Skip to content

Tag-tree quality beyond PDF/UA conformance

On 2026-09-23 the USD Annual Security & Fire Safety Report (72 pp) came out of the long preserve lane veraPDF PDF/UA-1 compliant — 262,169 checks passed, 0 failed — with a tag tree that described the document wrongly in a dozen ways. Its tables read every figure under the wrong heading. Its page footer was tagged as body content on 70 pages and as a heading on one, which put a footer in the document outline. All 109 of its links contained no text.

Nothing in the pipeline noticed, because nothing was looking.

This document records what we learned from that, so the next person does not have to rediscover it from a customer’s file.

The rule

A clean conformance gate is a floor, not a verdict. PDF/UA checks that the tree is well-formed. It cannot check that the tree is true. Every defect above is invisible to veraPDF by construction:

DefectWhy the gate passes it
Table cells one column offThe grid is rectangular. 7.2-42 only requires uniform row width.
Footer tagged as a paragraphA <P> is a valid element wherever it sits.
<Link> with no contentThe annotation has /Contents, which satisfies 7.18.
Heading levels inconsistent7.4.2 only forbids skipped levels, not wrong ones.
Title says the wrong yearDisplayDocTitle is set and a title exists.

So the pipeline reports these itself, in flyer_tagger/tree_findings.py. They are quality findings: they set needs_review, they never fail the gate. A finding we cannot auto-fix is worth far more than silence, because once veraPDF is green, silence is indistinguishable from correctness.

When you add a check there, make it conservative — report a pattern that is usually wrong, and say so in the message. A finding nobody trusts gets muted, and then it is worth less than nothing.

What counts as a heading

A heading is a label for the section that follows. Everything below follows from that one sentence, and the reason it is written down is that the USD report arrived with four different answers to the same question.

Issue #3477 asked for one rule for short bold labels — “Fraternities:”, “Sororities:”, “Resources:”. The answer:

A short bold label ending in a colon is a <P> with the label in <Strong>, not a heading.

Why that way round:

  • Nothing about the outline changes at “Fraternities:”. It names the list directly beneath it and the section carries on. A heading asserts that a new navigable division begins, and here that assertion is false.
  • Screen-reader users navigate by heading. Promoting every bold label puts one-line entries between the real sections, so the outline gets longer and tells you less.
  • WCAG 1.3.1 asks that visual emphasis be conveyed programmatically, and <Strong> does that. 2.4.6 and 2.4.10 are about headings that label sections, which these do not.
  • PDF/UA permits either, which is exactly why it has to be decided here rather than left to the gate.

The rule is falsifiable rather than a matter of taste: if the labelled block is itself divided into subsections, the label heads a section and is a heading. A label followed by a list or a single block of prose is not.

The full decision table lives in flyer_tagger/heading_shape.py, which is also where the thresholds and their measured justifications are. In short, an element tagged as a heading is demoted when it is:

ShapeExample from the reportBecomes
No letters at all2026 under the title<P>
Short label ending in a colonFraternities:<P> > <Strong>
Label: <sentence>Dating Violence: Violence committed by…<P>
Over 25 words and ending in sentence punctuation—<P>

Two of those deserve a note.

A colon does not make a run-in lead-in, and neither does a long tail. “Separate Campus Buildings and Property: USD Sioux Falls” is a real heading and appears three times; so is “Table 4: Reported Offenses on Campus and Non-campus Property by Year”, which has a nine-word tail. A colon lead-in is demoted only when the tail is long and one of two things corroborates it:

  • the line ends in sentence punctuation, or
  • the element that follows it in reading order is a <P> whose text begins with a lowercase letter — a sentence still in progress.

The second test is the one that matters on this document. A bold lead-in whose sentence wraps is tagged one element per line, so the heading element holds only its first line — “Dating Violence: Violence committed by a person who is or has been in a social relationship of” — and cannot end in a full stop. What gives it away is the next element: “a romantic or intimate nature with the victim.” A titled heading is followed by a capital letter, a number or a table. Without a following paragraph to consult, the fragment is left as a heading: the conservative direction.

This rule was first written on tail length alone (which demoted “Table 4: …”), then on punctuation alone (which stopped demoting two of the report’s three real run-ins, and they became bookmarks). Both versions passed every fixture. Fixtures for this rule must be copied from the built document’s element dump, not written as sentences.

