Skip to content

Extract rendered text from SVG (and drawio) so diagrams can be used as references #104

Closed

Description

@cmungall

Summary

Allow a local file: reference to point at an .svg (later .drawio) and validate supporting_text against the text the diagram actually renders.

Current behaviour

FileSource reads files as raw text, and acquire.py has no format entry for SVG or drawio. Quotes are therefore matched against raw XML after normalization, which:

  • fails on labels split across <tspan> elements (a quote that crosses a line break becomes foo tspan x 10 y 24 bar);
  • matches against attribute and style noise (fill, id, transform);
  • for drawio, often sees nothing at all, because the <diagram> payload is deflated and base64-encoded.

Proposal

Add an SVGExtractor to etl/extract/, following the HTML, PDF and XML extractors:

  1. Emit one line per <text> element, joining its <tspan> children. Also read HTML inside <foreignObject>, which is where drawio's SVG export puts labels.
  2. Use only text that is rendered. Skip <desc>, <title>, <metadata>, comments, <defs>/<symbol> content not referenced by <use>, elements with display:none or visibility:hidden, and elements with zero opacity. This is an integrity rule, not just a cleanup step: a quote must not be able to pass against text a reader of the figure cannot see.
  3. Optionally emit edge lines (<source label> <edge label> <target label>) when connectors follow a simple convention, e.g. data-source="node-id" and data-target="node-id", with the edge label as the connector's text. This makes simple relational claims ("PTEN inhibits AKT") matchable. Edge lines should only be emitted when the connector's path endpoints actually fall on the referenced nodes' bounding boxes. Otherwise the attributes become a hidden channel again.
  4. Route it: add .svg and image/svg+xml to the format maps in acquire.py, and have FileSource pass recognised formats through their extractor instead of returning raw text.

drawio support (decompress, walk mxCell, strip HTML from value, edges from source/target) can be part of this issue or a follow-up.

Out of scope

  • Text converted to outlines/paths (would need OCR).
  • Inferring edges from the geometry of arbitrary SVGs with no convention.
  • Producing the SVG; see Allow image snippet checking #63 for the raster → SVG pipeline this enables.

Tests (written first)

  • Multi-<tspan> label is quotable across the line break.
  • Text inside <desc>, <metadata>, hidden or zero-opacity elements is not quotable.
  • Attribute values (id, fill) are not quotable.
  • drawio-exported SVG with <foreignObject> labels is quotable.
  • With the edge convention: a valid connector yields a quotable edge line; a connector whose endpoints don't touch the named nodes yields none.

