Phase 8 #7 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md ("PDF/UA structure tree"). Design and merge criteria in REVIEWS/adr/0011-pdf-structure-tree.md. Investigating the accessibility gap surfaced four defects in the marked-content operators the structure tree depends on. The first is not an accessibility problem at all. 1. BMC never parsed, and corrupted the colour of everything after it. The dispatcher matched b'B'+b'M' only when the third byte was a delimiter; BMC's third byte is 'C', so the arm never fired. Because the operator was never recognised it never CLEARED ITS OPERAND, and the leftover /Tag shifted the operands of whatever came next: 1 0 0 rg -> RgbFill(1.0, 0.0, 0.0) red /Span BMC 1 0 0 rg -> RgbFill(0.0, 1.0, 0.0) green Tagged documents are precisely the ones containing BMC, so the documents that tried hardest to be accessible rendered wrong colours. This is the fifth instance of the operator-shadowing family already fixed for cm, rg, gs, b/b* and end-of-stream text operators. ADR 0009's every_multi_char_operator_parses_as_itself test exists to stop exactly this - and would have caught it, except BMC was one of two operators excluded from its table as "genuinely unimplemented". Excluding a known-broken operator from the test whose job is finding broken operators is how it survived. The exclusion list is gone. 2. BDC discarded its property list, which carries /MCID - the only link between a run of page content and the structure element describing it. Without it a tree can be parsed but never attached to anything. 3. The op produced by BDC was named MarkContentBmc, and BMC produced nothing. The names were the wrong way round, which is how the missing arm survived review: the enum looked like it had a BMC case. 4. Found while fixing 2: the content lexer had no dictionary support at all. It read `<` as a hex string without checking for a second `<`, so `<</MCID 0>>` parsed as the string "0C0D0". Content streams now parse direct objects properly, bounded at 16 levels; a single `<` is still a hex string and a test pins that. On top of that, new pdf-document/src/structure.rs: /StructTreeRoot, /StructElem trees, /RoleMap resolution, depth-first reading order, /Alt, /ActualText, /E, /Lang, and MCID-to-element lookup. Cycles in /K are cut at the first repeat and reported by object number rather than expanded to the depth bound. Accessibility findings are mechanical checks reported as findings, NOT a conformance verdict: there is no is_pdf_ua and no ComplianceReport, and a mutation-checked tripwire test fails if either appears. Real PDF/UA conformance needs human judgement - whether /Alt text is accurate is not mechanically decidable - so claiming it from six checks would be exactly the overclaim this codebase keeps removing. 7 corpus fixtures, 14 acceptance tests, 22 unit tests, and a parse_content_dict fuzz target for the new object parser. TEST_TARGET=pdf 536 -> 575, TEST_TARGET=pdf-ui 580 -> 619. rustfmt and clippy -D warnings clean.
9.7 KiB
ADR 0011: PDF/UA structure tree — tagged content, reading order, accessibility
- Status: Accepted
- Date: 2026-08-01
- Review item: Phase 8,
DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md("PDF/UA structure tree", 3–4 weeks, "Tagged PDF model, accessibility tree") - Supersedes: nothing
- Related: ADR 0009 (transparency — its operator-shadowing guard is what
should have caught the
BMCbug below, and now does)
Context
Nothing in this codebase reads a structure tree. /StructTreeRoot,
/StructElem, /MarkInfo, /Alt, /ActualText and /Lang appear
nowhere. A tagged PDF — one that has gone to the trouble of describing its
own reading order and semantics — is treated exactly like an untagged scan.
That is the accessibility gap the review names. But investigating it surfaced three defects in the marked-content operators that the structure tree depends on, and one of them is not an accessibility problem at all.
1. BMC does not parse, and corrupts the colour of everything after it
The dispatcher matches b'B' + b'M' only when the third byte is a
delimiter. For BMC the third byte is C, so the arm never fires and the
operator is never recognised. Because it is never recognised, its /Tag
operand is never cleared from the operand stack, and it shifts the
operands of whatever comes next:
1 0 0 rg -> RgbFill(1.0, 0.0, 0.0) red, correct
/Span BMC 1 0 0 rg -> RgbFill(0.0, 1.0, 0.0) green
A page containing a single BMC renders red as green, and every
subsequent operator with operands is shifted too. Tagged documents are
exactly the ones that contain BMC, so the documents that tried hardest to
be accessible are the ones that render with wrong colours.
This is the fifth instance of the operator-shadowing family already fixed
for cm, rg, gs, b/b* and the end-of-stream T* case. ADR 0009's
every_multi_char_operator_parses_as_itself test was written precisely to
prevent a fifth — and it would have caught this, except BMC was one of the
two operators deliberately excluded from its table as "genuinely
unimplemented". Excluding a known-broken operator from the test that exists
to find broken operators is how it survived. That exclusion is removed here.
2. BDC discards /MCID, which is the entire link to the structure tree
/P <</MCID 0>> BDC -> MarkContentBmc("P")
The properties dictionary is dropped. /MCID is the marked-content
identifier: it is the only thing connecting a run of page content to the
structure element that describes it. Without it a structure tree can be
parsed but never attached to anything, which would make the whole feature
decorative.
3. The op is named MarkContentBmc but is produced by BDC
BDC produces PdfOp::MarkContentBmc, and BMC produces nothing. The
names are the wrong way round, which is how the missing BMC arm stayed
invisible during review: the enum looked like it had a BMC case.
Decision
Fix the marked-content operators, then build the structure tree on top.
Operators
BMCgets an arm, clears its operand, and producesBeginMarkedContent.BDCproducesBeginMarkedContentProps { tag, properties }, retaining the properties dictionary, which may be inline or a/Propertiesresource name.- The misnamed
MarkContentBmc/MarkContentEmcvariants are removed rather than left as confusing aliases. BMCandBDCare added to ADR 0009's operator table, and the two "genuinely unimplemented" exclusions are removed, because an exclusion list on that test is a place for bugs to hide.
Structure tree, in pdf-document
- Parse
/StructTreeRoot:/K,/IDTree,/ParentTree,/RoleMap,/ClassMap. - Parse
/StructElemnodes recursively into a real tree, carrying/S(type),/P(parent),/Pg(page),/K(kids),/Alt,/ActualText,/E(expansion),/Lang,/T(title),/A//Cattributes. - Resolve
/RoleMapso a custom tag maps to its standard equivalent, and classify standard types into aStructRoleenum (document, part, headings H1–H6, paragraph, list, table, figure, link, and so on). - Extract the reading order: the depth-first order of the tree is the logical reading order, which is what a screen reader follows and is frequently different from the geometric order text appears in.
- Map
/MCIDto structure elements per page, so a content run can be asked "what am I part of".
Accessibility checks, reported not enforced
A small set of checks that are mechanically decidable from the tree:
- Is the document tagged at all (
/MarkInfo /Marked true)? - Does it declare a
/Lang? - Do figures have
/Alttext? - Is the heading hierarchy well-formed (no level skipped, e.g. H1 → H3)?
- Do table cells sit inside rows inside a table?
- Are there structure elements pointing at pages that do not exist?
These are reported as typed findings. They are not a PDF/UA conformance
claim: real conformance has dozens of requirements, many needing human
judgement (is this /Alt text accurate?), and asserting conformance from
six mechanical checks would be exactly the kind of overclaim this codebase
keeps removing. The type is named AccessibilityFindings, not
ComplianceReport, and there is no boolean called is_pdf_ua.
Not implemented, and not faked
- Writing or repairing tags. Generating a structure tree for an untagged document is a heuristic content-analysis problem, not a parsing one.
- PDF/UA conformance certification, per above.
- Reading order inference for untagged documents. If a document has no structure tree, the honest answer is "untagged", not a guess.
Non-negotiable rules
- No operator may be silently unrecognised. An unmatched operator that
leaves operands on the stack corrupts every operator after it; that is
how
BMCturned red into green. - The operator table test has no exclusion list. An operator that does not parse gets fixed or gets an explicitly failing test, not an omission.
- A cyclic or deep
/Kgraph must not recurse without bound. Structure trees are attacker-controlled and/P//Kcan point at each other. - No PDF/UA conformance claim from mechanical checks alone.
- An untagged document reports as untagged, never as an empty tree that looks like a tagged document with no content.
Merge criteria
BMCparses, clears its operand, and no longer shifts the operands of following operators.- A regression test proves
/Span BMC 1 0 0 rgstill fills red. BDCretains its/MCIDand properties dictionary.BMCandBDCare in ADR 0009's operator table and its exclusion list is gone./StructTreeRootparses into a tree with parent/child links./RoleMapmaps a custom tag to its standard role.- Reading order is extracted depth-first and differs from geometric order in a fixture where it should.
/Alt,/ActualText,/Eand/Langare exposed./MCIDresolves to its structure element.- A cyclic
/Kgraph terminates with a typed error rather than a stack overflow. - Accessibility findings: missing
/Alt, skipped heading level, absent/Lang, untagged document. - No API reports PDF/UA conformance, and a test asserts that absence.
- Corpus fixtures for a tagged document, a role-mapped document, a cyclic tree, an untagged document and an inaccessible one, generated by the checked-in script.
TEST_TARGET=pdf ./tools/test-rust-clean.shpasses, rustfmt and clippy-D warningsclean.
All criteria met. TEST_TARGET=pdf went from 536 to 575 and
TEST_TARGET=pdf-ui from 580 to 619.
A fourth bug, found while fixing the third
BDC's property list could not be read even once BDC retained it,
because the content-stream lexer had no dictionary support at all: it
treated < as the start of a hex string without checking for a second
<. <</MCID 0>> therefore parsed as the hex string "0C0D0", so every
property list in every document was unreadable. Content streams now parse
dictionaries, arrays, strings, names, numbers and booleans as direct
objects, bounded at 16 levels of nesting because the bytes are
attacker-controlled. A single < is still a hex string, and a test pins
that.
Cycle handling was tightened after first implementation
The first version relied on the depth bound alone, which terminated
safely but expanded a two-element cycle into 65 nonsense elements before
stopping. It now tracks the ancestor path and cuts at the first repeat,
reporting the object number through AccessibilityIssue::CyclicStructure
rather than pruning silently.
The nothing_claims_pdf_ua_conformance tripwire was mutation-checked:
adding an is_pdf_ua method to AccessibilityFindings fails it. It
skips comment lines, so the module documentation can go on explaining why
no such verdict exists without tripping the test that enforces it.
Consequences
Positive. The BMC fix repairs colour on every tagged document, which
is a rendering correctness win far outside accessibility. Screen-reader
reading order, alternative text and language become available to a host. The
/MCID map is the prerequisite for accessible text extraction and for
content redaction that respects structure.
Negative. PdfOp gains a variant and loses two misnamed ones, so
RecordingDevice and the Makepad device must handle it. That is the
correct cost of the enum having been wrong.
Risk. Treating the mechanical checks as a conformance verdict. Mitigated by rule 4, by the naming, and by a test asserting no conformance API exists.
Out of scope, deliberately: tag generation and repair, PDF/UA
certification, /AF associated files, MathML, and structure-aware
redaction.