Skip to content

Commit 7721360

Browse files
authored
Merge pull request #118 from cloakyard/dev
Release: smart per-page PDF compression
2 parents cfe5736 + 7237b43 commit 7721360

3 files changed

Lines changed: 386 additions & 68 deletions

File tree

src/editor/ExportModal.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,13 @@ const IMAGE_DPI = 150;
5959
type Quality = "low" | "medium" | "high";
6060
type Format = "pdf" | "images" | "contact" | "split" | "text" | "markdown";
6161

62+
// Compression is smart per-page: light text/vector pages are kept lossless
63+
// (text stays selectable), and scanned / image-heavy pages are re-rendered only
64+
// when that's actually smaller — so the level below controls those pages' detail.
6265
const COMPRESS_INFO: Record<Quality, string> = {
63-
low: "Sharpest pages, modest size drop (1× render, JPEG 85%).",
64-
medium: "Balanced size vs quality — suits most documents (1.5× render, JPEG 70%).",
65-
high: "Smallest file, softest pages (2× render, JPEG 50%).",
66+
low: "Text pages kept sharp & selectable; image pages at high detail (2×, JPEG 82%).",
67+
medium: "Text pages kept selectable; image pages balanced (1.5×, JPEG 68%).",
68+
high: "Text pages kept selectable; image pages smallest & softest (1×, JPEG 50%).",
6669
};
6770

6871
const FORMATS: { value: Format; icon: LucideIcon; label: string; hint: string }[] = [
@@ -598,7 +601,7 @@ export function ExportButton() {
598601
<OptionRow
599602
icon={Archive}
600603
label="Compress"
601-
hint="Shrink by re-rendering pages as images"
604+
hint="Shrink smartly — keep text, re-render image pages"
602605
checked={compress}
603606
onChange={setCompress}
604607
>

src/utils/pdf/transform.ts

Lines changed: 193 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,100 +3,229 @@
33
* images-to-PDF, N-up, and crop/uncrop.
44
*/
55

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";
716
import type { CropMargins } from "../../types.ts";
817
import { PDFJS_WASM_URL } from "../pdfjs-config.ts";
918
import { clampScaleForCanvas, decodeImageToPngBytes, getPdfJs } from "./raster.ts";
1019

1120
/**
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.
1387
*
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.
1891
*
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.
23107
*
24108
* @param file - The PDF file to compress.
25109
* @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).
27111
*/
28112
export async function compressPdf(
29113
file: File,
30114
quality: "low" | "medium" | "high" = "medium",
31115
onProgress?: (rendered: number, total: number) => void,
32116
): Promise<Uint8Array> {
33117
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 },
37121
};
38122

39123
const { scale, jpegQuality } = qualitySettings[quality];
40124

41125
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+
});
44130
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+
});
45138
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+
};
46175

47176
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+
}
92188

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);
94218
await new Promise((r) => setTimeout(r, 0));
95219
}
96220

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;
100229
} finally {
101230
// Always release the PDF.js document + worker session, even on a mid-page throw.
102231
void loadingTask.destroy();

0 commit comments

Comments
 (0)