compress-pdf-lib 1.0.4 → 1.0.5
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/README.md +195 -124
- package/package.json +4 -5
- package/src/compress.js +150 -50
- package/src/pdf-compressor.worker.js +26 -43
package/README.md
CHANGED
|
@@ -1,26 +1,46 @@
|
|
|
1
1
|
# compress-pdf-lib
|
|
2
2
|
|
|
3
|
-
Client-side PDF compression
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Client-side PDF compression for **Vite-powered apps** (React + Vite, Astro, SvelteKit, plain Vite, etc.).
|
|
4
|
+
|
|
5
|
+
Extracts every embedded raster image from the PDF, recompresses it individually using the
|
|
6
|
+
browser's hardware-accelerated JPEG encoder (`OffscreenCanvas.convertToBlob` → libjpeg-turbo),
|
|
7
|
+
and writes the result back into the exact same PDF object slot with `pdf-lib`.
|
|
8
|
+
Text, fonts, and vector drawing are **never touched** — text-only PDFs are safe and won't bloat.
|
|
9
|
+
|
|
10
|
+
**Zero WASM · zero server · nothing leaves the browser · no bundled UI.**
|
|
11
|
+
|
|
12
|
+
---
|
|
7
13
|
|
|
8
14
|
## Requirements
|
|
9
15
|
|
|
10
16
|
This package ships as **raw ESM source**, not a pre-bundled dist. It relies on
|
|
11
|
-
Vite-specific import syntax (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
Vite-specific import syntax (`new URL(..., import.meta.url)` worker resolution),
|
|
18
|
+
so it only works inside a **Vite-powered build**:
|
|
19
|
+
|
|
20
|
+
| Environment | Works? |
|
|
21
|
+
|---|---|
|
|
22
|
+
| React + Vite (`npm create vite@latest`) | ✅ |
|
|
23
|
+
| Astro | ✅ |
|
|
24
|
+
| SvelteKit, Vue + Vite, plain Vite | ✅ |
|
|
25
|
+
| Webpack / CRA / Next.js default Webpack | ❌ (needs worker-loader tweaks) |
|
|
26
|
+
|
|
27
|
+
It also only runs **in the browser** — it uses `Worker`, `OffscreenCanvas`, `createImageBitmap`,
|
|
28
|
+
and `navigator.*`, so always call it from client-side code, never during SSR.
|
|
29
|
+
|
|
30
|
+
> **Vite config note:** If Vite complains that `pdf-lib`'s CJS sub-modules can't be resolved,
|
|
31
|
+
> add this alias to your `vite.config.js`:
|
|
32
|
+
> ```js
|
|
33
|
+
> resolve: {
|
|
34
|
+
> alias: {
|
|
35
|
+
> "pdf-lib": new URL(
|
|
36
|
+
> "./node_modules/pdf-lib/dist/pdf-lib.esm.js",
|
|
37
|
+
> import.meta.url
|
|
38
|
+
> ).pathname,
|
|
39
|
+
> },
|
|
40
|
+
> }
|
|
41
|
+
> ```
|
|
19
42
|
|
|
20
|
-
|
|
21
|
-
`createImageBitmap`, and `navigator.*`, so call it from client-side code
|
|
22
|
-
(a React event handler, a browser `<script>` in Astro, etc.), never during
|
|
23
|
-
SSR.
|
|
43
|
+
---
|
|
24
44
|
|
|
25
45
|
## Install
|
|
26
46
|
|
|
@@ -28,35 +48,134 @@ SSR.
|
|
|
28
48
|
npm install compress-pdf-lib
|
|
29
49
|
```
|
|
30
50
|
|
|
31
|
-
Or
|
|
51
|
+
Or directly from GitHub (no npm publish needed):
|
|
32
52
|
|
|
33
53
|
```bash
|
|
34
54
|
npm install github:dgbkn/compress-pdf-lib
|
|
55
|
+
# pin to a tag:
|
|
56
|
+
npm install github:dgbkn/compress-pdf-lib#v1.0.4
|
|
35
57
|
```
|
|
36
58
|
|
|
37
|
-
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Quick Start
|
|
38
62
|
|
|
39
63
|
```js
|
|
40
64
|
import { compressPDF } from "compress-pdf-lib";
|
|
41
65
|
|
|
42
66
|
const { file, stats } = await compressPDF(pdfFile, {
|
|
43
|
-
quality:
|
|
44
|
-
|
|
67
|
+
quality: 80, // JPEG quality 0–100 (default 75)
|
|
68
|
+
scale: 1, // image resize factor (default 1 = keep native size)
|
|
45
69
|
});
|
|
46
70
|
|
|
47
|
-
console.log(stats);
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
console.log(`Saved ${stats.reduction.toFixed(1)}% (${stats.savedBytes} bytes)`);
|
|
72
|
+
// download or upload `file` — it's a File/Blob
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## API
|
|
78
|
+
|
|
79
|
+
### `compressPDF(input, options?)` — ⭐ recommended
|
|
80
|
+
|
|
81
|
+
Extracts each embedded raster image from the PDF, recompresses it, and rebuilds the PDF in place.
|
|
82
|
+
Text and vectors are untouched.
|
|
83
|
+
|
|
84
|
+
**input** — `File | Blob | ArrayBuffer | TypedArray`
|
|
85
|
+
|
|
86
|
+
| Option | Type | Default | Description |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| `quality` | `number` | `75` | JPEG quality, 0–100. Higher = better quality, larger file. |
|
|
89
|
+
| `scale` | `number` | `1` | Resize factor applied to each image's **own** pixel dimensions. `1` = keep native size, just recompress. `0.5` = halve width & height. Clamped to 0.05–1. |
|
|
90
|
+
| `minImageBytes` | `number` | `2048` | Skip images already smaller than this many bytes — not worth re-encoding overhead. |
|
|
91
|
+
| `onlyIfSmaller` | `boolean` | `true` | If the recompressed JPEG would be **larger** than the original stream, keep the original. |
|
|
92
|
+
| `workers` | `number` | auto | Override the auto-detected worker count. Pass `0` or omit for auto. |
|
|
93
|
+
| `onProgress` | `function` | — | `(update) => void`. Called with `{ stage, progress, imageIndex, totalImages, completed }` as compression proceeds. `progress` is 0–100. |
|
|
94
|
+
|
|
95
|
+
**Returns** `Promise<{ file: File|Blob, stats }>`
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
stats = {
|
|
99
|
+
pages, // number of pages in the PDF
|
|
100
|
+
imagesFound, // total image XObjects discovered
|
|
101
|
+
imagesCompressed, // images successfully recompressed
|
|
102
|
+
imagesSkipped, // images skipped (too small, unsupported codec, already smaller, etc.)
|
|
103
|
+
originalBytes, // input file size in bytes
|
|
104
|
+
compressedBytes, // output file size in bytes
|
|
105
|
+
savedBytes, // originalBytes - compressedBytes (≥ 0)
|
|
106
|
+
reduction, // (savedBytes / originalBytes) * 100 — percent
|
|
107
|
+
elapsed, // wall-clock ms
|
|
108
|
+
workersUsed, // number of Worker threads used (0 = direct / no worker pool)
|
|
109
|
+
perImage: [ // one entry per image XObject
|
|
110
|
+
{
|
|
111
|
+
ref, // PDF object reference string
|
|
112
|
+
skipped, // null if compressed; reason string if skipped
|
|
113
|
+
width, height, // original pixel dimensions
|
|
114
|
+
newWidth, newHeight,
|
|
115
|
+
originalBytes,
|
|
116
|
+
compressedBytes,
|
|
117
|
+
format, // "jpeg" | "flate+smask"
|
|
118
|
+
},
|
|
119
|
+
// ...
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
### `rasterizePDF(input, options?)` — legacy
|
|
127
|
+
|
|
128
|
+
Renders **every page** to a full-page bitmap and rebuilds the PDF from those images.
|
|
129
|
+
This **discards text and vector content** (replaces them with pictures of them).
|
|
130
|
+
Prefer `compressPDF` unless the document is already purely scanned images.
|
|
131
|
+
|
|
132
|
+
| Option | Type | Default | Description |
|
|
133
|
+
|---|---|---|---|
|
|
134
|
+
| `quality` | `number` | `65` | JPEG quality, 0–100 |
|
|
135
|
+
| `resolution` | `number \| "original"` | `1600` | Max px on a page's longest side. `"original"` keeps full render scale. |
|
|
136
|
+
| `scale` | `number` | — | Direct render multiplier (overrides `resolution` when set). Clamped 0.25–3. |
|
|
137
|
+
| `workers` | `number` | auto | Worker count override |
|
|
138
|
+
| `engine` | `string` | `"native"` | Encoder engine hint (passed to worker) |
|
|
139
|
+
| `onProgress` | `function` | — | Same shape as `compressPDF` |
|
|
140
|
+
|
|
141
|
+
**Returns** `Promise<{ file: File|Blob, stats }>`
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### `getClientPower()`
|
|
146
|
+
|
|
147
|
+
Returns `{ cores, memory, workers }` — the auto-detected hardware profile and
|
|
148
|
+
recommended worker count that `compressPDF` would use by default.
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
import { getClientPower } from "compress-pdf-lib";
|
|
152
|
+
|
|
153
|
+
const { cores, memory, workers } = getClientPower();
|
|
154
|
+
console.log(`Using ${workers} workers on ${cores}-core / ${memory}GB RAM machine`);
|
|
55
155
|
```
|
|
56
156
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### `CompressionPool`
|
|
160
|
+
|
|
161
|
+
The underlying worker-pool class. Exported for advanced use cases where you want
|
|
162
|
+
to manage pool lifecycle across multiple compressions (e.g. keep workers alive
|
|
163
|
+
between calls instead of creating/destroying per-call).
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
import { CompressionPool, getClientPower } from "compress-pdf-lib";
|
|
167
|
+
|
|
168
|
+
const pool = new CompressionPool();
|
|
169
|
+
pool.init(getClientPower().workers);
|
|
170
|
+
|
|
171
|
+
// ... compress multiple PDFs reusing the same pool ...
|
|
172
|
+
|
|
173
|
+
pool.destroy();
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Examples
|
|
60
179
|
|
|
61
180
|
### React + Vite
|
|
62
181
|
|
|
@@ -64,12 +183,17 @@ typed array, and returns `{ file, stats }` where `file` is a `File` (or
|
|
|
64
183
|
import { compressPDF } from "compress-pdf-lib";
|
|
65
184
|
|
|
66
185
|
function Uploader() {
|
|
67
|
-
async function handleChange(
|
|
68
|
-
const original =
|
|
69
|
-
const { file, stats } = await compressPDF(original, { quality: 65 });
|
|
186
|
+
async function handleChange(e) {
|
|
187
|
+
const original = e.target.files[0];
|
|
70
188
|
|
|
71
|
-
|
|
72
|
-
|
|
189
|
+
const { file, stats } = await compressPDF(original, {
|
|
190
|
+
quality: 80,
|
|
191
|
+
onProgress: ({ progress, stage }) =>
|
|
192
|
+
console.log(stage, `${progress}%`),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
console.log(`${stats.reduction.toFixed(1)}% smaller in ${(stats.elapsed/1000).toFixed(2)}s`);
|
|
196
|
+
// upload `file`, trigger a download, etc.
|
|
73
197
|
}
|
|
74
198
|
|
|
75
199
|
return <input type="file" accept="application/pdf" onChange={handleChange} />;
|
|
@@ -78,124 +202,71 @@ function Uploader() {
|
|
|
78
202
|
|
|
79
203
|
### Astro
|
|
80
204
|
|
|
81
|
-
Astro components render on the server by default, so run this inside a
|
|
82
|
-
client-side script or an interactive island (`client:load` etc.):
|
|
83
|
-
|
|
84
205
|
```astro
|
|
85
206
|
---
|
|
86
207
|
// src/pages/index.astro
|
|
87
208
|
---
|
|
88
209
|
<input type="file" id="pdf-input" accept="application/pdf" />
|
|
89
|
-
<pre id="
|
|
210
|
+
<pre id="out"></pre>
|
|
90
211
|
|
|
91
212
|
<script>
|
|
92
213
|
import { compressPDF } from "compress-pdf-lib";
|
|
93
214
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
input.addEventListener("change", async (event) => {
|
|
98
|
-
const file = event.target.files?.[0];
|
|
215
|
+
document.getElementById("pdf-input").addEventListener("change", async (e) => {
|
|
216
|
+
const file = e.target.files?.[0];
|
|
99
217
|
if (!file) return;
|
|
100
218
|
|
|
101
|
-
const { file: compressed, stats } = await compressPDF(file, { quality:
|
|
102
|
-
|
|
219
|
+
const { file: compressed, stats } = await compressPDF(file, { quality: 75 });
|
|
220
|
+
document.getElementById("out").textContent = JSON.stringify(stats, null, 2);
|
|
103
221
|
});
|
|
104
222
|
</script>
|
|
105
223
|
```
|
|
106
224
|
|
|
107
|
-
|
|
108
|
-
`compressPDF` inside a browser event handler.)
|
|
225
|
+
### Intercept fetch uploads
|
|
109
226
|
|
|
110
|
-
|
|
227
|
+
Auto-compress any PDF before it leaves the browser:
|
|
111
228
|
|
|
112
229
|
```js
|
|
113
230
|
import { compressPDF } from "compress-pdf-lib";
|
|
114
231
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const [resource, config] = args;
|
|
119
|
-
|
|
232
|
+
const _fetch = window.fetch;
|
|
233
|
+
window.fetch = async function(...args) {
|
|
234
|
+
const [url, config] = args;
|
|
120
235
|
if (config?.body instanceof FormData) {
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
for (const [key, value] of entries) {
|
|
130
|
-
if (value instanceof File && value.type === "application/pdf") {
|
|
131
|
-
const { file, stats } = await compressPDF(value, { quality: 70 });
|
|
132
|
-
console.log(`Compressed ${value.name}:`, stats);
|
|
133
|
-
newFormData.append(key, file, value.name);
|
|
134
|
-
} else {
|
|
135
|
-
newFormData.append(key, value);
|
|
136
|
-
}
|
|
236
|
+
const next = new FormData();
|
|
237
|
+
for (const [key, val] of config.body.entries()) {
|
|
238
|
+
if (val instanceof File && val.type === "application/pdf") {
|
|
239
|
+
const { file } = await compressPDF(val, { quality: 75 });
|
|
240
|
+
next.append(key, file, val.name);
|
|
241
|
+
} else {
|
|
242
|
+
next.append(key, val);
|
|
137
243
|
}
|
|
138
|
-
|
|
139
|
-
config.body = newFormData;
|
|
140
244
|
}
|
|
245
|
+
config.body = next;
|
|
141
246
|
}
|
|
142
|
-
|
|
143
|
-
return originalFetch.call(this, resource, config);
|
|
247
|
+
return _fetch.call(this, url, config);
|
|
144
248
|
};
|
|
145
249
|
```
|
|
146
250
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
### `compressPDF(input, options?)`
|
|
150
|
-
|
|
151
|
-
| Option | Type | Default | Description |
|
|
152
|
-
|--------------|----------|---------|-----------------------------------------------|
|
|
153
|
-
| `quality` | number | `65` | JPEG quality, 0–100 |
|
|
154
|
-
| `resolution` | number | `1600` | Max px on a page's longest rendered side |
|
|
155
|
-
| `workers` | number | auto | Override the auto-detected worker count |
|
|
156
|
-
| `onProgress` | function | — | `(update) => void`, called with `{ stage, progress, ... }` |
|
|
251
|
+
### With progress bar
|
|
157
252
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
### Option A — npm registry
|
|
174
|
-
|
|
175
|
-
```bash
|
|
176
|
-
cd compress-pdf-lib
|
|
177
|
-
npm login
|
|
178
|
-
npm publish
|
|
253
|
+
```js
|
|
254
|
+
const { file, stats } = await compressPDF(pdfFile, {
|
|
255
|
+
quality: 80,
|
|
256
|
+
scale: 0.85,
|
|
257
|
+
onlyIfSmaller: true,
|
|
258
|
+
|
|
259
|
+
onProgress({ stage, progress, imageIndex, totalImages }) {
|
|
260
|
+
progressBar.style.width = `${progress}%`;
|
|
261
|
+
statusEl.textContent =
|
|
262
|
+
stage === "compressing"
|
|
263
|
+
? `Compressing image ${imageIndex} / ${totalImages}…`
|
|
264
|
+
: stage;
|
|
265
|
+
},
|
|
266
|
+
});
|
|
179
267
|
```
|
|
180
268
|
|
|
181
|
-
|
|
182
|
-
you ever scope the package name like `@you/compress-pdf-lib`.)
|
|
183
|
-
|
|
184
|
-
Bump `version` in `package.json` before each subsequent `npm publish`
|
|
185
|
-
(`npm version patch|minor|major` does this for you and tags git).
|
|
186
|
-
|
|
187
|
-
### Option B — GitHub only (no npm publish)
|
|
188
|
-
|
|
189
|
-
1. Push this folder as a repo, e.g. `github.com/YOUR_USERNAME/compress-pdf-lib`.
|
|
190
|
-
2. Consumers install with:
|
|
191
|
-
```bash
|
|
192
|
-
npm install github:YOUR_USERNAME/compress-pdf-lib
|
|
193
|
-
# or a specific tag/branch:
|
|
194
|
-
npm install github:YOUR_USERNAME/compress-pdf-lib#v1.0.0
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
Either way, update the `repository`/`homepage`/`bugs` URLs in `package.json`
|
|
198
|
-
to your actual GitHub username first.
|
|
269
|
+
---
|
|
199
270
|
|
|
200
271
|
## License
|
|
201
272
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "compress-pdf-lib",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Client-side PDF compression:
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"description": "Client-side PDF compression: extract & recompress embedded images with hardware-accelerated native Canvas / libjpeg-turbo + pdf-lib rebuild. No server, no UI. Ships as raw ESM source for Vite-based apps (React + Vite, Astro).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"module": "src/index.js",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"compression",
|
|
24
24
|
"compress-pdf",
|
|
25
25
|
"jpeg",
|
|
26
|
-
"
|
|
26
|
+
"canvas",
|
|
27
27
|
"pdfjs",
|
|
28
28
|
"pdf-lib",
|
|
29
29
|
"vite",
|
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"pdfjs-dist": "^6.3.289",
|
|
37
|
-
"pdf-lib": "^1.17.1"
|
|
38
|
-
"@jsquash/jpeg": "^1.4.0"
|
|
37
|
+
"pdf-lib": "^1.17.1"
|
|
39
38
|
},
|
|
40
39
|
"publishConfig": {
|
|
41
40
|
"access": "public"
|
package/src/compress.js
CHANGED
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
* // }
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
import * as pdfjsLib from "pdfjs-dist";
|
|
42
41
|
import {
|
|
43
42
|
PDFDocument,
|
|
44
43
|
PDFName,
|
|
@@ -48,9 +47,6 @@ import {
|
|
|
48
47
|
PDFNumber,
|
|
49
48
|
decodePDFRawStream,
|
|
50
49
|
} from "pdf-lib";
|
|
51
|
-
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
|
52
|
-
|
|
53
|
-
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
|
54
50
|
|
|
55
51
|
/* ============================================================
|
|
56
52
|
CLIENT POWER DETECTION
|
|
@@ -268,6 +264,47 @@ function jpegResultToBytes(item) {
|
|
|
268
264
|
throw new Error("Invalid JPEG encoder output");
|
|
269
265
|
}
|
|
270
266
|
|
|
267
|
+
/*
|
|
268
|
+
* ---- Direct/Worker JPEG Encoder ------------------------------------------
|
|
269
|
+
*/
|
|
270
|
+
|
|
271
|
+
async function encodeBitmapToJpeg(bitmap, targetWidth, targetHeight, quality, engine = "native", pool = null) {
|
|
272
|
+
if (pool) {
|
|
273
|
+
const compressed = await pool.run(
|
|
274
|
+
{
|
|
275
|
+
type: "compress-image",
|
|
276
|
+
bitmap,
|
|
277
|
+
targetWidth,
|
|
278
|
+
targetHeight,
|
|
279
|
+
quality,
|
|
280
|
+
engine,
|
|
281
|
+
},
|
|
282
|
+
[bitmap]
|
|
283
|
+
);
|
|
284
|
+
return jpegResultToBytes(compressed);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ULTRA-FAST DIRECT NATIVE PATH:
|
|
288
|
+
// Zero worker overhead, zero postMessage serialization, runs in ~15ms via browser C++ engine
|
|
289
|
+
const canvas = new OffscreenCanvas(targetWidth, targetHeight);
|
|
290
|
+
const ctx = canvas.getContext("2d", { alpha: false, desynchronized: true });
|
|
291
|
+
if (!ctx) {
|
|
292
|
+
bitmap.close();
|
|
293
|
+
throw new Error("OffscreenCanvas unavailable");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
ctx.fillStyle = "#ffffff";
|
|
297
|
+
ctx.fillRect(0, 0, targetWidth, targetHeight);
|
|
298
|
+
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
|
|
299
|
+
bitmap.close();
|
|
300
|
+
|
|
301
|
+
const q = Math.max(0.01, Math.min(Number(quality) / 100, 1.0));
|
|
302
|
+
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: q });
|
|
303
|
+
canvas.width = 1;
|
|
304
|
+
canvas.height = 1;
|
|
305
|
+
return new Uint8Array(await blob.arrayBuffer());
|
|
306
|
+
}
|
|
307
|
+
|
|
271
308
|
/* ============================================================================
|
|
272
309
|
STRATEGY 1 (RECOMMENDED): EXTRACT EMBEDDED IMAGES, COMPRESS, REPLACE IN PLACE
|
|
273
310
|
============================================================================ */
|
|
@@ -276,19 +313,37 @@ function jpegResultToBytes(item) {
|
|
|
276
313
|
* ---- PDF filter name plumbing --------------------------------------------
|
|
277
314
|
*/
|
|
278
315
|
|
|
316
|
+
const FILTER_ALIASES = {
|
|
317
|
+
DCT: "DCTDecode",
|
|
318
|
+
Fl: "FlateDecode",
|
|
319
|
+
LZW: "LZWDecode",
|
|
320
|
+
A85: "ASCII85Decode",
|
|
321
|
+
AHx: "ASCIIHexDecode",
|
|
322
|
+
RL: "RunLengthDecode",
|
|
323
|
+
CCF: "CCITTFaxDecode",
|
|
324
|
+
JBIG2: "JBIG2Decode",
|
|
325
|
+
JPX: "JPXDecode",
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
function normalizeFilterName(name) {
|
|
329
|
+
if (!name) return "";
|
|
330
|
+
const clean = name.replace(/^\//, "");
|
|
331
|
+
return FILTER_ALIASES[clean] || clean;
|
|
332
|
+
}
|
|
333
|
+
|
|
279
334
|
function filterNamesOf(context, dict) {
|
|
280
335
|
const filter = context.lookup(dict.get(PDFName.of("Filter")));
|
|
281
336
|
|
|
282
337
|
if (!filter) return [];
|
|
283
338
|
|
|
284
339
|
if (filter instanceof PDFName) {
|
|
285
|
-
return [filter.asString()
|
|
340
|
+
return [normalizeFilterName(filter.asString())];
|
|
286
341
|
}
|
|
287
342
|
|
|
288
343
|
if (filter instanceof PDFArray) {
|
|
289
344
|
return filter.asArray().map((f) => {
|
|
290
345
|
const resolved = context.lookup(f);
|
|
291
|
-
return resolved instanceof PDFName ? resolved.asString()
|
|
346
|
+
return resolved instanceof PDFName ? normalizeFilterName(resolved.asString()) : "";
|
|
292
347
|
}).filter(Boolean);
|
|
293
348
|
}
|
|
294
349
|
|
|
@@ -534,30 +589,59 @@ async function decodeImageXObject(context, ref) {
|
|
|
534
589
|
|
|
535
590
|
if (filterNames.includes("DCTDecode")) {
|
|
536
591
|
// pdf-lib's decodePDFRawStream throws on DCTDecode because it natively lacks a
|
|
537
|
-
// mechanism to decode JPEGs to raw samples.
|
|
538
|
-
//
|
|
592
|
+
// mechanism to decode JPEGs to raw samples.
|
|
593
|
+
// If DCTDecode is the sole filter (most common), stream.contents is already the raw JPEG bytes.
|
|
594
|
+
// If there is an outer filter (e.g. ASCII85Decode or FlateDecode applied on top of DCTDecode),
|
|
595
|
+
// decode only the outer filters and extract the JPEG bytes without passing DCTDecode to pdf-lib.
|
|
539
596
|
let jpegBytes;
|
|
540
597
|
const originalFilterVal = dict.get(PDFName.of("Filter"));
|
|
541
598
|
const filterObj = context.lookup(originalFilterVal);
|
|
542
599
|
|
|
543
600
|
if (filterNames.length > 1 && filterObj instanceof PDFArray) {
|
|
544
|
-
const
|
|
601
|
+
const remainingFilters = [];
|
|
602
|
+
const remainingDecodeParms = [];
|
|
603
|
+
const originalDecodeParms = context.lookup(dict.get(PDFName.of("DecodeParms")));
|
|
604
|
+
|
|
605
|
+
const filterArray = filterObj.asArray();
|
|
606
|
+
for (let i = 0; i < filterArray.length; i++) {
|
|
607
|
+
const f = filterArray[i];
|
|
545
608
|
const resolved = context.lookup(f);
|
|
546
|
-
|
|
547
|
-
|
|
609
|
+
const name = resolved instanceof PDFName ? normalizeFilterName(resolved.asString()) : "";
|
|
610
|
+
if (name !== "DCTDecode") {
|
|
611
|
+
remainingFilters.push(f);
|
|
612
|
+
if (originalDecodeParms instanceof PDFArray) {
|
|
613
|
+
remainingDecodeParms.push(originalDecodeParms.get(i));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
548
617
|
|
|
549
|
-
if (
|
|
618
|
+
if (remainingFilters.length === 0) {
|
|
550
619
|
jpegBytes = stream.contents;
|
|
551
620
|
} else {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
621
|
+
// Construct a safe surrogate stream without mutating the original dictionary
|
|
622
|
+
const surrogateDict = {
|
|
623
|
+
lookup(key) {
|
|
624
|
+
if (key === PDFName.of("Filter")) {
|
|
625
|
+
return remainingFilters.length === 1
|
|
626
|
+
? remainingFilters[0]
|
|
627
|
+
: context.obj(remainingFilters);
|
|
628
|
+
}
|
|
629
|
+
if (key === PDFName.of("DecodeParms")) {
|
|
630
|
+
if (originalDecodeParms instanceof PDFArray) {
|
|
631
|
+
return remainingDecodeParms.length === 1
|
|
632
|
+
? remainingDecodeParms[0]
|
|
633
|
+
: context.obj(remainingDecodeParms);
|
|
634
|
+
}
|
|
635
|
+
return originalDecodeParms;
|
|
636
|
+
}
|
|
637
|
+
return dict.lookup(key);
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
jpegBytes = decodePDFRawStream({ dict: surrogateDict, contents: stream.contents }).decode();
|
|
558
642
|
}
|
|
559
643
|
} else {
|
|
560
|
-
//
|
|
644
|
+
// Direct raw DCTDecode stream (most common)
|
|
561
645
|
jpegBytes = stream.contents;
|
|
562
646
|
}
|
|
563
647
|
|
|
@@ -604,6 +688,7 @@ async function decodeImageXObject(context, ref) {
|
|
|
604
688
|
}
|
|
605
689
|
|
|
606
690
|
bitmap.close();
|
|
691
|
+
smaskDecoded.bitmap.close();
|
|
607
692
|
bitmap = await createImageBitmap(base);
|
|
608
693
|
}
|
|
609
694
|
} catch (smaskError) {
|
|
@@ -751,6 +836,8 @@ export async function compressPDF(input, options = {}) {
|
|
|
751
836
|
minImageBytes = 2048,
|
|
752
837
|
onlyIfSmaller = true,
|
|
753
838
|
workers: workerOverride,
|
|
839
|
+
useWorker = false,
|
|
840
|
+
engine = "native",
|
|
754
841
|
onProgress,
|
|
755
842
|
} = options;
|
|
756
843
|
|
|
@@ -768,11 +855,17 @@ export async function compressPDF(input, options = {}) {
|
|
|
768
855
|
const context = pdfDoc.context;
|
|
769
856
|
const imageRefs = findImageRefs(pdfDoc);
|
|
770
857
|
|
|
771
|
-
const
|
|
772
|
-
|
|
858
|
+
const shouldSpawnWorkers = useWorker || (typeof workerOverride === "number" && workerOverride > 0);
|
|
859
|
+
let pool = null;
|
|
860
|
+
let workerCount = 0;
|
|
773
861
|
|
|
774
|
-
|
|
775
|
-
|
|
862
|
+
if (shouldSpawnWorkers) {
|
|
863
|
+
const power = getClientPower();
|
|
864
|
+
const maxNeededWorkers = Math.max(1, Math.min(imageRefs.length, 4));
|
|
865
|
+
workerCount = Math.min(workerOverride || power.workers, maxNeededWorkers);
|
|
866
|
+
pool = new CompressionPool();
|
|
867
|
+
pool.init(workerCount);
|
|
868
|
+
}
|
|
776
869
|
|
|
777
870
|
const perImage = [];
|
|
778
871
|
let imagesCompressed = 0;
|
|
@@ -820,17 +913,19 @@ export async function compressPDF(input, options = {}) {
|
|
|
820
913
|
entry.newHeight = newHeight;
|
|
821
914
|
entry.originalBytes = origImgBytes;
|
|
822
915
|
|
|
823
|
-
let resizedBitmap = bitmap;
|
|
824
|
-
|
|
825
|
-
if (newWidth !== width || newHeight !== height) {
|
|
826
|
-
const resizeCanvas = new OffscreenCanvas(newWidth, newHeight);
|
|
827
|
-
const resizeCtx = resizeCanvas.getContext("2d");
|
|
828
|
-
resizeCtx.drawImage(bitmap, 0, 0, newWidth, newHeight);
|
|
829
|
-
bitmap.close();
|
|
830
|
-
resizedBitmap = await createImageBitmap(resizeCanvas);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
916
|
if (hasAlpha) {
|
|
917
|
+
let resizedBitmap = bitmap;
|
|
918
|
+
|
|
919
|
+
if (newWidth !== width || newHeight !== height) {
|
|
920
|
+
const resizeCanvas = new OffscreenCanvas(newWidth, newHeight);
|
|
921
|
+
const resizeCtx = resizeCanvas.getContext("2d");
|
|
922
|
+
resizeCtx.drawImage(bitmap, 0, 0, newWidth, newHeight);
|
|
923
|
+
bitmap.close();
|
|
924
|
+
resizedBitmap = await createImageBitmap(resizeCanvas);
|
|
925
|
+
resizeCanvas.width = 1;
|
|
926
|
+
resizeCanvas.height = 1;
|
|
927
|
+
}
|
|
928
|
+
|
|
834
929
|
const newBytes = await replaceImageWithFlateRGBA(
|
|
835
930
|
context,
|
|
836
931
|
ref,
|
|
@@ -845,19 +940,16 @@ export async function compressPDF(input, options = {}) {
|
|
|
845
940
|
entry.compressedBytes = newBytes;
|
|
846
941
|
imagesCompressed++;
|
|
847
942
|
} else {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
[resizedBitmap]
|
|
943
|
+
// Direct single-pass compression: zero-copy native encode without intermediate canvas
|
|
944
|
+
const jpegBytes = await encodeBitmapToJpeg(
|
|
945
|
+
bitmap,
|
|
946
|
+
newWidth,
|
|
947
|
+
newHeight,
|
|
948
|
+
quality,
|
|
949
|
+
engine,
|
|
950
|
+
pool
|
|
857
951
|
);
|
|
858
952
|
|
|
859
|
-
const jpegBytes = jpegResultToBytes(compressed);
|
|
860
|
-
|
|
861
953
|
if (onlyIfSmaller && jpegBytes.length >= origImgBytes) {
|
|
862
954
|
entry.skipped = "recompressed-not-smaller";
|
|
863
955
|
entry.compressedBytes = origImgBytes;
|
|
@@ -894,7 +986,9 @@ export async function compressPDF(input, options = {}) {
|
|
|
894
986
|
}
|
|
895
987
|
}
|
|
896
988
|
|
|
897
|
-
const concurrency =
|
|
989
|
+
const concurrency = shouldSpawnWorkers
|
|
990
|
+
? Math.max(1, Math.min(workerCount, 4))
|
|
991
|
+
: Math.min(Math.max(imageRefs.length, 1), 4);
|
|
898
992
|
const runnerCount = Math.min(concurrency, Math.max(imageRefs.length, 1));
|
|
899
993
|
|
|
900
994
|
await Promise.all(Array.from({ length: runnerCount }, () => runner()));
|
|
@@ -924,7 +1018,7 @@ export async function compressPDF(input, options = {}) {
|
|
|
924
1018
|
savedBytes,
|
|
925
1019
|
reduction,
|
|
926
1020
|
elapsed,
|
|
927
|
-
workersUsed: workerCount,
|
|
1021
|
+
workersUsed: shouldSpawnWorkers ? workerCount : 0,
|
|
928
1022
|
perImage,
|
|
929
1023
|
};
|
|
930
1024
|
|
|
@@ -939,7 +1033,7 @@ export async function compressPDF(input, options = {}) {
|
|
|
939
1033
|
|
|
940
1034
|
return { file, stats };
|
|
941
1035
|
} finally {
|
|
942
|
-
pool
|
|
1036
|
+
pool?.destroy();
|
|
943
1037
|
}
|
|
944
1038
|
}
|
|
945
1039
|
|
|
@@ -949,7 +1043,7 @@ export async function compressPDF(input, options = {}) {
|
|
|
949
1043
|
there's nothing for compressPDF's per-image extraction to find separately.
|
|
950
1044
|
============================================================================ */
|
|
951
1045
|
|
|
952
|
-
async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality, resolution, scale: fixedScale }) {
|
|
1046
|
+
async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality, resolution, scale: fixedScale, engine = "native" }) {
|
|
953
1047
|
let page = null;
|
|
954
1048
|
let canvas = null;
|
|
955
1049
|
let bitmap = null;
|
|
@@ -1011,9 +1105,12 @@ async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality
|
|
|
1011
1105
|
{
|
|
1012
1106
|
type: "compress-image",
|
|
1013
1107
|
bitmap,
|
|
1108
|
+
targetWidth: width,
|
|
1109
|
+
targetHeight: height,
|
|
1110
|
+
quality,
|
|
1111
|
+
engine,
|
|
1014
1112
|
pageNumber,
|
|
1015
1113
|
totalPages,
|
|
1016
|
-
quality,
|
|
1017
1114
|
pdfWidth,
|
|
1018
1115
|
pdfHeight,
|
|
1019
1116
|
},
|
|
@@ -1041,7 +1138,7 @@ async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality
|
|
|
1041
1138
|
}
|
|
1042
1139
|
}
|
|
1043
1140
|
|
|
1044
|
-
async function rasterizeProcessPages(pdf, pool, totalPages, { quality, resolution, scale, workers, onProgress }) {
|
|
1141
|
+
async function rasterizeProcessPages(pdf, pool, totalPages, { quality, resolution, scale, workers, engine = "native", onProgress }) {
|
|
1045
1142
|
const results = new Array(totalPages);
|
|
1046
1143
|
|
|
1047
1144
|
const renderConcurrency = Math.max(1, Math.min(workers, 4));
|
|
@@ -1061,6 +1158,7 @@ async function rasterizeProcessPages(pdf, pool, totalPages, { quality, resolutio
|
|
|
1061
1158
|
quality,
|
|
1062
1159
|
resolution,
|
|
1063
1160
|
scale,
|
|
1161
|
+
engine,
|
|
1064
1162
|
});
|
|
1065
1163
|
|
|
1066
1164
|
results[pageNumber - 1] = result;
|
|
@@ -1184,6 +1282,7 @@ export async function rasterizePDF(input, options = {}) {
|
|
|
1184
1282
|
resolution = 1600,
|
|
1185
1283
|
scale,
|
|
1186
1284
|
workers: workerOverride,
|
|
1285
|
+
engine = "native",
|
|
1187
1286
|
onProgress,
|
|
1188
1287
|
} = options;
|
|
1189
1288
|
|
|
@@ -1212,6 +1311,7 @@ export async function rasterizePDF(input, options = {}) {
|
|
|
1212
1311
|
resolution,
|
|
1213
1312
|
scale,
|
|
1214
1313
|
workers: workerCount,
|
|
1314
|
+
engine,
|
|
1215
1315
|
onProgress,
|
|
1216
1316
|
});
|
|
1217
1317
|
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pdf-compressor.worker.js
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Takes a transferred ImageBitmap, draws it to an OffscreenCanvas
|
|
6
|
-
*
|
|
4
|
+
* Worker used by CompressionPool in compress.js.
|
|
5
|
+
* Takes a transferred ImageBitmap, draws it to an OffscreenCanvas (handling
|
|
6
|
+
* any scaling in a single native GPU/canvas draw call), encodes it directly
|
|
7
|
+
* to JPEG using browser-native OffscreenCanvas.convertToBlob(), and transfers
|
|
7
8
|
* the resulting JPEG bytes back to the main thread.
|
|
9
|
+
*
|
|
10
|
+
* 100% native C++ libjpeg-turbo with SIMD hardware acceleration.
|
|
11
|
+
* Zero WASM, zero external dependencies, zero JS heap memory overhead.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
|
-
import { encode as encodeJpeg } from "@jsquash/jpeg";
|
|
11
|
-
|
|
12
14
|
self.onmessage = async (event) => {
|
|
13
15
|
const data = event.data;
|
|
14
16
|
|
|
@@ -19,9 +21,11 @@ self.onmessage = async (event) => {
|
|
|
19
21
|
const {
|
|
20
22
|
jobId,
|
|
21
23
|
bitmap,
|
|
24
|
+
targetWidth,
|
|
25
|
+
targetHeight,
|
|
22
26
|
pageNumber,
|
|
23
27
|
totalPages,
|
|
24
|
-
quality,
|
|
28
|
+
quality = 75,
|
|
25
29
|
pdfWidth,
|
|
26
30
|
pdfHeight,
|
|
27
31
|
} = data;
|
|
@@ -31,15 +35,14 @@ self.onmessage = async (event) => {
|
|
|
31
35
|
throw new Error("ImageBitmap missing");
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
const width = bitmap.width;
|
|
35
|
-
const height = bitmap.height;
|
|
38
|
+
const width = targetWidth || bitmap.width;
|
|
39
|
+
const height = targetHeight || bitmap.height;
|
|
36
40
|
|
|
37
41
|
/* ================================================
|
|
38
|
-
OFFSCREEN CANVAS
|
|
42
|
+
OFFSCREEN CANVAS (SINGLE-PASS DRAW & RESIZE)
|
|
39
43
|
================================================ */
|
|
40
44
|
|
|
41
45
|
const canvas = new OffscreenCanvas(width, height);
|
|
42
|
-
|
|
43
46
|
const ctx = canvas.getContext("2d", {
|
|
44
47
|
alpha: false,
|
|
45
48
|
desynchronized: true,
|
|
@@ -53,49 +56,32 @@ self.onmessage = async (event) => {
|
|
|
53
56
|
ctx.fillStyle = "#ffffff";
|
|
54
57
|
ctx.fillRect(0, 0, width, height);
|
|
55
58
|
|
|
59
|
+
// Draw and resize in a single hardware-accelerated operation
|
|
56
60
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
57
|
-
|
|
58
61
|
bitmap.close();
|
|
59
62
|
|
|
60
63
|
/* ================================================
|
|
61
|
-
|
|
62
|
-
================================================ */
|
|
63
|
-
|
|
64
|
-
const imageData = ctx.getImageData(0, 0, width, height);
|
|
65
|
-
|
|
66
|
-
/* ================================================
|
|
67
|
-
MOZJPEG WASM
|
|
64
|
+
DIRECT NATIVE JPEG ENCODING
|
|
68
65
|
================================================ */
|
|
69
66
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
const q = Math.max(0.01, Math.min(Number(quality) / 100, 1.0));
|
|
68
|
+
const blob = await canvas.convertToBlob({
|
|
69
|
+
type: "image/jpeg",
|
|
70
|
+
quality: q,
|
|
74
71
|
});
|
|
75
72
|
|
|
76
|
-
|
|
77
|
-
NORMALIZE ARRAYBUFFER
|
|
78
|
-
================================================ */
|
|
73
|
+
const jpegBuffer = await blob.arrayBuffer();
|
|
79
74
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (encoded instanceof ArrayBuffer) {
|
|
83
|
-
jpegBuffer = encoded;
|
|
84
|
-
} else if (ArrayBuffer.isView(encoded)) {
|
|
85
|
-
jpegBuffer = encoded.buffer.slice(
|
|
86
|
-
encoded.byteOffset,
|
|
87
|
-
encoded.byteOffset + encoded.byteLength
|
|
88
|
-
);
|
|
89
|
-
} else {
|
|
90
|
-
throw new Error("Invalid JPEG encoder output");
|
|
75
|
+
if (!jpegBuffer || jpegBuffer.byteLength === 0) {
|
|
76
|
+
throw new Error("Empty JPEG output");
|
|
91
77
|
}
|
|
92
78
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
79
|
+
// Immediately free canvas memory
|
|
80
|
+
canvas.width = 1;
|
|
81
|
+
canvas.height = 1;
|
|
96
82
|
|
|
97
83
|
/* ================================================
|
|
98
|
-
TRANSFER
|
|
84
|
+
TRANSFER BACK
|
|
99
85
|
================================================ */
|
|
100
86
|
|
|
101
87
|
self.postMessage(
|
|
@@ -113,9 +99,6 @@ self.onmessage = async (event) => {
|
|
|
113
99
|
},
|
|
114
100
|
[jpegBuffer]
|
|
115
101
|
);
|
|
116
|
-
|
|
117
|
-
canvas.width = 1;
|
|
118
|
-
canvas.height = 1;
|
|
119
102
|
} catch (error) {
|
|
120
103
|
try {
|
|
121
104
|
bitmap?.close();
|