compress-pdf-lib 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,202 @@
1
+ # compress-pdf-lib
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.
7
+
8
+ ## Requirements
9
+
10
+ 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)
19
+
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.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ npm install compress-pdf-lib
29
+ ```
30
+
31
+ Or, straight from GitHub without publishing to npm:
32
+
33
+ ```bash
34
+ npm install github:dgbkn/compress-pdf-lib
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```js
40
+ import { compressPDF } from "compress-pdf-lib";
41
+
42
+ const { file, stats } = await compressPDF(pdfFile, {
43
+ quality: 70, // JPEG quality, 0-100
44
+ resolution: 1600, // max px on a page's longest side
45
+ });
46
+
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
+ // }
55
+ ```
56
+
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.
60
+
61
+ ### React + Vite
62
+
63
+ ```jsx
64
+ import { compressPDF } from "compress-pdf-lib";
65
+
66
+ function Uploader() {
67
+ async function handleChange(event) {
68
+ const original = event.target.files[0];
69
+ const { file, stats } = await compressPDF(original, { quality: 65 });
70
+
71
+ console.log(`${stats.reduction.toFixed(1)}% smaller`);
72
+ // upload `file`, or trigger a download, etc.
73
+ }
74
+
75
+ return <input type="file" accept="application/pdf" onChange={handleChange} />;
76
+ }
77
+ ```
78
+
79
+ ### Astro
80
+
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
+ ```astro
85
+ ---
86
+ // src/pages/index.astro
87
+ ---
88
+ <input type="file" id="pdf-input" accept="application/pdf" />
89
+ <pre id="stats"></pre>
90
+
91
+ <script>
92
+ import { compressPDF } from "compress-pdf-lib";
93
+
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];
99
+ if (!file) return;
100
+
101
+ const { file: compressed, stats } = await compressPDF(file, { quality: 70 });
102
+ statsEl.textContent = JSON.stringify(stats, null, 2);
103
+ });
104
+ </script>
105
+ ```
106
+
107
+ (A React/Vue/Svelte island with `client:load` works the same way — just call
108
+ `compressPDF` inside a browser event handler.)
109
+
110
+ ### Intercepting a fetch upload
111
+
112
+ ```js
113
+ import { compressPDF } from "compress-pdf-lib";
114
+
115
+ const originalFetch = window.fetch;
116
+
117
+ window.fetch = async function (...args) {
118
+ const [resource, config] = args;
119
+
120
+ 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
+ }
137
+ }
138
+
139
+ config.body = newFormData;
140
+ }
141
+ }
142
+
143
+ return originalFetch.call(this, resource, config);
144
+ };
145
+ ```
146
+
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, ... }` |
157
+
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
179
+ ```
180
+
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.
199
+
200
+ ## License
201
+
202
+ MIT
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "compress-pdf-lib",
3
+ "version": "1.0.0",
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).",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./worker": "./src/pdf-compressor.worker.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "sideEffects": [
19
+ "src/pdf-compressor.worker.js"
20
+ ],
21
+ "keywords": [
22
+ "pdf",
23
+ "compression",
24
+ "compress-pdf",
25
+ "jpeg",
26
+ "mozjpeg",
27
+ "pdfjs",
28
+ "pdf-lib",
29
+ "vite",
30
+ "astro",
31
+ "react"
32
+ ],
33
+ "author": "Dev Goyal",
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "pdfjs-dist": "^6.3.289",
37
+ "pdf-lib": "^1.17.1",
38
+ "@jsquash/jpeg": "^1.4.0"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/dgbkn/compress-pdf-lib.git"
46
+ },
47
+ "homepage": "https://github.com/dgbkn/compress-pdf-lib#readme",
48
+ "bugs": {
49
+ "url": "https://github.com/dgbkn/compress-pdf-lib/issues"
50
+ }
51
+ }
@@ -0,0 +1,537 @@
1
+ /**
2
+ * compress.js
3
+ *
4
+ * Full, UI-less PDF compression library: worker-pool + parallel page
5
+ * rendering + mozjpeg (WASM) encoding + pdf-lib rebuild.
6
+ *
7
+ * Pairs with pdf-compressor.worker.js (same folder).
8
+ *
9
+ * Usage:
10
+ * import { compressPDF } from 'compress-pdf-lib';
11
+ *
12
+ * const { file, stats } = await compressPDF(pdfFile, {
13
+ * quality: 70,
14
+ * resolution: 1600,
15
+ * });
16
+ *
17
+ * console.log(stats);
18
+ * // {
19
+ * // pages, originalBytes, compressedBytes, savedBytes,
20
+ * // reduction, elapsed, workersUsed, perPage: [...]
21
+ * // }
22
+ */
23
+
24
+ import * as pdfjsLib from "pdfjs-dist";
25
+ import { PDFDocument } from "pdf-lib";
26
+ import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
27
+
28
+ pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
29
+
30
+ /* ============================================================
31
+ CLIENT POWER DETECTION
32
+ ============================================================ */
33
+
34
+ export function getClientPower() {
35
+ const cores = navigator.hardwareConcurrency || 4;
36
+ const memory = navigator.deviceMemory || 4;
37
+
38
+ /*
39
+ * Conservative worker count.
40
+ * We don't want one worker per core because
41
+ * WASM JPEG encoding is CPU intensive.
42
+ */
43
+
44
+ let workers;
45
+
46
+ if (cores <= 2) {
47
+ workers = 1;
48
+ } else if (cores <= 4) {
49
+ workers = 2;
50
+ } else if (cores <= 8) {
51
+ workers = 3;
52
+ } else if (cores <= 12) {
53
+ workers = 4;
54
+ } else {
55
+ workers = 6;
56
+ }
57
+
58
+ /* Memory protection */
59
+
60
+ if (memory <= 2) {
61
+ workers = Math.min(workers, 1);
62
+ } else if (memory <= 4) {
63
+ workers = Math.min(workers, 2);
64
+ } else if (memory <= 8) {
65
+ workers = Math.min(workers, 4);
66
+ }
67
+
68
+ /* Never create ridiculous amounts of workers */
69
+
70
+ workers = Math.max(1, Math.min(workers, Math.max(1, cores - 1), 6));
71
+
72
+ return { cores, memory, workers };
73
+ }
74
+
75
+ /* ============================================================
76
+ WORKER POOL
77
+ ============================================================ */
78
+
79
+ export class CompressionPool {
80
+ constructor() {
81
+ this.workers = [];
82
+ this.idle = [];
83
+ this.jobs = new Map();
84
+ this.queue = [];
85
+ this.nextId = 1;
86
+ }
87
+
88
+ init(count) {
89
+ this.destroy();
90
+
91
+ for (let i = 0; i < count; i++) {
92
+ const worker = new Worker(
93
+ new URL("./pdf-compressor.worker.js", import.meta.url),
94
+ { type: "module" }
95
+ );
96
+
97
+ const slot = { worker, busy: false };
98
+
99
+ worker.onmessage = (event) => {
100
+ const data = event.data;
101
+
102
+ if (data.type === "image-complete") {
103
+ const jobId = data.jobId;
104
+ const job = this.jobs.get(jobId);
105
+
106
+ if (!job) return;
107
+
108
+ this.jobs.delete(jobId);
109
+ slot.busy = false;
110
+ this.idle.push(slot);
111
+
112
+ job.resolve(data);
113
+ this.pump();
114
+ } else if (data.type === "error") {
115
+ const jobId = data.jobId;
116
+ const job = this.jobs.get(jobId);
117
+
118
+ if (!job) return;
119
+
120
+ this.jobs.delete(jobId);
121
+ slot.busy = false;
122
+ this.idle.push(slot);
123
+
124
+ job.reject(new Error(data.message || "Worker compression failed"));
125
+ this.pump();
126
+ }
127
+ };
128
+
129
+ worker.onerror = (error) => {
130
+ console.error("Worker error:", error);
131
+ slot.busy = false;
132
+
133
+ for (const [id, job] of this.jobs) {
134
+ if (job.slot === slot) {
135
+ this.jobs.delete(id);
136
+ job.reject(new Error("Compression worker crashed"));
137
+ }
138
+ }
139
+
140
+ this.pump();
141
+ };
142
+
143
+ this.workers.push(slot);
144
+ this.idle.push(slot);
145
+ }
146
+ }
147
+
148
+ run(payload, transferables = []) {
149
+ return new Promise((resolve, reject) => {
150
+ const id = this.nextId++;
151
+
152
+ this.queue.push({ id, payload, transferables, resolve, reject });
153
+
154
+ this.pump();
155
+ });
156
+ }
157
+
158
+ pump() {
159
+ while (this.queue.length > 0 && this.idle.length > 0) {
160
+ const slot = this.idle.shift();
161
+ if (!slot) return;
162
+
163
+ const job = this.queue.shift();
164
+ slot.busy = true;
165
+ job.slot = slot;
166
+
167
+ this.jobs.set(job.id, job);
168
+
169
+ slot.worker.postMessage({ ...job.payload, jobId: job.id }, job.transferables);
170
+ }
171
+ }
172
+
173
+ async drain() {
174
+ while (this.queue.length > 0 || this.jobs.size > 0) {
175
+ await new Promise((resolve) => setTimeout(resolve, 5));
176
+ }
177
+ }
178
+
179
+ destroy() {
180
+ for (const slot of this.workers) {
181
+ try {
182
+ slot.worker.terminate();
183
+ } catch {}
184
+ }
185
+
186
+ this.workers = [];
187
+ this.idle = [];
188
+ this.jobs.clear();
189
+ this.queue = [];
190
+ }
191
+ }
192
+
193
+ /* ============================================================
194
+ SAFE PDF PAGE CLEANUP
195
+ ============================================================ */
196
+
197
+ function safePageCleanup(page) {
198
+ try {
199
+ if (page && typeof page.cleanup === "function") {
200
+ page.cleanup();
201
+ }
202
+ } catch (error) {
203
+ console.warn("Page cleanup skipped:", error);
204
+ }
205
+ }
206
+
207
+ /* ============================================================
208
+ INPUT NORMALIZATION
209
+ ============================================================ */
210
+
211
+ async function normalizeInput(input) {
212
+ let arrayBuffer;
213
+ let fileName = "document.pdf";
214
+ let fileType = "application/pdf";
215
+
216
+ if (typeof File !== "undefined" && input instanceof File) {
217
+ fileName = input.name || fileName;
218
+ fileType = input.type || fileType;
219
+ arrayBuffer = await input.arrayBuffer();
220
+ } else if (input instanceof Blob) {
221
+ fileType = input.type || fileType;
222
+ arrayBuffer = await input.arrayBuffer();
223
+ } else if (input instanceof ArrayBuffer) {
224
+ arrayBuffer = input;
225
+ } else if (ArrayBuffer.isView(input)) {
226
+ arrayBuffer = input.buffer.slice(
227
+ input.byteOffset,
228
+ input.byteOffset + input.byteLength
229
+ );
230
+ } else {
231
+ throw new Error("compressPDF: unsupported input type");
232
+ }
233
+
234
+ return { arrayBuffer, fileName, fileType, originalBytes: arrayBuffer.byteLength };
235
+ }
236
+
237
+ /* ============================================================
238
+ RENDER + COMPRESS ONE PAGE
239
+ ============================================================ */
240
+
241
+ async function processPage(pdf, pool, pageNumber, totalPages, { quality, resolution }) {
242
+ let page = null;
243
+ let canvas = null;
244
+ let bitmap = null;
245
+
246
+ try {
247
+ page = await pdf.getPage(pageNumber);
248
+
249
+ const baseViewport = page.getViewport({ scale: 1 });
250
+ const pdfWidth = baseViewport.width;
251
+ const pdfHeight = baseViewport.height;
252
+
253
+ const largestDimension = Math.max(pdfWidth, pdfHeight);
254
+
255
+ let scale = resolution / largestDimension;
256
+ scale = Math.max(scale, 0.25);
257
+ scale = Math.min(scale, 3);
258
+
259
+ const viewport = page.getViewport({ scale });
260
+ const width = Math.ceil(viewport.width);
261
+ const height = Math.ceil(viewport.height);
262
+
263
+ canvas = document.createElement("canvas");
264
+ canvas.width = width;
265
+ canvas.height = height;
266
+
267
+ const ctx = canvas.getContext("2d", {
268
+ alpha: false,
269
+ desynchronized: true,
270
+ willReadFrequently: false,
271
+ });
272
+
273
+ if (!ctx) {
274
+ throw new Error(`Canvas unavailable for page ${pageNumber}`);
275
+ }
276
+
277
+ ctx.fillStyle = "#ffffff";
278
+ ctx.fillRect(0, 0, width, height);
279
+
280
+ await page.render({
281
+ canvasContext: ctx,
282
+ viewport,
283
+ background: "white",
284
+ }).promise;
285
+
286
+ bitmap = await createImageBitmap(canvas);
287
+
288
+ canvas.width = 1;
289
+ canvas.height = 1;
290
+
291
+ const compressed = await pool.run(
292
+ {
293
+ type: "compress-image",
294
+ bitmap,
295
+ pageNumber,
296
+ totalPages,
297
+ quality,
298
+ pdfWidth,
299
+ pdfHeight,
300
+ },
301
+ [bitmap]
302
+ );
303
+
304
+ bitmap = null;
305
+
306
+ return compressed;
307
+ } finally {
308
+ if (canvas) {
309
+ try {
310
+ canvas.width = 1;
311
+ canvas.height = 1;
312
+ } catch {}
313
+ }
314
+
315
+ if (bitmap) {
316
+ try {
317
+ bitmap.close();
318
+ } catch {}
319
+ }
320
+
321
+ safePageCleanup(page);
322
+ }
323
+ }
324
+
325
+ /* ============================================================
326
+ PARALLEL PAGE PIPELINE
327
+ ============================================================ */
328
+
329
+ async function processPages(pdf, pool, totalPages, { quality, resolution, workers, onProgress }) {
330
+ const results = new Array(totalPages);
331
+
332
+ const renderConcurrency = Math.max(1, Math.min(workers, 4));
333
+
334
+ let nextPage = 1;
335
+ let completed = 0;
336
+
337
+ async function runner() {
338
+ while (true) {
339
+ const pageNumber = nextPage++;
340
+
341
+ if (pageNumber > totalPages) {
342
+ return;
343
+ }
344
+
345
+ const result = await processPage(pdf, pool, pageNumber, totalPages, {
346
+ quality,
347
+ resolution,
348
+ });
349
+
350
+ results[pageNumber - 1] = result;
351
+ completed++;
352
+
353
+ if (typeof onProgress === "function") {
354
+ onProgress({
355
+ stage: "compressing",
356
+ pageNumber,
357
+ totalPages,
358
+ completed,
359
+ progress: Math.round((completed / totalPages) * 90),
360
+ });
361
+ }
362
+ }
363
+ }
364
+
365
+ const runners = Math.min(renderConcurrency, totalPages);
366
+
367
+ await Promise.all(Array.from({ length: runners }, () => runner()));
368
+
369
+ return results;
370
+ }
371
+
372
+ /* ============================================================
373
+ BUILD FINAL PDF
374
+ ============================================================ */
375
+
376
+ async function buildPdf(compressedPages, { originalBytes, startedAt, workersUsed, onProgress }) {
377
+ if (typeof onProgress === "function") {
378
+ onProgress({ stage: "building", progress: 92 });
379
+ }
380
+
381
+ const outputPdf = await PDFDocument.create();
382
+
383
+ const perPage = [];
384
+
385
+ for (let i = 0; i < compressedPages.length; i++) {
386
+ const item = compressedPages[i];
387
+
388
+ if (!item) {
389
+ throw new Error(`Missing compressed page ${i + 1}`);
390
+ }
391
+
392
+ let jpegBytes;
393
+
394
+ if (item.jpeg instanceof ArrayBuffer) {
395
+ jpegBytes = new Uint8Array(item.jpeg);
396
+ } else if (item.jpeg instanceof Uint8Array) {
397
+ jpegBytes = item.jpeg;
398
+ } else if (ArrayBuffer.isView(item.jpeg)) {
399
+ jpegBytes = new Uint8Array(item.jpeg.buffer, item.jpeg.byteOffset, item.jpeg.byteLength);
400
+ } else {
401
+ throw new Error(`Invalid JPEG for page ${i + 1}`);
402
+ }
403
+
404
+ const image = await outputPdf.embedJpg(jpegBytes);
405
+
406
+ const page = outputPdf.addPage([item.pdfWidth, item.pdfHeight]);
407
+
408
+ page.drawImage(image, {
409
+ x: 0,
410
+ y: 0,
411
+ width: item.pdfWidth,
412
+ height: item.pdfHeight,
413
+ });
414
+
415
+ perPage.push({
416
+ pageNumber: i + 1,
417
+ renderedWidth: item.width,
418
+ renderedHeight: item.height,
419
+ jpegBytes: item.jpegBytes,
420
+ });
421
+
422
+ item.jpeg = null;
423
+
424
+ if (typeof onProgress === "function") {
425
+ onProgress({
426
+ stage: "building",
427
+ progress: 92 + Math.round(((i + 1) / compressedPages.length) * 7),
428
+ });
429
+ }
430
+ }
431
+
432
+ if (typeof onProgress === "function") {
433
+ onProgress({ stage: "finalizing", progress: 99 });
434
+ }
435
+
436
+ const pdfBytes = await outputPdf.save({
437
+ useObjectStreams: true,
438
+ addDefaultPage: false,
439
+ objectsPerTick: 50,
440
+ });
441
+
442
+ const blob = new Blob([pdfBytes], { type: "application/pdf" });
443
+
444
+ const compressedBytes = blob.size;
445
+ const savedBytes = Math.max(0, originalBytes - compressedBytes);
446
+ const reduction = originalBytes > 0 ? (savedBytes / originalBytes) * 100 : 0;
447
+ const elapsed = performance.now() - startedAt;
448
+
449
+ const stats = {
450
+ pages: compressedPages.length,
451
+ originalBytes,
452
+ compressedBytes,
453
+ savedBytes,
454
+ reduction,
455
+ elapsed,
456
+ workersUsed,
457
+ perPage,
458
+ };
459
+
460
+ if (typeof onProgress === "function") {
461
+ onProgress({ stage: "complete", progress: 100 });
462
+ }
463
+
464
+ return { blob, stats };
465
+ }
466
+
467
+ /* ============================================================
468
+ PUBLIC API
469
+ ============================================================ */
470
+
471
+ /**
472
+ * Compress a PDF using the same render -> mozjpeg WASM -> pdf-lib rebuild
473
+ * pipeline as the original app, run across a parallel worker pool.
474
+ *
475
+ * @param {File|Blob|ArrayBuffer|ArrayBufferView} input
476
+ * @param {Object} [options]
477
+ * @param {number} [options.quality=65] - JPEG quality, 0-100
478
+ * @param {number} [options.resolution=1600] - max px on the longest page side
479
+ * @param {number} [options.workers] - override auto-detected worker count
480
+ * @param {(update: object) => void} [options.onProgress] - optional progress callback
481
+ * @returns {Promise<{file: File|Blob, stats: object}>}
482
+ */
483
+ export async function compressPDF(input, options = {}) {
484
+ const { quality = 65, resolution = 1600, workers: workerOverride, onProgress } = options;
485
+
486
+ const startedAt = performance.now();
487
+
488
+ const { arrayBuffer, fileName, fileType, originalBytes } = await normalizeInput(input);
489
+
490
+ const power = getClientPower();
491
+ const workerCount = workerOverride || power.workers;
492
+
493
+ const pool = new CompressionPool();
494
+ pool.init(workerCount);
495
+
496
+ const loadingTask = pdfjsLib.getDocument({
497
+ data: new Uint8Array(arrayBuffer.slice(0)),
498
+ useSystemFonts: true,
499
+ isEvalSupported: true,
500
+ });
501
+
502
+ try {
503
+ const pdf = await loadingTask.promise;
504
+ const totalPages = pdf.numPages;
505
+
506
+ const compressedPages = await processPages(pdf, pool, totalPages, {
507
+ quality,
508
+ resolution,
509
+ workers: workerCount,
510
+ onProgress,
511
+ });
512
+
513
+ const { blob, stats } = await buildPdf(compressedPages, {
514
+ originalBytes,
515
+ startedAt,
516
+ workersUsed: workerCount,
517
+ onProgress,
518
+ });
519
+
520
+ const file =
521
+ typeof File !== "undefined"
522
+ ? new File([blob], fileName, { type: fileType })
523
+ : blob;
524
+
525
+ return { file, stats };
526
+ } finally {
527
+ try {
528
+ if (loadingTask && typeof loadingTask.destroy === "function") {
529
+ await loadingTask.destroy();
530
+ }
531
+ } catch (cleanupError) {
532
+ console.warn("compressPDF: loading task cleanup skipped:", cleanupError);
533
+ }
534
+
535
+ pool.destroy();
536
+ }
537
+ }
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export { compressPDF, getClientPower, CompressionPool } from "./compress.js";
@@ -0,0 +1,132 @@
1
+ /**
2
+ * pdf-compressor.worker.js
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
7
+ * the resulting JPEG bytes back to the main thread.
8
+ */
9
+
10
+ import { encode as encodeJpeg } from "@jsquash/jpeg";
11
+
12
+ self.onmessage = async (event) => {
13
+ const data = event.data;
14
+
15
+ if (data?.type !== "compress-image") {
16
+ return;
17
+ }
18
+
19
+ const {
20
+ jobId,
21
+ bitmap,
22
+ pageNumber,
23
+ totalPages,
24
+ quality,
25
+ pdfWidth,
26
+ pdfHeight,
27
+ } = data;
28
+
29
+ try {
30
+ if (!bitmap) {
31
+ throw new Error("ImageBitmap missing");
32
+ }
33
+
34
+ const width = bitmap.width;
35
+ const height = bitmap.height;
36
+
37
+ /* ================================================
38
+ OFFSCREEN CANVAS
39
+ ================================================ */
40
+
41
+ const canvas = new OffscreenCanvas(width, height);
42
+
43
+ const ctx = canvas.getContext("2d", {
44
+ alpha: false,
45
+ desynchronized: true,
46
+ });
47
+
48
+ if (!ctx) {
49
+ bitmap.close();
50
+ throw new Error("OffscreenCanvas unavailable");
51
+ }
52
+
53
+ ctx.fillStyle = "#ffffff";
54
+ ctx.fillRect(0, 0, width, height);
55
+
56
+ ctx.drawImage(bitmap, 0, 0, width, height);
57
+
58
+ bitmap.close();
59
+
60
+ /* ================================================
61
+ GET PIXELS
62
+ ================================================ */
63
+
64
+ const imageData = ctx.getImageData(0, 0, width, height);
65
+
66
+ /* ================================================
67
+ MOZJPEG WASM
68
+ ================================================ */
69
+
70
+ const encoded = await encodeJpeg(imageData, {
71
+ quality: Number(quality),
72
+ progressive: true,
73
+ optimize_coding: true,
74
+ });
75
+
76
+ /* ================================================
77
+ NORMALIZE ARRAYBUFFER
78
+ ================================================ */
79
+
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");
91
+ }
92
+
93
+ if (jpegBuffer.byteLength === 0) {
94
+ throw new Error("Empty JPEG");
95
+ }
96
+
97
+ /* ================================================
98
+ TRANSFER
99
+ ================================================ */
100
+
101
+ self.postMessage(
102
+ {
103
+ type: "image-complete",
104
+ jobId,
105
+ pageNumber,
106
+ totalPages,
107
+ jpeg: jpegBuffer,
108
+ jpegBytes: jpegBuffer.byteLength,
109
+ width,
110
+ height,
111
+ pdfWidth,
112
+ pdfHeight,
113
+ },
114
+ [jpegBuffer]
115
+ );
116
+
117
+ canvas.width = 1;
118
+ canvas.height = 1;
119
+ } catch (error) {
120
+ try {
121
+ bitmap?.close();
122
+ } catch {}
123
+
124
+ self.postMessage({
125
+ type: "error",
126
+ jobId,
127
+ pageNumber,
128
+ totalPages,
129
+ message: error?.message || String(error),
130
+ });
131
+ }
132
+ };