qpdf-compress 0.6.1 → 0.6.3
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 +15 -0
- package/binding.gyp +23 -9
- package/package.json +4 -3
- package/scripts/download-harfbuzz.mjs +166 -0
- package/scripts/download-mozjpeg.mjs +1 -0
- package/scripts/download-qpdf.mjs +8 -1
- package/scripts/install.mjs +1 -0
- package/src/content.cc +291 -0
- package/src/content.h +6 -0
- package/src/encoding_tables.h +42 -0
- package/src/font_subset.cc +128 -425
- package/src/font_subset.h +17 -7
- package/src/fonts.cc +1209 -0
- package/src/fonts.h +5 -0
- package/src/hash_utils.h +15 -0
- package/src/images.cc +156 -132
- package/src/images.h +20 -2
- package/src/jpeg.cc +6 -0
- package/src/jpeg.h +1 -0
- package/src/optimize.h +5 -13
- package/src/qpdf_addon.cc +137 -55
- package/src/strip.cc +391 -0
- package/src/strip.h +9 -0
- package/src/structure.cc +346 -0
- package/src/structure.h +7 -0
- package/src/optimize.cc +0 -1048
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.3] - 2026-04-26
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Build TypeScript on `npm install` via `prepare` script (`husky && tsc`) so `dist/` is always rebuilt from `lib/` after install and on `npm pack`/publish, preventing stale `dist/` drift
|
|
12
|
+
|
|
13
|
+
## [0.6.2] - 2026-04-11
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **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
|
|
18
|
+
- **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
|
|
19
|
+
- **Nested XObject font usage**: font usage collection now recursively scans Form XObjects at all nesting levels instead of only the first level
|
|
20
|
+
- Removed unreliable unused font removal from `optimizeFonts` (already handled by `removeUnusedResources`)
|
|
21
|
+
|
|
7
22
|
## [0.6.1] - 2026-04-06
|
|
8
23
|
|
|
9
24
|
### Fixed
|
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/
|
|
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
|
-
"-
|
|
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++
|
|
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++
|
|
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
|
-
"-
|
|
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,10 +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",
|
|
78
86
|
"-static-libstdc++",
|
|
79
87
|
"-flto",
|
|
88
|
+
"-Wl,--allow-multiple-definition",
|
|
80
89
|
"-Wl,--gc-sections",
|
|
81
90
|
"-Wl,-S",
|
|
82
91
|
"-lpthread",
|
|
@@ -93,21 +102,26 @@
|
|
|
93
102
|
"msvs_settings": {
|
|
94
103
|
"VCCLCompilerTool": {
|
|
95
104
|
"ExceptionHandling": 1,
|
|
96
|
-
"Optimization":
|
|
105
|
+
"Optimization": 2,
|
|
97
106
|
"WholeProgramOptimization": "true",
|
|
98
107
|
"RuntimeLibrary": 0,
|
|
99
108
|
"AdditionalOptions": [
|
|
100
|
-
"/std:c++
|
|
109
|
+
"/std:c++17"
|
|
101
110
|
]
|
|
102
111
|
},
|
|
103
112
|
"VCLinkerTool": {
|
|
104
|
-
"LinkTimeCodeGeneration": 1
|
|
113
|
+
"LinkTimeCodeGeneration": 1,
|
|
114
|
+
"AdditionalOptions": [
|
|
115
|
+
"/FORCE:MULTIPLE"
|
|
116
|
+
]
|
|
105
117
|
}
|
|
106
118
|
},
|
|
107
119
|
"libraries": [
|
|
108
120
|
"<(module_root_dir)/deps/qpdf/lib/qpdf.lib",
|
|
109
121
|
"<(module_root_dir)/deps/qpdf/lib/zlib.lib",
|
|
110
|
-
"<(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"
|
|
111
125
|
]
|
|
112
126
|
}
|
|
113
127
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qpdf-compress",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Native PDF compression for Node.js, powered by QPDF",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -69,15 +69,16 @@
|
|
|
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 .",
|
|
78
79
|
"format": "prettier --write .",
|
|
79
80
|
"format:check": "prettier --check .",
|
|
80
|
-
"prepare": "husky"
|
|
81
|
+
"prepare": "husky && tsc"
|
|
81
82
|
},
|
|
82
83
|
"lint-staged": {
|
|
83
84
|
"*.{ts,js,json,md,yaml,yml}": "prettier --write",
|
|
@@ -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);
|
package/scripts/install.mjs
CHANGED
|
@@ -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
|
+
}
|