Skip to content

Retag an already-tagged PDF with --retag - #7

Merged
bbertucc merged 2 commits into
mainfrom
retag-confirm
Oct 1, 2026
Merged

bbertucc merged 2 commits into
mainfrom
retag-confirm

Conversation

@bbertucc

@bbertucc bbertucc commented Oct 1, 2026

Copy link
Copy Markdown
Member

A tagged PDF was refused with no way forward. Now:

  • The refusal stays (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, page StructParents and annotation StructParent, then tags as usual. The report warns retagged.
  • When the PDF is our own output, each page gets its original content back first, so the invisible overlays do not stack. The checks then compare against that restored source. The output stays an incremental update.
  • In a foreign tagged PDF, the old marked content stays inside our artifact, so the existing source_marked_content warning 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

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>

@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.

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>

@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.

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 /Contents this tool writes and untag() deletes exactly those, so run 2's HTML replaces run 1's link text. Verified: tag(links.pdf) → "the city website", retag with the city <a>website</a> → "website", and an author-written "Permits" survives. The Lang: en asymmetry on the generic placeholder goes away with it, since generic is recomputed once the marker's /Contents is gone.
  • Fixed — test/document.test.ts:102: the assertion now reads get("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_content warned, no pdfuaid:part in the output's XMP).
  • Fixed — the README --retag row 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.

@bbertucc
bbertucc merged commit 6344fcb into main Oct 1, 2026
2 checks passed
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