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. +
+ )} +
+