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 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.4",
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,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().replace(/^\//, "")];
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().replace(/^\//, "") : "";
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. We must extract the bytes dynamically
538
- // whilst bypassing the "DCTDecode" step inside its pipeline filter.
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 filtered = filterObj.asArray().filter((f) => {
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
- return resolved instanceof PDFName && resolved.asString() !== "/DCTDecode";
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 (filtered.length === 0) {
618
+ if (remainingFilters.length === 0) {
550
619
  jpegBytes = stream.contents;
551
620
  } else {
552
- dict.set(PDFName.of("Filter"), context.obj(filtered));
553
- try {
554
- jpegBytes = decodePDFRawStream(stream).decode();
555
- } finally {
556
- dict.set(PDFName.of("Filter"), originalFilterVal);
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
- // It's just a raw DCTDecode stream (most common).
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 power = getClientPower();
772
- const workerCount = workerOverride || power.workers;
858
+ const shouldSpawnWorkers = useWorker || (typeof workerOverride === "number" && workerOverride > 0);
859
+ let pool = null;
860
+ let workerCount = 0;
773
861
 
774
- const pool = new CompressionPool();
775
- 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
+ }
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
- const compressed = await pool.run(
849
- {
850
- type: "compress-image",
851
- bitmap: resizedBitmap,
852
- quality,
853
- pageNumber: index + 1,
854
- totalPages: imageRefs.length,
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 = 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);
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.destroy();
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
- * 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();