From 7a6acda7c2d76f55807d088016174a0703a25b71 Mon Sep 17 00:00:00 2001 From: Blake Bertuccelli-Booth <46652+bbertucc@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:13:09 -0400 Subject: [PATCH 01/11] Check every PDF/UA-1 claim with veraPDF; withhold it for unembedded source 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 --- .github/workflows/test.yml | 2 ++ README.md | 4 +-- src/pdf/fonts.ts | 40 +++++++++++++++++++++++++ src/tag.ts | 20 +++++++++++-- test/fixtures/blank-page.pdf | Bin 1119 -> 23144 bytes test/fixtures/make.ts | 27 ++++++++++++++--- test/fixtures/text-embedded.pages.json | 10 +++++++ test/fixtures/text-embedded.pdf | Bin 0 -> 23024 bytes test/forms.test.ts | 1 + test/pdfua.test.ts | 34 +++++++++++++++++++++ test/tag.test.ts | 13 ++++++-- 11 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/text-embedded.pages.json create mode 100644 test/fixtures/text-embedded.pdf create mode 100644 test/pdfua.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b01b398..edec948 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,8 @@ jobs: node-version-file: .nvmrc cache: npm - run: sudo apt-get update && sudo apt-get install -y tesseract-ocr + # veraPDF checks every PDF/UA-1 claim the tests make (test/pdfua.test.ts). + - run: /home/linuxbrew/.linuxbrew/bin/brew install verapdf && echo /home/linuxbrew/.linuxbrew/bin >> "$GITHUB_PATH" - run: npm ci - run: npm run typecheck - run: npm test diff --git a/README.md b/README.md index 5af0364..53c9889 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ Then two checks run, and if either fails nothing is written (exit 2): ## The report -`--report` writes JSON: per page, where the text came from and how many words matched; the structure written; fields set and skipped; the check results; and every warning. Warnings name what could not be done, for example `unmatched_text` (page text missing from the HTML, kept as a paragraph), `missing_alt`, `field_not_in_html`, `unmatched_link`, `duplicate_text_layer`, `page_not_in_html` and `page_not_tagged` (the page is left as it was; a blank page needs no HTML and is not warned), `no_title`, `alignment_incomplete` (the page and the HTML differ too much to match every word in time; the rest is kept as unmatched text). +`--report` writes JSON: per page, where the text came from and how many words matched; the structure written; fields set and skipped; the check results; and every warning. Warnings name what could not be done, for example `unmatched_text` (page text missing from the HTML, kept as a paragraph), `missing_alt`, `field_not_in_html`, `unmatched_link`, `duplicate_text_layer`, `page_not_in_html` and `page_not_tagged` (the page is left as it was; a blank page needs no HTML and is not warned), `no_title`, `font_not_embedded` (a source font has no embedded program, which PDF/UA-1 requires; the source drawing is not changed), `alignment_incomplete` (the page and the HTML differ too much to match every word in time; the rest is kept as unmatched text). -The output declares PDF/UA-1 only when it has a title and every page is tagged. +The output declares PDF/UA-1 only when it has a title, every page is tagged, and every source font is embedded. The tests check each such claim with veraPDF. ## Refusals and exit codes diff --git a/src/pdf/fonts.ts b/src/pdf/fonts.ts index 3dc5697..d9ccfc1 100644 --- a/src/pdf/fonts.ts +++ b/src/pdf/fonts.ts @@ -37,9 +37,49 @@ export class FontSet { for (const i of this.used) refs[this.resourceName(i)] = tmp.addFont(this.fonts[i]); for (const s of streams) tmp.insertPage(-1, tmp.addPage([0, 0, 1, 1], 0, { Font: refs }, s)); tmp.subsetFonts(); + // Codes are glyph ids. PDF/UA-1 (7.21.3.2) wants a TrueType CIDFont to say so. + for (const ref of Object.values(refs)) { + const cid = ref.get("DescendantFonts").get(0); + if (cid.get("Subtype").asName() === "CIDFontType2" && cid.get("CIDToGIDMap").isNull()) cid.put("CIDToGIDMap", tmp.newName("Identity")); + } const out: Record = {}; const graft = doc.newGraftMap(); for (const [name, ref] of Object.entries(refs)) out[name] = graft.graftObject(ref); return out; } } + +// Base names of the source's fonts with no embedded program (PDF/UA-1 7.21.4.1). +// Looks in every page, form XObject and annotation appearance. +export function unembeddedFonts(doc: mupdf.PDFDocument): string[] { + const out = new Set(), seen = new Set(); + const once = (o: mupdf.PDFObject) => { + if (!o.isIndirect()) return true; + if (seen.has(o.asIndirect())) return false; + seen.add(o.asIndirect()); + return true; + }; + const resources = (res: mupdf.PDFObject) => { + if (!res.isDictionary() || !once(res)) return; + res.get("Font").forEach((f) => { if (f.isDictionary() && once(f)) font(f); }); + res.get("XObject").forEach((x) => { if (x.isStream() && x.get("Subtype").asName() === "Form" && once(x)) resources(x.get("Resources")); }); + }; + const font = (f: mupdf.PDFObject) => { + const type = f.get("Subtype").asName(); + if (type === "Type3") return resources(f.get("Resources")); + const kids = f.get("DescendantFonts"); + const base = type !== "Type0" ? f : kids.isArray() ? kids.get(0) : kids; + const d = base.isDictionary() ? base.get("FontDescriptor") : base; + if (!d.isDictionary() || !["FontFile", "FontFile2", "FontFile3"].some((k) => d.get(k).isStream())) out.add(f.get("BaseFont").asName() || "(unnamed)"); + }; + const appearance = (ap: mupdf.PDFObject) => { + if (ap.isStream()) return resources(ap.get("Resources")); + if (ap.isDictionary()) ap.forEach(appearance); + }; + for (let i = 0; i < doc.countPages(); i++) { + const page = doc.findPage(i); + resources(page.getInheritable("Resources")); + page.get("Annots").forEach((a) => { if (a.isDictionary()) appearance(a.get("AP")); }); + } + return [...out]; +} diff --git a/src/tag.ts b/src/tag.ts index c5ddd3a..8304939 100644 --- a/src/tag.ts +++ b/src/tag.ts @@ -3,7 +3,7 @@ import * as mupdf from "mupdf"; import { openPdf, save, type OpenOptions } from "./pdf/document.ts"; import { artifactStreams, drawsNothing, Overlay } from "./pdf/content.ts"; -import { FontSet } from "./pdf/fonts.ts"; +import { FontSet, unembeddedFonts } from "./pdf/fonts.ts"; import { StructTree } from "./pdf/struct.ts"; import { setDocumentInfo } from "./pdf/metadata.ts"; import { textLayerWords } from "./pdf/words.ts"; @@ -65,6 +65,9 @@ export function tag(pdf: Uint8Array, input: PagesInput, opts: TagOptions = {}, r const widgets = allWidgets(doc).map((w) => ({ ...w, box: w.widget.getBounds() as Box, used: false })); if (opts.flatten) doc.bake(false, true); + // The source's own fonts must be embedded for the file to be PDF/UA. We do not rewrite them. + const unembedded = unembeddedFonts(doc); + if (unembedded.length) warn({ code: "font_not_embedded", detail: `${unembedded.join(", ")}; the output does not claim PDF/UA-1.` }); const struct = new StructTree(doc); const fonts = new FontSet(); const written: { page: mupdf.PDFObject; overlay: string }[] = []; @@ -108,7 +111,7 @@ export function tag(pdf: Uint8Array, input: PagesInput, opts: TagOptions = {}, r if (fonts.missing.size) warn({ code: "missing_glyph", detail: [...fonts.missing].join("") }); struct.finish(); report.structure = { elements: struct.elements, byType: struct.byType }; - setDocumentInfo(doc, lang, title ?? "", !!title && !untagged); + setDocumentInfo(doc, lang, title ?? "", !!title && !untagged && !unembedded.length); const out = save(doc); report.sizeIncreaseBytes = out.length - pdf.length; @@ -233,6 +236,7 @@ function tagPage(page: mupdf.PDFPage, i: number, html: string, ctx: PageCtx): { for (const w of ctx.widgets.filter((w) => !w.used && !ctx.flatten)) { const elem = ctx.struct.add(ctx.struct.top, "Form"); ctx.struct.objr(elem, pageObj, w.widget.getObject()); + nameField(w, "", ctx.doc); w.used = true; warn({ code: "field_not_in_html", detail: w.name }); } @@ -329,10 +333,20 @@ function emitField(n: Node, parent: mupdf.PDFObject, e: Emitter) { const name = f.group || f.label; for (const w of mine) { e.struct.objr(elem, e.pageObj, w.widget.getObject()); - if (name) w.field.put("TU", e.doc.newString(name)); + nameField(w, name, e.doc); } } +// /TU, the name a screen reader announces (PDF/UA-1 7.18.1). The HTML's label +// wins; without one, keep the source's, else the button caption, else the field name. +function nameField(w: Widget, label: string, doc: mupdf.PDFDocument) { + const had = w.field.get("TU"); + if (!label && had.isString() && had.asString()) return; + const caption = w.widget.getObject().get("MK", "CA"); + const name = label || (caption.isString() && caption.asString()) || w.name.split(".").at(-1)!; + w.field.put("TU", doc.newString(name)); +} + // The value a flattened field shows, as text. function fieldText(w: Widget, state?: string): string { const v = w.field.get("V"); diff --git a/test/fixtures/blank-page.pdf b/test/fixtures/blank-page.pdf index 555848e6883a189f96cc751d22f953e47ffb3efe..11a02cf9f91c76b817a4c1a353ce5ccf83306a8c 100644 GIT binary patch literal 23144 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 InjFdh1EvM$s{jB1 delta 593 zcmaE{h4DVegxPH73I+;63KJI_SsLm)B^IZ;<>!^?d!*)+rIutSC+fTACFiGP=B4Y0 zXXZKP6=%Zuc6MB;c`5lxSzKHulk;^IxRG>EUZ^V}g6b3{T@^Y#tYo!^p1`b5x7*897*D>{ z-N-81azN+P|JjvG4?H@tq3yy0$xo$>3k~kh7ARD^KBHmswPlMWkI1qcYT9$sJMDS>2d+fp zKr)Z3q$n{nC$$I^7r~iTs;LT=`a${mB?>@&pjhK7E=epZsVGWK^a} E09F6dhyVZp diff --git a/test/fixtures/make.ts b/test/fixtures/make.ts index 225e392..9fae1bd 100644 --- a/test/fixtures/make.ts +++ b/test/fixtures/make.ts @@ -21,10 +21,25 @@ function helvetica(doc: mupdf.PDFDocument, bold = false) { }); } -function textDoc(pages: string[]): mupdf.PDFDocument { +// embed: embedded fonts, as PDF/UA requires, so the file can reach the claim. +// mupdf embeds only composite fonts, so each string is rewritten as glyph ids. +function textDoc(pages: string[], embed = false): mupdf.PDFDocument { const doc = new mupdf.PDFDocument(); - const res = doc.addObject({ Font: { F1: helvetica(doc), F2: helvetica(doc, true) } }); - for (const c of pages) doc.insertPage(-1, doc.addPage([0, 0, W, H], 0, res, c)); + if (!embed) { + const res = doc.addObject({ Font: { F1: helvetica(doc), F2: helvetica(doc, true) } }); + for (const c of pages) doc.insertPage(-1, doc.addPage([0, 0, W, H], 0, res, c)); + return doc; + } + const fonts = { F1: new mupdf.Font("Helvetica"), F2: new mupdf.Font("Helvetica-Bold") }; + const res = doc.addObject({ Font: { F1: doc.addFont(fonts.F1), F2: doc.addFont(fonts.F2) } }); + const gids = (font: mupdf.Font, s: string) => "<" + [...s].map((c) => font.encodeCharacter(c).toString(16).padStart(4, "0")).join("") + ">"; + const unlit = (s: string) => s.slice(1, -1).replace(/\\(.)/g, "$1"); + for (const c of pages) { + const content = c.replace(/\/(F[12]) ([\d.]+) Tf (.*?) (\((?:\\.|[^\\)])*\)) Tj/g, + (_, f: "F1" | "F2", size, td, str) => `/${f} ${size} Tf ${td} ${gids(fonts[f], unlit(str))} Tj`); + doc.insertPage(-1, doc.addPage([0, 0, W, H], 0, res, content)); + } + doc.subsetFonts(); return doc; } @@ -53,6 +68,10 @@ const simpleHtml = save("text-simple.pdf", textDoc([simple])); pagesJson("text-simple.pages.json", [{ sourcePage: 1, html: simpleHtml }]); +// --- text-embedded: text-simple with its fonts embedded. +save("text-embedded.pdf", textDoc([simple], true)); +pagesJson("text-embedded.pages.json", [{ sourcePage: 1, html: simpleHtml }]); + // --- text-two-column: operators run across the columns line by line, so the // PDF's own order interleaves them. Iris's HTML reads left column first. const left = ["The library opens at nine", "every weekday morning and", "closes at six in the evening."]; @@ -101,7 +120,7 @@ pagesJson("mixed.pages.json", [ ]); // --- blank-page: page 2 is empty, and Iris sends no HTML for it. -save("blank-page.pdf", textDoc([simple, ""])); +save("blank-page.pdf", textDoc([simple, ""], true)); pagesJson("blank-page.pages.json", [{ sourcePage: 1, html: simpleHtml }]); // --- links: a link annotation over its text. diff --git a/test/fixtures/text-embedded.pages.json b/test/fixtures/text-embedded.pages.json new file mode 100644 index 0000000..6f5a6a7 --- /dev/null +++ b/test/fixtures/text-embedded.pages.json @@ -0,0 +1,10 @@ +{ + "lang": "en", + "title": "Test document", + "pages": [ + { + "sourcePage": 1, + "html": "

