diff --git a/.gitignore b/.gitignore index c7abc6a..0d003c2 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ debug/ # Benchmark fixtures (downloaded at runtime) fixtures/benchmarks/ +fixtures/private/ # Temporary files tmp/ diff --git a/content/docs/advanced/low-level-drawing.mdx b/content/docs/advanced/low-level-drawing.mdx index 5271d11..6c9928c 100644 --- a/content/docs/advanced/low-level-drawing.mdx +++ b/content/docs/advanced/low-level-drawing.mdx @@ -25,9 +25,8 @@ page.drawOperators([ ``` - The low-level API requires understanding of PDF content stream structure. - Invalid operator sequences may produce corrupted PDFs. Use the high-level - methods when they're sufficient. + The low-level API requires understanding of PDF content stream structure. Invalid operator + sequences may produce corrupted PDFs. Use the high-level methods when they're sufficient. --- @@ -36,15 +35,15 @@ page.drawOperators([ The high-level methods (`drawRectangle`, `drawText`, etc.) cover most needs. Reach for the low-level API when you need: -| Feature | Low-Level Approach | -| --- | --- | -| Matrix transforms | `ops.concatMatrix()` for arbitrary rotation/scale/skew | -| Gradients | `createAxialShading()` or `createRadialShading()` | -| Repeating patterns | `createTilingPattern()` or `createImagePattern()` | -| Blend modes | `createExtGState({ blendMode: "Multiply" })` | -| Clipping regions | `ops.clip()` with `ops.endPath()` | -| Reusable graphics | `createFormXObject()` for stamps/watermarks | -| Fine-grained control | Direct operator sequences | +| Feature | Low-Level Approach | +| -------------------- | ------------------------------------------------------ | +| Matrix transforms | `ops.concatMatrix()` for arbitrary rotation/scale/skew | +| Gradients | `createAxialShading()` or `createRadialShading()` | +| Repeating patterns | `createTilingPattern()` or `createImagePattern()` | +| Blend modes | `createExtGState({ blendMode: "Multiply" })` | +| Clipping regions | `ops.clip()` with `ops.endPath()` | +| Reusable graphics | `createFormXObject()` for stamps/watermarks | +| Fine-grained control | Direct operator sequences | --- @@ -59,80 +58,80 @@ import { ops } from "@libpdf/core"; ### Graphics State ```typescript -ops.pushGraphicsState() // Save current state (q) -ops.popGraphicsState() // Restore saved state (Q) -ops.setGraphicsState(name) // Apply ExtGState resource (gs) -ops.concatMatrix(a, b, c, d, e, f) // Transform CTM (cm) +ops.pushGraphicsState(); // Save current state (q) +ops.popGraphicsState(); // Restore saved state (Q) +ops.setGraphicsState(name); // Apply ExtGState resource (gs) +ops.concatMatrix(a, b, c, d, e, f); // Transform CTM (cm) ``` ### Path Construction ```typescript -ops.moveTo(x, y) // Begin subpath (m) -ops.lineTo(x, y) // Line to point (l) -ops.curveTo(x1, y1, x2, y2, x3, y3) // Cubic bezier (c) -ops.rectangle(x, y, w, h) // Rectangle shorthand (re) -ops.closePath() // Close subpath (h) +ops.moveTo(x, y); // Begin subpath (m) +ops.lineTo(x, y); // Line to point (l) +ops.curveTo(x1, y1, x2, y2, x3, y3); // Cubic bezier (c) +ops.rectangle(x, y, w, h); // Rectangle shorthand (re) +ops.closePath(); // Close subpath (h) ``` ### Path Painting ```typescript -ops.stroke() // Stroke path (S) -ops.fill() // Fill path, non-zero winding (f) -ops.fillEvenOdd() // Fill path, even-odd rule (f*) -ops.fillAndStroke() // Fill then stroke (B) -ops.endPath() // Discard path without painting (n) +ops.stroke(); // Stroke path (S) +ops.fill(); // Fill path, non-zero winding (f) +ops.fillEvenOdd(); // Fill path, even-odd rule (f*) +ops.fillAndStroke(); // Fill then stroke (B) +ops.endPath(); // Discard path without painting (n) ``` ### Clipping ```typescript -ops.clip() // Set clip region, non-zero (W) -ops.clipEvenOdd() // Set clip region, even-odd (W*) +ops.clip(); // Set clip region, non-zero (W) +ops.clipEvenOdd(); // Set clip region, even-odd (W*) ``` ### Color ```typescript -ops.setStrokingGray(g) // Stroke grayscale (G) -ops.setNonStrokingGray(g) // Fill grayscale (g) -ops.setStrokingRGB(r, g, b) // Stroke RGB (RG) -ops.setNonStrokingRGB(r, g, b) // Fill RGB (rg) -ops.setStrokingCMYK(c, m, y, k) // Stroke CMYK (K) -ops.setNonStrokingCMYK(c, m, y, k) // Fill CMYK (k) -ops.setStrokingColorSpace(cs) // Set stroke color space (CS) -ops.setNonStrokingColorSpace(cs) // Set fill color space (cs) -ops.setStrokingColorN(name) // Set stroke pattern (SCN) -ops.setNonStrokingColorN(name) // Set fill pattern (scn) +ops.setStrokingGray(g); // Stroke grayscale (G) +ops.setNonStrokingGray(g); // Fill grayscale (g) +ops.setStrokingRGB(r, g, b); // Stroke RGB (RG) +ops.setNonStrokingRGB(r, g, b); // Fill RGB (rg) +ops.setStrokingCMYK(c, m, y, k); // Stroke CMYK (K) +ops.setNonStrokingCMYK(c, m, y, k); // Fill CMYK (k) +ops.setStrokingColorSpace(cs); // Set stroke color space (CS) +ops.setNonStrokingColorSpace(cs); // Set fill color space (cs) +ops.setStrokingColorN(name); // Set stroke pattern (SCN) +ops.setNonStrokingColorN(name); // Set fill pattern (scn) ``` ### Line Style ```typescript -ops.setLineWidth(w) // Line width (w) -ops.setLineCap(cap) // 0=butt, 1=round, 2=square (J) -ops.setLineJoin(join) // 0=miter, 1=round, 2=bevel (j) -ops.setMiterLimit(limit) // Miter limit ratio (M) -ops.setDashPattern(array, phase) // Dash pattern (d) +ops.setLineWidth(w); // Line width (w) +ops.setLineCap(cap); // 0=butt, 1=round, 2=square (J) +ops.setLineJoin(join); // 0=miter, 1=round, 2=bevel (j) +ops.setMiterLimit(limit); // Miter limit ratio (M) +ops.setDashPattern(array, phase); // Dash pattern (d) ``` ### Text ```typescript -ops.beginText() // Begin text object (BT) -ops.endText() // End text object (ET) -ops.setFont(name, size) // Set font (Tf) -ops.moveText(tx, ty) // Position text (Td) -ops.setTextMatrix(a, b, c, d, e, f) // Text matrix (Tm) -ops.showText(string) // Show text (Tj) +ops.beginText(); // Begin text object (BT) +ops.endText(); // End text object (ET) +ops.setFont(name, size); // Set font (Tf) +ops.moveText(tx, ty); // Position text (Td) +ops.setTextMatrix(a, b, c, d, e, f); // Text matrix (Tm) +ops.showText(string); // Show text (Tj) ``` ### XObjects and Shading ```typescript -ops.paintXObject(name) // Draw XObject (Do) -ops.paintShading(name) // Paint shading (sh) +ops.paintXObject(name); // Draw XObject (Do) +ops.paintShading(name); // Paint shading (sh) ``` --- @@ -146,7 +145,7 @@ import { Matrix, ops } from "@libpdf/core"; const matrix = Matrix.identity() .translate(200, 300) - .rotate(45) // degrees + .rotate(45) // degrees .scale(2, 1.5); page.drawOperators([ @@ -162,14 +161,14 @@ Or use raw matrix components: ```typescript // Translation: move 100 points right, 200 points up -ops.concatMatrix(1, 0, 0, 1, 100, 200) +ops.concatMatrix(1, 0, 0, 1, 100, 200); // Scale: 2x horizontal, 0.5x vertical -ops.concatMatrix(2, 0, 0, 0.5, 0, 0) +ops.concatMatrix(2, 0, 0, 0.5, 0, 0); // Rotation: 45 degrees around origin -const angle = 45 * Math.PI / 180; -ops.concatMatrix(Math.cos(angle), Math.sin(angle), -Math.sin(angle), Math.cos(angle), 0, 0) +const angle = (45 * Math.PI) / 180; +ops.concatMatrix(Math.cos(angle), Math.sin(angle), -Math.sin(angle), Math.cos(angle), 0, 0); ``` --- @@ -183,7 +182,7 @@ Create linear or radial gradients with color stops: ```typescript // CSS-style: angle + length const gradient = pdf.createLinearGradient({ - angle: 90, // 0=up, 90=right, 180=down, 270=left + angle: 90, // 0=up, 90=right, 180=down, 270=left length: 200, stops: [ { offset: 0, color: rgb(1, 0, 0) }, @@ -194,7 +193,7 @@ const gradient = pdf.createLinearGradient({ // Or explicit coordinates const axial = pdf.createAxialShading({ - coords: [0, 0, 200, 0], // x0, y0, x1, y1 + coords: [0, 0, 200, 0], // x0, y0, x1, y1 stops: [ { offset: 0, color: rgb(0, 0, 1) }, { offset: 1, color: rgb(1, 0, 1) }, @@ -206,7 +205,7 @@ const axial = pdf.createAxialShading({ ```typescript const radial = pdf.createRadialShading({ - coords: [100, 100, 0, 100, 100, 80], // x0, y0, r0, x1, y1, r1 + coords: [100, 100, 0, 100, 100, 80], // x0, y0, r0, x1, y1, r1 stops: [ { offset: 0, color: rgb(1, 1, 1) }, { offset: 1, color: rgb(0, 0, 0) }, @@ -234,9 +233,7 @@ page.drawOperators([ // Or wrap in a pattern for PathBuilder const pattern = pdf.createShadingPattern({ shading: gradient }); -page.drawPath() - .rectangle(50, 200, 200, 100) - .fill({ pattern }); +page.drawPath().rectangle(50, 200, 200, 100).fill({ pattern }); ``` --- @@ -282,9 +279,7 @@ const pattern = pdf.createImagePattern({ height: 50, }); -page.drawPath() - .circle(200, 400, 80) - .fill({ pattern }); +page.drawPath().circle(200, 400, 80).fill({ pattern }); ``` ### Gradient Pattern @@ -387,23 +382,23 @@ Restrict drawing to a region: ```typescript page.drawOperators([ ops.pushGraphicsState(), - + // Define clip region (circle) ops.moveTo(200, 300), // ... circle path using bezier curves ops.clip(), - ops.endPath(), // Required after clip - + ops.endPath(), // Required after clip + // Everything here is clipped to the circle ops.paintShading(gradientName), - - ops.popGraphicsState(), // Clipping is restored + + ops.popGraphicsState(), // Clipping is restored ]); ``` - Always follow `ops.clip()` with a path-painting operator. Use `ops.endPath()` - to discard the path, or `ops.fill()` to both clip and fill. + Always follow `ops.clip()` with a path-painting operator. Use `ops.endPath()` to discard the path, + or `ops.fill()` to both clip and fill. --- @@ -444,7 +439,7 @@ const page = pdf.addPage(); // Create button gradient const gradient = pdf.createLinearGradient({ - angle: 180, // top to bottom + angle: 180, // top to bottom length: 40, stops: [ { offset: 0, color: rgb(0.4, 0.6, 1) }, @@ -475,7 +470,8 @@ page.drawOperators([ ]); // Rounded rectangle path -page.drawPath() +page + .drawPath() .moveTo(110, 700) .lineTo(250, 700) .curveTo(255, 700, 260, 705, 260, 710) diff --git a/content/docs/api/pdf-page.mdx b/content/docs/api/pdf-page.mdx index 1b30d01..8d73ca4 100644 --- a/content/docs/api/pdf-page.mdx +++ b/content/docs/api/pdf-page.mdx @@ -570,9 +570,9 @@ For advanced graphics operations, PDFPage provides methods to emit raw operators Emit raw PDF operators to the page content stream. -| Param | Type | Description | -| ----------- | ------------ | --------------------------- | -| `operators` | `Operator[]` | Array of operators to emit | +| Param | Type | Description | +| ----------- | ------------ | -------------------------- | +| `operators` | `Operator[]` | Array of operators to emit | ```typescript import { ops } from "@libpdf/core"; @@ -588,8 +588,8 @@ page.drawOperators([ ``` - The caller is responsible for valid operator sequences. Invalid sequences - may produce corrupted PDFs. + The caller is responsible for valid operator sequences. Invalid sequences may produce corrupted + PDFs. --- @@ -598,8 +598,8 @@ page.drawOperators([ Register a font resource and return its operator name. -| Param | Type | Description | -| ------ | ------------------------------ | ------------------ | +| Param | Type | Description | +| ------ | ------------------------------------ | ---------------- | | `font` | `EmbeddedFont \| Standard14FontName` | Font to register | **Returns**: `string` - Resource name (e.g., `"F0"`) @@ -623,9 +623,9 @@ page.drawOperators([ Register an image resource and return its operator name. -| Param | Type | Description | -| ------- | ---------- | ------------------ | -| `image` | `PDFImage` | Embedded image | +| Param | Type | Description | +| ------- | ---------- | -------------- | +| `image` | `PDFImage` | Embedded image | **Returns**: `string` - Resource name (e.g., `"Im0"`) @@ -647,8 +647,8 @@ page.drawOperators([ Register a shading (gradient) resource and return its operator name. -| Param | Type | Description | -| --------- | ------------ | ------------ | +| Param | Type | Description | +| --------- | ------------ | ---------------- | | `shading` | `PDFShading` | Shading resource | **Returns**: `string` - Resource name (e.g., `"Sh0"`) @@ -699,8 +699,8 @@ page.drawOperators([ Register an extended graphics state and return its operator name. -| Param | Type | Description | -| ------- | ------------- | -------------- | +| Param | Type | Description | +| ------- | -------------- | -------------- | | `state` | `PDFExtGState` | Graphics state | **Returns**: `string` - Resource name (e.g., `"GS0"`) @@ -728,8 +728,8 @@ page.drawOperators([ Register a Form XObject or embedded page and return its operator name. -| Param | Type | Description | -| --------- | --------------------------------- | ---------------- | +| Param | Type | Description | +| --------- | ----------------------------------- | ------------------- | | `xobject` | `PDFFormXObject \| PDFEmbeddedPage` | XObject to register | **Returns**: `string` - Resource name (e.g., `"Fm0"`) diff --git a/content/docs/api/pdf.mdx b/content/docs/api/pdf.mdx index 87248e5..d212c14 100644 --- a/content/docs/api/pdf.mdx +++ b/content/docs/api/pdf.mdx @@ -653,11 +653,11 @@ These methods create PDF resources for advanced drawing operations. See [Low-Lev Create a linear gradient using CSS-style angle and length. -| Param | Type | Default | Description | -| ---------------- | ------------- | -------- | ----------------------------------------- | -| `options.angle` | `number` | required | Angle in degrees (0=up, 90=right) | -| `options.length` | `number` | required | Gradient length in points | -| `options.stops` | `ColorStop[]` | required | Color stops with offset (0-1) and color | +| Param | Type | Default | Description | +| ---------------- | ------------- | -------- | --------------------------------------- | +| `options.angle` | `number` | required | Angle in degrees (0=up, 90=right) | +| `options.length` | `number` | required | Gradient length in points | +| `options.stops` | `ColorStop[]` | required | Color stops with offset (0-1) and color | **Returns**: `PDFShading` @@ -681,10 +681,10 @@ page.drawRectangle({ x: 50, y: 50, width: 200, height: 100, pattern }); Create an axial (linear) shading with explicit coordinates. -| Param | Type | Default | Description | -| --------------- | ------------------------------------ | -------- | ----------------------------- | -| `options.coords` | `[x0, y0, x1, y1]` | required | Start and end points | -| `options.stops` | `ColorStop[]` | required | Color stops | +| Param | Type | Default | Description | +| ---------------- | ------------------ | -------- | -------------------- | +| `options.coords` | `[x0, y0, x1, y1]` | required | Start and end points | +| `options.stops` | `ColorStop[]` | required | Color stops | **Returns**: `PDFShading` @@ -705,10 +705,10 @@ const gradient = pdf.createAxialShading({ Create a radial shading with explicit coordinates. -| Param | Type | Default | Description | -| ---------------- | ----------------------------------- | -------- | ------------------------------------ | -| `options.coords` | `[x0, y0, r0, x1, y1, r1]` | required | Center and radius for both circles | -| `options.stops` | `ColorStop[]` | required | Color stops | +| Param | Type | Default | Description | +| ---------------- | -------------------------- | -------- | ---------------------------------- | +| `options.coords` | `[x0, y0, r0, x1, y1, r1]` | required | Center and radius for both circles | +| `options.stops` | `ColorStop[]` | required | Color stops | **Returns**: `PDFShading` @@ -728,11 +728,11 @@ const radial = pdf.createRadialShading({ Create a repeating tiling pattern. -| Param | Type | Default | Description | -| ------------------ | ------------ | -------- | -------------------------------- | -| `options.bbox` | `BBox` | required | Pattern cell bounding box | -| `options.xStep` | `number` | required | Horizontal spacing between tiles | -| `options.yStep` | `number` | required | Vertical spacing between tiles | +| Param | Type | Default | Description | +| ------------------- | ------------ | -------- | --------------------------------- | +| `options.bbox` | `BBox` | required | Pattern cell bounding box | +| `options.xStep` | `number` | required | Horizontal spacing between tiles | +| `options.yStep` | `number` | required | Vertical spacing between tiles | | `options.operators` | `Operator[]` | required | Content operators for the pattern | **Returns**: `PDFTilingPattern` @@ -742,11 +742,7 @@ const pattern = pdf.createTilingPattern({ bbox: { x: 0, y: 0, width: 10, height: 10 }, xStep: 10, yStep: 10, - operators: [ - ops.setNonStrokingGray(0.8), - ops.rectangle(0, 0, 5, 5), - ops.fill(), - ], + operators: [ops.setNonStrokingGray(0.8), ops.rectangle(0, 0, 5, 5), ops.fill()], }); ``` @@ -781,10 +777,10 @@ page.drawCircle({ x: 200, y: 400, radius: 80, pattern }); Wrap a shading (gradient) as a pattern for use with drawing methods. -| Param | Type | Default | Description | -| ----------------- | ------------ | -------- | --------------------- | -| `options.shading` | `PDFShading` | required | Shading to wrap | -| `[options.matrix]` | `PatternMatrix` | | Transform matrix | +| Param | Type | Default | Description | +| ------------------ | --------------- | -------- | ---------------- | +| `options.shading` | `PDFShading` | required | Shading to wrap | +| `[options.matrix]` | `PatternMatrix` | | Transform matrix | **Returns**: `PDFShadingPattern` @@ -803,11 +799,11 @@ page.drawPath() Create an extended graphics state for opacity and blend modes. -| Param | Type | Default | Description | -| ------------------------ | ----------- | ------- | ---------------- | -| `[options.fillOpacity]` | `number` | | Fill opacity 0-1 | +| Param | Type | Default | Description | +| ------------------------- | ----------- | ------- | ------------------ | +| `[options.fillOpacity]` | `number` | | Fill opacity 0-1 | | `[options.strokeOpacity]` | `number` | | Stroke opacity 0-1 | -| `[options.blendMode]` | `BlendMode` | | Blend mode | +| `[options.blendMode]` | `BlendMode` | | Blend mode | **Returns**: `PDFExtGState` @@ -827,9 +823,9 @@ const gsName = page.registerExtGState(gs); Create a reusable Form XObject (content block). -| Param | Type | Default | Description | -| ------------------ | ------------ | -------- | ----------------- | -| `options.bbox` | `BBox` | required | Bounding box | +| Param | Type | Default | Description | +| ------------------- | ------------ | -------- | ----------------- | +| `options.bbox` | `BBox` | required | Bounding box | | `options.operators` | `Operator[]` | required | Content operators | **Returns**: `PDFFormXObject` @@ -837,18 +833,11 @@ Create a reusable Form XObject (content block). ```typescript const stamp = pdf.createFormXObject({ bbox: { x: 0, y: 0, width: 100, height: 50 }, - operators: [ - ops.setNonStrokingRGB(1, 0, 0), - ops.rectangle(0, 0, 100, 50), - ops.fill(), - ], + operators: [ops.setNonStrokingRGB(1, 0, 0), ops.rectangle(0, 0, 100, 50), ops.fill()], }); const xobjectName = page.registerXObject(stamp); -page.drawOperators([ - ops.concatMatrix(1, 0, 0, 1, 200, 700), - ops.paintXObject(xobjectName), -]); +page.drawOperators([ops.concatMatrix(1, 0, 0, 1, 200, 700), ops.paintXObject(xobjectName)]); ``` --- diff --git a/content/docs/guides/drawing.mdx b/content/docs/guides/drawing.mdx index 84df72a..9922944 100644 --- a/content/docs/guides/drawing.mdx +++ b/content/docs/guides/drawing.mdx @@ -588,9 +588,7 @@ page.drawRectangle({ }); // Also works with PathBuilder -page.drawPath() - .circle(300, 550, 50) - .fill({ pattern }); +page.drawPath().circle(300, 550, 50).fill({ pattern }); ``` See [Low-Level Drawing](/docs/advanced/low-level-drawing) for gradients, tiling patterns, and more. diff --git a/src/filters/flate-filter.test.ts b/src/filters/flate-filter.test.ts index 7ff0c07..9d1d42f 100644 --- a/src/filters/flate-filter.test.ts +++ b/src/filters/flate-filter.test.ts @@ -113,6 +113,63 @@ describe("FlateFilter", () => { }); }); + describe("sync-flush streams", () => { + // Some PDF generators (notably PDFium) produce zlib streams terminated + // with a sync-flush marker (00 00 FF FF) instead of a proper final + // block and Adler-32 checksum. These are the actual byte sequences + // from the PDF reported in issue #16. + + it("decodes a small sync-flush stream", () => { + // Decompresses to "q\n" — a PDF content stream save-state operator + const syncFlush = new Uint8Array([120, 156, 42, 228, 2, 0, 0, 0, 255, 255]); + + const result = filter.decode(syncFlush); + + expect(new TextDecoder().decode(result)).toBe("q\n"); + }); + + it("decodes a larger sync-flush stream with PDF content", () => { + // Decompresses to "/ADBE_FillSign BMC \nq \n/Fm0 Do \nQ \nEMC" + const syncFlush = new Uint8Array([ + 120, 156, 210, 119, 116, 113, 114, 141, 119, 203, 204, 201, 9, 206, 76, 207, 83, 112, 242, + 117, 86, 224, 42, 84, 224, 210, 119, 203, 53, 80, 112, 201, 87, 224, 10, 84, 224, 114, 245, + 117, 6, 0, 0, 0, 255, 255, + ]); + + const result = filter.decode(syncFlush); + + expect(new TextDecoder().decode(result)).toBe("/ADBE_FillSign BMC \nq \n/Fm0 Do \nQ \nEMC"); + }); + + it("decodes another small sync-flush stream", () => { + // Decompresses to "\nQ\n" — a PDF content stream restore-state operator + const syncFlush = new Uint8Array([120, 156, 226, 10, 228, 2, 0, 0, 0, 255, 255]); + + const result = filter.decode(syncFlush); + + expect(new TextDecoder().decode(result)).toBe("\nQ\n"); + }); + + it("still handles well-formed streams normally", () => { + const original = new TextEncoder().encode("Hello, World!"); + const compressed = pako.deflate(original); + + const result = filter.decode(compressed); + + expect(new TextDecoder().decode(result)).toBe("Hello, World!"); + }); + + it("returns empty for truly corrupt data (lenient)", () => { + // Random garbage that cannot be decompressed at all. + // Returns empty rather than throwing — callers handle empty data gracefully. + const garbage = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04]); + + const result = filter.decode(garbage); + + expect(result).toEqual(new Uint8Array(0)); + }); + }); + describe("predictor support", () => { // Predictor tests would need proper test data // For now, just verify that params are passed through diff --git a/src/filters/flate-filter.ts b/src/filters/flate-filter.ts index 5527dab..973ccbd 100644 --- a/src/filters/flate-filter.ts +++ b/src/filters/flate-filter.ts @@ -8,8 +8,13 @@ import { applyPredictor } from "./predictor"; * FlateDecode filter - zlib/deflate compression. * * This is the most common filter in modern PDFs. Uses pako for - * decompression as it handles malformed/truncated data gracefully, - * unlike native DecompressionStream which can hang indefinitely. + * decompression and includes fallback handling for malformed streams. + * + * Some PDF generators (notably PDFium) produce zlib streams terminated + * with a sync-flush marker (00 00 FF FF) instead of a proper final + * block and Adler-32 checksum. Standard `pako.inflate()` returns + * `undefined` for these streams. We detect this case and recover + * the decompressed data from pako's internal state. * * Supports Predictor parameter for PNG/TIFF prediction algorithms. */ @@ -17,10 +22,7 @@ export class FlateFilter implements Filter { readonly name = "FlateDecode"; decode(data: Uint8Array, params?: PdfDict): Uint8Array { - // pako.inflate handles zlib header automatically and gracefully - // handles truncated/corrupt data (unlike native DecompressionStream - // which can hang indefinitely on malformed input) - const decompressed = pako.inflate(data); + const decompressed = this.inflate(data); // Apply predictor if specified if (params) { @@ -39,4 +41,63 @@ export class FlateFilter implements Filter { // Returns zlib format with header return pako.deflate(data); } + + /** + * Decompress zlib data with fallback for sync-flush terminated streams. + * + * pako.inflate() returns undefined (instead of throwing) when the + * zlib stream ends with a sync-flush marker (00 00 FF FF) and lacks + * a proper final deflate block. This is technically invalid per the + * zlib spec but common in practice — PDFium and other generators + * produce these streams. + * + * When this happens, we use pako's Inflate class directly and + * extract whatever data was successfully decompressed from its + * internal output buffer. + */ + private inflate(data: Uint8Array): Uint8Array { + // Fast path: standard inflate handles well-formed streams + try { + const result = pako.inflate(data); + + if (result !== undefined) { + return result; + } + } catch { + // pako throws on invalid headers, corrupt data, etc. + // Fall through to the recovery path below. + } + + // Slow path: recover partial output from malformed streams. + // + // This handles two cases: + // 1. Sync-flush terminated streams (pako.inflate returns undefined): + // Some PDF generators (e.g. PDFium) produce zlib streams ending + // with a sync-flush marker (00 00 FF FF) instead of a proper + // final block. Push without finalization to extract output. + // 2. Streams with corrupt headers or checksums (pako.inflate throws): + // Try to recover whatever was decompressed before the error. + try { + const inf = new pako.Inflate(); + inf.push(data, false); + + // Access pako's internal zlib stream state. The `strm` property + // exists at runtime but is not exposed in pako's type definitions. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + const strm = (inf as any).strm; + const totalOut = strm.total_out; + + if (totalOut > 0 && strm.output) { + // Copy the decompressed bytes out of pako's internal buffer + return new Uint8Array(strm.output.slice(0, totalOut)); + } + } catch { + // Recovery also failed — truly unrecoverable + } + + // No data could be recovered. Return empty rather than throwing to + // stay lenient with malformed PDFs — callers (font parsers, content + // stream extractors) already handle empty data gracefully. + return new Uint8Array(0); + } } diff --git a/src/integration/signatures/signing.test.ts b/src/integration/signatures/signing.test.ts index cccd73c..9277b76 100644 --- a/src/integration/signatures/signing.test.ts +++ b/src/integration/signatures/signing.test.ts @@ -124,7 +124,7 @@ describe("signing integration", () => { describe("B-T signing (with timestamp)", () => { // FreeTSA is a free public timestamp authority - const tsa = new HttpTimestampAuthority("http://timestamp.sectigo.com"); + const tsa = new HttpTimestampAuthority("https://freetsa.org/tsr"); it("signs with timestamp (B-T level)", async () => { const pdfBytes = await loadFixture("basic", "rot0.pdf"); @@ -168,7 +168,7 @@ describe("signing integration", () => { describe("B-LT signing (long-term validation)", () => { // FreeTSA is a free public timestamp authority - const tsa = new HttpTimestampAuthority("http://timestamp.sectigo.com"); + const tsa = new HttpTimestampAuthority("https://freetsa.org/tsr"); it("signs with timestamp and LTV data (B-LT level)", async () => { const pdfBytes = await loadFixture("basic", "rot0.pdf");