feat(diagrams): add vector diagram fixtures and DOT ground truth - #1
Closed
MannXo wants to merge 1 commit into
Closed
feat(diagrams): add vector diagram fixtures and DOT ground truth#1MannXo wants to merge 1 commit into
MannXo wants to merge 1 commit into
Conversation
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
2 tasks
2 tasks
Member
|
Thanks for this — it's landed, reworked, in #2, and the fixtures and DOT ground truth are credited What changed on the way in:
That last one turned out well for your implementation: it scores identically on both, 4/4, 4/4 and Your point about generating fixtures rather than hand-writing them was right, and it's the reason |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 doubleborders 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 -Tsvgoutput found four defects thatthose 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.
doublecirclesplit 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
.dotsources 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/scalegroups with a viewBox that differs fromthe viewport, so nothing sits at the coordinate it is written at.
graphviz_flow.svgtranslatewith negative coordinatesgraphviz_states.svgdoublecircle, and a pair of antiparallel edges between adjacent nodesgraphviz_network.svg--edges, so no arrowhead anywhere;neatolayoutgraphviz_bidirectional.svgdir=bothanddir=backnested_transforms.svgGround truth lands in
ground_truth/dot/<stem>.dot, keyed by node label ratherthan 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.svgandxml/flowchart.svg, which staywhere they are.
Two ground truth files are deliberately empty.
data_dashboardis a bar chartand
simple_svgis a two-shape drawing; neither is a diagram, and recovering agraph from either is a false positive worth having a fixture for.
diagrams/README.mdcarries the regeneration commands and the Graphviz version.Not included
A vector PDF of the same graph (
dot -Tpdf) would exercise the PDF path, but*.pdfis excluded from git here and lives in the corpus bucket, so it has to gothrough
scripts/publish_corpus.py, which I cannot run. Say the word and I willattach the file for you to publish.
Checklist