Parking Permit

Residents may apply for one parking permit per car. Bring proof of address to the permit office.

Fees

A permit costs twenty dollars a year.

" + } + ] +} diff --git a/test/fixtures/text-embedded.pdf b/test/fixtures/text-embedded.pdf new file mode 100644 index 0000000000000000000000000000000000000000..dfbdf923294154d1389bd106ae14530217c98118 GIT binary patch literal 23024 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 should the output claim PDF/UA-1? +const CORPUS: [string, boolean][] = [ + ["text-embedded", true], ["blank-page", true], ["scan-300dpi", true], ["scan-skewed", true], + ["text-simple", false], ["text-two-column", false], ["links", false], ["form-acroform", false], ["cjk", false], ["mixed", false], +]; +const SCANS = new Set(["scan-300dpi", "scan-skewed", "mixed"]); + +for (const [name, conforms] of CORPUS) { + test(`${name}: ${conforms ? "claims PDF/UA-1 and passes veraPDF" : "does not claim PDF/UA-1"}`, { skip: noVera || (SCANS.has(name) && noOcr) }, () => { + const { out, doc } = tagFixture(name); + const claims = /1 { - const { doc } = tagFixture("text-simple"); + const { doc } = tagFixture("text-embedded"); const root = doc.getTrailer().get("Root"); assert.equal(root.get("Lang").asString(), "en"); assert.equal(root.get("MarkInfo", "Marked").asBoolean(), true); @@ -81,6 +81,13 @@ test("document info: language, title, marked, PDF/UA identifier", () => { assert.match(packet, /Test document<\/rdf:li>/); }); +test("a source font that is not embedded is reported, and PDF/UA is not claimed", () => { + const { doc, report } = tagFixture("text-simple"); + assert.equal(report.warnings.find((w) => w.code === "font_not_embedded")?.detail?.split(";")[0], "Helvetica, Helvetica-Bold"); + assert.doesNotMatch(doc.getTrailer().get("Root", "Metadata").readStream().asString(), /pdfuaid:part/); + assert.ok(!tagFixture("text-embedded").report.warnings.some((w) => w.code === "font_not_embedded")); +}); + test("links: the Link element owns its annotation, which gets the link text", () => { const { doc } = tagFixture("links"); const [link] = find(structTree(doc), "Link"); @@ -96,6 +103,8 @@ test("cjk: every character is recoverable through ToUnicode", () => { const want = pagesOf("cjk").pages[0].html.replace(/<[^>]+>|\s/g, ""); assert.equal(text, want); assert.ok(!report.warnings.some((w) => w.code === "missing_glyph")); + const cid = doc.findPage(0).get("Resources", "Font", "IrisF1", "DescendantFonts").get(0); + assert.equal(cid.get("CIDToGIDMap").asName(), "Identity"); }); test("a scan with OCR off is refused, or left untagged with --partial", () => { From e00d85d3eeb43202304d2b7d46a3a31ddb689010 Mon Sep 17 00:00:00 2001 From: Blake Bertuccelli-Booth <46652+bbertucc@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:30:00 -0400 Subject: [PATCH 02/11] Fix what real scans and the review found 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 --- .github/workflows/test.yml | 2 ++ README.md | 6 ++-- src/html/build.ts | 16 +++++++---- src/pdf/content.ts | 41 +++++++++++++++++++++++++--- src/pdf/fonts.ts | 23 ++++++++++------ src/tag.ts | 44 ++++++++++++++++++++---------- test/forms.test.ts | 13 +++++++++ test/html.test.ts | 4 +++ test/pdfua.test.ts | 4 +-- test/tag.test.ts | 56 ++++++++++++++++++++++++++++++++++++-- 10 files changed, 170 insertions(+), 39 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index edec948..1aec3a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,3 +24,5 @@ jobs: - run: npm ci - run: npm run typecheck - run: npm test + env: + IRIS_REQUIRE_VERAPDF: 1 diff --git a/README.md b/README.md index 53c9889..ce58b99 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ Then two checks run, and if either fails nothing is written (exit 2): ## The report -`--report` writes JSON: per page, where the text came from and how many words matched; the structure written; fields set and skipped; the check results; and every warning. Warnings name what could not be done, for example `unmatched_text` (page text missing from the HTML, kept as a paragraph), `missing_alt`, `field_not_in_html`, `unmatched_link`, `duplicate_text_layer`, `page_not_in_html` and `page_not_tagged` (the page is left as it was; a blank page needs no HTML and is not warned), `no_title`, `font_not_embedded` (a source font has no embedded program, which PDF/UA-1 requires; the source drawing is not changed), `alignment_incomplete` (the page and the HTML differ too much to match every word in time; the rest is kept as unmatched text). +`--report` writes JSON: per page, where the text came from and how many words matched; the structure written; fields set and skipped; the check results; and every warning. Warnings name what could not be done, for example `unmatched_text` (page text missing from the HTML, kept as a paragraph), `missing_alt`, `field_not_in_html`, `unmatched_link`, `duplicate_text_layer`, `page_not_in_html` and `page_not_tagged` (the page is left as it was; a blank page needs no HTML and is not warned), `no_title`, `font_not_embedded` (a source font has no embedded program, which PDF/UA-1 requires; the source drawing is not changed), `source_marked_content` (the page drawing has marked-content ids left from an earlier tag tree), `alignment_incomplete` (the page and the HTML differ too much to match every word in time; the rest is kept as unmatched text). -The output declares PDF/UA-1 only when it has a title, every page is tagged, and every source font is embedded. The tests check each such claim with veraPDF. +The output declares PDF/UA-1 only when it has a title, every page is tagged, every source font is embedded, and no page drawing has leftover marked content. The tests check each such claim with veraPDF. ## Refusals and exit codes @@ -97,7 +97,7 @@ Form values are personal data. They are never printed, logged, or put in the rep - **The text exists twice** on a page that already had a text layer: the original, now an artifact, and ours. Screen readers use ours. Plain copy-and-paste tools may show the text doubled. The report warns `duplicate_text_layer`. - A table that continues onto the next page is tagged as two tables. -- `check` needs veraPDF installed. Checking the whole corpus in CI is not done yet. +- `check` needs veraPDF installed. - A form with no fields (a flat form) cannot be filled. ## License diff --git a/src/html/build.ts b/src/html/build.ts index 59c71c9..4b2071f 100644 --- a/src/html/build.ts +++ b/src/html/build.ts @@ -159,17 +159,23 @@ function listItem(e: Elem, parent: Node, label: string, ctx: Ctx) { const li: Node = { type: "LI", kids: [] }; parent.kids.push(li); if (label) li.kids.push({ type: "Lbl", kids: [{ words: [word(label)] }] }); - const body: Node = { type: "LBody", kids: [] }; + let body: Node = { type: "LBody", kids: [] }; li.kids.push(body); - if (e.attrs.id && ctx.notes.has(e.attrs.id)) target(body, e.attrs.id, ctx); + // A footnote list item: LI may hold only Lbl and LBody, so the Note goes inside the LBody. + if (e.attrs.id && ctx.notes.has(e.attrs.id)) { + const note: Node = { type: "P", kids: [] }; + body.kids.push(note); + target(note, e.attrs.id, ctx); + body = note; + } for (const k of e.kids) build(k, body, ctx); } -// The target of an internal link gets an /ID. A paragraph or list body is a -// footnote, so it becomes a Note; anything else (a heading, say) keeps its type. +// The target of an internal link gets an /ID. A paragraph is a footnote, so +// it becomes a Note; anything else (a heading, say) keeps its type. function target(n: Node, id: string, ctx: Ctx) { n.id = `${ctx.prefix}${id}`; - if (n.type === "P" || n.type === "LBody") n.type = "Note"; + if (n.type === "P") n.type = "Note"; } const scope = (s: string) => ({ row: "Row", rowgroup: "Row", col: "Column", colgroup: "Column" })[s.toLowerCase()] ?? "Both"; diff --git a/src/pdf/content.ts b/src/pdf/content.ts index 0338968..315f755 100644 --- a/src/pdf/content.ts +++ b/src/pdf/content.ts @@ -15,6 +15,35 @@ export function artifactStreams(doc: mupdf.PDFDocument, page: mupdf.PDFObject): return [doc.addStream("/Artifact BMC q\n", {}), ...streams, doc.addStream("\nQ EMC\n", {})]; } +// Pages (1-based) whose own drawing has marked-content ids, left from a tag +// tree since removed. Inside our artifact they are tagged content in an +// artifact, which PDF/UA-1 forbids (7.1). Form XObjects are searched to a depth of 32. +export function pagesWithMcids(doc: mupdf.PDFDocument): number[] { + const out: number[] = []; + for (let i = 0; i < doc.countPages(); i++) { + const page = doc.findPage(i), seen = new Set(); + const marked = (s: mupdf.PDFObject) => /\/MCID\b/.test(s.readStream().asString()); + const forms = (res: mupdf.PDFObject, depth: number): boolean => { + let found = false; + if (depth > 32 || !res.isDictionary()) return false; + res.get("XObject").forEach((x) => { + if (found || !x.isStream() || x.get("Subtype").asName() !== "Form") return; + if (x.isIndirect()) { + if (seen.has(x.asIndirect())) return; + seen.add(x.asIndirect()); + } + found = marked(x) || forms(x.get("Resources"), depth + 1); + }); + return found; + }; + const contents = page.get("Contents"), streams: mupdf.PDFObject[] = []; + if (contents.isArray()) contents.forEach((s) => { if (s.isStream()) streams.push(s); }); + else if (contents.isStream()) streams.push(contents); + if (streams.some(marked) || forms(page.getInheritable("Resources"), 0)) out.push(i + 1); + } + return out; +} + // True if the page's own content paints nothing (annotations aside). export function drawsNothing(page: mupdf.PDFPage): boolean { let drew = false; @@ -54,10 +83,14 @@ export class Overlay { // One word, stretched to fill its box. A space after it, if the HTML had // one, lets extractors see the word break. word(text: string, box: Box, baseline: number, size: number, space = true) { - const chars = [...text], glyphs = chars.map((c) => this.fonts.glyph(c)); - this.text += chars.filter((_, k) => glyphs[k].gid).join("") + " "; // a missing glyph is reported, not checked - const natural = glyphs.reduce((w, g) => w + g.advance, 0) * size; - const h = natural > 0 ? Math.min(10, Math.max(0.1, (box[2] - box[0]) / natural)) : 1; + // A character no font has is reported, and left out: a .notdef glyph has no Unicode (PDF/UA-1 7.21.7). + const chars = [...text].filter((c) => this.fonts.glyph(c).gid), glyphs = chars.map((c) => this.fonts.glyph(c)); + if (!chars.length) return; + this.text += chars.join("") + " "; + const unit = glyphs.reduce((w, g) => w + g.advance, 0), wide = box[2] - box[0]; + // Squeezed to under half its width, extractors merge a word's repeated letters. Shrink the text instead. + if (unit > 0 && wide / (unit * size) < 0.5) size = Math.max(0.5, (2 * wide) / unit); + const h = unit > 0 ? Math.min(10, Math.max(0.1, wide / (unit * size))) : 1; const m = mupdf.Matrix.concat([h, 0, 0, -1, box[0], baseline], this.toUser); // Consecutive glyphs from the same font share one Tj. const runs: { font: number; hex: string }[] = []; diff --git a/src/pdf/fonts.ts b/src/pdf/fonts.ts index d9ccfc1..6131840 100644 --- a/src/pdf/fonts.ts +++ b/src/pdf/fonts.ts @@ -25,6 +25,11 @@ export class FontSet { return { font: 0, gid: 0, advance: 0.5 }; } + // Advance of a string at size 1. + width(text: string) { + return [...text].reduce((w, c) => w + this.glyph(c).advance, 0); + } + resourceName(font: number) { return `IrisF${font}`; } @@ -50,7 +55,7 @@ export class FontSet { } // Base names of the source's fonts with no embedded program (PDF/UA-1 7.21.4.1). -// Looks in every page, form XObject and annotation appearance. +// Looks in every page, form XObject and annotation appearance, to a nesting depth of 32. export function unembeddedFonts(doc: mupdf.PDFDocument): string[] { const out = new Set(), seen = new Set(); const once = (o: mupdf.PDFObject) => { @@ -59,26 +64,26 @@ export function unembeddedFonts(doc: mupdf.PDFDocument): string[] { seen.add(o.asIndirect()); return true; }; - const resources = (res: mupdf.PDFObject) => { - if (!res.isDictionary() || !once(res)) return; - res.get("Font").forEach((f) => { if (f.isDictionary() && once(f)) font(f); }); - res.get("XObject").forEach((x) => { if (x.isStream() && x.get("Subtype").asName() === "Form" && once(x)) resources(x.get("Resources")); }); + const resources = (res: mupdf.PDFObject, depth: number) => { + if (depth > 32 || !res.isDictionary() || !once(res)) return; + res.get("Font").forEach((f) => { if (f.isDictionary() && once(f)) font(f, depth); }); + res.get("XObject").forEach((x) => { if (x.isStream() && x.get("Subtype").asName() === "Form" && once(x)) resources(x.get("Resources"), depth + 1); }); }; - const font = (f: mupdf.PDFObject) => { + const font = (f: mupdf.PDFObject, depth: number) => { const type = f.get("Subtype").asName(); - if (type === "Type3") return resources(f.get("Resources")); + if (type === "Type3") return resources(f.get("Resources"), depth + 1); const kids = f.get("DescendantFonts"); const base = type !== "Type0" ? f : kids.isArray() ? kids.get(0) : kids; const d = base.isDictionary() ? base.get("FontDescriptor") : base; if (!d.isDictionary() || !["FontFile", "FontFile2", "FontFile3"].some((k) => d.get(k).isStream())) out.add(f.get("BaseFont").asName() || "(unnamed)"); }; const appearance = (ap: mupdf.PDFObject) => { - if (ap.isStream()) return resources(ap.get("Resources")); + if (ap.isStream()) return resources(ap.get("Resources"), 0); if (ap.isDictionary()) ap.forEach(appearance); }; for (let i = 0; i < doc.countPages(); i++) { const page = doc.findPage(i); - resources(page.getInheritable("Resources")); + resources(page.getInheritable("Resources"), 0); page.get("Annots").forEach((a) => { if (a.isDictionary()) appearance(a.get("AP")); }); } return [...out]; diff --git a/src/tag.ts b/src/tag.ts index 8304939..ac5dc8a 100644 --- a/src/tag.ts +++ b/src/tag.ts @@ -1,8 +1,8 @@ // tag(): the original PDF plus Iris's HTML -> the same PDF, tagged, with any // form values filled in, checked to render identically (spec ยง7). import * as mupdf from "mupdf"; -import { openPdf, save, type OpenOptions } from "./pdf/document.ts"; -import { artifactStreams, drawsNothing, Overlay } from "./pdf/content.ts"; +import { inherited, openPdf, save, type OpenOptions } from "./pdf/document.ts"; +import { artifactStreams, drawsNothing, Overlay, pagesWithMcids } from "./pdf/content.ts"; import { FontSet, unembeddedFonts } from "./pdf/fonts.ts"; import { StructTree } from "./pdf/struct.ts"; import { setDocumentInfo } from "./pdf/metadata.ts"; @@ -68,6 +68,8 @@ export function tag(pdf: Uint8Array, input: PagesInput, opts: TagOptions = {}, r // The source's own fonts must be embedded for the file to be PDF/UA. We do not rewrite them. const unembedded = unembeddedFonts(doc); if (unembedded.length) warn({ code: "font_not_embedded", detail: `${unembedded.join(", ")}; the output does not claim PDF/UA-1.` }); + const marked = pagesWithMcids(doc); + if (marked.length) warn({ code: "source_marked_content", detail: `Pages ${marked.join(", ")} carry marked-content ids from an earlier tag tree; the output does not claim PDF/UA-1.` }); const struct = new StructTree(doc); const fonts = new FontSet(); const written: { page: mupdf.PDFObject; overlay: string }[] = []; @@ -111,7 +113,7 @@ export function tag(pdf: Uint8Array, input: PagesInput, opts: TagOptions = {}, r if (fonts.missing.size) warn({ code: "missing_glyph", detail: [...fonts.missing].join("") }); struct.finish(); report.structure = { elements: struct.elements, byType: struct.byType }; - setDocumentInfo(doc, lang, title ?? "", !!title && !untagged && !unembedded.length); + setDocumentInfo(doc, lang, title ?? "", !!title && !untagged && !unembedded.length && !marked.length); const out = save(doc); report.sizeIncreaseBytes = out.length - pdf.length; @@ -193,7 +195,7 @@ function tagPage(page: mupdf.PDFPage, i: number, html: string, ctx: PageCtx): { blockOf.set(t, ordered[k].block); }); const bounds = page.getBounds(); - fillPositions(ordered.map((o) => o.word), { box: [bounds[0] + 10, bounds[1] + 10, bounds[0] + 10, bounds[1] + 20], baseline: bounds[1] + 20, size: 10 }); + fillPositions(ordered.map((o) => o.word), bounds, (s) => ctx.fonts.width(s)); // Page words Iris left out: furniture, or lost content reported and kept as a P. const lost = new Map(); // after which top-level block @@ -257,15 +259,27 @@ type Emitter = PageCtx & { pageObj: mupdf.PDFObject; overlay: Overlay; links: Li const asP = (r: Run): Node => ({ type: "P", kids: [r] }); const placed = (w: PageWord): Placed => ({ box: w.box, baseline: w.baseline, size: w.size }); -// An HTML word with no page word sits just after the word before it, or -// before the first placed word, or at the page's top left. -function fillPositions(words: Word[], fallback: Placed) { +// An HTML word with no page word follows the word before it, at its natural +// width, wrapping at the page edge: extractors drop text off the page, and +// merge repeated letters piled into one spot. With no word before it, it +// starts at the first placed word, or the page's top left. +function fillPositions(words: Word[], page: Box, width: (text: string) => number) { + const fallback: Placed = { box: [page[0] + 10, page[1] + 10, page[0] + 10, page[1] + 20], baseline: page[1] + 20, size: 10 }; const first = words.find((w) => w.at)?.at ?? fallback; - let prev: Placed | null = null; + let prev: Word | null = null; for (const w of words) { - if (w.at) prev = w.at; - else if (prev) w.at = { ...prev, box: [prev.box[2], prev.box[1], prev.box[2], prev.box[3]] }; - else w.at = { ...first, box: [first.box[0], first.box[1], first.box[0], first.box[3]] }; + if (!w.at) { + const from = prev?.at ?? first, size = from.size, line = size * 1.2; + const wide = Math.min(width(w.text) * size, page[2] - page[0] - 2); + let x = prev ? from.box[2] + (prev.space ?? true ? width(" ") * size : 0) : first.box[0]; + let dy = 0; + if (x + wide > page[2] - 1) { + x = page[0] + 1; + dy = from.box[3] + line > page[3] ? page[1] + line - from.box[3] : line; // off the bottom: back to the top + } + w.at = { size, baseline: from.baseline + dy, box: [x, from.box[1] + dy, x + wide, from.box[3] + dy] }; + } + prev = w; } } @@ -338,13 +352,15 @@ function emitField(n: Node, parent: mupdf.PDFObject, e: Emitter) { } // /TU, the name a screen reader announces (PDF/UA-1 7.18.1). The HTML's label -// wins; without one, keep the source's, else the button caption, else the field name. +// wins; without one, keep the source's, else a push button's caption, else the +// field name. (On a check box or radio button, /MK /CA is the check mark's glyph.) function nameField(w: Widget, label: string, doc: mupdf.PDFDocument) { const had = w.field.get("TU"); if (!label && had.isString() && had.asString()) return; + const push = inherited(w.field, "FT")?.asName() === "Btn" && ((inherited(w.field, "Ff")?.asNumber() ?? 0) & (1 << 16)) !== 0; const caption = w.widget.getObject().get("MK", "CA"); - const name = label || (caption.isString() && caption.asString()) || w.name.split(".").at(-1)!; - w.field.put("TU", doc.newString(name)); + const name = label || (push && caption.isString() && caption.asString()) || w.name.split(".").at(-1); + if (name) w.field.put("TU", doc.newString(name)); } // The value a flattened field shows, as text. diff --git a/test/forms.test.ts b/test/forms.test.ts index f8237ba..0f04655 100644 --- a/test/forms.test.ts +++ b/test/forms.test.ts @@ -105,3 +105,16 @@ test("--flatten bakes the values into the page and tags them as text", () => { assert.equal(report.verification.textPreserved, true); assert.ok(!JSON.stringify(report).includes("Lovelace"), "values never reach the report"); }); + +test("a field the HTML does not name keeps its own name, a button its caption, a check box its field name", () => { + const src = new mupdf.PDFDocument(pdf); + const w = widgets(src); + w.get("applicant.consent")![0].getObject().put("MK", { CA: src.newString("4") }); // the check mark's glyph, not a caption + w.get("reset")![0].getObject().put("MK", { CA: src.newString("Clear the form") }); + w.get("state")![0].getObject().put("TU", src.newString("State of residence")); + const html = { ...pages, pages: [{ sourcePage: 1, html: "

