compress-pdf-lib 1.0.2 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compress-pdf-lib",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
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).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/compress.js CHANGED
@@ -1,28 +1,53 @@
1
1
  /**
2
2
  * compress.js
3
3
  *
4
- * Full, UI-less PDF compression library: worker-pool + parallel page
5
- * rendering + mozjpeg (WASM) encoding + pdf-lib rebuild.
4
+ * PDF compression library with two strategies:
6
5
  *
7
- * Pairs with pdf-compressor.worker.js (same folder).
6
+ * compressPDF(input, options) — RECOMMENDED. Extracts each embedded raster
7
+ * image from the PDF, decodes it, resizes
8
+ * and recompresses it on its own (via the
9
+ * worker pool + mozjpeg WASM), and writes
10
+ * the result back into the exact same PDF
11
+ * object slot. Text, fonts, and vector
12
+ * drawing are never touched, so text-based
13
+ * PDFs are not bloated by rasterization.
14
+ *
15
+ * rasterizePDF(input, options) — the older approach: renders every page to
16
+ * a full-page image and rebuilds the PDF
17
+ * from those images. Useful only for
18
+ * PDFs that are already just scans (one
19
+ * image per page) where per-image
20
+ * extraction wouldn't find anything.
21
+ *
22
+ * Both use the same CompressionPool / pdf-compressor.worker.js for the
23
+ * actual JPEG encoding.
8
24
  *
9
25
  * Usage:
10
26
  * import { compressPDF } from 'compress-pdf-lib';
11
27
  *
12
28
  * const { file, stats } = await compressPDF(pdfFile, {
13
- * quality: 70,
14
- * resolution: 1600,
29
+ * quality: 75,
30
+ * scale: 1, // 1 = keep each image's native pixel size, just recompress
15
31
  * });
16
32
  *
17
33
  * console.log(stats);
18
34
  * // {
19
- * // pages, originalBytes, compressedBytes, savedBytes,
20
- * // reduction, elapsed, workersUsed, perPage: [...]
35
+ * // pages, imagesFound, imagesCompressed, imagesSkipped,
36
+ * // originalBytes, compressedBytes, savedBytes, reduction, elapsed,
37
+ * // workersUsed, perImage: [...]
21
38
  * // }
22
39
  */
23
40
 
24
41
  import * as pdfjsLib from "pdfjs-dist";
25
- import { PDFDocument } from "pdf-lib";
42
+ import {
43
+ PDFDocument,
44
+ PDFName,
45
+ PDFRawStream,
46
+ PDFDict,
47
+ PDFArray,
48
+ PDFNumber,
49
+ decodePDFRawStream,
50
+ } from "pdf-lib";
26
51
  import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
27
52
 
28
53
  pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
@@ -191,7 +216,7 @@ export class CompressionPool {
191
216
  }
192
217
 
193
218
  /* ============================================================
194
- SAFE PDF PAGE CLEANUP
219
+ SHARED HELPERS
195
220
  ============================================================ */
196
221
 
197
222
  function safePageCleanup(page) {
@@ -204,10 +229,6 @@ function safePageCleanup(page) {
204
229
  }
205
230
  }
206
231
 
