From 6a0dff34b0ceaecced3be075d61f4618c702b607 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:16:57 +0000 Subject: [PATCH] Compress on export: offer the setting that actually shrinks a scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compressing a 45 MB scan in Universal Compress got it to 1.5 MB; the same file in Universal PDF's Export dialog reported no savings and greyed the Compressed tab out. The engines are not the difference — Universal Compress's PDF path is a port of this one, down to the raster presets — the entry point was. Export called compressPdf with no quality, taking the 'light' default: a lossless object-stream re-save. On a scan, whose bulk is image data, that is a few percent at best, so the tab it feeds had nothing to offer. Only the landing page's compress pill ever passed 'balanced'. So give Export the same three-way control the Compress dialogs have. 'light' stays the default there and only there: an export is an annotated, often signed document, and rasterising it costs the text layer — a trade worth offering, not worth making on someone's behalf. It is spelled out in the dialog when a rasterising level is picked. Compression is now its own pass, so changing the level re-compresses the annotated bytes instead of re-baking every annotation to arrive at the same input again. Two things behind that, both of which bit the same big file: The never-return-a-bigger-file guarantee was measured by saving the whole source document a second time as a lossless yardstick. On a 45 MB scan that is a second 45 MB buffer on top of the source, pdf.js's copy and the parsed document, and it is the slowest step in the function. It is now only paid for when the lossless pass could plausibly win. Repacking removes structural slack, never image data, so a raster result under half the original is a landslide the yardstick cannot overturn. The guarantee is unchanged; a text-only PDF still comes back losslessly rather than as 860 KB of JPEG. And compressPdf reported no progress at all, so a long rasterising run looked like a hung dialog — which is how people learn to kill the tab mid-export. It now takes an onProgress callback, threaded through the export dialog as a bar and through the landing page and both Compress dialogs as a percentage. Verified in a browser against an 8-page image-heavy fixture: 16.72 MB → 16.72 MB at Light, → 1.41 MB at Balanced, → 0.29 MB at Maximum, with the text-only fixture still falling back to lossless. --- .../Compress/BatchCompressModal.tsx | 9 +- .../Compress/CompressResultModal.tsx | 8 +- src/components/Export/ExportModal.tsx | 138 ++++++++++++++++-- src/components/Landing/LandingPage.tsx | 17 ++- src/lib/export.ts | 33 ++++- 5 files changed, 182 insertions(+), 23 deletions(-) diff --git a/src/components/Compress/BatchCompressModal.tsx b/src/components/Compress/BatchCompressModal.tsx index a59b039..8d85373 100644 --- a/src/components/Compress/BatchCompressModal.tsx +++ b/src/components/Compress/BatchCompressModal.tsx @@ -44,6 +44,7 @@ export default function BatchCompressModal({ const [quality, setQuality] = useState(initialQuality) const [busy, setBusy] = useState(false) const [progress, setProgress] = useState(0) + const [filePct, setFilePct] = useState(0) const reqId = useRef(0) const totalOriginal = results.reduce((n, r) => n + r.originalSize, 0) @@ -67,11 +68,15 @@ export default function BatchCompressModal({ const id = ++reqId.current setBusy(true) setProgress(0) + setFilePct(0) try { const next: CompressResult[] = [] for (let i = 0; i < files.length; i++) { + setFilePct(0) // Slice so pdfjs can detach without consuming our kept-around copy. - const r = await compressPdf(files[i].sourceBytes.slice(0), files[i].fileName, q) + const r = await compressPdf(files[i].sourceBytes.slice(0), files[i].fileName, q, (f) => { + if (id === reqId.current) setFilePct(f) + }) if (id !== reqId.current) return next.push(r) setProgress(i + 1) @@ -176,7 +181,7 @@ export default function BatchCompressModal({ ].join(' ')} > {busy - ? `Compressing ${progress}/${files.length}…` + ? `Compressing ${Math.min(progress + 1, files.length)}/${files.length} — ${Math.round(filePct * 100)}%` : didShrink ? `Saved ${formatSize(totalSaved)} (${pct.toFixed(1)}%) across ${files.length} files` : noGainNote} diff --git a/src/components/Compress/CompressResultModal.tsx b/src/components/Compress/CompressResultModal.tsx index da887f7..449c073 100644 --- a/src/components/Compress/CompressResultModal.tsx +++ b/src/components/Compress/CompressResultModal.tsx @@ -38,6 +38,7 @@ export default function CompressResultModal({ const [result, setResult] = useState(initialResult) const [quality, setQuality] = useState(initialResult.quality) const [busy, setBusy] = useState(false) + const [progressPct, setProgressPct] = useState(0) const reqId = useRef(0) const saved = result.originalSize - result.compressedSize @@ -59,9 +60,12 @@ export default function CompressResultModal({ setQuality(q) const id = ++reqId.current setBusy(true) + setProgressPct(0) try { // Slice so pdfjs can safely detach without consuming our kept-around copy. - const r = await compressPdf(sourceBytes.slice(0), fileName, q) + const r = await compressPdf(sourceBytes.slice(0), fileName, q, (f) => { + if (id === reqId.current) setProgressPct(f) + }) if (id === reqId.current) setResult(r) } catch (err) { console.error(err) @@ -152,7 +156,7 @@ export default function CompressResultModal({ ].join(' ')} > {busy - ? 'Compressing…' + ? `Compressing… ${Math.round(progressPct * 100)}%` : didShrink ? `Saved ${formatSize(saved)} (${pct.toFixed(1)}%)` : noGainNote} diff --git a/src/components/Export/ExportModal.tsx b/src/components/Export/ExportModal.tsx index 2455cc9..177f591 100644 --- a/src/components/Export/ExportModal.tsx +++ b/src/components/Export/ExportModal.tsx @@ -2,7 +2,13 @@ import { useEffect, useRef, useState } from 'react' import { usePdfStore } from '../../stores/pdfStore' import { useAnnotationStore } from '../../stores/annotationStore' import { useFormStore } from '../../stores/formStore' -import { buildAnnotatedPdfBytes, compressPdf, downloadPdfBytes } from '../../lib/export' +import { + buildAnnotatedPdfBytes, + compressPdf, + downloadPdfBytes, + type CompressQuality, + type CompressResult +} from '../../lib/export' import { nextExportName, previewExportName } from '../../lib/exportName' import { RedactIcon } from '../icons/RedactIcon' @@ -15,6 +21,17 @@ const EXPORT_SCALE = 1.0 type Variant = 'original' | 'compressed' +// The same three strengths the Compress dialogs offer, worded for this one. +// 'light' stays the default here and nowhere else: an export is an annotated — +// often signed — document, and rasterising it trades away the text layer of a +// file someone is about to send on. That trade is worth offering, but not worth +// making on their behalf. +const QUALITY_OPTIONS: { value: CompressQuality; label: string; hint: string }[] = [ + { value: 'light', label: 'Light', hint: 'Lossless re-save · text stays selectable' }, + { value: 'balanced', label: 'Balanced', hint: 'Pages become images · big saving on scans' }, + { value: 'strong', label: 'Maximum', hint: 'Smallest · most visible loss' } +] + function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` @@ -65,11 +82,15 @@ export default function ExportModal({ open, onClose }: Props) { const formValues = useFormStore((s) => s.values) const [annotated, setAnnotated] = useState(null) - const [compressed, setCompressed] = useState(null) + const [compressed, setCompressed] = useState(null) const [building, setBuilding] = useState(false) + const [compressing, setCompressing] = useState(false) + const [compressPct, setCompressPct] = useState(0) + const [quality, setQuality] = useState('light') const [error, setError] = useState(null) const [tab, setTab] = useState('compressed') const buildIdRef = useRef(0) + const compressIdRef = useRef(0) // Export is the point of no return for redactions: until now they're just // movable black-box markup, but the rasterise-and-rebuild pass below removes @@ -83,6 +104,7 @@ export default function ExportModal({ open, onClose }: Props) { useEffect(() => { if (!open) return setTab('compressed') + setQuality('light') setRedactConfirm('') }, [open]) @@ -111,10 +133,6 @@ export default function ExportModal({ open, onClose }: Props) { const annot = await buildAnnotatedPdfBytes(copy, annotations, EXPORT_SCALE, formValues) if (myId !== buildIdRef.current) return setAnnotated(annot) - const annotBuf = annot.slice().buffer - const comp = await compressPdf(annotBuf, fileName ?? 'document.pdf') - if (myId !== buildIdRef.current) return - setCompressed(comp.bytes) } catch (e) { if (myId !== buildIdRef.current) return setError((e as Error).message || 'Export failed') @@ -122,7 +140,33 @@ export default function ExportModal({ open, onClose }: Props) { if (myId === buildIdRef.current) setBuilding(false) } })() - }, [open, sourceBytes, annotations, formValues, fileName, isXfa, doc]) + }, [open, sourceBytes, annotations, formValues, isXfa, doc]) + + // Compression is its own pass so that changing the quality re-compresses the + // annotated bytes we already have, instead of re-baking every annotation and + // re-rasterising every redaction to arrive at the identical input again. + useEffect(() => { + if (!open || isXfa || !annotated) return + const myId = ++compressIdRef.current + setCompressed(null) + setCompressing(true) + setCompressPct(0) + ;(async () => { + try { + const annotBuf = annotated.slice().buffer + const comp = await compressPdf(annotBuf, fileName ?? 'document.pdf', quality, (f) => { + if (myId === compressIdRef.current) setCompressPct(f) + }) + if (myId !== compressIdRef.current) return + setCompressed(comp) + } catch (e) { + if (myId !== compressIdRef.current) return + setError((e as Error).message || 'Compression failed') + } finally { + if (myId === compressIdRef.current) setCompressing(false) + } + })() + }, [open, annotated, quality, fileName, isXfa]) useEffect(() => { if (!open) return @@ -135,14 +179,25 @@ export default function ExportModal({ open, onClose }: Props) { if (!open) return null - const ready = !building && annotated && compressed + const ready = !building && !compressing && annotated && compressed const origSize = annotated?.byteLength ?? 0 - const compSize = compressed?.byteLength ?? 0 + const compSize = compressed?.compressedSize ?? 0 const saved = origSize - compSize const pct = origSize > 0 ? (saved / origSize) * 100 : 0 const didShrink = saved > 0 const effectiveTab: Variant = ready && tab === 'compressed' && !didShrink ? 'original' : tab + // Why the Compressed tab has nothing to offer. On the lossless pass that is + // usually a scan whose bulk is images — exactly what Balanced is for — so say + // so rather than leaving a dead tab with no way forward. Where rasterising + // itself would have bloated the file, compressPdf already kept the lossless + // bytes and says so. + const noGainNote = compressed?.fellBackToLossless + ? 'Kept the lossless version — turning these pages into images would have made the file bigger.' + : quality === 'light' + ? 'Already optimised — try Balanced or Maximum for image-heavy PDFs.' + : 'Already optimised — this PDF is as small as it goes.' + // ⚠️ The name is claimed at DOWNLOAD time, not here. `nextExportName` // increments a per-document counter, so working it out during render would // burn a version on every re-render — hence `previewExportName` for the @@ -155,7 +210,7 @@ export default function ExportModal({ open, onClose }: Props) { // one of the two ever leaves per opening, and which variant it was is not // something the file needs to carry. const name = nextExportName(fileName) - downloadPdfBytes((which === 'original' ? annotated : compressed).slice(), name) + downloadPdfBytes((which === 'original' ? annotated : compressed.bytes).slice(), name) onClose() } @@ -227,6 +282,44 @@ export default function ExportModal({ open, onClose }: Props) { ) : ( <> + {/* Compression strength — re-compresses the annotated bytes live */} +
+
+ Compression +
+
+ {QUALITY_OPTIONS.map((opt) => { + const active = opt.value === quality + return ( + + ) + })} +
+
+ {QUALITY_OPTIONS.find((o) => o.value === quality)?.hint} +
+ {quality !== 'light' && ( +
+ Every page becomes a picture: the text in the compressed copy can no + longer be selected, searched or read aloud. The Original tab is unaffected. +
+ )} +
+