Retag an already-tagged PDF with --retag - #7
Conversation
A tagged PDF is still refused with already_tagged, now pointing at --retag, so a caller can confirm with the user and run again. --retag drops the old tree; on our own output it also restores the original content, so overlays do not stack. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
All checks pass. One blocking issue.
Blocking
src/pdf/document.ts:75-93 — a retag never replaces the link descriptions a previous run wrote
untag() deletes StructParents and each annotation's StructParent, but leaves the /Contents string this tool writes onto Link annotations. Both places that write it only do so when it is still null:
// src/tag.ts:328
if (link.obj.get("Contents").isNull() && text) link.obj.put("Contents", e.doc.newString(text));
// src/tag.ts:237
if (l.obj.get("Contents").isNull()) l.obj.put("Contents", ctx.doc.newString(under || l.uri || "Link to another part of this document"));Input that reaches it: tag links.pdf, then retag that output with corrected HTML — the stated reason for --retag (Iris produced better HTML). Measured with the repo's own fixture, run 2's link text changed:
source annots [ 'Link:<none>' ]
run1 annots [ 'Link:the city website' ]
run2 annots [ 'Link:the city website' ] <- still run 1's text, new HTML ignored
The Link structure element is rebuilt from the new HTML, so the structure and the annotation now disagree, and nothing warns. By the same guard at :237, run 1's fallback description — the raw URI, or the English placeholder Link to another part of this document — sticks on an unmatched link even when run 2's HTML matches it, which is exactly the unmatched_link warning a retag is meant to clear. The placeholder also carries Lang: en on the element only on the run that wrote it, so in a non-English document the second run reads English text without saying so.
Nothing else in the PR has this problem: /TU on fields is rewritten when the HTML gives a label (src/tag.ts:359-365), the structure tree and alt text are rebuilt, and the XMP title is replaced. The write-once guard is right for a description the source author wrote and wrong for one we wrote. A fix: record on the annotation that this tool described it, and drop /Contents from those in untag().
Non-blocking notes
test/document.test.ts:96 — the "a new, empty tree" assertion cannot fail
assert.equal(doc.getTrailer().get("Root", "StructTreeRoot", "K").length, 0, "a new, empty tree");K is the Document element dictionary (8 0 R in this case), and mupdf's length is pdf_array_len: 0 for a dictionary, and 0 for a missing object. The fixture's own foreign tree is K: [] — also 0. The assertion passes whether the tree was replaced, left in place, or deleted outright. Assert the new tree, e.g. that K is a dict with /S /Document. The two StructParent assertions above it are sound.
Untested too: the PR body's claim that a foreign tagged PDF keeps source_marked_content and withholds the PDF/UA claim. The new fixture is a blank page with no marked content, so nothing checks that path.
Each retag grows the file
The output stays an incremental update, so every retag keeps the previous overlay streams and structure tree as dead bytes: structure.pdf 40,292 → 57,576 on one retag, and again on the next. sizeIncreaseBytes reports it, but a caller retagging in a confirm loop may want it said in the README's --retag row.
Verified as working, for what it narrows: retag of our own output on structure, text-two-column, form-acroform, mixed and encrypted (password kept) all reproduce the first run's structure exactly with differingPixels: 0, textPreserved: true, one overlay per page; untag()'s length < 4 skip is unreachable for our own output (a page with no /Contents cannot get an overlay — it refuses with no_text_positions); and the unbounded readStream().asString() in untag() adds no exposure the existing pagesWithMcids read does not already have.
Accessibility impact: retagging rebuilds the structure tree correctly, but a link's annotation description is frozen at whatever the first run wrote, so a wrong or placeholder link description — the thing --retag exists to fix — cannot be fixed.
Link /Contents written by this tool is marked, so a retag drops it and describes the link from the new HTML. An author's description stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
All checks pass. My earlier blocking finding is fixed, and both non-blocking notes are addressed.
Earlier findings
- Fixed —
src/pdf/document.ts:75-95:describe()now marks the/Contentsthis tool writes anduntag()deletes exactly those, so run 2's HTML replaces run 1's link text. Verified:tag(links.pdf)→"the city website", retag withthe city <a>website</a>→"website", and an author-written"Permits"survives. TheLang: enasymmetry on the generic placeholder goes away with it, sincegenericis recomputed once the marker's/Contentsis gone. - Fixed —
test/document.test.ts:102: the assertion now readsget("Root","StructTreeRoot","K","S").asName() === "Document", which a left-in-place or deleted tree fails. - Fixed — the foreign tagged PDF path is now tested (
source_marked_contentwarned, nopdfuaid:partin the output's XMP). - Fixed — the README
--retagrow now says the output is an update of the input and each retag adds to the file's size.
What I re-verified on this head, beyond the tests: the output is still a true incremental update after the mid-run save(doc, true) baseline — twice.pdf keeps all 14,432 bytes of once.pdf as its prefix; three generations of retag on structure, text-two-column, form-acroform and cjk reproduce run 1's report.structure byte for byte with one overlay per page and differingPixels: 0; and a page dropped from the new pages.json on a retag has both its overlay and its /StructParents actually removed in the incremental output (Contents 4 → 1, StructParents null), not left dangling into the new ParentTree.
Non-blocking notes
src/pdf/document.ts:76 — the private key is not a second-class name
const DESCRIBED = "IrisPdfContents";This ships in every link annotation we describe (confirmed in the output: IrisPdfContents true). PDF 32000-1 §7.12.2 asks that a private key added to a standard dictionary use a registered four-character prefix, e.g. /IRIS_Described, so it cannot collide with a future standard key in the annotation dictionary. Not a PDF/UA-1 conformance rule, and I could not check veraPDF here (not installed; those tests skip).
A PDF tagged by an earlier build still keeps its old link description
The deletion is gated on the marker, which only exists from this commit on. Retagging output produced by the previous commit hits the old behaviour — run 1's /Contents, including the Link to another part of this document placeholder, is kept and nothing warns. Only matters for files already produced; worth one line in the --retag row if any exist.
Accessibility impact: a retag now rebuilds the structure tree, the overlay and the link descriptions this tool wrote, so corrected HTML reaches the output, and the author's own annotation descriptions are left alone.
A tagged PDF was refused with no way forward. Now:
already_tagged), and its message points at--retag. A caller such as Iris can show a confirm step on that code and run again.--retag(retag: true) drops the structure tree, pageStructParentsand annotationStructParent, then tags as usual. The report warnsretagged.source_marked_contentwarning applies and the PDF/UA claim is withheld.Tests: refusal message, retagging our own output (same structure, one overlay, checks pass), a foreign tree's pointers removed, and the CLI flag.
🤖 Generated with Claude Code