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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/superpowers/specs/2026-08-01-pdf-repair-design.md
Original file line number Diff line number Diff line change
@@ -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 `<name>-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.
90 changes: 90 additions & 0 deletions src/islands/pdf/PdfRepair.tsx
Original file line number Diff line number Diff line change
@@ -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<File | null>(null);
const [result, setResult] = useState<Blob | null>(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 (
<div className="space-y-4">
<Dropzone onDrop={onDrop} accept="application/pdf" multiple={false}>
<div className="space-y-1">
<p className="text-lg font-bold">Drop a damaged PDF here or click to browse</p>
<p className="text-sm text-muted-foreground">Rebuilds a broken PDF so it opens again · 100% on your device, no upload</p>
</div>
</Dropzone>

{file && <p className="text-sm font-bold text-foreground">{file.name}</p>}

<div className="flex flex-wrap gap-2">
<Button onClick={() => run(false)} disabled={!file || busy}>
{busy ? 'Repairing…' : 'Repair PDF'}
</Button>
<Button variant="secondary" onClick={() => run(true)} disabled={!file || busy} title="Rebuild the document page-by-page — for badly damaged files">
Force rebuild
</Button>
<Button variant="ghost" onClick={() => { setFile(null); setResult(null); setError(''); }}>
Clear
</Button>
</div>

<p className="text-xs text-muted-foreground">
Repair fixes structural damage (a broken cross-reference table, damaged trailer, junk after the end of the file).
If a normal repair doesn&apos;t open, try <strong>Force rebuild</strong>, which reconstructs the file from whatever
pages are still readable. Content that&apos;s physically missing can&apos;t be recovered.
</p>

{error && (
<Alert variant="error">
{error} You can try <strong>Force rebuild</strong> for a more aggressive recovery.
</Alert>
)}

{result && (
<>
<Alert variant="success">
{forced ? 'Rebuilt' : 'Repaired'} — {pages} page{pages === 1 ? '' : 's'} recovered. Check the preview before saving.
</Alert>
<PdfPreview source={result} />
<ResultActions blob={result} filename={outName} disabled={busy} />
</>
)}
</div>
);
}
13 changes: 12 additions & 1 deletion src/registry/tools.ts
Original file line number Diff line number Diff line change
@@ -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[] = [
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions src/tools/pdf/mupdf.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export async function getPageCount(file: File): Promise<number> {
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<RepairResult> {
const { bytes, pages } = await engine().repair(await bytesOf(file), force);
return { blob: toBlob(bytes), pages };
}

export async function extractPageList(file: File, pageNumbers: number[]): Promise<Blob> {
return toBlob(await engine().extractPages(await bytesOf(file), pageNumbers));
}
Expand Down
34 changes: 34 additions & 0 deletions src/tools/pdf/mupdf.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
const mupdf = await loadMupdf();
const doc = open(mupdf, bytes);
Expand Down
2 changes: 2 additions & 0 deletions src/tools/pdf/pdf.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export {
pdfNeedsPassword,
protectPdf,
unlockPdf,
repairPdf,
type RepairResult,
} from './mupdf.client';
import { normalizePdf } from './mupdf.client';

Expand Down
Loading