Activity

  1. github-actions commented on Oct 11, 2026

    @github-actions
    Contributor

    Summary and codebase notes

    Ask: add an SVGExtractor (drawio later) so a file: reference to a diagram is matched against the text it renders, not against raw XML. Only rendered text counts, so a quote can't pass against hidden text. Edge lines (A <label> B) are optional and are emitted only when the connector geometry confirms the data-source/data-target attributes.

    Related:

    What I found in the code (checked against main):

    • etl/extract/ has base.py (Extractor ABC with formats() and extract(data, content_type=...), plus ExtractorRegistry), html.py, xml.py and pdf.py. Extractors register via the @ExtractorRegistry.register decorator and are imported in extract/__init__.py. An SVG extractor is a small addition.
    • etl/acquire.py has _CONTENT_TYPE_FORMATS and _SUFFIX_FORMATS. .svg and image/svg+xml would map to "svg" there. sniff_format checks magic bytes, so <svg and <?xml … <svg need handling. An XML-declared SVG would otherwise sniff as xml and go to the JATS extractor. .drawio and .drawio.xml also begin with <mxfile.
    • FileSource.fetch in etl/sources/file.py currently does read_text and returns raw content. Its docstring says "content is read as-is without parsing". Routing known formats through extractors changes that for .html files too. Decide whether to apply the new routing to SVG only or to every recognised format. Applying it to all formats would change existing behaviour for .html references.
    • etl/reference_fetcher.py has per-format cache stamps (EXTRACTOR_CACHE_VERSION, XML_EXTRACTION_CACHE_VERSION, and so on). A new extractor starts with no cached entries, so it needs no bump. Any later change to its output that makes cached text wrong should get its own stamp, following the convention documented in extract/__init__.py.

    Design suggestions

    1. Parser. Use a secure XML parser. defusedxml or lxml with resolve_entities=False and no_network=True avoids entity-expansion (billion laughs) and XXE on untrusted SVGs. Don't use BeautifulSoup's html.parser for SVG, which lowercases foreignObject and mishandles namespaces. xml.py already uses bs4, so check whether its lxml-xml mode is enough.
    2. Visibility rules. Exact text per issue point 2 is a reasonable spec. Cases to pin down in tests:
      • Visibility inherits through ancestors (<g display="none">), and the style= attribute, the presentation attribute and CSS <style> blocks can all set it. Reject or warn on <style> rules that target text, or implement a minimal class-selector pass. Silently ignoring CSS is a hole in the integrity rule.
      • opacity, fill-opacity and fill="none" or fill="transparent" on text. Also consider text coloured the same as an opaque background, or placed outside the viewBox or clipped by clip-path. Full rendering fidelity is out of scope. I'd suggest handling the cheap, explicit cases and documenting the rest as known limits. If "a quote can't pass against invisible text" is the stated guarantee, say which cases are not covered.
      • <switch>/systemLanguage alternatives, <tspan display="none">, and <a> wrappers.
      • <use> references: resolve href and xlink:href, and guard against reference cycles.
      • Zero or tiny font-size.
    3. Line structure. One line per <text> works. For a <tspan> with x/y or dy it is a visual line break, so join with a space and let the existing normalizer collapse whitespace. For foreignObject HTML, reuse HTMLExtractor logic for the fragment.
    4. Edge convention. The geometry check is the hardest part. Use the endpoints of the path (the first and last M/L points), apply transform where present, and test against each node's bounding box with a small tolerance. For <path> curves, only the endpoints are needed. Phasing it as a follow-up PR would keep the first PR small. Phase 1 is the text extractor plus routing. Phase 2 is edges. Phase 3 is drawio.
    5. drawio. The <diagram> payload is base64 → raw deflate → URL-encoded XML. That is straightforward with zlib.decompress(data, -15). Newer exports may be uncompressed. Strip HTML from value when html=1, and skip cells with visible="0". Hidden layers are a drawio-specific version of the same integrity problem.
    6. Provenance. Per the Allow image snippet checking #63 comment, record source_figure, raster checksum and transcription method somewhere. That belongs to the cache-format discussion in The cache-file format has no owner: consumers must mirror the emitter, so every new frontmatter field is a downstream break #89 and is not needed for this PR. A cheap hook is putting the SVG's own sha256 in ReferenceContent.metadata so reports can say which file version passed.

    Tests. The five listed in the issue are good. I'd add: visibility inherited from an ancestor <g>, a <style>-driven hide, a <use> cycle, and an XXE payload that must not resolve.

    Status: it needs no design decision to start, and it is TDD-ready as written.

    If you want an implementation, here is a starting prompt:

    @dragon-ai-agent please implement phase 1 of #104: add SVGExtractor in etl/extract/svg.py (secure XML parser; one line per <text>, tspans joined, foreignObject HTML via HTMLExtractor; skip desc/title/metadata/comments/unreferenced defs, display:none, visibility:hidden, zero opacity, including inherited and style-attribute cases), register it, add .svg / image/svg+xml to the format maps in etl/acquire.py with sniffing for <svg, route FileSource through extractors for SVG, write tests first including the cases in the issue, and run just test. Leave edges and drawio for follow-ups.

    I haven't run any of this or verified how the existing extractors behave beyond reading their structure.

  2. self-assigned this
    on Oct 12, 2026
  3. caufieldjh commented on Oct 13, 2026

    @caufieldjh
    Contributor

    Everything proposed here is on main:

    • Rendered-text extraction (etl/extract/svg.py): one line per <text> with tspans joined, <foreignObject> HTML read, and only rendered text counted. Stylesheets are evaluated, and anything that can't be evaluated is reported unavailable instead of guessed at. Starts at fd0f40d.
    • Routing: .svg, image/svg+xml and leading-byte sniffing in acquire.py (50a20a1). FileSource passes figures through their extractor (ea9af1f). Other formats, .html included, are unchanged.
    • Edge lines, emitted only when the drawn geometry and arrowheads confirm them (cbaf614 onward, svg_geometry.py).
    • drawio, compressed or not, with hidden cells and layers skipped (29896b3 onward).

    The five tests listed in the issue are all in tests/test_svg_extractor.py and tests/test_svg_edges.py. The extra cases from the triage comment (inherited hiding, stylesheet hiding, <use> cycles, XXE, entity expansion) are covered too. Known limits, including text converted to outlines, are in docs/how-to/use-local-files-and-urls.md. #111 fixes the last two places in the docs that named SVG without drawio.

    Provenance (source figure, raster checksum) stays with #63 / #89.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions