From 8f42c961b3b25257a0172f77d0e0de51d222553c Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:10:36 +0530 Subject: [PATCH 01/35] Add browser-local qpdf compression dependency --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1eb7dd9..cc58b55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "toolstack", - "version": "0.3.0", + "version": "0.3.1", "private": true, "scripts": { "dev": "next dev", @@ -25,6 +25,7 @@ "pdf-lib": "^1.17.1", "piexifjs": "^1.0.6", "postcss-selector-parser": "^7.1.6", + "qpdf-run": "^0.2.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-image-crop": "^11.0.10", From 0d8760addfb467e4c65560a9dbb6f1381d018539 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:13:11 +0530 Subject: [PATCH 02/35] Improve PDF merge editing and add local compression --- src/app/pdf-toolkit/page.tsx | 337 ++++++++++++++++++++++++++++++----- 1 file changed, 291 insertions(+), 46 deletions(-) diff --git a/src/app/pdf-toolkit/page.tsx b/src/app/pdf-toolkit/page.tsx index 28e5e36..03b0d01 100644 --- a/src/app/pdf-toolkit/page.tsx +++ b/src/app/pdf-toolkit/page.tsx @@ -1,4 +1,5 @@ 'use client'; + import { useState } from 'react'; import JSZip from 'jszip'; import { degrees, PDFDocument } from 'pdf-lib'; @@ -7,10 +8,15 @@ import { parsePageSelection } from '@/lib/page-selection'; type PdfInfo = { file: File; pages: number; error?: string }; type MetadataState = { title: string; author: string; subject: string; keywords: string }; +type CompressionMode = 'lossless' | 'balanced' | 'strong'; +type CompressionResult = { original: number; compressed: number; downloaded: boolean }; + const EMPTY_META: MetadataState = { title: '', author: '', subject: '', keywords: '' }; const MAX_PDF_FILES = 20; const MAX_TOTAL_BYTES = 250 * 1024 * 1024; +const MAX_COMPRESSION_BYTES = 100 * 1024 * 1024; const fileKey = (file: File) => `${file.name}:${file.size}:${file.lastModified}`; +const baseName = (name: string) => name.replace(/\.pdf$/i, ''); function downloadBytes(bytes: Uint8Array, name: string, type = 'application/pdf') { const blob = new Blob([new Uint8Array(bytes).buffer], { type }); @@ -22,18 +28,65 @@ function downloadBytes(bytes: Uint8Array, name: string, type = 'application/pdf' URL.revokeObjectURL(url); } -async function loadPdf(file: File) { return PDFDocument.load(await file.arrayBuffer()); } -const baseName = (name: string) => name.replace(/\.pdf$/i, ''); +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = units[0]; + for (let index = 1; index < units.length && value >= 1024; index++) { + value /= 1024; + unit = units[index]; + } + return `${value.toFixed(value >= 10 ? 1 : 2)} ${unit}`; +} + +async function loadPdf(file: File) { + return PDFDocument.load(await file.arrayBuffer()); +} + +async function readMetadata(item: PdfInfo): Promise { + const doc = await loadPdf(item.file); + return { + title: doc.getTitle() ?? '', + author: doc.getAuthor() ?? '', + subject: doc.getSubject() ?? '', + keywords: doc.getKeywords() ?? '', + }; +} + +function compressionArgs(mode: CompressionMode) { + const args = [ + '--compress-streams=y', + '--decode-level=generalized', + '--recompress-flate', + '--compression-level=9', + '--object-streams=generate', + ]; + if (mode !== 'lossless') { + args.push('--optimize-images', `--jpeg-quality=${mode === 'balanced' ? 75 : 50}`); + } + return [...args, '--', 'input.pdf', 'output.pdf']; +} export default function PdfToolkitPage() { const [files, setFiles] = useState([]); + const [selectedKey, setSelectedKey] = useState(''); const [selection, setSelection] = useState(''); - const [operation, setOperation] = useState<'extract'|'delete'|'rotate'>('extract'); + const [operation, setOperation] = useState<'extract' | 'delete' | 'rotate'>('extract'); const [rotation, setRotation] = useState(90); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(''); const [metadata, setMetadata] = useState(EMPTY_META); const [images, setImages] = useState([]); + const [compressionMode, setCompressionMode] = useState('lossless'); + const [compressionResult, setCompressionResult] = useState(null); + + const activatePdf = async (item: PdfInfo) => { + setSelectedKey(fileKey(item.file)); + setSelection(`1-${item.pages}`); + setMetadata(await readMetadata(item)); + setCompressionResult(null); + }; const addPdfs = async (list: FileList | null) => { if (!list) return; @@ -55,7 +108,6 @@ export default function PdfToolkitPage() { return; } - const hadReadablePdf = files.some(item => !item.error && item.pages > 0); setBusy(true); setMessage(''); try { @@ -69,20 +121,65 @@ export default function PdfToolkitPage() { })); setFiles(prev => [...prev, ...loaded]); - if (!hadReadablePdf) { + if (!selectedKey) { const first = loaded.find(item => !item.error && item.pages > 0); - if (first) { - setSelection(`1-${first.pages}`); - const doc = await loadPdf(first.file); - setMetadata({ title: doc.getTitle() ?? '', author: doc.getAuthor() ?? '', subject: doc.getSubject() ?? '', keywords: doc.getKeywords() ?? '' }); - } + if (first) await activatePdf(first); } } finally { setBusy(false); } }; - const moveFile = (index: number, direction: -1|1) => setFiles(prev => { + const selectWorkingPdf = async (key: string) => { + const item = files.find(candidate => fileKey(candidate.file) === key && !candidate.error && candidate.pages > 0); + if (!item) return; + setBusy(true); + setMessage(''); + try { + await activatePdf(item); + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } + }; + + const removeFile = async (key: string) => { + const next = files.filter(item => fileKey(item.file) !== key); + setFiles(next); + setMessage(''); + + if (selectedKey !== key) return; + + const replacement = next.find(item => !item.error && item.pages > 0); + if (!replacement) { + setSelectedKey(''); + setSelection(''); + setMetadata(EMPTY_META); + setCompressionResult(null); + return; + } + + setBusy(true); + try { + await activatePdf(replacement); + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } + }; + + const clearFiles = () => { + setFiles([]); + setSelectedKey(''); + setSelection(''); + setMetadata(EMPTY_META); + setCompressionResult(null); + setMessage(''); + }; + + const moveFile = (index: number, direction: -1 | 1) => setFiles(prev => { const target = index + direction; if (target < 0 || target >= prev.length) return prev; const copy = [...prev]; @@ -103,22 +200,24 @@ export default function PdfToolkitPage() { copied.forEach(page => output.addPage(page)); } downloadBytes(await output.save(), 'merged.pdf'); - } catch (err) { setMessage((err as Error).message); } finally { setBusy(false); } + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } }; - const applyPages = async () => { - const first = files.find(item => !item.error && item.pages > 0); - if (!first) return; + const applyPages = async (workingPdf: PdfInfo) => { setBusy(true); setMessage(''); try { - const source = await loadPdf(first.file); + const source = await loadPdf(workingPdf.file); const indexes = parsePageSelection(selection, source.getPageCount()); if (operation === 'extract') { const output = await PDFDocument.create(); const copied = await output.copyPages(source, indexes); copied.forEach(page => output.addPage(page)); - downloadBytes(await output.save(), `${baseName(first.file.name)}-selected.pdf`); + downloadBytes(await output.save(), `${baseName(workingPdf.file.name)}-selected.pdf`); } else if (operation === 'delete') { const remove = new Set(indexes); const keep = source.getPageIndices().filter(index => !remove.has(index)); @@ -126,55 +225,107 @@ export default function PdfToolkitPage() { const output = await PDFDocument.create(); const copied = await output.copyPages(source, keep); copied.forEach(page => output.addPage(page)); - downloadBytes(await output.save(), `${baseName(first.file.name)}-pages-removed.pdf`); + downloadBytes(await output.save(), `${baseName(workingPdf.file.name)}-pages-removed.pdf`); } else { for (const index of new Set(indexes)) { const page = source.getPage(index); page.setRotation(degrees((page.getRotation().angle + rotation + 360) % 360)); } - downloadBytes(await source.save(), `${baseName(first.file.name)}-rotated.pdf`); + downloadBytes(await source.save(), `${baseName(workingPdf.file.name)}-rotated.pdf`); } - } catch (err) { setMessage((err as Error).message); } finally { setBusy(false); } + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } }; - const split = async () => { - const first = files.find(item => !item.error && item.pages > 0); - if (!first) return; + const split = async (workingPdf: PdfInfo) => { setBusy(true); setMessage(''); try { - const source = await loadPdf(first.file); + const source = await loadPdf(workingPdf.file); const zip = new JSZip(); for (const index of source.getPageIndices()) { const output = await PDFDocument.create(); const [page] = await output.copyPages(source, [index]); output.addPage(page); - zip.file(`${baseName(first.file.name)}-page-${String(index + 1).padStart(3,'0')}.pdf`, await output.save()); + zip.file(`${baseName(workingPdf.file.name)}-page-${String(index + 1).padStart(3, '0')}.pdf`, await output.save()); } const blob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `${baseName(first.file.name)}-split.zip`; + link.download = `${baseName(workingPdf.file.name)}-split.zip`; link.click(); URL.revokeObjectURL(url); - } catch (err) { setMessage((err as Error).message); } finally { setBusy(false); } + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } }; - const saveMetadata = async () => { - const first = files.find(item => !item.error && item.pages > 0); - if (!first) return; + const saveMetadata = async (workingPdf: PdfInfo) => { setBusy(true); setMessage(''); try { - const doc = await loadPdf(first.file); + const doc = await loadPdf(workingPdf.file); if (metadata.title) doc.setTitle(metadata.title); if (metadata.author) doc.setAuthor(metadata.author); if (metadata.subject) doc.setSubject(metadata.subject); doc.setKeywords(metadata.keywords.split(',').map(value => value.trim()).filter(Boolean)); doc.setModificationDate(new Date()); - downloadBytes(await doc.save(), `${baseName(first.file.name)}-metadata.pdf`); - } catch (err) { setMessage((err as Error).message); } finally { setBusy(false); } + downloadBytes(await doc.save(), `${baseName(workingPdf.file.name)}-metadata.pdf`); + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } + }; + + const compressPdf = async (workingPdf: PdfInfo) => { + if (workingPdf.file.size > MAX_COMPRESSION_BYTES) { + setMessage('For browser stability, compression is limited to PDFs up to 100 MB.'); + return; + } + + setBusy(true); + setMessage(''); + setCompressionResult(null); + try { + const { createQpdfRunner } = await import('qpdf-run'); + const runner = await createQpdfRunner({ + workerUrl: new URL('qpdf-run/worker', import.meta.url).href, + qpdfJsUrl: new URL('qpdf-run/qpdf.js', import.meta.url).href, + wasmUrl: new URL('qpdf-run/qpdf.wasm', import.meta.url).href, + timeoutMs: 90000, + }); + + try { + const input = new Uint8Array(await workingPdf.file.arrayBuffer()); + const output = await runner.runOne({ + input, + inputName: 'input.pdf', + outputName: 'output.pdf', + args: compressionArgs(compressionMode), + }); + const downloaded = output.byteLength < input.byteLength; + setCompressionResult({ original: input.byteLength, compressed: output.byteLength, downloaded }); + + if (downloaded) { + downloadBytes(output, `${baseName(workingPdf.file.name)}-compressed.pdf`); + } else { + setMessage('This PDF is already compact enough that the selected mode did not produce a smaller file. No larger copy was downloaded.'); + } + } finally { + await runner.destroy(); + } + } catch (err) { + setMessage(`Compression failed: ${(err as Error).message}`); + } finally { + setBusy(false); + } }; const imagesToPdf = async () => { @@ -212,30 +363,124 @@ export default function PdfToolkitPage() { page.drawImage(embedded, { x: (pageWidth - width) / 2, y: (pageHeight - height) / 2, width, height }); } downloadBytes(await doc.save(), 'images.pdf'); - } catch (err) { setMessage((err as Error).message); } finally { setBusy(false); } + } catch (err) { + setMessage((err as Error).message); + } finally { + setBusy(false); + } }; - const first = files.find(item => !item.error && item.pages > 0); - const readableCount = files.filter(item => !item.error && item.pages > 0).length; + const readableFiles = files.filter(item => !item.error && item.pages > 0); + const workingPdf = readableFiles.find(item => fileKey(item.file) === selectedKey) ?? null; + const operationHelp = operation === 'extract' + ? 'Creates a new PDF containing only the pages you list, in exactly that order. Use 5,2,1 to reorder or 8-6 to reverse a range.' + : operation === 'delete' + ? 'Creates a new PDF with the listed pages removed. Your original file is never modified.' + : 'Rotates only the listed pages by the angle you choose and downloads a new copy.'; + const compressionHelp = compressionMode === 'lossless' + ? 'Recompresses compatible streams and PDF structure without intentionally reducing image quality. Best first choice for normal documents.' + : compressionMode === 'balanced' + ? 'Includes image optimization at JPEG quality 75 when qpdf can make supported images smaller. Text remains text, but some images may become lossy.' + : 'Uses stronger JPEG quality 50 image optimization when possible. Best for size reduction when some image-quality loss is acceptable.'; - return + return
-

PDF files

-

Add several PDFs at once, or add them one at a time. New selections are appended to the current list.

+
+
+

PDF files

+

Add several PDFs at once, or add them one at a time. New selections are appended to the current list.

+
+ {files.length > 0 && } +
- void addPdfs(event.target.files)} className='mt-2 w-full rounded border p-3 disabled:opacity-50'/> - {files.length > 0 && <>

{files.length} file{files.length === 1 ? '' : 's'} loaded, {readableCount} readable.

{files.map((item, index) =>

{item.file.name}

{item.error ? 'Could not read this PDF' : `${item.pages} page${item.pages === 1 ? '' : 's'}`}

)}
} - + { void addPdfs(event.target.files); event.currentTarget.value = ''; }} className='mt-2 w-full rounded border p-3 disabled:opacity-50'/> + + {files.length > 0 && <> +

{files.length} file{files.length === 1 ? '' : 's'} loaded, {readableFiles.length} readable.

+
{files.map((item, index) =>
+
+

{item.file.name}

+

{item.error ? 'Could not read this PDF' : `${item.pages} page${item.pages === 1 ? '' : 's'} · ${formatBytes(item.file.size)}`}

+
+ + + +
)}
+ } + +
- {first &&

Page operations · {first.file.name}

Use page numbers such as 1-3,5,8-6. Descending ranges are useful for reversing pages.

setSelection(event.target.value)} className='rounded border p-2 font-mono' aria-label='Page selection'/>{operation === 'rotate' ? :
}
} + {readableFiles.length > 0 &&
+ +

Merge uses the full ordered list above. The tools below work on this selected PDF only.

+ +
} - {first &&

Document metadata

} + {workingPdf &&
+

Compress PDF

+

Compression runs locally with qpdf WebAssembly. Searchable/selectable text is preserved; ToolStack does not rasterize every page into an image.

+
+ +

{compressionHelp}

+
+ + {compressionResult &&
+
+
Original{formatBytes(compressionResult.original)}
+
Compressed result{formatBytes(compressionResult.compressed)}
+
Reduction{compressionResult.compressed < compressionResult.original ? `${((1 - compressionResult.compressed / compressionResult.original) * 100).toFixed(1)}%` : 'No reduction'}
+
+

{compressionResult.downloaded ? 'The smaller copy was downloaded automatically.' : 'The result was not smaller, so ToolStack kept your original instead of downloading a larger file.'}

+
} +
} -

