Repository navigation
Always retag an already-tagged PDF - #8
Conversation
Someone tagging a PDF wants it tagged, so the already_tagged refusal is gone. --retag is still accepted, and ignored, for callers that pass it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
All checks pass and the two structural tests (document.test.ts:75, :114) do cover the new default path. No blocking defect found. Four latent items.
Non-blocking notes
1. A foreign tagged PDF can now never produce a PDF/UA output, by default, with no way to refuse. src/pdf/document.ts:57-61:
if (!root.get("StructTreeRoot").isNull()) {
// An already-tagged PDF is retagged: its old tags are replaced.
source.restored = untag(doc);Input that reaches it: iris-pdf tag --pdf authored-tagged.pdf --pages pages.json --out out.pdf, no flags. Any genuinely tagged PDF has /MCID in its page streams, so pagesWithMcids always fires source_marked_content and setDocumentInfo drops the PDF/UA claim — this repo's own test asserts exactly that (test/document.test.ts:114-122). The author's structure tree is gone, replaced by what Iris derived from page images, and a PDF/UA-1 conformant input comes out non-conformant.
That trade is this PR's stated intent, and Iris confirms upstream. The gap is that retagged is not in STRICT (src/tag.ts:33), so with already_tagged removed no flag refuses the destruction — --strict, documented as failing on warnings that mean content went untagged, passes. Either add retagged to STRICT and say so in the README, or state that refusing is deliberately no longer possible.
2. The verification gate is blind on the restore path, now reachable without a flag. src/tag.ts:42:
const baseline = src.restored ? save(doc, true) : pdf;The baseline is snapshotted after untag() mutated the pages, so whatever untag dropped is invisible to both comparePixels and compareText. The restore heuristic is a string match (src/pdf/document.ts:95-99): contents.length >= 4, body(0) === "/Artifact BMC q\n", body(len-2) === "\nQ EMC\n", then it keeps only indices 1..len-3 — dropping index len-1 on the assumption it is our overlay. A PDF that mimics that wrapper and puts visible drawing in the last content stream loses it silently, and the gate that exists to catch exactly this cannot see it. Needs a crafted file (the markers are ours), so latent — but before this PR it needed --retag as well. Taking the baseline from the input bytes, or diffing the restore against them, would close it.
3. README dropped the only mention of size growth. The deleted --retag row carried "each retag adds to the file's size", and the operation it described is now the default. Measured on text-simple: 1,002 → 14,885 → 28,930 → 43,116 bytes over three passes (+~14 KB each, incremental save over the previous bytes). Worth one clause on README.md:66.
4. Minor. src/pdf/document.ts:58 — the comment restates the next two lines and the warning's own detail text. test/cli.test.ts:43 — assert.ok(existsSync(twice)) is vacuous, the preceding no-flag run already wrote that path; asserting the --retag output matches the no-flag output would make the "still accepted and ignored" claim actually tested.
Accessibility impact: tagging an already-tagged PDF is now the default, so an authored or PDF/UA-conformant structure tree is replaced by Iris's image-derived one — and its leftover marked content means the output can never claim PDF/UA-1 — on a plain tag run, warned but with no flag that refuses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
All checks pass. The pixel half of my earlier note 2 is fixed: verify(pdf, baseline, out, …) now renders the untouched input, so the restore in untag() can no longer change a pixel unseen. Note 3 (README size growth) and note 4 (dead comment; the vacuous existsSync(twice), now a byte comparison against the no-flag output) are fixed.
Non-blocking notes
1. retagged is still not in STRICT — unchanged since cf58dce. src/tag.ts:35. With already_tagged gone, no flag refuses the replacement of an authored structure tree; --strict, documented as failing on warnings that mean content went untagged, passes. I have raised this twice and the head does not change it, so I leave the call to a human.
2. The text half of the gate is still blind on the restore path. src/tag.ts:443:
const text = compareText(open(baseline), after, added);`baseline` is still snapshotted after `untag()` mutated the pages, so text the restore dropped is invisible to it. Pixels now catch anything the dropped stream painted, which closes most of this — what is left is text that paints nothing but extracts (render mode 3, white on white) in the last content stream of a file crafted to mimic our `/Artifact BMC q\n` … `\nQ EMC\n` markers. Needs a crafted file, so latent. Diffing the restore against the input bytes would close it.
3. A page with no /Contents is never restored, and its dead overlay becomes tagged content inside an artifact. src/pdf/content.ts:14 returns [] when a page has no content streams, so src/tag.ts:114 writes /Contents as a one-element array holding only the overlay. src/pdf/document.ts:94:
if (!contents.isArray() || contents.length < 4) continue;skips that page, so the old overlay survives. The next run wraps it with artifactStreams, and its /MCID operators are now inside /Artifact — pagesWithMcids fires source_marked_content, the PDF/UA-1 claim is dropped for good, and the page carries two invisible text layers. Reaching it needs a page whose dict omits /Contents (legal, and some generators emit it) and HTML for that page; Iris sends no HTML for a blank page, and no fixture builds such a page (doc.addPage(…, "") always makes a stream), so it is latent. Before this PR it also needed --retag; now a second plain tag run gets there. Keying the restore on the markers rather than on contents.length >= 4 would cover it.
4. Minor. src/tag.ts:442-443 — in the common non-restored path baseline === pdf, so the same bytes are parsed into two mupdf.PDFDocuments every run. Neither comparator mutates its before, so one document can serve both: const before = open(pdf), base = src.restored ? open(baseline) : before;.
Accessibility impact: the retag's pixel check now renders the real input, so a restore that drops visible drawing fails the run; the remaining gaps are a crafted file's invisible text and a /Contents-less page whose stale overlay costs the PDF/UA claim.
Someone who tags a PDF wants it tagged, so this drops the
already_taggedrefusal. A tagged PDF now has its old tags replaced, as--retagdid in #7, and the report warnsretagged.--retagis no longer documented. It is still accepted and ignored, because Iris passes it after its confirm step (equalify-iris#502). It can go once Iris stops sending it.🤖 Generated with Claude Code