qpdf-compress 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.0] - 2026-09-23
10
+
11
+ ### Fixed
12
+
13
+ - **Embedded file stripping removed the lookup path but not the file.** Only the `/EmbeddedFiles` name tree was cleared, while the `/AF` associated-files array (PDF/A-3, PDF 2.0) and `/FileAttachment` annotations kept their own reference to the same file specification. The attachment therefore stayed in the output and was recoverable in full, while readers that resolve attachments by name — pdfium among them — no longer found it: the removal did not remove, and a legitimate consumer lost access. All three paths are now cleared, so the writer drops the file specification and its stream as unreferenced. ([#34](https://github.com/xonaman/nodejs-qpdf-compress/issues/34))
14
+
15
+ ### Added
16
+
17
+ - `stripAttachments` option (default `true`, matching previous behaviour) to keep embedded file attachments. Hybrid invoices (ZUGFeRD / Factur-X) carry the invoice itself as an attachment, so `compress(pdf, { stripAttachments: false })` is what keeps such a document machine-readable. Note that the output is still not a conforming PDF/A-3: output intents and structure information are dropped in every mode.
18
+ - A `with-attachment.pdf` fixture whose attachment is reachable through all three paths, and tests that search decompressed stream contents rather than raw bytes — with object streams on, a byte search can miss an attachment that is entirely intact.
19
+
20
+ ## [0.7.1] - 2026-07-15
21
+
22
+ ### Security
23
+
24
+ - Verify the downloaded prebuilt binary against a per-platform SHA-256 pin shipped in the package before use, falling back to the checksum-pinned source build on mismatch or a missing pin. Closes the prebuilt-binary trust gap where a swapped GitHub release asset could be executed unverified.
25
+
26
+ ### Changed
27
+
28
+ - The `update-native-deps` PR body now describes `verify-checksums` as a consistency check against the same source rather than "independent" verification.
29
+
30
+ ### Fixed
31
+
32
+ - Corrected the HarfBuzz version recorded in `THIRD-PARTY-NOTICES.md` (14.1.0 → 14.2.1) to match the pinned dependency.
33
+
34
+ ### Added
35
+
36
+ - Hermetic test coverage for prebuilt verification and manifest generation (`test/prebuild.test.ts`).
37
+
9
38
  ## [0.7.0] - 2026-06-27
10
39
 
11
40
  ### Added
@@ -193,7 +222,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
193
222
  - QEMU-based cross-compilation for Linux arm64/arm and musl variants
194
223
  - vcpkg integration for Windows static linking
195
224
 
196
- [Unreleased]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.7.0...HEAD
225
+ [Unreleased]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.7.1...HEAD
226
+ [0.7.1]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.7.0...v0.7.1
197
227
  [0.7.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.4...v0.7.0
198
228
  [0.6.4]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.3...v0.6.4
199
229
  [0.6.3]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.2...v0.6.3
package/README.md CHANGED
@@ -71,7 +71,7 @@ const smaller = await compress(pdfBuffer, { lossy: true });
71
71
  | Form flattening | ✅ Automatic | ❌ | ❌ |
72
72
  | Stream deduplication | ✅ Automatic | ❌ | ❌ |
73
73
  | Content minification | ✅ Automatic | ❌ | ❌ |
74
- | JS/embedded file removal | ✅ Automatic | ❌ | ❌ |
74
+ | JS/embedded file removal | ✅ Default on | ❌ | ❌ |
75
75
  | Metadata stripping | ✅ Default on | ✅ Manual flag | ✅ |
76
76
  | PDF repair | ✅ Automatic | ✅ Manual flag | ⚠️ Partial |
77
77
  | License | Apache-2.0 | Apache-2.0 | AGPL-3.0 ⚠️ |
@@ -131,6 +131,9 @@ const smaller = await compress(pdfBuffer, { lossy: true });
131
131
  // keep metadata (stripped by default)
132
132
  const withMeta = await compress(pdfBuffer, { stripMetadata: false });
133
133
 
134
+ // keep embedded file attachments (stripped by default)
135
+ const withAttachments = await compress(pdfBuffer, { stripAttachments: false });
136
+
134
137
  // file path input (avoids copying into memory twice)
135
138
  const result = await compress('/path/to/file.pdf');
136
139
 
@@ -149,12 +152,15 @@ const fixed = await compress(damagedBuffer);
149
152
 
150
153
  Compresses a PDF document. Automatically repairs damaged PDFs.
151
154
 
152
- | Parameter | Type | Description |
153
- | ----------------------- | ------------------ | ------------------------------------------------------------------- |
154
- | `input` | `Buffer \| string` | PDF data or file path |
155
- | `options.lossy` | `boolean` | Enable lossy compression. Default: `false` |
156
- | `options.stripMetadata` | `boolean` | Remove XMP metadata, document info, and thumbnails. Default: `true` |
157
- | `options.output` | `string` | Write to file path instead of returning a `Buffer` |
155
+ | Parameter | Type | Description |
156
+ | -------------------------- | ------------------ | ------------------------------------------------------------------- |
157
+ | `input` | `Buffer \| string` | PDF data or file path |
158
+ | `options.lossy` | `boolean` | Enable lossy compression. Default: `false` |
159
+ | `options.stripMetadata` | `boolean` | Remove XMP metadata, document info, and thumbnails. Default: `true` |
160
+ | `options.stripAttachments` | `boolean` | Remove embedded file attachments. Default: `true` |
161
+ | `options.output` | `string` | Write to file path instead of returning a `Buffer` |
162
+
163
+ > **Hybrid invoices (ZUGFeRD / Factur-X)**: the invoice XML rides along as an attachment, so the defaults remove it. Pass `stripAttachments: false` to keep the attachment and every path readers look it up through. The result is still not a conforming PDF/A-3 — output intents and structure information are dropped in every mode, and XMP metadata too unless `stripMetadata: false` — so a file that has to stay conformant should not be compressed at all.
158
164
 
159
165
  **Both modes:**
160
166
 
@@ -170,7 +176,8 @@ Compresses a PDF document. Automatically repairs damaged PDFs.
170
176
  - Flattens page tree (pushes inherited attributes to pages)
171
177
  - Coalesces multiple content streams per page into one
172
178
  - Minifies content streams (whitespace normalization, numeric formatting)
173
- - Strips embedded files and JavaScript actions
179
+ - Strips embedded file attachments and every path to them — the `/EmbeddedFiles` name tree, `/AF` associated-file arrays, and `/FileAttachment` annotations (default: on)
180
+ - Strips JavaScript actions
174
181
  - Recompresses all decodable streams with Flate level 9
175
182
  - Generates object streams for smaller metadata overhead
176
183
  - Removes unreferenced objects
@@ -231,9 +238,10 @@ All operations run in a background thread via `Napi::AsyncWorker`, so the event
231
238
  13. Coalesce multiple content streams per page
232
239
  14. Minify content streams
233
240
  15. Deduplicate identical non-image streams
234
- 16. Strip embedded files and JavaScript
235
- 17. _(optional)_ Strip metadata
236
- 18. QPDFWriter: Flate 9, object streams, unreferenced object removal
241
+ 16. _(optional)_ Strip embedded file attachments
242
+ 17. Strip JavaScript
243
+ 18. _(optional)_ Strip metadata
244
+ 19. QPDFWriter: Flate 9, object streams, unreferenced object removal
237
245
 
238
246
  ## License
239
247
 
@@ -9,7 +9,7 @@ full license text of each is available from the upstream project linked.
9
9
  | -------- | ------------------ | --------------------------------------------------------- | -------------------------------------- |
10
10
  | QPDF | 12.3.2 | Apache-2.0 | <https://github.com/qpdf/qpdf> |
11
11
  | mozjpeg | 4.1.1 | BSD-3-Clause and IJG (with zlib-licensed SIMD components) | <https://github.com/mozilla/mozjpeg> |
12
- | HarfBuzz | 14.1.0 | MIT ("Old MIT") | <https://github.com/harfbuzz/harfbuzz> |
12
+ | HarfBuzz | 14.2.1 | MIT ("Old MIT") | <https://github.com/harfbuzz/harfbuzz> |
13
13
  | zlib | bundled on Windows | zlib License | <https://github.com/madler/zlib> |
14
14
 
15
15
  ## Details
package/dist/index.d.ts CHANGED
@@ -8,6 +8,11 @@ type PdfInput = Buffer | string;
8
8
  * JPEG Huffman tables, recompresses all streams with Flate level 9,
9
9
  * generates object streams, and removes unreferenced objects.
10
10
  *
11
+ * Embedded file attachments are removed by default, along with every path
12
+ * they are reachable through. Pass `stripAttachments: false` to keep them —
13
+ * needed for hybrid invoices (ZUGFeRD / Factur-X), where the attachment is
14
+ * the document's payload rather than a rider on it.
15
+ *
11
16
  * With `lossy: true`, uses more aggressive image re-encoding (skips JPEGs
12
17
  * at q65 or below, re-encodes the rest at q75) and downscales to 72 DPI.
13
18
  * Text, vectors, and fonts are preserved.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAe,MAAM,YAAY,CAAC;AAc/D,KAAK,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhC;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,QAAQ,EACf,OAAO,EAAE,eAAe,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,IAAI,CAAC,CAAC;AACjB,wBAAgB,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AA2BtF,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC3F,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAe,MAAM,YAAY,CAAC;AAc/D,KAAK,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhC;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,QAAQ,EACf,OAAO,EAAE,eAAe,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,IAAI,CAAC,CAAC;AACjB,wBAAgB,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AA6BtF,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC3F,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -27,10 +27,12 @@ export async function compress(input, options) {
27
27
  throw new TypeError('Input must be a Buffer or file path string');
28
28
  }
29
29
  const stripMetadata = options?.stripMetadata ?? true;
30
+ const stripAttachments = options?.stripAttachments ?? true;
30
31
  try {
31
32
  return await withConcurrency(() => addon.compress(input, {
32
33
  ...(options?.lossy ? { lossy: true } : {}),
33
34
  ...(stripMetadata ? { stripMetadata: true } : {}),
35
+ ...(stripAttachments ? { stripAttachments: true } : {}),
34
36
  ...(options?.output ? { output: options.output } : {}),
35
37
  }));
36
38
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG/C,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE1D,MAAM,QAAQ,GAAG,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC;AAC9D,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;IACjC,OAAO,CAAC,GAAG,CAAC,IAAI,GAAG,GAAG,QAAQ,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;AAC7D,CAAC;AAED,8EAA8E;AAC9E,kEAAkE;AAClE,MAAM,KAAK,GAAG,OAAO,CAAC,qCAAqC,CAAgB,CAAC;AAqB5E,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,KAAe,EAAE,OAAyB;IACvE,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,8BAA8B,CAAC,CAAC;QACtD,CAAC;IACH,CAAC;SAAM,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACrC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,4BAA4B,CAAC,CAAC;QACpD,CAAC;IACH,CAAC;SAAM,CAAC;QACN,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;IACpE,CAAC;IACD,MAAM,aAAa,GAAG,OAAO,EAAE,aAAa,IAAI,IAAI,CAAC;IACrD,IAAI,CAAC;QACH,OAAO,MAAM,eAAe,CAAC,GAAG,EAAE,CAChC,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE;YACpB,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC1C,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACjD,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACvD,CAAC,CACH,CAAC;IACJ,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,gBAAgB,CAAC,GAAG,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC;AAED,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG/C,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE1D,MAAM,QAAQ,GAAG,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC;AAC9D,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;IACjC,OAAO,CAAC,GAAG,CAAC,IAAI,GAAG,GAAG,QAAQ,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;AAC7D,CAAC;AAED,8EAA8E;AAC9E,kEAAkE;AAClE,MAAM,KAAK,GAAG,OAAO,CAAC,qCAAqC,CAAgB,CAAC;AA0B5E,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,KAAe,EAAE,OAAyB;IACvE,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,8BAA8B,CAAC,CAAC;QACtD,CAAC;IACH,CAAC;SAAM,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACrC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,4BAA4B,CAAC,CAAC;QACpD,CAAC;IACH,CAAC;SAAM,CAAC;QACN,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;IACpE,CAAC;IACD,MAAM,aAAa,GAAG,OAAO,EAAE,aAAa,IAAI,IAAI,CAAC;IACrD,MAAM,gBAAgB,GAAG,OAAO,EAAE,gBAAgB,IAAI,IAAI,CAAC;IAC3D,IAAI,CAAC;QACH,OAAO,MAAM,eAAe,CAAC,GAAG,EAAE,CAChC,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE;YACpB,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC1C,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACjD,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACvD,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACvD,CAAC,CACH,CAAC;IACJ,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,gBAAgB,CAAC,GAAG,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC;AAED,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
package/dist/types.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface CompressOptions {
3
3
  readonly lossy?: boolean;
4
4
  /** Remove XMP metadata, document info, and thumbnails. Default: true. */
5
5
  readonly stripMetadata?: boolean;
6
+ /** Remove embedded file attachments. Default: true. */
7
+ readonly stripAttachments?: boolean;
6
8
  /** Write to this file path instead of returning a Buffer. */
7
9
  readonly output?: string;
8
10
  }
@@ -10,6 +12,7 @@ export interface NativeAddon {
10
12
  compress(input: Buffer | string, options: {
11
13
  lossy?: boolean;
12
14
  stripMetadata?: boolean;
15
+ stripAttachments?: boolean;
13
16
  output?: string;
14
17
  }): Promise<Buffer | undefined>;
15
18
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../lib/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,eAAe;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB,yEAAyE;IACzE,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CACN,KAAK,EAAE,MAAM,GAAG,MAAM,EACtB,OAAO,EAAE;QACP,KAAK,CAAC,EAAE,OAAO,CAAC;QAChB,aAAa,CAAC,EAAE,OAAO,CAAC;QACxB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,GACA,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAChC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../lib/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,eAAe;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB,yEAAyE;IACzE,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC,uDAAuD;IACvD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,CAAC;IACpC,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CACN,KAAK,EAAE,MAAM,GAAG,MAAM,EACtB,OAAO,EAAE;QACP,KAAK,CAAC,EAAE,OAAO,CAAC;QAChB,aAAa,CAAC,EAAE,OAAO,CAAC;QACxB,gBAAgB,CAAC,EAAE,OAAO,CAAC;QAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,GACA,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAChC"}
package/lib/index.ts CHANGED
@@ -27,6 +27,11 @@ type PdfInput = Buffer | string;
27
27
  * JPEG Huffman tables, recompresses all streams with Flate level 9,
28
28
  * generates object streams, and removes unreferenced objects.
29
29
  *
30
+ * Embedded file attachments are removed by default, along with every path
31
+ * they are reachable through. Pass `stripAttachments: false` to keep them —
32
+ * needed for hybrid invoices (ZUGFeRD / Factur-X), where the attachment is
33
+ * the document's payload rather than a rider on it.
34
+ *
30
35
  * With `lossy: true`, uses more aggressive image re-encoding (skips JPEGs
31
36
  * at q65 or below, re-encodes the rest at q75) and downscales to 72 DPI.
32
37
  * Text, vectors, and fonts are preserved.
@@ -49,11 +54,13 @@ export async function compress(input: PdfInput, options?: CompressOptions): Prom
49
54
  throw new TypeError('Input must be a Buffer or file path string');
50
55
  }
51
56
  const stripMetadata = options?.stripMetadata ?? true;
57
+ const stripAttachments = options?.stripAttachments ?? true;
52
58
  try {
53
59
  return await withConcurrency(() =>
54
60
  addon.compress(input, {
55
61
  ...(options?.lossy ? { lossy: true } : {}),
56
62
  ...(stripMetadata ? { stripMetadata: true } : {}),
63
+ ...(stripAttachments ? { stripAttachments: true } : {}),
57
64
  ...(options?.output ? { output: options.output } : {}),
58
65
  }),
59
66
  );
package/lib/types.ts CHANGED
@@ -3,6 +3,8 @@ export interface CompressOptions {
3
3
  readonly lossy?: boolean;
4
4
  /** Remove XMP metadata, document info, and thumbnails. Default: true. */
5
5
  readonly stripMetadata?: boolean;
6
+ /** Remove embedded file attachments. Default: true. */
7
+ readonly stripAttachments?: boolean;
6
8
  /** Write to this file path instead of returning a Buffer. */
7
9
  readonly output?: string;
8
10
  }
@@ -13,6 +15,7 @@ export interface NativeAddon {
13
15
  options: {
14
16
  lossy?: boolean;
15
17
  stripMetadata?: boolean;
18
+ stripAttachments?: boolean;
16
19
  output?: string;
17
20
  },
18
21
  ): Promise<Buffer | undefined>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qpdf-compress",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Native PDF compression for Node.js, powered by QPDF",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -96,16 +96,16 @@
96
96
  },
97
97
  "devDependencies": {
98
98
  "@eslint/js": "^10.0.1",
99
- "@types/node": "^25.5.0",
99
+ "@types/node": "^22.0.0",
100
100
  "@vitest/coverage-v8": "^4.1.4",
101
101
  "eslint": "^10.1.0",
102
102
  "eslint-config-prettier": "^10.1.8",
103
103
  "globals": "^17.0.0",
104
104
  "husky": "^9.1.7",
105
- "lint-staged": "^16.4.0",
105
+ "lint-staged": "^17.0.8",
106
106
  "node-gyp": "^13.0.0",
107
107
  "prettier": "^3.8.1",
108
- "typescript": "^5.9.3",
108
+ "typescript": "^6.0.3",
109
109
  "typescript-eslint": "^8.57.2",
110
110
  "vitest": "^4.1.4"
111
111
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Generate `scripts/prebuilds.json`: a map of "<platform>-<arch>" -> SHA-256 of
3
+ * each prebuilt release tarball.
4
+ *
5
+ * Run in the release workflow AFTER the prebuild artifacts are downloaded and
6
+ * BEFORE `npm publish`, so the pins ship *inside* the published (SLSA-attested)
7
+ * npm package. `install.mjs` then verifies a downloaded prebuilt against the
8
+ * pin baked into the package, closing the prebuilt-binary trust gap: a swapped
9
+ * GitHub release asset will not match the pin and falls back to the
10
+ * checksum-pinned source build.
11
+ *
12
+ * Usage: node scripts/gen-prebuild-manifest.mjs <artifacts-dir>
13
+ */
14
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { sha256File } from './lib/integrity.mjs';
18
+
19
+ /**
20
+ * Map every "<prefix>-v<version>-<platform>-<arch>.tar.gz" in `artifactsDir` to
21
+ * its SHA-256, keyed by "<platform>-<arch>" (matching install.mjs's lookup).
22
+ *
23
+ * @param {string} artifactsDir
24
+ * @param {string} version
25
+ * @returns {Promise<Record<string, string>>}
26
+ */
27
+ export async function computePins(artifactsDir, version) {
28
+ // strip the "<prefix>-v<version>-" head and ".tar.gz" tail to recover the key
29
+ const headRe = new RegExp('^.*-v' + version.replace(/[.]/g, '\\.') + '-');
30
+ const files = readdirSync(artifactsDir)
31
+ .filter((f) => f.endsWith('.tar.gz'))
32
+ .sort();
33
+ const pins = {};
34
+ for (const file of files) {
35
+ const key = file.replace(/\.tar\.gz$/, '').replace(headRe, '');
36
+ pins[key] = await sha256File(join(artifactsDir, file));
37
+ }
38
+ return pins;
39
+ }
40
+
41
+ // CLI entrypoint (skipped when this module is imported, e.g. by tests)
42
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
43
+ const artifactsDir = process.argv[2];
44
+ if (!artifactsDir) {
45
+ console.error('usage: node scripts/gen-prebuild-manifest.mjs <artifacts-dir>');
46
+ process.exit(1);
47
+ }
48
+ const root = join(import.meta.dirname, '..');
49
+ const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
50
+ const pins = await computePins(artifactsDir, version);
51
+ if (Object.keys(pins).length === 0) {
52
+ console.error(`No .tar.gz artifacts found in ${artifactsDir}`);
53
+ process.exit(1);
54
+ }
55
+ for (const [key, sha] of Object.entries(pins)) console.log(` ${key} ${sha}`);
56
+ const out = join(root, 'scripts', 'prebuilds.json');
57
+ writeFileSync(out, JSON.stringify(pins, null, 2) + '\n');
58
+ console.log(`Wrote ${Object.keys(pins).length} prebuild pin(s) to ${out}`);
59
+ }
@@ -11,12 +11,25 @@ import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } fr
11
11
  import { join } from 'node:path';
12
12
  import { Readable } from 'node:stream';
13
13
  import { pipeline } from 'node:stream/promises';
14
- import { extractTarball } from './lib/integrity.mjs';
14
+ import { extractTarball, verifyPrebuild } from './lib/integrity.mjs';
15
15
 
16
16
  const root = join(import.meta.dirname, '..');
17
17
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
18
18
  const version = pkg.version;
19
19
 
20
+ // Per-platform SHA-256 pins for the prebuilt tarballs, generated at release
21
+ // time (scripts/gen-prebuild-manifest.mjs) and shipped inside this package.
22
+ // The prebuilt binary is the code that actually runs, so it is verified against
23
+ // this pin before use; a swapped release asset fails the check and we fall back
24
+ // to the checksum-pinned source build.
25
+ function loadPrebuildPins() {
26
+ try {
27
+ return JSON.parse(readFileSync(join(import.meta.dirname, 'prebuilds.json'), 'utf8'));
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
20
33
  // validate version to prevent SSRF via malicious package.json
21
34
  if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) {
22
35
  console.error(`Invalid version format: ${version}`);
@@ -74,6 +87,23 @@ async function tryDownload() {
74
87
 
75
88
  await pipeline(Readable.fromWeb(body), fileStream);
76
89
 
90
+ // verify the prebuilt against the pinned checksum before trusting it
91
+ const pinKey = `${platformKey}-${arch}`;
92
+ const check = await verifyPrebuild(tmpTar, pinKey, loadPrebuildPins());
93
+ if (!check.ok) {
94
+ if (check.reason === 'no-pin') {
95
+ console.log(
96
+ `No pinned checksum for ${pinKey}; refusing unverified prebuilt, will compile from source.`,
97
+ );
98
+ } else {
99
+ console.log(
100
+ `Prebuilt checksum mismatch for ${pinKey} (expected ${check.expected}, got ${check.actual}); will compile from source.`,
101
+ );
102
+ }
103
+ unlinkSync(tmpTar);
104
+ return false;
105
+ }
106
+
77
107
  // extract tar.gz into project root (hardened: no -P, no archived ownership)
78
108
  extractTarball(tmpTar, root);
79
109
  unlinkSync(tmpTar);
@@ -32,6 +32,29 @@ export async function sha256File(path) {
32
32
  return hash.digest('hex');
33
33
  }
34
34
 
35
+ /**
36
+ * Decide whether a downloaded prebuilt tarball is trusted by comparing its
37
+ * SHA-256 to the pin recorded for `pinKey` in `pins` (scripts/prebuilds.json).
38
+ * Returns a discriminated result so the installer can log and fall back to a
39
+ * source build instead of running an unverified binary.
40
+ *
41
+ * @param {string} tarPath
42
+ * @param {string} pinKey e.g. "darwin-arm64" / "linux-musl-x64"
43
+ * @param {Record<string, string>} pins
44
+ * @returns {Promise<{ok: true, pinKey: string, sha256: string}
45
+ * | {ok: false, reason: 'no-pin', pinKey: string}
46
+ * | {ok: false, reason: 'mismatch', pinKey: string, expected: string, actual: string}>}
47
+ */
48
+ export async function verifyPrebuild(tarPath, pinKey, pins) {
49
+ const expected = pins[pinKey];
50
+ if (!expected) return { ok: false, reason: 'no-pin', pinKey };
51
+ const actual = await sha256File(tarPath);
52
+ if (actual !== expected.toLowerCase()) {
53
+ return { ok: false, reason: 'mismatch', pinKey, expected, actual };
54
+ }
55
+ return { ok: true, pinKey, sha256: actual };
56
+ }
57
+
35
58
  /** Read the native-dependency manifest (scripts/native-deps.json). */
36
59
  export function loadManifest() {
37
60
  return JSON.parse(readFileSync(join(import.meta.dirname, '..', 'native-deps.json'), 'utf8'));
@@ -7,10 +7,10 @@
7
7
  "checksumSource": "official PGP-signed release manifest (qpdf-12.3.2.sha256)"
8
8
  },
9
9
  "harfbuzz": {
10
- "version": "14.1.0",
10
+ "version": "14.2.1",
11
11
  "origin": "https://github.com",
12
- "url": "https://github.com/harfbuzz/harfbuzz/archive/refs/tags/14.1.0.tar.gz",
13
- "sha256": "9deffe9dded3c367c7d08265fd0a64193fad505a362a4889d898568c1cd46812",
12
+ "url": "https://github.com/harfbuzz/harfbuzz/archive/refs/tags/14.2.1.tar.gz",
13
+ "sha256": "3c2a9006a7e1bf58737e557014d7882c554c628fb379a9f00008f5ea53dbbdfb",
14
14
  "checksumSource": "self-computed from the GitHub git-archive tarball; HarfBuzz's only release asset is .tar.xz, whose extraction hangs Windows bsdtar — the gzip git-archive builds identically"
15
15
  },
16
16
  "mozjpeg": {
@@ -0,0 +1,11 @@
1
+ {
2
+ "darwin-arm64": "91bce035bd98d4c5b51eb3c43bb4b3f1734768d4edb47042cce4dbb7b73bd001",
3
+ "darwin-x64": "fe1fdd42d8ff24e5ed3777ced647d29f9878d0dde347b3925af3b388d49f4e15",
4
+ "linux-arm": "478c10a51adefddc6f4cccc580edcb0d2242e8b155d495aac6ae2dc57ec96c10",
5
+ "linux-arm64": "6f09b7b95ffdb382430e8d474fdad4664058210863668e85130d5f76d157796e",
6
+ "linux-musl-arm64": "aee2f73320bb8d911be2dbfd657fd36ad7d767f27e5257fd8060d8c53b930c37",
7
+ "linux-musl-x64": "0fa4510d0aac780f955880bf511e086b9c88af97f87d348f97569859d65d191d",
8
+ "linux-x64": "75b2b183f810af513c7905406c695e325b39d5ef6125a8845465b75985515f0d",
9
+ "win32-arm64": "fbac6517c8ea188ea9cfb72c5daac0e6b90a5cb8e185a6e1cf7dff116b4ff032",
10
+ "win32-x64": "6b0a6f16c5cbe881aa3b4da12dcd138a637cf0b6764505a0e82a8b23df4d48d9"
11
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Check each native dependency in native-deps.json for a newer upstream GitHub
3
+ * release and, when found, re-pin the version and re-hash the asset(s).
4
+ *
5
+ * This is the piece Dependabot cannot do: the prebuilt/source native libraries
6
+ * are upstream release tarballs, not a package-manager ecosystem. Runs in CI
7
+ * (see .github/workflows/update-native-deps.yml) and emits a markdown summary
8
+ * for the pull-request body via GITHUB_OUTPUT.
9
+ *
10
+ * Supports both manifest shapes:
11
+ * - per-platform "targets" (baseUrl + { asset, sha256 } map), version == full tag
12
+ * - single "url" + "sha256", version == numeric (tag with any v/n prefix stripped)
13
+ *
14
+ * Only upstreams that publish a GitHub Release are tracked; a dependency whose
15
+ * repo has no usable release is skipped with a warning (kept manual) rather than
16
+ * guessed from noisy git tags.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { Readable } from 'node:stream';
22
+ import { pipeline } from 'node:stream/promises';
23
+
24
+ const manifestPath = join(import.meta.dirname, 'native-deps.json');
25
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
26
+
27
+ const token = process.env.GITHUB_TOKEN;
28
+ const apiHeaders = {
29
+ accept: 'application/vnd.github+json',
30
+ 'user-agent': 'update-native-deps',
31
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
32
+ };
33
+
34
+ // derive "OWNER/REPO" from a github.com baseUrl (targets shape) or url (single)
35
+ function repoFor(dep) {
36
+ const source = dep.baseUrl ?? dep.url;
37
+ const match = /^https:\/\/github\.com\/([^/]+\/[^/]+)/.exec(source ?? '');
38
+ if (!match) throw new Error(`cannot derive a github.com OWNER/REPO from: ${source}`);
39
+ return match[1];
40
+ }
41
+
42
+ // returns the latest release tag, or null when the repo publishes no release
43
+ async function latestReleaseTag(repo) {
44
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
45
+ headers: apiHeaders,
46
+ signal: AbortSignal.timeout(30000),
47
+ });
48
+ if (res.status === 404) return null;
49
+ if (!res.ok) throw new Error(`GitHub API returned HTTP ${res.status} for ${repo} latest release`);
50
+ const { tag_name: tag } = await res.json();
51
+ if (!tag) throw new Error(`latest release for ${repo} has no tag_name`);
52
+ return tag;
53
+ }
54
+
55
+ async function sha256Url(url, allowedOrigin) {
56
+ const parsed = new URL(url);
57
+ if (allowedOrigin && parsed.origin !== allowedOrigin) {
58
+ throw new Error(`refusing to fetch from unexpected origin: ${parsed.origin}`);
59
+ }
60
+ const res = await fetch(parsed, { redirect: 'follow', signal: AbortSignal.timeout(120000) });
61
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${parsed.href}`);
62
+ if (!res.body) throw new Error(`empty response body for ${parsed.href}`);
63
+ const hash = createHash('sha256');
64
+ await pipeline(Readable.fromWeb(res.body), hash);
65
+ return hash.digest('hex');
66
+ }
67
+
68
+ const changes = [];
69
+ for (const [name, dep] of Object.entries(manifest)) {
70
+ const repo = repoFor(dep);
71
+ const tag = await latestReleaseTag(repo);
72
+ if (tag === null) {
73
+ console.warn(`${name}: ${repo} publishes no GitHub release; skipping (update manually)`);
74
+ continue;
75
+ }
76
+
77
+ if (dep.targets) {
78
+ // targets shape: version is the full tag (e.g. "chromium/7920")
79
+ if (tag === dep.version) {
80
+ console.log(`${name}: up to date (${dep.version})`);
81
+ continue;
82
+ }
83
+ console.log(
84
+ `${name}: ${dep.version} -> ${tag}; re-hashing ${Object.keys(dep.targets).length} targets`,
85
+ );
86
+ for (const [key, target] of Object.entries(dep.targets)) {
87
+ const url = `${dep.baseUrl}/${encodeURIComponent(tag)}/${target.asset}`;
88
+ target.sha256 = await sha256Url(url, dep.origin);
89
+ console.log(` ${key}: ${target.sha256}`);
90
+ }
91
+ changes.push({ name, from: dep.version, to: tag });
92
+ dep.version = tag;
93
+ } else {
94
+ // single-url shape: manifest version is numeric; the tag may carry a v/n
95
+ // prefix. The url embeds the version string, so a plain substitution rebuilds
96
+ // both the tag segment and the filename.
97
+ const version = tag.replace(/^[^0-9]*/, '');
98
+ if (version === dep.version) {
99
+ console.log(`${name}: up to date (${dep.version})`);
100
+ continue;
101
+ }
102
+ const url = dep.url.replaceAll(dep.version, version);
103
+ console.log(`${name}: ${dep.version} -> ${version}; re-hashing`);
104
+ dep.sha256 = await sha256Url(url, dep.origin);
105
+ dep.url = url;
106
+ changes.push({ name, from: dep.version, to: version });
107
+ dep.version = version;
108
+ }
109
+ }
110
+
111
+ if (changes.length === 0) {
112
+ console.log('\nAll native dependencies are up to date.');
113
+ } else {
114
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
115
+ const summary = changes.map((c) => `- **${c.name}**: \`${c.from}\` → \`${c.to}\``).join('\n');
116
+ console.log(`\n${summary}`);
117
+
118
+ if (process.env.GITHUB_OUTPUT) {
119
+ const body = [
120
+ 'Automated native dependency update.',
121
+ '',
122
+ summary,
123
+ '',
124
+ 'Checksums were recomputed from the upstream release assets. `verify-checksums` re-downloads each asset and confirms it matches the recorded hash — a consistency check against the same source, not independent provenance.',
125
+ ].join('\n');
126
+ appendFileSync(process.env.GITHUB_OUTPUT, 'changed=true\n');
127
+ appendFileSync(process.env.GITHUB_OUTPUT, `body<<PR_BODY_EOF\n${body}\nPR_BODY_EOF\n`);
128
+ }
129
+ }
package/src/qpdf_addon.cc CHANGED
@@ -101,18 +101,18 @@ class CompressWorker : public Napi::AsyncWorker {
101
101
  public:
102
102
  // buffer variant
103
103
  CompressWorker(Napi::Env env, std::vector<uint8_t> data, bool lossy,
104
- bool stripMeta, std::string outputPath)
104
+ bool stripMeta, bool stripAttach, std::string outputPath)
105
105
  : Napi::AsyncWorker(env), deferred_(Napi::Promise::Deferred::New(env)),
106
106
  envAlive_(GetEnvAlive(env)), bufferData_(std::move(data)),
107
- lossy_(lossy), stripMeta_(stripMeta), useFile_(false),
108
- outputPath_(std::move(outputPath)) {}
107
+ lossy_(lossy), stripMeta_(stripMeta), stripAttach_(stripAttach),
108
+ useFile_(false), outputPath_(std::move(outputPath)) {}
109
109
 
110
110
  // file path variant
111
111
  CompressWorker(Napi::Env env, std::string path, bool lossy, bool stripMeta,
112
- std::string outputPath)
112
+ bool stripAttach, std::string outputPath)
113
113
  : Napi::AsyncWorker(env), deferred_(Napi::Promise::Deferred::New(env)),
114
114
  envAlive_(GetEnvAlive(env)), filePath_(std::move(path)), lossy_(lossy),
115
- stripMeta_(stripMeta), useFile_(true),
115
+ stripMeta_(stripMeta), stripAttach_(stripAttach), useFile_(true),
116
116
  outputPath_(std::move(outputPath)) {}
117
117
 
118
118
  Napi::Promise Promise() { return deferred_.Promise(); }
@@ -187,7 +187,8 @@ protected:
187
187
  coalesceContentStreams(*qpdf);
188
188
  minifyContentStreams(*qpdf);
189
189
  deduplicateStreams(*qpdf);
190
- stripEmbeddedFiles(*qpdf);
190
+ if (stripAttach_)
191
+ stripEmbeddedFiles(*qpdf);
191
192
  stripJavaScript(*qpdf);
192
193
  if (stripMeta_)
193
194
  stripMetadata(*qpdf);
@@ -267,6 +268,7 @@ private:
267
268
  std::string filePath_;
268
269
  bool lossy_;
269
270
  bool stripMeta_;
271
+ bool stripAttach_;
270
272
  bool useFile_;
271
273
  std::string outputPath_;
272
274
  std::shared_ptr<Buffer> writerBuf_;
@@ -287,6 +289,7 @@ static Napi::Value Compress(const Napi::CallbackInfo &info) {
287
289
 
288
290
  bool lossy = false;
289
291
  bool stripMeta = false;
292
+ bool stripAttach = false;
290
293
  std::string outputPath;
291
294
 
292
295
  if (info.Length() >= 2 && info[1].IsObject()) {
@@ -298,6 +301,9 @@ static Napi::Value Compress(const Napi::CallbackInfo &info) {
298
301
  if (options.Has("stripMetadata"))
299
302
  stripMeta = options.Get("stripMetadata").As<Napi::Boolean>().Value();
300
303
 
304
+ if (options.Has("stripAttachments"))
305
+ stripAttach = options.Get("stripAttachments").As<Napi::Boolean>().Value();
306
+
301
307
  if (options.Has("output"))
302
308
  outputPath = options.Get("output").As<Napi::String>().Utf8Value();
303
309
  }
@@ -306,7 +312,7 @@ static Napi::Value Compress(const Napi::CallbackInfo &info) {
306
312
  auto buf = info[0].As<Napi::Buffer<uint8_t>>();
307
313
  std::vector<uint8_t> data(buf.Data(), buf.Data() + buf.Length());
308
314
  auto *worker = new CompressWorker(env, std::move(data), lossy, stripMeta,
309
- std::move(outputPath));
315
+ stripAttach, std::move(outputPath));
310
316
  worker->Queue();
311
317
  return worker->Promise();
312
318
  }
@@ -314,7 +320,7 @@ static Napi::Value Compress(const Napi::CallbackInfo &info) {
314
320
  if (info[0].IsString()) {
315
321
  auto path = info[0].As<Napi::String>().Utf8Value();
316
322
  auto *worker = new CompressWorker(env, std::move(path), lossy, stripMeta,
317
- std::move(outputPath));
323
+ stripAttach, std::move(outputPath));
318
324
  worker->Queue();
319
325
  return worker->Promise();
320
326
  }
package/src/strip.cc CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  #include <set>
4
4
  #include <string>
5
+ #include <vector>
5
6
 
6
7
  #include <qpdf/QPDFObjectHandle.hh>
7
8
  #include <qpdf/QPDFPageDocumentHelper.hh>
@@ -45,24 +46,69 @@ void stripMetadata(QPDF &qpdf) {
45
46
  }
46
47
 
47
48
  // ---------------------------------------------------------------------------
48
- // Embedded file stripping — remove /EmbeddedFiles from the name tree
49
+ // Embedded file stripping — remove every path an attachment is reachable
50
+ // through
49
51
  // ---------------------------------------------------------------------------
50
52
 
53
+ static void removeAssociatedFiles(QPDFObjectHandle obj) {
54
+ auto dict = obj.isStream() ? obj.getDict() : obj;
55
+ if (dict.isDictionary() && dict.hasKey("/AF"))
56
+ dict.removeKey("/AF");
57
+ }
58
+
59
+ // an embedded file stream is reachable three ways: the /EmbeddedFiles name
60
+ // tree, an /AF associated-files array (PDF/A-3 and PDF 2.0), and a
61
+ // /FileAttachment annotation. Dropping only the name tree leaves the payload
62
+ // in the file and fully recoverable while hiding it from readers that look
63
+ // attachments up by name, so all three have to go — once nothing references
64
+ // the file specification, the writer drops it and its stream as unreferenced.
51
65
  void stripEmbeddedFiles(QPDF &qpdf) {
52
66
  auto root = qpdf.getRoot();
53
- if (!root.hasKey("/Names"))
54
- return;
55
67
 
56
- auto names = root.getKey("/Names");
57
- if (!names.isDictionary())
58
- return;
68
+ if (root.hasKey("/Names")) {
69
+ auto names = root.getKey("/Names");
70
+ if (names.isDictionary()) {
71
+ if (names.hasKey("/EmbeddedFiles"))
72
+ names.removeKey("/EmbeddedFiles");
73
+
74
+ // if /Names is now empty, remove it too
75
+ if (names.getKeys().empty())
76
+ root.removeKey("/Names");
77
+ }
78
+ }
79
+
80
+ // /AF may hang off the catalog, a page, an XObject, or an annotation
81
+ for (auto &obj : qpdf.getAllObjects())
82
+ removeAssociatedFiles(obj);
83
+
84
+ for (auto &page : QPDFPageDocumentHelper(qpdf).getAllPages()) {
85
+ auto pageObj = page.getObjectHandle();
86
+ auto annots = pageObj.getKey("/Annots");
87
+ if (!annots.isArray())
88
+ continue;
89
+
90
+ std::vector<QPDFObjectHandle> kept;
91
+ for (int i = 0; i < annots.getArrayNItems(); ++i) {
92
+ auto annot = annots.getArrayItem(i);
93
+ // a directly embedded annotation dictionary is not an object of its own,
94
+ // so the sweep above does not reach it
95
+ removeAssociatedFiles(annot);
96
+ if (annot.isDictionary()) {
97
+ auto subtype = annot.getKey("/Subtype");
98
+ if (subtype.isName() && subtype.getName() == "/FileAttachment")
99
+ continue;
100
+ }
101
+ kept.push_back(annot);
102
+ }
59
103
 
60
- if (names.hasKey("/EmbeddedFiles"))
61
- names.removeKey("/EmbeddedFiles");
104
+ if (static_cast<int>(kept.size()) == annots.getArrayNItems())
105
+ continue;
62
106
 
63
- // if /Names is now empty, remove it too
64
- if (names.getKeys().empty())
65
- root.removeKey("/Names");
107
+ if (kept.empty())
108
+ pageObj.removeKey("/Annots");
109
+ else
110
+ pageObj.replaceKey("/Annots", QPDFObjectHandle::newArray(kept));
111
+ }
66
112
  }
67
113
 
68
114
  // ---------------------------------------------------------------------------