Skip to content

veraPDF-checked PDF/UA-1 claims - #4

Merged
bbertucc merged 11 commits into
mainfrom
mvp-hardening
Sep 24, 2026
Merged

bbertucc merged 11 commits into
mainfrom
mvp-hardening

Conversation

@bbertucc

Copy link
Copy Markdown
Member

Running veraPDF over the tagged fixtures found three gaps. This fixes them and keeps them fixed.

  • Overlay CIDFonts now carry CIDToGIDMap /Identity (PDF/UA-1 7.21.3.2).
  • Form fields always get a name: the HTML label, an existing TU, the caption, or the last part of the field name.
  • A source font with no embedded program is reported as font_not_embedded, and the output does not claim PDF/UA-1. The source drawing stays as it was.
  • test/pdfua.test.ts tags every fixture and checks that each PDF/UA-1 claim passes veraPDF and that the fixtures able to conform do claim it. CI installs veraPDF through Homebrew, and in CI the test is never skipped.
  • New text-embedded fixture; blank-page now embeds its fonts.

🤖 Generated with Claude Code

…ource fonts

veraPDF found three conformance gaps in the tagged fixtures:
- the overlay's TrueType CIDFonts lacked CIDToGIDMap /Identity
- a widget with no HTML label and no TU had no accessible name
- source fonts without an embedded program; now warned as
  font_not_embedded and the claim is withheld

test/pdfua.test.ts tags each fixture and runs veraPDF; CI installs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

1. The new veraPDF tests hard-fail in this repo's own Code Review workflow. test/pdfua.test.ts:12

const noVera = !process.env.CI && !!spawnSync("verapdf", ["--version"]).error && "veraPDF is not installed";

.github/workflows/code-review.yml:299 runs npm test, and that job installs only Tesseract — no veraPDF. CI is set, so the skip is refused, checkPdfUa returns passed: null, and four tests fail. This is the fail in this review's check summary:

✖ text-embedded: claims PDF/UA-1 and passes veraPDF
  AssertionError [ERR_ASSERTION]: veraPDF is not installed; PDF/UA-1 was not checked.
  null !== true

(also blank-page, scan-300dpi, scan-skewed). The Test workflow itself passes — brew install verapdf works, run 35937412991 is green — so what breaks is every other CI without veraPDF: the review job on this and every future PR, plus any fork or contributor runner with CI=1. Install veraPDF in code-review.yml as well, or gate on a dedicated variable (IRIS_REQUIRE_VERAPDF=1 set only in test.yml) instead of generic CI.

2. /MK/CA is not a caption on a check box or radio button, so the field is announced as "4". src/tag.ts:345-346

const caption = w.widget.getObject().get("MK", "CA");
const name = label || (caption.isString() && caption.asString()) || w.name.split(".").at(-1)!;

For a push button /MK/CA is the caption. For a check box or radio button it is a ZapfDingbats character code selecting the check style — 4 check, 8 cross, l circle (PDF 32000-1 Table 189). Input that reaches it: any Acrobat- or LibreOffice-authored form whose check box carries /MK<</CA(4)>> and whose HTML gives it no label — an unlabeled <input>, or the field_not_in_html path at src/tag.ts:239, which now calls nameField(w, "", ctx.doc). /TU becomes 4, so a screen reader announces "4" where the field-name fallback would at least have said consent. Take /MK/CA only for push buttons (FT Btn with /Ff bit 17). No test covers the caption branch — test/forms.test.ts:93 only exercises the field-name fallback, and the fixture has no /MK.

3. Unbounded recursion on an untrusted PDF. src/pdf/fonts.ts:253-268 — resources() → font() → resources() walks nested form XObjects with no depth cap; once() only dedupes by object number, it does not bound depth. Verified: a PDF with 40,000 nested form XObjects, each with its own /Resources, makes unembeddedFonts die with RangeError: Maximum call stack size exceeded, so tag() crashes instead of refusing cleanly. too_many_pages caps pages at 25; object count is uncapped, and one page is enough. Add a depth limit.