Images → PDF

JPEG, PNG and browser-decodable images are fitted onto A4 portrait or landscape pages.

setImages(Array.from(event.target.files ?? []))} className='mt-4 w-full rounded border p-3'/>
+ {workingPdf &&
+

Edit pages in a PDF

+

Extract, reorder, delete or rotate pages in {workingPdf.file.name}. Enter pages such as 1-3,5,8-6. Descending ranges reverse page order.

+
+ setSelection(event.target.value)} className='rounded border p-2 font-mono' aria-label='Page selection'/> + + {operation === 'rotate' ? :
} +
+

{operationHelp}

+
+ + +
+

“Split every page to ZIP” creates one single-page PDF per page and packages all of them into a ZIP file. It does not use the page-selection field.

+
} + + {workingPdf &&
+

Document metadata · {workingPdf.file.name}

+
+ + + + +
+ +
} + +
+

Images → PDF

+

JPEG, PNG and browser-decodable images are fitted onto A4 portrait or landscape pages.

+ setImages(Array.from(event.target.files ?? []))} className='mt-4 w-full rounded border p-3'/> + +
-
PDF operations rewrite document structure. Existing digital signatures can become invalid, and unusual/encrypted PDFs may not be supported. This tool does not claim secure redaction or PDF compression.
+
PDF operations rewrite document structure. Existing digital signatures can become invalid, and unusual/encrypted PDFs may not be supported. Lossless compression mainly optimizes PDF structure and streams, so an already-efficient or image-heavy PDF may shrink only a little. Balanced/strong modes can use lossy JPEG recompression for supported images.
{message &&

{message}

}
; From 1309e19e76b8ebecd7422f354915dbd0869a3f10 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:13:24 +0530 Subject: [PATCH 03/35] Temporarily refresh PDF compression dependency lockfile --- .github/workflows/refresh-pdf-lock.yml | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/refresh-pdf-lock.yml diff --git a/.github/workflows/refresh-pdf-lock.yml b/.github/workflows/refresh-pdf-lock.yml new file mode 100644 index 0000000..e8306e7 --- /dev/null +++ b/.github/workflows/refresh-pdf-lock.yml @@ -0,0 +1,35 @@ +name: Refresh PDF dependency lockfile + +on: + push: + branches: + - feature/pdf-toolkit-compression + paths: + - package.json + - .github/workflows/refresh-pdf-lock.yml + +permissions: + contents: write + +jobs: + refresh-lockfile: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/pdf-toolkit-compression + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm install --package-lock-only + - name: Commit refreshed lockfile + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add package-lock.json + if ! git diff --cached --quiet; then + git commit -m "Refresh PDF compression dependency lockfile" + git push origin HEAD:feature/pdf-toolkit-compression + fi From 41eb41daea48953dd9a6981bfac16510ad1413f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:43:38 +0000 Subject: [PATCH 04/35] Refresh PDF compression dependency lockfile --- package-lock.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 296d56a..15637bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "toolstack", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "toolstack", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "@contentauth/c2pa-web": "^0.14.4", "browser-image-compression": "^2.0.2", @@ -20,6 +20,7 @@ "pdf-lib": "^1.17.1", "piexifjs": "^1.0.6", "postcss-selector-parser": "^7.1.6", + "qpdf-run": "^0.2.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-image-crop": "^11.0.10", @@ -6333,6 +6334,12 @@ "node": ">=6" } }, + "node_modules/qpdf-run": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/qpdf-run/-/qpdf-run-0.2.1.tgz", + "integrity": "sha512-X4ZknJPl7av/o+VEX3XafWyzfOUm9qSoa+JqXS+hGSNVI1Wxk60bKhmLvvX/8gp7pCzbp3KW0zY8aLBjL5EkYQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", From 9938098bac96fe3679731afdf6f2a4b5496b0096 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:13:59 +0530 Subject: [PATCH 05/35] Remove temporary lockfile refresh workflow --- .github/workflows/refresh-pdf-lock.yml | 35 -------------------------- 1 file changed, 35 deletions(-) delete mode 100644 .github/workflows/refresh-pdf-lock.yml diff --git a/.github/workflows/refresh-pdf-lock.yml b/.github/workflows/refresh-pdf-lock.yml deleted file mode 100644 index e8306e7..0000000 --- a/.github/workflows/refresh-pdf-lock.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Refresh PDF dependency lockfile - -on: - push: - branches: - - feature/pdf-toolkit-compression - paths: - - package.json - - .github/workflows/refresh-pdf-lock.yml - -permissions: - contents: write - -jobs: - refresh-lockfile: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/pdf-toolkit-compression - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - run: npm install --package-lock-only - - name: Commit refreshed lockfile - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add package-lock.json - if ! git diff --cached --quiet; then - git commit -m "Refresh PDF compression dependency lockfile" - git push origin HEAD:feature/pdf-toolkit-compression - fi From 0735d7a58e9c08e5124fa8dc4df62abca667ebc4 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:16:00 +0530 Subject: [PATCH 06/35] Advertise PDF compression in tool catalog --- src/config/tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/tools.ts b/src/config/tools.ts index 7b3f028..6607d32 100644 --- a/src/config/tools.ts +++ b/src/config/tools.ts @@ -38,7 +38,7 @@ export const TOOLS: Tool[] = [ tool('text-privacy-cleaner','Text Privacy Cleaner','Reveal or remove hidden Unicode, control and zero-width characters.','Privacy & Security',['privacy','text','unicode','cleaner','security'],{new:true}), tool('image-converter','Image Format Converter','Convert supported images to PNG, JPEG or WebP locally.','Images',['image','convert','png','jpeg','webp'],{new:true}), tool('file-encryption','File Encryption','Encrypt and decrypt files locally with AES-GCM and a password-derived key.','Privacy & Security',['privacy','encryption','aes','file','security'],{featured:true,new:true}), - tool('pdf-toolkit','PDF Toolkit','Merge, split, reorder, extract, delete and rotate PDF pages locally.','PDF & Documents',['pdf','merge','split','pages','documents','privacy'],{featured:true,new:true}), + tool('pdf-toolkit','PDF Toolkit','Compress, merge, split, reorder, extract, delete and rotate PDF pages locally.','PDF & Documents',['pdf','compress','merge','split','pages','documents','privacy'],{featured:true,new:true}), tool('file-signature-inspector','File Type & Signature Inspector','Inspect file magic bytes instead of trusting only the extension.','Privacy & Security',['file','signature','magic bytes','security','inspector'],{new:true}), tool('content-credentials','Content Credentials Inspector','Read embedded C2PA provenance metadata locally with bundled WebAssembly.','Privacy & Security',['c2pa','content credentials','provenance','metadata','privacy'],{featured:true,new:true}), tool('xml-toolbox','XML Toolbox','Validate, format, minify and convert XML to JSON locally.','Converters',['xml','json','format','validate','converter'],{new:true}), From 8c2e2a67a3b0240cc8eaca2d6068fd0a0d787546 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:16:15 +0530 Subject: [PATCH 07/35] Document local PDF compression --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d164df8..59d7966 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ ToolStack is an open-source collection of utilities designed around a strict loc ## Highlights ### PDF & Documents -- PDF Toolkit: merge PDFs, reorder merge inputs, extract/reorder/delete/rotate pages, split every page to ZIP, edit basic metadata and turn images into a PDF. -- PDF operations run in-browser with bundled `pdf-lib`; ToolStack does not claim secure redaction or general PDF compression. +- PDF Toolkit: compress PDFs, merge and reorder merge inputs, remove accidental uploads, select which loaded PDF to edit, extract/reorder/delete/rotate pages, split every page to ZIP, edit basic metadata and turn images into a PDF. +- PDF compression runs in-browser with bundled qpdf WebAssembly. Lossless mode optimizes PDF structure/streams; optional balanced and strong modes can use lossy JPEG recompression for supported images without rasterizing all pages or turning searchable text into images. +- Other PDF editing operations use bundled `pdf-lib`. ToolStack does not claim secure redaction. ### Privacy & Security Image Metadata Remover, Data Anonymizer, File Hash & Checksum, Tracking URL Cleaner, Password & Passphrase Generator, JWT Inspector, Browser Privacy Inspector, Text Privacy Cleaner, AES-GCM File Encryption, File Type & Signature Inspector, Content Credentials (C2PA) Inspector, and HMAC Generator & Verifier. From a994a05b33903c4bba4c8fb3987464556aad91d8 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:40:56 +0530 Subject: [PATCH 08/35] Serve qpdf worker assets from same origin --- scripts/copy-qpdf-assets.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 scripts/copy-qpdf-assets.mjs diff --git a/scripts/copy-qpdf-assets.mjs b/scripts/copy-qpdf-assets.mjs new file mode 100644 index 0000000..9df4952 --- /dev/null +++ b/scripts/copy-qpdf-assets.mjs @@ -0,0 +1,21 @@ +import { copyFile, mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = dirname(fileURLToPath(new URL('../package.json', import.meta.url))); +const packageRoot = join(projectRoot, 'node_modules', 'qpdf-run'); +const outputRoot = join(projectRoot, 'public', 'qpdf'); + +const assets = [ + ['src/worker.js', 'worker.js'], + ['vendor/qpdf/lib/qpdf.js', 'qpdf.js'], + ['vendor/qpdf/lib/qpdf.wasm', 'qpdf.wasm'], +]; + +await mkdir(outputRoot, { recursive: true }); + +for (const [source, destination] of assets) { + await copyFile(join(packageRoot, source), join(outputRoot, destination)); +} + +console.log('Prepared qpdf browser assets in public/qpdf/.'); From 0d32fe1af3c9edfed6307ef0c8979735f2924542 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:41:08 +0530 Subject: [PATCH 09/35] Prepare qpdf assets before dev and build --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index cc58b55..e83813f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,10 @@ "version": "0.3.1", "private": true, "scripts": { + "prepare-qpdf-assets": "node scripts/copy-qpdf-assets.mjs", + "predev": "npm run prepare-qpdf-assets", "dev": "next dev", + "prebuild": "npm run prepare-qpdf-assets", "build": "next build", "start": "next start", "lint": "eslint .", From c6846a43b20b1f0d0165a4157a926a88c02afc43 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:42:35 +0530 Subject: [PATCH 10/35] Work around Turbopack qpdf worker file URLs --- scripts/copy-qpdf-assets.mjs | 38 ++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/scripts/copy-qpdf-assets.mjs b/scripts/copy-qpdf-assets.mjs index 9df4952..88bc024 100644 --- a/scripts/copy-qpdf-assets.mjs +++ b/scripts/copy-qpdf-assets.mjs @@ -1,21 +1,39 @@ -import { copyFile, mkdir } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const projectRoot = dirname(fileURLToPath(new URL('../package.json', import.meta.url))); const packageRoot = join(projectRoot, 'node_modules', 'qpdf-run'); const outputRoot = join(projectRoot, 'public', 'qpdf'); - -const assets = [ - ['src/worker.js', 'worker.js'], - ['vendor/qpdf/lib/qpdf.js', 'qpdf.js'], - ['vendor/qpdf/lib/qpdf.wasm', 'qpdf.wasm'], -]; +const workerPath = join(packageRoot, 'src', 'worker.js'); +const marker = 'TOOLSTACK_QPDF_ASSET_FALLBACK'; await mkdir(outputRoot, { recursive: true }); -for (const [source, destination] of assets) { - await copyFile(join(packageRoot, source), join(outputRoot, destination)); +let workerSource = await readFile(workerPath, 'utf8'); + +// Next 16/Turbopack can turn qpdf-run's companion asset URLs into file:/// URLs +// inside the worker. A browser worker cannot importScripts() from file:// when the +// application is being served over HTTP(S), so fall back to ToolStack's same-origin +// public/qpdf copies when that happens. +if (!workerSource.includes(marker)) { + const initLine = ' await initQpdf(message.qpdfJsUrl, message.wasmUrl);'; + if (!workerSource.includes(initLine)) { + throw new Error('qpdf-run worker layout changed; update scripts/copy-qpdf-assets.mjs before continuing.'); + } + + workerSource = workerSource.replace( + initLine, + ` // ${marker}\n await initQpdf(resolveToolStackAsset(message.qpdfJsUrl, 'qpdf.js'), resolveToolStackAsset(message.wasmUrl, 'qpdf.wasm'));`, + ); + + workerSource += `\nfunction resolveToolStackAsset(url, filename) {\n try {\n var resolved = new URL(String(url || ''), self.location.href);\n if (resolved.protocol !== 'file:') return resolved.href;\n } catch (_) {\n // Fall through to the same-origin public asset.\n }\n return new URL('/qpdf/' + filename, self.location.origin).href;\n}\n`; + + await writeFile(workerPath, workerSource, 'utf8'); } -console.log('Prepared qpdf browser assets in public/qpdf/.'); +await writeFile(join(outputRoot, 'worker.js'), workerSource, 'utf8'); +await copyFile(join(packageRoot, 'vendor', 'qpdf', 'lib', 'qpdf.js'), join(outputRoot, 'qpdf.js')); +await copyFile(join(packageRoot, 'vendor', 'qpdf', 'lib', 'qpdf.wasm'), join(outputRoot, 'qpdf.wasm')); + +console.log('Prepared qpdf browser assets in public/qpdf/ with the Turbopack URL fallback.'); From 83b5a473b9cbdb753d5d5fcf0e320a476dcedf0f Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:42:42 +0530 Subject: [PATCH 11/35] Ignore generated qpdf public assets --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5ef6a52..c058b68 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ /.next/ /out/ +# generated browser runtime assets +/public/qpdf/ + # production /build From 0c91f20e18208231d4b865287ccfe44bfef2f9ee Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:42:49 +0530 Subject: [PATCH 12/35] Gate high severity runtime dependency vulnerabilities --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58aa29a..beb05ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: node-version: 24 cache: npm - run: npm ci + - run: npm audit --omit=dev --audit-level=high - run: npm run privacy-check - run: npm run lint - run: npm run typecheck From 0aaceac48985094cc9efb153a5db67f894ea8eb7 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:56:33 +0530 Subject: [PATCH 13/35] Add PDF.js for raster compression --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index e83813f..088da63 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "next": "16.3.4", "papaparse": "^5.5.3", "pdf-lib": "^1.17.1", + "pdfjs-dist": "^6.3.289", "piexifjs": "^1.0.6", "postcss-selector-parser": "^7.1.6", "qpdf-run": "^0.2.1", From 028e9d97b1f7bcb0ad9ba69924872b5ef352dd20 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:56:47 +0530 Subject: [PATCH 14/35] Prepare PDF.js worker for raster compression --- scripts/copy-qpdf-assets.mjs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/copy-qpdf-assets.mjs b/scripts/copy-qpdf-assets.mjs index 88bc024..7788dab 100644 --- a/scripts/copy-qpdf-assets.mjs +++ b/scripts/copy-qpdf-assets.mjs @@ -3,12 +3,15 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const projectRoot = dirname(fileURLToPath(new URL('../package.json', import.meta.url))); -const packageRoot = join(projectRoot, 'node_modules', 'qpdf-run'); -const outputRoot = join(projectRoot, 'public', 'qpdf'); -const workerPath = join(packageRoot, 'src', 'worker.js'); +const qpdfPackageRoot = join(projectRoot, 'node_modules', 'qpdf-run'); +const qpdfOutputRoot = join(projectRoot, 'public', 'qpdf'); +const pdfjsPackageRoot = join(projectRoot, 'node_modules', 'pdfjs-dist'); +const pdfjsOutputRoot = join(projectRoot, 'public', 'pdfjs'); +const workerPath = join(qpdfPackageRoot, 'src', 'worker.js'); const marker = 'TOOLSTACK_QPDF_ASSET_FALLBACK'; -await mkdir(outputRoot, { recursive: true }); +await mkdir(qpdfOutputRoot, { recursive: true }); +await mkdir(pdfjsOutputRoot, { recursive: true }); let workerSource = await readFile(workerPath, 'utf8'); @@ -32,8 +35,9 @@ if (!workerSource.includes(marker)) { await writeFile(workerPath, workerSource, 'utf8'); } -await writeFile(join(outputRoot, 'worker.js'), workerSource, 'utf8'); -await copyFile(join(packageRoot, 'vendor', 'qpdf', 'lib', 'qpdf.js'), join(outputRoot, 'qpdf.js')); -await copyFile(join(packageRoot, 'vendor', 'qpdf', 'lib', 'qpdf.wasm'), join(outputRoot, 'qpdf.wasm')); +await writeFile(join(qpdfOutputRoot, 'worker.js'), workerSource, 'utf8'); +await copyFile(join(qpdfPackageRoot, 'vendor', 'qpdf', 'lib', 'qpdf.js'), join(qpdfOutputRoot, 'qpdf.js')); +await copyFile(join(qpdfPackageRoot, 'vendor', 'qpdf', 'lib', 'qpdf.wasm'), join(qpdfOutputRoot, 'qpdf.wasm')); +await copyFile(join(pdfjsPackageRoot, 'build', 'pdf.worker.min.mjs'), join(pdfjsOutputRoot, 'pdf.worker.min.mjs')); -console.log('Prepared qpdf browser assets in public/qpdf/ with the Turbopack URL fallback.'); +console.log('Prepared qpdf and PDF.js browser assets for local PDF compression.'); From a7f454ab8ba3b91bee4e37bd3af5a2d930388ac0 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:57:31 +0530 Subject: [PATCH 15/35] Temporarily patch PDF raster compression --- .github/workflows/patch-pdf-raster.yml | 239 +++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 .github/workflows/patch-pdf-raster.yml diff --git a/.github/workflows/patch-pdf-raster.yml b/.github/workflows/patch-pdf-raster.yml new file mode 100644 index 0000000..f1e8a2f --- /dev/null +++ b/.github/workflows/patch-pdf-raster.yml @@ -0,0 +1,239 @@ +name: Patch PDF raster compression +on: + push: + branches: [feature/pdf-toolkit-compression] +permissions: + contents: write +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/pdf-toolkit-compression + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm install --package-lock-only + - name: Patch PDF toolkit + shell: bash + run: | + python - <<'PY' + from pathlib import Path + import re + + path = Path('src/app/pdf-toolkit/page.tsx') + text = path.read_text() + + text = text.replace( + "const MAX_COMPRESSION_BYTES = 100 * 1024 * 1024;", + "const MAX_COMPRESSION_BYTES = 100 * 1024 * 1024;\nconst MAX_RASTER_PAGES = 150;\nconst MAX_RASTER_PAGE_PIXELS = 20_000_000;\nconst MAX_RASTER_TOTAL_PIXELS = 220_000_000;" + ) + + old_args = re.compile(r"function compressionArgs\(mode: CompressionMode\) \{.*?\n\}\n\nexport default", re.S) + new_args = r'''function losslessCompressionArgs() { + return [ + '--compress-streams=y', + '--decode-level=generalized', + '--recompress-flate', + '--compression-level=9', + '--object-streams=generate', + '--', + 'input.pdf', + 'output.pdf', + ]; + } + + function rasterSettings(mode: Exclude) { + return mode === 'balanced' + ? { dpi: 150, jpegQuality: 0.72 } + : { dpi: 96, jpegQuality: 0.52 }; + } + + function canvasToJpeg(canvas: HTMLCanvasElement, quality: number) { + return new Promise((resolve, reject) => { + canvas.toBlob(async blob => { + if (!blob) { + reject(new Error('The browser could not encode a compressed PDF page.')); + return; + } + resolve(new Uint8Array(await blob.arrayBuffer())); + }, 'image/jpeg', quality); + }); + } + + async function rasterCompressPdf( + file: File, + mode: Exclude, + onProgress: (current: number, total: number) => void, + ) { + const pdfjs = await import('pdfjs-dist/build/pdf.mjs'); + pdfjs.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.mjs'; + + const input = new Uint8Array(await file.arrayBuffer()); + const loadingTask = pdfjs.getDocument({ data: input }); + const source = await loadingTask.promise; + const { dpi, jpegQuality } = rasterSettings(mode); + + try { + if (source.numPages > MAX_RASTER_PAGES) { + throw new Error(`Balanced/Strong compression is limited to ${MAX_RASTER_PAGES} pages. Use Lossless for larger documents.`); + } + + const output = await PDFDocument.create(); + let totalPixels = 0; + + for (let pageNumber = 1; pageNumber <= source.numPages; pageNumber++) { + onProgress(pageNumber, source.numPages); + const page = await source.getPage(pageNumber); + const baseViewport = page.getViewport({ scale: 1 }); + let scale = dpi / 72; + const projectedPixels = baseViewport.width * scale * baseViewport.height * scale; + + if (projectedPixels > MAX_RASTER_PAGE_PIXELS) { + scale *= Math.sqrt(MAX_RASTER_PAGE_PIXELS / projectedPixels); + } + + const viewport = page.getViewport({ scale }); + const width = Math.max(1, Math.ceil(viewport.width)); + const height = Math.max(1, Math.ceil(viewport.height)); + totalPixels += width * height; + + if (totalPixels > MAX_RASTER_TOTAL_PIXELS) { + throw new Error('Balanced/Strong compression would require too much browser memory. Split this PDF or use Lossless mode.'); + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { alpha: false }); + if (!context) throw new Error('Canvas rendering is unavailable in this browser.'); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, width, height); + + await page.render({ canvasContext: context, viewport, background: '#ffffff' }).promise; + const jpeg = await canvasToJpeg(canvas, jpegQuality); + const image = await output.embedJpg(jpeg); + const outputPage = output.addPage([baseViewport.width, baseViewport.height]); + outputPage.drawImage(image, { x: 0, y: 0, width: baseViewport.width, height: baseViewport.height }); + page.cleanup(); + canvas.width = 1; + canvas.height = 1; + } + + return await output.save({ useObjectStreams: true }); + } finally { + await source.destroy(); + } + } + + export default''' + text, count = old_args.subn(new_args, text, count=1) + if count != 1: + raise SystemExit('Could not replace compression argument block') + + text = text.replace( + " const [compressionResult, setCompressionResult] = useState(null);", + " const [compressionResult, setCompressionResult] = useState(null);\n const [compressionStatus, setCompressionStatus] = useState('');" + ) + + compress_block = re.compile(r" const compressPdf = async \(workingPdf: PdfInfo\) => \{.*?\n \};\n\n const imagesToPdf", re.S) + new_compress = r''' const compressPdf = async (workingPdf: PdfInfo) => { + if (workingPdf.file.size > MAX_COMPRESSION_BYTES) { + setMessage('For browser stability, compression is limited to PDFs up to 100 MB.'); + return; + } + + setBusy(true); + setMessage(''); + setCompressionResult(null); + setCompressionStatus(compressionMode === 'lossless' ? 'Optimizing PDF structure…' : 'Preparing pages…'); + + try { + const input = new Uint8Array(await workingPdf.file.arrayBuffer()); + let output: Uint8Array; + + if (compressionMode === 'lossless') { + const { createQpdfRunner } = await import('qpdf-run'); + const runner = await createQpdfRunner({ + workerUrl: '/qpdf/worker.js', + qpdfJsUrl: '/qpdf/qpdf.js', + wasmUrl: '/qpdf/qpdf.wasm', + timeoutMs: 90000, + }); + + try { + output = await runner.runOne({ + input, + inputName: 'input.pdf', + outputName: 'output.pdf', + args: losslessCompressionArgs(), + }); + } finally { + await runner.destroy(); + } + } else { + output = await rasterCompressPdf( + workingPdf.file, + compressionMode, + (current, total) => setCompressionStatus(`Rendering page ${current} of ${total}…`), + ); + } + + const downloaded = output.byteLength < input.byteLength; + setCompressionResult({ original: input.byteLength, compressed: output.byteLength, downloaded }); + + if (downloaded) { + downloadBytes(output, `${baseName(workingPdf.file.name)}-compressed.pdf`); + } else { + setMessage('This PDF is already compact enough that the selected mode did not produce a smaller file. No larger copy was downloaded.'); + } + } catch (err) { + const error = err as Error & { stderr?: string[] }; + const detail = Array.isArray(error.stderr) && error.stderr.length + ? `${error.message} — ${error.stderr.slice(-2).join(' ')}` + : error.message || String(err); + setMessage(`Compression failed: ${detail}`); + } finally { + setCompressionStatus(''); + setBusy(false); + } + }; + + const imagesToPdf''' + text, count = compress_block.subn(new_compress, text, count=1) + if count != 1: + raise SystemExit('Could not replace compressPdf block') + + old_help = """ const compressionHelp = compressionMode === 'lossless'\n ? 'Recompresses compatible streams and PDF structure without intentionally reducing image quality. Best first choice for normal documents.'\n : compressionMode === 'balanced'\n ? 'Includes image optimization at JPEG quality 75 when qpdf can make supported images smaller. Text remains text, but some images may become lossy.'\n : 'Uses stronger JPEG quality 50 image optimization when possible. Best for size reduction when some image-quality loss is acceptable.';""" + new_help = """ const compressionHelp = compressionMode === 'lossless'\n ? 'Recompresses compatible streams and PDF structure without intentionally reducing image quality. Selectable text, links, forms and document structure are preserved.'\n : compressionMode === 'balanced'\n ? 'Renders each page at about 150 DPI and rebuilds the PDF with JPEG pages. Usually much smaller for scans, but selectable text, links, forms, bookmarks, accessibility structure and signatures are removed.'\n : 'Renders each page at about 96 DPI with stronger JPEG compression. Best for small email/upload files when reduced sharpness and flattened pages are acceptable.';""" + if old_help not in text: + raise SystemExit('Could not find compression help block') + text = text.replace(old_help, new_help) + + text = text.replace( + "

Compression runs locally with qpdf WebAssembly. Searchable/selectable text is preserved; ToolStack does not rasterize every page into an image.

", + "

All compression stays in your browser. Lossless preserves document structure; Balanced and Strong deliberately flatten pages for much larger reductions on scanned or image-heavy PDFs.

" + ) + text = text.replace("", "") + text = text.replace("", "") + text = text.replace( + " ", + " \n {compressionStatus &&

{compressionStatus}

}" + ) + text = text.replace( + "Lossless compression mainly optimizes PDF structure and streams, so an already-efficient or image-heavy PDF may shrink only a little. Balanced/strong modes can use lossy JPEG recompression for supported images.", + "Lossless compression mainly optimizes PDF structure and streams, so an already-efficient or image-heavy PDF may shrink only a little. Balanced/Strong modes flatten every page into a JPEG-backed page, so text selection, links, forms, bookmarks, accessibility structure and digital signatures are not preserved." + ) + + path.write_text(text) + PY + - name: Commit patch and lockfile + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/app/pdf-toolkit/page.tsx package-lock.json + git commit -m "Use PDF.js for reliable lossy PDF compression" + git push origin HEAD:feature/pdf-toolkit-compression From f9e4725766bdf95ae9c372e19b6ebf9527783e93 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:27:41 +0000 Subject: [PATCH 16/35] Use PDF.js for reliable lossy PDF compression --- package-lock.json | 278 +++++++++++++++++++++++++++++++++++ src/app/pdf-toolkit/page.tsx | 185 ++++++++++++++++++----- 2 files changed, 423 insertions(+), 40 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15637bf..3a6dcc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "next": "16.3.4", "papaparse": "^5.5.3", "pdf-lib": "^1.17.1", + "pdfjs-dist": "^6.3.289", "piexifjs": "^1.0.6", "postcss-selector-parser": "^7.1.6", "qpdf-run": "^0.2.1", @@ -1230,6 +1231,271 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.8.tgz", + "integrity": "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.8", + "@napi-rs/canvas-darwin-arm64": "1.0.8", + "@napi-rs/canvas-darwin-x64": "1.0.8", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.8", + "@napi-rs/canvas-linux-arm64-musl": "1.0.8", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.8", + "@napi-rs/canvas-linux-x64-gnu": "1.0.8", + "@napi-rs/canvas-linux-x64-musl": "1.0.8", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.8", + "@napi-rs/canvas-win32-x64-msvc": "1.0.8" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.8.tgz", + "integrity": "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.8.tgz", + "integrity": "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.8.tgz", + "integrity": "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.8.tgz", + "integrity": "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.8.tgz", + "integrity": "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.8.tgz", + "integrity": "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.8.tgz", + "integrity": "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.8.tgz", + "integrity": "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.8.tgz", + "integrity": "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.8.tgz", + "integrity": "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.8.tgz", + "integrity": "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz", @@ -6219,6 +6485,18 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "node_modules/pdfjs-dist": { + "version": "6.3.289", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", + "integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/src/app/pdf-toolkit/page.tsx b/src/app/pdf-toolkit/page.tsx index 03b0d01..fb87d7b 100644 --- a/src/app/pdf-toolkit/page.tsx +++ b/src/app/pdf-toolkit/page.tsx @@ -15,6 +15,9 @@ const EMPTY_META: MetadataState = { title: '', author: '', subject: '', keywords const MAX_PDF_FILES = 20; const MAX_TOTAL_BYTES = 250 * 1024 * 1024; const MAX_COMPRESSION_BYTES = 100 * 1024 * 1024; +const MAX_RASTER_PAGES = 150; +const MAX_RASTER_PAGE_PIXELS = 20_000_000; +const MAX_RASTER_TOTAL_PIXELS = 220_000_000; const fileKey = (file: File) => `${file.name}:${file.size}:${file.lastModified}`; const baseName = (name: string) => name.replace(/\.pdf$/i, ''); @@ -54,18 +57,100 @@ async function readMetadata(item: PdfInfo): Promise { }; } -function compressionArgs(mode: CompressionMode) { - const args = [ +function losslessCompressionArgs() { + return [ '--compress-streams=y', '--decode-level=generalized', '--recompress-flate', '--compression-level=9', '--object-streams=generate', + '--', + 'input.pdf', + 'output.pdf', ]; - if (mode !== 'lossless') { - args.push('--optimize-images', `--jpeg-quality=${mode === 'balanced' ? 75 : 50}`); +} + +function rasterSettings(mode: Exclude) { + return mode === 'balanced' + ? { dpi: 150, jpegQuality: 0.72 } + : { dpi: 96, jpegQuality: 0.52 }; +} + +function canvasToJpeg(canvas: HTMLCanvasElement, quality: number) { + return new Promise((resolve, reject) => { + canvas.toBlob(async blob => { + if (!blob) { + reject(new Error('The browser could not encode a compressed PDF page.')); + return; + } + resolve(new Uint8Array(await blob.arrayBuffer())); + }, 'image/jpeg', quality); + }); +} + +async function rasterCompressPdf( + file: File, + mode: Exclude, + onProgress: (current: number, total: number) => void, +) { + const pdfjs = await import('pdfjs-dist/build/pdf.mjs'); + pdfjs.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.mjs'; + + const input = new Uint8Array(await file.arrayBuffer()); + const loadingTask = pdfjs.getDocument({ data: input }); + const source = await loadingTask.promise; + const { dpi, jpegQuality } = rasterSettings(mode); + + try { + if (source.numPages > MAX_RASTER_PAGES) { + throw new Error(`Balanced/Strong compression is limited to ${MAX_RASTER_PAGES} pages. Use Lossless for larger documents.`); + } + + const output = await PDFDocument.create(); + let totalPixels = 0; + + for (let pageNumber = 1; pageNumber <= source.numPages; pageNumber++) { + onProgress(pageNumber, source.numPages); + const page = await source.getPage(pageNumber); + const baseViewport = page.getViewport({ scale: 1 }); + let scale = dpi / 72; + const projectedPixels = baseViewport.width * scale * baseViewport.height * scale; + + if (projectedPixels > MAX_RASTER_PAGE_PIXELS) { + scale *= Math.sqrt(MAX_RASTER_PAGE_PIXELS / projectedPixels); + } + + const viewport = page.getViewport({ scale }); + const width = Math.max(1, Math.ceil(viewport.width)); + const height = Math.max(1, Math.ceil(viewport.height)); + totalPixels += width * height; + + if (totalPixels > MAX_RASTER_TOTAL_PIXELS) { + throw new Error('Balanced/Strong compression would require too much browser memory. Split this PDF or use Lossless mode.'); + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { alpha: false }); + if (!context) throw new Error('Canvas rendering is unavailable in this browser.'); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, width, height); + + await page.render({ canvasContext: context, viewport, background: '#ffffff' }).promise; + const jpeg = await canvasToJpeg(canvas, jpegQuality); + const image = await output.embedJpg(jpeg); + const outputPage = output.addPage([baseViewport.width, baseViewport.height]); + outputPage.drawImage(image, { x: 0, y: 0, width: baseViewport.width, height: baseViewport.height }); + page.cleanup(); + canvas.width = 1; + canvas.height = 1; + } + + return await output.save({ useObjectStreams: true }); + } finally { + await source.destroy(); } - return [...args, '--', 'input.pdf', 'output.pdf']; } export default function PdfToolkitPage() { @@ -80,6 +165,7 @@ export default function PdfToolkitPage() { const [images, setImages] = useState([]); const [compressionMode, setCompressionMode] = useState('lossless'); const [compressionResult, setCompressionResult] = useState(null); + const [compressionStatus, setCompressionStatus] = useState(''); const activatePdf = async (item: PdfInfo) => { setSelectedKey(fileKey(item.file)); @@ -285,50 +371,68 @@ export default function PdfToolkitPage() { }; const compressPdf = async (workingPdf: PdfInfo) => { - if (workingPdf.file.size > MAX_COMPRESSION_BYTES) { - setMessage('For browser stability, compression is limited to PDFs up to 100 MB.'); - return; - } + if (workingPdf.file.size > MAX_COMPRESSION_BYTES) { + setMessage('For browser stability, compression is limited to PDFs up to 100 MB.'); + return; + } - setBusy(true); - setMessage(''); - setCompressionResult(null); - try { + setBusy(true); + setMessage(''); + setCompressionResult(null); + setCompressionStatus(compressionMode === 'lossless' ? 'Optimizing PDF structure…' : 'Preparing pages…'); + + try { + const input = new Uint8Array(await workingPdf.file.arrayBuffer()); + let output: Uint8Array; + + if (compressionMode === 'lossless') { const { createQpdfRunner } = await import('qpdf-run'); const runner = await createQpdfRunner({ - workerUrl: new URL('qpdf-run/worker', import.meta.url).href, - qpdfJsUrl: new URL('qpdf-run/qpdf.js', import.meta.url).href, - wasmUrl: new URL('qpdf-run/qpdf.wasm', import.meta.url).href, + workerUrl: '/qpdf/worker.js', + qpdfJsUrl: '/qpdf/qpdf.js', + wasmUrl: '/qpdf/qpdf.wasm', timeoutMs: 90000, }); try { - const input = new Uint8Array(await workingPdf.file.arrayBuffer()); - const output = await runner.runOne({ + output = await runner.runOne({ input, inputName: 'input.pdf', outputName: 'output.pdf', - args: compressionArgs(compressionMode), + args: losslessCompressionArgs(), }); - const downloaded = output.byteLength < input.byteLength; - setCompressionResult({ original: input.byteLength, compressed: output.byteLength, downloaded }); - - if (downloaded) { - downloadBytes(output, `${baseName(workingPdf.file.name)}-compressed.pdf`); - } else { - setMessage('This PDF is already compact enough that the selected mode did not produce a smaller file. No larger copy was downloaded.'); - } } finally { await runner.destroy(); } - } catch (err) { - setMessage(`Compression failed: ${(err as Error).message}`); - } finally { - setBusy(false); + } else { + output = await rasterCompressPdf( + workingPdf.file, + compressionMode, + (current, total) => setCompressionStatus(`Rendering page ${current} of ${total}…`), + ); } - }; - const imagesToPdf = async () => { + const downloaded = output.byteLength < input.byteLength; + setCompressionResult({ original: input.byteLength, compressed: output.byteLength, downloaded }); + + if (downloaded) { + downloadBytes(output, `${baseName(workingPdf.file.name)}-compressed.pdf`); + } else { + setMessage('This PDF is already compact enough that the selected mode did not produce a smaller file. No larger copy was downloaded.'); + } + } catch (err) { + const error = err as Error & { stderr?: string[] }; + const detail = Array.isArray(error.stderr) && error.stderr.length + ? `${error.message} — ${error.stderr.slice(-2).join(' ')}` + : error.message || String(err); + setMessage(`Compression failed: ${detail}`); + } finally { + setCompressionStatus(''); + setBusy(false); + } +}; + +const imagesToPdf = async () => { if (!images.length) return; setBusy(true); setMessage(''); @@ -378,10 +482,10 @@ export default function PdfToolkitPage() { ? 'Creates a new PDF with the listed pages removed. Your original file is never modified.' : 'Rotates only the listed pages by the angle you choose and downloads a new copy.'; const compressionHelp = compressionMode === 'lossless' - ? 'Recompresses compatible streams and PDF structure without intentionally reducing image quality. Best first choice for normal documents.' + ? 'Recompresses compatible streams and PDF structure without intentionally reducing image quality. Selectable text, links, forms and document structure are preserved.' : compressionMode === 'balanced' - ? 'Includes image optimization at JPEG quality 75 when qpdf can make supported images smaller. Text remains text, but some images may become lossy.' - : 'Uses stronger JPEG quality 50 image optimization when possible. Best for size reduction when some image-quality loss is acceptable.'; + ? 'Renders each page at about 150 DPI and rebuilds the PDF with JPEG pages. Usually much smaller for scans, but selectable text, links, forms, bookmarks, accessibility structure and signatures are removed.' + : 'Renders each page at about 96 DPI with stronger JPEG compression. Best for small email/upload files when reduced sharpness and flattened pages are acceptable.'; return
@@ -422,16 +526,17 @@ export default function PdfToolkitPage() { {workingPdf &&

Compress PDF

-

Compression runs locally with qpdf WebAssembly. Searchable/selectable text is preserved; ToolStack does not rasterize every page into an image.

+

All compression stays in your browser. Lossless preserves document structure; Balanced and Strong deliberately flatten pages for much larger reductions on scanned or image-heavy PDFs.

{compressionHelp}

+ {compressionStatus &&

{compressionStatus}

} {compressionResult &&
Original{formatBytes(compressionResult.original)}
@@ -480,7 +585,7 @@ export default function PdfToolkitPage() {
-
PDF operations rewrite document structure. Existing digital signatures can become invalid, and unusual/encrypted PDFs may not be supported. Lossless compression mainly optimizes PDF structure and streams, so an already-efficient or image-heavy PDF may shrink only a little. Balanced/strong modes can use lossy JPEG recompression for supported images.
+
PDF operations rewrite document structure. Existing digital signatures can become invalid, and unusual/encrypted PDFs may not be supported. Lossless compression mainly optimizes PDF structure and streams, so an already-efficient or image-heavy PDF may shrink only a little. Balanced/Strong modes flatten every page into a JPEG-backed page, so text selection, links, forms, bookmarks, accessibility structure and digital signatures are not preserved.
{message &&

{message}

}
; From 19d0318697980612219d1b4cce86cd5f6d000e84 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:59:32 +0530 Subject: [PATCH 17/35] Remove temporary PDF patch workflow --- .github/workflows/patch-pdf-raster.yml | 239 ------------------------- 1 file changed, 239 deletions(-) delete mode 100644 .github/workflows/patch-pdf-raster.yml diff --git a/.github/workflows/patch-pdf-raster.yml b/.github/workflows/patch-pdf-raster.yml deleted file mode 100644 index f1e8a2f..0000000 --- a/.github/workflows/patch-pdf-raster.yml +++ /dev/null @@ -1,239 +0,0 @@ -name: Patch PDF raster compression -on: - push: - branches: [feature/pdf-toolkit-compression] -permissions: - contents: write -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/pdf-toolkit-compression - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - run: npm install --package-lock-only - - name: Patch PDF toolkit - shell: bash - run: | - python - <<'PY' - from pathlib import Path - import re - - path = Path('src/app/pdf-toolkit/page.tsx') - text = path.read_text() - - text = text.replace( - "const MAX_COMPRESSION_BYTES = 100 * 1024 * 1024;", - "const MAX_COMPRESSION_BYTES = 100 * 1024 * 1024;\nconst MAX_RASTER_PAGES = 150;\nconst MAX_RASTER_PAGE_PIXELS = 20_000_000;\nconst MAX_RASTER_TOTAL_PIXELS = 220_000_000;" - ) - - old_args = re.compile(r"function compressionArgs\(mode: CompressionMode\) \{.*?\n\}\n\nexport default", re.S) - new_args = r'''function losslessCompressionArgs() { - return [ - '--compress-streams=y', - '--decode-level=generalized', - '--recompress-flate', - '--compression-level=9', - '--object-streams=generate', - '--', - 'input.pdf', - 'output.pdf', - ]; - } - - function rasterSettings(mode: Exclude) { - return mode === 'balanced' - ? { dpi: 150, jpegQuality: 0.72 } - : { dpi: 96, jpegQuality: 0.52 }; - } - - function canvasToJpeg(canvas: HTMLCanvasElement, quality: number) { - return new Promise((resolve, reject) => { - canvas.toBlob(async blob => { - if (!blob) { - reject(new Error('The browser could not encode a compressed PDF page.')); - return; - } - resolve(new Uint8Array(await blob.arrayBuffer())); - }, 'image/jpeg', quality); - }); - } - - async function rasterCompressPdf( - file: File, - mode: Exclude, - onProgress: (current: number, total: number) => void, - ) { - const pdfjs = await import('pdfjs-dist/build/pdf.mjs'); - pdfjs.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.mjs'; - - const input = new Uint8Array(await file.arrayBuffer()); - const loadingTask = pdfjs.getDocument({ data: input }); - const source = await loadingTask.promise; - const { dpi, jpegQuality } = rasterSettings(mode); - - try { - if (source.numPages > MAX_RASTER_PAGES) { - throw new Error(`Balanced/Strong compression is limited to ${MAX_RASTER_PAGES} pages. Use Lossless for larger documents.`); - } - - const output = await PDFDocument.create(); - let totalPixels = 0; - - for (let pageNumber = 1; pageNumber <= source.numPages; pageNumber++) { - onProgress(pageNumber, source.numPages); - const page = await source.getPage(pageNumber); - const baseViewport = page.getViewport({ scale: 1 }); - let scale = dpi / 72; - const projectedPixels = baseViewport.width * scale * baseViewport.height * scale; - - if (projectedPixels > MAX_RASTER_PAGE_PIXELS) { - scale *= Math.sqrt(MAX_RASTER_PAGE_PIXELS / projectedPixels); - } - - const viewport = page.getViewport({ scale }); - const width = Math.max(1, Math.ceil(viewport.width)); - const height = Math.max(1, Math.ceil(viewport.height)); - totalPixels += width * height; - - if (totalPixels > MAX_RASTER_TOTAL_PIXELS) { - throw new Error('Balanced/Strong compression would require too much browser memory. Split this PDF or use Lossless mode.'); - } - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext('2d', { alpha: false }); - if (!context) throw new Error('Canvas rendering is unavailable in this browser.'); - context.fillStyle = '#ffffff'; - context.fillRect(0, 0, width, height); - - await page.render({ canvasContext: context, viewport, background: '#ffffff' }).promise; - const jpeg = await canvasToJpeg(canvas, jpegQuality); - const image = await output.embedJpg(jpeg); - const outputPage = output.addPage([baseViewport.width, baseViewport.height]); - outputPage.drawImage(image, { x: 0, y: 0, width: baseViewport.width, height: baseViewport.height }); - page.cleanup(); - canvas.width = 1; - canvas.height = 1; - } - - return await output.save({ useObjectStreams: true }); - } finally { - await source.destroy(); - } - } - - export default''' - text, count = old_args.subn(new_args, text, count=1) - if count != 1: - raise SystemExit('Could not replace compression argument block') - - text = text.replace( - " const [compressionResult, setCompressionResult] = useState(null);", - " const [compressionResult, setCompressionResult] = useState(null);\n const [compressionStatus, setCompressionStatus] = useState('');" - ) - - compress_block = re.compile(r" const compressPdf = async \(workingPdf: PdfInfo\) => \{.*?\n \};\n\n const imagesToPdf", re.S) - new_compress = r''' const compressPdf = async (workingPdf: PdfInfo) => { - if (workingPdf.file.size > MAX_COMPRESSION_BYTES) { - setMessage('For browser stability, compression is limited to PDFs up to 100 MB.'); - return; - } - - setBusy(true); - setMessage(''); - setCompressionResult(null); - setCompressionStatus(compressionMode === 'lossless' ? 'Optimizing PDF structure…' : 'Preparing pages…'); - - try { - const input = new Uint8Array(await workingPdf.file.arrayBuffer()); - let output: Uint8Array; - - if (compressionMode === 'lossless') { - const { createQpdfRunner } = await import('qpdf-run'); - const runner = await createQpdfRunner({ - workerUrl: '/qpdf/worker.js', - qpdfJsUrl: '/qpdf/qpdf.js', - wasmUrl: '/qpdf/qpdf.wasm', - timeoutMs: 90000, - }); - - try { - output = await runner.runOne({ - input, - inputName: 'input.pdf', - outputName: 'output.pdf', - args: losslessCompressionArgs(), - }); - } finally { - await runner.destroy(); - } - } else { - output = await rasterCompressPdf( - workingPdf.file, - compressionMode, - (current, total) => setCompressionStatus(`Rendering page ${current} of ${total}…`), - ); - } - - const downloaded = output.byteLength < input.byteLength; - setCompressionResult({ original: input.byteLength, compressed: output.byteLength, downloaded }); - - if (downloaded) { - downloadBytes(output, `${baseName(workingPdf.file.name)}-compressed.pdf`); - } else { - setMessage('This PDF is already compact enough that the selected mode did not produce a smaller file. No larger copy was downloaded.'); - } - } catch (err) { - const error = err as Error & { stderr?: string[] }; - const detail = Array.isArray(error.stderr) && error.stderr.length - ? `${error.message} — ${error.stderr.slice(-2).join(' ')}` - : error.message || String(err); - setMessage(`Compression failed: ${detail}`); - } finally { - setCompressionStatus(''); - setBusy(false); - } - }; - - const imagesToPdf''' - text, count = compress_block.subn(new_compress, text, count=1) - if count != 1: - raise SystemExit('Could not replace compressPdf block') - - old_help = """ const compressionHelp = compressionMode === 'lossless'\n ? 'Recompresses compatible streams and PDF structure without intentionally reducing image quality. Best first choice for normal documents.'\n : compressionMode === 'balanced'\n ? 'Includes image optimization at JPEG quality 75 when qpdf can make supported images smaller. Text remains text, but some images may become lossy.'\n : 'Uses stronger JPEG quality 50 image optimization when possible. Best for size reduction when some image-quality loss is acceptable.';""" - new_help = """ const compressionHelp = compressionMode === 'lossless'\n ? 'Recompresses compatible streams and PDF structure without intentionally reducing image quality. Selectable text, links, forms and document structure are preserved.'\n : compressionMode === 'balanced'\n ? 'Renders each page at about 150 DPI and rebuilds the PDF with JPEG pages. Usually much smaller for scans, but selectable text, links, forms, bookmarks, accessibility structure and signatures are removed.'\n : 'Renders each page at about 96 DPI with stronger JPEG compression. Best for small email/upload files when reduced sharpness and flattened pages are acceptable.';""" - if old_help not in text: - raise SystemExit('Could not find compression help block') - text = text.replace(old_help, new_help) - - text = text.replace( - "

Compression runs locally with qpdf WebAssembly. Searchable/selectable text is preserved; ToolStack does not rasterize every page into an image.

", - "

All compression stays in your browser. Lossless preserves document structure; Balanced and Strong deliberately flatten pages for much larger reductions on scanned or image-heavy PDFs.

" - ) - text = text.replace("", "") - text = text.replace("", "") - text = text.replace( - " ", - " \n {compressionStatus &&

{compressionStatus}

}" - ) - text = text.replace( - "Lossless compression mainly optimizes PDF structure and streams, so an already-efficient or image-heavy PDF may shrink only a little. Balanced/strong modes can use lossy JPEG recompression for supported images.", - "Lossless compression mainly optimizes PDF structure and streams, so an already-efficient or image-heavy PDF may shrink only a little. Balanced/Strong modes flatten every page into a JPEG-backed page, so text selection, links, forms, bookmarks, accessibility structure and digital signatures are not preserved." - ) - - path.write_text(text) - PY - - name: Commit patch and lockfile - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/app/pdf-toolkit/page.tsx package-lock.json - git commit -m "Use PDF.js for reliable lossy PDF compression" - git push origin HEAD:feature/pdf-toolkit-compression From 7292dceb5ada94f85fc5602950622ea6762e0374 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:59:55 +0530 Subject: [PATCH 18/35] Document PDF raster compression tradeoffs --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 59d7966..deb0702 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ ToolStack is an open-source collection of utilities designed around a strict loc ### PDF & Documents - PDF Toolkit: compress PDFs, merge and reorder merge inputs, remove accidental uploads, select which loaded PDF to edit, extract/reorder/delete/rotate pages, split every page to ZIP, edit basic metadata and turn images into a PDF. -- PDF compression runs in-browser with bundled qpdf WebAssembly. Lossless mode optimizes PDF structure/streams; optional balanced and strong modes can use lossy JPEG recompression for supported images without rasterizing all pages or turning searchable text into images. +- Lossless PDF compression runs in-browser with bundled qpdf WebAssembly and preserves document structure, selectable text, links and forms. +- Balanced and Strong compression use bundled PDF.js to render pages locally and rebuild them as JPEG-backed PDF pages. This can produce much larger reductions for scans/image-heavy files, but intentionally flattens the document: selectable text, links, forms, bookmarks, accessibility structure and digital signatures are not preserved. - Other PDF editing operations use bundled `pdf-lib`. ToolStack does not claim secure redaction. ### Privacy & Security From d52f11adab0cb649ea87d3e78c9c745643a008b1 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:02:34 +0530 Subject: [PATCH 19/35] Use typed PDF.js package entry --- src/app/pdf-toolkit/page.tsx | 110 +++++++++++++++++------------------ 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/src/app/pdf-toolkit/page.tsx b/src/app/pdf-toolkit/page.tsx index fb87d7b..ea59e69 100644 --- a/src/app/pdf-toolkit/page.tsx +++ b/src/app/pdf-toolkit/page.tsx @@ -93,7 +93,7 @@ async function rasterCompressPdf( mode: Exclude, onProgress: (current: number, total: number) => void, ) { - const pdfjs = await import('pdfjs-dist/build/pdf.mjs'); + const pdfjs = await import('pdfjs-dist'); pdfjs.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.mjs'; const input = new Uint8Array(await file.arrayBuffer()); @@ -371,68 +371,68 @@ export default function PdfToolkitPage() { }; const compressPdf = async (workingPdf: PdfInfo) => { - if (workingPdf.file.size > MAX_COMPRESSION_BYTES) { - setMessage('For browser stability, compression is limited to PDFs up to 100 MB.'); - return; - } + if (workingPdf.file.size > MAX_COMPRESSION_BYTES) { + setMessage('For browser stability, compression is limited to PDFs up to 100 MB.'); + return; + } - setBusy(true); - setMessage(''); - setCompressionResult(null); - setCompressionStatus(compressionMode === 'lossless' ? 'Optimizing PDF structure…' : 'Preparing pages…'); + setBusy(true); + setMessage(''); + setCompressionResult(null); + setCompressionStatus(compressionMode === 'lossless' ? 'Optimizing PDF structure…' : 'Preparing pages…'); - try { - const input = new Uint8Array(await workingPdf.file.arrayBuffer()); - let output: Uint8Array; - - if (compressionMode === 'lossless') { - const { createQpdfRunner } = await import('qpdf-run'); - const runner = await createQpdfRunner({ - workerUrl: '/qpdf/worker.js', - qpdfJsUrl: '/qpdf/qpdf.js', - wasmUrl: '/qpdf/qpdf.wasm', - timeoutMs: 90000, - }); - - try { - output = await runner.runOne({ - input, - inputName: 'input.pdf', - outputName: 'output.pdf', - args: losslessCompressionArgs(), + try { + const input = new Uint8Array(await workingPdf.file.arrayBuffer()); + let output: Uint8Array; + + if (compressionMode === 'lossless') { + const { createQpdfRunner } = await import('qpdf-run'); + const runner = await createQpdfRunner({ + workerUrl: '/qpdf/worker.js', + qpdfJsUrl: '/qpdf/qpdf.js', + wasmUrl: '/qpdf/qpdf.wasm', + timeoutMs: 90000, }); - } finally { - await runner.destroy(); + + try { + output = await runner.runOne({ + input, + inputName: 'input.pdf', + outputName: 'output.pdf', + args: losslessCompressionArgs(), + }); + } finally { + await runner.destroy(); + } + } else { + output = await rasterCompressPdf( + workingPdf.file, + compressionMode, + (current, total) => setCompressionStatus(`Rendering page ${current} of ${total}…`), + ); } - } else { - output = await rasterCompressPdf( - workingPdf.file, - compressionMode, - (current, total) => setCompressionStatus(`Rendering page ${current} of ${total}…`), - ); - } - const downloaded = output.byteLength < input.byteLength; - setCompressionResult({ original: input.byteLength, compressed: output.byteLength, downloaded }); + const downloaded = output.byteLength < input.byteLength; + setCompressionResult({ original: input.byteLength, compressed: output.byteLength, downloaded }); - if (downloaded) { - downloadBytes(output, `${baseName(workingPdf.file.name)}-compressed.pdf`); - } else { - setMessage('This PDF is already compact enough that the selected mode did not produce a smaller file. No larger copy was downloaded.'); + if (downloaded) { + downloadBytes(output, `${baseName(workingPdf.file.name)}-compressed.pdf`); + } else { + setMessage('This PDF is already compact enough that the selected mode did not produce a smaller file. No larger copy was downloaded.'); + } + } catch (err) { + const error = err as Error & { stderr?: string[] }; + const detail = Array.isArray(error.stderr) && error.stderr.length + ? `${error.message} — ${error.stderr.slice(-2).join(' ')}` + : error.message || String(err); + setMessage(`Compression failed: ${detail}`); + } finally { + setCompressionStatus(''); + setBusy(false); } - } catch (err) { - const error = err as Error & { stderr?: string[] }; - const detail = Array.isArray(error.stderr) && error.stderr.length - ? `${error.message} — ${error.stderr.slice(-2).join(' ')}` - : error.message || String(err); - setMessage(`Compression failed: ${detail}`); - } finally { - setCompressionStatus(''); - setBusy(false); - } -}; + }; -const imagesToPdf = async () => { + const imagesToPdf = async () => { if (!images.length) return; setBusy(true); setMessage(''); From f1b0de688d1bc1fbfe22557ae40391603a8e1a18 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:03:48 +0530 Subject: [PATCH 20/35] Temporarily apply PDF.js 6 API fixes --- .github/workflows/fix-pdfjs-types.yml | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/fix-pdfjs-types.yml diff --git a/.github/workflows/fix-pdfjs-types.yml b/.github/workflows/fix-pdfjs-types.yml new file mode 100644 index 0000000..5142826 --- /dev/null +++ b/.github/workflows/fix-pdfjs-types.yml @@ -0,0 +1,34 @@ +name: Fix PDF.js 6 API usage +on: + push: + branches: [feature/pdf-toolkit-compression] +permissions: + contents: write +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/pdf-toolkit-compression + - name: Patch PDF.js API usage + run: | + python - <<'PY' + from pathlib import Path + p = Path('src/app/pdf-toolkit/page.tsx') + s = p.read_text() + s = s.replace( + "await page.render({ canvasContext: context, viewport, background: '#ffffff' }).promise;", + "await page.render({ canvas, canvasContext: context, viewport, background: '#ffffff' }).promise;" + ) + s = s.replace("await source.destroy();", "await loadingTask.destroy();") + p.write_text(s) + PY + - name: Commit fix + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/app/pdf-toolkit/page.tsx + git commit -m "Fix PDF.js 6 rendering API usage" + git push origin HEAD:feature/pdf-toolkit-compression From 5ca63e2f7952b76b8a7000c7f72c528b7b42f2f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:33:54 +0000 Subject: [PATCH 21/35] Fix PDF.js 6 rendering API usage --- src/app/pdf-toolkit/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/pdf-toolkit/page.tsx b/src/app/pdf-toolkit/page.tsx index ea59e69..5c824a6 100644 --- a/src/app/pdf-toolkit/page.tsx +++ b/src/app/pdf-toolkit/page.tsx @@ -137,7 +137,7 @@ async function rasterCompressPdf( context.fillStyle = '#ffffff'; context.fillRect(0, 0, width, height); - await page.render({ canvasContext: context, viewport, background: '#ffffff' }).promise; + await page.render({ canvas, canvasContext: context, viewport, background: '#ffffff' }).promise; const jpeg = await canvasToJpeg(canvas, jpegQuality); const image = await output.embedJpg(jpeg); const outputPage = output.addPage([baseViewport.width, baseViewport.height]); @@ -149,7 +149,7 @@ async function rasterCompressPdf( return await output.save({ useObjectStreams: true }); } finally { - await source.destroy(); + await loadingTask.destroy(); } } From 59956c10517a340c7309e5a8c919322231e31050 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:04:16 +0530 Subject: [PATCH 22/35] Remove temporary PDF.js fix workflow --- .github/workflows/fix-pdfjs-types.yml | 34 --------------------------- 1 file changed, 34 deletions(-) delete mode 100644 .github/workflows/fix-pdfjs-types.yml diff --git a/.github/workflows/fix-pdfjs-types.yml b/.github/workflows/fix-pdfjs-types.yml deleted file mode 100644 index 5142826..0000000 --- a/.github/workflows/fix-pdfjs-types.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Fix PDF.js 6 API usage -on: - push: - branches: [feature/pdf-toolkit-compression] -permissions: - contents: write -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/pdf-toolkit-compression - - name: Patch PDF.js API usage - run: | - python - <<'PY' - from pathlib import Path - p = Path('src/app/pdf-toolkit/page.tsx') - s = p.read_text() - s = s.replace( - "await page.render({ canvasContext: context, viewport, background: '#ffffff' }).promise;", - "await page.render({ canvas, canvasContext: context, viewport, background: '#ffffff' }).promise;" - ) - s = s.replace("await source.destroy();", "await loadingTask.destroy();") - p.write_text(s) - PY - - name: Commit fix - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/app/pdf-toolkit/page.tsx - git commit -m "Fix PDF.js 6 rendering API usage" - git push origin HEAD:feature/pdf-toolkit-compression From b2ffa32c308ae233c073b2ef6a43423b70d6d6a6 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:36:03 +0530 Subject: [PATCH 23/35] Temporarily patch PDF selector UI --- .github/workflows/fix-pdf-selector-ui.yml | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/workflows/fix-pdf-selector-ui.yml diff --git a/.github/workflows/fix-pdf-selector-ui.yml b/.github/workflows/fix-pdf-selector-ui.yml new file mode 100644 index 0000000..4d8629e --- /dev/null +++ b/.github/workflows/fix-pdf-selector-ui.yml @@ -0,0 +1,69 @@ +name: Fix PDF selector UI +on: + push: + branches: [feature/pdf-toolkit-compression] +permissions: + contents: write +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/pdf-toolkit-compression + - name: Replace native PDF selector + run: | + python - <<'PY' + from pathlib import Path + + path = Path('src/app/pdf-toolkit/page.tsx') + text = path.read_text() + + old = """ {readableFiles.length > 0 &&
+ +

Merge uses the full ordered list above. The tools below work on this selected PDF only.

+ +
}""" + + new = """ {readableFiles.length > 0 &&
+

PDF to edit or compress

+

Merge uses the full ordered list above. The tools below work on {readableFiles.length === 1 ? 'this PDF' : 'the selected PDF'} only.

+ + {readableFiles.length === 1 && workingPdf ?
+

{workingPdf.file.name}

+

{workingPdf.pages} page{workingPdf.pages === 1 ? '' : 's'} · {formatBytes(workingPdf.file.size)}

+
:
+ {readableFiles.map(item => { + const key = fileKey(item.file); + const selected = key === selectedKey; + return ; + })} +
} +
}""" + + if old not in text: + raise SystemExit('PDF selector block not found') + + path.write_text(text.replace(old, new)) + PY + - name: Commit fix + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/app/pdf-toolkit/page.tsx + git commit -m "Improve PDF working-file selector UX" + git push origin HEAD:feature/pdf-toolkit-compression From a4f9aed0ee942a6e94438dc676212c0bf11ba77d Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:37:23 +0530 Subject: [PATCH 24/35] Fix temporary PDF selector patch workflow --- .github/workflows/fix-pdf-selector-ui.yml | 48 +---------------------- 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/.github/workflows/fix-pdf-selector-ui.yml b/.github/workflows/fix-pdf-selector-ui.yml index 4d8629e..e689367 100644 --- a/.github/workflows/fix-pdf-selector-ui.yml +++ b/.github/workflows/fix-pdf-selector-ui.yml @@ -14,52 +14,8 @@ jobs: ref: feature/pdf-toolkit-compression - name: Replace native PDF selector run: | - python - <<'PY' - from pathlib import Path - - path = Path('src/app/pdf-toolkit/page.tsx') - text = path.read_text() - - old = """ {readableFiles.length > 0 &&
- -

Merge uses the full ordered list above. The tools below work on this selected PDF only.

- -
}""" - - new = """ {readableFiles.length > 0 &&
-

PDF to edit or compress

-

Merge uses the full ordered list above. The tools below work on {readableFiles.length === 1 ? 'this PDF' : 'the selected PDF'} only.

- - {readableFiles.length === 1 && workingPdf ?
-

{workingPdf.file.name}

-

{workingPdf.pages} page{workingPdf.pages === 1 ? '' : 's'} · {formatBytes(workingPdf.file.size)}

-
:
- {readableFiles.map(item => { - const key = fileKey(item.file); - const selected = key === selectedKey; - return ; - })} -
} -
}""" - - if old not in text: - raise SystemExit('PDF selector block not found') - - path.write_text(text.replace(old, new)) - PY + echo 'CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKcGF0aCA9IFBhdGgoInNyYy9hcHAvcGRmLXRvb2xraXQvcGFnZS50c3giKQp0ZXh0ID0gcGF0aC5yZWFkX3RleHQoKQoKb2xkID0gIiIiICAgICAge3JlYWRhYmxlRmlsZXMubGVuZ3RoID4gMCAmJiA8c2VjdGlvbiBjbGFzc05hbWU9J3JvdW5kZWQteGwgYm9yZGVyIGJvcmRlci1za3ktMTAwIGJnLXNreS01MCBwLTUnPgogICAgICAgIDxsYWJlbCBodG1sRm9yPSd3b3JraW5nLXBkZicgY2xhc3NOYW1lPSdibG9jayBmb250LXNlbWlib2xkIHRleHQtc2xhdGUtOTAwJz5QREYgdG8gZWRpdCBvciBjb21wcmVzczwvbGFiZWw+CiAgICAgICAgPHAgY2xhc3NOYW1lPSdtdC0xIHRleHQtc20gdGV4dC1zbGF0ZS02MDAnPk1lcmdlIHVzZXMgdGhlIGZ1bGwgb3JkZXJlZCBsaXN0IGFib3ZlLiBUaGUgdG9vbHMgYmVsb3cgd29yayBvbiB0aGlzIHNlbGVjdGVkIFBERiBvbmx5LjwvcD4KICAgICAgICA8c2VsZWN0IGlkPSd3b3JraW5nLXBkZicgZGlzYWJsZWQ9e2J1c3l9IHZhbHVlPXt3b3JraW5nUGRmID8gZmlsZUtleSh3b3JraW5nUGRmLmZpbGUpIDogJyd9IG9uQ2hhbmdlPXtldmVudCA9PiB2b2lkIHNlbGVjdFdvcmtpbmdQZGYoZXZlbnQudGFyZ2V0LnZhbHVlKX0gY2xhc3NOYW1lPSdtdC0zIHctZnVsbCByb3VuZGVkIGJvcmRlciBib3JkZXItc2t5LTIwMCBiZy13aGl0ZSBwLTMgZGlzYWJsZWQ6b3BhY2l0eS01MCc+CiAgICAgICAgICB7cmVhZGFibGVGaWxlcy5tYXAoaXRlbSA9PiA8b3B0aW9uIGtleT17ZmlsZUtleShpdGVtLmZpbGUpfSB2YWx1ZT17ZmlsZUtleShpdGVtLmZpbGUpfT57aXRlbS5maWxlLm5hbWV9ICh7aXRlbS5wYWdlc30gcGFnZXMpPC9vcHRpb24+KX0KICAgICAgICA8L3NlbGVjdD4KICAgICAgPC9zZWN0aW9uPn0iIiIKCm5ldyA9ICIiIiAgICAgIHtyZWFkYWJsZUZpbGVzLmxlbmd0aCA+IDAgJiYgPHNlY3Rpb24gY2xhc3NOYW1lPSdyb3VuZGVkLXhsIGJvcmRlciBib3JkZXItc2t5LTEwMCBiZy1za3ktNTAgcC01Jz4KICAgICAgICA8aDIgY2xhc3NOYW1lPSdmb250LXNlbWlib2xkIHRleHQtc2xhdGUtOTAwJz5QREYgdG8gZWRpdCBvciBjb21wcmVzczwvaDI+CiAgICAgICAgPHAgY2xhc3NOYW1lPSdtdC0xIHRleHQtc20gdGV4dC1zbGF0ZS02MDAnPk1lcmdlIHVzZXMgdGhlIGZ1bGwgb3JkZXJlZCBsaXN0IGFib3ZlLiBUaGUgdG9vbHMgYmVsb3cgd29yayBvbiB7cmVhZGFibGVGaWxlcy5sZW5ndGggPT09IDEgPyAndGhpcyBQREYnIDogJ3RoZSBzZWxlY3RlZCBQREYnfSBvbmx5LjwvcD4KCiAgICAgICAge3JlYWRhYmxlRmlsZXMubGVuZ3RoID09PSAxICYmIHdvcmtpbmdQZGYgPyA8ZGl2IGNsYXNzTmFtZT0nbXQtMyBtaW4tdy0wIHJvdW5kZWQtbGcgYm9yZGVyIGJvcmRlci1za3ktMjAwIGJnLXdoaXRlIHAtMyc+CiAgICAgICAgICA8cCBjbGFzc05hbWU9J3RydW5jYXRlIGZvbnQtbWVkaXVtIHRleHQtc2xhdGUtOTAwJyB0aXRsZT17d29ya2luZ1BkZi5maWxlLm5hbWV9Pnt3b3JraW5nUGRmLmZpbGUubmFtZX08L3A+CiAgICAgICAgICA8cCBjbGFzc05hbWU9J210LTEgdGV4dC1zbSB0ZXh0LXNsYXRlLTUwMCc+e3dvcmtpbmdQZGYucGFnZXN9IHBhZ2V7d29ya2luZ1BkZi5wYWdlcyA9PT0gMSA/ICcnIDogJ3MnfSDCtyB7Zm9ybWF0Qnl0ZXMod29ya2luZ1BkZi5maWxlLnNpemUpfTwvcD4KICAgICAgICA8L2Rpdj4gOiA8ZGl2IGNsYXNzTmFtZT0nbXQtMyBtYXgtaC02NCBzcGFjZS15LTIgb3ZlcmZsb3cteS1hdXRvIHByLTEnIHJvbGU9J2xpc3Rib3gnIGFyaWEtbGFiZWw9J1BERiB0byBlZGl0IG9yIGNvbXByZXNzJz4KICAgICAgICAgIHtyZWFkYWJsZUZpbGVzLm1hcChpdGVtID0+IHsKICAgICAgICAgICAgY29uc3Qga2V5ID0gZmlsZUtleShpdGVtLmZpbGUpOwogICAgICAgICAgICBjb25zdCBzZWxlY3RlZCA9IGtleSA9PT0gc2VsZWN0ZWRLZXk7CiAgICAgICAgICAgIHJldHVybiA8YnV0dG9uCiAgICAgICAgICAgICAga2V5PXtrZXl9CiAgICAgICAgICAgICAgdHlwZT0nYnV0dG9uJwogICAgICAgICAgICAgIHJvbGU9J29wdGlvbicKICAgICAgICAgICAgICBhcmlhLXNlbGVjdGVkPXtzZWxlY3RlZH0KICAgICAgICAgICAgICBkaXNhYmxlZD17YnVzeX0KICAgICAgICAgICAgICBvbkNsaWNrPXsoKSA9PiB2b2lkIHNlbGVjdFdvcmtpbmdQZGYoa2V5KX0KICAgICAgICAgICAgICBjbGFzc05hbWU9e2BibG9jayB3LWZ1bGwgbWluLXctMCByb3VuZGVkLWxnIGJvcmRlciBwLTMgdGV4dC1sZWZ0IHRyYW5zaXRpb24gZGlzYWJsZWQ6b3BhY2l0eS01MCAke3NlbGVjdGVkID8gJ2JvcmRlci1za3ktNDAwIGJnLXdoaXRlIHJpbmctMiByaW5nLXNreS0xMDAnIDogJ2JvcmRlci1za3ktMjAwIGJnLXdoaXRlLzcwIGhvdmVyOmJnLXdoaXRlJ31gfQogICAgICAgICAgICA+CiAgICAgICAgICAgICAgPHNwYW4gY2xhc3NOYW1lPSdibG9jayB0cnVuY2F0ZSBmb250LW1lZGl1bSB0ZXh0LXNsYXRlLTkwMCcgdGl0bGU9e2l0ZW0uZmlsZS5uYW1lfT57aXRlbS5maWxlLm5hbWV9PC9zcGFuPgogICAgICAgICAgICAgIDxzcGFuIGNsYXNzTmFtZT0nbXQtMSBibG9jayB0ZXh0LXNtIHRleHQtc2xhdGUtNTAwJz57aXRlbS5wYWdlc30gcGFnZXtpdGVtLnBhZ2VzID09PSAxID8gJycgOiAncyd9IMK3IHtmb3JtYXRCeXRlcyhpdGVtLmZpbGUuc2l6ZSl9PC9zcGFuPgogICAgICAgICAgICA8L2J1dHRvbj47CiAgICAgICAgICB9KX0KICAgICAgICA8L2Rpdj59CiAgICAgIDwvc2VjdGlvbj59IiIiCgppZiBvbGQgbm90IGluIHRleHQ6CiAgICByYWlzZSBTeXN0ZW1FeGl0KCJQREYgc2VsZWN0b3IgYmxvY2sgbm90IGZvdW5kIikKcGF0aC53cml0ZV90ZXh0KHRleHQucmVwbGFjZShvbGQsIG5ldykpCg==' | base64 -d > /tmp/patch_pdf_selector.py + python /tmp/patch_pdf_selector.py - name: Commit fix run: | git config user.name "github-actions[bot]" From 094e8724ad10a8216d8fb84e3b7b4902181dccfe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:07:30 +0000 Subject: [PATCH 25/35] Improve PDF working-file selector UX --- src/app/pdf-toolkit/page.tsx | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/app/pdf-toolkit/page.tsx b/src/app/pdf-toolkit/page.tsx index 5c824a6..c20bbbf 100644 --- a/src/app/pdf-toolkit/page.tsx +++ b/src/app/pdf-toolkit/page.tsx @@ -517,11 +517,30 @@ export default function PdfToolkitPage() { {readableFiles.length > 0 &&
- -

Merge uses the full ordered list above. The tools below work on this selected PDF only.

- +

PDF to edit or compress

+

Merge uses the full ordered list above. The tools below work on {readableFiles.length === 1 ? 'this PDF' : 'the selected PDF'} only.

+ + {readableFiles.length === 1 && workingPdf ?
+

{workingPdf.file.name}

+

{workingPdf.pages} page{workingPdf.pages === 1 ? '' : 's'} · {formatBytes(workingPdf.file.size)}

+
:
+ {readableFiles.map(item => { + const key = fileKey(item.file); + const selected = key === selectedKey; + return ; + })} +
}
} {workingPdf &&
From 48acb043327217c8f8bf6d98b7fff46457670ef2 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:37:50 +0530 Subject: [PATCH 26/35] Remove temporary PDF selector patch workflow --- .github/workflows/fix-pdf-selector-ui.yml | 25 ----------------------- 1 file changed, 25 deletions(-) delete mode 100644 .github/workflows/fix-pdf-selector-ui.yml diff --git a/.github/workflows/fix-pdf-selector-ui.yml b/.github/workflows/fix-pdf-selector-ui.yml deleted file mode 100644 index e689367..0000000 --- a/.github/workflows/fix-pdf-selector-ui.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Fix PDF selector UI -on: - push: - branches: [feature/pdf-toolkit-compression] -permissions: - contents: write -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/pdf-toolkit-compression - - name: Replace native PDF selector - run: | - echo 'CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKcGF0aCA9IFBhdGgoInNyYy9hcHAvcGRmLXRvb2xraXQvcGFnZS50c3giKQp0ZXh0ID0gcGF0aC5yZWFkX3RleHQoKQoKb2xkID0gIiIiICAgICAge3JlYWRhYmxlRmlsZXMubGVuZ3RoID4gMCAmJiA8c2VjdGlvbiBjbGFzc05hbWU9J3JvdW5kZWQteGwgYm9yZGVyIGJvcmRlci1za3ktMTAwIGJnLXNreS01MCBwLTUnPgogICAgICAgIDxsYWJlbCBodG1sRm9yPSd3b3JraW5nLXBkZicgY2xhc3NOYW1lPSdibG9jayBmb250LXNlbWlib2xkIHRleHQtc2xhdGUtOTAwJz5QREYgdG8gZWRpdCBvciBjb21wcmVzczwvbGFiZWw+CiAgICAgICAgPHAgY2xhc3NOYW1lPSdtdC0xIHRleHQtc20gdGV4dC1zbGF0ZS02MDAnPk1lcmdlIHVzZXMgdGhlIGZ1bGwgb3JkZXJlZCBsaXN0IGFib3ZlLiBUaGUgdG9vbHMgYmVsb3cgd29yayBvbiB0aGlzIHNlbGVjdGVkIFBERiBvbmx5LjwvcD4KICAgICAgICA8c2VsZWN0IGlkPSd3b3JraW5nLXBkZicgZGlzYWJsZWQ9e2J1c3l9IHZhbHVlPXt3b3JraW5nUGRmID8gZmlsZUtleSh3b3JraW5nUGRmLmZpbGUpIDogJyd9IG9uQ2hhbmdlPXtldmVudCA9PiB2b2lkIHNlbGVjdFdvcmtpbmdQZGYoZXZlbnQudGFyZ2V0LnZhbHVlKX0gY2xhc3NOYW1lPSdtdC0zIHctZnVsbCByb3VuZGVkIGJvcmRlciBib3JkZXItc2t5LTIwMCBiZy13aGl0ZSBwLTMgZGlzYWJsZWQ6b3BhY2l0eS01MCc+CiAgICAgICAgICB7cmVhZGFibGVGaWxlcy5tYXAoaXRlbSA9PiA8b3B0aW9uIGtleT17ZmlsZUtleShpdGVtLmZpbGUpfSB2YWx1ZT17ZmlsZUtleShpdGVtLmZpbGUpfT57aXRlbS5maWxlLm5hbWV9ICh7aXRlbS5wYWdlc30gcGFnZXMpPC9vcHRpb24+KX0KICAgICAgICA8L3NlbGVjdD4KICAgICAgPC9zZWN0aW9uPn0iIiIKCm5ldyA9ICIiIiAgICAgIHtyZWFkYWJsZUZpbGVzLmxlbmd0aCA+IDAgJiYgPHNlY3Rpb24gY2xhc3NOYW1lPSdyb3VuZGVkLXhsIGJvcmRlciBib3JkZXItc2t5LTEwMCBiZy1za3ktNTAgcC01Jz4KICAgICAgICA8aDIgY2xhc3NOYW1lPSdmb250LXNlbWlib2xkIHRleHQtc2xhdGUtOTAwJz5QREYgdG8gZWRpdCBvciBjb21wcmVzczwvaDI+CiAgICAgICAgPHAgY2xhc3NOYW1lPSdtdC0xIHRleHQtc20gdGV4dC1zbGF0ZS02MDAnPk1lcmdlIHVzZXMgdGhlIGZ1bGwgb3JkZXJlZCBsaXN0IGFib3ZlLiBUaGUgdG9vbHMgYmVsb3cgd29yayBvbiB7cmVhZGFibGVGaWxlcy5sZW5ndGggPT09IDEgPyAndGhpcyBQREYnIDogJ3RoZSBzZWxlY3RlZCBQREYnfSBvbmx5LjwvcD4KCiAgICAgICAge3JlYWRhYmxlRmlsZXMubGVuZ3RoID09PSAxICYmIHdvcmtpbmdQZGYgPyA8ZGl2IGNsYXNzTmFtZT0nbXQtMyBtaW4tdy0wIHJvdW5kZWQtbGcgYm9yZGVyIGJvcmRlci1za3ktMjAwIGJnLXdoaXRlIHAtMyc+CiAgICAgICAgICA8cCBjbGFzc05hbWU9J3RydW5jYXRlIGZvbnQtbWVkaXVtIHRleHQtc2xhdGUtOTAwJyB0aXRsZT17d29ya2luZ1BkZi5maWxlLm5hbWV9Pnt3b3JraW5nUGRmLmZpbGUubmFtZX08L3A+CiAgICAgICAgICA8cCBjbGFzc05hbWU9J210LTEgdGV4dC1zbSB0ZXh0LXNsYXRlLTUwMCc+e3dvcmtpbmdQZGYucGFnZXN9IHBhZ2V7d29ya2luZ1BkZi5wYWdlcyA9PT0gMSA/ICcnIDogJ3MnfSDCtyB7Zm9ybWF0Qnl0ZXMod29ya2luZ1BkZi5maWxlLnNpemUpfTwvcD4KICAgICAgICA8L2Rpdj4gOiA8ZGl2IGNsYXNzTmFtZT0nbXQtMyBtYXgtaC02NCBzcGFjZS15LTIgb3ZlcmZsb3cteS1hdXRvIHByLTEnIHJvbGU9J2xpc3Rib3gnIGFyaWEtbGFiZWw9J1BERiB0byBlZGl0IG9yIGNvbXByZXNzJz4KICAgICAgICAgIHtyZWFkYWJsZUZpbGVzLm1hcChpdGVtID0+IHsKICAgICAgICAgICAgY29uc3Qga2V5ID0gZmlsZUtleShpdGVtLmZpbGUpOwogICAgICAgICAgICBjb25zdCBzZWxlY3RlZCA9IGtleSA9PT0gc2VsZWN0ZWRLZXk7CiAgICAgICAgICAgIHJldHVybiA8YnV0dG9uCiAgICAgICAgICAgICAga2V5PXtrZXl9CiAgICAgICAgICAgICAgdHlwZT0nYnV0dG9uJwogICAgICAgICAgICAgIHJvbGU9J29wdGlvbicKICAgICAgICAgICAgICBhcmlhLXNlbGVjdGVkPXtzZWxlY3RlZH0KICAgICAgICAgICAgICBkaXNhYmxlZD17YnVzeX0KICAgICAgICAgICAgICBvbkNsaWNrPXsoKSA9PiB2b2lkIHNlbGVjdFdvcmtpbmdQZGYoa2V5KX0KICAgICAgICAgICAgICBjbGFzc05hbWU9e2BibG9jayB3LWZ1bGwgbWluLXctMCByb3VuZGVkLWxnIGJvcmRlciBwLTMgdGV4dC1sZWZ0IHRyYW5zaXRpb24gZGlzYWJsZWQ6b3BhY2l0eS01MCAke3NlbGVjdGVkID8gJ2JvcmRlci1za3ktNDAwIGJnLXdoaXRlIHJpbmctMiByaW5nLXNreS0xMDAnIDogJ2JvcmRlci1za3ktMjAwIGJnLXdoaXRlLzcwIGhvdmVyOmJnLXdoaXRlJ31gfQogICAgICAgICAgICA+CiAgICAgICAgICAgICAgPHNwYW4gY2xhc3NOYW1lPSdibG9jayB0cnVuY2F0ZSBmb250LW1lZGl1bSB0ZXh0LXNsYXRlLTkwMCcgdGl0bGU9e2l0ZW0uZmlsZS5uYW1lfT57aXRlbS5maWxlLm5hbWV9PC9zcGFuPgogICAgICAgICAgICAgIDxzcGFuIGNsYXNzTmFtZT0nbXQtMSBibG9jayB0ZXh0LXNtIHRleHQtc2xhdGUtNTAwJz57aXRlbS5wYWdlc30gcGFnZXtpdGVtLnBhZ2VzID09PSAxID8gJycgOiAncyd9IMK3IHtmb3JtYXRCeXRlcyhpdGVtLmZpbGUuc2l6ZSl9PC9zcGFuPgogICAgICAgICAgICA8L2J1dHRvbj47CiAgICAgICAgICB9KX0KICAgICAgICA8L2Rpdj59CiAgICAgIDwvc2VjdGlvbj59IiIiCgppZiBvbGQgbm90IGluIHRleHQ6CiAgICByYWlzZSBTeXN0ZW1FeGl0KCJQREYgc2VsZWN0b3IgYmxvY2sgbm90IGZvdW5kIikKcGF0aC53cml0ZV90ZXh0KHRleHQucmVwbGFjZShvbGQsIG5ldykpCg==' | base64 -d > /tmp/patch_pdf_selector.py - python /tmp/patch_pdf_selector.py - - name: Commit fix - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/app/pdf-toolkit/page.tsx - git commit -m "Improve PDF working-file selector UX" - git push origin HEAD:feature/pdf-toolkit-compression From 86cc7dfed03b6ee3f36ede0787b51d77fd4905d8 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:44:59 +0530 Subject: [PATCH 27/35] Prevent stale PWA cache during local development --- src/components/PwaRegistration.tsx | 42 +++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/components/PwaRegistration.tsx b/src/components/PwaRegistration.tsx index 50ee199..59b2899 100644 --- a/src/components/PwaRegistration.tsx +++ b/src/components/PwaRegistration.tsx @@ -1,3 +1,43 @@ 'use client'; + import { useEffect } from 'react'; -export default function PwaRegistration() { useEffect(() => { if (process.env.NODE_ENV !== 'production' || !('serviceWorker' in navigator)) return; navigator.serviceWorker.register('/sw.js').catch(() => undefined); }, []); return null; } + +const TOOLSTACK_CACHE_PREFIX = 'toolstack-'; + +async function cleanupDevelopmentPwaState() { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map(async registration => { + const workers = [registration.active, registration.waiting, registration.installing].filter(Boolean); + const isToolStackWorker = workers.some(worker => { + try { + return new URL(worker!.scriptURL).pathname === '/sw.js'; + } catch { + return false; + } + }); + if (isToolStackWorker) await registration.unregister(); + })); + + if ('caches' in window) { + const keys = await caches.keys(); + await Promise.all(keys.filter(key => key.startsWith(TOOLSTACK_CACHE_PREFIX)).map(key => caches.delete(key))); + } +} + +export default function PwaRegistration() { + useEffect(() => { + if (!('serviceWorker' in navigator)) return; + + if (process.env.NODE_ENV !== 'production') { + void cleanupDevelopmentPwaState(); + return; + } + + void navigator.serviceWorker + .register('/sw.js', { updateViaCache: 'none' }) + .then(registration => registration.update()) + .catch(() => undefined); + }, []); + + return null; +} From 3ecdda9468e60999b04a86f45bed445bda4ce5a7 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:50:33 +0530 Subject: [PATCH 28/35] Temporarily patch PDF hydration boundary --- .github/workflows/fix-pdf-hydration.yml | 75 +++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/fix-pdf-hydration.yml diff --git a/.github/workflows/fix-pdf-hydration.yml b/.github/workflows/fix-pdf-hydration.yml new file mode 100644 index 0000000..3f8a4ec --- /dev/null +++ b/.github/workflows/fix-pdf-hydration.yml @@ -0,0 +1,75 @@ +name: Fix PDF hydration boundary +on: + push: + branches: [feature/pdf-toolkit-compression] +permissions: + contents: write +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/pdf-toolkit-compression + - name: Add client mount boundary to PDF toolkit + run: | + python - <<'PY' + from pathlib import Path + + path = Path('src/app/pdf-toolkit/page.tsx') + text = path.read_text() + + text = text.replace("import { useState } from 'react';", "import { useEffect, useState } from 'react';", 1) + + state_anchor = """export default function PdfToolkitPage() { + const [files, setFiles] = useState([]);""" + state_replacement = """export default function PdfToolkitPage() { + const [mounted, setMounted] = useState(false); + const [files, setFiles] = useState([]);""" + if state_anchor not in text: + raise SystemExit('PDF component state anchor not found') + text = text.replace(state_anchor, state_replacement, 1) + + effect_anchor = """ const [compressionResult, setCompressionResult] = useState(null); + const [compressionStatus, setCompressionStatus] = useState(''); + + const activatePdf = async (item: PdfInfo) => {""" + effect_replacement = """ const [compressionResult, setCompressionResult] = useState(null); + const [compressionStatus, setCompressionStatus] = useState(''); + + useEffect(() => { + setMounted(true); + }, []); + + const activatePdf = async (item: PdfInfo) => {""" + if effect_anchor not in text: + raise SystemExit('PDF effect anchor not found') + text = text.replace(effect_anchor, effect_replacement, 1) + + render_anchor = """ const readableFiles = files.filter(item => !item.error && item.pages > 0);""" + render_replacement = """ if (!mounted) { + return +
+
+

Loading browser PDF tools…

+

Preparing the local PDF workspace. Your files stay on this device.

+
+
+
; + } + + const readableFiles = files.filter(item => !item.error && item.pages > 0);""" + if render_anchor not in text: + raise SystemExit('PDF render anchor not found') + text = text.replace(render_anchor, render_replacement, 1) + + path.write_text(text) + PY + - name: Commit hydration fix + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/app/pdf-toolkit/page.tsx + git commit -m "Make PDF toolkit hydration deterministic" + git push origin HEAD:feature/pdf-toolkit-compression From 6430d3f5c2059ae0c65a58b6f2f9fa883769cacb Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:51:24 +0530 Subject: [PATCH 29/35] Trigger PDF hydration patch workflow From c8749c6409f51ef604fcdc22a9a41a760d6b4b35 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:52:09 +0530 Subject: [PATCH 30/35] Add hydration-safe PDF route boundary --- src/app/pdf-toolkit/ClientMountBoundary.tsx | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/app/pdf-toolkit/ClientMountBoundary.tsx diff --git a/src/app/pdf-toolkit/ClientMountBoundary.tsx b/src/app/pdf-toolkit/ClientMountBoundary.tsx new file mode 100644 index 0000000..e0f5b48 --- /dev/null +++ b/src/app/pdf-toolkit/ClientMountBoundary.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { useSyncExternalStore, type ReactNode } from 'react'; + +const subscribe = () => () => undefined; +const getClientSnapshot = () => true; +const getServerSnapshot = () => false; + +export default function ClientMountBoundary({ children }: { children: ReactNode }) { + const mounted = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot); + + if (!mounted) { + return
+
+
+

Preparing PDF Toolkit…

+

Loading the browser-only PDF workspace. Your files stay on this device.

+
+
+
; + } + + return children; +} From 0e4ebaec2dd54d2275eee3ecacd0e9f40d122be6 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:52:22 +0530 Subject: [PATCH 31/35] Wrap PDF toolkit in hydration-safe client boundary --- src/app/pdf-toolkit/layout.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/pdf-toolkit/layout.tsx diff --git a/src/app/pdf-toolkit/layout.tsx b/src/app/pdf-toolkit/layout.tsx new file mode 100644 index 0000000..82c22ab --- /dev/null +++ b/src/app/pdf-toolkit/layout.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; +import ClientMountBoundary from './ClientMountBoundary'; + +export const metadata: Metadata = { + title: 'PDF Toolkit', + description: 'Compress, merge, split, reorder, extract, delete and rotate PDFs locally in your browser without uploading documents.', +}; + +export default function PdfToolkitLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return {children}; +} From ac2e19ae510e9fae504bd56619b964b3eabf9639 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:52:35 +0530 Subject: [PATCH 32/35] Remove temporary PDF hydration patch workflow --- .github/workflows/fix-pdf-hydration.yml | 75 ------------------------- 1 file changed, 75 deletions(-) delete mode 100644 .github/workflows/fix-pdf-hydration.yml diff --git a/.github/workflows/fix-pdf-hydration.yml b/.github/workflows/fix-pdf-hydration.yml deleted file mode 100644 index 3f8a4ec..0000000 --- a/.github/workflows/fix-pdf-hydration.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Fix PDF hydration boundary -on: - push: - branches: [feature/pdf-toolkit-compression] -permissions: - contents: write -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/pdf-toolkit-compression - - name: Add client mount boundary to PDF toolkit - run: | - python - <<'PY' - from pathlib import Path - - path = Path('src/app/pdf-toolkit/page.tsx') - text = path.read_text() - - text = text.replace("import { useState } from 'react';", "import { useEffect, useState } from 'react';", 1) - - state_anchor = """export default function PdfToolkitPage() { - const [files, setFiles] = useState([]);""" - state_replacement = """export default function PdfToolkitPage() { - const [mounted, setMounted] = useState(false); - const [files, setFiles] = useState([]);""" - if state_anchor not in text: - raise SystemExit('PDF component state anchor not found') - text = text.replace(state_anchor, state_replacement, 1) - - effect_anchor = """ const [compressionResult, setCompressionResult] = useState(null); - const [compressionStatus, setCompressionStatus] = useState(''); - - const activatePdf = async (item: PdfInfo) => {""" - effect_replacement = """ const [compressionResult, setCompressionResult] = useState(null); - const [compressionStatus, setCompressionStatus] = useState(''); - - useEffect(() => { - setMounted(true); - }, []); - - const activatePdf = async (item: PdfInfo) => {""" - if effect_anchor not in text: - raise SystemExit('PDF effect anchor not found') - text = text.replace(effect_anchor, effect_replacement, 1) - - render_anchor = """ const readableFiles = files.filter(item => !item.error && item.pages > 0);""" - render_replacement = """ if (!mounted) { - return -
-
-

Loading browser PDF tools…

-

Preparing the local PDF workspace. Your files stay on this device.

-
-
-
; - } - - const readableFiles = files.filter(item => !item.error && item.pages > 0);""" - if render_anchor not in text: - raise SystemExit('PDF render anchor not found') - text = text.replace(render_anchor, render_replacement, 1) - - path.write_text(text) - PY - - name: Commit hydration fix - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/app/pdf-toolkit/page.tsx - git commit -m "Make PDF toolkit hydration deterministic" - git push origin HEAD:feature/pdf-toolkit-compression From 8aaa000dd956f18e9a86ad5830a34a5e4833a14b Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:20:40 +0530 Subject: [PATCH 33/35] Handle development PWA cleanup failures --- src/components/PwaRegistration.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PwaRegistration.tsx b/src/components/PwaRegistration.tsx index 59b2899..db460ea 100644 --- a/src/components/PwaRegistration.tsx +++ b/src/components/PwaRegistration.tsx @@ -29,7 +29,7 @@ export default function PwaRegistration() { if (!('serviceWorker' in navigator)) return; if (process.env.NODE_ENV !== 'production') { - void cleanupDevelopmentPwaState(); + void cleanupDevelopmentPwaState().catch(() => undefined); return; } From 8b7da19dee974e71a3830ec93ffc5e5a0ddb0cee Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:20:47 +0530 Subject: [PATCH 34/35] Ignore generated PDF.js browser assets --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c058b68..b72755b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ # generated browser runtime assets /public/qpdf/ +/public/pdfjs/ # production /build From e74d0be2dbfc04ac5d24b64be3a797a55a1a41bf Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:21:52 +0530 Subject: [PATCH 35/35] Make PDF removal state updates composable --- src/app/pdf-toolkit/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/pdf-toolkit/page.tsx b/src/app/pdf-toolkit/page.tsx index c20bbbf..d521e87 100644 --- a/src/app/pdf-toolkit/page.tsx +++ b/src/app/pdf-toolkit/page.tsx @@ -232,7 +232,7 @@ export default function PdfToolkitPage() { const removeFile = async (key: string) => { const next = files.filter(item => fileKey(item.file) !== key); - setFiles(next); + setFiles(prev => prev.filter(item => fileKey(item.file) !== key)); setMessage(''); if (selectedKey !== key) return;