qpdf-compress 0.6.0 → 0.6.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
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.6.2] - 2026-04-11
8
+
9
+ ### Fixed
10
+
11
+ - **Shared FontFile2 corruption**: fonts sharing the same `/FontDescriptor` and `/FontFile2` are now subset with merged glyph IDs across all referencing font objects, preventing glyph removal needed by sibling fonts
12
+ - **Already-subset font corruption**: fonts with `ABCDEF+` subset prefix are now skipped in width zeroing, TrueType/CID subsetting, and `/W` optimization to avoid double-processing
13
+ - **Nested XObject font usage**: font usage collection now recursively scans Form XObjects at all nesting levels instead of only the first level
14
+ - Removed unreliable unused font removal from `optimizeFonts` (already handled by `removeUnusedResources`)
15
+
16
+ ## [0.6.1] - 2026-04-06
17
+
18
+ ### Fixed
19
+
20
+ - Statically link libstdc++ on Linux to fix `GLIBCXX_3.4.31 not found` errors on systems with older GCC
21
+
7
22
  ## [0.6.0] - 2026-04-04
8
23
 
9
24
  ### Changed
package/binding.gyp CHANGED
@@ -6,13 +6,17 @@
6
6
  "src/qpdf_addon.cc",
7
7
  "src/jpeg.cc",
8
8
  "src/images.cc",
9
- "src/optimize.cc",
9
+ "src/fonts.cc",
10
+ "src/content.cc",
11
+ "src/strip.cc",
12
+ "src/structure.cc",
10
13
  "src/font_subset.cc"
11
14
  ],
12
15
  "include_dirs": [
13
16
  "<!@(node -p \"require('node-addon-api').include\")",
14
17
  "deps/qpdf/include",
15
18
  "deps/mozjpeg/include",
19
+ "deps/harfbuzz/include/harfbuzz",
16
20
  "src"
17
21
  ],
18
22
  "defines": [
@@ -22,7 +26,7 @@
22
26
  "-fno-exceptions"
23
27
  ],
24
28
  "cflags": [
25
- "-Os",
29
+ "-O2",
26
30
  "-flto",
27
31
  "-ffunction-sections",
28
32
  "-fdata-sections"
@@ -31,7 +35,7 @@
31
35
  "-fno-exceptions"
32
36
  ],
33
37
  "cflags_cc": [
34
- "-std=c++20",
38
+ "-std=c++17",
35
39
  "-fvisibility=hidden"
36
40
  ],
37
41
  "conditions": [
@@ -45,18 +49,20 @@
45
49
  ],