Non-blocking notes

  • src/tag.ts:346: a widget with no /T anywhere gives widget.getName() === "", so "".split(".").at(-1) is "" and /TU is written as an empty string — an empty announced name where before there was none at all. Reached by a source PDF with an unnamed widget.
  • nameField's "keep the source's /TU" branch has no test either.
  • README.md:100 still reads "Checking the whole corpus in CI is not done yet", contradicting README.md:79 and test/pdfua.test.ts added in this same PR.

Accessibility impact: the veraPDF gate and CIDToGIDMap fix are real improvements, but an unlabeled check box now gets "4" as its announced name, so some forms come out less usable than before.

bbertucc and others added 3 commits September 23, 2026 20:30
Tagging a 25-page scanned report with Iris HTML failed the text check.
- HTML words with no page word were piled at one spot, squeezed to a
  tenth of their width; extractors merged their repeated letters. They
  now follow each other at natural width and wrap inside the page.
- A word in a very narrow OCR box (sideways text) shrinks its size
  rather than squeezing under half its width, for the same reason.
- A character no font has is left out of the overlay, not drawn as
  .notdef, which has no Unicode (PDF/UA-1 7.21.7).
- A footnote list item keeps LI > LBody, with the Note inside it (7.2).
- Page drawings with leftover marked-content ids are reported as
  source_marked_content and stop the PDF/UA-1 claim (7.1).

From the review:
- /MK /CA names only push buttons; on a check box it is the mark glyph.
- An empty field name writes no /TU.
- Resource walks stop at a nesting depth of 32.
- veraPDF is required where IRIS_REQUIRE_VERAPDF is set, not on any CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… content

veraPDF 7.3-1 failed on a real Iris page: a chart <figure> holding only a
figcaption and a data table became a Figure with no Alt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every real bench chunk has a broken xref. mupdf repairs it on open, and the
checks render the original through the same repair, so a full rewrite is
checked like an update. Encryption is kept. Warning: repaired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review (7a6acda): all three blocking findings are fixed — the veraPDF tests now gate on IRIS_REQUIRE_VERAPDF and skip in code-review.yml; /MK/CA is taken only for push buttons; unembeddedFonts and the new pagesWithMcids are depth-capped and tested. The empty-/TU and README.md:100 notes are fixed too. The Test workflow is green at head with veraPDF installed.

Blocking

nameField's field-name fallback truncates any name containing a period. src/tag.ts:362

const name = label || (push && caption.isString() && caption.asString()) || w.name.split(".").at(-1);

w.name is widget.getName(), the fully qualified name, whose parts are joined with .. Splitting on . to strip the parent prefix also cuts a partial name that contains one. Verified against form-acroform.pdf with one field's own /T set to Date of birth (dd.mm.yyyy) and HTML naming no field, so every field takes the field_not_in_html path at src/tag.ts:241:

"applicant.name"             -> TU: "name"
"Date of birth (dd.mm.yyyy)" -> TU: "yyyy)"

A screen reader announces "yyyy)". Periods in partial field names are ordinary in hand-authored, Word and LibreOffice forms (Date (dd.mm.yyyy), Item 3.a, U.S. citizen). w.field is already the terminal field dictionary, so its own partial name is w.field.get("T").asString() — use that rather than splitting the qualified name. No test covers a dotted name: test/forms.test.ts only asserts applicant.consent -> consent and reset -> reset, where splitting happens to be right.

This is the same line as finding 2 of the review on 7a6acda; the /MK/CA half of that finding is fixed.

Non-blocking notes

Overlay.word emits NaN into the content stream when a page word has size 0. src/pdf/content.ts:92-93

The replaced guard was natural = advances * size; h = natural > 0 ? … : 1, which covered size === 0. The new guard tests unit (advances only), so wide / (unit * size) is 0/0:

q 3 Tr
BT NaN NaN 0 -1 100 110 Tm /IrisF0 0 Tf <0049…> Tj ET

