From 6326d6bf9daa197cf684486797c7bb69c216d8cb Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 19:01:06 +0700 Subject: [PATCH] feat(pdf-repair): new client-side Repair PDF tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix damaged PDFs entirely in the browser (mupdf WASM) — the file never leaves the device, unlike server-side repair tools. - mupdf worker gains repair(bytes, force): opening auto-rebuilds a broken xref, then save with garbage=deduplicate,sanitize,clean writes a clean structure. force rebuilds page-by-page into a fresh document (graftPage, skipping unrecoverable pages) — recovers what's still readable from badly broken files. Returns the repaired bytes + recovered page count. - repairPdf client wrapper + pdf.lib re-export. Thin island (Repair / Force rebuild), PdfPreview + download, honest copy about what can't be recovered. - Registered pdf-repair (PDF, Wrench, beta). 546 tests · lint clean · build green (/tools/pdf-repair built). mupdf path is worker/WASM — build + manual smoke (like the other PDF tools). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-08-01-pdf-repair-design.md | 61 +++++++++++++ src/islands/pdf/PdfRepair.tsx | 90 +++++++++++++++++++ src/registry/tools.ts | 13 ++- src/tools/pdf/mupdf.client.ts | 8 ++ src/tools/pdf/mupdf.worker.ts | 34 +++++++ src/tools/pdf/pdf.lib.ts | 2 + 6 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-08-01-pdf-repair-design.md create mode 100644 src/islands/pdf/PdfRepair.tsx diff --git a/docs/superpowers/specs/2026-08-01-pdf-repair-design.md b/docs/superpowers/specs/2026-08-01-pdf-repair-design.md new file mode 100644 index 0000000..9e2c90f --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-pdf-repair-design.md @@ -0,0 +1,61 @@ +# Repair PDF Tool — Design + +**Date:** 2026-08-01 +**Tool:** PDF → Repair PDF (`/tools/pdf-repair`) — NEW +**Type:** New tool +**Icon:** `Wrench` +**Category:** PDF + +## Problem + +Damaged PDFs ("this file is corrupt / won't open") are usually **structurally** broken — +a bad cross-reference (xref) table, damaged trailer, or junk appended after `%%EOF` — while +the actual page objects are intact. iLovePDF's "Repair PDF" recovers these, but uploads the +file to their servers. + +## Goal + +A **100% client-side** Repair PDF tool: fix structural corruption so the PDF opens again, +with the damaged file never leaving the browser. + +## Approach — reuse the existing mupdf worker + +The project already runs `mupdf@1.28` in a Comlink Web Worker (`mupdf.worker.ts`). mupdf is +the ideal engine: `PDFDocument.openDocument()` **rebuilds a broken xref on open**, and +re-saving with garbage-collection + sanitize writes a clean structure (this is what +`mutool clean` does). + +New worker method `repair(bytes, force)`: +- **Standard** (`force=false`): `open` (auto-repairs) → `saveToBuffer('garbage=deduplicate,sanitize=yes,clean=yes')`. +- **Force-recover** (`force=true`): create a fresh `PDFDocument`, `graftPage` every page from + the opened doc into it (skipping any page that throws), then save. This discards broken + global structure and reconstructs from whatever pages are still readable. +- Returns `{ bytes, pages }` (repaired bytes + recovered page count) via `Comlink.transfer`. + +## Files + +- `mupdf.worker.ts` — add `repair`. +- `mupdf.client.ts` — `repairPdf(file, force) → { blob, pages }` (+ `RepairResult` type). +- `pdf.lib.ts` — re-export `repairPdf` / `RepairResult`. +- `src/islands/pdf/PdfRepair.tsx` — Dropzone → **Repair PDF** / **Force rebuild** → success + (recovered page count) + `PdfPreview` + `ResultActions` (download `-repaired.pdf`). +- `src/registry/tools.ts` — register `pdf-repair` (PDF, `Wrench`, beta). + +## Scope / honesty + +- **Fixes:** broken xref, damaged trailer/offsets, junk after EOF, "won't open" structural + corruption. +- **Can't recover:** physically missing/overwritten content bytes; encrypted files without + the password (that's the Unlock tool). The UI states this plainly. + +## Testing + +mupdf runs in a Web Worker over WASM — not headless-testable in jsdom (same as the other +PDF tools: compress/unlock/merge). Verified by the full suite still passing, build, and +manual smoke (repair a deliberately-corrupted PDF; confirm it opens and page count is +reported). Registry load test covers the new entry. + +## Out of scope + +- OCR/content re-extraction of image-only scans. +- Recovering a specific damaged page's content when its stream is gone. diff --git a/src/islands/pdf/PdfRepair.tsx b/src/islands/pdf/PdfRepair.tsx new file mode 100644 index 0000000..f5c56a9 --- /dev/null +++ b/src/islands/pdf/PdfRepair.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { PdfPreview } from '@/components/ui/PdfPreview'; +import { ResultActions } from '@/components/ui/ResultActions'; +import { repairPdf } from '@/tools/pdf/pdf.lib'; + +export default function PdfRepair() { + const [file, setFile] = useState(null); + const [result, setResult] = useState(null); + const [pages, setPages] = useState(0); + const [forced, setForced] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const onDrop = (files: File[]) => { + const pdf = files.find(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')); + if (!pdf) return; + setFile(pdf); + setResult(null); + setError(''); + }; + + const outName = file ? file.name.replace(/\.pdf$/i, '') + '-repaired.pdf' : 'repaired.pdf'; + + const run = async (force: boolean) => { + if (!file) return; + setBusy(true); + setError(''); + setResult(null); + try { + const { blob, pages: recovered } = await repairPdf(file, force); + setResult(blob); + setPages(recovered); + setForced(force); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not repair this PDF.'); + } finally { + setBusy(false); + } + }; + + return ( +
+ +
+

