compress-pdf-lib 1.0.2 → 1.0.4
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 +1 -1
- package/src/compress.js +745 -75
- package/src/index.js +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "compress-pdf-lib",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
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
|
-
*
|
|
5
|
-
* rendering + mozjpeg (WASM) encoding + pdf-lib rebuild.
|
|
4
|
+
* PDF compression library with two strategies:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
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:
|
|
14
|
-
*
|
|
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,
|
|
20
|
-
* //
|
|
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 {
|
|
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
|
-
|
|
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,701 @@ async function normalizeInput(input) {
|
|
|
234
255
|
return { arrayBuffer, fileName, fileType, originalBytes: arrayBuffer.byteLength };
|
|
235
256
|
}
|
|
236
257
|
|
|
237
|
-
|
|
238
|
-
|
|
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(context, dict) {
|
|
280
|
+
const filter = context.lookup(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) => {
|
|
290
|
+
const resolved = context.lookup(f);
|
|
291
|
+
return resolved instanceof PDFName ? resolved.asString().replace(/^\//, "") : "";
|
|
292
|
+
}).filter(Boolean);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/*
|
|
299
|
+
* ---- ColorSpace resolution --------------------------------------------
|
|
300
|
+
* Returns { kind, components, palette?, paletteComponents? }
|
|
301
|
+
*/
|
|
302
|
+
|
|
303
|
+
function resolveColorSpace(context, csObj, depth = 0) {
|
|
304
|
+
if (depth > 4 || !csObj) {
|
|
305
|
+
return { kind: "rgb", components: 3 };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const resolved = csObj instanceof PDFDict || csObj instanceof PDFArray || csObj instanceof PDFName
|
|
309
|
+
? csObj
|
|
310
|
+
: context.lookupMaybe(csObj, PDFName) ||
|
|
311
|
+
context.lookupMaybe(csObj, PDFArray) ||
|
|
312
|
+
context.lookupMaybe(csObj, PDFDict) ||
|
|
313
|
+
csObj;
|
|
314
|
+
|
|
315
|
+
if (resolved instanceof PDFName) {
|
|
316
|
+
const name = resolved.asString().replace(/^\//, "");
|
|
317
|
+
|
|
318
|
+
if (name === "DeviceGray" || name === "CalGray" || name === "G") {
|
|
319
|
+
return { kind: "gray", components: 1 };
|
|
320
|
+
}
|
|
321
|
+
if (name === "DeviceCMYK" || name === "CMYK") {
|
|
322
|
+
return { kind: "cmyk", components: 4 };
|
|
323
|
+
}
|
|
324
|
+
// DeviceRGB, CalRGB, Lab (approximated as RGB), and anything unknown
|
|
325
|
+
return { kind: "rgb", components: 3 };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (resolved instanceof PDFArray) {
|
|
329
|
+
const items = resolved.asArray();
|
|
330
|
+
const familyName = items[0] instanceof PDFName ? items[0].asString().replace(/^\//, "") : "";
|
|
331
|
+
|
|
332
|
+
if (familyName === "ICCBased") {
|
|
333
|
+
const stream = context.lookup(items[1]);
|
|
334
|
+
const n = stream?.dict?.get(PDFName.of("N"));
|
|
335
|
+
const components = n instanceof PDFNumber ? n.asNumber() : 3;
|
|
336
|
+
|
|
337
|
+
if (components === 1) return { kind: "gray", components: 1 };
|
|
338
|
+
if (components === 4) return { kind: "cmyk", components: 4 };
|
|
339
|
+
return { kind: "rgb", components: 3 };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (familyName === "Indexed") {
|
|
343
|
+
const base = resolveColorSpace(context, items[1], depth + 1);
|
|
344
|
+
const lookupObj = context.lookup(items[2]) ?? items[2];
|
|
345
|
+
|
|
346
|
+
let paletteBytes;
|
|
347
|
+
|
|
348
|
+
if (lookupObj instanceof PDFRawStream || (lookupObj && lookupObj.dict && lookupObj.contents)) {
|
|
349
|
+
paletteBytes = decodePDFRawStream(lookupObj).decode();
|
|
350
|
+
} else if (lookupObj && typeof lookupObj.asBytes === "function") {
|
|
351
|
+
paletteBytes = lookupObj.asBytes();
|
|
352
|
+
} else if (lookupObj && typeof lookupObj.value === "string") {
|
|
353
|
+
paletteBytes = Uint8Array.from(lookupObj.value, (c) => c.charCodeAt(0));
|
|
354
|
+
} else {
|
|
355
|
+
paletteBytes = new Uint8Array(0);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
kind: "indexed",
|
|
360
|
+
components: 1,
|
|
361
|
+
palette: paletteBytes,
|
|
362
|
+
paletteComponents: base.components,
|
|
363
|
+
baseKind: base.kind,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (familyName === "DeviceN" || familyName === "Separation") {
|
|
368
|
+
// Rare, hard to interpret correctly — fall back to gray-ish approximation
|
|
369
|
+
return { kind: "gray", components: 1 };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (familyName === "CalRGB" || familyName === "Lab") {
|
|
373
|
+
return { kind: "rgb", components: 3 };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (familyName === "CalGray") {
|
|
377
|
+
return { kind: "gray", components: 1 };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return { kind: "rgb", components: 3 };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/*
|
|
385
|
+
* ---- Raw sample bit-reader ------------------------------------------------
|
|
386
|
+
* PDF image rows are byte-aligned: each row starts on a new byte even if the
|
|
387
|
+
* previous row didn't end on one.
|
|
388
|
+
*/
|
|
389
|
+
|
|
390
|
+
function readSamples(rawBytes, width, height, bitsPerComponent, numComponents) {
|
|
391
|
+
const bitsPerPixel = bitsPerComponent * numComponents;
|
|
392
|
+
const rowBytes = Math.ceil((bitsPerPixel * width) / 8);
|
|
393
|
+
const samples = new Uint16Array(width * height * numComponents);
|
|
394
|
+
|
|
395
|
+
const maxVal = (1 << bitsPerComponent) - 1;
|
|
396
|
+
|
|
397
|
+
let sampleIndex = 0;
|
|
398
|
+
|
|
399
|
+
for (let y = 0; y < height; y++) {
|
|
400
|
+
const rowStart = y * rowBytes;
|
|
401
|
+
let bitPos = 0;
|
|
402
|
+
|
|
403
|
+
for (let x = 0; x < width * numComponents; x++) {
|
|
404
|
+
if (bitsPerComponent === 8) {
|
|
405
|
+
samples[sampleIndex++] = rawBytes[rowStart + x] || 0;
|
|
406
|
+
} else if (bitsPerComponent === 16) {
|
|
407
|
+
const byteOffset = rowStart + x * 2;
|
|
408
|
+
samples[sampleIndex++] =
|
|
409
|
+
((rawBytes[byteOffset] || 0) << 8) | (rawBytes[byteOffset + 1] || 0);
|
|
410
|
+
} else {
|
|
411
|
+
// 1, 2, or 4 bits per component
|
|
412
|
+
const byteIndex = rowStart + (bitPos >> 3);
|
|
413
|
+
const bitOffsetInByte = bitPos & 7;
|
|
414
|
+
const byte = rawBytes[byteIndex] || 0;
|
|
415
|
+
|
|
416
|
+
const shift = 8 - bitOffsetInByte - bitsPerComponent;
|
|
417
|
+
const value = (byte >> Math.max(shift, 0)) & maxVal;
|
|
418
|
+
|
|
419
|
+
samples[sampleIndex++] = value;
|
|
420
|
+
bitPos += bitsPerComponent;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return { samples, maxVal };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function scaleSample(value, maxVal) {
|
|
429
|
+
return maxVal === 255 ? value : Math.round((value / maxVal) * 255);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/*
|
|
433
|
+
* ---- Convert decoded samples -> RGBA Uint8ClampedArray --------------------
|
|
434
|
+
*/
|
|
435
|
+
|
|
436
|
+
function samplesToRGBA(rawBytes, width, height, bitsPerComponent, colorSpaceInfo) {
|
|
437
|
+
const { kind, components } = colorSpaceInfo;
|
|
438
|
+
const { samples, maxVal } = readSamples(rawBytes, width, height, bitsPerComponent, components);
|
|
439
|
+
|
|
440
|
+
const rgba = new Uint8ClampedArray(width * height * 4);
|
|
441
|
+
|
|
442
|
+
for (let i = 0, s = 0; i < width * height; i++, s += components) {
|
|
443
|
+
let r, g, b;
|
|
444
|
+
|
|
445
|
+
if (kind === "gray") {
|
|
446
|
+
const v = scaleSample(samples[s], maxVal);
|
|
447
|
+
r = g = b = v;
|
|
448
|
+
} else if (kind === "cmyk") {
|
|
449
|
+
const c = samples[s] / maxVal;
|
|
450
|
+
const m = samples[s + 1] / maxVal;
|
|
451
|
+
const y = samples[s + 2] / maxVal;
|
|
452
|
+
const k = samples[s + 3] / maxVal;
|
|
453
|
+
|
|
454
|
+
r = 255 * (1 - c) * (1 - k);
|
|
455
|
+
g = 255 * (1 - m) * (1 - k);
|
|
456
|
+
b = 255 * (1 - y) * (1 - k);
|
|
457
|
+
} else if (kind === "indexed") {
|
|
458
|
+
const index = samples[s];
|
|
459
|
+
const { palette, paletteComponents, baseKind } = colorSpaceInfo;
|
|
460
|
+
const base = index * paletteComponents;
|
|
461
|
+
|
|
462
|
+
if (baseKind === "gray") {
|
|
463
|
+
r = g = b = palette[base] ?? 0;
|
|
464
|
+
} else if (baseKind === "cmyk") {
|
|
465
|
+
const c = (palette[base] ?? 0) / 255;
|
|
466
|
+
const m = (palette[base + 1] ?? 0) / 255;
|
|
467
|
+
const y = (palette[base + 2] ?? 0) / 255;
|
|
468
|
+
const k = (palette[base + 3] ?? 0) / 255;
|
|
469
|
+
r = 255 * (1 - c) * (1 - k);
|
|
470
|
+
g = 255 * (1 - m) * (1 - k);
|
|
471
|
+
b = 255 * (1 - y) * (1 - k);
|
|
472
|
+
} else {
|
|
473
|
+
r = palette[base] ?? 0;
|
|
474
|
+
g = palette[base + 1] ?? 0;
|
|
475
|
+
b = palette[base + 2] ?? 0;
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
// rgb
|
|
479
|
+
r = scaleSample(samples[s], maxVal);
|
|
480
|
+
g = scaleSample(samples[s + 1], maxVal);
|
|
481
|
+
b = scaleSample(samples[s + 2], maxVal);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const o = i * 4;
|
|
485
|
+
rgba[o] = r;
|
|
486
|
+
rgba[o + 1] = g;
|
|
487
|
+
rgba[o + 2] = b;
|
|
488
|
+
rgba[o + 3] = 255;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return rgba;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/*
|
|
495
|
+
* ---- Decode one PDF Image XObject into an ImageBitmap ---------------------
|
|
496
|
+
* Returns null if the image uses a codec we don't support (JPX/CCITT/JBIG2),
|
|
497
|
+
* is a stencil mask, or otherwise can't be safely handled.
|
|
498
|
+
*/
|
|
499
|
+
|
|
500
|
+
async function decodeImageXObject(context, ref) {
|
|
501
|
+
const stream = context.lookup(ref);
|
|
502
|
+
|
|
503
|
+
if (!stream || !(stream instanceof PDFRawStream)) return null;
|
|
504
|
+
|
|
505
|
+
const dict = stream.dict;
|
|
506
|
+
|
|
507
|
+
const subtype = dict.get(PDFName.of("Subtype"));
|
|
508
|
+
if (!subtype || subtype.asString().replace(/^\//, "") !== "Image") return null;
|
|
509
|
+
|
|
510
|
+
const isMask = dict.get(PDFName.of("ImageMask"));
|
|
511
|
+
if (isMask && isMask.constructor?.name === "PDFBool" && isMask.asBoolean?.()) return null;
|
|
512
|
+
|
|
513
|
+
// Color-key / stencil Mask (not SMask) changes meaning based on exact pixel
|
|
514
|
+
// values — recompressing would break it, so skip these entirely.
|
|
515
|
+
if (dict.get(PDFName.of("Mask"))) return null;
|
|
516
|
+
|
|
517
|
+
const filterNames = filterNamesOf(context, dict);
|
|
518
|
+
|
|
519
|
+
// Skip explicitly unhandled compression formats natively
|
|
520
|
+
const unsupported = filterNames.find(f => f === "JPXDecode" || f === "CCITTFaxDecode" || f === "JBIG2Decode");
|
|
521
|
+
if (unsupported) {
|
|
522
|
+
return { unsupported: true, reason: unsupported };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const width = dict.get(PDFName.of("Width"))?.asNumber?.();
|
|
526
|
+
const height = dict.get(PDFName.of("Height"))?.asNumber?.();
|
|
527
|
+
|
|
528
|
+
if (!width || !height) return null;
|
|
529
|
+
|
|
530
|
+
const originalBytes = stream.contents.length;
|
|
531
|
+
|
|
532
|
+
let bitmap;
|
|
533
|
+
let hasAlpha = false;
|
|
534
|
+
|
|
535
|
+
if (filterNames.includes("DCTDecode")) {
|
|
536
|
+
// 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.
|
|
539
|
+
let jpegBytes;
|
|
540
|
+
const originalFilterVal = dict.get(PDFName.of("Filter"));
|
|
541
|
+
const filterObj = context.lookup(originalFilterVal);
|
|
542
|
+
|
|
543
|
+
if (filterNames.length > 1 && filterObj instanceof PDFArray) {
|
|
544
|
+
const filtered = filterObj.asArray().filter((f) => {
|
|
545
|
+
const resolved = context.lookup(f);
|
|
546
|
+
return resolved instanceof PDFName && resolved.asString() !== "/DCTDecode";
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
if (filtered.length === 0) {
|
|
550
|
+
jpegBytes = stream.contents;
|
|
551
|
+
} 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
|
+
}
|
|
558
|
+
}
|
|
559
|
+
} else {
|
|
560
|
+
// It's just a raw DCTDecode stream (most common).
|
|
561
|
+
jpegBytes = stream.contents;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const blob = new Blob([jpegBytes], { type: "image/jpeg" });
|
|
565
|
+
bitmap = await createImageBitmap(blob);
|
|
566
|
+
} else {
|
|
567
|
+
// Raw samples (typically FlateDecode) — decode manually.
|
|
568
|
+
const bitsPerComponent = dict.get(PDFName.of("BitsPerComponent"))?.asNumber?.() || 8;
|
|
569
|
+
const colorSpaceObj = dict.get(PDFName.of("ColorSpace"));
|
|
570
|
+
const colorSpaceInfo = resolveColorSpace(context, colorSpaceObj);
|
|
571
|
+
|
|
572
|
+
const rawBytes = decodePDFRawStream(stream).decode();
|
|
573
|
+
const rgba = samplesToRGBA(rawBytes, width, height, bitsPerComponent, colorSpaceInfo);
|
|
574
|
+
|
|
575
|
+
const imageData = new ImageData(rgba, width, height);
|
|
576
|
+
bitmap = await createImageBitmap(imageData);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Soft mask (alpha channel) — decode and merge in.
|
|
580
|
+
const smaskRef = dict.get(PDFName.of("SMask"));
|
|
581
|
+
|
|
582
|
+
if (smaskRef) {
|
|
583
|
+
try {
|
|
584
|
+
const smaskDecoded = await decodeImageXObject(context, smaskRef);
|
|
585
|
+
|
|
586
|
+
if (smaskDecoded && !smaskDecoded.unsupported && smaskDecoded.bitmap) {
|
|
587
|
+
hasAlpha = true;
|
|
588
|
+
|
|
589
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
590
|
+
const ctx = canvas.getContext("2d");
|
|
591
|
+
|
|
592
|
+
// Draw color image
|
|
593
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
594
|
+
const base = ctx.getImageData(0, 0, width, height);
|
|
595
|
+
|
|
596
|
+
// Draw alpha mask resized to match
|
|
597
|
+
const alphaCanvas = new OffscreenCanvas(width, height);
|
|
598
|
+
const alphaCtx = alphaCanvas.getContext("2d");
|
|
599
|
+
alphaCtx.drawImage(smaskDecoded.bitmap, 0, 0, width, height);
|
|
600
|
+
const alphaData = alphaCtx.getImageData(0, 0, width, height);
|
|
601
|
+
|
|
602
|
+
for (let i = 0; i < width * height; i++) {
|
|
603
|
+
base.data[i * 4 + 3] = alphaData.data[i * 4]; // gray channel -> alpha
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
bitmap.close();
|
|
607
|
+
bitmap = await createImageBitmap(base);
|
|
608
|
+
}
|
|
609
|
+
} catch (smaskError) {
|
|
610
|
+
console.warn("compressPDF: SMask decode failed, ignoring alpha:", smaskError);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return { bitmap, width, height, hasAlpha, originalBytes };
|
|
615
|
+
}
|
|
240
616
|
|
|
241
|
-
|
|
617
|
+
/*
|
|
618
|
+
* ---- Deflate (zlib) bytes using the native Compression Streams API --------
|
|
619
|
+
*/
|
|
620
|
+
|
|
621
|
+
async function deflateBytes(bytes) {
|
|
622
|
+
if (typeof CompressionStream === "undefined") {
|
|
623
|
+
throw new Error("CompressionStream API unavailable — cannot write alpha/PNG-style images");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const cs = new CompressionStream("deflate");
|
|
627
|
+
const writer = cs.writable.getWriter();
|
|
628
|
+
writer.write(bytes);
|
|
629
|
+
writer.close();
|
|
630
|
+
|
|
631
|
+
const buffer = await new Response(cs.readable).arrayBuffer();
|
|
632
|
+
return new Uint8Array(buffer);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/*
|
|
636
|
+
* ---- Replace one image object in place ------------------------------------
|
|
637
|
+
*/
|
|
638
|
+
|
|
639
|
+
async function replaceImageWithJpeg(context, ref, jpegBytes, width, height) {
|
|
640
|
+
const dict = context.obj({
|
|
641
|
+
Type: "XObject",
|
|
642
|
+
Subtype: "Image",
|
|
643
|
+
Width: width,
|
|
644
|
+
Height: height,
|
|
645
|
+
ColorSpace: "DeviceRGB",
|
|
646
|
+
BitsPerComponent: 8,
|
|
647
|
+
Filter: "DCTDecode",
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
context.assign(ref, PDFRawStream.of(dict, jpegBytes));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function replaceImageWithFlateRGBA(context, ref, rgbaBitmap, width, height) {
|
|
654
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
655
|
+
const ctx = canvas.getContext("2d");
|
|
656
|
+
ctx.drawImage(rgbaBitmap, 0, 0, width, height);
|
|
657
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
658
|
+
|
|
659
|
+
const rgbBytes = new Uint8Array(width * height * 3);
|
|
660
|
+
const alphaBytes = new Uint8Array(width * height);
|
|
661
|
+
|
|
662
|
+
for (let i = 0; i < width * height; i++) {
|
|
663
|
+
rgbBytes[i * 3] = imageData.data[i * 4];
|
|
664
|
+
rgbBytes[i * 3 + 1] = imageData.data[i * 4 + 1];
|
|
665
|
+
rgbBytes[i * 3 + 2] = imageData.data[i * 4 + 2];
|
|
666
|
+
alphaBytes[i] = imageData.data[i * 4 + 3];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const [rgbDeflated, alphaDeflated] = await Promise.all([
|
|
670
|
+
deflateBytes(rgbBytes),
|
|
671
|
+
deflateBytes(alphaBytes),
|
|
672
|
+
]);
|
|
673
|
+
|
|
674
|
+
const smaskDict = context.obj({
|
|
675
|
+
Type: "XObject",
|
|
676
|
+
Subtype: "Image",
|
|
677
|
+
Width: width,
|
|
678
|
+
Height: height,
|
|
679
|
+
ColorSpace: "DeviceGray",
|
|
680
|
+
BitsPerComponent: 8,
|
|
681
|
+
Filter: "FlateDecode",
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
const smaskRef = context.register(PDFRawStream.of(smaskDict, alphaDeflated));
|
|
685
|
+
|
|
686
|
+
const mainDict = context.obj({
|
|
687
|
+
Type: "XObject",
|
|
688
|
+
Subtype: "Image",
|
|
689
|
+
Width: width,
|
|
690
|
+
Height: height,
|
|
691
|
+
ColorSpace: "DeviceRGB",
|
|
692
|
+
BitsPerComponent: 8,
|
|
693
|
+
Filter: "FlateDecode",
|
|
694
|
+
SMask: smaskRef,
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
context.assign(ref, PDFRawStream.of(mainDict, rgbDeflated));
|
|
698
|
+
|
|
699
|
+
return rgbDeflated.length + alphaDeflated.length;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/*
|
|
703
|
+
* ---- Find every Image XObject in the document ----------------------------
|
|
704
|
+
*/
|
|
705
|
+
|
|
706
|
+
function findImageRefs(pdfDoc) {
|
|
707
|
+
const refs = [];
|
|
708
|
+
|
|
709
|
+
for (const [ref, obj] of pdfDoc.context.enumerateIndirectObjects()) {
|
|
710
|
+
if (!(obj instanceof PDFRawStream)) continue;
|
|
711
|
+
|
|
712
|
+
const subtype = obj.dict.get(PDFName.of("Subtype"));
|
|
713
|
+
if (subtype && subtype.asString?.().replace(/^\//, "") === "Image") {
|
|
714
|
+
refs.push(ref);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return refs;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/*
|
|
722
|
+
* ---- compressPDF: the public, recommended API -----------------------------
|
|
723
|
+
*/
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Compress a PDF by extracting each embedded raster image, resizing and
|
|
727
|
+
* recompressing it individually (via a parallel worker pool running mozjpeg
|
|
728
|
+
* WASM), and writing the result back into the same PDF object slot. Text,
|
|
729
|
+
* fonts, and vector drawing instructions are left completely untouched.
|
|
730
|
+
*
|
|
731
|
+
* @param {File|Blob|ArrayBuffer|ArrayBufferView} input
|
|
732
|
+
* @param {Object} [options]
|
|
733
|
+
* @param {number} [options.quality=75] - JPEG quality, 0-100 (only applies to
|
|
734
|
+
* opaque images; images with transparency are re-encoded losslessly as
|
|
735
|
+
* Flate-compressed raw pixels + an SMask, since JPEG has no alpha channel)
|
|
736
|
+
* @param {number} [options.scale=1] - resize factor applied to each image's
|
|
737
|
+
* OWN native pixel dimensions (not the page). 1 = keep native size and only
|
|
738
|
+
* recompress; 0.5 = half width/height; etc. Clamped to 0.05–1.
|
|
739
|
+
* @param {number} [options.minImageBytes=2048] - skip images already smaller
|
|
740
|
+
* than this (not worth the re-encode overhead)
|
|
741
|
+
* @param {boolean} [options.onlyIfSmaller=true] - keep the original image
|
|
742
|
+
* bytes if the recompressed version would end up bigger
|
|
743
|
+
* @param {number} [options.workers] - override auto-detected worker count
|
|
744
|
+
* @param {(update: object) => void} [options.onProgress] - progress callback
|
|
745
|
+
* @returns {Promise<{file: File|Blob, stats: object}>}
|
|
746
|
+
*/
|
|
747
|
+
export async function compressPDF(input, options = {}) {
|
|
748
|
+
const {
|
|
749
|
+
quality = 75,
|
|
750
|
+
scale = 1,
|
|
751
|
+
minImageBytes = 2048,
|
|
752
|
+
onlyIfSmaller = true,
|
|
753
|
+
workers: workerOverride,
|
|
754
|
+
onProgress,
|
|
755
|
+
} = options;
|
|
756
|
+
|
|
757
|
+
const clampedScale = Math.max(0.05, Math.min(scale, 1));
|
|
758
|
+
|
|
759
|
+
const startedAt = performance.now();
|
|
760
|
+
|
|
761
|
+
const { arrayBuffer, fileName, fileType, originalBytes } = await normalizeInput(input);
|
|
762
|
+
|
|
763
|
+
const pdfDoc = await PDFDocument.load(arrayBuffer, {
|
|
764
|
+
updateMetadata: false,
|
|
765
|
+
ignoreEncryption: true,
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
const context = pdfDoc.context;
|
|
769
|
+
const imageRefs = findImageRefs(pdfDoc);
|
|
770
|
+
|
|
771
|
+
const power = getClientPower();
|
|
772
|
+
const workerCount = workerOverride || power.workers;
|
|
773
|
+
|
|
774
|
+
const pool = new CompressionPool();
|
|
775
|
+
pool.init(workerCount);
|
|
776
|
+
|
|
777
|
+
const perImage = [];
|
|
778
|
+
let imagesCompressed = 0;
|
|
779
|
+
let imagesSkipped = 0;
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
let nextIndex = 0;
|
|
783
|
+
let completed = 0;
|
|
784
|
+
|
|
785
|
+
async function runner() {
|
|
786
|
+
while (true) {
|
|
787
|
+
const index = nextIndex++;
|
|
788
|
+
if (index >= imageRefs.length) return;
|
|
789
|
+
|
|
790
|
+
const ref = imageRefs[index];
|
|
791
|
+
|
|
792
|
+
let entry = {
|
|
793
|
+
ref: ref.toString(),
|
|
794
|
+
skipped: null,
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
try {
|
|
798
|
+
const decoded = await decodeImageXObject(context, ref);
|
|
799
|
+
|
|
800
|
+
if (!decoded) {
|
|
801
|
+
entry.skipped = "unreadable-or-stencil";
|
|
802
|
+
imagesSkipped++;
|
|
803
|
+
} else if (decoded.unsupported) {
|
|
804
|
+
entry.skipped = `unsupported-codec:${decoded.reason}`;
|
|
805
|
+
imagesSkipped++;
|
|
806
|
+
} else if (decoded.originalBytes < minImageBytes) {
|
|
807
|
+
decoded.bitmap.close();
|
|
808
|
+
entry.skipped = "below-min-size";
|
|
809
|
+
entry.originalBytes = decoded.originalBytes;
|
|
810
|
+
imagesSkipped++;
|
|
811
|
+
} else {
|
|
812
|
+
const { bitmap, width, height, hasAlpha, originalBytes: origImgBytes } = decoded;
|
|
813
|
+
|
|
814
|
+
const newWidth = Math.max(1, Math.round(width * clampedScale));
|
|
815
|
+
const newHeight = Math.max(1, Math.round(height * clampedScale));
|
|
816
|
+
|
|
817
|
+
entry.width = width;
|
|
818
|
+
entry.height = height;
|
|
819
|
+
entry.newWidth = newWidth;
|
|
820
|
+
entry.newHeight = newHeight;
|
|
821
|
+
entry.originalBytes = origImgBytes;
|
|
822
|
+
|
|
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
|
+
if (hasAlpha) {
|
|
834
|
+
const newBytes = await replaceImageWithFlateRGBA(
|
|
835
|
+
context,
|
|
836
|
+
ref,
|
|
837
|
+
resizedBitmap,
|
|
838
|
+
newWidth,
|
|
839
|
+
newHeight
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
resizedBitmap.close();
|
|
843
|
+
|
|
844
|
+
entry.format = "flate+smask";
|
|
845
|
+
entry.compressedBytes = newBytes;
|
|
846
|
+
imagesCompressed++;
|
|
847
|
+
} 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]
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
const jpegBytes = jpegResultToBytes(compressed);
|
|
860
|
+
|
|
861
|
+
if (onlyIfSmaller && jpegBytes.length >= origImgBytes) {
|
|
862
|
+
entry.skipped = "recompressed-not-smaller";
|
|
863
|
+
entry.compressedBytes = origImgBytes;
|
|
864
|
+
imagesSkipped++;
|
|
865
|
+
} else {
|
|
866
|
+
await replaceImageWithJpeg(context, ref, jpegBytes, newWidth, newHeight);
|
|
867
|
+
entry.format = "jpeg";
|
|
868
|
+
entry.compressedBytes = jpegBytes.length;
|
|
869
|
+
imagesCompressed++;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
} catch (error) {
|
|
874
|
+
console.warn(`compressPDF: skipping image (${ref.toString()}):`, error);
|
|
875
|
+
entry.skipped = "error";
|
|
876
|
+
entry.error = error?.message || String(error);
|
|
877
|
+
imagesSkipped++;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
perImage.push(entry);
|
|
881
|
+
completed++;
|
|
882
|
+
|
|
883
|
+
if (typeof onProgress === "function") {
|
|
884
|
+
onProgress({
|
|
885
|
+
stage: "compressing",
|
|
886
|
+
imageIndex: index + 1,
|
|
887
|
+
totalImages: imageRefs.length,
|
|
888
|
+
completed,
|
|
889
|
+
progress: imageRefs.length
|
|
890
|
+
? Math.round((completed / imageRefs.length) * 90)
|
|
891
|
+
: 90,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const concurrency = Math.max(1, Math.min(workerCount, 4));
|
|
898
|
+
const runnerCount = Math.min(concurrency, Math.max(imageRefs.length, 1));
|
|
899
|
+
|
|
900
|
+
await Promise.all(Array.from({ length: runnerCount }, () => runner()));
|
|
901
|
+
|
|
902
|
+
if (typeof onProgress === "function") {
|
|
903
|
+
onProgress({ stage: "saving", progress: 95 });
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const pdfBytes = await pdfDoc.save({
|
|
907
|
+
useObjectStreams: true,
|
|
908
|
+
addDefaultPage: false,
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
const blob = new Blob([pdfBytes], { type: "application/pdf" });
|
|
912
|
+
const compressedBytes = blob.size;
|
|
913
|
+
const savedBytes = Math.max(0, originalBytes - compressedBytes);
|
|
914
|
+
const reduction = originalBytes > 0 ? (savedBytes / originalBytes) * 100 : 0;
|
|
915
|
+
const elapsed = performance.now() - startedAt;
|
|
916
|
+
|
|
917
|
+
const stats = {
|
|
918
|
+
pages: pdfDoc.getPageCount(),
|
|
919
|
+
imagesFound: imageRefs.length,
|
|
920
|
+
imagesCompressed,
|
|
921
|
+
imagesSkipped,
|
|
922
|
+
originalBytes,
|
|
923
|
+
compressedBytes,
|
|
924
|
+
savedBytes,
|
|
925
|
+
reduction,
|
|
926
|
+
elapsed,
|
|
927
|
+
workersUsed: workerCount,
|
|
928
|
+
perImage,
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
if (typeof onProgress === "function") {
|
|
932
|
+
onProgress({ stage: "complete", progress: 100 });
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const file =
|
|
936
|
+
typeof File !== "undefined"
|
|
937
|
+
? new File([blob], fileName, { type: fileType })
|
|
938
|
+
: blob;
|
|
939
|
+
|
|
940
|
+
return { file, stats };
|
|
941
|
+
} finally {
|
|
942
|
+
pool.destroy();
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/* ============================================================================
|
|
947
|
+
STRATEGY 2 (LEGACY): FULL-PAGE RASTERIZATION
|
|
948
|
+
Kept for cases where the whole page really is a single scanned image and
|
|
949
|
+
there's nothing for compressPDF's per-image extraction to find separately.
|
|
950
|
+
============================================================================ */
|
|
951
|
+
|
|
952
|
+
async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality, resolution, scale: fixedScale }) {
|
|
242
953
|
let page = null;
|
|
243
954
|
let canvas = null;
|
|
244
955
|
let bitmap = null;
|
|
@@ -255,19 +966,8 @@ async function processPage(pdf, pool, pageNumber, totalPages, { quality, resolut
|
|
|
255
966
|
let scale;
|
|
256
967
|
|
|
257
968
|
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
969
|
scale = Math.max(0.25, Math.min(fixedScale, 3));
|
|
265
970
|
} 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
971
|
scale = 3;
|
|
272
972
|
} else {
|
|
273
973
|
scale = resolution / largestDimension;
|
|
@@ -341,11 +1041,7 @@ async function processPage(pdf, pool, pageNumber, totalPages, { quality, resolut
|
|
|
341
1041
|
}
|
|
342
1042
|
}
|
|
343
1043
|
|
|
344
|
-
|
|
345
|
-
PARALLEL PAGE PIPELINE
|
|
346
|
-
============================================================ */
|
|
347
|
-
|
|
348
|
-
async function processPages(pdf, pool, totalPages, { quality, resolution, scale, workers, onProgress }) {
|
|
1044
|
+
async function rasterizeProcessPages(pdf, pool, totalPages, { quality, resolution, scale, workers, onProgress }) {
|
|
349
1045
|
const results = new Array(totalPages);
|
|
350
1046
|
|
|
351
1047
|
const renderConcurrency = Math.max(1, Math.min(workers, 4));
|
|
@@ -361,7 +1057,7 @@ async function processPages(pdf, pool, totalPages, { quality, resolution, scale,
|
|
|
361
1057
|
return;
|
|
362
1058
|
}
|
|
363
1059
|
|
|
364
|
-
const result = await
|
|
1060
|
+
const result = await rasterizeProcessPage(pdf, pool, pageNumber, totalPages, {
|
|
365
1061
|
quality,
|
|
366
1062
|
resolution,
|
|
367
1063
|
scale,
|
|
@@ -389,11 +1085,7 @@ async function processPages(pdf, pool, totalPages, { quality, resolution, scale,
|
|
|
389
1085
|
return results;
|
|
390
1086
|
}
|
|
391
1087
|
|
|
392
|
-
|
|
393
|
-
BUILD FINAL PDF
|
|
394
|
-
============================================================ */
|
|
395
|
-
|
|
396
|
-
async function buildPdf(compressedPages, { originalBytes, startedAt, workersUsed, onProgress }) {
|
|
1088
|
+
async function rasterizeBuildPdf(compressedPages, { originalBytes, startedAt, workersUsed, onProgress }) {
|
|
397
1089
|
if (typeof onProgress === "function") {
|
|
398
1090
|
onProgress({ stage: "building", progress: 92 });
|
|
399
1091
|
}
|
|
@@ -409,17 +1101,7 @@ async function buildPdf(compressedPages, { originalBytes, startedAt, workersUsed
|
|
|
409
1101
|
throw new Error(`Missing compressed page ${i + 1}`);
|
|
410
1102
|
}
|
|
411
1103
|
|
|
412
|
-
|
|
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
|
-
}
|
|
1104
|
+
const jpegBytes = jpegResultToBytes(item);
|
|
423
1105
|
|
|
424
1106
|
const image = await outputPdf.embedJpg(jpegBytes);
|
|
425
1107
|
|
|
@@ -484,31 +1166,19 @@ async function buildPdf(compressedPages, { originalBytes, startedAt, workersUsed
|
|
|
484
1166
|
return { blob, stats };
|
|
485
1167
|
}
|
|
486
1168
|
|
|
487
|
-
/* ============================================================
|
|
488
|
-
PUBLIC API
|
|
489
|
-
============================================================ */
|
|
490
|
-
|
|
491
1169
|
/**
|
|
492
|
-
*
|
|
493
|
-
*
|
|
1170
|
+
* Legacy full-page rasterization strategy. Renders every page to a bitmap and
|
|
1171
|
+
* rebuilds the PDF from those images — this DOES discard text/vector content
|
|
1172
|
+
* in favor of pictures of it, so prefer compressPDF() unless you specifically
|
|
1173
|
+
* want this (e.g. flattening a document, or it's already scan-only).
|
|
1174
|
+
*
|
|
1175
|
+
* Same options as before: quality, resolution ("original" to skip
|
|
1176
|
+
* downscaling), scale (direct render multiplier, overrides resolution),
|
|
1177
|
+
* workers, onProgress.
|
|
494
1178
|
*
|
|
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
1179
|
* @returns {Promise<{file: File|Blob, stats: object}>}
|
|
510
1180
|
*/
|
|
511
|
-
export async function
|
|
1181
|
+
export async function rasterizePDF(input, options = {}) {
|
|
512
1182
|
const {
|
|
513
1183
|
quality = 65,
|
|
514
1184
|
resolution = 1600,
|
|
@@ -537,7 +1207,7 @@ export async function compressPDF(input, options = {}) {
|
|
|
537
1207
|
const pdf = await loadingTask.promise;
|
|
538
1208
|
const totalPages = pdf.numPages;
|
|
539
1209
|
|
|
540
|
-
const compressedPages = await
|
|
1210
|
+
const compressedPages = await rasterizeProcessPages(pdf, pool, totalPages, {
|
|
541
1211
|
quality,
|
|
542
1212
|
resolution,
|
|
543
1213
|
scale,
|
|
@@ -545,7 +1215,7 @@ export async function compressPDF(input, options = {}) {
|
|
|
545
1215
|
onProgress,
|
|
546
1216
|
});
|
|
547
1217
|
|
|
548
|
-
const { blob, stats } = await
|
|
1218
|
+
const { blob, stats } = await rasterizeBuildPdf(compressedPages, {
|
|
549
1219
|
originalBytes,
|
|
550
1220
|
startedAt,
|
|
551
1221
|
workersUsed: workerCount,
|
|
@@ -564,9 +1234,9 @@ export async function compressPDF(input, options = {}) {
|
|
|
564
1234
|
await loadingTask.destroy();
|
|
565
1235
|
}
|
|
566
1236
|
} catch (cleanupError) {
|
|
567
|
-
console.warn("
|
|
1237
|
+
console.warn("rasterizePDF: loading task cleanup skipped:", cleanupError);
|
|
568
1238
|
}
|
|
569
1239
|
|
|
570
1240
|
pool.destroy();
|
|
571
1241
|
}
|
|
572
|
-
}
|
|
1242
|
+
}
|