compress-pdf-lib 1.0.3 → 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 CHANGED
@@ -1,26 +1,46 @@
1
1
  # compress-pdf-lib
2
2
 
3
- Client-side PDF compression. Renders each page with `pdf.js`, encodes it to
4
- JPEG with mozjpeg (WASM, via `@jsquash/jpeg`) across a parallel `Worker`
5
- pool, and rebuilds the PDF with `pdf-lib`. Nothing leaves the browser, and
6
- there's no bundled UI you call one function and get a compressed file back.
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 (`?url` asset imports, `new URL(..., import.meta.url)`
12
- worker resolution), so it only works inside a **Vite-powered build**:
13
-
14
- - React + Vite (`npm create vite@latest`)
15
- - ✅ Astro (Astro's dev server and build are Vite under the hood)
16
- - Any other Vite app (SvelteKit, Vue + Vite, plain Vite, etc.)
17
- - Webpack / CRA / Next.js's default Webpack build (untested, likely needs
18
- worker-loader / asset-url tweaks)
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
- It also only runs in the browser — it uses `Worker`, `OffscreenCanvas`,
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, straight from GitHub without publishing to npm:
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
- ## Usage
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: 70, // JPEG quality, 0-100
44
- resolution: 1600, // max px on a page's longest side
67
+ quality: 80, // JPEG quality 0100 (default 75)
68
+ scale: 1, // image resize factor (default 1 = keep native size)
45
69
  });
46
70
 
