Skip to content

feat(diagrams): add vector diagram fixtures and DOT ground truth - #1

Closed
MannXo wants to merge 1 commit into
xberg-io:mainfrom
MannXo:feat/diagram-fixtures
Closed

feat(diagrams): add vector diagram fixtures and DOT ground truth#1
MannXo wants to merge 1 commit into
xberg-io:mainfrom
MannXo:feat/diagram-fixtures

Conversation

@MannXo

@MannXo MannXo commented Aug 8, 2026

Copy link
Copy Markdown

Related

Fixtures for xberg-io/xberg#579 and its PR xberg-io/xberg#1410, where the review
asked for diverse diagram documents here plus ground truth to calibrate against.

Description

The four SVGs already in xml/ have no transform chain, no arrowheads, no double
borders and no curved connectors. A diagram recogniser can pass all four and
still be wrong about every file a user would actually bring, which is what
happened: running recovery over real dot -Tsvg output found four defects that
those four fixtures could not see. Every arrowhead was read as a node, and every
edge terminated on the arrowhead instead of the shape behind it. doublecircle
split into two concentric nodes. Edge labels on curved connectors were missed.
And the midpoint of a straight two-point connector was computed as its endpoint,
so straight edges could never carry a label at all.

Four fixtures are rendered from committed .dot sources with Graphviz 15.1.1,
which puts the correct answer in the corpus rather than letting a recogniser
assert its own output is right. A fifth is hand-written and puts every shape and
label under nested translate/scale groups with a viewBox that differs from
the viewport, so nothing sits at the coordinate it is written at.

fixture exercises
graphviz_flow.svg box/diamond/ellipse nodes, arrowheads, edge labels, a dashed edge, root translate with negative coordinates
graphviz_states.svg doublecircle, and a pair of antiparallel edges between adjacent nodes
graphviz_network.svg undirected -- edges, so no arrowhead anywhere; neato layout
graphviz_bidirectional.svg dir=both and dir=back
nested_transforms.svg nested transform groups plus a viewBox/viewport mismatch

Ground truth lands in ground_truth/dot/<stem>.dot, keyed by node label rather
than by generated id so it does not depend on any one recogniser's numbering, and
it records what each file draws rather than what any implementation currently
returns. It also covers xml/org_chart.svg and xml/flowchart.svg, which stay
where they are.

Two ground truth files are deliberately empty. data_dashboard is a bar chart
and simple_svg is a two-shape drawing; neither is a diagram, and recovering a
graph from either is a false positive worth having a fixture for.

diagrams/README.md carries the regeneration commands and the Graphviz version.

Not included

A vector PDF of the same graph (dot -Tpdf) would exercise the PDF path, but
*.pdf is excluded from git here and lives in the corpus bucket, so it has to go
through scripts/publish_corpus.py, which I cannot run. Say the word and I will
attach the file for you to publish.

Checklist

  • CI passing
  • Tests added where applicable