Reached end to end by a source page that draws text at an effective size of 0 (BT /F1 0 Tf 72 300 Td (Parking Permit) Tj ET, or a zero-scale Tm): textLayerWords reports size: 0, the matched HTML word inherits it through placed(), mupdf logs warning: unknown keyword: 'NaN', and the run exits 2 text_lost. Nothing corrupt is written, because the text gate catches it — that is the only thing keeping this latent. Guarding on unit * size > 0 restores the old behaviour.

A figure that does have image text still hides its caption and any table. src/html/build.ts:110

kids(fig, { ...inner, inFigure: true }) keeps Caption and any nested <table> as descendants of a Figure carrying /Alt. AT reads /Alt in place of the subtree, so a data table beside a chart is reachable in the HTML but not in the PDF. The no-alt branch added here fixes only the other half of the same problem.

unembeddedFonts does not look at the AcroForm /DR. src/pdf/fonts.ts:80-83

Only page, form-XObject and /AP /Resources are walked. A widget appearance stream with no /Resources of its own, resolving its font through Root/AcroForm/DR/Font, leaves an unembedded font unreported; if it is the only one, the output claims PDF/UA-1 and veraPDF rejects it. No fixture has such a widget, so nothing in the corpus reaches it.

Accessibility impact: the veraPDF gate, CIDToGIDMap, and the figure and footnote-LBody fixes are real gains, but a form field whose own name contains a period now gets a truncated fragment of it written as the name a screen reader announces.

…rm DR fonts

- A field unnamed in the HTML takes its own /T, so a period in it is kept.
- A word drawn at size 0 no longer writes NaN.
- <figure> is a Div; its img is the Figure, so caption and tables stay content.
- Fonts in the AcroForm /DR are checked for embedding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous reviews: all four earlier findings are fixed at this head — the dotted field name (inherited(w.field, "T"), asserted by Office (dd.mm) in test/forms.test.ts), the size-0 NaN matrix, the figure that hid its caption and table, and the AcroForm /DR gap (note 2 below is the new half of that last one). All checks pass here, and test is green at head with veraPDF installed.

Non-blocking notes

1. A stream pagesWithMcids cannot decode aborts the whole run. src/pdf/content.ts:25

const marked = (s: mupdf.PDFObject) => /\/MCID\b/.test(s.readStream().asString());

readStream() throws where rendering does not, and nothing catches it, so tag() leaves through a raw mupdf Error and src/cli.ts:95 turns it into internal_error — exit 3, a code the README's table does not list. Verified on a valid, non-damaged 292 KB PDF: text-simple.pdf with one extra page content stream that decompresses to 300 MB.

300MB pad -> file 292701 bytes
  tag THREW: Error | compression bomb detected

Before this PR the same file tags normally: nothing decoded page content, and page.toPixmap streams it without tripping mupdf's bomb check (verified separately). At a 100 MB pad it still tags, i.e. the page stream is materialised as a JS string. Wrapping both new scans so an undecodable stream becomes a warning ("not checked") rather than an abort also bounds that allocation.

2. Every /DR font counts as used, so a conformant form can lose the PDF/UA-1 claim. src/pdf/fonts.ts:84

resources(doc.getTrailer().get("Root", "AcroForm", "DR"), 0); // fonts a field appearance can name

PDF/UA-1 7.21.4.1 is about fonts used for rendering; /DR is a pool, and Acrobat writes /Helv and /ZaDb into it in every form whether or not an appearance names them. test/tag.test.ts:95 pins the over-reach: a document with Fields: [] and a /DR /Helv reports font_not_embedded, so setDocumentInfo drops the claim on a file veraPDF would accept. Reached by a form whose widget appearance streams carry their own embedded fonts, and by --flatten, where the fields are baked away but /DR stays. Narrower than it looks — most real appearances do name the /DR font, and then the report is right — but resolving the font through the appearance that uses it would avoid dropping the claim for files that deserve it.

3. The new veraPDF gate validates only headings, paragraphs and scans. test/pdfua.test.ts:18

