From 9eaf741c811136b9de4c056b02431a845622aa7c Mon Sep 17 00:00:00 2001 From: Blake Bertuccelli-Booth <46652+bbertucc@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:29:41 -0400 Subject: [PATCH] Compare drawn text in the text gate, not the old structure's ActualText A tagged source can give a glyph ActualText through its structure tree, such as a soft hyphen at a line break. Tagging replaces that tree, so the same glyph then extracted differently and the gate reported it lost. Co-Authored-By: Claude Opus 5.5 --- src/verify/text.ts | 3 ++- test/tag.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/verify/text.ts b/src/verify/text.ts index 8435779..e213403 100644 --- a/src/verify/text.ts +++ b/src/verify/text.ts @@ -38,6 +38,7 @@ function take(have: Map, want: string[]): string[] { } // Page content only, so a field's changed value does not count as lost text. +// The drawn text only: ActualText comes from the old structure, which tagging replaces. function words(doc: mupdf.PDFDocument, i: number): string[] { - return doc.loadPage(i).toDisplayList(false).toStructuredText("").asText().split(/\s+/).filter(Boolean); + return doc.loadPage(i).toDisplayList(false).toStructuredText("ignore-actualtext").asText().split(/\s+/).filter(Boolean); } diff --git a/test/tag.test.ts b/test/tag.test.ts index 4d5f7af..a658d74 100644 --- a/test/tag.test.ts +++ b/test/tag.test.ts @@ -148,6 +148,39 @@ test("the verification gate sees changed pixels and lost text", () => { assert.ok(t.missing.includes("Residents")); }); +test("the text gate compares drawn text, not the old structure's ActualText", () => { + // A tagged source marks a line-end hyphen as a soft hyphen: ActualText U+00AD on its MCID. + // Our tagging replaces that structure, so the same glyph then reads as "-". + const doc = new mupdf.PDFDocument(); + const content = (end: string) => "BT /F1 10 Tf 20 300 Td\n" + + "/P <> BDC (See example.com/2022-PA) Tj EMC\n/Span <> BDC (-) Tj EMC\n" + + `0 -14 Td /P <> BDC (${end}) Tj EMC\nET\n`; + const font = doc.addObject({ Type: "Font", Subtype: "Type1", BaseFont: "Helvetica", Encoding: "WinAnsiEncoding" }); + const page = doc.addPage([0, 0, 306, 396], 0, { Font: { F1: font } }, content("0060.htm today.")); + page.put("StructParents", 0); + doc.insertPage(-1, page); + const pg = doc.findPage(0); + const root = doc.addObject({ Type: "StructTreeRoot" }); + const kid = (s: string, mcid: number, extra = {}) => doc.addObject({ Type: "StructElem", S: s, P: root, Pg: pg, K: mcid, ...extra }); + const kids = [kid("P", 0), kid("Span", 1, { ActualText: doc.newString("­") }), kid("P", 2)]; + root.put("K", kids); + root.put("ParentTree", doc.addObject({ Nums: [0, kids] })); + doc.getTrailer().get("Root").put("StructTreeRoot", root); + doc.getTrailer().get("Root").put("MarkInfo", { Marked: true }); + const pdf = doc.saveToBuffer("").asUint8Array().slice(); + assert.match(new mupdf.PDFDocument(pdf).loadPage(0).toStructuredText("").asText(), /2022-PA­/, "the source reads a soft hyphen"); + + const pages = { lang: "en", title: "T", pages: [{ sourcePage: 1, html: "

See example.com/2022-PA0060.htm today.

" }] }; + const report = newReport(); + tag(pdf, pages, {}, report); + assert.equal(report.verification.textPreserved, true); + + // Real loss still fails: the next line's word is gone. + const cut = new mupdf.PDFDocument(pdf); + cut.findPage(0).put("Contents", cut.addStream(content("today."), {})); + assert.deepEqual(compareText(new mupdf.PDFDocument(pdf), cut)[0].missing, ["0060.htm"]); +}); + test("xmp keeps an existing packet and replaces only its title and PDF/UA part", () => { const old = 'Old'; const out = xmp(old, "New & ");