207
- /* ============================================================
208
- INPUT NORMALIZATION
209
- ============================================================ */
210
-
211
232
  async function normalizeInput(input) {
212
233
  let arrayBuffer;
213
234
  let fileName = "document.pdf";
@@ -234,11 +255,679 @@ async function normalizeInput(input) {
234
255
  return { arrayBuffer, fileName, fileType, originalBytes: arrayBuffer.byteLength };
235
256
  }
236
257
 
237
- /* ============================================================
238
- RENDER + COMPRESS ONE PAGE
239
- ============================================================ */
258
+ function jpegResultToBytes(item) {
259
+ if (item.jpeg instanceof ArrayBuffer) {
260
+ return new Uint8Array(item.jpeg);
261
+ }
262
+ if (item.jpeg instanceof Uint8Array) {
263
+ return item.jpeg;
264
+ }
265
+ if (ArrayBuffer.isView(item.jpeg)) {
266
+ return new Uint8Array(item.jpeg.buffer, item.jpeg.byteOffset, item.jpeg.byteLength);
267
+ }
268
+ throw new Error("Invalid JPEG encoder output");
269
+ }
270
+
271
+ /* ============================================================================
272
+ STRATEGY 1 (RECOMMENDED): EXTRACT EMBEDDED IMAGES, COMPRESS, REPLACE IN PLACE
273
+ ============================================================================ */
274
+
275
+ /*
276
+ * ---- PDF filter name plumbing --------------------------------------------
277
+ */
278
+
279
+ function filterNamesOf(dict) {
280
+ const filter = dict.get(PDFName.of("Filter"));
281
+
282
+ if (!filter) return [];
283
+
284
+ if (filter instanceof PDFName) {
285
+ return [filter.asString().replace(/^\//, "")];
286
+ }
287
+
288
+ if (filter instanceof PDFArray) {
289
+ return filter.asArray().map((f) => f.asString().replace(/^\//, ""));
290
+ }
291
+
292
+ return [];
293
+ }
294
+
295
+ const IMAGE_CODEC_FILTERS = new Set([
296
+ "DCTDecode",
297
+ "JPXDecode",
298
+ "CCITTFaxDecode",
299
+ "JBIG2Decode",
300
+ ]);
301
+
302
+ /*
303
+ * ---- ColorSpace resolution --------------------------------------------
304
+ * Returns { kind, components, palette?, paletteComponents? }
305
+ */
306
+
307
+ function resolveColorSpace(context, csObj, depth = 0) {
308
+ if (depth > 4 || !csObj) {
309
+ return { kind: "rgb", components: 3 };
310
+ }
311
+
312
+ const resolved = csObj instanceof PDFDict || csObj instanceof PDFArray || csObj instanceof PDFName
313
+ ? csObj
314
+ : context.lookupMaybe(csObj, PDFName) ||
315
+ context.lookupMaybe(csObj, PDFArray) ||
316
+ context.lookupMaybe(csObj, PDFDict) ||
317
+ csObj;
318
+
319
+ if (resolved instanceof PDFName) {
320
+ const name = resolved.asString().replace(/^\//, "");
321
+
322
+ if (name === "DeviceGray" || name === "CalGray" || name === "G") {
323
+ return { kind: "gray", components: 1 };
324
+ }
325
+ if (name === "DeviceCMYK" || name === "CMYK") {
326
+ return { kind: "cmyk", components: 4 };
327
+ }
328
+ // DeviceRGB, CalRGB, Lab (approximated as RGB), and anything unknown
329
+ return { kind: "rgb", components: 3 };
330
+ }
331
+
332
+ if (resolved instanceof PDFArray) {
333
+ const items = resolved.asArray();
334
+ const familyName = items[0] instanceof PDFName ? items[0].asString().replace(/^\//, "") : "";
335
+
336
+ if (familyName === "ICCBased") {
337
+ const stream = context.lookup(items[1]);
338
+ const n = stream?.dict?.get(PDFName.of("N"));
339
+ const components = n instanceof PDFNumber ? n.asNumber() : 3;
340
+
341
+ if (components === 1) return { kind: "gray", components: 1 };
342
+ if (components === 4) return { kind: "cmyk", components: 4 };
343
+ return { kind: "rgb", components: 3 };
344
+ }
345
+
346
+ if (familyName === "Indexed") {
347
+ const base = resolveColorSpace(context, items[1], depth + 1);
348
+ const lookupObj = context.lookup(items[2]) ?? items[2];
349
+
350
+ let paletteBytes;
351
+
352
+ if (lookupObj instanceof PDFRawStream || (lookupObj && lookupObj.dict && lookupObj.contents)) {
353
+ paletteBytes = decodePDFRawStream(lookupObj).decode();
354
+ } else if (lookupObj && typeof lookupObj.asBytes === "function") {
355
+ paletteBytes = lookupObj.asBytes();
356
+ } else if (lookupObj && typeof lookupObj.value === "string") {
357
+ paletteBytes = Uint8Array.from(lookupObj.value, (c) => c.charCodeAt(0));
358
+ } else {
359
+ paletteBytes = new Uint8Array(0);
360
+ }
361
+
362
+ return {
363
+ kind: "indexed",
364
+ components: 1,
365
+ palette: paletteBytes,
366
+ paletteComponents: base.components,
367
+ baseKind: base.kind,
368
+ };
369
+ }
370
+
371
+ if (familyName === "DeviceN" || familyName === "Separation") {
372
+ // Rare, hard to interpret correctly — fall back to gray-ish approximation
373
+ return { kind: "gray", components: 1 };
374
+ }
375
+
376
+ if (familyName === "CalRGB" || familyName === "Lab") {
377
+ return { kind: "rgb", components: 3 };
378
+ }
379
+
380
+ if (familyName === "CalGray") {
381
+ return { kind: "gray", components: 1 };
382
+ }
383
+ }
384
+
385
+ return { kind: "rgb", components: 3 };
386
+ }
387
+
388
+ /*
389
+ * ---- Raw sample bit-reader ------------------------------------------------
390
+ * PDF image rows are byte-aligned: each row starts on a new byte even if the
391
+ * previous row didn't end on one.
392
+ */
393
+
394
+ function readSamples(rawBytes, width, height, bitsPerComponent, numComponents) {
395
+ const bitsPerPixel = bitsPerComponent * numComponents;
396
+ const rowBytes = Math.ceil((bitsPerPixel * width) / 8);
397
+ const samples = new Uint16Array(width * height * numComponents);
398
+
399
+ const maxVal = (1 << bitsPerComponent) - 1;
400
+
401
+ let sampleIndex = 0;
402
+
403
+ for (let y = 0; y < height; y++) {
404
+ const rowStart = y * rowBytes;
405
+ let bitPos = 0;
406
+
407
+ for (let x = 0; x < width * numComponents; x++) {
408
+ if (bitsPerComponent === 8) {
409
+ samples[sampleIndex++] = rawBytes[rowStart + x] || 0;
410
+ } else if (bitsPerComponent === 16) {
411
+ const byteOffset = rowStart + x * 2;
412
+ samples[sampleIndex++] =
413
+ ((rawBytes[byteOffset] || 0) << 8) | (rawBytes[byteOffset + 1] || 0);
414
+ } else {
415
+ // 1, 2, or 4 bits per component
416
+ const byteIndex = rowStart + (bitPos >> 3);
417
+ const bitOffsetInByte = bitPos & 7;
418
+ const byte = rawBytes[byteIndex] || 0;
419
+
420
+ const shift = 8 - bitOffsetInByte - bitsPerComponent;
421
+ const value = (byte >> Math.max(shift, 0)) & maxVal;
422
+
423
+ samples[sampleIndex++] = value;
424
+ bitPos += bitsPerComponent;
425
+ }
426
+ }
427
+ }
428
+
429
+ return { samples, maxVal };
430
+ }
431
+
432
+ function scaleSample(value, maxVal) {
433
+ return maxVal === 255 ? value : Math.round((value / maxVal) * 255);
434
+ }
435
+
436
+ /*
437
+ * ---- Convert decoded samples -> RGBA Uint8ClampedArray --------------------
438
+ */
439
+
440
+ function samplesToRGBA(rawBytes, width, height, bitsPerComponent, colorSpaceInfo) {
441
+ const { kind, components } = colorSpaceInfo;
442
+ const { samples, maxVal } = readSamples(rawBytes, width, height, bitsPerComponent, components);
443
+
444
+ const rgba = new Uint8ClampedArray(width * height * 4);
445
+
446
+ for (let i = 0, s = 0; i < width * height; i++, s += components) {
447
+ let r, g, b;
448
+
449
+ if (kind === "gray") {
450
+ const v = scaleSample(samples[s], maxVal);
451
+ r = g = b = v;
452
+ } else if (kind === "cmyk") {
453
+ const c = samples[s] / maxVal;
454
+ const m = samples[s + 1] / maxVal;
455
+ const y = samples[s + 2] / maxVal;
456
+ const k = samples[s + 3] / maxVal;
457
+
458
+ r = 255 * (1 - c) * (1 - k);
459
+ g = 255 * (1 - m) * (1 - k);
460
+ b = 255 * (1 - y) * (1 - k);
461
+ } else if (kind === "indexed") {
462
+ const index = samples[s];
463
+ const { palette, paletteComponents, baseKind } = colorSpaceInfo;
464
+ const base = index * paletteComponents;
465
+
466
+ if (baseKind === "gray") {
467
+ r = g = b = palette[base] ?? 0;
468
+ } else if (baseKind === "cmyk") {
469
+ const c = (palette[base] ?? 0) / 255;
470
+ const m = (palette[base + 1] ?? 0) / 255;
471
+ const y = (palette[base + 2] ?? 0) / 255;
472
+ const k = (palette[base + 3] ?? 0) / 255;
473
+ r = 255 * (1 - c) * (1 - k);
474
+ g = 255 * (1 - m) * (1 - k);
475
+ b = 255 * (1 - y) * (1 - k);
476
+ } else {
477
+ r = palette[base] ?? 0;
478
+ g = palette[base + 1] ?? 0;
479
+ b = palette[base + 2] ?? 0;
480
+ }
481
+ } else {
482
+ // rgb
483
+ r = scaleSample(samples[s], maxVal);
484
+ g = scaleSample(samples[s + 1], maxVal);
485
+ b = scaleSample(samples[s + 2], maxVal);
486
+ }
487
+
488
+ const o = i * 4;
489
+ rgba[o] = r;
490
+ rgba[o + 1] = g;
491
+ rgba[o + 2] = b;
492
+ rgba[o + 3] = 255;
493
+ }
494
+
495
+ return rgba;
496
+ }
497
+
498
+ /*
499
+ * ---- Decode one PDF Image XObject into an ImageBitmap ---------------------
500
+ * Returns null if the image uses a codec we don't support (JPX/CCITT/JBIG2),
501
+ * is a stencil mask, or otherwise can't be safely handled.
502
+ */
503
+
504
+ async function decodeImageXObject(context, ref) {
505
+ const stream = context.lookup(ref);
506
+
507
+ if (!stream || !(stream instanceof PDFRawStream)) return null;
508
+
509
+ const dict = stream.dict;
510
+
511
+ const subtype = dict.get(PDFName.of("Subtype"));
512
+ if (!subtype || subtype.asString().replace(/^\//, "") !== "Image") return null;
513
+
514
+ const isMask = dict.get(PDFName.of("ImageMask"));
515
+ if (isMask && isMask.constructor?.name === "PDFBool" && isMask.asBoolean?.()) return null;
516
+
517
+ // Color-key / stencil Mask (not SMask) changes meaning based on exact pixel
518
+ // values — recompressing would break it, so skip these entirely.
519
+ if (dict.get(PDFName.of("Mask"))) return null;
520
+
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 };
526
+ }
527
+
528
+ const width = dict.get(PDFName.of("Width"))?.asNumber?.();
529
+ const height = dict.get(PDFName.of("Height"))?.asNumber?.();
530
+
531
+ if (!width || !height) return null;
532
+
533
+ const originalBytes = stream.contents.length;
534
+
535
+ let bitmap;
536
+ let hasAlpha = false;
537
+
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();
542
+ const blob = new Blob([jpegBytes], { type: "image/jpeg" });
543
+ bitmap = await createImageBitmap(blob);
544
+ } else {
545
+ // Raw samples (typically FlateDecode) — decode manually.
546
+ const bitsPerComponent = dict.get(PDFName.of("BitsPerComponent"))?.asNumber?.() || 8;
547
+ const colorSpaceObj = dict.get(PDFName.of("ColorSpace"));
548
+ const colorSpaceInfo = resolveColorSpace(context, colorSpaceObj);
549
+
550
+ const rawBytes = decodePDFRawStream(stream).decode();
551
+ const rgba = samplesToRGBA(rawBytes, width, height, bitsPerComponent, colorSpaceInfo);
552
+
553
+ const imageData = new ImageData(rgba, width, height);
554
+ bitmap = await createImageBitmap(imageData);
555
+ }
556
+
557
+ // Soft mask (alpha channel) — decode and merge in.
558
+ const smaskRef = dict.get(PDFName.of("SMask"));
559
+
560
+ if (smaskRef) {
561
+ try {
562
+ const smaskDecoded = await decodeImageXObject(context, smaskRef);
563
+
564
+ if (smaskDecoded && !smaskDecoded.unsupported && smaskDecoded.bitmap) {
565
+ hasAlpha = true;
566
+
567
+ const canvas = new OffscreenCanvas(width, height);
568
+ const ctx = canvas.getContext("2d");
569
+
570
+ // Draw color image
571
+ ctx.drawImage(bitmap, 0, 0, width, height);
572
+ const base = ctx.getImageData(0, 0, width, height);
573
+
574
+ // Draw alpha mask resized to match
575
+ const alphaCanvas = new OffscreenCanvas(width, height);
576
+ const alphaCtx = alphaCanvas.getContext("2d");
577
+ alphaCtx.drawImage(smaskDecoded.bitmap, 0, 0, width, height);
578
+ const alphaData = alphaCtx.getImageData(0, 0, width, height);
579
+
580
+ for (let i = 0; i < width * height; i++) {
581
+ base.data[i * 4 + 3] = alphaData.data[i * 4]; // gray channel -> alpha
582
+ }
583
+
584
+ bitmap.close();
585
+ bitmap = await createImageBitmap(base);
586
+ }
587
+ } catch (smaskError) {
588
+ console.warn("compressPDF: SMask decode failed, ignoring alpha:", smaskError);
589
+ }
590
+ }
591
+
592
+ return { bitmap, width, height, hasAlpha, originalBytes };
593
+ }
594
+
595
+ /*
596
+ * ---- Deflate (zlib) bytes using the native Compression Streams API --------
597
+ */
240
598
 
241
- async function processPage(pdf, pool, pageNumber, totalPages, { quality, resolution, scale: fixedScale }) {
599
+ async function deflateBytes(bytes) {
600
+ if (typeof CompressionStream === "undefined") {
601
+ throw new Error("CompressionStream API unavailable — cannot write alpha/PNG-style images");
602
+ }
603
+
604
+ const cs = new CompressionStream("deflate");
605
+ const writer = cs.writable.getWriter();
606
+ writer.write(bytes);
607
+ writer.close();
608
+
609
+ const buffer = await new Response(cs.readable).arrayBuffer();
610
+ return new Uint8Array(buffer);
611
+ }
612
+
613
+ /*
614
+ * ---- Replace one image object in place ------------------------------------
615
+ */
616
+
617
+ async function replaceImageWithJpeg(context, ref, jpegBytes, width, height) {
618
+ const dict = context.obj({
619
+ Type: "XObject",
620
+ Subtype: "Image",
621
+ Width: width,
622
+ Height: height,
623
+ ColorSpace: "DeviceRGB",
624
+ BitsPerComponent: 8,
625
+ Filter: "DCTDecode",
626
+ });
627
+
628
+ context.assign(ref, PDFRawStream.of(dict, jpegBytes));
629
+ }
630
+
631
+ async function replaceImageWithFlateRGBA(context, ref, rgbaBitmap, width, height) {
632
+ const canvas = new OffscreenCanvas(width, height);
633
+ const ctx = canvas.getContext("2d");
634
+ ctx.drawImage(rgbaBitmap, 0, 0, width, height);
635
+ const imageData = ctx.getImageData(0, 0, width, height);
636
+
637
+ const rgbBytes = new Uint8Array(width * height * 3);
638
+ const alphaBytes = new Uint8Array(width * height);
639
+
640
+ for (let i = 0; i < width * height; i++) {
641
+ rgbBytes[i * 3] = imageData.data[i * 4];
642
+ rgbBytes[i * 3 + 1] = imageData.data[i * 4 + 1];
643
+ rgbBytes[i * 3 + 2] = imageData.data[i * 4 + 2];
644
+ alphaBytes[i] = imageData.data[i * 4 + 3];
645
+ }
646
+
647
+ const [rgbDeflated, alphaDeflated] = await Promise.all([
648
+ deflateBytes(rgbBytes),
649
+ deflateBytes(alphaBytes),
650
+ ]);
651
+
652
+ const smaskDict = context.obj({
653
+ Type: "XObject",
654
+ Subtype: "Image",
655
+ Width: width,
656
+ Height: height,
657
+ ColorSpace: "DeviceGray",
658
+ BitsPerComponent: 8,
659
+ Filter: "FlateDecode",
660
+ });
661
+
662
+ const smaskRef = context.register(PDFRawStream.of(smaskDict, alphaDeflated));
663
+
664
+ const mainDict = context.obj({
665
+ Type: "XObject",
666
+ Subtype: "Image",
667
+ Width: width,
668
+ Height: height,
669
+ ColorSpace: "DeviceRGB",
670
+ BitsPerComponent: 8,
671
+ Filter: "FlateDecode",
672
+ SMask: smaskRef,
673
+ });
674
+
675
+ context.assign(ref, PDFRawStream.of(mainDict, rgbDeflated));
676
+
677
+ return rgbDeflated.length + alphaDeflated.length;
678
+ }
679
+
680
+ /*
681
+ * ---- Find every Image XObject in the document ----------------------------
682
+ */
683
+
684
+ function findImageRefs(pdfDoc) {
685
+ const refs = [];
686
+
687
+ for (const [ref, obj] of pdfDoc.context.enumerateIndirectObjects()) {
688
+ if (!(obj instanceof PDFRawStream)) continue;
689
+
690
+ const subtype = obj.dict.get(PDFName.of("Subtype"));
691
+ if (subtype && subtype.asString?.().replace(/^\//, "") === "Image") {
692
+ refs.push(ref);
693
+ }
694
+ }
695
+
696
+ return refs;
697
+ }
698
+
699
+ /*
700
+ * ---- compressPDF: the public, recommended API -----------------------------
701
+ */
702
+
703
+ /**
704
+ * Compress a PDF by extracting each embedded raster image, resizing and
705
+ * recompressing it individually (via a parallel worker pool running mozjpeg
706
+ * WASM), and writing the result back into the same PDF object slot. Text,
707
+ * fonts, and vector drawing instructions are left completely untouched.
708
+ *
709
+ * @param {File|Blob|ArrayBuffer|ArrayBufferView} input
710
+ * @param {Object} [options]
711
+ * @param {number} [options.quality=75] - JPEG quality, 0-100 (only applies to
712
+ * opaque images; images with transparency are re-encoded losslessly as
713
+ * Flate-compressed raw pixels + an SMask, since JPEG has no alpha channel)
714
+ * @param {number} [options.scale=1] - resize factor applied to each image's
715
+ * OWN native pixel dimensions (not the page). 1 = keep native size and only
716
+ * recompress; 0.5 = half width/height; etc. Clamped to 0.05–1.
717
+ * @param {number} [options.minImageBytes=2048] - skip images already smaller
718
+ * than this (not worth the re-encode overhead)
719
+ * @param {boolean} [options.onlyIfSmaller=true] - keep the original image
720
+ * bytes if the recompressed version would end up bigger
721
+ * @param {number} [options.workers] - override auto-detected worker count
722
+ * @param {(update: object) => void} [options.onProgress] - progress callback
723
+ * @returns {Promise<{file: File|Blob, stats: object}>}
724
+ */
725
+ export async function compressPDF(input, options = {}) {
726
+ const {
727
+ quality = 75,
728
+ scale = 1,
729
+ minImageBytes = 2048,
730
+ onlyIfSmaller = true,
731
+ workers: workerOverride,
732
+ onProgress,
733
+ } = options;
734
+
735
+ const clampedScale = Math.max(0.05, Math.min(scale, 1));
736
+
737
+ const startedAt = performance.now();
738
+
739
+ const { arrayBuffer, fileName, fileType, originalBytes } = await normalizeInput(input);
740
+
741
+ const pdfDoc = await PDFDocument.load(arrayBuffer, {
742
+ updateMetadata: false,
743
+ ignoreEncryption: true,
744
+ });
745
+
746
+ const context = pdfDoc.context;
747
+ const imageRefs = findImageRefs(pdfDoc);
748
+
749
+ const power = getClientPower();
750
+ const workerCount = workerOverride || power.workers;
751
+
752
+ const pool = new CompressionPool();
753
+ pool.init(workerCount);
754
+
755
+ const perImage = [];
756
+ let imagesCompressed = 0;
757
+ let imagesSkipped = 0;
758
+
759
+ try {
760
+ let nextIndex = 0;
761
+ let completed = 0;
762
+
763
+ async function runner() {
764
+ while (true) {
765
+ const index = nextIndex++;
766
+ if (index >= imageRefs.length) return;
767
+
768
+ const ref = imageRefs[index];
769
+
770
+ let entry = {
771
+ ref: ref.toString(),
772
+ skipped: null,
773
+ };
774
+
775
+ try {
776
+ const decoded = await decodeImageXObject(context, ref);
777
+
778
+ if (!decoded) {
779
+ entry.skipped = "unreadable-or-stencil";
780
+ imagesSkipped++;
781
+ } else if (decoded.unsupported) {
782
+ entry.skipped = `unsupported-codec:${decoded.reason}`;
783
+ imagesSkipped++;
784
+ } else if (decoded.originalBytes < minImageBytes) {
785
+ decoded.bitmap.close();
786
+ entry.skipped = "below-min-size";
787
+ entry.originalBytes = decoded.originalBytes;
788
+ imagesSkipped++;
789
+ } else {
790
+ const { bitmap, width, height, hasAlpha, originalBytes: origImgBytes } = decoded;
791
+
792
+ const newWidth = Math.max(1, Math.round(width * clampedScale));
793
+ const newHeight = Math.max(1, Math.round(height * clampedScale));
794
+
795
+ entry.width = width;
796
+ entry.height = height;
797
+ entry.newWidth = newWidth;
798
+ entry.newHeight = newHeight;
799
+ entry.originalBytes = origImgBytes;
800
+
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
+ if (hasAlpha) {
812
+ const newBytes = await replaceImageWithFlateRGBA(
813
+ context,
814
+ ref,
815
+ resizedBitmap,
816
+ newWidth,
817
+ newHeight
818
+ );
819
+
820
+ resizedBitmap.close();
821
+
822
+ entry.format = "flate+smask";
823
+ entry.compressedBytes = newBytes;
824
+ imagesCompressed++;
825
+ } 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]
835
+ );
836
+
837
+ const jpegBytes = jpegResultToBytes(compressed);
838
+
839
+ if (onlyIfSmaller && jpegBytes.length >= origImgBytes) {
840
+ entry.skipped = "recompressed-not-smaller";
841
+ entry.compressedBytes = origImgBytes;
842
+ imagesSkipped++;
843
+ } else {
844
+ await replaceImageWithJpeg(context, ref, jpegBytes, newWidth, newHeight);
845
+ entry.format = "jpeg";
846
+ entry.compressedBytes = jpegBytes.length;
847
+ imagesCompressed++;
848
+ }
849
+ }
850
+ }
851
+ } catch (error) {
852
+ console.warn(`compressPDF: skipping image (${ref.toString()}):`, error);
853
+ entry.skipped = "error";
854
+ entry.error = error?.message || String(error);
855
+ imagesSkipped++;
856
+ }
857
+
858
+ perImage.push(entry);
859
+ completed++;
860
+
861
+ if (typeof onProgress === "function") {
862
+ onProgress({
863
+ stage: "compressing",
864
+ imageIndex: index + 1,
865
+ totalImages: imageRefs.length,
866
+ completed,
867
+ progress: imageRefs.length
868
+ ? Math.round((completed / imageRefs.length) * 90)
869
+ : 90,
870
+ });
871
+ }
872
+ }
873
+ }
874
+
875
+ const concurrency = Math.max(1, Math.min(workerCount, 4));
876
+ const runnerCount = Math.min(concurrency, Math.max(imageRefs.length, 1));
877
+
878
+ await Promise.all(Array.from({ length: runnerCount }, () => runner()));
879
+
880
+ if (typeof onProgress === "function") {
881
+ onProgress({ stage: "saving", progress: 95 });
882
+ }
883
+
884
+ const pdfBytes = await pdfDoc.save({
885
+ useObjectStreams: true,
886
+ addDefaultPage: false,
887
+ });
888
+
889
+ const blob = new Blob([pdfBytes], { type: "application/pdf" });
890
+ const compressedBytes = blob.size;
891
+ const savedBytes = Math.max(0, originalBytes - compressedBytes);
892
+ const reduction = originalBytes > 0 ? (savedBytes / originalBytes) * 100 : 0;
893
+ const elapsed = performance.now() - startedAt;
894
+
895
+ const stats = {
896
+ pages: pdfDoc.getPageCount(),
897
+ imagesFound: imageRefs.length,
898
+ imagesCompressed,
899
+ imagesSkipped,
900
+ originalBytes,
901
+ compressedBytes,
902
+ savedBytes,
903
+ reduction,
904
+ elapsed,
905
+ workersUsed: workerCount,
906
+ perImage,
907
+ };
908
+
909
+ if (typeof onProgress === "function") {
910
+ onProgress({ stage: "complete", progress: 100 });
911
+ }
912
+
913
+ const file =
914
+ typeof File !== "undefined"
915
+ ? new File([blob], fileName, { type: fileType })
916
+ : blob;
917
+
918
+ return { file, stats };
919
+ } finally {
920
+ pool.destroy();
921
+ }
922
+ }
923
+
924
+ /* ============================================================================
925
+ STRATEGY 2 (LEGACY): FULL-PAGE RASTERIZATION
926
+ Kept for cases where the whole page really is a single scanned image and
927
+ there's nothing for compressPDF's per-image extraction to find separately.
928
+ ============================================================================ */
929
+
930
+ async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality, resolution, scale: fixedScale }) {
242
931
  let page = null;
243
932
  let canvas = null;
244
933
  let bitmap = null;
@@ -255,19 +944,8 @@ async function processPage(pdf, pool, pageNumber, totalPages, { quality, resolut
255
944
  let scale;
256
945
 
257
946
  if (typeof fixedScale === "number") {
258
- /*
259
- * Direct scale multiplier — same units as pdf.js's own
260
- * page.getViewport({ scale }). 1 = native ~72 DPI, 2 = ~144 DPI,
261
- * 3 = ~216 DPI (max allowed). Proportional across any page size,
262
- * unlike a fixed pixel target.
263
- */
264
947
  scale = Math.max(0.25, Math.min(fixedScale, 3));
265
948
  } else if (resolution === "original" || resolution === Infinity) {
266
- /*
267
- * No downscaling — render at the max allowed multiplier so page
268
- * quality is limited only by the JPEG quality setting, not by
269
- * resizing.
270
- */
271
949
  scale = 3;
272
950
  } else {
273
951
  scale = resolution / largestDimension;
@@ -341,11 +1019,7 @@ async function processPage(pdf, pool, pageNumber, totalPages, { quality, resolut
341
1019
  }
342
1020
  }
343
1021
 
344
- /* ============================================================
345
- PARALLEL PAGE PIPELINE
346
- ============================================================ */
347
-
348
- async function processPages(pdf, pool, totalPages, { quality, resolution, scale, workers, onProgress }) {
1022
+ async function rasterizeProcessPages(pdf, pool, totalPages, { quality, resolution, scale, workers, onProgress }) {
349
1023
  const results = new Array(totalPages);
350
1024
 
351
1025
  const renderConcurrency = Math.max(1, Math.min(workers, 4));
@@ -361,7 +1035,7 @@ async function processPages(pdf, pool, totalPages, { quality, resolution, scale,
361
1035
  return;
362
1036
  }
363
1037
 
364
- const result = await processPage(pdf, pool, pageNumber, totalPages, {
1038
+ const result = await rasterizeProcessPage(pdf, pool, pageNumber, totalPages, {
365
1039
  quality,
366
1040
  resolution,
367
1041
  scale,
@@ -389,11 +1063,7 @@ async function processPages(pdf, pool, totalPages, { quality, resolution, scale,
389
1063
  return results;
390
1064
  }
391
1065
 
392
- /* ============================================================
393
- BUILD FINAL PDF
394
- ============================================================ */
395
-
396
- async function buildPdf(compressedPages, { originalBytes, startedAt, workersUsed, onProgress }) {
1066
+ async function rasterizeBuildPdf(compressedPages, { originalBytes, startedAt, workersUsed, onProgress }) {
397
1067
  if (typeof onProgress === "function") {
398
1068
  onProgress({ stage: "building", progress: 92 });
399
1069
  }
@@ -409,17 +1079,7 @@ async function buildPdf(compressedPages, { originalBytes, startedAt, workersUsed
409
1079
  throw new Error(`Missing compressed page ${i + 1}`);
410
1080
  }
411
1081
 
412
- let jpegBytes;
413
-
414
- if (item.jpeg instanceof ArrayBuffer) {
415
- jpegBytes = new Uint8Array(item.jpeg);
416
- } else if (item.jpeg instanceof Uint8Array) {
417
- jpegBytes = item.jpeg;
418
- } else if (ArrayBuffer.isView(item.jpeg)) {
419
- jpegBytes = new Uint8Array(item.jpeg.buffer, item.jpeg.byteOffset, item.jpeg.byteLength);
420
- } else {
421
- throw new Error(`Invalid JPEG for page ${i + 1}`);
422
- }
1082
+ const jpegBytes = jpegResultToBytes(item);
423
1083
 
424
1084
  const image = await outputPdf.embedJpg(jpegBytes);
425
1085
 
@@ -484,31 +1144,19 @@ async function buildPdf(compressedPages, { originalBytes, startedAt, workersUsed
484
1144
  return { blob, stats };
485
1145
  }
486
1146
 
487
- /* ============================================================
488
- PUBLIC API
489
- ============================================================ */
490
-
491
1147
  /**
492
- * Compress a PDF using the same render -> mozjpeg WASM -> pdf-lib rebuild
493
- * pipeline as the original app, run across a parallel worker pool.
1148
+ * Legacy full-page rasterization strategy. Renders every page to a bitmap and
1149
+ * rebuilds the PDF from those images this DOES discard text/vector content
1150
+ * in favor of pictures of it, so prefer compressPDF() unless you specifically
1151
+ * want this (e.g. flattening a document, or it's already scan-only).
1152
+ *
1153
+ * Same options as before: quality, resolution ("original" to skip
1154
+ * downscaling), scale (direct render multiplier, overrides resolution),
1155
+ * workers, onProgress.
494
1156
  *
495
- * @param {File|Blob|ArrayBuffer|ArrayBufferView} input
496
- * @param {Object} [options]
497
- * @param {number} [options.quality=65] - JPEG quality, 0-100
498
- * @param {number|"original"} [options.resolution=1600] - max px on the longest
499
- * page side. Pass "original" (or Infinity) to skip downscaling entirely and
500
- * rely on `quality` alone — renders at the max supported multiplier (3x).
501
- * Ignored if `scale` is set.
502
- * @param {number} [options.scale] - direct render-scale multiplier (same units
503
- * as pdf.js's `page.getViewport({ scale })`), clamped to 0.25–3.
504
- * 1 = native ~72 DPI, 2 ≈ 144 DPI, 3 ≈ 216 DPI (max). Proportional across
505
- * any page size, unlike `resolution`'s fixed pixel target. Takes
506
- * precedence over `resolution` when provided.
507
- * @param {number} [options.workers] - override auto-detected worker count
508
- * @param {(update: object) => void} [options.onProgress] - optional progress callback
509
1157
  * @returns {Promise<{file: File|Blob, stats: object}>}
510
1158
  */
511
- export async function compressPDF(input, options = {}) {
1159
+ export async function rasterizePDF(input, options = {}) {
512
1160
  const {
513
1161
  quality = 65,
514
1162
  resolution = 1600,
@@ -537,7 +1185,7 @@ export async function compressPDF(input, options = {}) {
537
1185
  const pdf = await loadingTask.promise;
538
1186
  const totalPages = pdf.numPages;
539
1187
 
540
- const compressedPages = await processPages(pdf, pool, totalPages, {
1188
+ const compressedPages = await rasterizeProcessPages(pdf, pool, totalPages, {
541
1189
  quality,
542
1190
  resolution,
543
1191
  scale,
@@ -545,7 +1193,7 @@ export async function compressPDF(input, options = {}) {
545
1193
  onProgress,
546
1194
  });
547
1195
 
548
- const { blob, stats } = await buildPdf(compressedPages, {
1196
+ const { blob, stats } = await rasterizeBuildPdf(compressedPages, {
549
1197
  originalBytes,
550
1198
  startedAt,
551
1199
  workersUsed: workerCount,
@@ -564,7 +1212,7 @@ export async function compressPDF(input, options = {}) {
564
1212
  await loadingTask.destroy();
565
1213
  }
566
1214
  } catch (cleanupError) {
567
- console.warn("compressPDF: loading task cleanup skipped:", cleanupError);
1215
+ console.warn("rasterizePDF: loading task cleanup skipped:", cleanupError);
568
1216
  }
569
1217
 
570
1218
  pool.destroy();
package/src/index.js CHANGED
@@ -1 +1,6 @@
1
- export { compressPDF, getClientPower, CompressionPool } from "./compress.js";
1
+ export {
2
+ compressPDF,
3
+ rasterizePDF,
4
+ getClientPower,
5
+ CompressionPool,
6
+ } from "./compress.js";