"use client"; import Link from "next/link"; import { useCallback, useEffect, useRef, useState } from "react"; import { analyzePage, buildFlattenedPdf, composeReveal, createOcrWorker, downloadBlob, loadPdf, ocrPage, renderPage, toGrayscale, type HiddenScan, type OcrPageResult, } from "@/lib/clientPipeline"; /** * One page after both renders and the diff. `delivered` is the page as a * reader sees it; `revealed` is every mark on it with the invisible ones * flagged; `allInk` is what OCR reads. */ type ProcessedPage = { pageNumber: number; delivered: ImageData; revealed: ImageData; allInk: ImageData; scan: HiddenScan; ocr?: OcrPageResult; }; type View = "delivered" | "revealed"; type Phase = "idle" | "rendering" | "ready" | "ocr" | "error"; /** * Worth flagging when a real area of the page is invisible ink. The floor * exists because glyph antialiasing puts a few stray pixels in the same band, * and a handful of those is noise rather than a finding. */ function isSuspicious(scan: HiddenScan): boolean { return !scan.unreliable && scan.hiddenRatio > 0.0002; } export default function ScanPage() { const [pages, setPages] = useState([]); const [fileName, setFileName] = useState(null); const [phase, setPhase] = useState("idle"); const [progress, setProgress] = useState({ done: 0, total: 0 }); const [error, setError] = useState(null); const [current, setCurrent] = useState(0); const [view, setView] = useState("revealed"); const [ocrEnabled, setOcrEnabled] = useState(false); const [ocrNote, setOcrNote] = useState(null); const [dragging, setDragging] = useState(false); const canvasRef = useRef(null); const inputRef = useRef(null); const page = pages[current]; /* ---------------------------------------------------------- processing --- */ const handleFile = useCallback(async (file: File) => { if (!file.name.toLowerCase().endsWith(".pdf")) { setError("That is not a PDF."); setPhase("error"); return; } setError(null); setOcrNote(null); setPages([]); setCurrent(0); setFileName(file.name); setPhase("rendering"); try { const bytes = await file.arrayBuffer(); const pdf = await loadPdf(bytes); setProgress({ done: 0, total: pdf.numPages }); const processed: ProcessedPage[] = []; for (let n = 1; n <= pdf.numPages; n++) { // White shows what a reader sees; black exposes ink that is invisible // against white. The diff between them is the whole detection. const rendered = await renderPage(pdf, n); const analysis = analyzePage(rendered.onWhite, rendered.onBlack); processed.push({ pageNumber: n, delivered: rendered.onWhite, revealed: composeReveal(analysis), allInk: analysis.allInk, scan: analysis.scan, }); setProgress({ done: n, total: pdf.numPages }); // Yield so the progress counter actually paints between pages. await new Promise((r) => setTimeout(r, 0)); } setPages(processed); setPhase("ready"); } catch (e) { setError( e instanceof Error ? e.message : "Could not read that PDF." ); setPhase("error"); } }, []); // Paint the selected page/view. useEffect(() => { const canvas = canvasRef.current; if (!canvas || !page) return; const source = view === "revealed" ? page.revealed : toGrayscale(page.delivered); canvas.width = source.width; canvas.height = source.height; canvas.getContext("2d")?.putImageData(source, 0, 0); }, [page, view]); /* ----------------------------------------------------------------- OCR --- */ const runOcr = useCallback(async () => { if (pages.length === 0) return; setPhase("ocr"); setOcrNote("Loading the recognition model…"); setProgress({ done: 0, total: pages.length }); let worker: Awaited> | null = null; try { worker = await createOcrWorker(); const results: OcrPageResult[] = []; for (let i = 0; i < pages.length; i++) { setOcrNote(`Reading page ${i + 1} of ${pages.length}…`); // OCR reads the all-ink composite, so invisible text is transcribed // along with the visible copy. results.push(await ocrPage(worker, pages[i].allInk)); setProgress({ done: i + 1, total: pages.length }); } setPages((prev) => prev.map((p, i) => ({ ...p, ocr: results[i] }))); setOcrNote(null); setPhase("ready"); } catch (e) { setOcrNote(null); setError(e instanceof Error ? e.message : "OCR failed."); setPhase("ready"); } finally { await worker?.terminate?.(); } }, [pages]); useEffect(() => { if (ocrEnabled && phase === "ready" && pages.length > 0 && !pages[0].ocr) { runOcr(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ocrEnabled]); /* ------------------------------------------------------------- exports --- */ const baseName = (fileName ?? "document").replace(/\.pdf$/i, ""); const exportPdf = useCallback(async () => { // The export uses the page as delivered — flattening is what removes the // hidden layer, so the output should look like the original, not like the // annotated reveal view. const blob = await buildFlattenedPdf( pages.map((p) => ({ imageData: p.delivered })) ); downloadBlob(blob, `${baseName}_cleaned.pdf`); }, [pages, baseName]); const exportText = useCallback(() => { const body = pages .map((p) => `--- page ${p.pageNumber} ---\n${p.ocr?.text ?? ""}`) .join("\n\n"); downloadBlob( new Blob([body], { type: "text/plain" }), `${baseName}_ocr.txt` ); }, [pages, baseName]); const reset = () => { setPages([]); setFileName(null); setPhase("idle"); setError(null); setOcrEnabled(false); }; const flagged = pages.filter((p) => isSuspicious(p.scan)); const busy = phase === "rendering" || phase === "ocr"; return (
{/* ------------------------------------------------------ dropzone --- */} {pages.length === 0 && (

Drop a PDF in.

It is opened and processed by this tab. Nothing is uploaded, and there is no account and no server to store it on — watch your network tab if you like.

{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); const file = e.dataTransfer.files?.[0]; if (file) handleFile(file); }} onClick={() => inputRef.current?.click()} className={`mt-10 cursor-pointer border-2 border-dashed px-8 py-16 transition-colors ${ dragging ? "border-signal bg-signal-wash" : "border-rule bg-sheet hover:border-ink-faint" }`} > { const file = e.target.files?.[0]; if (file) handleFile(file); }} /> {busy ? (

Rendering…

page {progress.done} of {progress.total}

) : (

Drag a file here, or click to choose

Stays on this device

)}
{error && (

{error}

)}
)} {/* -------------------------------------------------------- results --- */} {pages.length > 0 && (
{fileName} · page {page.pageNumber} of {pages.length}
{(["delivered", "revealed"] as View[]).map((v) => ( ))}
{pages.length > 1 && (
{pages.map((p, i) => ( ))}
)} {page.ocr && (

OCR transcript · page {page.pageNumber} ·{" "} {Math.round(page.ocr.confidence)}% confidence

                    {page.ocr.text.trim() || "(no text recognized)"}
                  
)}
{/* ------------------------------------------------------ panel --- */}
)}
); }