diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md
index dd0744f..9ad81bb 100644
--- a/ATTRIBUTIONS.md
+++ b/ATTRIBUTIONS.md
@@ -133,3 +133,11 @@ Vendored WordPerfect-family test files. Per-file provenance (upstream path, revi
- **License:** CDLA-Permissive-1.0
- **Used here:** 2 financial-report pages (`images/doclaynet_page_01..02.jpg`) from the `test` split (`NASDAQ_ATRI_2003.pdf` p24, `NYSE_MGM_2004.pdf` p49).
- **Modifications:** authoritative PDF text cells linearized in reading order (top-to-bottom, then left-to-right). No OCR and no model used; text is DocLayNet's verbatim PDF-layer cell text.
+
+## Diagram fixtures (this repository)
+
+- **Source:** authored here, not derived from any third-party dataset. The first four Graphviz fixtures were contributed by Parman Mohammadalizadeh (@MannXo) in xberg-io/test_documents#1.
+- **License:** same as this repository.
+- **Used here:** `diagrams/svg/*.svg` plus their `diagrams/src/*.{dot,mmd,puml,fodg}` sources, `diagrams/manifest.json`, and `ground_truth/dot/*.dot`.
+- **Rendering tools:** the source graphs are ours; the SVGs are what each renderer emitted from them. Graphviz 15.1.1 (`dot`/`neato`) is EPL-1.0, Mermaid CLI 11.16.0 is MIT, PlantUML 1.2026.0 is GPL, LibreOffice 26.2.5.2 is MPL-2.0. None of these licenses reaches the output: rendering a document is not a derivative work of the renderer, and PlantUML says so explicitly in its FAQ. No tool binary, jar, font or stylesheet is redistributed here.
+- **Modifications:** every fixture whose producer states its own graph also ships as a `*_geometry.svg` variant with that metadata removed by `scripts/strip_svg_graph_metadata.py` — Graphviz writes the node ids and the full edge list into `
` elements and again into XML comments, Mermaid into `id="L_start_auth_0"`, PlantUML into `id="Read config-to-Open input"`, and a fixture that names its own answer cannot measure geometry recovery. Stripping removes no geometry: each variant renders to a byte-identical PNG. `nested_transforms.svg`, `icon_nodes.svg`, `mixed_page.svg` and the three `negative_*.svg` files are hand-written. Ground truth is the source graph restated by node label, so it is independent of any recogniser's numbering.
diff --git a/README.md b/README.md
index 64cda62..24c1d8a 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,31 @@
-# xberg PDF→Markdown benchmark corpus
+# xberg test corpus
-Ground truth for the xberg PDF→Markdown benchmark. **Every document here is reproduced from a pinned
-upstream source and gated against an independent text oracle** — see `ground_truth/corpus_manifest.json`
-for per-document provenance and verdicts. Do not hand-edit GT; change the builder and re-run.
+Test documents and ground truth for [xberg](https://github.com/xberg-io/xberg): about 1,400
+documents spanning 105 file extensions, and 1,465 ground-truth files saying what extracting them
+should produce. 638 of the documents are binaries served from a bucket rather than committed here.
+
+Consumers use it as a git submodule (`xberg/test_documents`) and as the corpus behind the
+PDF→Markdown benchmark harness. Two things live here and they are governed differently:
+
+- **Documents.** The inputs. Text formats are committed to git; binaries are not (see below).
+- **Ground truth.** What correct extraction looks like. Always committed, always text.
## Getting the documents
-**The binary documents are not in this repository.** Git tracks only text — ground truth, manifests
-and scripts. The binaries live in the public bucket `gs://xberg-test-documents`, content-addressed by
-sha256 and pinned by `corpus.lock.json`, which maps each path to the object that belongs there.
+**The binary documents are not in this repository, and they are not in Git LFS either.** Git
+tracks only text — ground truth, manifests, scripts, and the text-format fixtures. The binaries live
+in the public Google Cloud Storage bucket `gs://xberg-test-documents`, content-addressed by sha256
+and pinned by `corpus.lock.json`, which maps each repository path to the object that belongs there.
+
+The bucket is world-readable, so fetching needs no credentials, no `gcloud`, and no SDK:
+
+```sh
+python3 scripts/fetch_corpus.py # everything, ~580 MiB
+python3 scripts/fetch_corpus.py --include 'pdf/**' # just the PDFs
+```
+
+Files already present with the right hash are skipped, so re-running is cheap. Several consumers
+reference fixtures through `include_bytes!`, so the bytes must be on disk before `cargo build` runs.
In CI, use the shared action, which fetches only what a job needs and caches on the manifest:
@@ -19,98 +36,167 @@ In CI, use the shared action, which fetches only what a job needs and caches on
pdf/fake_memo.pdf
```
-Locally, materialise the files into their paths with the same manifest and the same anonymous
-HTTPS. Several consumers reference fixtures through `include_bytes!`, so the bytes must be on disk
-before `cargo build` runs:
+Anything else can read `corpus.lock.json` and fetch over plain HTTPS directly:
```text
-python3 scripts/fetch_corpus.py # everything, ~578 MiB
-python3 scripts/fetch_corpus.py --include 'pdf/**' # just the PDFs
+https://storage.googleapis.com/xberg-test-documents/objects/
```
-Files already present with the right hash are skipped, so re-running is cheap. Anything else can
-read `corpus.lock.json` and fetch over plain HTTPS directly — the bucket is world-readable, so no
-credentials or SDK are involved:
+### Why a bucket and not Git LFS
-```text
-https://storage.googleapis.com/xberg-test-documents/objects/
-```
+The corpus moved off Git LFS. LFS bills bandwidth per clone and makes every consumer authenticate
+against it, including CI jobs that need three files out of six hundred. Content-addressed objects in
+a public bucket are anonymous to read, cache trivially, and let a job fetch exactly the paths it
+uses. `scripts/corpus-patterns.txt` was lifted verbatim from the old `.gitattributes` filter list so
+the publish set did not change in the move.
-`python3 scripts/verify_corpus.py --bucket xberg-test-documents` checks that every pinned object is
-still served with the pinned size; add `--sample N` to download some in full and verify their hashes.
+## What is in git and what is in the bucket
-### Adding or changing a document
+`scripts/corpus-patterns.txt` is the authority. It uses gitignore semantics: a pattern with no `/`
+matches a basename at any depth. Every line in it is mirrored in `.gitignore`, and
+`scripts/test_publish_corpus.py` fails if the two drift apart.
-Put the file in its directory (`.gitignore` keeps it out of git), then publish it and commit the
-refreshed pin:
+**Bucket-managed** (never committed): `.pdf .doc .docx .odt .rtf .msg .pst .xls .xlsx .xlsm .xlsb
+.xlam .xla .ods .ppt .pptx .pptm .ppsx .odp .key .epub .fb2 .pages .numbers .hwp .hwpx .png .jpg
+.jpeg .gif .bmp .webp .tiff .tif .heif .heic .avif .jp2 .jpx .jpm .j2k .j2c .mj2 .ppm .pnm .pgm
+.pbm .wav .mp3 .zip .tar .tgz .gz .7z .dbf`, plus `ground_truth/structured/parsebench/*.jsonl`.
-```text
-python3 scripts/publish_corpus.py --bucket xberg-test-documents
-git add corpus.lock.json && git commit
-```
+**Git-tracked**: everything else — all ground truth, and the text-format fixtures
+(`.svg .xml .html .md .rst .org .tex .typ .json .yaml .csv .tsv .eml .opml .ipynb .wp .wpd .wpg
+.dot .mmd .puml .fodg` and friends).
-This needs write access to the bucket, so CI cannot do it — a CI checkout contains no binaries to
-publish. Outside contributors should open a pull request describing the fixture; a maintainer
-publishes it. `scripts/corpus-patterns.txt` declares which files count as corpus binaries.
+The split is by *format*, not by size: a fixture whose bytes a human can read in a diff belongs in
+git, because that is where review happens.
-## Reproduce
+## Adding or changing a document
-The corpus builder lives in the **xberg** repository, not here:
+1. Put the file in its directory. If it is a bucket-managed extension, `.gitignore` keeps it out of
+ git automatically.
+2. Add ground truth under `ground_truth//.{txt,md}` — these **are** committed.
+3. Register it with the benchmark harness if it should be scored: a descriptor JSON in
+ `xberg/tools/benchmark-harness/fixtures/` (see below).
+4. Record provenance. Anything from a third party needs an entry in `ATTRIBUTIONS.md` and, for a
+ multi-file set, a `PROVENANCE.md` with per-file sha256 (`wordperfect/PROVENANCE.md` is the
+ template).
+5. **If the file is bucket-managed**, a maintainer publishes it and commits the refreshed pin:
-```text
-python tools/benchmark-harness/scripts/build_corpus.py --stage all
-```
+ ```sh
+ python3 scripts/publish_corpus.py --bucket xberg-test-documents --dry-run # check first
+ python3 scripts/publish_corpus.py --bucket xberg-test-documents
+ git add corpus.lock.json && git commit
+ ```
+
+ This needs write access to the bucket, so **CI cannot do it** — a CI checkout contains no
+ binaries to publish. Outside contributors should open a pull request describing the fixture and a
+ maintainer publishes it.
+6. **Publish before you push the refreshed lock file.** CI verifies that every pinned object
+ resolves from the bucket; a lock file that names an object nobody uploaded fails the build.
+7. If the fixture uses an extension not yet in `scripts/corpus-patterns.txt` and it should be
+ bucket-managed, add the pattern to **both** that file and `.gitignore`, with identical text.
+
+Never `git add` a corpus binary. `publish_corpus.py` refuses to run if one was committed.
-This is the ONLY sanctioned way to modify the corpus. It acquires the pinned sources, normalizes GT to
-canonical GFM, gates each doc, and writes `pdf/`, `ground_truth/pdf/.{md,txt}`, the fixtures, and
-this file. Re-running with the same pins is deterministic.
+## Layout
+
+| directory | what |
+|---|---|
+| `pdf/`, `pdf_scanned/`, `charts/` | 247 PDFs: born-digital, scanned, and chart-heavy |
+| `docx/`, `doc/`, `odt/`, `rtf/`, `wordperfect/`, `hwp/`, `hwpx/` | word-processor formats, current and legacy |
+| `pptx/`, `ppt/`, `odp/` | presentations |
+| `xlsx/`, `xls/`, `data_formats/`, `csv/`, `dbf/` | spreadsheets and tabular data |
+| `images/`, `images_extra/` | raster fixtures; `images_extra/` is one file per exotic codec |
+| `xml/`, `html/`, `markdown/`, `markup/`, `rst/`, `org/`, `latex/`, `typst/`, `docbook/`, `jats/` | markup and text formats |
+| `diagrams/` | vector diagram fixtures for node/edge recovery — see `diagrams/README.md` |
+| `email/`, `epub/`, `fictionbook/`, `iwork/`, `jupyter/`, `opml/`, `archives/`, `audio/` | everything else |
+| `vendored/` | third-party corpora kept verbatim with their own provenance |
+| `ground_truth/` | expected output, one subdirectory per source extension |
+| `scripts/` | the corpus tooling: fetch, publish, verify |
+
+## Ground truth
+
+`ground_truth//.txt` is plaintext ground truth, scored as text-F1.
+`ground_truth//.md` is canonical-GFM ground truth, scored structurally. Both may exist;
+for pure-OCR fixtures they are byte-identical by design.
+
+Two kinds are not plain text:
+
+- `ground_truth/dot/.dot` — the node/edge graph a diagram fixture draws, as Graphviz DOT keyed
+ by node label. Indexed by `diagrams/manifest.json`, which also carries provenance, the recovery
+ class, and the page/bbox of each graph within its document. An empty file means the fixture is a
+ negative control, and the manifest says why. See `diagrams/README.md`.
+- `ground_truth/structured/` — field- and formula-level extraction targets, with their own manifest.
+
+Two files index the rest:
+
+- `ground_truth/corpus_manifest.json` — the immutable benchmark manifest: per-document hashes,
+ source, license, revision, normalization transforms, oracle verdict and scores, cohorts, size
+ tier, and tune/eval role, under one frozen top-level hash. **Generated — do not hand-edit.**
+- `ground_truth/ground_truth_mapping.json` — a flat `stem → path` index.
+
+The authoritative binding between a document and its ground truth is neither of those: it is the
+per-document descriptor in `xberg/tools/benchmark-harness/fixtures/*.json`, which names the
+document, its type, its expected frameworks, and its ground-truth files.
+
+## Licensing
-## Sources
+`test_documents` is part of xberg, which is MIT-licensed, public, non-commercial open source. The
+MIT `LICENSE` covers **our own work** — the tooling, the manifests, the prose. It does not
+relicense third-party content: every source document and its upstream ground truth keeps its own
+license. See `LICENSES.md` and `ATTRIBUTIONS.md`.
-| dataset | license | GT provenance | role |
-|---|---|---|---|
-| [lazyc/READoc](https://huggingface.co/datasets/lazyc/READoc) @`HEAD` | MIT | arXiv GT = author LaTeX→pandoc (no tables); GitHub GT = author README rendered to PDF | document |
-| [llamaindex/ParseBench](https://huggingface.co/datasets/llamaindex/ParseBench) @`HEAD` | Apache-2.0 | only table.jsonl ships expected_markdown (HTML tables); human-verified | page |
-| [bsmock/FinTabNet.c](https://huggingface.co/datasets/bsmock/FinTabNet.c) @`HEAD` | CDLA-Permissive-2.0 | financial-statement table crops; GT = canonicalized cell structure rendered to GFM | page |
-| [federalregister.gov](https://www.federalregister.gov) @`2026-07-` | US-PD (17 U.S.C. §105) | OFR/GPO full-text XML → GFM (headings, label blocks, GPOTABLE pipe tables) | document |
+Datasets are handled in two classes:
-Excluded on purpose: **OmniDocBench** (research-only / non-commercial — incompatible with this MIT repo)
-and **Nougat** (weights CC-BY-NC; corpus not distributed).
+- **vendor** — permissively licensed (MIT / Apache-2.0 / BSD / CC-BY / CC0 / CDLA-Permissive /
+ MPL-2.0 / US public domain). Committed here, with attribution.
+- **reference** — non-commercial, ShareAlike, or research-only. **Never redistributed here.**
+ Fetched to local staging on demand and used only for non-commercial benchmarking. Their manifest
+ entries carry the source URL and license for provenance.
-## How the data was modified
+This keeps the public repo free of content it cannot redistribute while still letting the
+benchmark use data that cannot be shipped.
-Upstream GT is not committed verbatim — it is normalized to canonical GFM so it can be scored
-consistently. The transforms are declared once in `scripts/normalize_gt.py` and applied by
-`build_corpus.py`; this section and the per-doc `transforms` field in the manifest are generated from
-the build ledger, so they always match what actually ran.
+## Reproducing the benchmark corpus
-| transform | applies to | what it changes |
+The builder for the PDF→Markdown benchmark slice lives in the **xberg** repository, not here:
+
+```sh
+python tools/benchmark-harness/scripts/build_corpus.py --stage all
+```
+
+It acquires the pinned sources, normalizes ground truth to canonical GFM, gates each document
+against an independent text oracle, and writes `pdf/`, `ground_truth/pdf/.{md,txt}` and the
+manifest. Re-running with the same pins is deterministic. This is the only sanctioned way to modify
+that slice; hand-added fixtures elsewhere in the corpus are not covered by it.
+
+| dataset | license | ground-truth provenance |
|---|---|---|
-| `math_display` | ReaDoc arXiv | display math \[…\] → $$…$$ |
-| `math_inline` | ReaDoc arXiv | inline math \(…\) → $…$ |
-| `double_bold` | ReaDoc arXiv | merge pandoc doubled bold **** (bold-close+bold-open) |
-| `trailing_ws` | all sources | strip trailing whitespace |
-| `blank_runs` | all sources | collapse >2 blank lines to one |
+| [lazyc/READoc](https://huggingface.co/datasets/lazyc/READoc) | MIT | arXiv GT = author LaTeX→pandoc (no tables); GitHub GT = author README rendered to PDF |
+| [llamaindex/ParseBench](https://huggingface.co/datasets/llamaindex/ParseBench) | Apache-2.0 | only `table.jsonl` ships expected markdown (HTML tables); human-verified |
+| [bsmock/FinTabNet.c](https://huggingface.co/datasets/bsmock/FinTabNet.c) | CDLA-Permissive-2.0 | financial-statement table crops; GT = canonicalized cell structure rendered to GFM |
+| [federalregister.gov](https://www.federalregister.gov) | US-PD (17 U.S.C. §105) | OFR/GPO full-text XML → GFM |
-Applied this build:
+Excluded on purpose: **OmniDocBench** (research-only) and **Nougat** (weights CC-BY-NC, corpus not
+distributed).
-- **2577** documents had at least one normalization applied.
-- `math_inline`: 231969 substitutions across the corpus.
-- `math_display`: 26997 substitutions across the corpus.
-- `trailing_ws`: 26990 substitutions across the corpus.
-- `double_bold`: 3490 substitutions across the corpus.
-- `html_to_gfm`: 503 substitutions across the corpus.
+Upstream ground truth is not committed verbatim — it is normalized to canonical GFM so it can be
+scored consistently. The transforms are declared once in `scripts/normalize_gt.py` and applied by
+the builder; the per-document `transforms` field in the manifest records what actually ran.
-## Layout
+## Verifying
+
+```sh
+python3 scripts/verify_corpus.py --bucket xberg-test-documents # every pin resolves
+python3 scripts/verify_corpus.py --bucket xberg-test-documents --sample 20 # download and re-hash
+python3 -m unittest discover -s scripts -v # tooling tests
+```
-- `pdf/.pdf` — source document.
-- `ground_truth/pdf/.md` — normalized canonical-GFM GT (scored by the harness).
-- `ground_truth/pdf/.txt` — plaintext GT (text-F1).
-- `ground_truth/corpus_manifest.json` — immutable manifest: per-doc hashes, source+license+revision,
- transforms, oracle verdict+scores, cohorts, size tier, tune/eval role; one frozen top-level hash.
+CI (`.github/workflows/verify-corpus.yaml`) runs exactly these on every push and pull request. It
+needs no credentials: it proves the manifest is still fetchable rather than trying to publish.
-## Manifest, cohorts, tiers, roles
+The unittest suite also covers the corpus itself where the corpus can contradict its own answer
+key — `scripts/test_diagram_manifest.py` checks `diagrams/manifest.json` against the files and the
+ground truth on disk. One further check needs a renderer and so is not part of CI:
-See the plan and `corpus_manifest.json`. Cohorts tag execution mode (native-clean / native-corrupt-font
-/ selective-OCR / forced-OCR) and diagnostic strata (tables, multicolumn, formulas, …). Size tiers
-`smoke ⊂ core ⊂ extended` and a `tune`/`eval` role per doc support fast iteration without overfitting.
+```sh
+python3 scripts/check_diagram_ground_truth.py # needs graphviz; skips cleanly without it
+```
diff --git a/diagrams/README.md b/diagrams/README.md
new file mode 100644
index 0000000..1e90801
--- /dev/null
+++ b/diagrams/README.md
@@ -0,0 +1,218 @@
+# Diagram fixtures
+
+Fixtures for node/edge recovery — xberg-io/xberg#579. `manifest.json` is the index: it says what
+each file is, where it came from, why it is in the set, and what the correct answer is.
+
+Ground truth lives in `ground_truth/dot/.dot`, written as a Graphviz graph keyed **by node
+label** rather than by generated id, so it does not depend on how any one recogniser numbers its
+output. It records what the file draws, not what any implementation currently returns.
+
+## Two classes of fixture
+
+`manifest.json` tags every fixture `class: "A"` or `class: "B"`, and the class decides how it
+should be scored.
+
+**Class A — the file states its graph.** ODF `draw:connector` carries `draw:start-shape` and
+`draw:end-shape`; PowerPoint `p:cxnSp` carries ``; SmartArt carries
+`dgm:ptLst`/`dgm:cxnLst`; `.drawio` edges carry `source=`/`target=`; a fenced ` ```mermaid ` block
+is the graph in plain text. Recovery is lossless, so a correct implementation scores **exactly
+1.0**. A miss here is a bug, not a threshold to tune.
+
+**Class B — geometry only.** SVG and vector PDF give you shapes, strokes and text positions, and
+the graph has to be inferred. Scoring is precision/recall against a threshold.
+
+One Class A fixture ships today: `src/libreoffice_connectors.fodg`. Everything else is Class B.
+
+## The metadata problem
+
+Every diagram tool writes the graph it just laid out back into its own output, each in its own
+way:
+
+```xml
+
+a->b
+
+
+
+
+
+
+
+
+```
+
+That is the complete edge list, verbatim, in the file — and xberg's SVG extractor already collects
+`` (`SVG_TEXT_ELEMENTS` in `extraction/xml.rs`). A recogniser that reads it scores 4/4 nodes
+and 4/4 edges on every Graphviz fixture without inspecting a single coordinate.
+
+Reading it is not cheating: when a file states its graph, using it is the correct and exact thing
+to do — that is what Class A *is*. It is simply a different capability from inferring a graph from
+geometry, and measuring the two together measures neither. So each affected fixture ships twice, as
+emitted and stripped:
+
+```sh
+python3 scripts/strip_svg_graph_metadata.py \
+ diagrams/svg/graphviz_flow.svg diagrams/svg/graphviz_flow_geometry.svg
+```
+
+The stripper works on two producer-agnostic rules rather than a list of the dialects seen so far:
+drop `` inside an element group, and drop every identifier the document itself never refers
+to. What stays is anything giving an element's *type* without naming its endpoints — `class="node"`,
+`class="com.sun.star.drawing.ConnectorShape"` — because knowing a stroke is some connector still
+leaves you the whole job of working out what it connects. XML comments go entirely; provenance
+belongs in `manifest.json`, which is where a reader should be looking for it.
+
+The `*_geometry.svg` variants share the ground truth of their originals: same answer, arrived at a
+harder way, and identical to the pixel — the stripped file renders to a byte-identical PNG.
+`manifest.json` marks which fixtures need one with `states_graph_in_metadata`.
+
+Two producers need no variant. LibreOffice numbers its shapes `id1`..`id9` and says only what kind
+of shape each one is; PlantUML's swimlane output carries no id, no class and no comment at all.
+Both are honest by construction.
+
+## What each fixture exercises
+
+Positives, by producer:
+
+| file | producer | n/e | exercises |
+|---|---|---|---|
+| `svg/graphviz_flow.svg` | Graphviz | 4/4 | box, diamond and ellipse nodes; arrowheads; edge labels; a dashed edge; root `translate` with negative coordinates |
+| `svg/graphviz_states.svg` | Graphviz | 4/4 | `doublecircle` — one node drawn as two concentric outlines; a pair of antiparallel edges |
+| `svg/graphviz_network.svg` | Graphviz | 5/4 | undirected `--` edges, so no arrowhead anywhere; `neato` layout |
+| `svg/graphviz_bidirectional.svg` | Graphviz | 3/3 | `dir=both` and `dir=back` — the arrowhead at the tail, so the edge reads the other way round |
+| `svg/graphviz_clusters.svg` | Graphviz | 5/4 | two cluster containers that are **not** nodes; an edge crossing a container boundary |
+| `svg/graphviz_selfloop.svg` | Graphviz | 4/5 | self-loops, one labelled; two edges that cross in mid-drawing and share no endpoint |
+| `svg/graphviz_ortho.svg` | Graphviz | 5/5 | orthogonal elbow routing, the default in every non-Graphviz tool |
+| `svg/graphviz_record.svg` | Graphviz | 3/2 | `shape=record`: one outline divided by internal rules, edges anchored to a named port |
+| `svg/graphviz_cjk.svg` | Graphviz | 5/4 | stroke-only nodes with no fill at all; Japanese, Korean, Hebrew and Arabic labels |
+| `svg/graphviz_large.svg` | Graphviz | 128/141 | scale, for the perf and memory profiling the review asked for |
+| `svg/mermaid_flow.svg` | Mermaid 11.16.0 | 6/6 | a 4.4 KB CSS block, ten `` defs, HTML labels in ``, and edge labels on an opaque background box that looks exactly like a small node |
+| `svg/plantuml_activity.svg` | PlantUML 1.2026.0 | 5/4 | rounded activity shapes; unlabelled `(*)` terminals |
+| `svg/plantuml_swimlane.svg` | PlantUML 1.2026.0 | 5/4 | three swimlane bands that are **not** nodes |
+| `svg/libreoffice_connectors.svg` | LibreOffice 26.2.5.2 | 4/3 | `draw:custom-shape` enhanced geometry; glued connectors that stop a few units short of the outline, so endpoints match only by proximity |
+| `src/libreoffice_connectors.fodg` | hand-authored | 4/3 | **Class A** — `draw:start-shape`/`draw:end-shape` name the endpoints outright |
+| `svg/nested_transforms.svg` | hand-authored | 4/3 | nested `translate`/`scale` groups plus a viewBox that differs from the viewport, so nothing sits at the coordinate it is written at |
+| `svg/icon_nodes.svg` | hand-authored | 4/3 | the AWS/Azure house style: a node is an icon glyph with its caption underneath and no outline at all |
+| `svg/mixed_page.svg` | hand-authored | 3/2 | a whole page — heading, prose, a ruled table, and one figure. Recovery has to be selective *within* the page |
+| `xml/org_chart.svg` | hand-authored | 9/3 | multi-line labels, six isolated nodes |
+| `xml/flowchart.svg` | hand-authored | 4/3 | `marker-end` arrowheads, annotations outside every shape |
+
+`src/` holds every source — `.dot`, `.mmd`, `.puml`, `.fodg` — so all of the above is regenerable.
+
+## Ground-truth conventions
+
+- **Keyed by node label.** A recogniser's own numbering never enters into it.
+- **Only labelled nodes.** An unlabelled decoration — PlantUML's start/stop markers, an arrowhead,
+ a pie chart's leader dot — has no key and is not a node, and an edge joining one is not an edge.
+- **Containers are not nodes.** Cluster rectangles, swimlane bands and lane headers are absent from
+ the ground truth however closed their outlines look.
+- **One record is one node.** Its key is the field texts as one multi-line label, the same way
+ `org_chart.dot` keys a two-line box, because splitting a record into one node per field is the
+ failure that fixture exists to catch.
+- **Direction as drawn.** `dir=back` puts the arrowhead at the tail, so the ground truth records
+ what the drawing shows, not the order the source declared.
+- **Undirected stays undirected** — `graph` and `--`, never restated as a `digraph`.
+- **Comments carry no DOT syntax.** Not every consumer strips `//`, so a comment mentioning
+ `a -> b` gets scored as an edge. `test_diagram_manifest.py` enforces this.
+
+## Negatives
+
+Six fixtures have `"negative": true`, an empty ground-truth file and a stated `reason` in the
+manifest. Recovering anything from them is a false positive, and that is worth a test of its own.
+The empty file plus the reason is what keeps "not a diagram" and "not yet annotated" from being the
+same bytes.
+
+- `svg/negative_ruled_table.svg` — **the most dangerous one.** A table drawn as ruling lines has the
+ same signature as a diagram: closed rectangular regions with text inside, joined by straight
+ strokes running from the edge of one region to the next. Whatever rejects it also has to leave
+ table detection working.
+- `svg/negative_pie_chart.svg` — each label sits in a rounded box joined to its slice by a
+ two-segment leader line ending in a dot, which is exactly the shape of a labelled node wired up
+ by an elbow connector.
+- `svg/negative_form.svg` — captions beside empty boxes, section rules, checkboxes, a signature
+ line. The rules run right up to the boxes, which is what an edge looks like.
+- `xml/data_dashboard.svg` — a bar chart. Closed outlines and straight strokes, but the strokes are
+ axes and gridlines.
+- `xml/simple_svg.svg` — two unconnected shapes and a label.
+- `images/5_level_paging_and_5_level_ept_intel_revision_1_1_may_2017.svg` — despite the filename,
+ an **inferno flame graph**: 77 nested ``s, 81 `` labels, a CSS `
yes
no
Receive request
Authenticated?
Check quota
Return 401
Run handler
Return 200
\ No newline at end of file
diff --git a/diagrams/svg/mermaid_flow_geometry.svg b/diagrams/svg/mermaid_flow_geometry.svg
new file mode 100644
index 0000000..35cc19e
--- /dev/null
+++ b/diagrams/svg/mermaid_flow_geometry.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/diagrams/svg/mixed_page.svg b/diagrams/svg/mixed_page.svg
new file mode 100644
index 0000000..e326397
--- /dev/null
+++ b/diagrams/svg/mixed_page.svg
@@ -0,0 +1,71 @@
+
+
+
diff --git a/diagrams/svg/negative_form.svg b/diagrams/svg/negative_form.svg
new file mode 100644
index 0000000..7f5e3d6
--- /dev/null
+++ b/diagrams/svg/negative_form.svg
@@ -0,0 +1,40 @@
+
+
+
diff --git a/diagrams/svg/negative_pie_chart.svg b/diagrams/svg/negative_pie_chart.svg
new file mode 100644
index 0000000..2377388
--- /dev/null
+++ b/diagrams/svg/negative_pie_chart.svg
@@ -0,0 +1,41 @@
+
+
+
diff --git a/diagrams/svg/negative_ruled_table.svg b/diagrams/svg/negative_ruled_table.svg
new file mode 100644
index 0000000..c3a6e87
--- /dev/null
+++ b/diagrams/svg/negative_ruled_table.svg
@@ -0,0 +1,50 @@
+
+
+
diff --git a/diagrams/svg/nested_transforms.svg b/diagrams/svg/nested_transforms.svg
new file mode 100644
index 0000000..31a01e3
--- /dev/null
+++ b/diagrams/svg/nested_transforms.svg
@@ -0,0 +1,43 @@
+
+
+
diff --git a/diagrams/svg/plantuml_activity.svg b/diagrams/svg/plantuml_activity.svg
new file mode 100644
index 0000000..d795753
--- /dev/null
+++ b/diagrams/svg/plantuml_activity.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/diagrams/svg/plantuml_activity_geometry.svg b/diagrams/svg/plantuml_activity_geometry.svg
new file mode 100644
index 0000000..b7667d2
--- /dev/null
+++ b/diagrams/svg/plantuml_activity_geometry.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/diagrams/svg/plantuml_swimlane.svg b/diagrams/svg/plantuml_swimlane.svg
new file mode 100644
index 0000000..7d295a0
--- /dev/null
+++ b/diagrams/svg/plantuml_swimlane.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ground_truth/dot/5_level_paging_and_5_level_ept_intel_revision_1_1_may_2017.dot b/ground_truth/dot/5_level_paging_and_5_level_ept_intel_revision_1_1_may_2017.dot
new file mode 100644
index 0000000..e69de29
diff --git a/ground_truth/dot/data_dashboard.dot b/ground_truth/dot/data_dashboard.dot
new file mode 100644
index 0000000..e69de29
diff --git a/ground_truth/dot/flowchart.dot b/ground_truth/dot/flowchart.dot
new file mode 100644
index 0000000..8a8f8b4
--- /dev/null
+++ b/ground_truth/dot/flowchart.dot
@@ -0,0 +1,9 @@
+digraph flowchart {
+ "Requirements" [shape=box fillcolor="#4a90d9"];
+ "Design" [shape=box fillcolor="#50b848"];
+ "Implementation" [shape=box fillcolor="#f5a623"];
+ "Testing" [shape=box fillcolor="#d0021b"];
+ "Requirements" -> "Design";
+ "Design" -> "Implementation";
+ "Implementation" -> "Testing";
+}
diff --git a/ground_truth/dot/graphviz_bidirectional.dot b/ground_truth/dot/graphviz_bidirectional.dot
new file mode 100644
index 0000000..cb95d4b
--- /dev/null
+++ b/ground_truth/dot/graphviz_bidirectional.dot
@@ -0,0 +1,8 @@
+digraph graphviz_bidirectional {
+ "API" [shape=box fillcolor="#fdb462"];
+ "Database" [shape=box fillcolor="#fdb462"];
+ "Cache" [shape=box fillcolor="#fdb462"];
+ "API" -> "Database" [dir=both];
+ "API" -> "Cache";
+ "Database" -> "Cache";
+}
diff --git a/ground_truth/dot/graphviz_cjk.dot b/ground_truth/dot/graphviz_cjk.dot
new file mode 100644
index 0000000..a2766b8
--- /dev/null
+++ b/ground_truth/dot/graphviz_cjk.dot
@@ -0,0 +1,13 @@
+digraph graphviz_cjk {
+ // Stroke-only nodes: no fill anywhere in the drawing. Labels are Japanese, Korean,
+ // Hebrew and Arabic, so nothing here survives a Latin-1 round trip.
+ "受注処理" [shape=box];
+ "검증" [shape=box];
+ "אימות" [shape=box];
+ "اعتماد" [shape=box];
+ "完成" [shape=box];
+ "受注処理" -> "검증" [label="次"];
+ "검증" -> "אימות";
+ "אימות" -> "اعتماد";
+ "اعتماد" -> "完成" [label="סיום"];
+}
diff --git a/ground_truth/dot/graphviz_clusters.dot b/ground_truth/dot/graphviz_clusters.dot
new file mode 100644
index 0000000..4bc88a0
--- /dev/null
+++ b/ground_truth/dot/graphviz_clusters.dot
@@ -0,0 +1,13 @@
+digraph graphviz_clusters {
+ // "Ingest" and "Storage" are cluster containers, not nodes, and are absent here
+ // on purpose: reporting either of them is a false positive.
+ "Receive" [shape=box fillcolor="#a6cee3"];
+ "Parse" [shape=box fillcolor="#a6cee3"];
+ "Index" [shape=box fillcolor="#fdbf6f"];
+ "Archive" [shape=box fillcolor="#fdbf6f"];
+ "Emit" [shape=ellipse fillcolor="#b2df8a"];
+ "Receive" -> "Parse";
+ "Parse" -> "Index";
+ "Parse" -> "Archive";
+ "Index" -> "Emit";
+}
diff --git a/ground_truth/dot/graphviz_flow.dot b/ground_truth/dot/graphviz_flow.dot
new file mode 100644
index 0000000..520333f
--- /dev/null
+++ b/ground_truth/dot/graphviz_flow.dot
@@ -0,0 +1,10 @@
+digraph graphviz_flow {
+ "Start" [shape=box fillcolor="#a6cee3"];
+ "Validate" [shape=diamond fillcolor="#b2df8a"];
+ "Process" [shape=box fillcolor="#fb9a99"];
+ "Done" [shape=ellipse fillcolor="#fdbf6f"];
+ "Start" -> "Validate";
+ "Validate" -> "Process" [label="ok"];
+ "Validate" -> "Done" [label="skip" style=dashed];
+ "Process" -> "Done";
+}
diff --git a/ground_truth/dot/graphviz_large.dot b/ground_truth/dot/graphviz_large.dot
new file mode 100644
index 0000000..b0165ac
--- /dev/null
+++ b/ground_truth/dot/graphviz_large.dot
@@ -0,0 +1,275 @@
+digraph graphviz_large {
+ // 128 nodes, 141 edges. Generated from the same rule as diagrams/src/graphviz_large.dot:
+ // a complete binary tree over N0..N127, plus a dashed shortcut from N_i to the node
+ // sixteen along for every i divisible by 8. Keyed by label, which for this fixture is
+ // the node id with the prefix capitalised.
+ "N0" [shape=box fillcolor="#d9d9d9"];
+ "N1" [shape=box fillcolor="#d9d9d9"];
+ "N2" [shape=box fillcolor="#d9d9d9"];
+ "N3" [shape=box fillcolor="#d9d9d9"];
+ "N4" [shape=box fillcolor="#d9d9d9"];
+ "N5" [shape=box fillcolor="#d9d9d9"];
+ "N6" [shape=box fillcolor="#d9d9d9"];
+ "N7" [shape=box fillcolor="#d9d9d9"];
+ "N8" [shape=box fillcolor="#d9d9d9"];
+ "N9" [shape=box fillcolor="#d9d9d9"];
+ "N10" [shape=box fillcolor="#d9d9d9"];
+ "N11" [shape=box fillcolor="#d9d9d9"];
+ "N12" [shape=box fillcolor="#d9d9d9"];
+ "N13" [shape=box fillcolor="#d9d9d9"];
+ "N14" [shape=box fillcolor="#d9d9d9"];
+ "N15" [shape=box fillcolor="#d9d9d9"];
+ "N16" [shape=box fillcolor="#d9d9d9"];
+ "N17" [shape=box fillcolor="#d9d9d9"];
+ "N18" [shape=box fillcolor="#d9d9d9"];
+ "N19" [shape=box fillcolor="#d9d9d9"];
+ "N20" [shape=box fillcolor="#d9d9d9"];
+ "N21" [shape=box fillcolor="#d9d9d9"];
+ "N22" [shape=box fillcolor="#d9d9d9"];
+ "N23" [shape=box fillcolor="#d9d9d9"];
+ "N24" [shape=box fillcolor="#d9d9d9"];
+ "N25" [shape=box fillcolor="#d9d9d9"];
+ "N26" [shape=box fillcolor="#d9d9d9"];
+ "N27" [shape=box fillcolor="#d9d9d9"];
+ "N28" [shape=box fillcolor="#d9d9d9"];
+ "N29" [shape=box fillcolor="#d9d9d9"];
+ "N30" [shape=box fillcolor="#d9d9d9"];
+ "N31" [shape=box fillcolor="#d9d9d9"];
+ "N32" [shape=box fillcolor="#d9d9d9"];
+ "N33" [shape=box fillcolor="#d9d9d9"];
+ "N34" [shape=box fillcolor="#d9d9d9"];
+ "N35" [shape=box fillcolor="#d9d9d9"];
+ "N36" [shape=box fillcolor="#d9d9d9"];
+ "N37" [shape=box fillcolor="#d9d9d9"];
+ "N38" [shape=box fillcolor="#d9d9d9"];
+ "N39" [shape=box fillcolor="#d9d9d9"];
+ "N40" [shape=box fillcolor="#d9d9d9"];
+ "N41" [shape=box fillcolor="#d9d9d9"];
+ "N42" [shape=box fillcolor="#d9d9d9"];
+ "N43" [shape=box fillcolor="#d9d9d9"];
+ "N44" [shape=box fillcolor="#d9d9d9"];
+ "N45" [shape=box fillcolor="#d9d9d9"];
+ "N46" [shape=box fillcolor="#d9d9d9"];
+ "N47" [shape=box fillcolor="#d9d9d9"];
+ "N48" [shape=box fillcolor="#d9d9d9"];
+ "N49" [shape=box fillcolor="#d9d9d9"];
+ "N50" [shape=box fillcolor="#d9d9d9"];
+ "N51" [shape=box fillcolor="#d9d9d9"];
+ "N52" [shape=box fillcolor="#d9d9d9"];
+ "N53" [shape=box fillcolor="#d9d9d9"];
+ "N54" [shape=box fillcolor="#d9d9d9"];
+ "N55" [shape=box fillcolor="#d9d9d9"];
+ "N56" [shape=box fillcolor="#d9d9d9"];
+ "N57" [shape=box fillcolor="#d9d9d9"];
+ "N58" [shape=box fillcolor="#d9d9d9"];
+ "N59" [shape=box fillcolor="#d9d9d9"];
+ "N60" [shape=box fillcolor="#d9d9d9"];
+ "N61" [shape=box fillcolor="#d9d9d9"];
+ "N62" [shape=box fillcolor="#d9d9d9"];
+ "N63" [shape=box fillcolor="#d9d9d9"];
+ "N64" [shape=box fillcolor="#d9d9d9"];
+ "N65" [shape=box fillcolor="#d9d9d9"];
+ "N66" [shape=box fillcolor="#d9d9d9"];
+ "N67" [shape=box fillcolor="#d9d9d9"];
+ "N68" [shape=box fillcolor="#d9d9d9"];
+ "N69" [shape=box fillcolor="#d9d9d9"];
+ "N70" [shape=box fillcolor="#d9d9d9"];
+ "N71" [shape=box fillcolor="#d9d9d9"];
+ "N72" [shape=box fillcolor="#d9d9d9"];
+ "N73" [shape=box fillcolor="#d9d9d9"];
+ "N74" [shape=box fillcolor="#d9d9d9"];
+ "N75" [shape=box fillcolor="#d9d9d9"];
+ "N76" [shape=box fillcolor="#d9d9d9"];
+ "N77" [shape=box fillcolor="#d9d9d9"];
+ "N78" [shape=box fillcolor="#d9d9d9"];
+ "N79" [shape=box fillcolor="#d9d9d9"];
+ "N80" [shape=box fillcolor="#d9d9d9"];
+ "N81" [shape=box fillcolor="#d9d9d9"];
+ "N82" [shape=box fillcolor="#d9d9d9"];
+ "N83" [shape=box fillcolor="#d9d9d9"];
+ "N84" [shape=box fillcolor="#d9d9d9"];
+ "N85" [shape=box fillcolor="#d9d9d9"];
+ "N86" [shape=box fillcolor="#d9d9d9"];
+ "N87" [shape=box fillcolor="#d9d9d9"];
+ "N88" [shape=box fillcolor="#d9d9d9"];
+ "N89" [shape=box fillcolor="#d9d9d9"];
+ "N90" [shape=box fillcolor="#d9d9d9"];
+ "N91" [shape=box fillcolor="#d9d9d9"];
+ "N92" [shape=box fillcolor="#d9d9d9"];
+ "N93" [shape=box fillcolor="#d9d9d9"];
+ "N94" [shape=box fillcolor="#d9d9d9"];
+ "N95" [shape=box fillcolor="#d9d9d9"];
+ "N96" [shape=box fillcolor="#d9d9d9"];
+ "N97" [shape=box fillcolor="#d9d9d9"];
+ "N98" [shape=box fillcolor="#d9d9d9"];
+ "N99" [shape=box fillcolor="#d9d9d9"];
+ "N100" [shape=box fillcolor="#d9d9d9"];
+ "N101" [shape=box fillcolor="#d9d9d9"];
+ "N102" [shape=box fillcolor="#d9d9d9"];
+ "N103" [shape=box fillcolor="#d9d9d9"];
+ "N104" [shape=box fillcolor="#d9d9d9"];
+ "N105" [shape=box fillcolor="#d9d9d9"];
+ "N106" [shape=box fillcolor="#d9d9d9"];
+ "N107" [shape=box fillcolor="#d9d9d9"];
+ "N108" [shape=box fillcolor="#d9d9d9"];
+ "N109" [shape=box fillcolor="#d9d9d9"];
+ "N110" [shape=box fillcolor="#d9d9d9"];
+ "N111" [shape=box fillcolor="#d9d9d9"];
+ "N112" [shape=box fillcolor="#d9d9d9"];
+ "N113" [shape=box fillcolor="#d9d9d9"];
+ "N114" [shape=box fillcolor="#d9d9d9"];
+ "N115" [shape=box fillcolor="#d9d9d9"];
+ "N116" [shape=box fillcolor="#d9d9d9"];
+ "N117" [shape=box fillcolor="#d9d9d9"];
+ "N118" [shape=box fillcolor="#d9d9d9"];
+ "N119" [shape=box fillcolor="#d9d9d9"];
+ "N120" [shape=box fillcolor="#d9d9d9"];
+ "N121" [shape=box fillcolor="#d9d9d9"];
+ "N122" [shape=box fillcolor="#d9d9d9"];
+ "N123" [shape=box fillcolor="#d9d9d9"];
+ "N124" [shape=box fillcolor="#d9d9d9"];
+ "N125" [shape=box fillcolor="#d9d9d9"];
+ "N126" [shape=box fillcolor="#d9d9d9"];
+ "N127" [shape=box fillcolor="#d9d9d9"];
+ "N0" -> "N1";
+ "N0" -> "N2";
+ "N1" -> "N3";
+ "N1" -> "N4";
+ "N2" -> "N5";
+ "N2" -> "N6";
+ "N3" -> "N7";
+ "N3" -> "N8";
+ "N4" -> "N9";
+ "N4" -> "N10";
+ "N5" -> "N11";
+ "N5" -> "N12";
+ "N6" -> "N13";
+ "N6" -> "N14";
+ "N7" -> "N15";
+ "N7" -> "N16";
+ "N8" -> "N17";
+ "N8" -> "N18";
+ "N9" -> "N19";
+ "N9" -> "N20";
+ "N10" -> "N21";
+ "N10" -> "N22";
+ "N11" -> "N23";
+ "N11" -> "N24";
+ "N12" -> "N25";
+ "N12" -> "N26";
+ "N13" -> "N27";
+ "N13" -> "N28";
+ "N14" -> "N29";
+ "N14" -> "N30";
+ "N15" -> "N31";
+ "N15" -> "N32";
+ "N16" -> "N33";
+ "N16" -> "N34";
+ "N17" -> "N35";
+ "N17" -> "N36";
+ "N18" -> "N37";
+ "N18" -> "N38";
+ "N19" -> "N39";
+ "N19" -> "N40";
+ "N20" -> "N41";
+ "N20" -> "N42";
+ "N21" -> "N43";
+ "N21" -> "N44";
+ "N22" -> "N45";
+ "N22" -> "N46";
+ "N23" -> "N47";
+ "N23" -> "N48";
+ "N24" -> "N49";
+ "N24" -> "N50";
+ "N25" -> "N51";
+ "N25" -> "N52";
+ "N26" -> "N53";
+ "N26" -> "N54";
+ "N27" -> "N55";
+ "N27" -> "N56";
+ "N28" -> "N57";
+ "N28" -> "N58";
+ "N29" -> "N59";
+ "N29" -> "N60";
+ "N30" -> "N61";
+ "N30" -> "N62";
+ "N31" -> "N63";
+ "N31" -> "N64";
+ "N32" -> "N65";
+ "N32" -> "N66";
+ "N33" -> "N67";
+ "N33" -> "N68";
+ "N34" -> "N69";
+ "N34" -> "N70";
+ "N35" -> "N71";
+ "N35" -> "N72";
+ "N36" -> "N73";
+ "N36" -> "N74";
+ "N37" -> "N75";
+ "N37" -> "N76";
+ "N38" -> "N77";
+ "N38" -> "N78";
+ "N39" -> "N79";
+ "N39" -> "N80";
+ "N40" -> "N81";
+ "N40" -> "N82";
+ "N41" -> "N83";
+ "N41" -> "N84";
+ "N42" -> "N85";
+ "N42" -> "N86";
+ "N43" -> "N87";
+ "N43" -> "N88";
+ "N44" -> "N89";
+ "N44" -> "N90";
+ "N45" -> "N91";
+ "N45" -> "N92";
+ "N46" -> "N93";
+ "N46" -> "N94";
+ "N47" -> "N95";
+ "N47" -> "N96";
+ "N48" -> "N97";
+ "N48" -> "N98";
+ "N49" -> "N99";
+ "N49" -> "N100";
+ "N50" -> "N101";
+ "N50" -> "N102";
+ "N51" -> "N103";
+ "N51" -> "N104";
+ "N52" -> "N105";
+ "N52" -> "N106";
+ "N53" -> "N107";
+ "N53" -> "N108";
+ "N54" -> "N109";
+ "N54" -> "N110";
+ "N55" -> "N111";
+ "N55" -> "N112";
+ "N56" -> "N113";
+ "N56" -> "N114";
+ "N57" -> "N115";
+ "N57" -> "N116";
+ "N58" -> "N117";
+ "N58" -> "N118";
+ "N59" -> "N119";
+ "N59" -> "N120";
+ "N60" -> "N121";
+ "N60" -> "N122";
+ "N61" -> "N123";
+ "N61" -> "N124";
+ "N62" -> "N125";
+ "N62" -> "N126";
+ "N63" -> "N127";
+ "N0" -> "N16" [style=dashed];
+ "N8" -> "N24" [style=dashed];
+ "N16" -> "N32" [style=dashed];
+ "N24" -> "N40" [style=dashed];
+ "N32" -> "N48" [style=dashed];
+ "N40" -> "N56" [style=dashed];
+ "N48" -> "N64" [style=dashed];
+ "N56" -> "N72" [style=dashed];
+ "N64" -> "N80" [style=dashed];
+ "N72" -> "N88" [style=dashed];
+ "N80" -> "N96" [style=dashed];
+ "N88" -> "N104" [style=dashed];
+ "N96" -> "N112" [style=dashed];
+ "N104" -> "N120" [style=dashed];
+}
diff --git a/ground_truth/dot/graphviz_network.dot b/ground_truth/dot/graphviz_network.dot
new file mode 100644
index 0000000..c073492
--- /dev/null
+++ b/ground_truth/dot/graphviz_network.dot
@@ -0,0 +1,11 @@
+graph graphviz_network {
+ "Gateway" [shape=ellipse fillcolor="#8dd3c7"];
+ "Switch A" [shape=ellipse fillcolor="#ffffb3"];
+ "Switch B" [shape=ellipse fillcolor="#ffffb3"];
+ "Host 1" [shape=ellipse fillcolor="#cccccc"];
+ "Host 2" [shape=ellipse fillcolor="#cccccc"];
+ "Gateway" -- "Switch A";
+ "Gateway" -- "Switch B";
+ "Switch A" -- "Host 1";
+ "Switch B" -- "Host 2";
+}
diff --git a/ground_truth/dot/graphviz_ortho.dot b/ground_truth/dot/graphviz_ortho.dot
new file mode 100644
index 0000000..fe2af78
--- /dev/null
+++ b/ground_truth/dot/graphviz_ortho.dot
@@ -0,0 +1,12 @@
+digraph graphviz_ortho {
+ "Client" [shape=box fillcolor="#ccebc5"];
+ "Balancer" [shape=box fillcolor="#ccebc5"];
+ "Web 1" [shape=box fillcolor="#ccebc5"];
+ "Web 2" [shape=box fillcolor="#ccebc5"];
+ "Database" [shape=box fillcolor="#ccebc5"];
+ "Client" -> "Balancer";
+ "Balancer" -> "Web 1";
+ "Balancer" -> "Web 2";
+ "Web 1" -> "Database";
+ "Web 2" -> "Database";
+}
diff --git a/ground_truth/dot/graphviz_record.dot b/ground_truth/dot/graphviz_record.dot
new file mode 100644
index 0000000..7074ca4
--- /dev/null
+++ b/ground_truth/dot/graphviz_record.dot
@@ -0,0 +1,11 @@
+digraph graphviz_record {
+ // One record is one node. Its fields are separated by rules drawn inside the single
+ // outline, so the node key is the field texts as one multi-line label, the same
+ // convention org_chart.dot uses. Splitting a record into one node per field is the
+ // failure this fixture looks for.
+ "Customer\nid\nname" [shape=record fillcolor="#fbb4ae"];
+ "Order\nid\ncustomer_id\ntotal" [shape=record fillcolor="#fbb4ae"];
+ "Item\norder_id\nsku" [shape=record fillcolor="#fbb4ae"];
+ "Customer\nid\nname" -> "Order\nid\ncustomer_id\ntotal";
+ "Order\nid\ncustomer_id\ntotal" -> "Item\norder_id\nsku";
+}
diff --git a/ground_truth/dot/graphviz_selfloop.dot b/ground_truth/dot/graphviz_selfloop.dot
new file mode 100644
index 0000000..19d7f6a
--- /dev/null
+++ b/ground_truth/dot/graphviz_selfloop.dot
@@ -0,0 +1,11 @@
+digraph graphviz_selfloop {
+ "Retry" [shape=circle fillcolor="#bebada"];
+ "Work" [shape=circle fillcolor="#bebada"];
+ "Left" [shape=circle fillcolor="#bebada"];
+ "Right" [shape=circle fillcolor="#bebada"];
+ "Retry" -> "Retry" [label="backoff"];
+ "Work" -> "Work";
+ "Retry" -> "Work";
+ "Left" -> "Work";
+ "Retry" -> "Right";
+}
diff --git a/ground_truth/dot/graphviz_states.dot b/ground_truth/dot/graphviz_states.dot
new file mode 100644
index 0000000..b68677b
--- /dev/null
+++ b/ground_truth/dot/graphviz_states.dot
@@ -0,0 +1,10 @@
+digraph graphviz_states {
+ "idle" [shape=ellipse fillcolor="#b3de69"];
+ "running" [shape=ellipse fillcolor="#b3de69"];
+ "paused" [shape=ellipse fillcolor="#b3de69"];
+ "done" [shape=ellipse fillcolor="#fb8072"];
+ "idle" -> "running" [label="start"];
+ "running" -> "paused" [label="pause"];
+ "paused" -> "running" [label="resume"];
+ "running" -> "done" [label="finish"];
+}
diff --git a/ground_truth/dot/icon_nodes.dot b/ground_truth/dot/icon_nodes.dot
new file mode 100644
index 0000000..1ee68b0
--- /dev/null
+++ b/ground_truth/dot/icon_nodes.dot
@@ -0,0 +1,11 @@
+digraph icon_nodes {
+ // Each node is an icon glyph with its caption underneath and no bounding outline, so the
+ // label sits outside every closed region in the drawing.
+ "Browser" [shape=none];
+ "Queue" [shape=none];
+ "Worker" [shape=none];
+ "Bucket" [shape=none];
+ "Browser" -> "Queue";
+ "Queue" -> "Worker";
+ "Worker" -> "Bucket";
+}
diff --git a/ground_truth/dot/libreoffice_connectors.dot b/ground_truth/dot/libreoffice_connectors.dot
new file mode 100644
index 0000000..0036752
--- /dev/null
+++ b/ground_truth/dot/libreoffice_connectors.dot
@@ -0,0 +1,9 @@
+digraph libreoffice_connectors {
+ "Intake" [shape=box];
+ "Review" [shape=box];
+ "Approve" [shape=box];
+ "Publish" [shape=box];
+ "Intake" -> "Review";
+ "Review" -> "Approve";
+ "Approve" -> "Publish";
+}
diff --git a/ground_truth/dot/mermaid_flow.dot b/ground_truth/dot/mermaid_flow.dot
new file mode 100644
index 0000000..921ae47
--- /dev/null
+++ b/ground_truth/dot/mermaid_flow.dot
@@ -0,0 +1,14 @@
+digraph mermaid_flow {
+ "Receive request" [shape=box];
+ "Authenticated?" [shape=diamond];
+ "Check quota" [shape=box];
+ "Return 401" [shape=box];
+ "Run handler" [shape=box];
+ "Return 200" [shape=box];
+ "Receive request" -> "Authenticated?";
+ "Authenticated?" -> "Check quota" [label="yes"];
+ "Authenticated?" -> "Return 401" [label="no"];
+ "Check quota" -> "Run handler";
+ "Run handler" -> "Return 200";
+ "Return 401" -> "Return 200";
+}
diff --git a/ground_truth/dot/mixed_page.dot b/ground_truth/dot/mixed_page.dot
new file mode 100644
index 0000000..e86c622
--- /dev/null
+++ b/ground_truth/dot/mixed_page.dot
@@ -0,0 +1,9 @@
+digraph mixed_page {
+ // The page also carries a heading, two paragraphs and a four-row ruled table. None of that
+ // is here: the ground truth is the figure at the foot of the page and nothing else.
+ "Build" [shape=box];
+ "Sign-off" [shape=box];
+ "Ship" [shape=box];
+ "Build" -> "Sign-off";
+ "Sign-off" -> "Ship";
+}
diff --git a/ground_truth/dot/negative_form.dot b/ground_truth/dot/negative_form.dot
new file mode 100644
index 0000000..e69de29
diff --git a/ground_truth/dot/negative_pie_chart.dot b/ground_truth/dot/negative_pie_chart.dot
new file mode 100644
index 0000000..e69de29
diff --git a/ground_truth/dot/negative_ruled_table.dot b/ground_truth/dot/negative_ruled_table.dot
new file mode 100644
index 0000000..e69de29
diff --git a/ground_truth/dot/nested_transforms.dot b/ground_truth/dot/nested_transforms.dot
new file mode 100644
index 0000000..18a1822
--- /dev/null
+++ b/ground_truth/dot/nested_transforms.dot
@@ -0,0 +1,9 @@
+digraph nested_transforms {
+ "Ingest" [shape=box fillcolor="#4e79a7"];
+ "Transform" [shape=box fillcolor="#f28e2b"];
+ "Publish" [shape=box fillcolor="#59a14f"];
+ "Quarantine" [shape=box fillcolor="#e15759" style=dashed];
+ "Ingest" -> "Transform";
+ "Transform" -> "Publish";
+ "Transform" -> "Quarantine" [label="on error" style=dashed];
+}
diff --git a/ground_truth/dot/org_chart.dot b/ground_truth/dot/org_chart.dot
new file mode 100644
index 0000000..a0f0d72
--- /dev/null
+++ b/ground_truth/dot/org_chart.dot
@@ -0,0 +1,14 @@
+digraph org_chart {
+ "Jane Smith\nChief Executive Officer" [shape=box fillcolor="#2c3e50"];
+ "Bob Chen\nChief Technology Officer" [shape=box fillcolor="#2980b9"];
+ "Maria Garcia\nChief Financial Officer" [shape=box fillcolor="#27ae60"];
+ "Alex Johnson\nChief Operating Officer" [shape=box fillcolor="#8e44ad"];
+ "Engineering" [shape=box fillcolor="#3498db"];
+ "Product" [shape=box fillcolor="#3498db"];
+ "Finance" [shape=box fillcolor="#2ecc71"];
+ "Sales" [shape=box fillcolor="#9b59b6"];
+ "Operations" [shape=box fillcolor="#9b59b6"];
+ "Jane Smith\nChief Executive Officer" -> "Bob Chen\nChief Technology Officer";
+ "Jane Smith\nChief Executive Officer" -> "Maria Garcia\nChief Financial Officer";
+ "Jane Smith\nChief Executive Officer" -> "Alex Johnson\nChief Operating Officer";
+}
diff --git a/ground_truth/dot/plantuml_activity.dot b/ground_truth/dot/plantuml_activity.dot
new file mode 100644
index 0000000..9b3b0bf
--- /dev/null
+++ b/ground_truth/dot/plantuml_activity.dot
@@ -0,0 +1,14 @@
+digraph plantuml_activity {
+ // PlantUML draws the (*) start and end as unlabelled markers. Ground truth in this corpus
+ // is keyed by label, so an unlabelled marker has no key and is not a node, and the two
+ // edges joining them are not edges either. See diagrams/README.md.
+ "Read config" [shape=box];
+ "Open input" [shape=box];
+ "Detect format" [shape=box];
+ "Extract text" [shape=box];
+ "Render markdown" [shape=box];
+ "Read config" -> "Open input";
+ "Open input" -> "Detect format";
+ "Detect format" -> "Extract text";
+ "Extract text" -> "Render markdown";
+}
diff --git a/ground_truth/dot/plantuml_swimlane.dot b/ground_truth/dot/plantuml_swimlane.dot
new file mode 100644
index 0000000..ec00a6c
--- /dev/null
+++ b/ground_truth/dot/plantuml_swimlane.dot
@@ -0,0 +1,14 @@
+digraph plantuml_swimlane {
+ // "Author", "Editor" and "Legal" are swimlane headers labelling a band that spans the whole
+ // drawing. A band is not a node; reporting one is a false positive. The unlabelled start and
+ // stop markers are out of scope for label-keyed ground truth, as in plantuml_activity.
+ "Draft article" [shape=box];
+ "Review draft" [shape=box];
+ "Clear rights" [shape=box];
+ "Schedule slot" [shape=box];
+ "Publish" [shape=box];
+ "Draft article" -> "Review draft";
+ "Review draft" -> "Clear rights";
+ "Clear rights" -> "Schedule slot";
+ "Schedule slot" -> "Publish";
+}
diff --git a/ground_truth/dot/simple_svg.dot b/ground_truth/dot/simple_svg.dot
new file mode 100644
index 0000000..e69de29
diff --git a/ground_truth/ground_truth_mapping.json b/ground_truth/ground_truth_mapping.json
index d5ce093..789a5a0 100644
--- a/ground_truth/ground_truth_mapping.json
+++ b/ground_truth/ground_truth_mapping.json
@@ -102,6 +102,31 @@
"docx_rich_cells.docx": "test_documents/ground_truth/json/docx_rich_cells.docx.txt",
"docx_simple": "test_documents/ground_truth/docx/docx_simple.txt",
"docx_tables": "test_documents/ground_truth/docx/docx_tables.txt",
+ "dot_data_dashboard": "test_documents/ground_truth/dot/data_dashboard.dot",
+ "dot_flamegraph_intel_paging": "test_documents/ground_truth/dot/5_level_paging_and_5_level_ept_intel_revision_1_1_may_2017.dot",
+ "dot_flowchart": "test_documents/ground_truth/dot/flowchart.dot",
+ "dot_graphviz_bidirectional": "test_documents/ground_truth/dot/graphviz_bidirectional.dot",
+ "dot_graphviz_cjk": "test_documents/ground_truth/dot/graphviz_cjk.dot",
+ "dot_graphviz_clusters": "test_documents/ground_truth/dot/graphviz_clusters.dot",
+ "dot_graphviz_flow": "test_documents/ground_truth/dot/graphviz_flow.dot",
+ "dot_graphviz_large": "test_documents/ground_truth/dot/graphviz_large.dot",
+ "dot_graphviz_network": "test_documents/ground_truth/dot/graphviz_network.dot",
+ "dot_graphviz_ortho": "test_documents/ground_truth/dot/graphviz_ortho.dot",
+ "dot_graphviz_record": "test_documents/ground_truth/dot/graphviz_record.dot",
+ "dot_graphviz_selfloop": "test_documents/ground_truth/dot/graphviz_selfloop.dot",
+ "dot_graphviz_states": "test_documents/ground_truth/dot/graphviz_states.dot",
+ "dot_icon_nodes": "test_documents/ground_truth/dot/icon_nodes.dot",
+ "dot_libreoffice_connectors": "test_documents/ground_truth/dot/libreoffice_connectors.dot",
+ "dot_mermaid_flow": "test_documents/ground_truth/dot/mermaid_flow.dot",
+ "dot_mixed_page": "test_documents/ground_truth/dot/mixed_page.dot",
+ "dot_negative_form": "test_documents/ground_truth/dot/negative_form.dot",
+ "dot_negative_pie_chart": "test_documents/ground_truth/dot/negative_pie_chart.dot",
+ "dot_negative_ruled_table": "test_documents/ground_truth/dot/negative_ruled_table.dot",
+ "dot_nested_transforms": "test_documents/ground_truth/dot/nested_transforms.dot",
+ "dot_org_chart": "test_documents/ground_truth/dot/org_chart.dot",
+ "dot_plantuml_activity": "test_documents/ground_truth/dot/plantuml_activity.dot",
+ "dot_plantuml_swimlane": "test_documents/ground_truth/dot/plantuml_swimlane.dot",
+ "dot_simple_svg": "test_documents/ground_truth/dot/simple_svg.dot",
"drawingml.docx": "test_documents/ground_truth/json/drawingml.docx.txt",
"duck.md": "test_documents/ground_truth/md/duck.md.txt",
"duplicate-paragraphs": "test_documents/ground_truth/doc/duplicate-paragraphs.txt",
diff --git a/poly.toml b/poly.toml
index f4bd657..e5e5e42 100644
--- a/poly.toml
+++ b/poly.toml
@@ -18,6 +18,7 @@ exclude = [
"csv/**",
"data_formats/**",
"dbf/**",
+ "diagrams/**",
"doc/**",
"docbook/**",
"docx/**",
diff --git a/scripts/check_diagram_ground_truth.py b/scripts/check_diagram_ground_truth.py
new file mode 100644
index 0000000..d1d0c93
--- /dev/null
+++ b/scripts/check_diagram_ground_truth.py
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""Check every Graphviz diagram fixture's ground truth against what Graphviz actually drew.
+
+The ground truth under `ground_truth/dot/` is written by hand, keyed by node label, and it is
+what the whole diagram corpus is measured against -- so a transcription slip in it is worse
+than a bug, because it silently redefines "correct". This re-derives the graph from the
+committed `.dot` source using `dot -Tplain`, which reports the labels and edges as Graphviz
+itself resolved them, and diffs that against the ground truth.
+
+Two things the raw `-Tplain` output does not say, and this accounts for:
+
+* `dir=back` draws the arrowhead at the tail, so the edge reads the other way round. A
+ recogniser working from geometry sees only where the arrowhead is, so the ground truth
+ records the drawn direction and the declaration order is discarded.
+* A record node's label carries port names and field separators. One record is one node,
+ so its key is the field texts as one multi-line label.
+
+Needs Graphviz on PATH, which is a fixture-regeneration dependency rather than a CI one -- it
+reports that it skipped instead of failing when Graphviz is absent.
+
+Usage:
+ python3 scripts/check_diagram_ground_truth.py
+"""
+
+from __future__ import annotations
+
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+# Engine per fixture: the layout Graphviz was invoked with, which has to match how the
+# committed SVG was rendered or the comparison is against a different drawing.
+ENGINES = {
+ "graphviz_bidirectional": "dot",
+ "graphviz_cjk": "dot",
+ "graphviz_clusters": "dot",
+ "graphviz_flow": "dot",
+ "graphviz_large": "dot",
+ "graphviz_network": "neato",
+ "graphviz_ortho": "dot",
+ "graphviz_record": "dot",
+ "graphviz_selfloop": "neato",
+ "graphviz_states": "dot",
+}
+
+ROOT = Path(__file__).resolve().parent.parent
+SOURCES = ROOT / "diagrams" / "src"
+GROUND_TRUTH = ROOT / "ground_truth" / "dot"
+
+PLAIN_TOKEN = re.compile(r'"[^"]*"|\S+')
+PORT = re.compile(r"<\w+>")
+COMMENT = re.compile(r"//.*")
+GT_NODE = re.compile(r'^\s*"([^"]+)"\s*\[', re.M)
+GT_EDGE = re.compile(r'"([^"]+)"\s*-[->]\s*"([^"]+)"')
+
+
+def joined_record_fields(label: str) -> str:
+ """One record is one node, so its key is its field texts as one multi-line label."""
+ if "<" not in label:
+ return label
+ return "\\n".join(PORT.sub("", field).strip() for field in label.split("|"))
+
+
+def reversed_edges(source: Path) -> set[tuple[str, str]]:
+ """Edges declared with dir=back, which Graphviz draws pointing the other way."""
+ text = COMMENT.sub("", source.read_text(encoding="utf-8"))
+ declarations = re.findall(r"(\w+)\s*->\s*(\w+)\s*\[([^\]]*)\]", text)
+ return {(tail, head) for tail, head, attrs in declarations if "dir=back" in attrs}
+
+
+def drawn_graph(stem: str, engine: str) -> tuple[list[str], list[tuple[str, str]]]:
+ source = SOURCES / f"{stem}.dot"
+ plain = subprocess.run([engine, "-Tplain", str(source)], capture_output=True, text=True, check=True).stdout
+ flipped = reversed_edges(source)
+ labels: dict[str, str] = {}
+ nodes: list[str] = []
+ edges: list[tuple[str, str]] = []
+ for line in plain.splitlines():
+ fields = [token[1:-1] if token.startswith('"') else token for token in PLAIN_TOKEN.findall(line)]
+ if fields[0] == "node":
+ labels[fields[1]] = joined_record_fields(fields[6])
+ nodes.append(labels[fields[1]])
+ elif fields[0] == "edge":
+ tail, head = fields[1], fields[2]
+ if (tail, head) in flipped:
+ tail, head = head, tail
+ edges.append((labels[tail], labels[head]))
+ return sorted(nodes), sorted(edges)
+
+
+def recorded_graph(stem: str) -> tuple[list[str], list[tuple[str, str]]]:
+ text = COMMENT.sub("", (GROUND_TRUTH / f"{stem}.dot").read_text(encoding="utf-8"))
+ return sorted(GT_NODE.findall(text)), sorted(GT_EDGE.findall(text))
+
+
+def main() -> int:
+ if shutil.which("dot") is None:
+ print("graphviz is not installed, skipping the ground-truth cross-check")
+ return 0
+ failures = 0
+ for stem, engine in sorted(ENGINES.items()):
+ drawn_nodes, drawn_edges = drawn_graph(stem, engine)
+ recorded_nodes, recorded_edges = recorded_graph(stem)
+ if drawn_nodes == recorded_nodes and drawn_edges == recorded_edges:
+ print(f"ok {stem:24s} nodes {len(recorded_nodes):3d} edges {len(recorded_edges):3d}")
+ continue
+ failures += 1
+ print(f"FAIL {stem}")
+ for name, drawn, recorded in (
+ ("nodes", drawn_nodes, recorded_nodes),
+ ("edges", drawn_edges, recorded_edges),
+ ):
+ print(f" {name} drawn but not in ground truth: {sorted(set(drawn) - set(recorded))}")
+ print(f" {name} in ground truth but not drawn: {sorted(set(recorded) - set(drawn))}")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/strip_svg_graph_metadata.py b/scripts/strip_svg_graph_metadata.py
new file mode 100644
index 0000000..ef532de
--- /dev/null
+++ b/scripts/strip_svg_graph_metadata.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+"""Remove the answer from a rendered SVG, leaving a geometry-only fixture behind.
+
+Every diagram tool writes the graph it just laid out back into its own output, each in its
+own way:
+
+ graphviz a the node id
+ a->b the whole edge
+ the whole edge, again
+ mermaid both endpoints
+ plantuml both endpoints
+ both endpoints, again
+
+A recogniser that reads those scores perfectly without inspecting a single coordinate, so a
+fixture carrying them cannot measure geometry recovery.
+
+Reading them is not cheating -- when a file states its graph, using it is the correct and
+exact thing to do, and that is what the Class A fixtures are for. It is simply a different
+capability from inferring a graph from shapes and strokes, and measuring the two together
+measures neither. So each fixture whose producer states its answer ships twice: as emitted,
+and stripped.
+
+Two rules, and both are producer-agnostic rather than a list of the dialects seen so far:
+
+1. Drop `` inside an element group. The root `` names the whole drawing, which
+ a hand-drawn diagram would plausibly carry too, so it stays. Clusters count as elements:
+ a cluster is a rectangle that is deliberately *not* a node, and `cluster_ingest`
+ announces exactly that, which is the judgement the cluster fixtures exist to test.
+
+2. Drop every `id` that nothing in the document refers to, and every `data-id`. An id no
+ `url(#...)`, `href="#..."` or stylesheet selector points at has no effect on rendering --
+ it is there purely to say which element this is, and that is the answer.
+
+3. Drop every XML comment. Graphviz and PlantUML both restate the entire edge list in
+ comments as well, so stripping only the titles and the ids leaves the answer sitting in
+ the file in plain text. The producer banner goes with them; provenance belongs in
+ `diagrams/manifest.json`, which is where a reader should be looking for it anyway.
+
+What deliberately stays is anything that gives an element's *type* without naming its
+endpoints: `class="node"`, `class="flowchart-link"`, `class="com.sun.star.drawing.ConnectorShape"`.
+Knowing a stroke is some connector still leaves you to work out what it connects, which is
+the whole task.
+
+Usage:
+ python3 scripts/strip_svg_graph_metadata.py diagrams/svg/graphviz_flow.svg \
+ diagrams/svg/graphviz_flow_geometry.svg
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+TITLE = re.compile(r"[ \t]*.*?\n?", re.DOTALL)
+ELEMENT_GROUP = re.compile(r'()(.*?)()', re.DOTALL)
+
+COMMENT = re.compile(r"[ \t]*\n?", re.DOTALL)
+ID_ATTRIBUTE = re.compile(r'\s+id="([^"]*)"')
+DATA_ID_ATTRIBUTE = re.compile(r'\s+data-id="[^"]*"')
+FRAGMENT_REFERENCE = re.compile(r'(?:url\(\s*#|(?:xlink:)?href="#)([^)"\s]+)')
+STYLE_BLOCK = re.compile(r"", re.DOTALL)
+STYLE_SELECTOR = re.compile(r"#([A-Za-z_][\w:.-]*)")
+
+
+def referenced_ids(svg: str) -> set[str]:
+ """Every id the document itself points at, so removing the rest changes no pixels."""
+ ids = set(FRAGMENT_REFERENCE.findall(svg))
+ for block in STYLE_BLOCK.findall(svg):
+ ids.update(STYLE_SELECTOR.findall(block))
+ return ids
+
+
+def strip(svg: str) -> str:
+ without_titles = ELEMENT_GROUP.sub(lambda m: m.group(1) + TITLE.sub("", m.group(2)) + m.group(3), svg)
+ keep = referenced_ids(without_titles)
+ without_ids = ID_ATTRIBUTE.sub(lambda m: m.group(0) if m.group(1) in keep else "", without_titles)
+ return COMMENT.sub("", DATA_ID_ATTRIBUTE.sub("", without_ids))
+
+
+def main() -> int:
+ if len(sys.argv) != 3:
+ print(__doc__, file=sys.stderr)
+ return 2
+ source, destination = Path(sys.argv[1]), Path(sys.argv[2])
+ original = source.read_text(encoding="utf-8")
+ stripped = strip(original)
+ if stripped == original:
+ print(f"{source}: nothing to strip, it states no graph metadata", file=sys.stderr)
+ return 1
+ destination.write_text(stripped, encoding="utf-8")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/test_diagram_manifest.py b/scripts/test_diagram_manifest.py
new file mode 100644
index 0000000..fa24f3a
--- /dev/null
+++ b/scripts/test_diagram_manifest.py
@@ -0,0 +1,187 @@
+"""Invariants for `diagrams/manifest.json` and the fixtures it indexes.
+
+The manifest is the corpus's answer key, and an answer key that disagrees with the files on
+disk is worse than no answer key: it makes a scorer report numbers nobody can act on. These
+run in CI, need no Graphviz, and cover the two ways it has already gone wrong once -- a
+fixture path that does not exist, and a geometry-only variant that still states its answer.
+
+The Graphviz sources are cross-checked against what Graphviz actually drew separately, by
+`scripts/check_diagram_ground_truth.py`, which needs the renderer.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import unittest
+from pathlib import Path
+
+from strip_svg_graph_metadata import referenced_ids, strip
+
+ROOT = Path(__file__).resolve().parent.parent
+MANIFEST = json.loads((ROOT / "diagrams" / "manifest.json").read_text(encoding="utf-8"))
+FIXTURES = MANIFEST["fixtures"]
+
+COMMENT = re.compile(r"//.*")
+GT_NODE = re.compile(r'^\s*"([^"]+)"\s*\[', re.M)
+GT_EDGE = re.compile(r'"([^"]+)"\s*-[->]\s*"([^"]+)"')
+XML_COMMENT = re.compile(r"", re.DOTALL)
+ATTRIBUTE = re.compile(r'[\w:.-]+="([^"]*)"')
+
+
+def ground_truths(fixture: dict) -> list[Path]:
+ return [ROOT / graph["ground_truth"] for graph in fixture["graphs"]]
+
+
+class FixtureFilesTest(unittest.TestCase):
+ def test_should_index_a_file_that_exists(self) -> None:
+ for fixture in FIXTURES:
+ with self.subTest(fixture["path"]):
+ self.assertTrue((ROOT / fixture["path"]).is_file())
+
+ def test_should_point_at_a_ground_truth_that_exists(self) -> None:
+ for fixture in FIXTURES:
+ for path in ground_truths(fixture):
+ with self.subTest(str(path)):
+ self.assertTrue(path.is_file())
+
+ def test_should_point_at_a_source_that_exists_when_it_claims_one(self) -> None:
+ for fixture in FIXTURES:
+ source = fixture.get("source")
+ if source:
+ with self.subTest(source):
+ self.assertTrue((ROOT / source).is_file())
+
+ def test_should_ship_the_geometry_variant_it_names(self) -> None:
+ for fixture in FIXTURES:
+ variant = fixture.get("geometry_only_variant")
+ if variant:
+ with self.subTest(variant):
+ self.assertTrue((ROOT / variant).is_file())
+
+ def test_should_index_every_svg_that_ships_under_diagrams(self) -> None:
+ indexed = {f["path"] for f in FIXTURES}
+ indexed |= {f["geometry_only_variant"] for f in FIXTURES if f.get("geometry_only_variant")}
+ on_disk = {str(path.relative_to(ROOT)) for path in (ROOT / "diagrams" / "svg").glob("*.svg")}
+ self.assertEqual(set(), on_disk - indexed, "fixture on disk that the manifest does not index")
+
+
+class GroundTruthAgreementTest(unittest.TestCase):
+ def test_should_record_the_node_and_edge_counts_its_ground_truth_holds(self) -> None:
+ for fixture in FIXTURES:
+ for graph in fixture["graphs"]:
+ text = COMMENT.sub("", (ROOT / graph["ground_truth"]).read_text(encoding="utf-8"))
+ with self.subTest(graph["ground_truth"]):
+ self.assertEqual(graph["nodes"], len(GT_NODE.findall(text)))
+ self.assertEqual(graph["edges"], len(GT_EDGE.findall(text)))
+
+ def test_should_write_an_undirected_ground_truth_as_a_graph_not_a_digraph(self) -> None:
+ for fixture in FIXTURES:
+ for graph in fixture["graphs"]:
+ raw = (ROOT / graph["ground_truth"]).read_text(encoding="utf-8")
+ body = COMMENT.sub("", raw)
+ with self.subTest(graph["ground_truth"]):
+ if graph["directed"]:
+ self.assertRegex(body, r"^digraph\b")
+ self.assertNotIn(" -- ", body)
+ else:
+ self.assertRegex(body, r"^graph\b")
+ self.assertNotIn("->", body)
+
+ def test_should_leave_a_negative_with_no_graph_and_a_stated_reason(self) -> None:
+ negatives = [f for f in FIXTURES if f.get("negative")]
+ self.assertTrue(negatives)
+ for fixture in negatives:
+ with self.subTest(fixture["path"]):
+ self.assertEqual([], fixture["graphs"])
+ self.assertTrue(fixture["reason"])
+ # The empty file keeps "not a diagram" and "not yet annotated" apart: the
+ # reason in the manifest is what says which one this is.
+ self.assertEqual("", (ROOT / fixture["ground_truth"]).read_text(encoding="utf-8"))
+
+ def test_should_write_a_comment_that_cannot_be_misread_as_a_node_or_an_edge(self) -> None:
+ # Consumers parse these files line by line and do not all strip `//` comments -- the
+ # scorer on xberg#1410 does not, and a prose comment mentioning `a -> b` was read as a
+ # real edge, which is how graphviz_large came to report one more edge than it draws.
+ # Cheaper to keep the comments free of DOT syntax than to rely on every reader.
+ for fixture in FIXTURES:
+ for graph in fixture["graphs"]:
+ path = ROOT / graph["ground_truth"]
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
+ comment = line.partition("//")[2]
+ with self.subTest(f"{graph['ground_truth']}:{number}"):
+ for token in ("->", "--", "["):
+ self.assertNotIn(token, comment)
+
+
+class GeometryVariantTest(unittest.TestCase):
+ """A geometry-only variant must not state the answer anywhere a parser can reach it."""
+
+ def variants(self):
+ for fixture in FIXTURES:
+ variant = fixture.get("geometry_only_variant")
+ if variant:
+ yield fixture, (ROOT / variant).read_text(encoding="utf-8")
+
+ def test_should_be_exactly_what_stripping_its_parent_produces(self) -> None:
+ # The strongest statement available, and the one that does not depend on knowing how a
+ # given producer encodes its answer: the committed variant is the tool's output, not
+ # something hand-edited that merely looks stripped.
+ for fixture, body in self.variants():
+ parent = (ROOT / fixture["path"]).read_text(encoding="utf-8")
+ with self.subTest(fixture["geometry_only_variant"]):
+ self.assertEqual(strip(parent), body)
+
+ def test_should_have_nothing_left_to_strip(self) -> None:
+ for fixture, body in self.variants():
+ with self.subTest(fixture["geometry_only_variant"]):
+ self.assertEqual(body, strip(body))
+
+ def test_should_carry_no_identifier_that_could_name_an_element(self) -> None:
+ # Mermaid puts both endpoints of every edge in id="L_start_auth_0" and data-id, using
+ # its own node ids rather than the labels, so a label-based check cannot see it.
+ for fixture, body in self.variants():
+ referenced = referenced_ids(body)
+ declared = set(re.findall(r'\sid="([^"]*)"', body))
+ with self.subTest(fixture["geometry_only_variant"]):
+ self.assertEqual(set(), declared - referenced)
+ self.assertNotIn("data-id=", body)
+
+ def test_should_carry_no_xml_comments(self) -> None:
+ # Graphviz and PlantUML both restate the whole edge list in comments.
+ for fixture, body in self.variants():
+ with self.subTest(fixture["path"]):
+ self.assertEqual([], XML_COMMENT.findall(body))
+
+ def test_should_not_name_a_ground_truth_node_in_any_attribute(self) -> None:
+ for fixture, body in self.variants():
+ labels = set()
+ for graph in fixture["graphs"]:
+ text = COMMENT.sub("", (ROOT / graph["ground_truth"]).read_text(encoding="utf-8"))
+ labels.update(GT_NODE.findall(text))
+ values = ATTRIBUTE.findall(body)
+ for label in labels:
+ with self.subTest(fixture=fixture["path"], label=label):
+ self.assertFalse(
+ [v for v in values if label.lower() in v.lower()],
+ "a node label leaked into an attribute value",
+ )
+
+ def test_should_keep_every_node_label_as_rendered_text(self) -> None:
+ # Stripping must remove metadata only. If a label vanished from the drawing itself the
+ # variant would be measuring a different graph.
+ for fixture, body in self.variants():
+ if fixture["path"].endswith("mermaid_flow.svg"):
+ continue # mermaid wraps labels in HTML, which splits them across elements
+ for graph in fixture["graphs"]:
+ text = COMMENT.sub("", (ROOT / graph["ground_truth"]).read_text(encoding="utf-8"))
+ for label in GT_NODE.findall(text):
+ # A multi-line label is one node but several elements, so each line
+ # is looked for on its own.
+ for word in label.replace("\\n", " ").split():
+ with self.subTest(fixture=fixture["path"], word=word):
+ self.assertIn(word, body)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_strip_svg_graph_metadata.py b/scripts/test_strip_svg_graph_metadata.py
new file mode 100644
index 0000000..8a6dbe7
--- /dev/null
+++ b/scripts/test_strip_svg_graph_metadata.py
@@ -0,0 +1,119 @@
+"""Tests for `strip_svg_graph_metadata.py`.
+
+The script exists to stop a fixture from stating its own answer, so what these assert is that
+the answer really is gone, that the drawing still renders the same, and that an element's type
+survives -- knowing a stroke is a connector is not knowing what it connects.
+"""
+
+from __future__ import annotations
+
+import unittest
+
+from strip_svg_graph_metadata import strip
+
+GRAPHVIZ_NODE = (
+ '\na\n\n\n'
+)
+GRAPHVIZ_EDGE = (
+ '\n'
+ "a->b\n"
+ '\n'
+ "\n"
+)
+GRAPHVIZ_CLUSTER = (
+ '\n'
+ "cluster_ingest\n"
+ '\n'
+ "\n"
+)
+GRAPHVIZ_ROOT = '\nwhole_graph\n'
+
+MERMAID_EDGE = (
+ ''
+)
+PLANTUML_EDGE = (
+ ''
+ ''
+)
+
+
+class TitleStrippingTest(unittest.TestCase):
+ def test_should_remove_the_node_id_from_a_node_group(self) -> None:
+ self.assertNotIn("", strip(GRAPHVIZ_NODE))
+
+ def test_should_remove_the_edge_endpoints_from_an_edge_group(self) -> None:
+ stripped = strip(GRAPHVIZ_EDGE)
+ self.assertNotIn("a->b", stripped)
+ self.assertNotIn("", stripped)
+
+ def test_should_remove_the_cluster_name_so_a_container_is_not_labelled_as_one(self) -> None:
+ self.assertNotIn("cluster_ingest", strip(GRAPHVIZ_CLUSTER))
+
+ def test_should_keep_the_root_title_that_names_the_whole_drawing(self) -> None:
+ self.assertIn("whole_graph", strip(GRAPHVIZ_ROOT + GRAPHVIZ_NODE))
+
+
+class IdentifierStrippingTest(unittest.TestCase):
+ def test_should_remove_a_mermaid_edge_id_naming_both_endpoints(self) -> None:
+ stripped = strip(MERMAID_EDGE)
+ self.assertNotIn("L_start_auth_0", stripped)
+ self.assertNotIn("data-id", stripped)
+
+ def test_should_remove_a_plantuml_edge_id_naming_both_endpoints(self) -> None:
+ self.assertNotIn("Read config-to-Open input", strip(PLANTUML_EDGE))
+
+ def test_should_keep_an_id_the_document_points_at_with_url(self) -> None:
+ marker = ''
+ self.assertEqual(marker, strip(marker))
+
+ def test_should_keep_an_id_the_document_points_at_with_href(self) -> None:
+ used = ''
+ self.assertEqual(used, strip(used))
+
+ def test_should_keep_an_id_a_stylesheet_selector_points_at(self) -> None:
+ styled = ''
+ self.assertIn('id="my-svg"', strip(styled))
+
+ def test_should_keep_an_opaque_id_that_names_nothing_but_is_referenced(self) -> None:
+ # LibreOffice numbers its shapes id1..idN and refers to them from clip paths.
+ libreoffice = ''
+ self.assertEqual(libreoffice, strip(libreoffice))
+
+
+class CommentStrippingTest(unittest.TestCase):
+ def test_should_remove_a_graphviz_comment_restating_an_edge(self) -> None:
+ self.assertNotIn("a->b", strip("\n" + GRAPHVIZ_EDGE))
+
+ def test_should_remove_a_plantuml_comment_restating_an_edge(self) -> None:
+ commented = "" + PLANTUML_EDGE
+ self.assertNotIn("Read config", strip(commented))
+
+ def test_should_remove_the_producer_banner_because_provenance_lives_in_the_manifest(self) -> None:
+ banner = "\n"
+ self.assertEqual("", strip(banner))
+
+
+class PreservationTest(unittest.TestCase):
+ def test_should_preserve_every_byte_of_geometry(self) -> None:
+ stripped = strip(GRAPHVIZ_NODE + GRAPHVIZ_EDGE + GRAPHVIZ_CLUSTER)
+ self.assertIn('points="0,0 1,1"', stripped)
+ self.assertIn('d="M63,-234.8C63,-227.16 63,-208.24"', stripped)
+ self.assertIn('points="8,-148 8,-297 84,-297"', stripped)
+
+ def test_should_keep_the_class_that_gives_an_element_its_type(self) -> None:
+ for original in (GRAPHVIZ_NODE, GRAPHVIZ_EDGE, MERMAID_EDGE, PLANTUML_EDGE):
+ stripped = strip(original)
+ self.assertIn("class=", stripped)
+
+ def test_should_keep_the_text_a_node_is_labelled_with(self) -> None:
+ labelled = 'aStart'
+ self.assertIn("Start", strip(labelled))
+
+ def test_should_leave_a_hand_authored_svg_without_graph_metadata_untouched(self) -> None:
+ hand_authored = ''
+ self.assertEqual(hand_authored, strip(hand_authored))
+
+
+if __name__ == "__main__":
+ unittest.main()