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 +31 -1
- package/README.md +19 -11
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/lib/index.ts +7 -0
- package/lib/types.ts +3 -0
- package/package.json +4 -4
- package/scripts/gen-prebuild-manifest.mjs +59 -0
- package/scripts/install.mjs +31 -1
- package/scripts/lib/integrity.mjs +23 -0
- package/scripts/native-deps.json +3 -3
- package/scripts/prebuilds.json +11 -0
- package/scripts/update-native-deps.mjs +129 -0
- package/src/qpdf_addon.cc +14 -8
- package/src/strip.cc +57 -11
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.
|
|
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 | ✅
|
|
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
|
|
153
|
-
|
|
|
154
|
-
| `input`
|
|
155
|
-
| `options.lossy`
|
|
156
|
-
| `options.stripMetadata`
|
|
157
|
-
| `options.
|
|
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
|
|
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
|
|
235
|
-
17.
|
|
236
|
-
18.
|
|
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/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -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
|
|
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.
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
|
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;
|
|
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
|
}
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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.
|
|
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": "^
|
|
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": "^
|
|
105
|
+
"lint-staged": "^17.0.8",
|
|
106
106
|
"node-gyp": "^13.0.0",
|
|
107
107
|
"prettier": "^3.8.1",
|
|
108
|
-
"typescript": "^
|
|
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
|
+
}
|
package/scripts/install.mjs
CHANGED
|
@@ -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'));
|
package/scripts/native-deps.json
CHANGED
|
@@ -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
|
|
10
|
+
"version": "14.2.1",
|
|
11
11
|
"origin": "https://github.com",
|
|
12
|
-
"url": "https://github.com/harfbuzz/harfbuzz/archive/refs/tags/14.1.
|
|
13
|
-
"sha256": "
|
|
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),
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
104
|
+
if (static_cast<int>(kept.size()) == annots.getArrayNItems())
|
|
105
|
+
continue;
|
|
62
106
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
107
|
+
if (kept.empty())
|
|
108
|
+
pageObj.removeKey("/Annots");
|
|
109
|
+
else
|
|
110
|
+
pageObj.replaceKey("/Annots", QPDFObjectHandle::newArray(kept));
|
|
111
|
+
}
|
|
66
112
|
}
|
|
67
113
|
|
|
68
114
|
// ---------------------------------------------------------------------------
|