qpdf-compress 0.4.1 → 0.6.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 +13 -0
- package/README.md +25 -7
- package/binding.gyp +6 -4
- package/dist/concurrency.d.ts +9 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +22 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/lib/concurrency.ts +24 -0
- package/lib/index.ts +9 -5
- package/package.json +14 -4
- package/scripts/download-mozjpeg.mjs +162 -0
- package/scripts/download-qpdf.mjs +33 -34
- package/scripts/install.mjs +1 -0
- package/src/images.cc +1 -1
- package/src/jpeg.cc +15 -1
- package/src/jpeg.h +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ 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.0] - 2026-04-04
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **mozjpeg**: replaced libjpeg-turbo with vendored mozjpeg 4.1.1 for 5–15% smaller JPEGs via trellis quantization, overshoot deringing, and optimized progressive scan ordering — applies to both lossy recompression and lossless Huffman optimization
|
|
12
|
+
|
|
13
|
+
## [0.5.0] - 2026-04-04
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `concurrency()` — get/set max concurrent compress operations dispatched to the libuv thread pool (default: CPU cores, powered by p-limit)
|
|
18
|
+
- Husky + lint-staged pre-commit hook (Prettier + ESLint on staged files)
|
|
19
|
+
|
|
7
20
|
## [0.4.0] - 2026-03-31
|
|
8
21
|
|
|
9
22
|
### Added
|
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ const smaller = await compress(pdfBuffer, { lossy: true });
|
|
|
58
58
|
| Integration | Native Node.js addon | Shell exec | Shell exec |
|
|
59
59
|
| Async I/O | ✅ Non-blocking | ❌ Blocks on exec | ❌ Blocks on exec |
|
|
60
60
|
| Image deduplication | ✅ | ❌ | ❌ |
|
|
61
|
-
| JPEG Huffman optimization | ✅ Lossless (
|
|
61
|
+
| JPEG Huffman optimization | ✅ Lossless (mozjpeg) | ❌ | ❌ |
|
|
62
62
|
| Lossy image compression | ✅ Auto quality | ❌ | ✅ |
|
|
63
63
|
| CMYK → RGB conversion | ✅ Automatic | ❌ | ✅ |
|
|
64
64
|
| DPI downscaling | ✅ Lossy mode | ❌ | ✅ |
|
|
@@ -91,20 +91,20 @@ Prebuilt binaries are available for all [supported platforms](#-supported-platfo
|
|
|
91
91
|
- CMake ≥ 3.16
|
|
92
92
|
- C++20 compiler (GCC 10+, Clang 13+, MSVC 2019+)
|
|
93
93
|
- zlib development headers
|
|
94
|
-
-
|
|
94
|
+
- nasm (optional, for mozjpeg SIMD acceleration)
|
|
95
95
|
|
|
96
96
|
```bash
|
|
97
97
|
# macOS
|
|
98
|
-
brew install cmake
|
|
98
|
+
brew install cmake nasm
|
|
99
99
|
|
|
100
100
|
# Ubuntu / Debian
|
|
101
|
-
sudo apt install cmake g++ zlib1g-dev
|
|
101
|
+
sudo apt install cmake g++ zlib1g-dev nasm
|
|
102
102
|
|
|
103
103
|
# Amazon Linux / RHEL
|
|
104
|
-
sudo yum install cmake3 gcc-c++ zlib-devel
|
|
104
|
+
sudo yum install cmake3 gcc-c++ zlib-devel nasm
|
|
105
105
|
|
|
106
106
|
# Windows (using vcpkg)
|
|
107
|
-
vcpkg install zlib
|
|
107
|
+
vcpkg install zlib --triplet x64-windows-static
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
## 🌍 Supported Platforms
|
|
@@ -189,9 +189,27 @@ Compresses a PDF document. Automatically repairs damaged PDFs.
|
|
|
189
189
|
- Only replaces images where the result is actually smaller
|
|
190
190
|
- Skips tiny images (< 50×50 px)
|
|
191
191
|
|
|
192
|
+
### `concurrency(value?): number`
|
|
193
|
+
|
|
194
|
+
Gets or sets the maximum number of concurrent compress operations dispatched to the thread pool.
|
|
195
|
+
|
|
196
|
+
The default is the number of CPU cores (`os.availableParallelism()`). A value of `0` resets to the default.
|
|
197
|
+
|
|
198
|
+
Excess calls are queued in JavaScript, preventing libuv thread pool starvation.
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import { compress, concurrency } from 'qpdf-compress';
|
|
202
|
+
|
|
203
|
+
concurrency(); // 8 (CPU cores)
|
|
204
|
+
concurrency(2); // limit to 2 concurrent operations
|
|
205
|
+
concurrency(0); // reset to default
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
192
210
|
## ⚙️ How it works
|
|
193
211
|
|
|
194
|
-
This package embeds [QPDF](https://github.com/qpdf/qpdf) (v12.3.2) as
|
|
212
|
+
This package embeds [QPDF](https://github.com/qpdf/qpdf) (v12.3.2) and [mozjpeg](https://github.com/mozilla/mozjpeg) (v4.1.1) as statically linked C++ libraries, exposed to Node.js via N-API. Lossless JPEG optimization uses mozjpeg at the DCT coefficient level with progressive scan optimization. Image recompression in lossy mode uses mozjpeg's trellis quantization for 5–15% smaller JPEGs at the same perceptual quality. TrueType font subsetting is handled by a custom binary parser that reads cmap tables, resolves composite glyph dependencies, and rebuilds glyf/loca/hmtx tables with only the used glyphs.
|
|
195
213
|
|
|
196
214
|
All operations run in a background thread via `Napi::AsyncWorker`, so the event loop is never blocked.
|
|
197
215
|
|
package/binding.gyp
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"include_dirs": [
|
|
13
13
|
"<!@(node -p \"require('node-addon-api').include\")",
|
|
14
14
|
"deps/qpdf/include",
|
|
15
|
+
"deps/mozjpeg/include",
|
|
15
16
|
"src"
|
|
16
17
|
],
|
|
17
18
|
"defines": [
|
|
@@ -39,7 +40,8 @@
|
|
|
39
40
|
{
|
|
40
41
|
"include_dirs": [
|
|
41
42
|
"/opt/homebrew/include",
|
|
42
|
-
"/usr/local/include"
|
|
43
|
+
"/usr/local/include",
|
|
44
|
+
"<(module_root_dir)/deps/mozjpeg/include"
|
|
43
45
|
],
|
|
44
46
|
"xcode_settings": {
|
|
45
47
|
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
|
|
@@ -58,7 +60,7 @@
|
|
|
58
60
|
"-L/opt/homebrew/lib",
|
|
59
61
|
"-L/usr/local/lib",
|
|
60
62
|
"-lz",
|
|
61
|
-
"
|
|
63
|
+
"<(module_root_dir)/deps/mozjpeg/lib/libjpeg.a",
|
|
62
64
|
"-flto",
|
|
63
65
|
"-Wl,-dead_strip",
|
|
64
66
|
"-Wl,-S"
|
|
@@ -72,7 +74,7 @@
|
|
|
72
74
|
"libraries": [
|
|
73
75
|
"<(module_root_dir)/deps/qpdf/lib/libqpdf.a",
|
|
74
76
|
"-lz",
|
|
75
|
-
"
|
|
77
|
+
"<(module_root_dir)/deps/mozjpeg/lib/libjpeg.a",
|
|
76
78
|
"-flto",
|
|
77
79
|
"-Wl,--gc-sections",
|
|
78
80
|
"-Wl,-S",
|
|
@@ -104,7 +106,7 @@
|
|
|
104
106
|
"libraries": [
|
|
105
107
|
"<(module_root_dir)/deps/qpdf/lib/qpdf.lib",
|
|
106
108
|
"<(module_root_dir)/deps/qpdf/lib/zlib.lib",
|
|
107
|
-
"<(module_root_dir)/deps/
|
|
109
|
+
"<(module_root_dir)/deps/mozjpeg/lib/jpeg-static.lib"
|
|
108
110
|
]
|
|
109
111
|
}
|
|
110
112
|
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gets or sets the maximum number of concurrent operations.
|
|
3
|
+
*
|
|
4
|
+
* The default value is the number of CPU cores (`os.availableParallelism()`).
|
|
5
|
+
* A value of `0` resets to the default.
|
|
6
|
+
*/
|
|
7
|
+
export declare function concurrency(value?: number): number;
|
|
8
|
+
export declare function withConcurrency<T>(fn: () => Promise<T>): Promise<T>;
|
|
9
|
+
//# sourceMappingURL=concurrency.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"concurrency.d.ts","sourceRoot":"","sources":["../lib/concurrency.ts"],"names":[],"mappings":"AAKA;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAQlD;AAED,wBAAgB,eAAe,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAEnE"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { availableParallelism } from 'node:os';
|
|
2
|
+
import pLimit from 'p-limit';
|
|
3
|
+
let limit = pLimit(availableParallelism());
|
|
4
|
+
/**
|
|
5
|
+
* Gets or sets the maximum number of concurrent operations.
|
|
6
|
+
*
|
|
7
|
+
* The default value is the number of CPU cores (`os.availableParallelism()`).
|
|
8
|
+
* A value of `0` resets to the default.
|
|
9
|
+
*/
|
|
10
|
+
export function concurrency(value) {
|
|
11
|
+
if (value !== undefined) {
|
|
12
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
13
|
+
throw new TypeError('Concurrency must be a non-negative integer');
|
|
14
|
+
}
|
|
15
|
+
limit = pLimit(value === 0 ? availableParallelism() : value);
|
|
16
|
+
}
|
|
17
|
+
return limit.concurrency;
|
|
18
|
+
}
|
|
19
|
+
export function withConcurrency(fn) {
|
|
20
|
+
return limit(fn);
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=concurrency.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"concurrency.js","sourceRoot":"","sources":["../lib/concurrency.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,MAA8B,MAAM,SAAS,CAAC;AAErD,IAAI,KAAK,GAAkB,MAAM,CAAC,oBAAoB,EAAE,CAAC,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,KAAc;IACxC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;QACpE,CAAC;QACD,KAAK,GAAG,MAAM,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,oBAAoB,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAC/D,CAAC;IACD,OAAO,KAAK,CAAC,WAAW,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,eAAe,CAAI,EAAoB;IACrD,OAAO,KAAK,CAAC,EAAE,CAAC,CAAC;AACnB,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -16,5 +16,6 @@ export declare function compress(input: PdfInput, options: CompressOptions & {
|
|
|
16
16
|
output: string;
|
|
17
17
|
}): Promise<void>;
|
|
18
18
|
export declare function compress(input: PdfInput, options?: CompressOptions): Promise<Buffer>;
|
|
19
|
+
export { concurrency } from './concurrency.js';
|
|
19
20
|
export type { CompressOptions } from './types.js';
|
|
20
21
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAe,MAAM,YAAY,CAAC;AAY/D,KAAK,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhC;;;;;;;;;;;GAWG;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;AAuBtF,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { withConcurrency } from './concurrency.js';
|
|
4
5
|
const require = createRequire(import.meta.url);
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
const addonDir = resolve(__dirname, '..', 'build', 'Release');
|
|
@@ -23,10 +24,11 @@ export async function compress(input, options) {
|
|
|
23
24
|
throw new TypeError('Input must be a Buffer or file path string');
|
|
24
25
|
}
|
|
25
26
|
const stripMetadata = options?.stripMetadata ?? true;
|
|
26
|
-
return addon.compress(input, {
|
|
27
|
+
return withConcurrency(() => addon.compress(input, {
|
|
27
28
|
...(options?.lossy ? { lossy: true } : {}),
|
|
28
29
|
...(stripMetadata ? { stripMetadata: true } : {}),
|
|
29
30
|
...(options?.output ? { output: options.output } : {}),
|
|
30
|
-
});
|
|
31
|
+
}));
|
|
31
32
|
}
|
|
33
|
+
export { concurrency } from './concurrency.js';
|
|
32
34
|
//# sourceMappingURL=index.js.map
|
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;
|
|
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;AAGnD,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,MAAM,KAAK,GAAgB,OAAO,CAAC,qCAAqC,CAAC,CAAC;AAqB1E,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,OAAO,eAAe,CAAC,GAAG,EAAE,CAC1B,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE;QACpB,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1C,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjD,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvD,CAAC,CACuB,CAAC;AAC9B,CAAC;AAED,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { availableParallelism } from 'node:os';
|
|
2
|
+
import pLimit, { type LimitFunction } from 'p-limit';
|
|
3
|
+
|
|
4
|
+
let limit: LimitFunction = pLimit(availableParallelism());
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Gets or sets the maximum number of concurrent operations.
|
|
8
|
+
*
|
|
9
|
+
* The default value is the number of CPU cores (`os.availableParallelism()`).
|
|
10
|
+
* A value of `0` resets to the default.
|
|
11
|
+
*/
|
|
12
|
+
export function concurrency(value?: number): number {
|
|
13
|
+
if (value !== undefined) {
|
|
14
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
15
|
+
throw new TypeError('Concurrency must be a non-negative integer');
|
|
16
|
+
}
|
|
17
|
+
limit = pLimit(value === 0 ? availableParallelism() : value);
|
|
18
|
+
}
|
|
19
|
+
return limit.concurrency;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function withConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
|
23
|
+
return limit(fn);
|
|
24
|
+
}
|
package/lib/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { withConcurrency } from './concurrency.js';
|
|
4
5
|
import type { CompressOptions, NativeAddon } from './types.js';
|
|
5
6
|
|
|
6
7
|
const require = createRequire(import.meta.url);
|
|
@@ -45,11 +46,14 @@ export async function compress(input: PdfInput, options?: CompressOptions): Prom
|
|
|
45
46
|
throw new TypeError('Input must be a Buffer or file path string');
|
|
46
47
|
}
|
|
47
48
|
const stripMetadata = options?.stripMetadata ?? true;
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
return withConcurrency(() =>
|
|
50
|
+
addon.compress(input, {
|
|
51
|
+
...(options?.lossy ? { lossy: true } : {}),
|
|
52
|
+
...(stripMetadata ? { stripMetadata: true } : {}),
|
|
53
|
+
...(options?.output ? { output: options.output } : {}),
|
|
54
|
+
}),
|
|
55
|
+
) as Promise<Buffer | void>;
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
export { concurrency } from './concurrency.js';
|
|
55
59
|
export type { CompressOptions } from './types.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qpdf-compress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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,23 @@
|
|
|
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-qpdf.mjs",
|
|
72
|
+
"download": "node scripts/download-mozjpeg.mjs && node scripts/download-qpdf.mjs",
|
|
73
|
+
"download:mozjpeg": "node scripts/download-mozjpeg.mjs",
|
|
74
|
+
"download:qpdf": "node scripts/download-qpdf.mjs",
|
|
73
75
|
"test": "vitest run",
|
|
74
76
|
"lint": "eslint --fix .",
|
|
75
77
|
"lint:check": "eslint .",
|
|
76
78
|
"format": "prettier --write .",
|
|
77
|
-
"format:check": "prettier --check ."
|
|
79
|
+
"format:check": "prettier --check .",
|
|
80
|
+
"prepare": "husky"
|
|
81
|
+
},
|
|
82
|
+
"lint-staged": {
|
|
83
|
+
"*.{ts,js,json,md,yaml,yml}": "prettier --write",
|
|
84
|
+
"*.{ts,js}": "eslint --max-warnings 0"
|
|
78
85
|
},
|
|
79
86
|
"dependencies": {
|
|
80
|
-
"node-addon-api": "^8.0.0"
|
|
87
|
+
"node-addon-api": "^8.0.0",
|
|
88
|
+
"p-limit": "^7.3.0"
|
|
81
89
|
},
|
|
82
90
|
"devDependencies": {
|
|
83
91
|
"@eslint/js": "^10.0.1",
|
|
@@ -85,6 +93,8 @@
|
|
|
85
93
|
"eslint": "^10.1.0",
|
|
86
94
|
"eslint-config-prettier": "^10.1.8",
|
|
87
95
|
"eslint-plugin-prettier": "^5.5.5",
|
|
96
|
+
"husky": "^9.1.7",
|
|
97
|
+
"lint-staged": "^16.4.0",
|
|
88
98
|
"node-gyp": "^11.0.0",
|
|
89
99
|
"prettier": "^3.8.1",
|
|
90
100
|
"typescript": "^5.9.3",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createWriteStream, mkdirSync, existsSync, rmSync, 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 MOZJPEG_VERSION = '4.1.1';
|
|
8
|
+
const BASE_URL = 'https://github.com/mozilla/mozjpeg/archive/refs/tags';
|
|
9
|
+
|
|
10
|
+
const root = join(import.meta.dirname, '..');
|
|
11
|
+
const depsDir = join(root, 'deps', 'mozjpeg');
|
|
12
|
+
|
|
13
|
+
if (existsSync(join(depsDir, 'include', 'jpeglib.h'))) {
|
|
14
|
+
console.log('mozjpeg already built, skipping.');
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// validate version to prevent SSRF
|
|
19
|
+
if (!/^\d+\.\d+\.\d+$/.test(MOZJPEG_VERSION)) {
|
|
20
|
+
console.error(`Invalid mozjpeg version: ${MOZJPEG_VERSION}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const url = `${BASE_URL}/v${MOZJPEG_VERSION}.tar.gz`;
|
|
25
|
+
const tarball = join(root, `mozjpeg-${MOZJPEG_VERSION}.tar.gz`);
|
|
26
|
+
const srcDir = join(root, `mozjpeg-${MOZJPEG_VERSION}`);
|
|
27
|
+
const buildDir = join(root, 'build-mozjpeg');
|
|
28
|
+
|
|
29
|
+
// step 1: download
|
|
30
|
+
console.log(`Downloading mozjpeg ${MOZJPEG_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 `mozjpeg-{tag}` — find the directory
|
|
47
|
+
if (!existsSync(srcDir)) {
|
|
48
|
+
const candidates = readdirSync(root).filter(
|
|
49
|
+
(d) => d.startsWith('mozjpeg-') && !d.endsWith('.tar.gz'),
|
|
50
|
+
);
|
|
51
|
+
const match = candidates.find((d) => d.includes(MOZJPEG_VERSION));
|
|
52
|
+
if (match) {
|
|
53
|
+
const { renameSync } = await import('node:fs');
|
|
54
|
+
renameSync(join(root, match), srcDir);
|
|
55
|
+
console.log(`Renamed ${match} → mozjpeg-${MOZJPEG_VERSION}`);
|
|
56
|
+
} else {
|
|
57
|
+
console.error(
|
|
58
|
+
`Could not find extracted mozjpeg source directory. Found: ${candidates.join(', ')}`,
|
|
59
|
+
);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// step 2: build with CMake
|
|
65
|
+
console.log('Building mozjpeg...');
|
|
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
|
+
'-DCMAKE_POLICY_VERSION_MINIMUM=3.5',
|
|
76
|
+
'-DENABLE_STATIC=ON',
|
|
77
|
+
'-DENABLE_SHARED=OFF',
|
|
78
|
+
'-DPNG_SUPPORTED=OFF',
|
|
79
|
+
'-DWITH_TURBOJPEG=OFF',
|
|
80
|
+
`-DCMAKE_INSTALL_LIBDIR=${join(depsDir, 'lib')}`,
|
|
81
|
+
`-DCMAKE_INSTALL_PREFIX=${depsDir}`,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// force -fPIC on Linux
|
|
85
|
+
if (process.platform === 'linux') {
|
|
86
|
+
cmakeArgs.push('-DCMAKE_C_FLAGS=-fPIC', '-DCMAKE_CXX_FLAGS=-fPIC');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// match node-gyp's deployment target on macOS to avoid linker warnings
|
|
90
|
+
if (process.platform === 'darwin') {
|
|
91
|
+
cmakeArgs.push('-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Windows multi-config generator
|
|
95
|
+
if (process.platform === 'win32') {
|
|
96
|
+
cmakeArgs.push('-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded');
|
|
97
|
+
cmakeArgs.splice(cmakeArgs.indexOf('-DCMAKE_BUILD_TYPE=Release'), 1);
|
|
98
|
+
|
|
99
|
+
// cross-compile for ARM64 when VCPKG_TARGET_TRIPLET indicates it
|
|
100
|
+
const triplet = process.env.VCPKG_TARGET_TRIPLET || '';
|
|
101
|
+
if (triplet.startsWith('arm64')) {
|
|
102
|
+
// use lowercase 'arm64' — mozjpeg's CMakeLists.txt checks
|
|
103
|
+
// CMAKE_GENERATOR_PLATFORM with a case-sensitive regex ("arm64")
|
|
104
|
+
cmakeArgs.push('-A', 'arm64');
|
|
105
|
+
cmakeArgs.push('-DNEON_INTRINSICS=ON');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
execFileSync('cmake', cmakeArgs, { stdio: 'inherit' });
|
|
110
|
+
|
|
111
|
+
const buildArgs = ['--build', buildDir, '--parallel'];
|
|
112
|
+
if (process.platform === 'win32') {
|
|
113
|
+
buildArgs.push('--config', 'Release');
|
|
114
|
+
}
|
|
115
|
+
execFileSync('cmake', buildArgs, { stdio: 'inherit' });
|
|
116
|
+
|
|
117
|
+
// step 3: install
|
|
118
|
+
console.log('Installing to deps/mozjpeg...');
|
|
119
|
+
const installArgs = ['--install', buildDir];
|
|
120
|
+
if (process.platform === 'win32') {
|
|
121
|
+
installArgs.push('--config', 'Release');
|
|
122
|
+
}
|
|
123
|
+
execFileSync('cmake', installArgs, { stdio: 'inherit' });
|
|
124
|
+
|
|
125
|
+
// verify installation
|
|
126
|
+
if (!existsSync(join(depsDir, 'include', 'jpeglib.h'))) {
|
|
127
|
+
console.error('mozjpeg install failed: jpeglib.h not found');
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// find the static library (varies by platform and install layout)
|
|
132
|
+
const libDirs = ['lib', 'lib64'].map((d) => join(depsDir, d));
|
|
133
|
+
const libNames = ['libjpeg.a', 'jpeg-static.lib', 'jpeg.lib'];
|
|
134
|
+
let foundLib = false;
|
|
135
|
+
for (const dir of libDirs) {
|
|
136
|
+
if (!existsSync(dir)) continue;
|
|
137
|
+
for (const name of libNames) {
|
|
138
|
+
if (existsSync(join(dir, name))) {
|
|
139
|
+
foundLib = true;
|
|
140
|
+
console.log(`Found static library: ${join(dir, name)}`);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (foundLib) break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!foundLib) {
|
|
148
|
+
// list what we have for debugging
|
|
149
|
+
for (const dir of libDirs) {
|
|
150
|
+
if (existsSync(dir)) {
|
|
151
|
+
console.log(`Contents of ${dir}:`, readdirSync(dir));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
console.error('mozjpeg install failed: static library not found');
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// step 4: clean up source and build dirs
|
|
159
|
+
rmSync(srcDir, { recursive: true, force: true });
|
|
160
|
+
rmSync(buildDir, { recursive: true, force: true });
|
|
161
|
+
|
|
162
|
+
console.log(`mozjpeg ${MOZJPEG_VERSION} installed to ${depsDir}`);
|
|
@@ -88,24 +88,35 @@ if (process.platform === 'linux') {
|
|
|
88
88
|
cmakeArgs.push('-DCMAKE_C_FLAGS=-fPIC', '-DCMAKE_CXX_FLAGS=-fPIC');
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
// point QPDF at vendored mozjpeg
|
|
92
|
+
const mozjpegDir = join(root, 'deps', 'mozjpeg');
|
|
93
|
+
if (existsSync(join(mozjpegDir, 'include', 'jpeglib.h'))) {
|
|
94
|
+
// try lib/ then lib64/ (some CMake installs use lib64 on Linux)
|
|
95
|
+
const libDir = existsSync(join(mozjpegDir, 'lib')) ? 'lib' : 'lib64';
|
|
96
|
+
const libExt = process.platform === 'win32' ? 'jpeg-static.lib' : 'libjpeg.a';
|
|
97
|
+
|
|
98
|
+
// QPDF uses pkg_check_modules first, then falls back to find_path/find_library
|
|
99
|
+
// with custom variable names LIBJPEG_H_PATH and LIBJPEG_LIB_PATH.
|
|
100
|
+
|
|
101
|
+
// 1) set PKG_CONFIG_PATH so pkg-config finds mozjpeg's libjpeg.pc (Linux/macOS)
|
|
102
|
+
const pkgConfigDir = join(mozjpegDir, libDir, 'pkgconfig');
|
|
103
|
+
const existing = process.env.PKG_CONFIG_PATH || '';
|
|
104
|
+
process.env.PKG_CONFIG_PATH = existing ? `${pkgConfigDir}:${existing}` : pkgConfigDir;
|
|
105
|
+
|
|
106
|
+
// 2) set QPDF's cmake cache vars for the find_path/find_library fallback (Windows)
|
|
107
|
+
cmakeArgs.push(
|
|
108
|
+
`-DLIBJPEG_H_PATH:PATH=${join(mozjpegDir, 'include')}`,
|
|
109
|
+
`-DLIBJPEG_LIB_PATH:FILEPATH=${join(mozjpegDir, libDir, libExt)}`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// 3) add mozjpeg to CMAKE_PREFIX_PATH as a belt-and-suspenders fallback
|
|
113
|
+
cmakeArgs.push(`-DCMAKE_PREFIX_PATH=${mozjpegDir}`);
|
|
114
|
+
} else {
|
|
115
|
+
console.error('mozjpeg not found — run "node scripts/download-mozjpeg.mjs" first');
|
|
116
|
+
process.exit(1);
|
|
106
117
|
}
|
|
107
118
|
|
|
108
|
-
// on Windows, use vcpkg for zlib
|
|
119
|
+
// on Windows, use vcpkg for zlib (mozjpeg is vendored separately)
|
|
109
120
|
if (process.platform === 'win32') {
|
|
110
121
|
const vcpkgRoot = process.env.VCPKG_ROOT || join(process.env.GITHUB_WORKSPACE || '', 'vcpkg');
|
|
111
122
|
if (existsSync(join(vcpkgRoot, 'scripts', 'buildsystems', 'vcpkg.cmake'))) {
|
|
@@ -117,7 +128,7 @@ if (process.platform === 'win32') {
|
|
|
117
128
|
|
|
118
129
|
// cross-compile for ARM64 when triplet indicates it
|
|
119
130
|
if (triplet.startsWith('arm64')) {
|
|
120
|
-
cmakeArgs.push('-A', '
|
|
131
|
+
cmakeArgs.push('-A', 'arm64');
|
|
121
132
|
}
|
|
122
133
|
}
|
|
123
134
|
// force static CRT (/MT) to match node-gyp
|
|
@@ -170,7 +181,7 @@ if (staticLibs.length === 0) {
|
|
|
170
181
|
}
|
|
171
182
|
}
|
|
172
183
|
|
|
173
|
-
// step 4: on Windows, copy vcpkg zlib
|
|
184
|
+
// step 4: on Windows, copy vcpkg zlib static lib and headers for binding.gyp
|
|
174
185
|
if (process.platform === 'win32') {
|
|
175
186
|
const triplet = process.env.VCPKG_TARGET_TRIPLET || `${process.arch}-windows-static`;
|
|
176
187
|
const vcpkgRoot = process.env.VCPKG_ROOT || '';
|
|
@@ -180,27 +191,15 @@ if (process.platform === 'win32') {
|
|
|
180
191
|
join(buildDir, 'vcpkg_installed', triplet, 'lib'),
|
|
181
192
|
join(vcpkgRoot, 'installed', triplet, 'lib'),
|
|
182
193
|
];
|
|
183
|
-
const candidateIncludeDirs = [
|
|
184
|
-
join(buildDir, 'vcpkg_installed', triplet, 'include'),
|
|
185
|
-
join(vcpkgRoot, 'installed', triplet, 'include'),
|
|
186
|
-
];
|
|
187
194
|
|
|
188
195
|
const vcpkgLibDir = candidateLibDirs.find((d) => existsSync(d));
|
|
189
196
|
if (vcpkgLibDir) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
console.log(`Copied vcpkg ${lib}`);
|
|
195
|
-
}
|
|
197
|
+
const src = join(vcpkgLibDir, 'zlib.lib');
|
|
198
|
+
if (existsSync(src)) {
|
|
199
|
+
cpSync(src, join(depsDir, 'lib', 'zlib.lib'));
|
|
200
|
+
console.log('Copied vcpkg zlib.lib');
|
|
196
201
|
}
|
|
197
202
|
}
|
|
198
|
-
|
|
199
|
-
const vcpkgIncludeDir = candidateIncludeDirs.find((d) => existsSync(d));
|
|
200
|
-
if (vcpkgIncludeDir) {
|
|
201
|
-
cpSync(vcpkgIncludeDir, join(depsDir, 'include'), { recursive: true, force: true });
|
|
202
|
-
console.log(`Copied vcpkg headers from ${vcpkgIncludeDir}`);
|
|
203
|
-
}
|
|
204
203
|
}
|
|
205
204
|
|
|
206
205
|
// step 5: clean up source and build dirs
|
package/scripts/install.mjs
CHANGED
|
@@ -96,6 +96,7 @@ async function tryDownload() {
|
|
|
96
96
|
|
|
97
97
|
async function buildFromSource() {
|
|
98
98
|
console.log('Building from source...');
|
|
99
|
+
execSync('node scripts/download-mozjpeg.mjs', { stdio: 'inherit', cwd: root });
|
|
99
100
|
execSync('node scripts/download-qpdf.mjs', { stdio: 'inherit', cwd: root });
|
|
100
101
|
execSync('npx node-gyp rebuild', { stdio: 'inherit', cwd: root });
|
|
101
102
|
execSync('node scripts/bundle-lib.mjs', { stdio: 'inherit', cwd: root });
|
package/src/images.cc
CHANGED
|
@@ -391,7 +391,7 @@ void optimizeImages(QPDF &qpdf, const CompressOptions &opts) {
|
|
|
391
391
|
return;
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
-
// encode as JPEG via
|
|
394
|
+
// encode as JPEG via mozjpeg
|
|
395
395
|
std::vector<uint8_t> jpegData;
|
|
396
396
|
if (!encodeJpeg(pixels, width, height, encodeComponents, opts.targetQuality,
|
|
397
397
|
jpegData))
|
package/src/jpeg.cc
CHANGED
|
@@ -55,6 +55,10 @@ static bool losslessJpegOptimizeImpl(const unsigned char *data, size_t size,
|
|
|
55
55
|
jpeg_copy_critical_parameters(&srcinfo, &dstinfo);
|
|
56
56
|
dstinfo.optimize_coding = TRUE;
|
|
57
57
|
|
|
58
|
+
// mozjpeg progressive scan optimization — lossless, reorders existing DCT
|
|
59
|
+
// coefficients into optimized progressive scans for better entropy coding
|
|
60
|
+
jpeg_simple_progression(&dstinfo);
|
|
61
|
+
|
|
58
62
|
jpeg_write_coefficients(&dstinfo, coef_arrays);
|
|
59
63
|
jpeg_finish_compress(&dstinfo);
|
|
60
64
|
jpeg_finish_decompress(&srcinfo);
|
|
@@ -79,7 +83,7 @@ bool losslessJpegOptimize(const unsigned char *data, size_t size,
|
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
// ---------------------------------------------------------------------------
|
|
82
|
-
// Lossy JPEG encoding via
|
|
86
|
+
// Lossy JPEG encoding via mozjpeg
|
|
83
87
|
// ---------------------------------------------------------------------------
|
|
84
88
|
|
|
85
89
|
// isolated setjmp scope
|
|
@@ -115,6 +119,16 @@ static bool encodeJpegImpl(const unsigned char *pixels, int width, int height,
|
|
|
115
119
|
jpeg_set_quality(&cinfo, quality, TRUE);
|
|
116
120
|
cinfo.optimize_coding = TRUE;
|
|
117
121
|
|
|
122
|
+
// mozjpeg trellis quantization — 5–15% smaller at same perceptual quality
|
|
123
|
+
jpeg_c_set_bool_param(&cinfo, JBOOLEAN_TRELLIS_QUANT, TRUE);
|
|
124
|
+
jpeg_c_set_bool_param(&cinfo, JBOOLEAN_TRELLIS_QUANT_DC, TRUE);
|
|
125
|
+
jpeg_c_set_bool_param(&cinfo, JBOOLEAN_OVERSHOOT_DERINGING, TRUE);
|
|
126
|
+
jpeg_c_set_bool_param(&cinfo, JBOOLEAN_USE_SCANS_IN_TRELLIS, TRUE);
|
|
127
|
+
jpeg_c_set_bool_param(&cinfo, JBOOLEAN_USE_LAMBDA_WEIGHT_TBL, TRUE);
|
|
128
|
+
|
|
129
|
+
// mozjpeg progressive scan optimization
|
|
130
|
+
jpeg_simple_progression(&cinfo);
|
|
131
|
+
|
|
118
132
|
jpeg_start_compress(&cinfo, TRUE);
|
|
119
133
|
|
|
120
134
|
int row_stride = width * components;
|
package/src/jpeg.h
CHANGED
|
@@ -21,7 +21,7 @@ void jpegErrorExit(j_common_ptr cinfo);
|
|
|
21
21
|
bool losslessJpegOptimize(const unsigned char *data, size_t size,
|
|
22
22
|
std::vector<uint8_t> &out);
|
|
23
23
|
|
|
24
|
-
// encodes raw pixels as JPEG at the given quality (1–100) via
|
|
24
|
+
// encodes raw pixels as JPEG at the given quality (1–100) via mozjpeg
|
|
25
25
|
bool encodeJpeg(const unsigned char *pixels, int width, int height,
|
|
26
26
|
int components, int quality, std::vector<uint8_t> &out);
|
|
27
27
|
|