qpdf-compress 0.7.1 → 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,17 @@ 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
+
9
20
  ## [0.7.1] - 2026-07-15
10
21
 
11
22
  ### Security
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
 
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.1",
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",
@@ -1,11 +1,11 @@
1
1
  {
2
- "darwin-arm64": "2af510fe83270db0874eef4accafe24a8aefa37af4e5f7d621eb2bb4702491ed",
3
- "darwin-x64": "5b55c91245c220ad287bc20a02a72c34edc11765ed1f71630fb174554c402251",
4
- "linux-arm": "302480ffb46576cc009386a3621099946bc1435387afe680a07c8d4abe90920b",
5
- "linux-arm64": "ae3ae7629a6ead5e61b0f67167ef4ad6bc3309f84bcae70d0e30cd3690246161",
6
- "linux-musl-arm64": "075a23fae7a2cc3d4b01fde42aa09e9d52d024cdb003236adfa4d1b1b5435d81",
7
- "linux-musl-x64": "2aa7f761de96acbf604ef2ed7d9f45068257ca12ac735fa0c5529a64b9e9c34b",
8
- "linux-x64": "85743113468dcdc2f676e9def2dba2f8edca8fd3084fb4ea032542a5ce1b3ce2",
9
- "win32-arm64": "ce651ad9d9e1e4b57e549ef1379aeef44f013a5912b0890bcc8605050e531328",
10
- "win32-x64": "d7ccbcb0b089996f116100fcaf0b0554c8b9c1a423f43955be2c16b9ce499906"
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
11
  }
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
  // ---------------------------------------------------------------------------