47
- console.log(stats);
48
- // {
49
- // pages, originalBytes, compressedBytes, savedBytes,
50
- // reduction, // percent
51
- // elapsed, // ms
52
- // workersUsed,
53
- // perPage: [{ pageNumber, renderedWidth, renderedHeight, jpegBytes }, ...]
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
- `compressPDF(input, options)` accepts a `File`, `Blob`, `ArrayBuffer`, or
58
- typed array, and returns `{ file, stats }` where `file` is a `File` (or
59
- `Blob` if `File` isn't available) you can upload, download, or inspect.
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(event) {
68
- const original = event.target.files[0];
69
- const { file, stats } = await compressPDF(original, { quality: 65 });
186
+ async function handleChange(e) {
187
+ const original = e.target.files[0];
70
188
 
71
- console.log(`${stats.reduction.toFixed(1)}% smaller`);
72
- // upload `file`, or trigger a download, etc.
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="stats"></pre>
210
+ <pre id="out"></pre>
90
211
 
91
212
  <script>
92
213
  import { compressPDF } from "compress-pdf-lib";
93
214
 
94
- const input = document.getElementById("pdf-input");
95
- const statsEl = document.getElementById("stats");
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: 70 });
102
- statsEl.textContent = JSON.stringify(stats, null, 2);
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
- (A React/Vue/Svelte island with `client:load` works the same way — just call
108
- `compressPDF` inside a browser event handler.)
225
+ ### Intercept fetch uploads
109
226
 
110
- ### Intercepting a fetch upload
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 originalFetch = window.fetch;
116
-
117
- window.fetch = async function (...args) {
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 entries = [...config.body.entries()];
122
- const hasPdf = entries.some(
123
- ([, v]) => v instanceof File && v.type === "application/pdf"
124
- );
125
-
126
- if (hasPdf) {
127
- const newFormData = new FormData();
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
- ## API
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
- Returns `Promise<{ file, stats }>`.
159
-
160
- ### `getClientPower()`
161
-
162
- Returns `{ cores, memory, workers }` — the auto-detected hardware profile and
163
- the worker count `compressPDF` would use by default.
164
-
165
- ### `CompressionPool`
166
-
167
- The underlying worker-pool class, exported in case you want to manage the
168
- pool's lifecycle yourself across multiple compressions instead of letting
169
- `compressPDF` create/destroy one per call.
170
-
171
- ## Publishing this package
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
- (`publishConfig.access: public` is already set in `package.json`, needed if
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.3",
4
- "description": "Client-side PDF compression: pdf.js render + mozjpeg (WASM) encoding via a parallel worker pool + pdf-lib rebuild. No server, no UI. Ships as raw ESM source for Vite-based apps (React + Vite, Astro).",
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
- "mozjpeg",
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,29 +313,43 @@ function jpegResultToBytes(item) {
276
313
  * ---- PDF filter name plumbing --------------------------------------------
277
314
  */
278
315
 
279
- function filterNamesOf(dict) {
280
- const filter = dict.get(PDFName.of("Filter"));
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
+
334
+ function filterNamesOf(context, dict) {
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().replace(/^\//, "")];
340
+ return [normalizeFilterName(filter.asString())];
286
341
  }
287
342
 
288
343
  if (filter instanceof PDFArray) {
289
- return filter.asArray().map((f) => f.asString().replace(/^\//, ""));
344
+ return filter.asArray().map((f) => {
345
+ const resolved = context.lookup(f);
346
+ return resolved instanceof PDFName ? normalizeFilterName(resolved.asString()) : "";
347
+ }).filter(Boolean);
290
348
  }
291
349
 
292
350
  return [];
293
351
  }
294
352
 
295
- const IMAGE_CODEC_FILTERS = new Set([
296
- "DCTDecode",
297
- "JPXDecode",
298
- "CCITTFaxDecode",
299
- "JBIG2Decode",
300
- ]);
301
-
302
353
  /*
303
354
  * ---- ColorSpace resolution --------------------------------------------
304
355
  * Returns { kind, components, palette?, paletteComponents? }
@@ -518,11 +569,12 @@ async function decodeImageXObject(context, ref) {
518
569
  // values — recompressing would break it, so skip these entirely.
519
570
  if (dict.get(PDFName.of("Mask"))) return null;
520
571
 
521
- const filterNames = filterNamesOf(dict);
522
- const lastFilter = filterNames[filterNames.length - 1];
523
-
524
- if (lastFilter === "JPXDecode" || lastFilter === "CCITTFaxDecode" || lastFilter === "JBIG2Decode") {
525
- return { unsupported: true, reason: lastFilter };
572
+ const filterNames = filterNamesOf(context, dict);
573
+
574
+ // Skip explicitly unhandled compression formats natively
575
+ const unsupported = filterNames.find(f => f === "JPXDecode" || f === "CCITTFaxDecode" || f === "JBIG2Decode");
576
+ if (unsupported) {
577
+ return { unsupported: true, reason: unsupported };
526
578
  }
527
579
 
528
580
  const width = dict.get(PDFName.of("Width"))?.asNumber?.();
@@ -535,10 +587,64 @@ async function decodeImageXObject(context, ref) {
535
587
  let bitmap;
536
588
  let hasAlpha = false;
537
589
 
538
- if (lastFilter === "DCTDecode") {
539
- // Already a JPEG file (decodePDFRawStream only inverts general stream
540
- // filters like Flate/LZW, it leaves the image codec itself alone).
541
- const jpegBytes = decodePDFRawStream(stream).decode();
590
+ if (filterNames.includes("DCTDecode")) {
591
+ // pdf-lib's decodePDFRawStream throws on DCTDecode because it natively lacks a
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.
596
+ let jpegBytes;
597
+ const originalFilterVal = dict.get(PDFName.of("Filter"));
598
+ const filterObj = context.lookup(originalFilterVal);
599
+
600
+ if (filterNames.length > 1 && filterObj instanceof PDFArray) {
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];
608
+ const resolved = context.lookup(f);
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
+ }
617
+
618
+ if (remainingFilters.length === 0) {
619
+ jpegBytes = stream.contents;
620
+ } else {
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();
642
+ }
643
+ } else {
644
+ // Direct raw DCTDecode stream (most common)
645
+ jpegBytes = stream.contents;
646
+ }
647
+
542
648
  const blob = new Blob([jpegBytes], { type: "image/jpeg" });
543
649
  bitmap = await createImageBitmap(blob);
544
650
  } else {
@@ -582,6 +688,7 @@ async function decodeImageXObject(context, ref) {
582
688
  }
583
689
 
584
690
  bitmap.close();
691
+ smaskDecoded.bitmap.close();
585
692
  bitmap = await createImageBitmap(base);
586
693
  }
587
694
  } catch (smaskError) {
@@ -729,6 +836,8 @@ export async function compressPDF(input, options = {}) {
729
836
  minImageBytes = 2048,
730
837
  onlyIfSmaller = true,
731
838
  workers: workerOverride,
839
+ useWorker = false,
840
+ engine = "native",
732
841
  onProgress,
733
842
  } = options;
734
843
 
@@ -746,11 +855,17 @@ export async function compressPDF(input, options = {}) {
746
855
  const context = pdfDoc.context;
747
856
  const imageRefs = findImageRefs(pdfDoc);
748
857
 
749
- const power = getClientPower();
750
- const workerCount = workerOverride || power.workers;
858
+ const shouldSpawnWorkers = useWorker || (typeof workerOverride === "number" && workerOverride > 0);
859
+ let pool = null;
860
+ let workerCount = 0;
751
861
 
752
- const pool = new CompressionPool();
753
- pool.init(workerCount);
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
+ }
754
869
 
755
870
  const perImage = [];
756
871
  let imagesCompressed = 0;
@@ -798,17 +913,19 @@ export async function compressPDF(input, options = {}) {
798
913
  entry.newHeight = newHeight;
799
914
  entry.originalBytes = origImgBytes;
800
915
 
801
- let resizedBitmap = bitmap;
802
-
803
- if (newWidth !== width || newHeight !== height) {
804
- const resizeCanvas = new OffscreenCanvas(newWidth, newHeight);
805
- const resizeCtx = resizeCanvas.getContext("2d");
806
- resizeCtx.drawImage(bitmap, 0, 0, newWidth, newHeight);
807
- bitmap.close();
808
- resizedBitmap = await createImageBitmap(resizeCanvas);
809
- }
810
-
811
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
+
812
929
  const newBytes = await replaceImageWithFlateRGBA(
813
930
  context,
814
931
  ref,
@@ -823,19 +940,16 @@ export async function compressPDF(input, options = {}) {
823
940
  entry.compressedBytes = newBytes;
824
941
  imagesCompressed++;
825
942
  } else {
826
- const compressed = await pool.run(
827
- {
828
- type: "compress-image",
829
- bitmap: resizedBitmap,
830
- quality,
831
- pageNumber: index + 1,
832
- totalPages: imageRefs.length,
833
- },
834
- [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
835
951
  );
836
952
 
837
- const jpegBytes = jpegResultToBytes(compressed);
838
-
839
953
  if (onlyIfSmaller && jpegBytes.length >= origImgBytes) {
840
954
  entry.skipped = "recompressed-not-smaller";
841
955
  entry.compressedBytes = origImgBytes;
@@ -872,7 +986,9 @@ export async function compressPDF(input, options = {}) {
872
986
  }
873
987
  }
874
988
 
875
- const concurrency = Math.max(1, Math.min(workerCount, 4));
989
+ const concurrency = shouldSpawnWorkers
990
+ ? Math.max(1, Math.min(workerCount, 4))
991
+ : Math.min(Math.max(imageRefs.length, 1), 4);
876
992
  const runnerCount = Math.min(concurrency, Math.max(imageRefs.length, 1));
877
993
 
878
994
  await Promise.all(Array.from({ length: runnerCount }, () => runner()));
@@ -902,7 +1018,7 @@ export async function compressPDF(input, options = {}) {
902
1018
  savedBytes,
903
1019
  reduction,
904
1020
  elapsed,
905
- workersUsed: workerCount,
1021
+ workersUsed: shouldSpawnWorkers ? workerCount : 0,
906
1022
  perImage,
907
1023
  };
908
1024
 
@@ -917,7 +1033,7 @@ export async function compressPDF(input, options = {}) {
917
1033
 
918
1034
  return { file, stats };
919
1035
  } finally {
920
- pool.destroy();
1036
+ pool?.destroy();
921
1037
  }
922
1038
  }
923
1039
 
@@ -927,7 +1043,7 @@ export async function compressPDF(input, options = {}) {
927
1043
  there's nothing for compressPDF's per-image extraction to find separately.
928
1044
  ============================================================================ */
929
1045
 
930
- 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" }) {
931
1047
  let page = null;
932
1048
  let canvas = null;
933
1049
  let bitmap = null;
@@ -989,9 +1105,12 @@ async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality
989
1105
  {
990
1106
  type: "compress-image",
991
1107
  bitmap,
1108
+ targetWidth: width,
1109
+ targetHeight: height,
1110
+ quality,
1111
+ engine,
992
1112
  pageNumber,
993
1113
  totalPages,
994
- quality,
995
1114
  pdfWidth,
996
1115
  pdfHeight,
997
1116
  },
@@ -1019,7 +1138,7 @@ async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality
1019
1138
  }
1020
1139
  }
1021
1140
 
1022
- 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 }) {
1023
1142
  const results = new Array(totalPages);
1024
1143
 
1025
1144
  const renderConcurrency = Math.max(1, Math.min(workers, 4));
@@ -1039,6 +1158,7 @@ async function rasterizeProcessPages(pdf, pool, totalPages, { quality, resolutio
1039
1158
  quality,
1040
1159
  resolution,
1041
1160
  scale,
1161
+ engine,
1042
1162
  });
1043
1163
 
1044
1164
  results[pageNumber - 1] = result;
@@ -1162,6 +1282,7 @@ export async function rasterizePDF(input, options = {}) {
1162
1282
  resolution = 1600,
1163
1283
  scale,
1164
1284
  workers: workerOverride,
1285
+ engine = "native",
1165
1286
  onProgress,
1166
1287
  } = options;
1167
1288
 
@@ -1190,6 +1311,7 @@ export async function rasterizePDF(input, options = {}) {
1190
1311
  resolution,
1191
1312
  scale,
1192
1313
  workers: workerCount,
1314
+ engine,
1193
1315
  onProgress,
1194
1316
  });
1195
1317
 
@@ -1217,4 +1339,4 @@ export async function rasterizePDF(input, options = {}) {
1217
1339
 
1218
1340
  pool.destroy();
1219
1341
  }
1220
- }
1342
+ }
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * pdf-compressor.worker.js
3
3
  *
4
- * Same worker used by CompressionPool in compressPDFLIB.js.
5
- * Takes a transferred ImageBitmap, draws it to an OffscreenCanvas,
6
- * encodes it with mozjpeg (WASM) via @jsquash/jpeg, and transfers
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
- GET PIXELS
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 encoded = await encodeJpeg(imageData, {
71
- quality: Number(quality),
72
- progressive: true,
73
- optimize_coding: true,
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
- let jpegBuffer;
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
- if (jpegBuffer.byteLength === 0) {
94
- throw new Error("Empty JPEG");
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();