Long headings are ordinary. Policy documents have them; this report has several over twenty words that are unambiguously headings. Length alone is not evidence, which is why the demotion needs the sentence ending as well.

A heading that wrapped is still one heading

Three headings came out as two elements each, split mid-phrase, because the line wrapped and each visual line was tagged separately. They are merged when all of the following hold: same level, adjacent among the page’s kids, the same font size, the first does not end a sentence, the first reached the right edge of the text column, and the second begins on the very next line at the same left margin.

The width test is the load-bearing one. Measured on this report, the split headings’ first halves are 93.0%, 95.5% and 97.3% of the column, while ordinary consecutive headings on the same pages are 16.9% and 32.3%. The populations do not overlap, so the threshold is not delicately placed.

Where this runs, and why it matters

Both the merge and the <Strong> wrap move marked content between elements. The ParentTree maps every MCID to its owning element, so it has to agree. The pass therefore runs per page, after the element loop and before the ParentTree is built — the same slot as the TOC conversion, for the same reason. Doing it later would mean rewriting ParentTree rows by hand.

/Strong is a standard structure type only from PDF 2.0. PDF/UA-1 validates against ISO 32000-1, where it is not, so the structure tree root carries a /RoleMap sending /Strong and /Em to /Span. Without it the gate fails clause 7.1 — which is how this was found.

Prefer the author’s structure to a rebuilt one

The single most useful thing we learned: the input usually already knows the answer. The USD source is an Acrobat PDFMaker (Word) export, and it tags all nine of its statistics tables as clean 7-column grids — exactly the fact our rebuild threw away and then got wrong.

This is the same principle as #3151 (“never rebuild a usable tree”) and #3218 (carry the author’s /Alt, /ActualText, /E, /Lang across a rebuild), applied one level deeper: even when the tree as a whole is not worth keeping, parts of it are authoritative.

source_table_prior.py is the pattern to copy:

  1. Take the narrowest thing. It takes the grid — column count, each cell’s row/col, its spans. Nothing else. The same file’s p.38 table is tagged as an 18-column scramble of sentence fragments, so the source’s semantics are not trustworthy even where its geometry is.
  2. Never downgrade. A cell the model marked as a header stays a header where the source says plain <TD>. The prior fixes registration; it does not undo improvements.
  3. Fail closed, loudly. Four gates, and a table failing any of them keeps the model’s grid and is reported. A prior firing on the wrong table would reintroduce exactly the defect it exists to prevent, so “leave it alone and say so” is always the safer branch.

Read the tree the way the spec writes it, not the way we emit it

walk_structure assumed /StructTreeRoot /K is a dictionary. ISO 32000-1 Table 322 types it “a dictionary or an array”, and PDFMaker emits the array form for any document with more than one top-level element — which is every Word export of any length.

Our build path always writes a single /Document dictionary. So the walk was correct for every document we produce and wrong for a large share of the documents we receive, and no test caught it because every fixture was built the way we build.

The lesson generalises. Anything that reads an INPUT document must handle the whole shape the spec allows, not the subset we happen to emit:

  • /K may be a dictionary or an array, at the root and at every element.
  • /Pg carries down the hierarchy; an element with bare integer MCID kids and no /Pg of its own is ordinary.
  • A third-party tree can be cyclic or share an element between two parents. Guard the walk.
  • An empty marked-content sequence (/P <</MCID n>> BDC EMC) is not content. Word emits one per empty cell and empty paragraph — 622 in that one report — and counting them as content a tree “failed to reach” understates coverage badly enough to change routing decisions.

When you write a fixture for input-side code, build it the way Word does, not the way we do.

A sublist belongs to an item, not beside its list

The report’s policy sections read “A. … 1) 2) 3) … B. … C. … 1) … D.” and came out as five sibling <L>s: parent, sublist, parent, sublist, parent. The model had already told them apart (different list_group, and the parent’s group resumes after the sublist); the emission loop opened a new top-level list at every group change and threw that away. ISO 32000-1 14.8.4.3.3 puts the sublist inside the <LBody> of the item it hangs from.

