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:
| Defect | Why the gate passes it |
|---|---|
| Table cells one column off | The grid is rectangular. 7.2-42 only requires uniform row width. |
| Footer tagged as a paragraph | A <P> is a valid element wherever it sits. |
<Link> with no content | The annotation has /Contents, which satisfies 7.18. |
| Heading levels inconsistent | 7.4.2 only forbids skipped levels, not wrong ones. |
| Title says the wrong year | DisplayDocTitle 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:
| Shape | Example from the report | Becomes |
|---|---|---|
| No letters at all | 2026 under the title | <P> |
| Short label ending in a colon | Fraternities: | <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:
- 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.
- 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. - 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:
/Kmay be a dictionary or an array, at the root and at every element./Pgcarries down the hierarchy; an element with bare integer MCID kids and no/Pgof 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_widthssummed each row’s owncol_spans, so every row under aRowSpanread 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 becomeTH/Row and an empty cell directly under a label extends that label’sRowSpan; 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
--checkpointfor any long document.FileCheckpointstores 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
#3137stall handler caughtanthropic.APITimeoutErrorand botocore’s timeouts, but a Bedrock stream that stalls after the response has begun raiseshttpx.ReadTimeoutfrom 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), andreport.jsoncarriescheckpoint: {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 readfresh: 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.