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
217 changes: 217 additions & 0 deletions packages/pdf-codec/src/interpret.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,161 @@ describe("interpretContentStream: text", () => {
});
});

// ISO 32000-1 Table 52 lists the text state parameters (font and size, character spacing, word spacing, horizontal scaling, leading, rise) among the device-independent graphics state parameters, so `q` saves them and `Q` restores them exactly as it does the CTM or the fill colour. Only the text matrix and text line matrix are excluded -- those are text object state (9.4.1), reset by BT and untouched by q/Q.
describe("interpretContentStream: text state is graphics state", () => {
it("restores the font a Q's matching q selected, so a Tf inside the pair does not leak past it", () => {
const { sink } = collectDiagnostics();
const items = interpretContentStream(
textBytes(
"BT /F1 12 Tf (First) Tj ET q /F2 24 Tf Q BT 0 -20 Td (Second) Tj ET",
),
EMPTY_RESOURCES,
{
fontMetrics: fixedWidthFontMetrics(),
resolver: makeResolver(new Map()),
sink,
},
);
expect(items).toHaveLength(2);
const [first, second] = items;
if (first?.kind !== "text" || second?.kind !== "text") {
throw new Error("expected two text items");
}
expect(first.fontResourceName).toBe("F1");
expect(first.sizePt).toBe(12);
expect(second.fontResourceName).toBe("F1");
expect(second.sizePt).toBe(12);
});

it("restores character spacing across a q/Q pair", () => {
const { sink } = collectDiagnostics();
const items = interpretContentStream(
textBytes("/F1 10 Tf 0 Tc q 100 Tc Q BT 0 0 Td (AB) 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");
}
// Two glyphs at width 5 each (500/1000 * 10) with the restored Tc=0: 10, not the 210 a leaked Tc=100 would give.
expect(item.endMatrix).toEqual([10, 0, 0, 10, 10, 0]);
});

it("restores word spacing across a q/Q pair", () => {
const { sink } = collectDiagnostics();
const items = interpretContentStream(
textBytes("/F1 10 Tf q 40 Tw Q BT 0 0 Td (A B) 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.endMatrix).toEqual([10, 0, 0, 10, 15, 0]);
});

it("restores horizontal scaling across a q/Q pair", () => {
const { sink } = collectDiagnostics();
const items = interpretContentStream(
textBytes("/F1 10 Tf q 50 Tz Q BT 0 0 Td (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, 0]);
});

it("restores leading across a q/Q pair, so T* advances by the outer TL", () => {
const { sink } = collectDiagnostics();
const items = interpretContentStream(
textBytes("/F1 10 Tf 20 TL q 5 TL Q BT 0 0 Td (A) Tj T* (B) Tj ET"),
EMPTY_RESOURCES,
{
fontMetrics: fixedWidthFontMetrics(500, 1),
resolver: makeResolver(new Map()),
sink,
},
);
const [, second] = items;
if (second?.kind !== "text") {
throw new Error("expected a second text item");
}
expect(second.startMatrix).toEqual([10, 0, 0, 10, 0, -20]);
});

it("restores text rise across a q/Q pair", () => {
const { sink } = collectDiagnostics();
const items = interpretContentStream(
textBytes("/F1 10 Tf q 5 Ts Q BT 0 0 Td (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, 0]);
});

it("keeps a text-state change made outside any q/Q pair, which no Q ever unwinds", () => {
const { sink } = collectDiagnostics();
const items = interpretContentStream(
textBytes("q 1 0 0 1 0 0 cm Q /F2 24 Tf BT 0 0 Td (X) Tj ET"),
EMPTY_RESOURCES,
{
fontMetrics: fixedWidthFontMetrics(),
resolver: makeResolver(new Map()),
sink,
},
);
const [item] = items;
if (item?.kind !== "text") {
throw new Error("expected a text item");
}
expect(item.fontResourceName).toBe("F2");
expect(item.sizePt).toBe(24);
});

it("leaves the text matrix untouched by Q, since Tm/Tlm are text object state rather than graphics state", () => {
const { sink } = collectDiagnostics();
const items = interpretContentStream(
textBytes("BT /F1 10 Tf q 0 0 Td Q 30 40 Td (X) Tj ET"),
EMPTY_RESOURCES,
{
fontMetrics: fixedWidthFontMetrics(),
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, 30, 40]);
});
});

describe("interpretContentStream: axis-aligned rectangles", () => {
it("recovers a rectangle painted under a non-rotated CTM", () => {
const { sink } = collectDiagnostics();
Expand Down Expand Up @@ -865,6 +1020,68 @@ describe("interpretContentStream: XObjects", () => {
]);
});

// ISO 32000-1 8.10.2: a form XObject's content stream executes in the graphics state in effect at the moment of Do, as if it were nested inline inside an implicit q/Q pair -- so the text state travels inward, and the form's own changes to it do not travel back out.
it("runs a Form XObject in the caller's text state, so a form with no Tf of its own draws in the inherited font", () => {
const { sink } = collectDiagnostics();
const formDict = pdfDict({
Type: pdfName("XObject"),
Subtype: pdfName("Form"),
});
const objects = new Map<number, PdfObject>([
[30, pdfStream(formDict, textBytes("BT (FormText) Tj ET"))],
]);
const resources = pdfDict({ XObject: pdfDict({ Fm2: pdfRef(30, 0) }) });
const items = interpretContentStream(
textBytes("BT /F1 12 Tf (Outer) Tj ET /Fm2 Do"),
resources,
{
fontMetrics: fixedWidthFontMetrics(),
resolver: makeResolver(objects),
sink,
},
);
expect(items).toHaveLength(2);
const [outer, inner] = items;
if (outer?.kind !== "text" || inner?.kind !== "text") {
throw new Error("expected two text items");
}
expect(Array.from(outer.codes)).toEqual(Array.from(textBytes("Outer")));
expect(Array.from(inner.codes)).toEqual(Array.from(textBytes("FormText")));
expect(inner.fontResourceName).toBe("F1");
expect(inner.sizePt).toBe(12);
});

it("does not let a Form XObject's own text-state changes survive back into the caller", () => {
const { sink } = collectDiagnostics();
const formDict = pdfDict({
Type: pdfName("XObject"),
Subtype: pdfName("Form"),
});
const objects = new Map<number, PdfObject>([
[31, pdfStream(formDict, textBytes("BT /F2 24 Tf 8 Tc (Inner) Tj ET"))],
]);
const resources = pdfDict({ XObject: pdfDict({ Fm3: pdfRef(31, 0) }) });
const items = interpretContentStream(
textBytes("BT /F1 10 Tf (Outer) Tj ET /Fm3 Do BT 0 0 Td (After) Tj ET"),
resources,
{
fontMetrics: fixedWidthFontMetrics(500, 1),
resolver: makeResolver(objects),
sink,
},
);
expect(items).toHaveLength(3);
const [, inner, after] = items;
if (inner?.kind !== "text" || after?.kind !== "text") {
throw new Error("expected text items from the form and after it");
}
expect(inner.fontResourceName).toBe("F2");
expect(after.fontResourceName).toBe("F1");
expect(after.sizePt).toBe(10);
// Five glyphs at width 5 each with the caller's own Tc=0, not the form's Tc=8.
expect(after.endMatrix).toEqual([10, 0, 0, 10, 25, 0]);
});

it("stops a self-referential chain of forms at the recursion depth limit, with a diagnostic", () => {
const { sink, diagnostics } = collectDiagnostics();
const selfResources = pdfDict({
Expand Down
Loading
Loading