Permit application

" }] }; + const out = widgets(new mupdf.PDFDocument(tag(src.saveToBuffer("").asUint8Array().slice(), html))); + assert.equal(out.get("applicant.consent")![0].getLabel(), "consent"); + assert.equal(out.get("reset")![0].getLabel(), "Clear the form"); + assert.equal(out.get("state")![0].getLabel(), "State of residence"); +}); diff --git a/test/html.test.ts b/test/html.test.ts index 15d72ec..0f2f5fc 100644 --- a/test/html.test.ts +++ b/test/html.test.ts @@ -46,6 +46,10 @@ test("links and footnotes", () => { assert.equal((top.kids[1] as Node).id, "p1-n1"); // A heading that is a link target stays a heading. assert.equal(kids('

Fees

Fees

'), 'P(Reference(Link("Fees"))), H2("Fees")'); + // A footnote list item keeps LI > LBody; the Note is inside. + const list = build('

x1

  1. Source.
').top.kids[1] as Node; + assert.equal(shape(list), 'L(LI(Lbl("1."), LBody(Note("Source."))))'); + assert.equal(((((list.kids[0] as Node).kids[1] as Node).kids[0]) as Node).id, "p1-fn-1"); }); test("form controls take their label from for=, an enclosing label, or aria-label", () => { diff --git a/test/pdfua.test.ts b/test/pdfua.test.ts index 8c459ab..9a15a92 100644 --- a/test/pdfua.test.ts +++ b/test/pdfua.test.ts @@ -1,5 +1,5 @@ // veraPDF over the corpus: every output that claims PDF/UA-1 must pass it, -// and the fixtures that can conform must claim it. Skips without veraPDF, except in CI. +// and the fixtures that can conform must claim it. Skips without veraPDF, unless IRIS_REQUIRE_VERAPDF is set. import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; @@ -10,7 +10,7 @@ import { tesseractInstalled } from "../src/ocr/tesseract.ts"; import { checkPdfUa } from "../src/verify/pdfua.ts"; import { tagFixture } from "./helpers.ts"; -const noVera = !process.env.CI && !!spawnSync("verapdf", ["--version"]).error && "veraPDF is not installed"; +const noVera = !process.env.IRIS_REQUIRE_VERAPDF && !!spawnSync("verapdf", ["--version"]).error && "veraPDF is not installed"; const noOcr = !tesseractInstalled() && "Tesseract is not installed"; const dir = mkdtempSync(join(tmpdir(), "iris-pdf-ua-")); diff --git a/test/tag.test.ts b/test/tag.test.ts index 89e4876..a157c17 100644 --- a/test/tag.test.ts +++ b/test/tag.test.ts @@ -8,6 +8,8 @@ import { tesseractInstalled } from "../src/ocr/tesseract.ts"; import { comparePixels } from "../src/verify/pixels.ts"; import { compareText } from "../src/verify/text.ts"; import { xmp } from "../src/pdf/metadata.ts"; +import { Overlay, pagesWithMcids } from "../src/pdf/content.ts"; +import { FontSet, unembeddedFonts } from "../src/pdf/fonts.ts"; import { find, mcidText, pagesOf, readFixture, readingOrder, structTree, tagFixture } from "./helpers.ts"; const TEXT = ["text-simple", "text-embedded", "text-two-column", "links", "form-acroform", "cjk", "blank-page"]; @@ -167,11 +169,52 @@ test("a page with too many words is refused", () => { assert.throws(() => tag(readFixture("text-simple.pdf"), { lang: "en", pages: [{ sourcePage: 1, html }] }), { code: "too_many_words", exit: 1 }); }); -test("a character no font has is reported, not a verification failure", () => { +test("HTML words not on the page are laid out one after another, so none is lost to extraction", () => { + // Squeezed into one spot, the repeated letters of "viii" and "Illustrations" merge when extracted. + const pages = { lang: "en", title: "T", pages: [{ sourcePage: 1, html: pagesOf("text-simple").pages[0].html.replace("Permit", "Permit v Tables viii Illustrations") }] }; const report = newReport(); - tag(readFixture("text-simple.pdf"), { lang: "en", pages: [{ sourcePage: 1, html: "

Parking Permit ๐Ÿฆ„

" }] }, {}, report); + const doc = new mupdf.PDFDocument(tag(readFixture("text-simple.pdf"), pages, {}, report)); + assert.equal(report.pages[0].addedFromHtml, 4); + assert.match(readingOrder(structTree(doc)), /^Parking Permit v Tables viii Illustrations Residents/); + assert.match(doc.loadPage(0).toStructuredText("").asText(), /v\s*Tables\s*viii\s*Illustrations/); + + // Too many to fit on the line: they wrap, rather than run off the page. + const long = { ...pages, pages: [{ sourcePage: 1, html: pages.pages[0].html.replace("Permit", "Permit" + " Tablesโ€ฆโ€ฆโ€ฆโ€ฆโ€ฆโ€ฆโ€ฆ".repeat(40)) }] }; + const wrapped = newReport(); + tag(readFixture("text-simple.pdf"), long, {}, wrapped); + assert.equal(wrapped.verification.textPreserved, true); +}); + +test("a word in a very narrow box keeps its repeated letters", () => { + // OCR of sideways text gives boxes like this one: tall, and a tenth as wide as the word. + const doc = new mupdf.PDFDocument(); + const fonts = new FontSet(), overlay = new Overlay(fonts, mupdf.Matrix.identity); + overlay.word("Juasaffig", [100, 100, 116, 140], 140, 37); + const ops = overlay.toString(); + doc.insertPage(-1, doc.addPage([0, 0, 300, 300], 0, { Font: fonts.embed(doc, [ops]) }, ops)); + assert.equal(doc.loadPage(0).toStructuredText("").asText().trim(), "Juasaffig"); +}); + +test("a character no font has is reported and left out, not a verification failure", () => { + const report = newReport(); + tag(readFixture("text-simple.pdf"), { lang: "en", pages: [{ sourcePage: 1, html: "

Parking Permit ๐Ÿฆ„ x๐Ÿฆ„

" }] }, {}, report); assert.equal(report.warnings.find((w) => w.code === "missing_glyph")?.detail, "๐Ÿฆ„"); assert.equal(report.verification.textPreserved, true); + const overlay = new Overlay(new FontSet(), mupdf.Matrix.identity); + overlay.word("๐Ÿฆ„", [0, 0, 10, 10], 10, 10); + assert.doesNotMatch(overlay.toString(), /Tj/, "no .notdef glyph"); + overlay.word("x๐Ÿฆ„", [0, 0, 10, 10], 10, 10); + assert.match(overlay.toString(), /<[0-9a-f]{4}[0-9a-f]{4}> Tj/, "x and a space"); +}); + +test("marked content left from an old tag tree is reported and stops the PDF/UA claim", () => { + const doc = new mupdf.PDFDocument(readFixture("text-embedded.pdf")); + const page = doc.findPage(0); + page.put("Contents", doc.addStream("/P <> BDC " + page.get("Contents").readStream().asString() + " EMC", {})); + const report = newReport(); + const out = new mupdf.PDFDocument(tag(doc.saveToBuffer("").asUint8Array().slice(), pagesOf("text-embedded"), {}, report)); + assert.match(report.warnings.find((w) => w.code === "source_marked_content")?.detail ?? "", /^Pages 1 /); + assert.doesNotMatch(out.getTrailer().get("Root", "Metadata").readStream().asString(), /pdfuaid:part/); }); test("an internal link: Reference > Link owns the GoTo annotation, which gets the link text", () => { @@ -301,3 +344,12 @@ test("with no title there is no PDF/UA claim, no empty title, and --strict fails assert.doesNotMatch(packet, /pdfuaid:part>|/); assert.match(packet, /Old/, "an old title stays when there is no new one"); }); + +test("deeply nested form XObjects do not overflow the stack", () => { + const doc = new mupdf.PDFDocument(readFixture("text-embedded.pdf")); + let inner = doc.addStream("/P <> BDC EMC", { Type: "XObject", Subtype: "Form", BBox: [0, 0, 1, 1] }); + for (let i = 0; i < 20000; i++) inner = doc.addStream("/X Do", { Type: "XObject", Subtype: "Form", BBox: [0, 0, 1, 1], Resources: { XObject: { X: inner } } }); + doc.findPage(0).get("Resources").put("XObject", { Deep: inner }); + assert.deepEqual(unembeddedFonts(doc), []); + assert.deepEqual(pagesWithMcids(doc), [], "past the depth limit, not searched"); +}); From 44417f71519f67d6ba1a6ee8fcfe21ab0d478c11 Mon Sep 17 00:00:00 2001 From: Blake Bertuccelli-Booth <46652+bbertucc@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:33:45 -0400 Subject: [PATCH 03/11] Tag a figure with no image text as Div, so its caption and table stay content veraPDF 7.3-1 failed on a real Iris page: a chart
holding only a figcaption and a data table became a Figure with no Alt. Co-Authored-By: Claude Opus 5.5 --- src/html/build.ts | 6 ++++-- test/html.test.ts | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/html/build.ts b/src/html/build.ts index 4b2071f..97cef06 100644 --- a/src/html/build.ts +++ b/src/html/build.ts @@ -101,10 +101,12 @@ function build(e: Elem | string, parent: Node, ctx: Ctx) { return; } case "figure": { - const fig = add("Figure"); let alt: string | undefined; walk(e, (d) => { if (d.tag === "img" && d.attrs.alt) alt ??= d.attrs.alt; }); - if (alt) fig.alt = alt; + // With no image text, a Figure would hide its caption and tables behind a missing Alt (PDF/UA-1 7.3). Keep them as content. + if (!alt) return kids(add("Div"), inner); + const fig = add("Figure"); + fig.alt = alt; return kids(fig, { ...inner, inFigure: true }); } case "a": { diff --git a/test/html.test.ts b/test/html.test.ts index 0f2f5fc..bb0978a 100644 --- a/test/html.test.ts +++ b/test/html.test.ts @@ -37,6 +37,10 @@ test("images: alt text, decorative and missing", () => { assert.deepEqual(top.kids.map((k) => [(k as Node).type, (k as Node).alt]), [["Figure", "A map"]]); assert.deepEqual(warnings.map((w) => w.code), ["missing_alt"]); assert.equal(kids('
Chart
Sales
'), 'Figure(Caption("Sales"))'); + // A figure with no image text is a group: its caption and table stay content. + const chart = build("
Bars
1
"); + assert.equal(chart.top.kids.map(shape).join(), 'Div(Caption("Bars"), Table(TBody(TR(TD("1")))))'); + assert.deepEqual(chart.warnings.map((w) => w.code), ["missing_alt"]); }); test("links and footnotes", () => { From 5fef3dca938de170f0c208f1e31b8e1b0144522f Mon Sep 17 00:00:00 2001 From: Blake Bertuccelli-Booth <46652+bbertucc@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:36:30 -0400 Subject: [PATCH 04/11] Rewrite a damaged PDF from its repair instead of refusing it 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 --- README.md | 4 ++-- src/pdf/document.ts | 14 ++++++++------ src/tag.ts | 2 +- test/document.test.ts | 14 ++++++++++++++ 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ce58b99..14c89f9 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Text fields take strings, checkboxes `true`/`false`, radio groups and lists one 1. The page's original drawing is kept byte for byte and marked as an artifact. 2. Iris's words are matched to the words on the page (from the text layer, or from Tesseract on a scan). 3. An invisible text layer is added with Iris's words at those positions, tagged with the structure from the HTML: headings, lists, tables with their headers, links, figures with alt text, form fields. -4. The file is saved incrementally: the original bytes are the start of the output. +4. The file is saved incrementally: the original bytes are the start of the output. A damaged file is instead rewritten from mupdf's repair of it, with warning `repaired`. Then two checks run, and if either fails nothing is written (exit 2): @@ -83,7 +83,7 @@ The output declares PDF/UA-1 only when it has a title, every page is tagged, eve | Exit | When | |---|---| | 0 | Done. | -| 1 | Refused: `encrypted` (no or wrong password), `permissions_denied`, `damaged`, `too_many_pages` (over 25), `too_many_words` (over 4000 on a page), `already_tagged`, `xfa` (dynamic form), `signed`, `no_acroform_field`, `no_text_positions`, `strict`. | +| 1 | Refused: `encrypted` (no or wrong password), `permissions_denied`, `too_many_pages` (over 25), `too_many_words` (over 4000 on a page), `already_tagged`, `xfa` (dynamic form), `signed`, `no_acroform_field`, `no_text_positions`, `strict`. | | 2 | A check failed: `pixels_changed`, `text_lost`. | | 3 | Bad input: `unreadable`, `bad_pages`, `no_document_language`, `bad_value`, `field_not_settable`, `bad_arguments`. | diff --git a/src/pdf/document.ts b/src/pdf/document.ts index 16bbbfe..5ed2283 100644 --- a/src/pdf/document.ts +++ b/src/pdf/document.ts @@ -10,6 +10,7 @@ export type Source = { signed: boolean; acroform: boolean; xfa: boolean; + repaired: boolean; // damaged: saved as a full rewrite, not an update warnings: Warning[]; }; @@ -37,16 +38,17 @@ export function openPdf(bytes: Uint8Array, opts: OpenOptions = {}): Source { forEachField(doc, (field) => { if (inherited(field, "FT")?.asName() === "Sig" && inherited(field, "V")) signed = true; }); - const source = { doc, encrypted, signed, acroform: !acroform.isNull(), xfa, warnings }; + const repaired = doc.wasRepaired() || !doc.canBeSavedIncrementally(); + const source = { doc, encrypted, signed, acroform: !acroform.isNull(), xfa, repaired, warnings }; if (opts.readOnly) return source; // An owner password can forbid changes. We do not work around it. if (!doc.hasPermission("edit")) { throw new IrisPdfError("permissions_denied", "The PDF's owner does not permit changes to it."); } - if (doc.wasRepaired() || !doc.canBeSavedIncrementally()) { - throw new IrisPdfError("damaged", "The PDF is damaged, so it cannot be updated without rewriting it."); - } + // A damaged file cannot be updated in place. It is rewritten from what mupdf + // repaired, which is also what the checks render as the original. + if (source.repaired) warnings.push({ code: "repaired", detail: "The PDF was damaged; the output is a rewritten copy, not an update of the original bytes." }); const pages = doc.countPages(); if (pages > MAX_PAGES) { throw new IrisPdfError("too_many_pages", `The PDF has ${pages} pages; the limit is ${MAX_PAGES}.`); @@ -90,7 +92,7 @@ export function inherited(field: mupdf.PDFObject, key: string): mupdf.PDFObject return null; } -export function save(doc: mupdf.PDFDocument): Uint8Array { +export function save(doc: mupdf.PDFDocument, rewrite = false): Uint8Array { // A copy: the buffer lives in mupdf's memory, which can move. - return doc.saveToBuffer("incremental,compress").asUint8Array().slice(); + return doc.saveToBuffer(rewrite ? "compress,encrypt=keep" : "incremental,compress").asUint8Array().slice(); } diff --git a/src/tag.ts b/src/tag.ts index ac5dc8a..1cc1064 100644 --- a/src/tag.ts +++ b/src/tag.ts @@ -115,7 +115,7 @@ export function tag(pdf: Uint8Array, input: PagesInput, opts: TagOptions = {}, r report.structure = { elements: struct.elements, byType: struct.byType }; setDocumentInfo(doc, lang, title ?? "", !!title && !untagged && !unembedded.length && !marked.length); - const out = save(doc); + const out = save(doc, src.repaired); report.sizeIncreaseBytes = out.length - pdf.length; if (opts.verify !== false) verify(pdf, out, opts, filled.changed, overlayText, report); const strict = report.warnings.filter((w) => STRICT.includes(w.code)); diff --git a/test/document.test.ts b/test/document.test.ts index a2426f7..70519ab 100644 --- a/test/document.test.ts +++ b/test/document.test.ts @@ -50,6 +50,20 @@ test("opens an encrypted PDF with its password", () => { assert.equal(back.countPages(), doc.countPages()); }); +test("a damaged PDF is rewritten from its repair, checked, and keeps its encryption", () => { + // Point startxref at the wrong place, as a bad split or truncated upload does. + const damage = (pdf: Uint8Array) => new Uint8Array(Buffer.from(Buffer.from(pdf).toString("latin1").replace(/startxref\s+\d+\s+%%EOF\s*$/, "startxref\n9\n%%EOF\n"), "latin1")); + const report = newReport(); + const out = tag(damage(readFixture("text-simple.pdf")), simple, {}, report); + assert.ok(report.warnings.some((w) => w.code === "repaired")); + assert.equal(report.verification.textPreserved, true); + assert.equal(report.verification.differingPixels, 0); + assert.ok(!new mupdf.PDFDocument(out).wasRepaired()); + const locked = tag(damage(readFixture("encrypted.pdf")), simple, { password: "open" }); + const back = new mupdf.PDFDocument(locked); + assert.ok(back.needsPassword() && back.authenticatePassword("open")); +}); + test("--allow-signed tags a signed PDF and says the signature is gone", () => { const report = newReport(); tag(readFixture("signed.pdf"), pagesOf("form-acroform"), { allowSigned: true }, report); From b8a92c84bc8fe8a05f19dfc0f431cbbc255fb6ea Mon Sep 17 00:00:00 2001 From: Blake Bertuccelli-Booth <46652+bbertucc@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:47:07 -0400 Subject: [PATCH 05/11] Address review: own field name, size-0 words, figure as group, AcroForm 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. -
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 --- src/html/build.ts | 11 ----------- src/html/map.ts | 4 ++-- src/pdf/content.ts | 4 ++-- src/pdf/fonts.ts | 1 + src/tag.ts | 2 +- test/forms.test.ts | 2 ++ test/html.test.ts | 4 ++-- test/tag.test.ts | 8 ++++++++ 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/html/build.ts b/src/html/build.ts index 97cef06..4f3487d 100644 --- a/src/html/build.ts +++ b/src/html/build.ts @@ -34,7 +34,6 @@ type Ctx = { notes: Set; // ids that internal links point at label?: string; // text of an enclosing