From 25c883fb941f8d3835d1f14e6c3462d8df4ccc73 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 30 Oct 2026 20:29:30 +0000 Subject: [PATCH 1/2] fix(pdf-codec): start a text run where its first glyph is painted A TJ array that opens with a numeric adjustment moved the pen but not the start of the run that followed, so the run was reported from where the previous text ended and its advance absorbed the gap. A producer that starts a new text-showing operator with the word space expressed as an adjustment (a cross-reference set in a link colour inside prose) then had that word read as touching the one before it. An adjustment met before any glyph of a run now moves the run start with the pen. The interpreter test that pinned a leading adjustment inside the run is changed to the corrected position. Closes #1724 --- packages/pdf-codec/src/interpret.test.ts | 44 ++++++++++++++++++++++-- packages/pdf-codec/src/interpret.ts | 5 +-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/pdf-codec/src/interpret.test.ts b/packages/pdf-codec/src/interpret.test.ts index f89f490e5..8e49e43d8 100644 --- a/packages/pdf-codec/src/interpret.test.ts +++ b/packages/pdf-codec/src/interpret.test.ts @@ -212,7 +212,7 @@ describe("interpretContentStream: text", () => { expect(items).toHaveLength(1); }); - it("does not end a run for a wide adjustment that precedes its first string, so the run still starts before it", () => { + it("starts a run after an adjustment that precedes its first string, where its first glyph is painted", () => { const { sink } = collectDiagnostics(); const items = interpretContentStream( textBytes("BT /F1 10 Tf 0 0 Td [-250 (A)] TJ ET"), @@ -228,10 +228,50 @@ describe("interpretContentStream: text", () => { throw new Error("expected a text item"); } expect(items).toHaveLength(1); - expect(item.startMatrix).toEqual([10, 0, 0, 10, 0, 0]); + // The adjustment moves the pen 250/1000 * 10 = 2.5 on before 'A' is painted, so the run covers 2.5 to 7.5 and the gap before it stays a gap. + expect(item.startMatrix).toEqual([10, 0, 0, 10, 2.5, 0]); expect(item.endMatrix).toEqual([10, 0, 0, 10, 7.5, 0]); }); + it("starts a run after a leading adjustment too narrow to be a word space as well", () => { + const { sink } = collectDiagnostics(); + const items = interpretContentStream( + textBytes("BT /F1 10 Tf 0 0 Td [-50 (A)] TJ ET"), + EMPTY_RESOURCES, + { + fontMetrics: fixedWidthFontMetrics(500, 1), + resolver: makeResolver(new Map()), + sink, + }, + ); + const [item] = items; + if (item?.kind !== "text") { + throw new Error("expected a text item"); + } + expect(item.startMatrix).toEqual([10, 0, 0, 10, 0.5, 0]); + expect(item.endMatrix).toEqual([10, 0, 0, 10, 5.5, 0]); + }); + + it("keeps a run's start at its first glyph across a kerning adjustment inside it", () => { + const { sink } = collectDiagnostics(); + const items = interpretContentStream( + textBytes("BT /F1 10 Tf 0 0 Td [(A) -50 (V)] TJ ET"), + EMPTY_RESOURCES, + { + fontMetrics: fixedWidthFontMetrics(500, 1), + resolver: makeResolver(new Map()), + sink, + }, + ); + const [item] = items; + if (item?.kind !== "text") { + throw new Error("expected a text item"); + } + expect(items).toHaveLength(1); + expect(item.startMatrix).toEqual([10, 0, 0, 10, 0, 0]); + expect(item.endMatrix).toEqual([10, 0, 0, 10, 10.5, 0]); + }); + it("applies a rotated CTM to the text rendering matrix", () => { const { sink } = collectDiagnostics(); const items = interpretContentStream( diff --git a/packages/pdf-codec/src/interpret.ts b/packages/pdf-codec/src/interpret.ts index 59d7085ad..883fccc9e 100644 --- a/packages/pdf-codec/src/interpret.ts +++ b/packages/pdf-codec/src/interpret.ts @@ -464,7 +464,7 @@ function runContentStream( totalLength > 0 && adjustment >= DEFAULT_WORD_GAP_EM * gs.fontSizePt; if (endsRun) { - flushRun(); + flushRun(); // leaves totalLength at 0, so the start is taken after this adjustment below } text.tm = multiplyMatrices( vertical @@ -472,7 +472,8 @@ function runContentStream( : translationMatrix(adjustment * gs.horizScale, 0), text.tm, ); - if (endsRun) { + // A run starts where its first glyph is painted, so an adjustment met before any glyph of the run (one leading the array, or one that has just ended the previous run) moves the run's start with the pen. Left at the pen's position before a leading adjustment, the run would claim the gap that adjustment opens as part of its own advance, and a producer that starts a new text-showing operator with a word space expressed as an adjustment would have that word read as touching the one before it. + if (totalLength === 0) { startMatrix = computeTrm(gs, text); } } From 7d4a56d82e230fdb85a6a06893c3a6ed707c89f3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 30 Oct 2026 20:29:53 +0000 Subject: [PATCH 2/2] test(documents.js): read words apart when a TJ array opens with the space Refs #1724 --- .../src/convert/convert-from-pdf.test.ts | 12 +++++++++++- packages/documents.js/src/test-support/pdf.ts | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/convert/convert-from-pdf.test.ts b/packages/documents.js/src/convert/convert-from-pdf.test.ts index 31ef99eb4..cdc1467b6 100644 --- a/packages/documents.js/src/convert/convert-from-pdf.test.ts +++ b/packages/documents.js/src/convert/convert-from-pdf.test.ts @@ -17,7 +17,10 @@ import { requireArrayBufferBytes } from "../model/bytes"; import { readPdf } from "pdf-codec"; import { decodeMarkdownText, encodeMarkdownText } from "../markdown/text"; import { richMarkdownText } from "../test-support/markdown"; -import { tjAdjustedWordSpacesPdf } from "../test-support/pdf"; +import { + leadingTjAdjustmentWordSpacesPdf, + tjAdjustedWordSpacesPdf, +} from "../test-support/pdf"; import { minimalOdpBytes } from "../test-support/odp"; import { gridOdsBytes } from "../test-support/ods"; import { minimalOdtBytes } from "../test-support/odt"; @@ -156,6 +159,13 @@ describe("pdfToMarkdown word spacing", () => { ); expect(markdown.trim()).toBe("It should be noted"); }); + + it("keeps a word space a TJ array opens with, across a colour change between operators", () => { + const markdown = new TextDecoder().decode( + pdfToMarkdown(leadingTjAdjustmentWordSpacesPdf()), + ); + expect(markdown.trim()).toBe("shown in Table 2\\. Generators"); + }); }); describe("pdfToMarkdown", () => { diff --git a/packages/documents.js/src/test-support/pdf.ts b/packages/documents.js/src/test-support/pdf.ts index 74e15314d..9198f4f7c 100644 --- a/packages/documents.js/src/test-support/pdf.ts +++ b/packages/documents.js/src/test-support/pdf.ts @@ -136,6 +136,21 @@ export function tjAdjustedWordSpacesPdf(): Uint8Array { return b.bytes(); } +// One line set in three text-showing operators, the way a typesetter emits a cross-reference in a different colour inside running prose: each operator after the first opens with the word space as a leading adjustment rather than ending the one before with it. The font is selected at size 1 and scaled by the text matrix, as such producers do, so the adjustment is a quarter em of the 12pt text only once the matrix applies. No space character appears anywhere in the content stream. +export function leadingTjAdjustmentWordSpacesPdf(): Uint8Array { + const b = new FixtureBuilder().header("1.4"); + catalogPagesPageFontObjects(b, CONTENT_OBJ); + b.stream( + CONTENT_OBJ, + "<< >>", + enc( + "BT /F1 1 Tf 12 0 0 12 10 50 Tm [(shown) -250 (in)] TJ 0 0 1 rg [-250 (Table) -250 (2.)] TJ 0 g [-250 (Generators)] TJ ET", + ), + ); + b.classicXrefAndTrailer(CONTENT_OBJ, "/Root 1 0 R"); + return b.bytes(); +} + // The single highest-value fixture in the suite: Word/PowerPoint/Chrome/LibreOffice all default to PDF 1.5+ cross-reference *streams* with object streams, not the classic table our own writer emits — a reader that only handles the classic form would fail on the overwhelming majority of real-world, non-self-produced PDFs. Catalog/Pages/Page are packed into one compressed object stream (a stream object itself is never permitted inside an object stream, per ISO 32000-1 7.5.7, so the content stream, the object stream, and the xref stream itself all remain ordinary top-level objects). The xref stream is self-referential: its own entry describes its own byte offset. // A classic cross-reference stream row (ISO 32000-1 Table 18) is /W [1 4 2]: a 1-byte type field, a 4-byte second field, a 2-byte third field, seven bytes total. const XREF_STREAM_ROW_WIDTH = 7;