|
3 | 3 | * images-to-PDF, N-up, and crop/uncrop. |
4 | 4 | */ |
5 | 5 |
|
6 | | -import { PDFDocument, PDFName, rgb } from "@pdfme/pdf-lib"; |
| 6 | +import { |
| 7 | + PDFArray, |
| 8 | + PDFDict, |
| 9 | + PDFDocument, |
| 10 | + type PDFPage, |
| 11 | + PDFName, |
| 12 | + PDFRawStream, |
| 13 | + PDFRef, |
| 14 | + rgb, |
| 15 | +} from "@pdfme/pdf-lib"; |
7 | 16 | import type { CropMargins } from "../../types.ts"; |
8 | 17 | import { PDFJS_WASM_URL } from "../pdfjs-config.ts"; |
9 | 18 | import { clampScaleForCanvas, decodeImageToPngBytes, getPdfJs } from "./raster.ts"; |
10 | 19 |
|
11 | 20 | /** |
12 | | - * Compress a PDF by re-rendering each page as a JPEG image. |
| 21 | + * Below this lossless byte weight, rasterising a page can't plausibly beat |
| 22 | + * copying it through (a rasterised Letter page rarely encodes under ~30-90 KB), |
| 23 | + * so such pages skip the render entirely. Heavier pages get rendered and kept |
| 24 | + * only if the JPEG actually comes out smaller (see {@link compressPdf}). |
| 25 | + */ |
| 26 | +const COMPRESS_RENDER_GATE_BYTES = 50 * 1024; |
| 27 | + |
| 28 | +/** |
| 29 | + * Sum the lossless byte weight a page contributes: its content stream(s), the |
| 30 | + * image/form XObjects it references, and any Type3 font glyph programs |
| 31 | + * (CharProcs). This is what rasterising would replace, so it's the bar a page's |
| 32 | + * JPEG must beat to be worth rasterising. Shared resources are counted per page |
| 33 | + * (a slight over-count), which only biases toward rasterising heavy pages — the |
| 34 | + * per-page and whole-document guards keep that honest. |
| 35 | + */ |
| 36 | +function pageLosslessWeight(doc: PDFDocument, page: PDFPage): number { |
| 37 | + const ctx = doc.context; |
| 38 | + let weight = 0; |
| 39 | + const seen = new Set<string>(); |
| 40 | + const addStream = (v: unknown): void => { |
| 41 | + let obj = v; |
| 42 | + if (v instanceof PDFRef) { |
| 43 | + if (seen.has(v.tag)) return; |
| 44 | + seen.add(v.tag); |
| 45 | + obj = ctx.lookup(v); |
| 46 | + } |
| 47 | + if (obj instanceof PDFRawStream) weight += obj.contents.length; |
| 48 | + }; |
| 49 | + const resolveDict = (dict: PDFDict, key: string): unknown => { |
| 50 | + const v = dict.get(PDFName.of(key)); |
| 51 | + return v instanceof PDFRef ? ctx.lookup(v) : v; |
| 52 | + }; |
| 53 | + |
| 54 | + // Content stream(s) — a single stream or an array of them. |
| 55 | + const contents = resolveDict(page.node, "Contents"); |
| 56 | + if (contents instanceof PDFRawStream) weight += contents.contents.length; |
| 57 | + else if (contents instanceof PDFArray) for (const e of contents.asArray()) addStream(e); |
| 58 | + |
| 59 | + const res = resolveDict(page.node, "Resources"); |
| 60 | + if (res instanceof PDFDict) { |
| 61 | + const xobjects = resolveDict(res, "XObject"); |
| 62 | + if (xobjects instanceof PDFDict) for (const [, v] of xobjects.entries()) addStream(v); |
| 63 | + const fonts = resolveDict(res, "Font"); |
| 64 | + if (fonts instanceof PDFDict) { |
| 65 | + for (const [, fv] of fonts.entries()) { |
| 66 | + const fontDict = fv instanceof PDFRef ? ctx.lookup(fv) : fv; |
| 67 | + if (fontDict instanceof PDFDict) { |
| 68 | + const charProcs = resolveDict(fontDict, "CharProcs"); |
| 69 | + if (charProcs instanceof PDFDict) for (const [, pv] of charProcs.entries()) addStream(pv); |
| 70 | + } |
| 71 | + } |
| 72 | + } |
| 73 | + } |
| 74 | + return weight; |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Compress a PDF, picking the best strategy *per page* by measured bytes: |
| 79 | + * |
| 80 | + * - **Light pages** (lossless weight under {@link COMPRESS_RENDER_GATE_BYTES}) |
| 81 | + * are copied through losslessly without even rendering — rasterising them |
| 82 | + * couldn't win. Selectable text is preserved. |
| 83 | + * - **Heavy pages** (scans, photo pages, Type3-font / vector-dense pages) are |
| 84 | + * rendered to a JPEG and kept **only if that JPEG is smaller** than the |
| 85 | + * page's lossless weight; otherwise the page is copied through too. This is |
| 86 | + * the big win on scanned and image-heavy documents. |
13 | 87 | * |
14 | | - * This is a lossy compression strategy: every page is rasterised via PDF.js |
15 | | - * at a configurable scale, converted to JPEG at a given quality, and then |
16 | | - * re-embedded into a brand-new PDF document. Vector content and selectable |
17 | | - * text are lost, but the file size can be dramatically reduced. |
| 88 | + * The structural rebuild itself (a single `copyPages` into a fresh doc, saved |
| 89 | + * with object streams) drops unreachable objects and dedupes shared resources, |
| 90 | + * reclaiming ~15-25% on typical text PDFs with no quality loss. |
18 | 91 | * |
19 | | - * Quality presets: |
20 | | - * - `low` → scale 1.0×, JPEG quality 85% (lightest compression) |
21 | | - * - `medium` → scale 1.5×, JPEG quality 70% (balanced) |
22 | | - * - `high` → scale 2.0×, JPEG quality 50% (maximum compression) |
| 92 | + * The render `scale` for rasterised pages is pure supersampling — the output |
| 93 | + * page is sized at 1.0× (`origViewport`), so `scale` only sets the JPEG's pixel |
| 94 | + * count. More pixels → bigger JPEG. Higher compression therefore renders at a |
| 95 | + * *lower* scale (fewer pixels, also faster + less memory). The level also tunes |
| 96 | + * how aggressively pages rasterise: bigger JPEGs at `low` clear fewer pages' |
| 97 | + * weight bars, so more text is kept; smaller JPEGs at `high` rasterise more. |
| 98 | + * |
| 99 | + * Quality presets (apply to rasterised pages): |
| 100 | + * - `low` → scale 2.0×, JPEG quality 82% (sharpest, lightest drop) |
| 101 | + * - `medium` → scale 1.5×, JPEG quality 68% (balanced) |
| 102 | + * - `high` → scale 1.0×, JPEG quality 50% (smallest, softest) |
| 103 | + * |
| 104 | + * As a final guard the result is compared against the source: if the rebuild |
| 105 | + * didn't actually shrink the file (already-optimised input), the original bytes |
| 106 | + * are returned untouched rather than a larger "compressed" file. |
23 | 107 | * |
24 | 108 | * @param file - The PDF file to compress. |
25 | 109 | * @param quality - Compression preset: "low", "medium", or "high". |
26 | | - * @returns Compressed PDF bytes. |
| 110 | + * @returns Compressed PDF bytes (or the original bytes if no gain was possible). |
27 | 111 | */ |
28 | 112 | export async function compressPdf( |
29 | 113 | file: File, |
30 | 114 | quality: "low" | "medium" | "high" = "medium", |
31 | 115 | onProgress?: (rendered: number, total: number) => void, |
32 | 116 | ): Promise<Uint8Array> { |
33 | 117 | const qualitySettings = { |
34 | | - low: { scale: 1.0, jpegQuality: 0.85 }, |
35 | | - medium: { scale: 1.5, jpegQuality: 0.7 }, |
36 | | - high: { scale: 2.0, jpegQuality: 0.5 }, |
| 118 | + low: { scale: 2.0, jpegQuality: 0.82 }, |
| 119 | + medium: { scale: 1.5, jpegQuality: 0.68 }, |
| 120 | + high: { scale: 1.0, jpegQuality: 0.5 }, |
37 | 121 | }; |
38 | 122 |
|
39 | 123 | const { scale, jpegQuality } = qualitySettings[quality]; |
40 | 124 |
|
41 | 125 | const pdfjsLib = await getPdfJs(); |
42 | | - const arrayBuffer = await file.arrayBuffer(); |
43 | | - const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer, wasmUrl: PDFJS_WASM_URL }); |
| 126 | + const loadingTask = pdfjsLib.getDocument({ |
| 127 | + data: new Uint8Array(await file.arrayBuffer()), |
| 128 | + wasmUrl: PDFJS_WASM_URL, |
| 129 | + }); |
44 | 130 | const sourcePdf = await loadingTask.promise; |
| 131 | + // Separate pdf-lib parse (its own buffer) for the lossless copy path + weight |
| 132 | + // analysis — the PDF.js worker may detach the buffer it was handed. |
| 133 | + const srcLib = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()), { |
| 134 | + updateMetadata: false, |
| 135 | + throwOnInvalidObject: false, |
| 136 | + ignoreEncryption: true, |
| 137 | + }); |
45 | 138 | const newPdf = await PDFDocument.create(); |
| 139 | + const total = sourcePdf.numPages; |
| 140 | + const srcPages = srcLib.getPages(); |
| 141 | + |
| 142 | + // Render a single source page to JPEG bytes at the active preset; null if the |
| 143 | + // page can't be acquired. Releases its canvas before returning. |
| 144 | + const renderPageJpeg = async (pageNum: number): Promise<Uint8Array> => { |
| 145 | + const page = await sourcePdf.getPage(pageNum); |
| 146 | + // Clamp the render scale to the platform canvas ceiling (mobile ~4096 |
| 147 | + // px/side); a large page would otherwise render blank. Output size uses |
| 148 | + // origViewport below, so this only affects fidelity. |
| 149 | + const base = page.getViewport({ scale }); |
| 150 | + const safeScale = clampScaleForCanvas(base.width, base.height, scale); |
| 151 | + const viewport = page.getViewport({ scale: safeScale }); |
| 152 | + |
| 153 | + const canvas = document.createElement("canvas"); |
| 154 | + canvas.width = viewport.width; |
| 155 | + canvas.height = viewport.height; |
| 156 | + const ctx = canvas.getContext("2d"); |
| 157 | + if (!ctx) throw new Error(`Failed to acquire 2D canvas context for page ${pageNum}`); |
| 158 | + |
| 159 | + await page.render({ canvasContext: ctx, viewport, canvas }).promise; |
| 160 | + // Convert to JPEG via toBlob (avoids the overhead of a data-URL round-trip). |
| 161 | + const jpegBytes = await new Promise<Uint8Array>((resolve, reject) => { |
| 162 | + canvas.toBlob( |
| 163 | + (blob) => { |
| 164 | + if (!blob) return reject(new Error("Canvas toBlob returned null")); |
| 165 | + blob.arrayBuffer().then((ab) => resolve(new Uint8Array(ab)), reject); |
| 166 | + }, |
| 167 | + "image/jpeg", |
| 168 | + jpegQuality, |
| 169 | + ); |
| 170 | + }); |
| 171 | + canvas.width = 0; // release bitmap memory |
| 172 | + canvas.height = 0; |
| 173 | + return jpegBytes; |
| 174 | + }; |
46 | 175 |
|
47 | 176 | try { |
48 | | - for (let i = 1; i <= sourcePdf.numPages; i++) { |
49 | | - const page = await sourcePdf.getPage(i); |
50 | | - // Clamp the render scale to the platform canvas ceiling (mobile ~4096 |
51 | | - // px/side); a large page would otherwise render blank → a blank output |
52 | | - // page. Output size uses origViewport below, so this only affects fidelity. |
53 | | - const base = page.getViewport({ scale }); |
54 | | - const safeScale = clampScaleForCanvas(base.width, base.height, scale); |
55 | | - const viewport = page.getViewport({ scale: safeScale }); |
56 | | - |
57 | | - const canvas = document.createElement("canvas"); |
58 | | - canvas.width = viewport.width; |
59 | | - canvas.height = viewport.height; |
60 | | - const ctx = canvas.getContext("2d"); |
61 | | - if (!ctx) throw new Error(`Failed to acquire 2D canvas context for page ${i}`); |
62 | | - |
63 | | - await page.render({ canvasContext: ctx, viewport, canvas }).promise; |
64 | | - |
65 | | - // Convert to JPEG via toBlob (avoids the overhead of a data-URL round-trip) |
66 | | - const jpegBytes = await new Promise<Uint8Array>((resolve, reject) => { |
67 | | - canvas.toBlob( |
68 | | - (blob) => { |
69 | | - if (!blob) return reject(new Error("Canvas toBlob returned null")); |
70 | | - blob.arrayBuffer().then((ab) => resolve(new Uint8Array(ab)), reject); |
71 | | - }, |
72 | | - "image/jpeg", |
73 | | - jpegQuality, |
74 | | - ); |
75 | | - }); |
76 | | - |
77 | | - // Release canvas bitmap memory |
78 | | - canvas.width = 0; |
79 | | - canvas.height = 0; |
80 | | - |
81 | | - const image = await newPdf.embedJpg(jpegBytes); |
82 | | - |
83 | | - // Use original page dimensions (in PDF points) |
84 | | - const origViewport = page.getViewport({ scale: 1.0 }); |
85 | | - const newPage = newPdf.addPage([origViewport.width, origViewport.height]); |
86 | | - newPage.drawImage(image, { |
87 | | - x: 0, |
88 | | - y: 0, |
89 | | - width: origViewport.width, |
90 | | - height: origViewport.height, |
91 | | - }); |
| 177 | + // --- Phase 1: decide per page — rasterise only when it measurably wins. --- |
| 178 | + // A light page is copied losslessly without rendering. A heavy page is |
| 179 | + // rendered and rasterised only if the JPEG beats its lossless weight. |
| 180 | + const rasterJpeg = new Map<number, Uint8Array>(); // 0-based page index → JPEG |
| 181 | + for (let i = 0; i < total; i++) { |
| 182 | + const weight = pageLosslessWeight(srcLib, srcPages[i]); |
| 183 | + if (weight >= COMPRESS_RENDER_GATE_BYTES) { |
| 184 | + const jpeg = await renderPageJpeg(i + 1); |
| 185 | + if (jpeg.byteLength < weight) rasterJpeg.set(i, jpeg); |
| 186 | + } |
| 187 | + } |
92 | 188 |
|
93 | | - onProgress?.(i, sourcePdf.numPages); |
| 189 | + // --- Phase 2: copy ALL lossless pages in a SINGLE copyPages call. --- |
| 190 | + // One call dedupes shared resources (fonts) and drops unreachable objects; |
| 191 | + // copying per-page instead re-embeds shared fonts and bloats the output 4-5×. |
| 192 | + const losslessIdx: number[] = []; |
| 193 | + for (let i = 0; i < total; i++) if (!rasterJpeg.has(i)) losslessIdx.push(i); |
| 194 | + const copied = losslessIdx.length ? await newPdf.copyPages(srcLib, losslessIdx) : []; |
| 195 | + const copiedByIndex = new Map<number, (typeof copied)[number]>(); |
| 196 | + losslessIdx.forEach((idx, k) => copiedByIndex.set(idx, copied[k])); |
| 197 | + |
| 198 | + // --- Phase 3: assemble the output in original page order. --- |
| 199 | + for (let i = 0; i < total; i++) { |
| 200 | + const lossless = copiedByIndex.get(i); |
| 201 | + if (lossless) { |
| 202 | + newPdf.addPage(lossless); |
| 203 | + } else { |
| 204 | + const jpeg = rasterJpeg.get(i); |
| 205 | + if (!jpeg) throw new Error(`Missing rasterised page ${i + 1}`); |
| 206 | + const image = await newPdf.embedJpg(jpeg); |
| 207 | + // Draw into a page sized at the original dimensions (in PDF points). |
| 208 | + const origViewport = (await sourcePdf.getPage(i + 1)).getViewport({ scale: 1.0 }); |
| 209 | + const newPage = newPdf.addPage([origViewport.width, origViewport.height]); |
| 210 | + newPage.drawImage(image, { |
| 211 | + x: 0, |
| 212 | + y: 0, |
| 213 | + width: origViewport.width, |
| 214 | + height: origViewport.height, |
| 215 | + }); |
| 216 | + } |
| 217 | + onProgress?.(i + 1, total); |
94 | 218 | await new Promise((r) => setTimeout(r, 0)); |
95 | 219 | } |
96 | 220 |
|
97 | | - return await newPdf.save({ |
98 | | - useObjectStreams: true, |
99 | | - }); |
| 221 | + const compressed = await newPdf.save({ useObjectStreams: true }); |
| 222 | + |
| 223 | + // If the rebuild didn't actually shrink the file (already-optimised input), |
| 224 | + // return the source bytes untouched rather than a larger "compressed" file. |
| 225 | + if (compressed.byteLength >= file.size) { |
| 226 | + return new Uint8Array(await file.arrayBuffer()); |
| 227 | + } |
| 228 | + return compressed; |
100 | 229 | } finally { |
101 | 230 | // Always release the PDF.js document + worker session, even on a mid-page throw. |
102 | 231 | void loadingTask.destroy(); |
|
0 commit comments