diff --git a/packages/pdf-codec/src/interpret.test.ts b/packages/pdf-codec/src/interpret.test.ts index fefef2861..b0583671e 100644 --- a/packages/pdf-codec/src/interpret.test.ts +++ b/packages/pdf-codec/src/interpret.test.ts @@ -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(); @@ -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([ + [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([ + [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({ diff --git a/packages/pdf-codec/src/interpret.ts b/packages/pdf-codec/src/interpret.ts index bc7bb544e..617bf8324 100644 --- a/packages/pdf-codec/src/interpret.ts +++ b/packages/pdf-codec/src/interpret.ts @@ -165,29 +165,32 @@ const FALLBACK_GLYPH_WIDTH_PER_1000 = 500; // ISO 32000-1 Table 52: the graphics state's own line width parameter defaults to 1.0 (user-space units) until a `w` operator sets it explicitly. const DEFAULT_LINE_WIDTH_PT = 1; +// ISO 32000-1 Table 52 lists the text state parameters -- the font and size a Tf selects, plus Tc/Tw/Tz/TL/Ts -- among the device-independent graphics state parameters, so they belong here rather than beside the text matrix: `q` saves them and `Q` restores them exactly as it does the CTM or the fill colour, and a form XObject invoked by `Do` inherits them exactly as it inherits the CTM (8.10.2). Only the text matrix and text line matrix are excluded, and they live in TextObjectState below. Modelling the two as one record is what makes both facts structural rather than something each operator has to remember. interface GraphicsState { readonly ctm: Matrix; readonly fillColor: LayoutColor; readonly strokeColor: LayoutColor; readonly lineWidth: number; + readonly fontResourceName: string | undefined; + readonly fontSizePt: number; + readonly charSpace: number; + readonly wordSpace: number; + readonly horizScale: number; // Tz / 100 + readonly leading: number; + readonly rise: number; } -interface TextState { +// The text matrix and text line matrix: text OBJECT state (ISO 32000-1 9.4.1), not graphics state. They exist only between BT and ET, are reset to identity by every BT, and are the one part of the text machinery `Q` must not restore -- so they sit outside GraphicsState and outside the q/Q stack entirely. +interface TextObjectState { tm: Matrix; tlm: Matrix; - fontResourceName: string | undefined; - fontSizePt: number; - charSpace: number; - wordSpace: number; - horizScale: number; // Tz / 100 - leading: number; - rise: number; } -function defaultTextState(): TextState { +function initialTextParameters(): Omit< + GraphicsState, + "ctm" | "fillColor" | "strokeColor" | "lineWidth" +> { return { - tm: IDENTITY_MATRIX, - tlm: IDENTITY_MATRIX, fontResourceName: undefined, fontSizePt: 0, charSpace: 0, @@ -198,16 +201,20 @@ function defaultTextState(): TextState { }; } -function computeTrm(ctm: Matrix, ts: TextState): Matrix { +function defaultTextObjectState(): TextObjectState { + return { tm: IDENTITY_MATRIX, tlm: IDENTITY_MATRIX }; +} + +function computeTrm(gs: GraphicsState, text: TextObjectState): Matrix { const fontMatrix: Matrix = [ - ts.fontSizePt * ts.horizScale, + gs.fontSizePt * gs.horizScale, 0, 0, - ts.fontSizePt, + gs.fontSizePt, 0, - ts.rise, + gs.rise, ]; - return multiplyMatrices(multiplyMatrices(fontMatrix, ts.tm), ctm); + return multiplyMatrices(multiplyMatrices(fontMatrix, text.tm), gs.ctm); } function numAt(operands: readonly PdfObject[], index: number): number { @@ -637,6 +644,7 @@ export function interpretContentStream( fillColor: COLOR_BLACK, strokeColor: COLOR_BLACK, lineWidth: DEFAULT_LINE_WIDTH_PT, + ...initialTextParameters(), }; runContentStream(bytes, resources, initialState, context, items, 0, []); return items; @@ -678,7 +686,7 @@ function runContentStream( const operations = readContentStream(bytes, context.sink); const gsStack: GraphicsState[] = []; let gs = initialState; - let ts = defaultTextState(); + let text = defaultTextObjectState(); let pathSubpaths: MutableSubpath[] = []; let currentSubpath: MutableSubpath | undefined; @@ -810,13 +818,14 @@ function runContentStream( }; const advanceThroughString = (codes: Uint8Array): void => { - if (ts.fontResourceName === undefined) { + const fontResourceName = gs.fontResourceName; + if (fontResourceName === undefined) { return; } let offset = 0; while (offset < codes.length) { const glyph = context.fontMetrics.glyphAdvance( - ts.fontResourceName, + fontResourceName, resources, codes, offset, @@ -825,27 +834,28 @@ function runContentStream( context.sink({ code: "pdf/font-not-resolved", severity: "warning", - message: `could not resolve font resource /${ts.fontResourceName} to compute a glyph advance; assuming a fallback width`, + message: `could not resolve font resource /${fontResourceName} to compute a glyph advance; assuming a fallback width`, }); } const widthPer1000 = glyph?.widthPer1000 ?? FALLBACK_GLYPH_WIDTH_PER_1000; const byteLength = glyph?.byteLengthConsumed ?? 1; const isSingleByteSpace = byteLength === 1 && codes[offset] === 0x20; const tx = - ((widthPer1000 / 1000) * ts.fontSizePt + - ts.charSpace + - (isSingleByteSpace ? ts.wordSpace : 0)) * - ts.horizScale; - ts.tm = multiplyMatrices(translationMatrix(tx, 0), ts.tm); + ((widthPer1000 / 1000) * gs.fontSizePt + + gs.charSpace + + (isSingleByteSpace ? gs.wordSpace : 0)) * + gs.horizScale; + text.tm = multiplyMatrices(translationMatrix(tx, 0), text.tm); offset += byteLength; } }; const showTextArray = (elements: readonly PdfObject[]): void => { - if (ts.fontResourceName === undefined) { + const fontResourceName = gs.fontResourceName; + if (fontResourceName === undefined) { return; } - const startMatrix = computeTrm(gs.ctm, ts); + const startMatrix = computeTrm(gs, text); const chunks: Uint8Array[] = []; let totalLength = 0; for (const el of elements) { @@ -854,8 +864,8 @@ function runContentStream( totalLength += el.bytes.length; advanceThroughString(el.bytes); } else if (el.kind === "number") { - const adjustment = -(el.value / 1000) * ts.fontSizePt * ts.horizScale; - ts.tm = multiplyMatrices(translationMatrix(adjustment, 0), ts.tm); + const adjustment = -(el.value / 1000) * gs.fontSizePt * gs.horizScale; + text.tm = multiplyMatrices(translationMatrix(adjustment, 0), text.tm); } } if (totalLength === 0) { @@ -867,15 +877,15 @@ function runContentStream( combined.set(chunk, at); at += chunk.length; } - const endMatrix = computeTrm(gs.ctm, ts); + const endMatrix = computeTrm(gs, text); pushItem({ kind: "text", codes: combined, - fontResourceName: ts.fontResourceName, + fontResourceName, resources, startMatrix, endMatrix, - sizePt: ts.fontSizePt, + sizePt: gs.fontSizePt, color: gs.fillColor, }); }; @@ -885,8 +895,8 @@ function runContentStream( }; const nextLine = (): void => { - ts.tlm = multiplyMatrices(translationMatrix(0, -ts.leading), ts.tlm); - ts.tm = ts.tlm; + text.tlm = multiplyMatrices(translationMatrix(0, -gs.leading), text.tlm); + text.tm = text.tlm; }; const handleDo = (name: string | undefined): void => { @@ -940,6 +950,7 @@ function runContentStream( context.resolver.resolveDict(dictGet(xobj.dict, "Resources")) ?? resources; const decoded = decodeStream(xobj.raw, xobj.dict, context.sink); + // ISO 32000-1 8.10.2: the form executes in the graphics state in effect at this Do, as if nested inline inside an implicit q/Q -- so the whole state travels inward, the text parameters (a font a preceding Tf already selected, spacing, scaling) among them, and a form whose own content omits a redundant Tf still draws in the caller's font. The recursed call binds its own `gs` local, so nothing the form changes travels back out; the text matrix is not carried because it is text object state the form's own BT resets regardless. const formState: GraphicsState = { ...gs, ctm: multiplyMatrices(formMatrix, gs.ctm), @@ -980,6 +991,7 @@ function runContentStream( const { operands, operator } = token.operation; switch (operator) { case "q": + // One push covers every saved parameter, the text state included, because GraphicsState holds them all -- see its own comment for why that is the spec's own division rather than a convenience. An unbalanced Q with nothing to pop leaves the state as it stands, the most content a malformed stream can still be read with. gsStack.push(gs); break; case "Q": @@ -1140,46 +1152,49 @@ function runContentStream( emitPaint(operator); break; case "BT": - // ISO 32000-1 9.4.1: BT resets only the text matrix and text line matrix to identity. Every other text-state parameter (font, size, char/word spacing, horizontal scaling, leading, rise) belongs to the graphics state and persists across text objects -- a full defaultTextState() reset here was silently discarding a font selected by an earlier Tf, so any text object that omits a redundant Tf (the common case for a same-font paragraph continuation) fed showTextArray/advanceThroughString a `fontResourceName: undefined` and both early-return with zero items. - ts = { ...ts, tm: IDENTITY_MATRIX, tlm: IDENTITY_MATRIX }; + // ISO 32000-1 9.4.1: BT resets the text matrix and text line matrix to identity, and nothing else. Every other text-state parameter (font, size, char/word spacing, horizontal scaling, leading, rise) is a graphics state parameter that persists across text objects, so resetting the text object state here is now exactly the whole reset the spec asks for. + text = defaultTextObjectState(); break; case "Tf": - ts.fontResourceName = asName(operands[0]); - ts.fontSizePt = numAt(operands, 1); + gs = { + ...gs, + fontResourceName: asName(operands[0]), + fontSizePt: numAt(operands, 1), + }; break; case "Tc": - ts.charSpace = numAt(operands, 0); + gs = { ...gs, charSpace: numAt(operands, 0) }; break; case "Tw": - ts.wordSpace = numAt(operands, 0); + gs = { ...gs, wordSpace: numAt(operands, 0) }; break; case "Tz": - ts.horizScale = numAt(operands, 0) / 100; + gs = { ...gs, horizScale: numAt(operands, 0) / 100 }; break; case "TL": - ts.leading = numAt(operands, 0); + gs = { ...gs, leading: numAt(operands, 0) }; break; case "Ts": - ts.rise = numAt(operands, 0); + gs = { ...gs, rise: numAt(operands, 0) }; break; case "Td": - ts.tlm = multiplyMatrices( + text.tlm = multiplyMatrices( translationMatrix(numAt(operands, 0), numAt(operands, 1)), - ts.tlm, + text.tlm, ); - ts.tm = ts.tlm; + text.tm = text.tlm; break; case "TD": - ts.leading = -numAt(operands, 1); - ts.tlm = multiplyMatrices( + gs = { ...gs, leading: -numAt(operands, 1) }; + text.tlm = multiplyMatrices( translationMatrix(numAt(operands, 0), numAt(operands, 1)), - ts.tlm, + text.tlm, ); - ts.tm = ts.tlm; + text.tm = text.tlm; break; case "Tm": - ts.tlm = matrixFromOperands(operands); - ts.tm = ts.tlm; + text.tlm = matrixFromOperands(operands); + text.tm = text.tlm; break; case "T*": nextLine(); @@ -1200,8 +1215,11 @@ function runContentStream( break; } case '"': { - ts.wordSpace = numAt(operands, 0); - ts.charSpace = numAt(operands, 1); + gs = { + ...gs, + wordSpace: numAt(operands, 0), + charSpace: numAt(operands, 1), + }; nextLine(); const str = operands[2]; if (str?.kind === "string") {