Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/documents.js/src/convert/convert-from-pdf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
15 changes: 15 additions & 0 deletions packages/documents.js/src/test-support/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,21 @@ export function tjAdjustedWordSpacesPdf(): Uint8Array<ArrayBuffer> {
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<ArrayBuffer> {
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;
Expand Down
44 changes: 42 additions & 2 deletions packages/pdf-codec/src/interpret.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions packages/pdf-codec/src/interpret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,15 +464,16 @@ 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
? translationMatrix(0, adjustment)
: 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);
}
}
Expand Down
Loading