Drop a damaged PDF here or click to browse

+

Rebuilds a broken PDF so it opens again · 100% on your device, no upload

+
+
+ + {file &&

{file.name}

} + +
+ + + +
+ +

+ Repair fixes structural damage (a broken cross-reference table, damaged trailer, junk after the end of the file). + If a normal repair doesn't open, try Force rebuild, which reconstructs the file from whatever + pages are still readable. Content that's physically missing can't be recovered. +

+ + {error && ( + + {error} You can try Force rebuild for a more aggressive recovery. + + )} + + {result && ( + <> + + {forced ? 'Rebuilt' : 'Repaired'} — {pages} page{pages === 1 ? '' : 's'} recovered. Check the preview before saving. + + + + + )} +
+ ); +} diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 29b66dd..9672631 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -322,6 +322,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/pdf/PdfUnlock'), status: 'stable' }, + { + id: 'pdf-repair', + name: 'Repair PDF', + category: 'PDF', + route: '/tools/pdf-repair', + keywords: ['pdf', 'repair', 'fix', 'recover', 'damaged', 'corrupt', 'broken', 'restore', 'rebuild'], + icon: Wrench, + summary: 'Fix a damaged PDF so it opens again (client-side)', + load: () => import('@/islands/pdf/PdfRepair'), + status: 'beta' + }, { id: 'image-convert', name: 'Image Converter', diff --git a/src/tools/pdf/mupdf.client.ts b/src/tools/pdf/mupdf.client.ts index 95e0942..66ca417 100644 --- a/src/tools/pdf/mupdf.client.ts +++ b/src/tools/pdf/mupdf.client.ts @@ -39,6 +39,14 @@ export async function getPageCount(file: File): Promise { return engine().countPages(await bytesOf(file)); } +export interface RepairResult { blob: Blob; pages: number } + +/** Repair a damaged PDF. `force` rebuilds it page-by-page from what's readable. */ +export async function repairPdf(file: File, force = false): Promise { + const { bytes, pages } = await engine().repair(await bytesOf(file), force); + return { blob: toBlob(bytes), pages }; +} + export async function extractPageList(file: File, pageNumbers: number[]): Promise { return toBlob(await engine().extractPages(await bytesOf(file), pageNumbers)); } diff --git a/src/tools/pdf/mupdf.worker.ts b/src/tools/pdf/mupdf.worker.ts index e219535..9f50678 100644 --- a/src/tools/pdf/mupdf.worker.ts +++ b/src/tools/pdf/mupdf.worker.ts @@ -55,6 +55,40 @@ const api = { } }, + /** + * Repair a damaged PDF. mupdf rebuilds a broken cross-reference table when it + * opens the file, and re-saving with garbage collection + sanitize writes a + * clean structure. `force` rebuilds the document page-by-page into a fresh one, + * discarding broken global structure (recovers what's still readable). + * Returns the repaired bytes and the recovered page count. + */ + async repair(bytes: Uint8Array, force: boolean): Promise<{ bytes: Uint8Array; pages: number }> { + const mupdf = await loadMupdf(); + const doc = open(mupdf, bytes); // opening auto-repairs a broken xref + try { + if (!force) { + const pages = doc.countPages(); + const out = save(doc, 'garbage=deduplicate,sanitize=yes,clean=yes'); + return Comlink.transfer({ bytes: out, pages }, [out.buffer]); + } + const rebuilt: any = new (mupdf as any).PDFDocument(); + try { + const count = doc.countPages(); + for (let i = 0; i < count; i++) { + try { rebuilt.graftPage(-1, doc, i); } catch { /* skip an unrecoverable page */ } + } + const pages = rebuilt.countPages(); + if (pages === 0) throw new Error('Could not recover any readable pages from this file.'); + const out = save(rebuilt, 'garbage=deduplicate,sanitize=yes'); + return Comlink.transfer({ bytes: out, pages }, [out.buffer]); + } finally { + rebuilt.destroy?.(); + } + } finally { + doc.destroy?.(); + } + }, + async countPages(bytes: Uint8Array): Promise { const mupdf = await loadMupdf(); const doc = open(mupdf, bytes); diff --git a/src/tools/pdf/pdf.lib.ts b/src/tools/pdf/pdf.lib.ts index 29f21d4..23fa5f8 100644 --- a/src/tools/pdf/pdf.lib.ts +++ b/src/tools/pdf/pdf.lib.ts @@ -13,6 +13,8 @@ export { pdfNeedsPassword, protectPdf, unlockPdf, + repairPdf, + type RepairResult, } from './mupdf.client'; import { normalizePdf } from './mupdf.client';