46
50
  "xcode_settings": {
47
51
  "GCC_ENABLE_CPP_EXCEPTIONS": "YES",
48
- "CLANG_CXX_LANGUAGE_STANDARD": "c++20",
52
+ "CLANG_CXX_LANGUAGE_STANDARD": "c++17",
49
53
  "GCC_SYMBOLS_PRIVATE_EXTERN": "YES",
50
54
  "DEAD_CODE_STRIPPING": "YES",
51
55
  "LLVM_LTO": "YES",
52
56
  "OTHER_CPLUSPLUSFLAGS": [
53
- "-Os",
57
+ "-O2",
54
58
  "-flto",
55
59
  "-ffunction-sections",
56
60
  "-fdata-sections"
57
61
  ],
58
62
  "OTHER_LDFLAGS": [
59
63
  "<(module_root_dir)/deps/qpdf/lib/libqpdf.a",
64
+ "<(module_root_dir)/deps/harfbuzz/lib/libharfbuzz-subset.a",
65
+ "<(module_root_dir)/deps/harfbuzz/lib/libharfbuzz.a",
60
66
  "-L/opt/homebrew/lib",
61
67
  "-L/usr/local/lib",
62
68
  "-lz",
@@ -73,9 +79,13 @@
73
79
  {
74
80
  "libraries": [
75
81
  "<(module_root_dir)/deps/qpdf/lib/libqpdf.a",
82
+ "<(module_root_dir)/deps/harfbuzz/lib/libharfbuzz-subset.a",
83
+ "<(module_root_dir)/deps/harfbuzz/lib/libharfbuzz.a",
76
84
  "-lz",
77
85
  "<(module_root_dir)/deps/mozjpeg/lib/libjpeg.a",
86
+ "-static-libstdc++",
78
87
  "-flto",
88
+ "-Wl,--allow-multiple-definition",
79
89
  "-Wl,--gc-sections",
80
90
  "-Wl,-S",
81
91
  "-lpthread",
@@ -92,21 +102,26 @@
92
102
  "msvs_settings": {
93
103
  "VCCLCompilerTool": {
94
104
  "ExceptionHandling": 1,
95
- "Optimization": 1,
105
+ "Optimization": 2,
96
106
  "WholeProgramOptimization": "true",
97
107
  "RuntimeLibrary": 0,
98
108
  "AdditionalOptions": [
99
- "/std:c++20"
109
+ "/std:c++17"
100
110
  ]
101
111
  },
102
112
  "VCLinkerTool": {
103
- "LinkTimeCodeGeneration": 1
113
+ "LinkTimeCodeGeneration": 1,
114
+ "AdditionalOptions": [
115
+ "/FORCE:MULTIPLE"
116
+ ]
104
117
  }
105
118
  },
106
119
  "libraries": [
107
120
  "<(module_root_dir)/deps/qpdf/lib/qpdf.lib",
108
121
  "<(module_root_dir)/deps/qpdf/lib/zlib.lib",
109
- "<(module_root_dir)/deps/mozjpeg/lib/jpeg-static.lib"
122
+ "<(module_root_dir)/deps/mozjpeg/lib/jpeg-static.lib",
123
+ "<(module_root_dir)/deps/harfbuzz/lib/harfbuzz-subset.lib",
124
+ "<(module_root_dir)/deps/harfbuzz/lib/harfbuzz.lib"
110
125
  ]
111
126
  }
112
127
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qpdf-compress",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Native PDF compression for Node.js, powered by QPDF",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -69,9 +69,10 @@
69
69
  "build": "node-gyp rebuild && node scripts/bundle-lib.mjs",
70
70
  "build:ts": "tsc",
71
71
  "prepublishOnly": "tsc",
72
- "download": "node scripts/download-mozjpeg.mjs && node scripts/download-qpdf.mjs",
72
+ "download": "node scripts/download-mozjpeg.mjs && node scripts/download-qpdf.mjs && node scripts/download-harfbuzz.mjs",
73
73
  "download:mozjpeg": "node scripts/download-mozjpeg.mjs",
74
74
  "download:qpdf": "node scripts/download-qpdf.mjs",
75
+ "download:harfbuzz": "node scripts/download-harfbuzz.mjs",
75
76
  "test": "vitest run",
76
77
  "lint": "eslint --fix .",
77
78
  "lint:check": "eslint .",
@@ -0,0 +1,166 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createWriteStream, mkdirSync, existsSync, rmSync, cpSync, readdirSync } from 'node:fs';
3
+ import { pipeline } from 'node:stream/promises';
4
+ import { Readable } from 'node:stream';
5
+ import { join } from 'node:path';
6
+
7
+ const HARFBUZZ_VERSION = '14.1.0';
8
+ const BASE_URL = 'https://github.com/harfbuzz/harfbuzz/archive/refs/tags';
9
+
10
+ const root = join(import.meta.dirname, '..');
11
+ const depsDir = join(root, 'deps', 'harfbuzz');
12
+
13
+ if (existsSync(join(depsDir, 'include', 'harfbuzz', 'hb-subset.h'))) {
14
+ console.log('HarfBuzz already built, skipping.');
15
+ process.exit(0);
16
+ }
17
+
18
+ // validate version to prevent SSRF
19
+ if (!/^\d+\.\d+\.\d+$/.test(HARFBUZZ_VERSION)) {
20
+ console.error(`Invalid HarfBuzz version: ${HARFBUZZ_VERSION}`);
21
+ process.exit(1);
22
+ }
23
+
24
+ const url = `${BASE_URL}/${HARFBUZZ_VERSION}.tar.gz`;
25
+ const tarball = join(root, `harfbuzz-${HARFBUZZ_VERSION}.tar.gz`);
26
+ const srcDir = join(root, `harfbuzz-${HARFBUZZ_VERSION}`);
27
+ const buildDir = join(root, 'build-harfbuzz');
28
+
29
+ // step 1: download
30
+ console.log(`Downloading HarfBuzz ${HARFBUZZ_VERSION}...`);
31
+ console.log(`URL: ${url}`);
32
+
33
+ const response = await fetch(url, { redirect: 'follow' });
34
+ if (!response.ok) {
35
+ console.error(`Download failed: ${response.status} ${response.statusText}`);
36
+ process.exit(1);
37
+ }
38
+
39
+ mkdirSync(join(root, 'deps'), { recursive: true });
40
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(tarball));
41
+
42
+ console.log('Extracting...');
43
+ execFileSync('tar', ['-xzf', tarball, '-C', root], { stdio: 'inherit' });
44
+ rmSync(tarball);
45
+
46
+ // GitHub archive tarballs extract to `harfbuzz-{tag}` — find it
47
+ if (!existsSync(srcDir)) {
48
+ const candidates = readdirSync(root).filter(
49
+ (d) => d.startsWith('harfbuzz-') && !d.endsWith('.tar.gz'),
50
+ );
51
+ const match = candidates.find((d) => d.includes(HARFBUZZ_VERSION));
52
+ if (match) {
53
+ const { renameSync } = await import('node:fs');
54
+ renameSync(join(root, match), srcDir);
55
+ console.log(`Renamed ${match} → harfbuzz-${HARFBUZZ_VERSION}`);
56
+ } else {
57
+ console.error(
58
+ `Could not find extracted HarfBuzz source directory. Found: ${candidates.join(', ')}`,
59
+ );
60
+ process.exit(1);
61
+ }
62
+ }
63
+
64
+ // step 2: build with CMake (minimal static build — subset library only)
65
+ console.log('Building HarfBuzz...');
66
+ mkdirSync(buildDir, { recursive: true });
67
+
68
+ const cmakeArgs = [
69
+ '-S',
70
+ srcDir,
71
+ '-B',
72
+ buildDir,
73
+ '-DCMAKE_BUILD_TYPE=Release',
74
+ '-DCMAKE_POSITION_INDEPENDENT_CODE=ON',
75
+ '-DBUILD_SHARED_LIBS=OFF',
76
+ '-DHB_BUILD_SUBSET=ON',
77
+ '-DHB_BUILD_RASTER=OFF',
78
+ '-DHB_BUILD_VECTOR=OFF',
79
+ '-DHB_BUILD_GPU=OFF',
80
+ '-DHB_HAVE_FREETYPE=OFF',
81
+ '-DHB_HAVE_GLIB=OFF',
82
+ '-DHB_HAVE_ICU=OFF',
83
+ '-DHB_HAVE_GOBJECT=OFF',
84
+ '-DHB_BUILD_TESTS=OFF',
85
+ `-DCMAKE_INSTALL_PREFIX=${depsDir}`,
86
+ ];
87
+
88
+ if (process.platform === 'linux') {
89
+ cmakeArgs.push('-DCMAKE_C_FLAGS=-fPIC', '-DCMAKE_CXX_FLAGS=-fPIC');
90
+ }
91
+
92
+ if (process.platform === 'win32') {
93
+ const vcpkgRoot = process.env.VCPKG_ROOT || join(process.env.GITHUB_WORKSPACE || '', 'vcpkg');
94
+ if (existsSync(join(vcpkgRoot, 'scripts', 'buildsystems', 'vcpkg.cmake'))) {
95
+ const triplet = process.env.VCPKG_TARGET_TRIPLET || `${process.arch}-windows-static`;
96
+ cmakeArgs.push(
97
+ `-DCMAKE_TOOLCHAIN_FILE=${join(vcpkgRoot, 'scripts', 'buildsystems', 'vcpkg.cmake')}`,
98
+ `-DVCPKG_TARGET_TRIPLET=${triplet}`,
99
+ );
100
+ }
101
+ const targetArch = (process.env.VCPKG_TARGET_TRIPLET || '').split('-')[0] || process.arch;
102
+ if (targetArch === 'arm64') {
103
+ cmakeArgs.push('-A', 'arm64');
104
+ }
105
+ cmakeArgs.push('-DCMAKE_POLICY_DEFAULT_CMP0091=NEW');
106
+ cmakeArgs.push('-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded');
107
+ cmakeArgs.splice(cmakeArgs.indexOf('-DCMAKE_BUILD_TYPE=Release'), 1);
108
+ }
109
+
110
+ execFileSync('cmake', cmakeArgs, { stdio: 'inherit' });
111
+
112
+ const buildArgs = ['--build', buildDir, '--parallel'];
113
+ if (process.platform === 'win32') {
114
+ buildArgs.push('--config', 'Release');
115
+ }
116
+ execFileSync('cmake', buildArgs, { stdio: 'inherit' });
117
+
118
+ // step 3: install headers and libraries
119
+ console.log('Installing to deps/harfbuzz...');
120
+ mkdirSync(join(depsDir, 'lib'), { recursive: true });
121
+ mkdirSync(join(depsDir, 'include', 'harfbuzz'), { recursive: true });
122
+
123
+ // copy public headers from source
124
+ const srcHeaders = join(srcDir, 'src');
125
+ const headerFiles = readdirSync(srcHeaders).filter((f) => f.startsWith('hb') && f.endsWith('.h'));
126
+ for (const h of headerFiles) {
127
+ cpSync(join(srcHeaders, h), join(depsDir, 'include', 'harfbuzz', h));
128
+ }
129
+
130
+ // also copy generated config header
131
+ const generatedHeader = join(buildDir, 'src', 'config.h');
132
+ if (existsSync(generatedHeader)) {
133
+ cpSync(generatedHeader, join(depsDir, 'include', 'harfbuzz', 'config.h'));
134
+ }
135
+
136
+ // copy static libraries — CMake may place them in the build root or src/
137
+ const isLibFile = (f) => (f.endsWith('.a') || f.endsWith('.lib')) && f.includes('harfbuzz');
138
+
139
+ let libsCopied = 0;
140
+ const libSearchDirs = [
141
+ buildDir,
142
+ join(buildDir, 'src'),
143
+ join(buildDir, 'Release'),
144
+ join(buildDir, 'src', 'Release'),
145
+ ];
146
+ for (const dir of libSearchDirs) {
147
+ if (!existsSync(dir)) continue;
148
+ const libs = readdirSync(dir).filter(isLibFile);
149
+ for (const lib of libs) {
150
+ cpSync(join(dir, lib), join(depsDir, 'lib', lib));
151
+ console.log(`Copied ${lib} from ${dir}`);
152
+ libsCopied++;
153
+ }
154
+ }
155
+ if (libsCopied === 0) {
156
+ console.error('No HarfBuzz static libraries found!');
157
+ console.error('Searched:', libSearchDirs.join(', '));
158
+ process.exit(1);
159
+ }
160
+
161
+ // step 4: cleanup
162
+ console.log('Cleaning up...');
163
+ rmSync(srcDir, { recursive: true, force: true });
164
+ rmSync(buildDir, { recursive: true, force: true });
165
+
166
+ console.log(`HarfBuzz ${HARFBUZZ_VERSION} installed to deps/harfbuzz.`);
@@ -93,6 +93,7 @@ if (process.platform === 'darwin') {
93
93
 
94
94
  // Windows multi-config generator
95
95
  if (process.platform === 'win32') {
96
+ cmakeArgs.push('-DCMAKE_POLICY_DEFAULT_CMP0091=NEW');
96
97
  cmakeArgs.push('-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded');
97
98
  cmakeArgs.splice(cmakeArgs.indexOf('-DCMAKE_BUILD_TYPE=Release'), 1);
98
99
 
@@ -85,7 +85,13 @@ const cmakeArgs = [
85
85
  // force -fPIC for static library objects on Linux — CMAKE_POSITION_INDEPENDENT_CODE
86
86
  // alone is not always respected by QPDF's CMake targets
87
87
  if (process.platform === 'linux') {
88
- cmakeArgs.push('-DCMAKE_C_FLAGS=-fPIC', '-DCMAKE_CXX_FLAGS=-fPIC');
88
+ cmakeArgs.push('-DCMAKE_C_FLAGS=-fPIC', '-DCMAKE_CXX_FLAGS=-fPIC -fvisibility=hidden');
89
+ }
90
+
91
+ if (process.platform === 'darwin') {
92
+ // match the addon's -fvisibility=hidden so exception typeinfo pointers are
93
+ // consistent and catch(std::exception&) works across the static link boundary
94
+ cmakeArgs.push('-DCMAKE_CXX_VISIBILITY_PRESET=hidden', '-DCMAKE_VISIBILITY_INLINES_HIDDEN=ON');
89
95
  }
90
96
 
91
97
  // point QPDF at vendored mozjpeg
@@ -132,6 +138,7 @@ if (process.platform === 'win32') {
132
138
  }
133
139
  }
134
140
  // force static CRT (/MT) to match node-gyp
141
+ cmakeArgs.push('-DCMAKE_POLICY_DEFAULT_CMP0091=NEW');
135
142
  cmakeArgs.push('-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded');
136
143
  // MSVC uses multi-config generator — specify release at build time instead
137
144
  cmakeArgs.splice(cmakeArgs.indexOf('-DCMAKE_BUILD_TYPE=Release'), 1);
@@ -98,6 +98,7 @@ async function buildFromSource() {
98
98
  console.log('Building from source...');
99
99
  execSync('node scripts/download-mozjpeg.mjs', { stdio: 'inherit', cwd: root });
100
100
  execSync('node scripts/download-qpdf.mjs', { stdio: 'inherit', cwd: root });
101
+ execSync('node scripts/download-harfbuzz.mjs', { stdio: 'inherit', cwd: root });
101
102
  execSync('npx node-gyp rebuild', { stdio: 'inherit', cwd: root });
102
103
  execSync('node scripts/bundle-lib.mjs', { stdio: 'inherit', cwd: root });
103
104
  }
package/src/content.cc ADDED
@@ -0,0 +1,291 @@
1
+ #include "content.h"
2
+
3
+ #include <cctype>
4
+ #include <string>
5
+
6
+ #include <qpdf/QPDFObjectHandle.hh>
7
+ #include <qpdf/QPDFPageDocumentHelper.hh>
8
+ #include <qpdf/QPDFPageObjectHelper.hh>
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Content stream coalescing — merge multiple content streams per page into one
12
+ // ---------------------------------------------------------------------------
13
+
14
+ void coalesceContentStreams(QPDF &qpdf) {
15
+ for (auto &page : QPDFPageDocumentHelper(qpdf).getAllPages()) {
16
+ auto pageObj = page.getObjectHandle();
17
+ auto contents = pageObj.getKey("/Contents");
18
+
19
+ // only coalesce if there are multiple content streams (array)
20
+ if (contents.isArray() && contents.getArrayNItems() > 1) {
21
+ page.coalesceContentStreams();
22
+ }
23
+ }
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Content stream minification — normalize whitespace and number formatting
28
+ // to reduce content stream size before Flate compression
29
+ // ---------------------------------------------------------------------------
30
+
31
+ // trims a numeric string: remove trailing zeros after decimal point,
32
+ // remove the decimal point if it becomes the last char,
33
+ // and strip a leading zero for values between -1 and 1.
34
+ static std::string trimNumber(const std::string &s) {
35
+ // only process strings that look like decimal numbers
36
+ if (s.find('.') == std::string::npos)
37
+ return s;
38
+
39
+ std::string result = s;
40
+
41
+ // strip trailing zeros after decimal point
42
+ size_t dot = result.find('.');
43
+ if (dot != std::string::npos) {
44
+ size_t last = result.size() - 1;
45
+ while (last > dot && result[last] == '0')
46
+ --last;
47
+ if (last == dot)
48
+ result.erase(dot); // remove the dot too (e.g. "1." → "1")
49
+ else
50
+ result.erase(last + 1);
51
+ }
52
+
53
+ // if trimming left an empty string or bare sign, the value was zero
54
+ if (result.empty() || result == "-")
55
+ return "0";
56
+
57
+ // strip leading zero for values like "0.5" → ".5" or "-0.5" → "-.5"
58
+ if (result.size() >= 2 && result[0] == '0' && result[1] == '.')
59
+ result.erase(0, 1);
60
+ else if (result.size() >= 3 && result[0] == '-' && result[1] == '0' &&
61
+ result[2] == '.')
62
+ result.erase(1, 1);
63
+
64
+ return result;
65
+ }
66
+
67
+ void minifyContentStreams(QPDF &qpdf) {
68
+ for (auto &page : QPDFPageDocumentHelper(qpdf).getAllPages()) {
69
+ auto pageObj = page.getObjectHandle();
70
+ auto contents = pageObj.getKey("/Contents");
71
+
72
+ if (!contents.isStream())
73
+ continue;
74
+
75
+ std::string raw;
76
+ try {
77
+ auto buf = contents.getStreamData(qpdf_dl_generalized);
78
+ raw.assign(reinterpret_cast<const char *>(buf->getBuffer()),
79
+ buf->getSize());
80
+ } catch (...) {
81
+ continue;
82
+ }
83
+
84
+ // tokenize preserving string literals and hex strings intact
85
+ std::string minified;
86
+ minified.reserve(raw.size());
87
+ bool needSpace = false;
88
+ size_t pos = 0;
89
+
90
+ while (pos < raw.size()) {
91
+ char ch = raw[pos];
92
+
93
+ // skip whitespace
94
+ if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
95
+ if (!minified.empty())
96
+ needSpace = true;
97
+ ++pos;
98
+ continue;
99
+ }
100
+
101
+ // comments — skip to end of line
102
+ if (ch == '%') {
103
+ while (pos < raw.size() && raw[pos] != '\n')
104
+ ++pos;
105
+ continue;
106
+ }
107
+
108
+ // literal string — copy verbatim
109
+ if (ch == '(') {
110
+ if (needSpace) {
111
+ minified += ' ';
112
+ needSpace = false;
113
+ }
114
+ int depth = 1;
115
+ minified += '(';
116
+ ++pos;
117
+ while (pos < raw.size() && depth > 0) {
118
+ if (raw[pos] == '\\') {
119
+ minified += raw[pos++];
120
+ if (pos < raw.size())
121
+ minified += raw[pos++];
122
+ } else {
123
+ if (raw[pos] == '(')
124
+ ++depth;
125
+ else if (raw[pos] == ')')
126
+ --depth;
127
+ minified += raw[pos++];
128
+ }
129
+ }
130
+ needSpace = true;
131
+ continue;
132
+ }
133
+
134
+ // hex string — copy verbatim
135
+ if (ch == '<' && pos + 1 < raw.size() && raw[pos + 1] != '<') {
136
+ if (needSpace) {
137
+ minified += ' ';
138
+ needSpace = false;
139
+ }
140
+ minified += '<';
141
+ ++pos;
142
+ while (pos < raw.size() && raw[pos] != '>') {
143
+ if (!std::isspace(static_cast<unsigned char>(raw[pos])))
144
+ minified += raw[pos];
145
+ ++pos;
146
+ }
147
+ if (pos < raw.size()) {
148
+ minified += '>';
149
+ ++pos;
150
+ }
151
+ needSpace = true;
152
+ continue;
153
+ }
154
+
155
+ // dict delimiters << >> — self-delimiting, no space needed around them
156
+ if (ch == '<' && pos + 1 < raw.size() && raw[pos + 1] == '<') {
157
+ if (needSpace) {
158
+ minified += ' ';
159
+ needSpace = false;
160
+ }
161
+ minified += "<<";
162
+ pos += 2;
163
+ continue;
164
+ }
165
+ if (ch == '>' && pos + 1 < raw.size() && raw[pos + 1] == '>') {
166
+ minified += ">>";
167
+ pos += 2;
168
+ needSpace = true;
169
+ continue;
170
+ }
171
+
172
+ // array delimiters — self-delimiting
173
+ if (ch == '[' || ch == ']') {
174
+ if (needSpace && ch == '[') {
175
+ minified += ' ';
176
+ needSpace = false;
177
+ }
178
+ minified += ch;
179
+ ++pos;
180
+ if (ch == ']')
181
+ needSpace = true;
182
+ continue;
183
+ }
184
+
185
+ // name — starts with /
186
+ if (ch == '/') {
187
+ if (needSpace) {
188
+ minified += ' ';
189
+ needSpace = false;
190
+ }
191
+ size_t start = pos;
192
+ ++pos;
193
+ while (pos < raw.size() &&
194
+ !std::isspace(static_cast<unsigned char>(raw[pos])) &&
195
+ raw[pos] != '/' && raw[pos] != '[' && raw[pos] != ']' &&
196
+ raw[pos] != '<' && raw[pos] != '>' && raw[pos] != '(' &&
197
+ raw[pos] != ')')
198
+ ++pos;
199
+ minified.append(raw, start, pos - start);
200
+ needSpace = true;
201
+ continue;
202
+ }
203
+
204
+ // regular token (number, operator)
205
+ {
206
+ if (needSpace) {
207
+ minified += ' ';
208
+ needSpace = false;
209
+ }
210
+ size_t start = pos;
211
+ while (pos < raw.size() &&
212
+ !std::isspace(static_cast<unsigned char>(raw[pos])) &&
213
+ raw[pos] != '/' && raw[pos] != '[' && raw[pos] != ']' &&
214
+ raw[pos] != '<' && raw[pos] != '>' && raw[pos] != '(' &&
215
+ raw[pos] != ')')
216
+ ++pos;
217
+
218
+ std::string token(raw, start, pos - start);
219
+
220
+ // trim numeric formatting
221
+ if (!token.empty() &&
222
+ (token[0] == '-' || token[0] == '+' || token[0] == '.' ||
223
+ (token[0] >= '0' && token[0] <= '9'))) {
224
+ token = trimNumber(token);
225
+ }
226
+
227
+ minified += token;
228
+ needSpace = true;
229
+
230
+ // handle inline image data (BI <key-value pairs> ID <binary> EI)
231
+ // the binary data after ID can contain any byte — copy verbatim
232
+ if (token == "ID") {
233
+ // copy the required single whitespace delimiter after ID
234
+ if (pos < raw.size())
235
+ minified += raw[pos++];
236
+ // copy binary data verbatim until the EI end marker:
237
+ // whitespace + "EI" + (whitespace or end-of-stream)
238
+ // binary image data can contain byte sequences that match this
239
+ // pattern, so we validate that what follows EI looks like valid
240
+ // content stream syntax (not more binary data)
241
+ bool foundEI = false;
242
+ while (pos + 2 < raw.size()) {
243
+ if (std::isspace(static_cast<unsigned char>(raw[pos])) &&
244
+ raw[pos + 1] == 'E' && raw[pos + 2] == 'I' &&
245
+ (pos + 3 >= raw.size() ||
246
+ std::isspace(static_cast<unsigned char>(raw[pos + 3])))) {
247
+ // validate: after EI + whitespace, the next non-whitespace byte
248
+ // must be a valid content stream token start character, not
249
+ // a continuation of binary image data
250
+ size_t check = pos + 3;
251
+ while (check < raw.size() &&
252
+ std::isspace(static_cast<unsigned char>(raw[check])))
253
+ check++;
254
+ bool valid = (check >= raw.size());
255
+ if (!valid) {
256
+ unsigned char c = static_cast<unsigned char>(raw[check]);
257
+ // valid token start: letter (operator), digit/sign (number),
258
+ // ( (string), < (hex/dict), [ (array), / (name), % (comment)
259
+ valid = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
260
+ (c >= '0' && c <= '9') || c == '+' || c == '-' ||
261
+ c == '.' || c == '(' || c == '<' || c == '[' ||
262
+ c == '/' || c == '%';
263
+ }
264
+ if (valid) {
265
+ minified += raw[pos]; // whitespace before EI
266
+ minified += "EI";
267
+ pos += 3;
268
+ foundEI = true;
269
+ break;
270
+ }
271
+ // false EI match inside binary data — continue scanning
272
+ }
273
+ minified += raw[pos++];
274
+ }
275
+ if (!foundEI) {
276
+ // malformed stream — copy remaining bytes verbatim
277
+ while (pos < raw.size())
278
+ minified += raw[pos++];
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ // only replace if we actually reduced the size
285
+ if (minified.size() >= raw.size())
286
+ continue;
287
+
288
+ contents.replaceStreamData(minified, QPDFObjectHandle::newNull(),
289
+ QPDFObjectHandle::newNull());
290
+ }
291
+ }
package/src/content.h ADDED
@@ -0,0 +1,6 @@
1
+ #pragma once
2
+
3
+ #include <qpdf/QPDF.hh>
4
+
5
+ void coalesceContentStreams(QPDF &qpdf);
6
+ void minifyContentStreams(QPDF &qpdf);