Node/edge recovery from vector diagrams (xberg-io/xberg#579) had nothing
to calibrate against. The four SVGs in `xml/` carry no transform chain,
no arrowheads, no double borders and no curved connectors, so a
recogniser can pass all four and still be wrong about every file a user
would bring.

Four fixtures are rendered from committed `.dot` sources with Graphviz,
which makes the correct answer part of the corpus rather than something
a recogniser gets to assert about itself. A fifth is hand-written and
puts every shape and label under a chain of `translate`/`scale` groups
with a viewBox that differs from the viewport.

Ground truth is keyed by node label rather than by generated id, so it
does not depend on any one recogniser's numbering, and it records what
each file draws rather than what any implementation currently returns.
`data_dashboard` and `simple_svg` get deliberately empty ground truth:
a bar chart and a two-shape drawing are not diagrams, and recovering a
graph from either is a false positive worth testing for.

A vector PDF of the same graph would exercise the PDF path, but `*.pdf`
is excluded from git here and lives in the corpus bucket, so it needs
`scripts/publish_corpus.py`.
MannXo added a commit to MannXo/xberg that referenced this pull request Aug 8, 2026
… labels

The review asked for integration tests over real files and ground truth
to calibrate against. `diagram_dot_ground_truth` extracts each fixture
through the public pipeline with `output_format="dot"` and scores node
and edge recall against the graph the file actually draws.

Scoring rather than golden files is deliberate. A golden file freezes
whatever the implementation returns and calls it correct; recall against
an independently written answer says how good it is, and a regression
shows up as a number moving. Ground truth is keyed by node label, so it
does not depend on how this or any other recogniser numbers its output.

Fixtures and ground truth are in xberg-io/test_documents#1. Both tests
self-skip while that is unmerged, matching the corpus convention.

Current scores, over five generated diagrams and the two hand-written
ones already in the corpus:

    graphviz_flow           nodes 4/4 (100%)  edges 4/4 (100%)
    graphviz_states         nodes 4/4 (100%)  edges 3/4  (75%)
    graphviz_network        nodes 5/5 (100%)  edges 4/4 (100%)
    graphviz_bidirectional  nodes 3/3 (100%)  edges 3/3 (100%)
    nested_transforms       nodes 4/4 (100%)  edges 3/3 (100%)
    org_chart               nodes 9/9 (100%)  edges 3/3 (100%)
    flowchart               nodes 4/4 (100%)  edges 3/3 (100%)

33 of 33 nodes with nothing invented, 23 of 24 edges. The floor is set
at the current numbers so the one shortfall stays visible rather than
being rounded away: two nodes joined by a pair of opposing connectors
put four arrowheads within a few units of each other and one of the pair
is lost.

Two fixes fell out of running against real files. The midpoint of a
connector was taken by indexing to the middle of its point list, but a
straight `<line>` flattens to exactly two points, so that index is its
end and no straight connector could ever carry a label; it is now
measured along the arc. And a double border kept the outer ring's
styling only, while Graphviz paints the inner disc, so the collapsed
node lost its fill.

Refs xberg-io#579
@Goldziher

Copy link
Copy Markdown
Member

Thanks for this — it's landed, reworked, in #2, and the fixtures and DOT ground truth are credited
to you in ATTRIBUTIONS.md. Closing this one so there's a single set to review.

What changed on the way in:

  • Filed under diagrams/svg/ with diagrams/manifest.json as the index, so the set carries its own
    provenance and the page/bbox slots #579 asks for.
  • graphviz_network's ground truth restated an undirected source as a digraph with ->. It's now
    graph/--. That immediately exposed that undirected graphs are unmeasurable end to end —
    parse_dot in feat(diagram): recover Graphviz DOT from vector SVG diagrams xberg#1410 only understands ->, so it now scores a vacuous 0/0 edges.
    Worth fixing there.
  • The bigger one: dot -Tsvg writes one <title> per node holding the node id and one per edge
    holding src->dst, so all four Graphviz fixtures contained the complete edge list as text — and
    xberg's SVG extractor already reads <title>. As they stood they could not measure geometry
    recovery at all. Each now ships twice, as emitted and with element titles stripped.

That last one turned out well for your implementation: it scores identically on both, 4/4, 4/4 and
3/3 on the stripped variants. The geometry inference is real, and there's now a fixture that proves
it instead of one that would have hidden it.

Your point about generating fixtures rather than hand-writing them was right, and it's the reason
the corpus is being extended the same way — details in xberg-io/xberg#1410.

@Goldziher Goldziher closed this Aug 9, 2026
Goldziher pushed a commit to xberg-io/xberg that referenced this pull request Aug 10, 2026
* feat(diagram): recover Graphviz DOT from vector SVG diagrams

A vector diagram already contains its node/edge structure, so it can be
read rather than inferred with a detection model. usvg resolves `use`,
styles, units and the transform chain, so a closed path is a node, an
open stroke is a connector, and a connector's endpoints name the shapes
they land on.

Closedness is the discriminator rather than fill: SVG's initial `fill`
is black, so usvg gives a bare `<line>` a fill exactly as it gives a
`<rect>` one.

Labels need a second pass over the source XML. usvg is built here
without its `text` feature, which wants a font database, and drops text
elements during conversion, so that pass reproduces the one thing it
lost, the transform chain down to each `<text>` anchor.

Output goes through a `dot` renderer reached via
`OutputFormat::Custom("dot")`, the same route `doctags` takes, so no
generated binding crate changes. The matching rules live in
`extraction/diagram/mod.rs` rather than in the SVG file so that vector
PDF and DrawingML connectors can feed the same step later.

A drawing with no connectors is reported as no graph at all. Bar charts
and illustrations produce closed outlines too, and an edgeless node list
would be noise presented as structure.

Refs #579

* feat(diagram): read arrowheads, double borders and curved edge labels

Recovery was calibrated against hand-written SVG. Running it over real
renderer output (Graphviz `dot -Tsvg`) found three things it got wrong,
each of which changed the recovered graph rather than merely its
styling.

Arrowheads. A renderer draws an arrow as a small filled closed shape,
which arrives here indistinguishable from a box. Every one of them
became a node, and every edge terminated on the arrowhead instead of
the shape behind it. They are now identified by carrying no label,
sitting on a connector endpoint, and being small both next to the
largest shape and next to the canvas; all four conditions are required,
because a real node can satisfy any three. The arrowhead also supplies
the edge's direction, which previously came only from the path's point
order: one head reverses the edge when it sits at the start, and heads
at both ends emit `dir=both`.

Double borders. Graphviz `doublecircle`, and the double-ruled shapes
BPMN and ER diagrams use, draw one node as two concentric outlines. Both
became nodes, and connectors landed on whichever they reached first. An
outline enclosed by another and covering at least half its area is now
collapsed into it. The area test is what separates this from a panel
enclosing its boxes, which is far larger than what it contains.

Edge labels on curves. The label was matched against the average of the
connector's endpoints, which on a curved edge is nowhere near the line.
Connectors now carry their own midpoint, taken from the flattened path.

Also reads the diagram name from the outermost group's `<title>`, which
is where Graphviz and Mermaid put it, rather than only from a
document-level one.

Refs #579

* test(diagram): score recovery against ground truth, fix straight-edge labels

The review asked for integration tests over real files and ground truth
to calibrate against. `diagram_dot_ground_truth` extracts each fixture
through the public pipeline with `output_format="dot"` and scores node
and edge recall against the graph the file actually draws.

Scoring rather than golden files is deliberate. A golden file freezes
whatever the implementation returns and calls it correct; recall against
an independently written answer says how good it is, and a regression
shows up as a number moving. Ground truth is keyed by node label, so it
does not depend on how this or any other recogniser numbers its output.

Fixtures and ground truth are in xberg-io/test_documents#1. Both tests
self-skip while that is unmerged, matching the corpus convention.

Current scores, over five generated diagrams and the two hand-written
ones already in the corpus:

    graphviz_flow           nodes 4/4 (100%)  edges 4/4 (100%)
    graphviz_states         nodes 4/4 (100%)  edges 3/4  (75%)
    graphviz_network        nodes 5/5 (100%)  edges 4/4 (100%)
    graphviz_bidirectional  nodes 3/3 (100%)  edges 3/3 (100%)
    nested_transforms       nodes 4/4 (100%)  edges 3/3 (100%)
    org_chart               nodes 9/9 (100%)  edges 3/3 (100%)
    flowchart               nodes 4/4 (100%)  edges 3/3 (100%)

33 of 33 nodes with nothing invented, 23 of 24 edges. The floor is set
at the current numbers so the one shortfall stays visible rather than
being rounded away: two nodes joined by a pair of opposing connectors
put four arrowheads within a few units of each other and one of the pair
is lost.

Two fixes fell out of running against real files. The midpoint of a
connector was taken by indexing to the middle of its point list, but a
straight `<line>` flattens to exactly two points, so that index is its
end and no straight connector could ever carry a label; it is now
measured along the arc. And a double border kept the outer ring's
styling only, while Graphviz paints the inner disc, so the collapsed
node lost its fill.

Refs #579

* refactor(diagram): split the SVG recogniser into geometry and text passes

`svg.rs` had grown to 849 lines covering two jobs that share nothing but
a canvas size: reading shapes out of the converted `usvg` tree, and
reading labels out of the source XML because `usvg` dropped them.

    svg/mod.rs        recover(), and the end-to-end tests
    svg/geometry.rs   paths to outlines and connectors, shape naming
    svg/text.rs       the transform chain down to each `<text>` anchor

No behaviour change. Same 40 unit tests, same corpus scores.

Refs #579

* refactor(diagram): lift the point math out of the SVG front end

Curve sampling, shape classification and the arc-length midpoint are not
SVG-specific: they operate on a run of points and answer questions any
vector source has to ask. Only the walk over the source differs.

Moving them into a `polyline` module leaves `svg/geometry` with just
the usvg walk, and gives a second front end somewhere to arrive.

No behaviour change.

* fix(diagram): let connectors reach across decoration-sized arrowheads

Arrowheads were identified only among shapes that had already survived
the minimum-node-size filter, so an arrowhead small enough to be
decoration, which is what an arrowhead is, was never found. Without it a
connector cannot see past the gap the head leaves and the edge is lost.

Shapes below the node threshold are now kept aside and consulted for
reach while still never becoming nodes.

This closes the antiparallel-edge case the corpus had pinned at 0.75:
graphviz_states goes to 4/4 edges and every fixture is now at full
recall, so the floor moves to 1.0.

* feat(diagram): recover diagrams from vector PDF

Wires the second front end onto the matching rules the SVG one already
uses. Nothing about node/edge resolution is duplicated: `pdf` produces
the same outline, connector and label intermediates and hands them to
`assemble`.

Three things are genuinely different from SVG and each is handled:

- A PDF path object is a set of subpaths under one paint operator, so
  one `PathContent` routinely holds a node, an arrowhead and a
  connector. Each subpath is measured on its own; treating them as one
  run of points produces a shape nobody drew.
- Painting operators close paths implicitly, so a filled arrowhead
  never emits `h`. Fill therefore implies closed here, the opposite of
  SVG where every path inherits a black fill and fill means nothing.
  Whether a path was filled is read from the absence of a stroke
  colour, since a path painted in the default colour reports no colour
  at all.
- User space is bottom-up, so every coordinate is flipped against the
  media box on the way in and the rest of the pipeline stays in one
  space.

Gating moves from `all(svg, xml)` to `any(all(svg, xml), pdf)`; each
front end keeps its own requirement and either alone is enough.

The page loop stops at the path pass unless a page yields at least two
outlines and one connector, so a document of prose pays for one content
stream parse per page and no second text extraction.

The test builds its diagram with lopdf rather than reading a fixture,
so it needs nothing from the corpus and the input is readable as
operators.

* feat(diagram): recover containers, self-loops and HTML labels; reject charts

The corpus grew from 7 fixtures to 26, covering Mermaid, PlantUML,
LibreOffice, clusters, records, self-loops, CJK, a 128-node graph and
four negative controls. Scoring is now driven by `diagrams/manifest.json`
rather than a list kept in the test, so a fixture added upstream is
measured here without touching this repo.

Six things the wider corpus found:

- Cluster and swimlane boxes were being reported as nodes, and they also
  stole the edges: a connector between two boxes inside a cluster reaches
  the cluster border first. A shape enclosing two or more others is a
  container. One enclosed shape is a double border, which was already
  handled, so the threshold is two.
- Self-loops were dropped outright. A connector returning to the shape it
  left is a real edge when it arcs clear of the node; one lying entirely
  inside a shape is interior detail and still joins nothing.
- Mermaid recovered nothing at all: it draws no <text>, laying labels out
  as XHTML inside <foreignObject>, which carries a box rather than an
  anchor. Its centre is the anchor.
- A ruled table on a page with a diagram is one closed rectangle full of
  text, which is a node in every respect except that its text is laid out
  in rows *and* columns. Each alone is something a node does: a wrapped
  caption is many rows in one column, a Graphviz record is one row in
  many columns. Only a table is both.
- A pie chart was recovering as a graph, legend boxes wired to wedges by
  leader lines. The wedges all meet at the centre, so their boxes
  overlap, and a layout engine keeps real nodes apart.
- PlantUML terminal dots were reported as unlabelled nodes. An anonymous
  shape much smaller than the labelled ones is decoration.

Both geometric tests only ever condemn a shape nobody labelled, so a
named node stays a node whatever its neighbours do, and two labelled
nodes placed close enough to overlap are still two nodes.

24 of 26 fixtures now score 1.0 on nodes and edges, and every geometry
stripped variant scores exactly what its original does, which is what
says recovery is reading coordinates rather than producer metadata.

The two that do not are pinned at what they score today so the gap stays
visible and a regression still fails: `graphviz_large` loses 4 of 141
long-range edges, and `icon_nodes` needs glyph subpath merging and
captions read from below a shape.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants