From 3d4559eeef7e9b8e5a701def1cceb46b7231ff91 Mon Sep 17 00:00:00 2001 From: Blake Bertuccelli-Booth <46652+bbertucc@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:57:50 -0400 Subject: [PATCH 1/2] Squeeze a word running off the page onto it, so its tagged text keeps every letter Extractors drop glyphs off the page. A source word whose last glyph starts just inside the page edge kept it, but ours, stretched to the same box in another font, could start just outside, and the text gate failed. Co-Authored-By: Claude Opus 5.5 --- src/tag.ts | 11 ++++++----- test/tag.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/tag.ts b/src/tag.ts index ed95200..0526b72 100644 --- a/src/tag.ts +++ b/src/tag.ts @@ -188,7 +188,7 @@ function tagPage(page: mupdf.PDFPage, i: number, html: string, ctx: PageCtx): { throw new IrisPdfError("too_many_words", `Page ${n} has more than ${MAX_WORDS} words.`); const { match, complete } = align(ordered.map((o) => o.word.norm), tokens.map((t) => t.norm)); if (!complete) warn({ code: "alignment_incomplete", detail: "The page and the HTML differ too much to match every word in time." }); - const blockOf = new Map(); + const blockOf = new Map(), bounds = page.getBounds() as Box; const matched = new Set(); match.forEach((t, k) => { if (t < 0) { @@ -197,10 +197,9 @@ function tagPage(page: mupdf.PDFPage, i: number, html: string, ctx: PageCtx): { } report.matched++; matched.add(ordered[k].word); - ordered[k].word.at = placed(words[tokens[t].words[0]]); + ordered[k].word.at = placed(words[tokens[t].words[0]], bounds); blockOf.set(t, ordered[k].block); }); - const bounds = page.getBounds(); // With no page words, each block starts a new line, so blocks do not run together. const breaks = new Set(words.length ? [] : ordered.filter((o, k) => k && o.newLine).map((o) => o.word)); fillPositions(ordered.map((o) => o.word), bounds, (s) => ctx.fonts.width(s), breaks); @@ -227,7 +226,7 @@ function tagPage(page: mupdf.PDFPage, i: number, html: string, ctx: PageCtx): { } report.lost++; if (!run) lost.set(block, [...(lost.get(block) ?? []), (run = { words: [] })]); - run.words.push({ text: w.text, norm: "", at: placed(w) }); + run.words.push({ text: w.text, norm: "", at: placed(w, bounds) }); } }); for (const runs of lost.values()) { @@ -275,7 +274,9 @@ type Link = { obj: mupdf.PDFObject; box: Box; uri: string; used: boolean }; type Emitter = PageCtx & { pageObj: mupdf.PDFObject; overlay: Overlay; links: Link[]; mcids: number }; const asP = (r: Run): Node => ({ type: "P", kids: [r] }); -const placed = (w: PageWord): Placed => ({ box: w.box, baseline: w.baseline, size: w.size }); +// A word running off the page is squeezed onto it: extractors drop glyphs off the page. +const placed = (w: PageWord, page: Box): Placed => + ({ box: [Math.max(w.box[0], page[0]), w.box[1], Math.min(w.box[2], page[2]), w.box[3]], baseline: w.baseline, size: w.size }); // 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 diff --git a/test/tag.test.ts b/test/tag.test.ts index a658d74..40fa5c5 100644 --- a/test/tag.test.ts +++ b/test/tag.test.ts @@ -234,6 +234,17 @@ test("a word in a very narrow box keeps its repeated letters", () => { assert.doesNotMatch(overlay.toString(), /NaN|Infinity/); }); +test("a word running off the page edge keeps every letter of its tagged text", () => { + // Extractors drop a glyph off the page. The source word is monospaced, so its + // last letter starts just inside; stretched to the same box, ours would not. + const doc = new mupdf.PDFDocument(); + const res = doc.addObject({ Font: { F1: doc.addSimpleFont(new mupdf.Font("Helvetica")), F2: doc.addSimpleFont(new mupdf.Font("Courier")) } }); + doc.insertPage(-1, doc.addPage([0, 0, 300, 300], 0, res, "BT /F1 12 Tf 10 200 Td (Thanks) Tj ET BT /F2 12 Tf 276.4 200 Td (Hail) Tj ET")); + const report = newReport(); + tag(doc.saveToBuffer("").asUint8Array().slice(), { lang: "en", title: "t", pages: [{ sourcePage: 1, html: "

Thanks Hail

" }] }, {}, report); + assert.equal(report.verification.textPreserved, true); +}); + 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); From 0dd1f0a69a5eca710746ca4c68c36d96f2cd94fa Mon Sep 17 00:00:00 2001 From: Blake Bertuccelli-Booth <46652+bbertucc@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:04:51 -0400 Subject: [PATCH 2/2] Test the left page edge too Co-Authored-By: Claude Opus 5.5 --- test/tag.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/tag.test.ts b/test/tag.test.ts index 40fa5c5..744df8f 100644 --- a/test/tag.test.ts +++ b/test/tag.test.ts @@ -237,11 +237,12 @@ test("a word in a very narrow box keeps its repeated letters", () => { test("a word running off the page edge keeps every letter of its tagged text", () => { // Extractors drop a glyph off the page. The source word is monospaced, so its // last letter starts just inside; stretched to the same box, ours would not. + // So too at the left edge, for its first letter. const doc = new mupdf.PDFDocument(); const res = doc.addObject({ Font: { F1: doc.addSimpleFont(new mupdf.Font("Helvetica")), F2: doc.addSimpleFont(new mupdf.Font("Courier")) } }); - doc.insertPage(-1, doc.addPage([0, 0, 300, 300], 0, res, "BT /F1 12 Tf 10 200 Td (Thanks) Tj ET BT /F2 12 Tf 276.4 200 Td (Hail) Tj ET")); + doc.insertPage(-1, doc.addPage([0, 0, 300, 300], 0, res, "BT /F1 12 Tf 10 200 Td (Thanks) Tj ET BT /F2 12 Tf 276.4 200 Td (Hail) Tj ET BT /F2 12 Tf -4 180 Td (iWall) Tj ET")); const report = newReport(); - tag(doc.saveToBuffer("").asUint8Array().slice(), { lang: "en", title: "t", pages: [{ sourcePage: 1, html: "

Thanks Hail

" }] }, {}, report); + tag(doc.saveToBuffer("").asUint8Array().slice(), { lang: "en", title: "t", pages: [{ sourcePage: 1, html: "

Thanks Hail

iWall

" }] }, {}, report); assert.equal(report.verification.textPreserved, true); });