Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/components/Compress/BatchCompressModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default function BatchCompressModal({
const [quality, setQuality] = useState<CompressQuality>(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)
Expand All @@ -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)
Expand Down Expand Up @@ -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}
Expand Down
8 changes: 6 additions & 2 deletions src/components/Compress/CompressResultModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default function CompressResultModal({
const [result, setResult] = useState<CompressResult>(initialResult)
const [quality, setQuality] = useState<CompressQuality>(initialResult.quality)
const [busy, setBusy] = useState(false)
const [progressPct, setProgressPct] = useState(0)
const reqId = useRef(0)

const saved = result.originalSize - result.compressedSize
Expand All @@ -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)
Expand Down Expand Up @@ -152,7 +156,7 @@ export default function CompressResultModal({
].join(' ')}
>
{busy
? 'Compressing…'
? `Compressing… ${Math.round(progressPct * 100)}%`
: didShrink
? `Saved ${formatSize(saved)} (${pct.toFixed(1)}%)`
: noGainNote}
Expand Down
138 changes: 125 additions & 13 deletions src/components/Export/ExportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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`
Expand Down Expand Up @@ -65,11 +82,15 @@ export default function ExportModal({ open, onClose }: Props) {
const formValues = useFormStore((s) => s.values)

const [annotated, setAnnotated] = useState<Uint8Array | null>(null)
const [compressed, setCompressed] = useState<Uint8Array | null>(null)
const [compressed, setCompressed] = useState<CompressResult | null>(null)
const [building, setBuilding] = useState(false)
const [compressing, setCompressing] = useState(false)
const [compressPct, setCompressPct] = useState(0)
const [quality, setQuality] = useState<CompressQuality>('light')
const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState<Variant>('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
Expand All @@ -83,6 +104,7 @@ export default function ExportModal({ open, onClose }: Props) {
useEffect(() => {
if (!open) return
setTab('compressed')
setQuality('light')
setRedactConfirm('')
}, [open])

Expand Down Expand Up @@ -111,18 +133,40 @@ 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')
} finally {
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
Expand All @@ -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
Expand All @@ -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()
}

Expand Down Expand Up @@ -227,6 +282,44 @@ export default function ExportModal({ open, onClose }: Props) {
</>
) : (
<>
{/* Compression strength — re-compresses the annotated bytes live */}
<div className="mb-3">
<div className="text-xs uppercase tracking-wide text-slate-500 font-medium mb-1.5">
Compression
</div>
<div className="grid grid-cols-3 gap-1 p-1 bg-slate-100 rounded-lg">
{QUALITY_OPTIONS.map((opt) => {
const active = opt.value === quality
return (
<button
key={opt.value}
type="button"
onClick={() => setQuality(opt.value)}
disabled={building || compressing}
aria-pressed={active}
className={[
'rounded-md px-2 py-1.5 text-sm font-medium transition-colors disabled:cursor-wait',
active
? 'bg-white text-orange-700 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
].join(' ')}
>
{opt.label}
</button>
)
})}
</div>
<div className="mt-1.5 text-xs text-slate-500">
{QUALITY_OPTIONS.find((o) => o.value === quality)?.hint}
</div>
{quality !== 'light' && (
<div className="mt-1.5 text-xs text-amber-700">
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.
</div>
)}
</div>

<div className="rounded-lg border border-slate-200 overflow-hidden">
<div className="flex bg-slate-50 border-b border-slate-200" role="tablist">
<button
Expand Down Expand Up @@ -254,7 +347,7 @@ export default function ExportModal({ open, onClose }: Props) {
aria-selected={effectiveTab === 'compressed'}
onClick={() => setTab('compressed')}
disabled={ready ? !didShrink : false}
title={ready && !didShrink ? 'Already optimised — same size as Original' : undefined}
title={ready && !didShrink ? noGainNote : undefined}
className={[
'flex-1 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors disabled:cursor-not-allowed',
effectiveTab === 'compressed'
Expand All @@ -276,7 +369,22 @@ export default function ExportModal({ open, onClose }: Props) {

<div className="p-4">
{!ready ? (
<div className="text-sm text-slate-500">Building export…</div>
<>
<div className="text-sm text-slate-500">
{building ? 'Building export…' : 'Compressing…'}
</div>
{/* A rasterising pass over a long document is minutes of
work. Without a bar it reads as a hung dialog, which is
how people learn to kill the tab mid-export. */}
{compressing && (
<div className="mt-2 h-1.5 w-full rounded-full bg-slate-100 overflow-hidden">
<div
className="h-full bg-orange-600 transition-[width] duration-200"
style={{ width: `${Math.round(compressPct * 100)}%` }}
/>
</div>
)}
</>
) : effectiveTab === 'original' ? (
<>
<div className="flex items-baseline gap-3 flex-wrap">
Expand All @@ -296,7 +404,11 @@ export default function ExportModal({ open, onClose }: Props) {
Saved {formatSize(saved)} ({pct.toFixed(1)}%)
</div>
</div>
<div className="text-[11px] text-slate-400 mt-0.5">object-stream re-save</div>
<div className="text-[11px] text-slate-400 mt-0.5">
{compressed?.fellBackToLossless || quality === 'light'
? 'object-stream re-save'
: 'pages rasterised to JPEG'}
</div>
</>
)}
</div>
Expand Down
17 changes: 15 additions & 2 deletions src/components/Landing/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,15 @@ export default function LandingPage() {
if (files.length === 1) {
const buf = await files[0].arrayBuffer()
// Slice so the kept-around source survives pdfjs detaching its copy.
const result = await compressPdf(buf.slice(0), files[0].name, DEFAULT_COMPRESS_QUALITY)
// The percentage is the page counter: rasterising a long scan is
// minutes of work, and a pill that just says "Compressing…" for all of
// it is indistinguishable from one that has crashed.
const result = await compressPdf(
buf.slice(0),
files[0].name,
DEFAULT_COMPRESS_QUALITY,
(f) => setCompressProgress(`Compressing… ${Math.round(f * 100)}%`)
)
setCompressJob({ sourceBytes: buf, fileName: files[0].name, result })
return
}
Expand All @@ -108,7 +116,12 @@ export default function LandingPage() {
for (let i = 0; i < files.length; i++) {
setCompressProgress(`Compressing ${i + 1}/${files.length}…`)
const buf = await files[i].arrayBuffer()
const result = await compressPdf(buf.slice(0), files[i].name, DEFAULT_COMPRESS_QUALITY)
const result = await compressPdf(
buf.slice(0),
files[i].name,
DEFAULT_COMPRESS_QUALITY,
(f) => setCompressProgress(`Compressing ${i + 1}/${files.length} — ${Math.round(f * 100)}%`)
)
sources.push({ sourceBytes: buf, fileName: files[i].name })
results.push(result)
}
Expand Down
33 changes: 29 additions & 4 deletions src/lib/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,17 +694,27 @@ async function rasterizePageToJpeg(
return new Uint8Array(await blob.arrayBuffer())
}

// A rasterised result this much smaller than the input is a landslide the
// lossless pass cannot overturn: repacking removes structural slack, never
// image data, so a third off is a good day for it and half is out of reach.
// Below this ratio we skip the yardstick save entirely — see compressPdf.
const LANDSLIDE_RATIO = 0.5

export async function compressPdf(
sourceBytes: ArrayBuffer,
fileName: string,
quality: CompressQuality = 'light'
quality: CompressQuality = 'light',
onProgress: (fraction: number) => void = () => {}
): Promise<CompressResult> {
const originalSize = sourceBytes.byteLength
const outName = fileName.replace(/\.pdf$/i, '') + '-compressed.pdf'
onProgress(0.05)

if (quality === 'light') {
const pdf = await PDFDocument.load(sourceBytes, { updateMetadata: false })
onProgress(0.5)
const bytes = await pdf.save({ useObjectStreams: true })
onProgress(1)
return { bytes, originalSize, compressedSize: bytes.byteLength, fileName: outName, quality }
}

Expand All @@ -715,20 +725,35 @@ export async function compressPdf(
const pdfjsDoc = await pdfjsLib.getDocument({ data: sourceBytes.slice(0) }).promise
const srcPdf = await PDFDocument.load(sourceBytes, { updateMetadata: false })
const out = await PDFDocument.create()
for (let i = 0; i < srcPdf.getPageCount(); i++) {
const pageCount = srcPdf.getPageCount()
for (let i = 0; i < pageCount; i++) {
const { width, height } = srcPdf.getPage(i).getSize()
const imgBytes = await rasterizePageToJpeg(pdfjsDoc, i, renderScale, jpegQuality)
const img = await out.embedJpg(imgBytes)
const page = out.addPage([width, height])
page.drawImage(img, { x: 0, y: 0, width, height })
// Rendering is nearly all the wall-clock, so the bar is the page counter.
onProgress(0.05 + ((i + 1) / pageCount) * 0.9)
}
const bytes = await out.save({ useObjectStreams: true })
// ⚠️ Rasterising is only a win when there is something raster-shaped to win.
// A text-only PDF turns 7 KB of glyphs into ~860 KB of JPEG — 100× bigger,
// with the text no longer selectable. "1 Click Compress" must never hand back
// a bigger file than it was given, so measure the lossless pass and keep
// whichever is smaller. Same document either way; only the bytes differ.
// a bigger file than it was given, so the lossless pass is the yardstick and
// whichever is smaller wins. Same document either way; only the bytes differ.
//
// But that yardstick is the most expensive step in here — it serialises the
// whole source document a second time, which on a 45 MB scan means a second
// 45 MB buffer on top of the source, pdfjs's copy and the parsed doc. Big
// files were dying on it. So only pay for it when the lossless pass could
// plausibly win: a landslide raster result is already smaller than anything
// a repack could produce, and is returned without measuring.
if (bytes.byteLength < originalSize * LANDSLIDE_RATIO) {
onProgress(1)
return { bytes, originalSize, compressedSize: bytes.byteLength, fileName: outName, quality }
}
const lossless = await srcPdf.save({ useObjectStreams: true })
onProgress(1)
if (bytes.byteLength >= lossless.byteLength) {
return {
bytes: lossless,
Expand Down