The nesting decision needs two signals at once (#3651): the new group’s items are indented at least SUBLIST_MIN_INDENT past the parent’s, and their marker style differs (“A.” → “1)”, “1)” → “a)”, ”•” → ”–”). Indentation alone is a block quote; a marker change alone is two consecutive lists. A parent resumes by list_group, never by position — a fresh group back at the parent’s indent is a new list. The adjacent-list merge (#3634) still runs afterwards and still refuses a restarted run, so the two passes do not overlap: nesting handles a different list inside an item, merging handles one list emitted as several.

A clean grid can still say the wrong thing

The crime-statistics tables passed every grid check and announced two of every three rows with no row header: the offence sat in the 2023 row and the 2024 and 2025 rows had an empty first cell (or, from the source grid, a cell spanning columns 1-2 that held only the year). The fire log did the same for buildings; a procedures table had one category over nine empty cells; the hate-crime tables’ only header was their title; footnotes sat in the grid as data rows.

Two things were wrong, and the second hid the first:

  • table_grid._row_widths summed each row’s own col_spans, so every row under a RowSpan read as short, the grid read as ragged, padding collided with the span, and the last-resort repair dropped every span in the table. Any row-header span the source grid or the model produced was flattened on its way to the tree, silently, with “registered” in the report. It now counts the positions a span from above covers.
  • Nothing put the spans there in the first place when the author had not. table_semantics.py (runs after the source-grid prior, before injection) does, from the grid plus page geometry: a leading column whose non-empty body cells are all text and include a row header is a label column; its non-empty cells become TH/Row and an empty cell directly under a label extends that label’s RowSpan; a non-header cell spanning columns whose text sits inside one measured column is that column’s cell; a last row that is one full-width prose cell becomes a <P> after the table; a first row that is one full-width header with no column headers under it becomes the table’s <Caption>. Data columns are never carried down — a blank under a value may be a blank, and asserting the value above for it would be a false statement about the data. A header band resets every run.

Still open on the USD tables (#3473/#3474): the N/A cells drawn as black fills but tagged empty (needs the page drawings), the two-column table tagged as one column on pp. 41-42 (needs column geometry), sentences continued across rows, and the list flattened inside a cell on p. 38.

Long-running work must be resumable

A single stalled Bedrock stream on page 40 ended that 72-page run and discarded forty pages of paid analysis, because the CLI never passed a checkpoint. The host lane — the one with no execution ceiling, and therefore the one running the longest documents — goes through that same path.

Two things follow:

  • Pass --checkpoint for any long document. FileCheckpoint stores completed page/band analyses so a re-run resumes instead of re-paying.
  • When you add a network call, check what the failure actually raises. The #3137 stall handler caught anthropic.APITimeoutError and botocore’s timeouts, but a Bedrock stream that stalls after the response has begun raises httpx.ReadTimeout from inside the SDK’s own stream decoder — past the layer that translates errors. The handler existed, was correct, and did not fire, on the one transport we use in production.
  • A checkpoint entry is only a checkpoint if it can be reused. Three of the USD report’s 78 units failed reuse validation on every run and were re-paid each time (#3655). The store had handed out its own dict, the band merge aliased its elements into the page analysis, and the repeat-text pass then stripped the running footer’s line ids out of them in place; the next put() wrote the mutilated entry to disk. Now: the store never shares an object with a caller (copies in and out), a fresh analysis that would not pass reuse validation is not stored (and says so), a reuse failure logs why, the key names the model that actually produced the entry (the Bedrock fallback substitution used to hide behind the requested id), and report.json carries checkpoint: {reused, fresh} per page so an A/B on “the same checkpoint” can see which pages were really re-analysed. On a warm run every page must read fresh: 0; anything else is not a same-analysis comparison.

Reproducing a tag-tree audit

The audit scripts used for the USD report live in ~/Downloads/usd-asr-audit/ (pikepdf + pdfminer; PDF=<path> to point them at a file). They predate the array-root fix and crash on a PDFMaker source, so for input-side work use a walk that normalises /K — flyer_tagger.tree_findings._walk is the tolerant one.

Never commit a customer PDF, or any page of one, as a fixture. Build a synthetic document that reproduces the pattern instead; every test added for this work does that.