No fixture HTML contains a <table>, <ul>/<ol> or <figure> (checked every test/fixtures/*.pages.json), and the two that hold a link or a form (links, form-acroform) are pinned to conforms: false — not because their structure is wrong but because their fixture fonts are unembedded. So the four outputs veraPDF sees are two heading/paragraph pages and two scans, and the two structure changes in this PR (figure -> Div with a Caption sibling, LI > LBody > Note) are never seen by the tool the PR is named after. Embedding the fonts in links, form-acroform and cjk the way text-embedded now does, plus one fixture with a table, a list and a figure, would make the gate cover what it is meant to keep fixed.

4. The depth-32 caps fail open on the claim. src/pdf/content.ts:28, src/pdf/fonts.ts:68

Past depth 32 both scans report nothing, so the output still declares PDF/UA-1. test/tag.test.ts:362 asserts that outcome: a page with an /MCID behind 20 000 nested form XObjects yields [], "past the depth limit, not searched", and such a file would claim conformance that veraPDF then rejects. Reachable only with a hand-built file; failing closed (cap reached => treat as found) costs nothing.

5. A repaired file is checked against its own repair. src/pdf/document.ts:51, src/tag.ts:118

The pixel and text checks render the input through the same mupdf repair that produced the output, so visible content the repair dropped cannot be detected, and repaired is not in STRICT, so --strict accepts it too. Since this replaces an outright refusal, repaired looks like it belongs in STRICT.

Accessibility impact: the PDF/UA-1 claim is now gated on something real and the figure and footnote shapes are better than before; what is left is a conformant form quietly losing its claim to a /DR font nothing renders with, and a gate that does not yet cover tables, lists, figures or forms.

…gures, notes and links

- An undecodable stream, or nesting past depth 32, withholds the PDF/UA-1 claim.
- /DR fonts count only for an appearance with no resources of its own.
- --strict fails on repaired.
- New structure fixture, embedded fonts, in the veraPDF corpus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous reviews: all five notes on b8a92c8 are fixed at this head — the undecodable stream no longer aborts the run, /DR fonts count only for an appearance with no /Resources of its own, both depth caps fail closed, repaired is in STRICT, and the new structure fixture puts a table, list, figure, Note and link in front of veraPDF. The Test workflow is green at 2c5da62 with veraPDF installed: 84 pass, 0 skipped. No blocking issues.

Non-blocking notes

1. A conformant PDF whose visible text contains "/MCID" loses the PDF/UA-1 claim. src/pdf/content.ts:25

return /\/MCID\b/.test(s.readStream().asString());

The regex runs over the whole decoded stream, so the four characters in a string operand count as much as a real BDC. Verified on text-embedded.pdf with one line added to the page — BT /F1 9 Tf 20 150 Td (Each BDC carries an /MCID entry.) Tj ET:

pagesWithMcids: [1]
warnings: ["source_marked_content","duplicate_text_layer"]
claims UA: false

Reached by real input: any PDF that prints "/MCID" — documentation about tagging, a spec excerpt. The file is still tagged, so this only costs the claim. Requiring a BDC/BMC nearby, or /MCID followed by whitespace and digits, would narrow it.

2. A font reachable only through a tiling pattern is never checked, so the claim is made on a file veraPDF rejects. src/pdf/fonts.ts:70

res.get("Font").forEach(...);
res.get("XObject").forEach((x) => { if (x.isStream() && x.get("Subtype").asName() === "Form" && once(x)) ... });

/Pattern is not walked. Verified on text-embedded.pdf plus a PaintType-1 tiling pattern whose own /Resources names a non-embedded /Helvetica and whose stream is BT /PHelv 8 Tf 0 0 Td (Paid) Tj ET, painted with q /Pattern cs /P0 scn 20 100 200 40 re f Q:

unembeddedFonts: []
warnings: ["duplicate_text_layer"]
claims UA: true

veraPDF's 7.21.4.1 check reads pattern content streams, so this is the remaining fail-open of the kind this commit closes elsewhere. Rare in real files (a lettered watermark or hatch), which is why it is a note.

3. The allocation half of the earlier decode note is still there — it now fails closed instead of aborting. src/pdf/content.ts:25

Each page's content stream is materialised as a JS string. A 105 KB file with 25 pages sharing one high-ratio stream costs ~400 MB peak RSS and 5 s in pagesWithMcids before mupdf's bomb check fires (measured); the throw is caught, so the outcome is a withheld claim, not a crash. Bounded by mupdf, so not blocking.

4. Forms are the one shape veraPDF still never sees. test/pdfua.test.ts:18

form-acroform is pinned conforms: false because its fixture fonts are unembedded, not because its structure is wrong, so nameField, the Form elements and the widget OBJRs — the part of this PR with the most churn — are checked only by this repo's own assertions. Embedding that fixture's fonts the way text-embedded now does would close the last gap.

Accessibility impact: the PDF/UA-1 claim is now gated by veraPDF over headings, paragraphs, scans and a page with a table, list, figure, footnote and link, and the things that cannot be checked withhold the claim instead of granting it; what is left is a font used only through a tiling pattern keeping a claim it should not, and a page that merely mentions "/MCID" losing one it deserves.

… tiling patterns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous notes on 2c5da62: notes 1 and 2 are addressed at this head — /MCID is now found by its BDC or by a named property list (with the gap below), and a tiling pattern's fonts are walked and tested. All checks pass at 7e43f26.

Non-blocking notes

1. The narrowed /MCID regex misses a real BDC, and the file then claims PDF/UA-1. src/pdf/content.ts:25

return /\/MCID\s+\d+[^()]*?>>\s*BDC/.test(s.readStream().asString());

[^()] stops the match at the first parenthesis, so a property list with a literal string after /MCID is not matched. Verified end to end on text-embedded.pdf, its page wrapped in one leftover sequence:

/P    <</MCID 0>> BDC …             -> pagesWithMcids [1], claims UA false  (caught)
/Span <</MCID 0 /ActualText (fi)>>  -> pagesWithMcids [],  claims UA true
/P    <</MCID 0 /Lang (en-US)>>     -> pagesWithMcids [],  claims UA true

In that output the leftover Span sits inside /Artifact BMC … EMC, and MCID 0 on the page resolves through the ParentTree to our H1 — the whole original drawing is both an artifact and the H1's content, and the file declares PDF/UA-1. A hex string (/ActualText<FEFF0066>) still matches, and a page is still caught as long as one of its BDCs is a bare <</MCID n>>; a page where every MCID-bearing BDC carries a literal string is a constructed file today, which is why this is a note. Same cause, and also a regression from /\/MCID\b/: a property list and its BDC split across two entries of the /Contents array gives [] (verified). Removing string literals from the stream and then testing /MCID\b would keep the false positive out without opening either.

2. pagesWithMcids does not scan tiling patterns, which unembeddedFonts now does. src/pdf/content.ts:41

A pattern stream holding /P <</MCID 0>> BDC … EMC gives pagesWithMcids -> [] (verified). Not a regression — patterns were never scanned — but the font half of the same gap was just closed, so the two checks no longer agree on where drawing can live.

3. Two earlier notes are unchanged; raised twice, so left to a human. Each page content stream is still materialised as a JS string in pagesWithMcids (src/pdf/content.ts:25, note 3 on 2c5da62), and form-acroform is still pinned conforms: false (test/pdfua.test.ts:18), so veraPDF never sees nameField, the Form elements or the widget OBJRs.

Accessibility impact: leftover marked content and pattern fonts are checked more precisely than before, but a BDC whose property list puts a literal string after its /MCID now slips past, and that output claims PDF/UA-1 with tagged content inside its artifact.

…ling patterns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous notes on 7e43f26: both are fixed at this head. /MCID is now found by
stripping string literals and testing /MCID\b over a page's streams joined
together, and tiling patterns are scanned — the three cases from that note
(/ActualText (…) after the id, a property list split across two /Contents
entries, a pattern that carries the BDC) are all caught, and pinned by
test/tag.test.ts:212-231. All checks pass, and the Test workflow is green at
05b1931 with veraPDF installed.

Non-blocking notes

1. An unmatched ( outside a string hides a real /MCID … BDC, and the output then claims PDF/UA-1. src/pdf/content.ts:41

} else if (c === "(") depth = 1;
else out += c;

withoutStrings treats every ( as a string start, including the ones inside
inline-image data (BI … ID <binary> EI) and comments, which are not string
context. A ( there swallows the stream until the next ) — and anything in
between, including a genuine leftover BDC. Verified end to end on
text-embedded.pdf, its page rewritten with two ordinary inline images around
one leftover tag sequence:

q 1 0 0 1 10 10 cm BI /W 2 /H 1 /CS /G /BPC 8 ID a( EI Q
/P <</MCID 0>> BDC BT /F1 9 Tf 20 150 Td <0001> Tj ET EMC
q 1 0 0 1 10 10 cm BI /W 2 /H 1 /CS /G /BPC 8 ID )b EI Q
withoutStrings keeps /MCID?  false
pagesWithMcids: []

So the page's own drawing keeps MCID 0 inside our /Artifact BMC … EMC, and the
file declares PDF/UA-1 — tagged content in an artifact, which 7.1 forbids and
veraPDF rejects. Nothing malformed is needed: the trigger is image bytes. One
image alone still fails closed (the stream ends at depth > 0, so the raw stream
is tested), so this needs a second ) later — a second inline image, or a
comment — which is why it is a note and not blocking. Skipping % comments and
ID … EI regions before stripping strings closes it.

2. The one fixture that carries a table, list, figure and footnote is not in TEXT. test/tag.test.ts:15

const TEXT = ["text-simple", "text-embedded", "text-two-column", "links", "form-acroform", "cjk", "blank-page"];

structure appears only in test/pdfua.test.ts:19, so the shapes this PR
changed (figure -> Div + sibling Caption, LI > LBody > Note) get the
veraPDF pass/fail verdict but never checkCorpus: no reading-order assertion, no
pixel or text-preservation check, no ParentTree walk. veraPDF does not check that
the reading order matches Iris's HTML, which is the thing those two changes move.
Adding "structure" to TEXT passes today — I ran checkCorpus's assertions
against it:

structure pixels: identical-outside-fields text: true
bytes untouched: true   lost: [0]   order equal: true

3. veraPDF is pulled unpinned at CI time. .github/workflows/test.yml:23

- run: /home/linuxbrew/.linuxbrew/bin/brew install verapdf && echo /home/linuxbrew/.linuxbrew/bin >> "$GITHUB_PATH"

The gate this PR is named after depends on whatever version Homebrew resolves
that day, so a veraPDF release can turn test red — or quietly change what
PASS means — with no commit here. Test-only, so no license or runtime-dependency
concern.

4. Two earlier notes are unchanged, and were raised twice; left to a human.
Each of a page's content streams is materialised as a JS string — now all of them
at once, since marked joins before stripping (src/pdf/content.ts:25) — and
form-acroform is still pinned conforms: false, so veraPDF never sees
nameField, the Form elements or the widget OBJRs (test/pdfua.test.ts:20).

Accessibility impact: leftover marked content is now found across a page's streams and in its patterns, and the corpus gate is real; what is left is a page whose inline-image bytes hide a leftover BDC, so it keeps a PDF/UA-1 claim with tagged content inside its artifact.

…tructure fixture through the corpus checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous notes on 05b1931: both are fixed at this head. withoutStrings now skips % comments and ID … EI inline-image data before stripping string literals, so image bytes and comments no longer hide a leftover BDC (test/tag.test.ts:249-250 pins both), and structure is in TEXT (test/tag.test.ts:15), so the Div + Caption figure and the LI > LBody > Note footnote now go through checkCorpus — pixels, text preservation, reading order, ParentTree walk — as well as veraPDF. All checks pass.

Non-blocking notes

1. A non-embedded font that is only listed in a page's /Resources, and draws nothing, costs the output its PDF/UA-1 claim. src/pdf/fonts.ts:60

res.get("Font").forEach((f) => { if (f.isDictionary() && once(f)) font(f, depth); });

Every /Font entry is judged, whether or not any text selects it. PDF/UA-1 7.21.4.1 is about the fonts used to render text, so a file with an unused /Times-Roman left in a shared resource dictionary is conformant and still loses the claim. Verified on text-embedded.pdf with one unused entry added to the page's /Resources /Font:

unembeddedFonts: [ 'Times-Roman' ]
warnings: [ 'font_not_embedded', 'duplicate_text_layer' ]
claims UA: false

Reached by real input: producers that share one resource dictionary across pages (this repo's own textDoc in test/fixtures/make.ts does), or that leave a font behind after an edit. It fails closed — the file is still tagged — which is why it is a note; narrowing it means tracking which fonts the content streams actually select.

2. pagesWithMcids does not look in annotation appearance streams, which unembeddedFonts does. src/pdf/content.ts:41

A source whose tag tree was stripped keeps the /MCIDs in its widget and link appearances. Verified on text-embedded.pdf with one link annotation whose /AP /N is /P <</MCID 0>> BDC BT ET EMC:

pagesWithMcids (annot AP): []
warnings: [ 'unmatched_link', 'duplicate_text_layer' ]
claims UA: true

MCID 0 in that stream resolves through the annotation's new /StructParent to our Link, whose /K holds only an OBJR, so nothing owns it. Unlike the page case it is not inside our artifact, so 7.1 is not breached, and I could not run veraPDF here to see whether its orphan-MCID check rejects it — hence a note, not the same finding as the tiling-pattern one you just closed. The two halves of the gate otherwise agree on where drawing can live (page contents, form XObjects, tiling patterns); this is the one place they do not.

3. veraPDF is still pulled unpinned at CI time. .github/workflows/test.yml:23 — unchanged since 05b1931. A veraPDF release can turn test red, or quietly change what PASS means, with no commit here. Test-only.

4. Two notes raised twice are unchanged; left to a human. Each of a page's content streams is materialised as a JS string, all of them at once (src/pdf/content.ts:25), and form-acroform is still pinned conforms: false (test/pdfua.test.ts:20), so veraPDF never sees nameField, the Form elements or the widget OBJRs.

Accessibility impact: the marked-content check now reads a page's drawing the way a reader does — comments, inline images and strings are not operators — and the fixture carrying a table, list, figure, footnote and link is now held to Iris's reading order as well as to veraPDF; what is left is a claim withheld over a font that draws nothing, and a leftover /MCID in an annotation appearance that is never looked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous note 2 on 01a2a67 (a leftover /MCID in an annotation appearance was never looked for) is fixed at this head: pagesWithMcids now walks each annotation's /AP, pinned by test/tag.test.ts:391-394. All checks pass. The new walk brings one blocking issue with it.

1. A cyclic /AP dictionary sends both new scanners into unbounded recursion; the run dies with internal_error. src/pdf/content.ts:56-60 and src/pdf/fonts.ts:86-89

The /AP walk added in 8f95e2f has no depth limit and no visited set — unlike every other walk in the same two functions (forms depth 32, resources depth 32, and forEachField/inherited 32 elsewhere):

const ap = (a: mupdf.PDFObject) => {
  if (appearance) return;
  if (a.isStream()) appearance = marked(a) || forms(a.get("Resources"), 1);
  else if (a.isDictionary()) a.forEach(ap);
};

unembeddedFonts has the same hole:

const appearance = (ap: mupdf.PDFObject) => {
  if (ap.isStream()) return resources(ap.get("Resources").isDictionary() ? ap.get("Resources") : dr, 0);
  if (ap.isDictionary()) ap.forEach(appearance);
};

get resolves indirect references, so an appearance dictionary that contains itself — or a chain of a few thousand nested dictionaries — recurses forever. The input is text-embedded.pdf with one annotation whose /AP is an object holding itself (ap.put("N", ap)):

pagesWithMcids THREW: RangeError Maximum call stack size exceeded
unembeddedFonts THREW: RangeError Maximum call stack size exceeded

and end to end through the CLI on that file:

$ node src/cli.ts tag --pdf cycle.pdf --pages test/fixtures/text-embedded.pages.json --out out.pdf
iris-pdf: internal_error: Maximum call stack size exceeded
exit=3

It fails closed — nothing is written — but an attacker-supplied PDF turns a tag run into an internal_error that is in neither the README's exit table nor the refusal list, and tag() used as a library throws a bare RangeError with a half-filled report. test/tag.test.ts:390 ("deeply nested form XObjects do not overflow the stack") already pins the analogous guard for the XObject walk; the /AP walk in both files needs the same depth/seen guard, with a test whose input is a self-referential /AP. mupdf's own asJS carries a seen map for exactly this reason.

Non-blocking notes

2. A non-embedded font that is only listed in a page's /Resources, and draws nothing, still costs the output its PDF/UA-1 claim. src/pdf/fonts.ts:60 — unchanged since 01a2a67. Every /Font entry is judged, whether or not any text selects it, so a file with an unused /Times-Roman in a shared resource dictionary is PDF/UA-conformant on 7.21.4.1 and still loses the claim. Fails closed (the file is still tagged), and narrowing it means tracking which fonts the content streams select.

3. veraPDF is still pulled unpinned at CI time. .github/workflows/test.yml:23 — raised on 05b1931 and 01a2a67; pushed past twice, so left to a human. A veraPDF release can turn test red, or quietly change what PASS means, with no commit here. Test-only.

4. Two notes raised repeatedly are unchanged; left to a human. Each of a page's content streams is materialised as a JS string, all of them at once (src/pdf/content.ts:25), and form-acroform is still pinned conforms: false (test/pdfua.test.ts:20), so veraPDF never sees nameField, the Form elements or the widget OBJRs.

Accessibility impact: leftover marked content is now found in annotation appearances as well as page contents, forms and patterns, so fewer files keep a false PDF/UA-1 claim; the cost is that a PDF with a self-referential /AP is now refused with an internal error rather than tagged.

…t overflow the stack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issue 1 on 8f95e2f (a cyclic /AP sent both new scanners into unbounded recursion, so a tag run died with internal_error) is fixed at this head: both appearance walks are bounded to /AP's two dictionary levels — src/pdf/content.ts:59 and src/pdf/fonts.ts:86 — and only the dictionary branch is limited, so /AP → /N → /<state> streams are still reached at every legal depth. test/tag.test.ts:264-269 pins it with a /AP object that holds itself (self.put("N", self)), asserting both pagesWithMcids and unembeddedFonts return empty rather than throwing. All checks pass.

Non-blocking notes

1. A non-embedded font that is only listed in a page's /Resources, and draws nothing, still costs the output its PDF/UA-1 claim. src/pdf/fonts.ts:70 — unchanged since 01a2a67.

res.get("Font").forEach((f) => { if (f.isDictionary() && once(f)) font(f, depth); });

Every /Font entry is judged, whether or not any text selects it, so a file with an unused /Times-Roman in a shared resource dictionary is conformant on 7.21.4.1 and still loses the claim. Fails closed (the file is still tagged); narrowing it means tracking which fonts the content streams select.

2. veraPDF is still pulled unpinned at CI time. .github/workflows/test.yml:23 — raised on 05b1931, 01a2a67 and 8f95e2f; pushed past three times, so left to a human. A veraPDF release can turn test red, or quietly change what PASS means, with no commit here. Test-only, so no license or runtime-dependency concern.

3. Two notes raised repeatedly are unchanged; left to a human. Each of a page's content streams is materialised as a JS string, all of them at once (src/pdf/content.ts:28), and form-acroform is still pinned conforms: false (test/pdfua.test.ts:20), so veraPDF never sees nameField, the Form elements or the widget OBJRs.

Accessibility impact: leftover marked content and unembedded fonts are now found in annotation appearances as well as page contents, form XObjects and tiling patterns, without a malformed /AP being able to kill the run, so fewer files keep a false PDF/UA-1 claim and none is refused for it.

@bbertucc
bbertucc merged commit 3c78a44 into main Sep 24, 2026
2 checks passed
@bbertucc
bbertucc deleted the mvp-hardening branch September 24, 2026 01:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant