qpdf-compress 0.8.0 → 0.8.2

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,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.2] - 2026-09-28
10
+
11
+ ### Fixed
12
+
13
+ - **The content-stream minifier could hang on a form feed or vertical tab.** `minifyContentStreams` skipped only four whitespace bytes (space, tab, CR, LF) in its dispatcher, while its token loops stopped on `std::isspace`, which also counts form feed (`0x0C`) and vertical tab (`0x0B`) — both legal PDF whitespace. A form feed between two operators (a valid content stream such as `q\fQ`) therefore reached the token branch, which consumed nothing, so the cursor never advanced and the loop appended a space forever until the process ran out of memory. It runs on a libuv worker thread, so a caller's `try`/`catch` cannot interrupt it — a single crafted document is a denial of service. The dispatcher now skips whatever `std::isspace` recognises, and the token branch guarantees forward progress on any byte no branch consumes (a stray unbalanced `)` stalled it the same way), so no input can wedge the loop.
14
+
15
+ - **Correction to the 0.8.1 release notes.** 0.8.1 shipped with an entry claiming that 0.8.0 had been published without its prebuilt binaries and should be skipped. That was wrong. The REST `assets` array reads empty for releases flagged immutable, and an empty array was mistaken for missing files. Every 0.8.0 tarball is attached and downloadable, each one's SHA-256 matches the pin shipped inside the npm package, and `npm install qpdf-compress@0.8.0` installs the prebuilt binary without compiling anything. **0.8.0 is fine to use and is not deprecated.**
16
+
17
+ ## [0.8.1] - 2026-09-23
18
+
19
+ ### Changed
20
+
21
+ - The release workflow attaches the prebuilt tarballs to a draft release and publishes it afterwards, instead of publishing first and uploading after: immutable releases reject an upload to an already-published release, so the draft ordering keeps the upload clear of that edge. A following step then confirms every tarball is downloadable from the release before the job succeeds.
22
+
23
+ ### Added
24
+
25
+ - `npm run verify:pdfa` — a PDF/A conformance harness. It validates `compress()` output against veraPDF, the reference validator, over a real ZUGFeRD PDF/A-3a invoice, and compares the failing clauses to a recorded baseline (`scripts/pdfa-baseline.json`), failing on any difference in either direction so that neither a regression nor an improvement passes unnoticed. CI runs it on Linux / Node 22. veraPDF is GPL-3.0 / MPL-2.0 and is run as an external container pinned by digest — never vendored or linked. Groundwork for [#35](https://github.com/xonaman/nodejs-qpdf-compress/issues/35).
26
+
9
27
  ## [0.8.0] - 2026-09-23
10
28
 
11
29
  ### Fixed
package/README.md CHANGED
@@ -160,7 +160,7 @@ Compresses a PDF document. Automatically repairs damaged PDFs.
160
160
  | `options.stripAttachments` | `boolean` | Remove embedded file attachments. Default: `true` |
161
161
  | `options.output` | `string` | Write to file path instead of returning a `Buffer` |
162
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.
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. That is measured, not assumed: `npm run verify:pdfa` validates the output against veraPDF over a real PDF/A-3a invoice, and `scripts/pdfa-baseline.json` records exactly which clauses fail.
164
164
 
165
165
  **Both modes:**
166
166
 
@@ -42,3 +42,15 @@ Copyright Jean-loup Gailly and Mark Adler. Licensed under the zlib License.
42
42
  zlib provides DEFLATE compression and is statically linked into the Windows
43
43
  build (via vcpkg); on macOS and Linux the system zlib is used at build time.
44
44
  See <https://github.com/madler/zlib/blob/master/LICENSE>.
45
+
46
+ ## Development and test material
47
+
48
+ Neither of the following is distributed in the npm package: the fixture lives under `test/`, which is excluded from the package `files` list, and veraPDF is invoked as an external tool in its own container rather than vendored or linked.
49
+
50
+ ### `test/fixtures/pdfa3-invoice.pdf`
51
+
52
+ A real ZUGFeRD 2.1 (EN 16931) PDF/A-3a invoice, used by `npm run verify:pdfa` to measure the PDF/A conformance of compression output. Taken unmodified from the [ZUGFeRD/corpus](https://github.com/ZUGFeRD/corpus) project (Apache-2.0), path `ZUGFeRDv2/correct/symtrax/Beispiele/EN16931/zugferd_2p1_EN16931_AbweichenderZahlungsempf.pdf`, SHA-256 `a4b903d4e508a80d65276f030d0fbeab5081d62c2c2b19ffd2f13bcdae37c564`.
53
+
54
+ ### veraPDF
55
+
56
+ Copyright the veraPDF Consortium. Licensed under the GNU General Public License v3 or the Mozilla Public License v2. The reference PDF/A validator, run by `npm run verify:pdfa` from the `verapdf/cli` container image, pinned by digest in `scripts/pdfa-baseline.json`. See <https://github.com/veraPDF/veraPDF-apps>.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qpdf-compress",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Native PDF compression for Node.js, powered by QPDF",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -77,6 +77,7 @@
77
77
  "download:qpdf": "node scripts/download-qpdf.mjs",
78
78
  "download:harfbuzz": "node scripts/download-harfbuzz.mjs",
79
79
  "verify:checksums": "node scripts/verify-checksums.mjs",
80
+ "verify:pdfa": "tsc && node scripts/verify-pdfa.mjs",
80
81
  "test": "vitest run",
81
82
  "test:coverage": "vitest run --coverage",
82
83
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,23 @@
1
+ {
2
+ "image": "verapdf/cli@sha256:d5ee329657cf9bc4b2400392dd54c7d0a0ce9980ff6fa2da5590eebeec007cdb",
3
+ "flavour": "3a",
4
+ "fixture": "pdfa3-invoice.pdf",
5
+ "variants": {
6
+ "original": {
7
+ "compliant": true,
8
+ "failedRules": []
9
+ },
10
+ "default": {
11
+ "compliant": false,
12
+ "failedRules": ["6.1.7.1-2", "6.2.4.3-2", "6.2.4.3-4", "6.6.2.1-1", "6.7.2.2-1", "6.7.3.3-1"]
13
+ },
14
+ "keep-attachments": {
15
+ "compliant": false,
16
+ "failedRules": ["6.1.7.1-2", "6.2.4.3-2", "6.2.4.3-4", "6.6.2.1-1", "6.7.2.2-1", "6.7.3.3-1"]
17
+ },
18
+ "keep-both": {
19
+ "compliant": false,
20
+ "failedRules": ["6.1.7.1-2", "6.2.4.3-2", "6.2.4.3-4", "6.7.3.3-1"]
21
+ }
22
+ }
23
+ }
@@ -1,11 +1,11 @@
1
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"
2
+ "darwin-arm64": "a2fda84bd3569abf5a39b7ce30a58652b8189751cedbaf943e391c9f0b45646c",
3
+ "darwin-x64": "a908f0fe57b78a10e7dfa461a395da050da5499715f5aae2cf793dfe7a982e5f",
4
+ "linux-arm": "39531172aa069e69e3b7db3ffc2462572fddf2a725d2f9ac9abca06513111184",
5
+ "linux-arm64": "1216bea0d7a82fe47bcf3f77f3219f6f3a1620480ed42b3f3a9d0d688f88dbd3",
6
+ "linux-musl-arm64": "d0fe61e513147dced9454a590fe4c17a8f39c0ed9ed858b3f9d44d7b33ec0e86",
7
+ "linux-musl-x64": "865748574c08bdd74decdb7c9acd91b7a0c7e4269e3cb48811d8a9b8c654ff17",
8
+ "linux-x64": "2e766fd34ee2ff86d0f4e8f02008b08507b4d59081bea68185dd78e1341be91f",
9
+ "win32-arm64": "c6793d96feb0a272ced9ba18ec97b29dce6075fcef9692f095defa699252f657",
10
+ "win32-x64": "fcc1c299080e87a5932d5dbe7fcb2ec5c849cf98a094eff55f8408ed1ccce524"
11
11
  }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validates the PDF/A conformance of compress() output against a recorded baseline.
4
+ *
5
+ * veraPDF is the reference PDF/A validator. It is GPL-3.0 / MPL-2.0, so it is neither
6
+ * vendored nor linked — it runs as an external tool in its own container, pinned by
7
+ * digest so a verdict cannot drift under us when the image is rebuilt.
8
+ *
9
+ * The baseline records which clauses each variant fails today. Any difference, in
10
+ * either direction, fails the run: a regression and an improvement both need the
11
+ * baseline updated deliberately (`--update`) rather than silently absorbed.
12
+ *
13
+ * Usage: npm run verify:pdfa [-- --update]
14
+ */
15
+ import { execFileSync } from 'node:child_process';
16
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { dirname, join, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ import { compress } from '../dist/index.js';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const root = resolve(__dirname, '..');
24
+ const baselinePath = join(__dirname, 'pdfa-baseline.json');
25
+ const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
26
+ const update = process.argv.includes('--update');
27
+
28
+ // the work directory must sit inside the repo: Docker Desktop shares the user's
29
+ // own tree, not every host temp path
30
+ const workDir = join(root, '.cache', 'pdfa');
31
+
32
+ const variants = {
33
+ original: null,
34
+ default: {},
35
+ 'keep-attachments': { stripAttachments: false },
36
+ 'keep-both': { stripAttachments: false, stripMetadata: false },
37
+ };
38
+
39
+ function veraPdf(name) {
40
+ const args = [
41
+ 'run',
42
+ '--rm',
43
+ '--platform',
44
+ 'linux/amd64',
45
+ '-v',
46
+ `${workDir}:/data`,
47
+ baseline.image,
48
+ '--flavour',
49
+ baseline.flavour,
50
+ '--format',
51
+ 'mrr',
52
+ `/data/${name}.pdf`,
53
+ ];
54
+ let xml;
55
+ try {
56
+ xml = execFileSync('docker', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
57
+ } catch (err) {
58
+ // veraPDF exits non-zero for a non-compliant file; the report is still on stdout
59
+ if (typeof err.stdout !== 'string' || !err.stdout.includes('<validationReport')) throw err;
60
+ xml = err.stdout;
61
+ }
62
+ const compliant = /<validationReport[^>]*isCompliant="true"/.test(xml);
63
+ const failed = [
64
+ ...new Set(
65
+ [
66
+ ...xml.matchAll(
67
+ /<rule\b[^>]*\bclause="([^"]+)"[^>]*\btestNumber="([^"]+)"[^>]*\bstatus="failed"/g,
68
+ ),
69
+ ].map((m) => `${m[1]}-${m[2]}`),
70
+ ),
71
+ ].sort();
72
+ return { compliant, failedRules: failed };
73
+ }
74
+
75
+ try {
76
+ execFileSync('docker', ['version', '--format', '{{.Server.Version}}'], { stdio: 'ignore' });
77
+ } catch {
78
+ console.error('Docker is required to run veraPDF. Start Docker and try again.');
79
+ process.exit(1);
80
+ }
81
+
82
+ rmSync(workDir, { recursive: true, force: true });
83
+ mkdirSync(workDir, { recursive: true });
84
+
85
+ const fixture = readFileSync(join(root, 'test', 'fixtures', baseline.fixture));
86
+ writeFileSync(join(workDir, 'original.pdf'), fixture);
87
+ for (const [name, options] of Object.entries(variants)) {
88
+ if (!options) continue;
89
+ await compress(fixture, { ...options, output: join(workDir, `${name}.pdf`) });
90
+ }
91
+
92
+ const results = {};
93
+ let failures = 0;
94
+
95
+ for (const name of Object.keys(variants)) {
96
+ const actual = veraPdf(name);
97
+ results[name] = actual;
98
+
99
+ const expected = baseline.variants[name];
100
+ const same =
101
+ expected &&
102
+ expected.compliant === actual.compliant &&
103
+ expected.failedRules.join() === actual.failedRules.join();
104
+
105
+ const verdict = actual.compliant ? 'PASS' : `FAIL (${actual.failedRules.length})`;
106
+ console.log(
107
+ `${same || update ? ' ok ' : 'DIFF'} ${name.padEnd(17)} ${verdict.padEnd(9)} ${actual.failedRules.join(' ')}`,
108
+ );
109
+
110
+ if (!same && !update) {
111
+ failures++;
112
+ console.log(
113
+ ` expected: ${expected ? `${expected.compliant ? 'PASS' : 'FAIL'} ${expected.failedRules.join(' ')}` : '(no baseline entry)'}`,
114
+ );
115
+ }
116
+ }
117
+
118
+ if (update) {
119
+ writeFileSync(baselinePath, `${JSON.stringify({ ...baseline, variants: results }, null, 2)}\n`);
120
+ // keep the committed file canonical so the pre-commit hook has nothing to say
121
+ try {
122
+ execFileSync('npx', ['prettier', '--write', baselinePath], { stdio: 'ignore' });
123
+ } catch {
124
+ // prettier is a devDependency; a missing one is not worth failing the run over
125
+ }
126
+ console.log(`\nBaseline updated: ${baselinePath}`);
127
+ process.exit(0);
128
+ }
129
+
130
+ if (failures) {
131
+ console.error(
132
+ `\n${failures} variant(s) differ from the baseline. Re-run with --update once the change is intended.`,
133
+ );
134
+ process.exit(1);
135
+ }
136
+ console.log('\nAll variants match the recorded baseline.');
package/src/content.cc CHANGED
@@ -90,8 +90,10 @@ void minifyContentStreams(QPDF &qpdf) {
90
90
  while (pos < raw.size()) {
91
91
  char ch = raw[pos];
92
92
 
93
- // skip whitespace
94
- if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
93
+ // skip whitespace — use the same predicate the token loops below use
94
+ // (std::isspace), so a form-feed or vertical-tab can never fall through
95
+ // to the token branch and stall the tokenizer.
96
+ if (std::isspace(static_cast<unsigned char>(ch))) {
95
97
  if (!minified.empty())
96
98
  needSpace = true;
97
99
  ++pos;
@@ -203,10 +205,6 @@ void minifyContentStreams(QPDF &qpdf) {
203
205
 
204
206
  // regular token (number, operator)
205
207
  {
206
- if (needSpace) {
207
- minified += ' ';
208
- needSpace = false;
209
- }
210
208
  size_t start = pos;
211
209
  while (pos < raw.size() &&
212
210
  !std::isspace(static_cast<unsigned char>(raw[pos])) &&
@@ -215,6 +213,19 @@ void minifyContentStreams(QPDF &qpdf) {
215
213
  raw[pos] != ')')
216
214
  ++pos;
217
215
 
216
+ // forward-progress guard: a byte that starts no token — a stray ')'
217
+ // or any whitespace the dispatcher above did not catch — leaves
218
+ // pos == start. Without this the outer loop spins forever, appending
219
+ // to `minified` without bound. Skip the byte so progress is guaranteed.
220
+ if (pos == start) {
221
+ ++pos;
222
+ continue;
223
+ }
224
+
225
+ if (needSpace) {
226
+ minified += ' ';
227
+ needSpace = false;
228
+ }
218
229
  std::string token(raw, start, pos - start);
219
230
 
220
231
  // trim numeric formatting