qpdf-compress 0.1.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/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "qpdf-compress",
3
+ "version": "0.1.0",
4
+ "description": "Native PDF compression for Node.js, powered by QPDF",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=18.0.0"
9
+ },
10
+ "keywords": [
11
+ "pdf",
12
+ "qpdf",
13
+ "native",
14
+ "compress",
15
+ "compression",
16
+ "optimize",
17
+ "node-addon",
18
+ "napi",
19
+ "pdf-compress",
20
+ "pdf-optimize",
21
+ "pdf-repair",
22
+ "pdf-processing",
23
+ "pdf-tools",
24
+ "stream-optimization",
25
+ "lossless",
26
+ "lossy"
27
+ ],
28
+ "main": "dist/index.js",
29
+ "types": "dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "lib",
40
+ "scripts",
41
+ "binding.gyp"
42
+ ],
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/xonaman/nodejs-qpdf-compress.git"
46
+ },
47
+ "homepage": "https://github.com/xonaman/nodejs-qpdf-compress#readme",
48
+ "bugs": {
49
+ "url": "https://github.com/xonaman/nodejs-qpdf-compress/issues"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "os": [
55
+ "darwin",
56
+ "linux",
57
+ "win32"
58
+ ],
59
+ "cpu": [
60
+ "x64",
61
+ "arm64",
62
+ "arm"
63
+ ],
64
+ "scripts": {
65
+ "install": "node scripts/install.mjs",
66
+ "build": "node-gyp rebuild && node scripts/bundle-lib.mjs",
67
+ "build:ts": "tsc",
68
+ "prepublishOnly": "tsc",
69
+ "download": "node scripts/download-qpdf.mjs",
70
+ "test": "vitest run",
71
+ "lint": "eslint --fix .",
72
+ "lint:check": "eslint .",
73
+ "format": "prettier --write .",
74
+ "format:check": "prettier --check ."
75
+ },
76
+ "dependencies": {
77
+ "node-addon-api": "^8.0.0"
78
+ },
79
+ "devDependencies": {
80
+ "@eslint/js": "^10.0.1",
81
+ "@types/node": "^25.5.0",
82
+ "eslint": "^10.1.0",
83
+ "eslint-config-prettier": "^10.1.8",
84
+ "eslint-plugin-prettier": "^5.5.5",
85
+ "node-gyp": "^11.0.0",
86
+ "prettier": "^3.8.1",
87
+ "typescript": "^5.9.3",
88
+ "typescript-eslint": "^8.57.2",
89
+ "vitest": "^4.1.2"
90
+ }
91
+ }
@@ -0,0 +1,35 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+
5
+ const root = join(import.meta.dirname, '..');
6
+ const outDir = join(root, 'build', 'Release');
7
+ const nodeFile = join(outDir, 'qpdf_compress.node');
8
+
9
+ if (!existsSync(nodeFile)) {
10
+ console.error('qpdf_compress.node not found — run node-gyp rebuild first');
11
+ process.exit(1);
12
+ }
13
+
14
+ /** Strip debug symbols from a binary. */
15
+ function strip(file) {
16
+ if (process.platform === 'darwin') {
17
+ // macOS strip can corrupt .node binaries — skip
18
+ console.log(`Skipping strip on macOS (known compatibility issue)`);
19
+ return;
20
+ }
21
+ if (process.platform === 'win32') {
22
+ // MSVC handles stripping via /LTCG and Release config
23
+ console.log('Skipping strip on Windows (handled by MSVC linker).');
24
+ return;
25
+ }
26
+ try {
27
+ execFileSync('strip', ['-s', file], { stdio: 'inherit' });
28
+ console.log(`Stripped ${file.split('/').pop()}`);
29
+ } catch {
30
+ console.warn(`strip failed for ${file.split('/').pop()} — continuing`);
31
+ }
32
+ }
33
+
34
+ strip(nodeFile);
35
+ console.log('Bundle complete.');
@@ -0,0 +1,205 @@
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 QPDF_VERSION = '12.3.2';
8
+ const BASE_URL = 'https://github.com/qpdf/qpdf/archive/refs/tags';
9
+
10
+ const root = join(import.meta.dirname, '..');
11
+ const depsDir = join(root, 'deps', 'qpdf');
12
+
13
+ if (existsSync(join(depsDir, 'include', 'qpdf', 'QPDF.hh'))) {
14
+ console.log('QPDF already built, skipping.');
15
+ process.exit(0);
16
+ }
17
+
18
+ // validate version to prevent SSRF
19
+ if (!/^\d+\.\d+\.\d+$/.test(QPDF_VERSION)) {
20
+ console.error(`Invalid QPDF version: ${QPDF_VERSION}`);
21
+ process.exit(1);
22
+ }
23
+
24
+ const url = `${BASE_URL}/v${QPDF_VERSION}.tar.gz`;
25
+ const tarball = join(root, `qpdf-${QPDF_VERSION}.tar.gz`);
26
+ const srcDir = join(root, `qpdf-${QPDF_VERSION}`);
27
+ const buildDir = join(root, 'build-qpdf');
28
+
29
+ // step 1: download
30
+ console.log(`Downloading QPDF ${QPDF_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 `qpdf-{tag}` (without 'v' prefix)
47
+ // but may also be `qpdf-qpdf-{ver}` depending on repo naming — find it
48
+ if (!existsSync(srcDir)) {
49
+ // try common GitHub archive naming patterns
50
+ const candidates = readdirSync(root).filter(
51
+ (d) => d.startsWith('qpdf-') && !d.endsWith('.tar.gz'),
52
+ );
53
+ const match = candidates.find((d) => d.includes(QPDF_VERSION));
54
+ if (match) {
55
+ const { renameSync } = await import('node:fs');
56
+ renameSync(join(root, match), srcDir);
57
+ console.log(`Renamed ${match} → qpdf-${QPDF_VERSION}`);
58
+ } else {
59
+ console.error(
60
+ `Could not find extracted QPDF source directory. Found: ${candidates.join(', ')}`,
61
+ );
62
+ process.exit(1);
63
+ }
64
+ }
65
+
66
+ // step 2: build with CMake
67
+ console.log('Building QPDF...');
68
+ mkdirSync(buildDir, { recursive: true });
69
+
70
+ const cmakeArgs = [
71
+ '-S',
72
+ srcDir,
73
+ '-B',
74
+ buildDir,
75
+ '-DCMAKE_BUILD_TYPE=Release',
76
+ '-DCMAKE_POSITION_INDEPENDENT_CODE=ON',
77
+ '-DBUILD_SHARED_LIBS=OFF',
78
+ '-DBUILD_STATIC_LIBS=ON',
79
+ '-DREQUIRE_CRYPTO_NATIVE=ON',
80
+ '-DUSE_IMPLICIT_CRYPTO=OFF',
81
+ '-DBUILD_DOC=OFF',
82
+ `-DCMAKE_INSTALL_PREFIX=${depsDir}`,
83
+ ];
84
+
85
+ // force -fPIC for static library objects on Linux — CMAKE_POSITION_INDEPENDENT_CODE
86
+ // alone is not always respected by QPDF's CMake targets
87
+ if (process.platform === 'linux') {
88
+ cmakeArgs.push('-DCMAKE_C_FLAGS=-fPIC', '-DCMAKE_CXX_FLAGS=-fPIC');
89
+ }
90
+
91
+ // on macOS, help CMake find Homebrew libjpeg-turbo
92
+ if (process.platform === 'darwin') {
93
+ const brewPrefixes = ['/opt/homebrew', '/usr/local'];
94
+ for (const prefix of brewPrefixes) {
95
+ const jpegDir = join(prefix, 'opt', 'jpeg-turbo');
96
+ if (existsSync(jpegDir)) {
97
+ cmakeArgs.push(`-DCMAKE_PREFIX_PATH=${jpegDir}`);
98
+ break;
99
+ }
100
+ const jpegDir2 = join(prefix, 'opt', 'jpeg');
101
+ if (existsSync(jpegDir2)) {
102
+ cmakeArgs.push(`-DCMAKE_PREFIX_PATH=${jpegDir2}`);
103
+ break;
104
+ }
105
+ }
106
+ }
107
+
108
+ // on Windows, use vcpkg for zlib and libjpeg-turbo
109
+ if (process.platform === 'win32') {
110
+ const vcpkgRoot = process.env.VCPKG_ROOT || join(process.env.GITHUB_WORKSPACE || '', 'vcpkg');
111
+ if (existsSync(join(vcpkgRoot, 'scripts', 'buildsystems', 'vcpkg.cmake'))) {
112
+ const triplet = process.env.VCPKG_TARGET_TRIPLET || `${process.arch}-windows-static`;
113
+ cmakeArgs.push(
114
+ `-DCMAKE_TOOLCHAIN_FILE=${join(vcpkgRoot, 'scripts', 'buildsystems', 'vcpkg.cmake')}`,
115
+ `-DVCPKG_TARGET_TRIPLET=${triplet}`,
116
+ );
117
+ }
118
+ // force static CRT (/MT) to match node-gyp
119
+ cmakeArgs.push('-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded');
120
+ // MSVC uses multi-config generator — specify release at build time instead
121
+ cmakeArgs.splice(cmakeArgs.indexOf('-DCMAKE_BUILD_TYPE=Release'), 1);
122
+ }
123
+
124
+ execFileSync('cmake', cmakeArgs, { stdio: 'inherit' });
125
+ const buildArgs = ['--build', buildDir, '--parallel', '--target', 'libqpdf'];
126
+ if (process.platform === 'win32') {
127
+ buildArgs.push('--config', 'Release');
128
+ }
129
+ execFileSync('cmake', buildArgs, { stdio: 'inherit' });
130
+
131
+ // step 3: install headers and library
132
+ console.log('Installing to deps/qpdf...');
133
+ mkdirSync(join(depsDir, 'lib'), { recursive: true });
134
+ mkdirSync(join(depsDir, 'include'), { recursive: true });
135
+
136
+ // copy headers
137
+ cpSync(join(srcDir, 'include', 'qpdf'), join(depsDir, 'include', 'qpdf'), { recursive: true });
138
+
139
+ // also copy generated config header
140
+ const generatedInclude = join(buildDir, 'include', 'qpdf');
141
+ if (existsSync(generatedInclude)) {
142
+ cpSync(generatedInclude, join(depsDir, 'include', 'qpdf'), { recursive: true, force: true });
143
+ }
144
+
145
+ // copy static library
146
+ const libqpdfDir = join(buildDir, 'libqpdf');
147
+ const isLibFile = (f) =>
148
+ (f.endsWith('.a') || f.endsWith('.lib')) && (f.startsWith('libqpdf') || f.startsWith('qpdf'));
149
+ const staticLibs = readdirSync(libqpdfDir).filter(isLibFile);
150
+
151
+ if (staticLibs.length === 0) {
152
+ // try Release subdirectory (multi-config generators)
153
+ const releaseDir = join(libqpdfDir, 'Release');
154
+ if (existsSync(releaseDir)) {
155
+ const releaseLibs = readdirSync(releaseDir).filter(isLibFile);
156
+ for (const lib of releaseLibs) {
157
+ cpSync(join(releaseDir, lib), join(depsDir, 'lib', lib));
158
+ console.log(`Copied ${lib}`);
159
+ }
160
+ }
161
+ } else {
162
+ for (const lib of staticLibs) {
163
+ cpSync(join(libqpdfDir, lib), join(depsDir, 'lib', lib));
164
+ console.log(`Copied ${lib}`);
165
+ }
166
+ }
167
+
168
+ // step 4: on Windows, copy vcpkg zlib/jpeg static libs and headers for binding.gyp
169
+ if (process.platform === 'win32') {
170
+ const triplet = process.env.VCPKG_TARGET_TRIPLET || `${process.arch}-windows-static`;
171
+ const vcpkgRoot = process.env.VCPKG_ROOT || '';
172
+
173
+ // vcpkg packages may be in the CMake build dir or the global vcpkg root
174
+ const candidateLibDirs = [
175
+ join(buildDir, 'vcpkg_installed', triplet, 'lib'),
176
+ join(vcpkgRoot, 'installed', triplet, 'lib'),
177
+ ];
178
+ const candidateIncludeDirs = [
179
+ join(buildDir, 'vcpkg_installed', triplet, 'include'),
180
+ join(vcpkgRoot, 'installed', triplet, 'include'),
181
+ ];
182
+
183
+ const vcpkgLibDir = candidateLibDirs.find((d) => existsSync(d));
184
+ if (vcpkgLibDir) {
185
+ for (const lib of ['zlib.lib', 'jpeg.lib', 'turbojpeg.lib']) {
186
+ const src = join(vcpkgLibDir, lib);
187
+ if (existsSync(src)) {
188
+ cpSync(src, join(depsDir, 'lib', lib));
189
+ console.log(`Copied vcpkg ${lib}`);
190
+ }
191
+ }
192
+ }
193
+
194
+ const vcpkgIncludeDir = candidateIncludeDirs.find((d) => existsSync(d));
195
+ if (vcpkgIncludeDir) {
196
+ cpSync(vcpkgIncludeDir, join(depsDir, 'include'), { recursive: true, force: true });
197
+ console.log(`Copied vcpkg headers from ${vcpkgIncludeDir}`);
198
+ }
199
+ }
200
+
201
+ // step 5: clean up source and build dirs
202
+ rmSync(srcDir, { recursive: true, force: true });
203
+ rmSync(buildDir, { recursive: true, force: true });
204
+
205
+ console.log(`QPDF ${QPDF_VERSION} installed to ${depsDir}`);
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Try to download a prebuilt binary from GitHub releases.
3
+ * Falls back to compiling from source if no prebuilt is available.
4
+ *
5
+ * Prebuilt tarball naming: qpdf-compress-v{version}-{platform}-{arch}.tar.gz
6
+ * Musl (Alpine) naming: qpdf-compress-v{version}-linux-musl-{arch}.tar.gz
7
+ * Contents: build/Release/qpdf_compress.node
8
+ */
9
+ import { existsSync, mkdirSync, createWriteStream, readFileSync, unlinkSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { execSync, execFileSync } from 'node:child_process';
12
+ import { pipeline } from 'node:stream/promises';
13
+
14
+ const root = join(import.meta.dirname, '..');
15
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
16
+ const version = pkg.version;
17
+
18
+ // validate version to prevent SSRF via malicious package.json
19
+ if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) {
20
+ console.error(`Invalid version format: ${version}`);
21
+ process.exit(1);
22
+ }
23
+
24
+ // detect musl libc (Alpine, Void, etc.)
25
+ function isMusl() {
26
+ if (process.platform !== 'linux') return false;
27
+ try {
28
+ const ldd = execSync('ldd --version 2>&1 || true', { encoding: 'utf8' });
29
+ return ldd.toLowerCase().includes('musl');
30
+ } catch {
31
+ return (
32
+ existsSync('/lib/ld-musl-x86_64.so.1') ||
33
+ existsSync('/lib/ld-musl-aarch64.so.1') ||
34
+ existsSync('/lib/ld-musl-armhf.so.1')
35
+ );
36
+ }
37
+ }
38
+
39
+ const platform = process.platform;
40
+ const arch = process.arch;
41
+ const musl = isMusl();
42
+ const platformKey = musl ? `${platform}-musl` : platform;
43
+ const tarName = `qpdf-compress-v${version}-${platformKey}-${arch}.tar.gz`;
44
+
45
+ // hardcoded origin ensures the URL can never point to an attacker-controlled host
46
+ const releaseOrigin = 'https://github.com';
47
+ const releasePath = `/xonaman/nodejs-qpdf-compress/releases/download/v${version}/${tarName}`;
48
+ const releaseUrl = new URL(releasePath, releaseOrigin);
49
+
50
+ const outDir = join(root, 'build', 'Release');
51
+
52
+ async function tryDownload() {
53
+ console.log(`Checking for prebuilt binary: ${tarName}`);
54
+
55
+ try {
56
+ if (releaseUrl.origin !== releaseOrigin) {
57
+ throw new Error(`Unexpected URL origin: ${releaseUrl.origin}`);
58
+ }
59
+ const res = await fetch(releaseUrl, { redirect: 'follow' });
60
+ if (!res.ok) {
61
+ console.log(`No prebuilt binary found (HTTP ${res.status}), will compile from source.`);
62
+ return false;
63
+ }
64
+
65
+ mkdirSync(outDir, { recursive: true });
66
+ const tmpTar = join(root, tarName);
67
+
68
+ // download to temp file
69
+ const fileStream = createWriteStream(tmpTar);
70
+ const body = res.body;
71
+ if (!body) return false;
72
+
73
+ await pipeline(body, fileStream);
74
+
75
+ // extract tar.gz into project root
76
+ execFileSync('tar', ['xzf', tmpTar, '-C', root], { stdio: 'inherit' });
77
+ unlinkSync(tmpTar);
78
+
79
+ // verify the .node file exists
80
+ const nodeFile = join(outDir, 'qpdf_compress.node');
81
+ if (existsSync(nodeFile)) {
82
+ console.log('Prebuilt binary installed successfully.');
83
+ return true;
84
+ }
85
+
86
+ console.log(
87
+ 'Prebuilt archive extracted but qpdf_compress.node not found, will compile from source.',
88
+ );
89
+ return false;
90
+ } catch (err) {
91
+ console.log(`Failed to download prebuilt binary: ${err.message}`);
92
+ return false;
93
+ }
94
+ }
95
+
96
+ async function buildFromSource() {
97
+ console.log('Building from source...');
98
+ execSync('node scripts/download-qpdf.mjs', { stdio: 'inherit', cwd: root });
99
+ execSync('npx node-gyp rebuild', { stdio: 'inherit', cwd: root });
100
+ execSync('node scripts/bundle-lib.mjs', { stdio: 'inherit', cwd: root });
101
+ }
102
+
103
+ const nodeFile = join(outDir, 'qpdf_compress.node');
104
+ if (existsSync(nodeFile)) {
105
+ console.log('qpdf_compress.node already exists, skipping install.');
106
+ process.exit(0);
107
+ }
108
+
109
+ const downloaded = await tryDownload();
110
+ if (!downloaded) {
111
+ await buildFromSource();
112
+ }