Move the whole pipeline into the browser, drop the server
Every stage now runs client-side: pdf.js renders, a per-pixel diff finds the hidden ink, tesseract.js transcribes it on request, and pdf-lib rebuilds the flattened export. The PDF goes from the file picker into memory and back out as a download, and is never sent anywhere. That makes the privacy claim structural rather than a promise. The server is left with one route — a health check — so there is no code path that can receive a document, nothing is persisted, and no account exists. The OCR model and WASM are staged out of node_modules into public/ at build time so the page never reaches a CDN either. Verified in the browser: a full scan with OCR contacts exactly one origin, its own, and POST /api/upload returns 404 in the built image. Contrast stretching turned out to be the wrong method entirely. Text painted in pure #FFFFFF rasterizes to bytes identical to blank paper, so there is no difference in the image to amplify — and a page with black body text already spans 0-255, so the old server-side normalize() was a no-op. It could never have found the case it advertised. So each page is rendered twice, on white and on black. White ink vanishes into the first and blazes in the second; inverting the black-backed render and taking the darker pixel of each pair puts every mark on the page in view whatever colour it was drawn in. Confirmed against a PDF carrying a genuine white-on-white payload: invisible in "as delivered", flagged in vermilion in "revealed", and transcribed verbatim by OCR. Pages that paint their own opaque background defeat the technique, so that case is detected and reported as unreliable instead of showing a page-sized false positive. OCR is opt-in, as asked: it costs an ~8MB model download and runs at a few seconds a page, so nothing is fetched until the box is ticked. Consequences: - Removed auth, Prisma, Postgres, the upload/render/pixel/ocr/export routes, the storage layer and the signed-in dashboard. - Runtime dependencies drop from 13 to 6, all browser-capable; sharp, @napi-rs/canvas, next-auth, pg and the Prisma stack are all gone. - compose is a single stateless service with no volumes and no config, the entrypoint and its migration step are gone, and the image drops from 868MB to 455MB. - The landing page no longer describes the contrast method it used to claim, since that method provably does not work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5ca71853eb
commit
c6a564ec5d
44 files changed
+1142
-4654
No files matched your search
@@ -1,46 +0,0 @@
|
||||
//setup google auth
|
||||
//only allow google login and store the user in the database if they login for the first time
|
||||
|
||||
import NextAuth, { type NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import { createUserOnFirstGoogleLogin } from "./serverActions";
|
||||
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
if (!googleClientId || !googleClientSecret) {
|
||||
throw new Error(
|
||||
"Missing Google OAuth env vars. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.",
|
||||
);
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: googleClientId,
|
||||
clientSecret: googleClientSecret,
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async signIn({ user, account }) {
|
||||
if (account?.provider !== "google") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await createUserOnFirstGoogleLogin({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,28 +0,0 @@
|
||||
//auth functions again teh database go here and get then gen imported to the route file to be used
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type UpsertGoogleUserInput = {
|
||||
email: string;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
export const createUserOnFirstGoogleLogin = async ({
|
||||
email,
|
||||
name,
|
||||
}: UpsertGoogleUserInput) => {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: name ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readFile } from "fs/promises";
|
||||
import { sourcePdfPath, resultPagePath, type ResultKind } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* Authenticated file server for document assets. Streams stored files from
|
||||
* STORAGE_DIR only after verifying the session owns the document, replacing the
|
||||
* previous public/ static URLs.
|
||||
*
|
||||
* ?type=source → the original uploaded PDF
|
||||
* ?type=original&page=N → clean rendered page image
|
||||
* ?type=pixel&page=N → sanitized page image
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const type = req.nextUrl.searchParams.get("type") ?? "original";
|
||||
const pageParam = req.nextUrl.searchParams.get("page");
|
||||
|
||||
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
|
||||
if (!doc) {
|
||||
return NextResponse.json({ error: "Document not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
if (!user || doc.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (type === "source") {
|
||||
const data = await readFile(sourcePdfPath(doc.filePath));
|
||||
return new NextResponse(new Uint8Array(data), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (type !== "original" && type !== "pixel") {
|
||||
return NextResponse.json({ error: "Invalid asset type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const page = Number(pageParam);
|
||||
if (!Number.isInteger(page) || page < 1) {
|
||||
return NextResponse.json({ error: "Invalid page" }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = await readFile(
|
||||
resultPagePath(user.id, id, type as ResultKind, page)
|
||||
);
|
||||
return new NextResponse(new Uint8Array(data), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Asset not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { removeDocumentFiles } from "@/lib/storage";
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
|
||||
if (!doc) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
|
||||
if (!user || doc.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.pdfDocument.delete({ where: { id } });
|
||||
await removeDocumentFiles(user.id, id, doc.filePath);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readdir, readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { resultsDir } from "@/lib/storage";
|
||||
|
||||
type ExportType = "original" | "pixel";
|
||||
|
||||
function parsePageNumber(fileName: string): number {
|
||||
const match = fileName.match(/^page_(\d+)\.png$/);
|
||||
return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const typeParam = req.nextUrl.searchParams.get("type");
|
||||
const type: ExportType = typeParam === "pixel" ? "pixel" : "original";
|
||||
|
||||
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
|
||||
if (!doc) {
|
||||
return NextResponse.json({ error: "Document not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
if (!user || doc.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const dir = resultsDir(user.id, id, type);
|
||||
|
||||
const files = (await readdir(dir))
|
||||
.filter((fileName) => /^page_\d+\.png$/.test(fileName))
|
||||
.sort((a, b) => parsePageNumber(a) - parsePageNumber(b));
|
||||
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `No ${type} pages found. Render/process the document first.` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
|
||||
for (const fileName of files) {
|
||||
const imagePath = path.join(dir, fileName);
|
||||
const imageBytes = await readFile(imagePath);
|
||||
const pngImage = await pdfDoc.embedPng(imageBytes);
|
||||
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
|
||||
page.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pngImage.width,
|
||||
height: pngImage.height,
|
||||
});
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const safeBaseName = doc.originalName.replace(/\.pdf$/i, "").replace(/\s+/g, "_");
|
||||
const outputName = `${safeBaseName}_${type}.pdf`;
|
||||
|
||||
return new NextResponse(Buffer.from(pdfBytes), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename=\"${outputName}\"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("PDF export error:", error);
|
||||
return NextResponse.json({ error: "Failed to export PDF" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readFile } from "fs/promises";
|
||||
import {
|
||||
loadPdf,
|
||||
renderPage,
|
||||
sanitizePage,
|
||||
ocrPage,
|
||||
createOcrWorker,
|
||||
type OcrWorker,
|
||||
} from "@/lib/pipeline";
|
||||
import { sourcePdfPath } from "@/lib/storage";
|
||||
import { rateLimit } from "@/lib/rateLimit";
|
||||
import { safeErrorMessage } from "@/lib/safeError";
|
||||
|
||||
/**
|
||||
* Tesseract OCR over every page of a document.
|
||||
*
|
||||
* - OCR runs on the SANITIZED image (the same pipeline the Pixel panel uses),
|
||||
* not the raw render, so hidden low-contrast text is picked up.
|
||||
* - Word-level boxes + per-page confidence are persisted to the pdfPage table.
|
||||
* - Runs are idempotent: an existing run's page rows are cleared and overwritten
|
||||
* rather than rejected.
|
||||
* - Each page is isolated (one bad page does not abort the run) and retried once
|
||||
* with backoff. Progress + per-page results/errors stream as NDJSON.
|
||||
*/
|
||||
|
||||
const OCR_RATE_MAX = 3;
|
||||
const OCR_RATE_WINDOW_MS = 10 * 60 * 1000; // 3 runs / 10 minutes / user
|
||||
|
||||
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
|
||||
if (!doc) {
|
||||
return NextResponse.json({ error: "Document not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
if (!user || doc.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Per-user rate limit on OCR runs.
|
||||
const limit = rateLimit(`ocr:${user.id}`, OCR_RATE_MAX, OCR_RATE_WINDOW_MS);
|
||||
if (!limit.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Rate limit reached. Try again in ${limit.retryAfterSeconds}s.`,
|
||||
retryAfterSeconds: limit.retryAfterSeconds,
|
||||
},
|
||||
{ status: 429, headers: { "Retry-After": String(limit.retryAfterSeconds) } }
|
||||
);
|
||||
}
|
||||
|
||||
let pdfDoc: Awaited<ReturnType<typeof loadPdf>>;
|
||||
let numPages: number;
|
||||
try {
|
||||
const pdfBuffer = await readFile(sourcePdfPath(doc.filePath));
|
||||
pdfDoc = await loadPdf(pdfBuffer);
|
||||
numPages = pdfDoc.numPages;
|
||||
} catch (e) {
|
||||
console.error("OCR load error:", e);
|
||||
return NextResponse.json({ error: safeErrorMessage(e) }, { status: 500 });
|
||||
}
|
||||
|
||||
// Idempotent re-run: drop any previously persisted pages for this document.
|
||||
await prisma.pdfPage.deleteMany({ where: { documentId: id } });
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const emit = (obj: unknown) =>
|
||||
controller.enqueue(encoder.encode(JSON.stringify(obj) + "\n"));
|
||||
|
||||
let worker: OcrWorker | null = null;
|
||||
let succeeded = 0;
|
||||
try {
|
||||
worker = await createOcrWorker();
|
||||
|
||||
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
|
||||
emit({ type: "progress", page: pageNum, totalPages: numPages });
|
||||
|
||||
try {
|
||||
const result = await withRetry(async () => {
|
||||
const { buffer } = await renderPage(pdfDoc, pageNum);
|
||||
const sanitized = await sanitizePage(buffer);
|
||||
return ocrPage(worker, sanitized);
|
||||
});
|
||||
|
||||
await prisma.pdfPage.create({
|
||||
data: {
|
||||
documentId: id,
|
||||
pageNumber: pageNum,
|
||||
confidence: result.confidence,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
wordsJson: result.words as any,
|
||||
},
|
||||
});
|
||||
succeeded += 1;
|
||||
|
||||
emit({
|
||||
type: "page_done",
|
||||
page: pageNum,
|
||||
totalPages: numPages,
|
||||
text: result.text,
|
||||
confidence: result.confidence,
|
||||
wordCount: result.words.length,
|
||||
});
|
||||
} catch (pageErr) {
|
||||
// One bad page must not kill the whole run.
|
||||
console.error(`OCR page ${pageNum} failed:`, pageErr);
|
||||
emit({
|
||||
type: "page_error",
|
||||
page: pageNum,
|
||||
totalPages: numPages,
|
||||
error: safeErrorMessage(pageErr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.pdfDocument.update({
|
||||
where: { id },
|
||||
data: {
|
||||
ocrRan: true,
|
||||
ocrResultDir: null,
|
||||
ocrPageCount: succeeded,
|
||||
pageCount: numPages,
|
||||
},
|
||||
});
|
||||
|
||||
emit({ type: "complete", pageCount: numPages, succeeded });
|
||||
} catch (err) {
|
||||
console.error("OCR streaming error:", err);
|
||||
emit({ type: "error", error: safeErrorMessage(err) });
|
||||
} finally {
|
||||
if (worker) {
|
||||
try {
|
||||
await worker.terminate();
|
||||
} catch {
|
||||
/* ignore terminate errors */
|
||||
}
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Transfer-Encoding": "chunked",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { wordsToText, type OcrWord } from "@/lib/pipeline";
|
||||
|
||||
/**
|
||||
* Persisted OCR words for a single page (used by the confidence-heatmap
|
||||
* overlay so it survives reloads). Returns per-word boxes + confidence, the
|
||||
* page-level confidence, and text reconstructed from the words.
|
||||
*
|
||||
* GET /api/documents/[id]/ocr/words?page=N
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const page = Number(req.nextUrl.searchParams.get("page"));
|
||||
if (!Number.isInteger(page) || page < 1) {
|
||||
return NextResponse.json({ error: "Invalid page" }, { status: 400 });
|
||||
}
|
||||
|
||||
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
|
||||
if (!doc) {
|
||||
return NextResponse.json({ error: "Document not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
if (!user || doc.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const row = await prisma.pdfPage.findUnique({
|
||||
where: { documentId_pageNumber: { documentId: id, pageNumber: page } },
|
||||
});
|
||||
if (!row) {
|
||||
return NextResponse.json(
|
||||
{ error: "No OCR data for this page" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const words = (row.wordsJson as unknown as OcrWord[]) ?? [];
|
||||
return NextResponse.json({
|
||||
page,
|
||||
confidence: row.confidence,
|
||||
words,
|
||||
text: wordsToText(words),
|
||||
});
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readFile, writeFile, mkdir } from "fs/promises";
|
||||
import { loadPdf, renderPage, sanitizePage } from "@/lib/pipeline";
|
||||
import { sourcePdfPath, resultsDir, resultPagePath } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* Pixel-level analysis: renders EVERY page of the PDF and runs the shared
|
||||
* sanitize pipeline (flatten→grayscale→normalize→sharpen) to reveal hidden
|
||||
* white-on-white / low-contrast text. Returns per-page result image paths that
|
||||
* are served through the authenticated /asset route.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
|
||||
if (!doc) {
|
||||
return NextResponse.json({ error: "Document not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
if (!user || doc.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Always regenerate so stale cached outputs don't persist.
|
||||
try {
|
||||
const pdfBuffer = await readFile(sourcePdfPath(doc.filePath));
|
||||
const pdfDoc = await loadPdf(pdfBuffer);
|
||||
const numPages = pdfDoc.numPages;
|
||||
|
||||
await mkdir(resultsDir(user.id, id, "pixel"), { recursive: true });
|
||||
|
||||
const pages: { page: number; path: string }[] = [];
|
||||
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
|
||||
const { buffer } = await renderPage(pdfDoc, pageNum);
|
||||
const sanitized = await sanitizePage(buffer);
|
||||
await writeFile(resultPagePath(user.id, id, "pixel", pageNum), sanitized);
|
||||
pages.push({
|
||||
page: pageNum,
|
||||
path: `/api/documents/${id}/asset?type=pixel&page=${pageNum}`,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.pdfDocument.update({
|
||||
where: { id },
|
||||
data: {
|
||||
pixelResultDir: `results/${user.id}/${id}/pixel`,
|
||||
pixelPageCount: numPages,
|
||||
pageCount: numPages,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ pages, pageCount: numPages });
|
||||
} catch (e) {
|
||||
console.error("Pixel analysis error:", e);
|
||||
return NextResponse.json({ error: "Pixel analysis failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readFile, writeFile, mkdir } from "fs/promises";
|
||||
import { loadPdf, renderPage } from "@/lib/pipeline";
|
||||
import { sourcePdfPath, resultsDir, resultPagePath } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* Server-side PDF page renderer: renders EVERY page of the PDF as a clean PNG
|
||||
* image (no processing) via the shared pipeline. Used by the "Original" panel
|
||||
* so all three panels display consistently via server-rendered images.
|
||||
*
|
||||
* Result images are stored under STORAGE_DIR and served through the
|
||||
* authenticated /asset route (never as public static files).
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
|
||||
if (!doc) {
|
||||
return NextResponse.json({ error: "Document not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
if (!user || doc.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Always regenerate so stale cached renders don't persist.
|
||||
try {
|
||||
const pdfBuffer = await readFile(sourcePdfPath(doc.filePath));
|
||||
const pdfDoc = await loadPdf(pdfBuffer);
|
||||
const numPages = pdfDoc.numPages;
|
||||
|
||||
await mkdir(resultsDir(user.id, id, "original"), { recursive: true });
|
||||
|
||||
const pages: { page: number; path: string }[] = [];
|
||||
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
|
||||
const { buffer } = await renderPage(pdfDoc, pageNum);
|
||||
await writeFile(resultPagePath(user.id, id, "original", pageNum), buffer);
|
||||
pages.push({
|
||||
page: pageNum,
|
||||
path: `/api/documents/${id}/asset?type=original&page=${pageNum}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (doc.pageCount === 0) {
|
||||
await prisma.pdfDocument.update({
|
||||
where: { id },
|
||||
data: { pageCount: numPages },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ pages, pageCount: numPages });
|
||||
} catch (e) {
|
||||
console.error("Render error:", e);
|
||||
return NextResponse.json({ error: "Page rendering failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: {
|
||||
documents: { orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ documents: user.documents });
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import path from "path";
|
||||
import { uploadsDir } from "@/lib/storage";
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
const MAX_DOCUMENTS = 5;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: { documents: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("pdf") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: "File exceeds 10 MB limit" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!file.name.toLowerCase().endsWith(".pdf")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only PDF files are accepted" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce 5-document limit — delete oldest if at cap
|
||||
if (user.documents.length >= MAX_DOCUMENTS) {
|
||||
const oldest = user.documents[0];
|
||||
await prisma.pdfDocument.delete({ where: { id: oldest.id } });
|
||||
// Note: could also delete files from disk here
|
||||
}
|
||||
|
||||
const fileId = uuidv4();
|
||||
const dir = uploadsDir(user.id);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const fileName = `${fileId}.pdf`;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(path.join(dir, fileName), buffer);
|
||||
|
||||
// Stored relative to STORAGE_DIR; served only via the authenticated /asset route.
|
||||
const storageKey = `uploads/${user.id}/${fileName}`;
|
||||
|
||||
// Detect page count from the uploaded PDF
|
||||
let pageCount = 0;
|
||||
try {
|
||||
const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buffer) });
|
||||
const pdfDoc = await loadingTask.promise;
|
||||
pageCount = pdfDoc.numPages;
|
||||
} catch {
|
||||
// fallback: if pdfjs fails, just store 0
|
||||
}
|
||||
|
||||
const doc = await prisma.pdfDocument.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
originalName: file.name,
|
||||
filePath: storageKey,
|
||||
fileSize: file.size,
|
||||
pageCount,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ document: doc }, { status: 201 });
|
||||
}
|
||||
@@ -1,948 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, use, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type DocData = {
|
||||
id: string;
|
||||
originalName: string;
|
||||
filePath: string;
|
||||
pixelResultDir: string | null;
|
||||
pixelPageCount: number;
|
||||
ocrResultDir: string | null;
|
||||
ocrPageCount: number;
|
||||
ocrRan: boolean;
|
||||
fileSize: number;
|
||||
pageCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type OriginalPage = { page: number; path: string };
|
||||
type PixelPage = { page: number; path: string };
|
||||
type OcrWord = {
|
||||
text: string;
|
||||
confidence: number;
|
||||
bbox: { x0: number; y0: number; x1: number; y1: number };
|
||||
};
|
||||
type OcrPage = {
|
||||
page: number;
|
||||
text?: string;
|
||||
confidence?: number;
|
||||
wordCount?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function assetUrl(id: string, type: "original" | "pixel", page: number) {
|
||||
return `/api/documents/${id}/asset?type=${type}&page=${page}`;
|
||||
}
|
||||
|
||||
function confidenceStyle(c: number): { bg: string; border: string } {
|
||||
if (c >= 90) return { bg: "rgba(34,197,94,0.35)", border: "#22c55e" };
|
||||
if (c >= 70) return { bg: "rgba(245,158,11,0.35)", border: "#f59e0b" };
|
||||
return { bg: "rgba(239,68,68,0.35)", border: "#ef4444" };
|
||||
}
|
||||
|
||||
function confidenceTextColor(c: number): string {
|
||||
if (c >= 90) return "text-emerald-400";
|
||||
if (c >= 70) return "text-amber-400";
|
||||
return "text-red-400";
|
||||
}
|
||||
|
||||
export default function ViewerPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const [doc, setDoc] = useState<DocData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Original server-rendered state
|
||||
const [originalLoading, setOriginalLoading] = useState(false);
|
||||
const [originalPages, setOriginalPages] = useState<OriginalPage[]>([]);
|
||||
const [originalCurrentPage, setOriginalCurrentPage] = useState(1);
|
||||
|
||||
// Pixel state
|
||||
const [pixelLoading, setPixelLoading] = useState(false);
|
||||
const [pixelPages, setPixelPages] = useState<PixelPage[]>([]);
|
||||
const [pixelCurrentPage, setPixelCurrentPage] = useState(1);
|
||||
|
||||
// OCR state
|
||||
const [ocrLoading, setOcrLoading] = useState(false);
|
||||
const [ocrPages, setOcrPages] = useState<OcrPage[]>([]);
|
||||
const [ocrCurrentPage, setOcrCurrentPage] = useState(1);
|
||||
const [ocrProgress, setOcrProgress] = useState({ page: 0, total: 0 });
|
||||
const [ocrError, setOcrError] = useState<string | null>(null);
|
||||
const [ocrView, setOcrView] = useState<"text" | "overlay">("text");
|
||||
const [ocrWords, setOcrWords] = useState<Record<number, OcrWord[]>>({});
|
||||
const [overlayDims, setOverlayDims] = useState<{ w: number; h: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"original" | "pixel" | "ocr">(
|
||||
"original"
|
||||
);
|
||||
|
||||
const fetchDoc = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/documents");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const found = data.documents.find((d: DocData) => d.id === id);
|
||||
if (found) {
|
||||
setDoc(found);
|
||||
renderOriginalPages(found.id);
|
||||
if (found.pixelPageCount === 0) {
|
||||
runPixelAnalysis(found.id);
|
||||
} else {
|
||||
setPixelPages(
|
||||
Array.from({ length: found.pixelPageCount }, (_, i) => ({
|
||||
page: i + 1,
|
||||
path: assetUrl(found.id, "pixel", i + 1),
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (found.ocrRan && found.ocrPageCount > 0) {
|
||||
// Set page placeholders; per-page text/words hydrate lazily on view.
|
||||
setOcrPages(
|
||||
Array.from({ length: found.ocrPageCount }, (_, i) => ({
|
||||
page: i + 1,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDoc();
|
||||
}, [fetchDoc]);
|
||||
|
||||
const renderOriginalPages = async (docId: string) => {
|
||||
setOriginalLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}/render`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setOriginalPages(data.pages);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Render error:", e);
|
||||
}
|
||||
setOriginalLoading(false);
|
||||
};
|
||||
|
||||
const runPixelAnalysis = async (docId: string) => {
|
||||
setPixelLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}/pixel`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPixelPages(data.pages);
|
||||
setDoc((prev) =>
|
||||
prev
|
||||
? { ...prev, pixelPageCount: data.pageCount, pageCount: data.pageCount }
|
||||
: prev
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Pixel analysis error:", e);
|
||||
}
|
||||
setPixelLoading(false);
|
||||
};
|
||||
|
||||
// Lazily hydrate persisted OCR words + reconstructed text for a page.
|
||||
const ensureOcrPageLoaded = useCallback(
|
||||
async (page: number) => {
|
||||
if (ocrWords[page]) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${id}/ocr/words?page=${page}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setOcrWords((prev) => ({ ...prev, [page]: data.words ?? [] }));
|
||||
setOcrPages((prev) =>
|
||||
prev.map((p) =>
|
||||
p.page === page
|
||||
? {
|
||||
...p,
|
||||
text: data.text,
|
||||
confidence: data.confidence,
|
||||
wordCount: (data.words ?? []).length,
|
||||
}
|
||||
: p
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[id, ocrWords]
|
||||
);
|
||||
|
||||
// Hydrate the currently-viewed OCR page after a reload / re-run completes.
|
||||
useEffect(() => {
|
||||
if (!doc?.ocrRan || ocrLoading) return;
|
||||
const entry = ocrPages.find((p) => p.page === ocrCurrentPage);
|
||||
if (entry && entry.text === undefined && !entry.error) {
|
||||
ensureOcrPageLoaded(ocrCurrentPage);
|
||||
}
|
||||
}, [doc?.ocrRan, ocrLoading, ocrCurrentPage, ocrPages, ensureOcrPageLoaded]);
|
||||
|
||||
// Reset the measured overlay image size whenever the page/view changes.
|
||||
useEffect(() => {
|
||||
setOverlayDims(null);
|
||||
}, [ocrCurrentPage, ocrView]);
|
||||
|
||||
const runOcr = async () => {
|
||||
if (!doc) return;
|
||||
setOcrLoading(true);
|
||||
setOcrError(null);
|
||||
setOcrProgress({ page: 0, total: 0 });
|
||||
setOcrPages([]);
|
||||
setOcrWords({});
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${doc.id}/ocr`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = "OCR request failed";
|
||||
try {
|
||||
const data = await res.json();
|
||||
message = data.error || message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setOcrError(message);
|
||||
setOcrLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
setOcrLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (event.type === "progress") {
|
||||
setOcrProgress({ page: event.page, total: event.totalPages });
|
||||
} else if (event.type === "page_done") {
|
||||
setOcrProgress({ page: event.page, total: event.totalPages });
|
||||
setOcrPages((prev) => [
|
||||
...prev.filter((p) => p.page !== event.page),
|
||||
{
|
||||
page: event.page,
|
||||
text: event.text,
|
||||
confidence: event.confidence,
|
||||
wordCount: event.wordCount,
|
||||
},
|
||||
]);
|
||||
} else if (event.type === "page_error") {
|
||||
setOcrProgress({ page: event.page, total: event.totalPages });
|
||||
setOcrPages((prev) => [
|
||||
...prev.filter((p) => p.page !== event.page),
|
||||
{ page: event.page, error: event.error },
|
||||
]);
|
||||
} else if (event.type === "complete") {
|
||||
setDoc((prev) =>
|
||||
prev
|
||||
? { ...prev, ocrRan: true, ocrPageCount: event.succeeded }
|
||||
: prev
|
||||
);
|
||||
} else if (event.type === "error") {
|
||||
setOcrError(event.error || "OCR processing failed");
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-sort by page number after streaming; drop cached words so the
|
||||
// overlay re-fetches fresh boxes from persistence.
|
||||
setOcrPages((prev) => [...prev].sort((a, b) => a.page - b.page));
|
||||
setOcrWords({});
|
||||
} catch (e) {
|
||||
console.error("OCR error:", e);
|
||||
setOcrError("OCR processing failed");
|
||||
}
|
||||
setOcrLoading(false);
|
||||
};
|
||||
|
||||
const downloadOcrText = () => {
|
||||
[...ocrPages]
|
||||
.sort((a, b) => a.page - b.page)
|
||||
.forEach((p) => {
|
||||
const content = p.error
|
||||
? `(page ${p.page} failed: ${p.error})`
|
||||
: p.text ?? "";
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `page_${p.page}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
};
|
||||
|
||||
const downloadAsPdf = async (type: "original" | "pixel") => {
|
||||
if (!doc) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${doc.id}/export?type=${type}`);
|
||||
if (!res.ok) {
|
||||
console.error("PDF export failed", await res.text());
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${doc.originalName.replace(/\.pdf$/i, "")}_${type}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("PDF download failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImages = (pages: { page: number; path: string }[]) => {
|
||||
pages.forEach((p) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = p.path;
|
||||
a.download = `page_${p.page}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="flex min-h-[60vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!doc) {
|
||||
return (
|
||||
<main className="flex min-h-[60vh] flex-col items-center justify-center gap-4">
|
||||
<p className="text-zinc-400">Document not found.</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded-lg bg-violet-600 px-4 py-2 text-sm font-semibold transition hover:bg-violet-500"
|
||||
>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const currentOcr = ocrPages.find((p) => p.page === ocrCurrentPage);
|
||||
const currentWords = ocrWords[ocrCurrentPage] ?? [];
|
||||
const overlayBg = originalPages[ocrCurrentPage - 1]?.path;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-[1600px] px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded-lg border border-white/10 p-2 transition hover:border-white/20 hover:bg-white/5"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-zinc-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 19.5L8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">{doc.originalName}</h1>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{doc.pageCount > 0 ? `${doc.pageCount} pages · ` : ""}
|
||||
Uploaded {new Date(doc.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile tab switcher */}
|
||||
<div className="flex gap-1 rounded-xl bg-white/5 p-1 sm:hidden">
|
||||
{(["original", "pixel", "ocr"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 rounded-lg px-3 py-1.5 text-xs font-semibold capitalize transition ${
|
||||
activeTab === tab
|
||||
? "bg-violet-600 text-white"
|
||||
: "text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tab === "ocr" ? "OCR" : tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-Panel Grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{/* Panel 1: Original */}
|
||||
<Panel
|
||||
title="Original PDF"
|
||||
badge={
|
||||
originalLoading
|
||||
? "Rendering..."
|
||||
: originalPages.length > 0
|
||||
? `${originalPages.length} pages`
|
||||
: "Source"
|
||||
}
|
||||
badgeColor="bg-blue-500/10 text-blue-400"
|
||||
visible={activeTab === "original"}
|
||||
onDownload={
|
||||
originalPages.length > 0
|
||||
? () => downloadImages(originalPages)
|
||||
: undefined
|
||||
}
|
||||
onDownloadPdf={
|
||||
originalPages.length > 0 ? () => downloadAsPdf("original") : undefined
|
||||
}
|
||||
>
|
||||
{originalLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<p className="text-sm text-zinc-500">Rendering pages...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : originalPages.length > 0 ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageNav
|
||||
current={originalCurrentPage}
|
||||
total={originalPages.length}
|
||||
onPrev={() => setOriginalCurrentPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() =>
|
||||
setOriginalCurrentPage((p) =>
|
||||
Math.min(originalPages.length, p + 1)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={originalPages[originalCurrentPage - 1]?.path}
|
||||
alt={`Original page ${originalCurrentPage}`}
|
||||
className="w-full rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Thumbs
|
||||
count={originalPages.length}
|
||||
current={originalCurrentPage}
|
||||
onSelect={setOriginalCurrentPage}
|
||||
activeClass="border-blue-500 bg-blue-500/20 text-blue-300"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={`/api/documents/${doc.id}/asset?type=source`}
|
||||
className="h-full w-full rounded-lg"
|
||||
title="Original PDF"
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Panel 2: Pixel Analysis */}
|
||||
<Panel
|
||||
title="Pixel Analysis"
|
||||
badge={
|
||||
pixelLoading
|
||||
? "Processing..."
|
||||
: pixelPages.length > 0
|
||||
? `${pixelPages.length} pages`
|
||||
: "Pending"
|
||||
}
|
||||
badgeColor="bg-violet-500/10 text-violet-400"
|
||||
visible={activeTab === "pixel"}
|
||||
onDownload={
|
||||
pixelPages.length > 0 ? () => downloadImages(pixelPages) : undefined
|
||||
}
|
||||
onDownloadPdf={
|
||||
pixelPages.length > 0 ? () => downloadAsPdf("pixel") : undefined
|
||||
}
|
||||
>
|
||||
{pixelLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
|
||||
<p className="text-sm text-zinc-500">Analyzing all pages...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : pixelPages.length > 0 ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageNav
|
||||
current={pixelCurrentPage}
|
||||
total={pixelPages.length}
|
||||
onPrev={() => setPixelCurrentPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() =>
|
||||
setPixelCurrentPage((p) =>
|
||||
Math.min(pixelPages.length, p + 1)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={pixelPages[pixelCurrentPage - 1]?.path}
|
||||
alt={`Pixel analysis page ${pixelCurrentPage}`}
|
||||
className="w-full rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Thumbs
|
||||
count={pixelPages.length}
|
||||
current={pixelCurrentPage}
|
||||
onSelect={setPixelCurrentPage}
|
||||
activeClass="border-violet-500 bg-violet-500/20 text-violet-300"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
Pixel analysis will run automatically...
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Panel 3: Tesseract OCR */}
|
||||
<Panel
|
||||
title="Tesseract OCR"
|
||||
badge={
|
||||
ocrLoading
|
||||
? `Processing ${ocrProgress.page}/${ocrProgress.total}`
|
||||
: doc.ocrRan
|
||||
? `${ocrPages.length} pages`
|
||||
: "Manual"
|
||||
}
|
||||
badgeColor={
|
||||
doc.ocrRan
|
||||
? "bg-emerald-500/10 text-emerald-400"
|
||||
: "bg-amber-500/10 text-amber-400"
|
||||
}
|
||||
visible={activeTab === "ocr"}
|
||||
onDownload={
|
||||
ocrPages.length > 0 && !ocrLoading ? downloadOcrText : undefined
|
||||
}
|
||||
>
|
||||
{ocrLoading ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
<p className="text-sm text-zinc-400">Running Tesseract OCR...</p>
|
||||
{ocrProgress.total > 0 && (
|
||||
<div className="w-full max-w-xs">
|
||||
<div className="mb-1 flex justify-between text-[10px] text-zinc-500">
|
||||
<span>
|
||||
Page {ocrProgress.page} of {ocrProgress.total}
|
||||
</span>
|
||||
<span>
|
||||
{Math.round((ocrProgress.page / ocrProgress.total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-white/5">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
||||
style={{
|
||||
width: `${(ocrProgress.page / ocrProgress.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ocrPages.length > 0 && (
|
||||
<div className="mt-2 w-full max-w-xs rounded-lg border border-white/5 bg-white/[0.02] p-3">
|
||||
<p className="mb-1 text-[10px] font-semibold text-zinc-500">
|
||||
Pages completed:
|
||||
</p>
|
||||
{[...ocrPages]
|
||||
.sort((a, b) => a.page - b.page)
|
||||
.map((p) => (
|
||||
<div
|
||||
key={p.page}
|
||||
className="flex items-center justify-between text-[10px] text-zinc-400"
|
||||
>
|
||||
<span>Page {p.page}</span>
|
||||
{p.error ? (
|
||||
<span className="text-red-400">failed</span>
|
||||
) : p.confidence !== undefined ? (
|
||||
<span className={confidenceTextColor(p.confidence)}>
|
||||
{p.confidence.toFixed(0)}%
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : ocrPages.length > 0 ? (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Navigator + view toggle + re-run */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-white/5 px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setOcrCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={ocrCurrentPage <= 1}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setOcrCurrentPage((p) => Math.min(ocrPages.length, p + 1))
|
||||
}
|
||||
disabled={ocrCurrentPage >= ocrPages.length}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<span className="ml-1 text-xs text-zinc-400">
|
||||
{ocrCurrentPage}/{ocrPages.length}
|
||||
</span>
|
||||
{currentOcr?.confidence !== undefined && (
|
||||
<span
|
||||
className={`ml-1 rounded-full bg-white/5 px-2 py-0.5 text-[10px] font-semibold ${confidenceTextColor(
|
||||
currentOcr.confidence
|
||||
)}`}
|
||||
title="Page-level OCR confidence"
|
||||
>
|
||||
{currentOcr.confidence.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex rounded-lg bg-white/5 p-0.5">
|
||||
{(["text", "overlay"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setOcrView(v)}
|
||||
className={`rounded-md px-2 py-0.5 text-[10px] font-semibold capitalize transition ${
|
||||
ocrView === v
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={runOcr}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-[10px] font-semibold text-zinc-400 transition hover:border-white/20 hover:text-white"
|
||||
title="Re-run OCR"
|
||||
>
|
||||
Re-run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ocrError && (
|
||||
<div className="border-b border-red-500/20 bg-red-500/5 px-3 py-1.5 text-[11px] text-red-300">
|
||||
{ocrError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ocrView === "text" ? (
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{currentOcr?.error ? (
|
||||
<p className="text-sm text-red-300">
|
||||
Page {ocrCurrentPage} failed: {currentOcr.error}
|
||||
</p>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-zinc-300">
|
||||
{currentOcr?.text ?? "(loading…)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{currentOcr?.error ? (
|
||||
<p className="p-4 text-sm text-red-300">
|
||||
Page {ocrCurrentPage} failed: {currentOcr.error}
|
||||
</p>
|
||||
) : overlayBg ? (
|
||||
<div className="relative inline-block w-full">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={overlayBg}
|
||||
alt={`OCR overlay page ${ocrCurrentPage}`}
|
||||
className="w-full rounded-lg object-contain"
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget;
|
||||
setOverlayDims({
|
||||
w: el.naturalWidth,
|
||||
h: el.naturalHeight,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{overlayDims &&
|
||||
currentWords.map((w, i) => {
|
||||
const style = confidenceStyle(w.confidence);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
title={`${w.text} — ${w.confidence.toFixed(0)}%`}
|
||||
className="absolute cursor-help"
|
||||
style={{
|
||||
left: `${(w.bbox.x0 / overlayDims.w) * 100}%`,
|
||||
top: `${(w.bbox.y0 / overlayDims.h) * 100}%`,
|
||||
width: `${
|
||||
((w.bbox.x1 - w.bbox.x0) / overlayDims.w) * 100
|
||||
}%`,
|
||||
height: `${
|
||||
((w.bbox.y1 - w.bbox.y0) / overlayDims.h) * 100
|
||||
}%`,
|
||||
backgroundColor: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="p-4 text-sm text-zinc-500">
|
||||
Render the original page to see the overlay.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-zinc-500">
|
||||
<LegendDot color="#22c55e" label="≥90" />
|
||||
<LegendDot color="#f59e0b" label="70–89" />
|
||||
<LegendDot color="#ef4444" label="<70" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Thumbs
|
||||
count={ocrPages.length}
|
||||
current={ocrCurrentPage}
|
||||
onSelect={setOcrCurrentPage}
|
||||
activeClass="border-emerald-500 bg-emerald-500/20 text-emerald-300"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||
<div className="rounded-full bg-amber-500/10 p-4">
|
||||
<svg
|
||||
className="h-8 w-8 text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="mb-1 text-sm font-medium text-zinc-300">
|
||||
OCR Not Yet Run
|
||||
</p>
|
||||
<p className="mb-4 text-xs text-zinc-500">
|
||||
Runs Tesseract on the sanitized image of every page. Re-runnable.
|
||||
</p>
|
||||
{ocrError && (
|
||||
<p className="mb-3 text-xs text-red-300">{ocrError}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={runOcr}
|
||||
className="rounded-full bg-emerald-600 px-6 py-2.5 text-sm font-semibold transition hover:bg-emerald-500"
|
||||
>
|
||||
Run Tesseract OCR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PageNav({
|
||||
current,
|
||||
total,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
current: number;
|
||||
total: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={current <= 1}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-xs text-zinc-400">
|
||||
Page {current} / {total}
|
||||
</span>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={current >= total}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumbs({
|
||||
count,
|
||||
current,
|
||||
onSelect,
|
||||
activeClass,
|
||||
}: {
|
||||
count: number;
|
||||
current: number;
|
||||
onSelect: (page: number) => void;
|
||||
activeClass: string;
|
||||
}) {
|
||||
if (count <= 1) return null;
|
||||
return (
|
||||
<div className="flex gap-1 overflow-x-auto border-t border-white/5 p-2">
|
||||
{Array.from({ length: count }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onSelect(page)}
|
||||
className={`flex-shrink-0 rounded-md border px-2 py-1 text-[10px] font-semibold transition ${
|
||||
current === page
|
||||
? activeClass
|
||||
: "border-white/10 text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({
|
||||
title,
|
||||
badge,
|
||||
badgeColor,
|
||||
children,
|
||||
visible,
|
||||
onDownload,
|
||||
onDownloadPdf,
|
||||
}: {
|
||||
title: string;
|
||||
badge: string;
|
||||
badgeColor: string;
|
||||
children: React.ReactNode;
|
||||
visible: boolean;
|
||||
onDownload?: () => void;
|
||||
onDownloadPdf?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col overflow-hidden rounded-2xl border border-white/5 bg-white/[0.02] ${
|
||||
visible ? "" : "hidden sm:flex"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{onDownload && (
|
||||
<button
|
||||
onClick={onDownload}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-[10px] font-semibold text-zinc-400 transition hover:border-white/20 hover:text-white"
|
||||
title="Download all pages"
|
||||
>
|
||||
<svg
|
||||
className="mr-1 inline-block h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
All
|
||||
</button>
|
||||
)}
|
||||
{onDownloadPdf && (
|
||||
<button
|
||||
onClick={onDownloadPdf}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-[10px] font-semibold text-zinc-400 transition hover:border-white/20 hover:text-white"
|
||||
title="Convert to PDF and download"
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${badgeColor}`}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" style={{ minHeight: "65vh" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import Navbar from "@/components/Navbar";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
||||
<Navbar />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Document = {
|
||||
id: string;
|
||||
originalName: string;
|
||||
filePath: string;
|
||||
fileSize: number;
|
||||
createdAt: string;
|
||||
ocrRan: boolean;
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [docs, setDocs] = useState<Document[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const fetchDocs = useCallback(async () => {
|
||||
const res = await fetch("/api/documents");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDocs(data.documents);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchDocs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!file.name.toLowerCase().endsWith(".pdf")) {
|
||||
alert("Only PDF files are accepted.");
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert("File must be smaller than 10 MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append("pdf", file);
|
||||
|
||||
const res = await fetch("/api/upload", { method: "POST", body: formData });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
router.push(`/dashboard/${data.document.id}`);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || "Upload failed");
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleUpload(file);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Delete this document?")) return;
|
||||
await fetch(`/api/documents/${id}/delete`, { method: "DELETE" });
|
||||
fetchDocs();
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||
{/* Upload zone */}
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`relative flex flex-col items-center justify-center rounded-2xl border-2 border-dashed p-12 text-center transition-colors ${
|
||||
dragOver
|
||||
? "border-violet-500 bg-violet-500/5"
|
||||
: "border-white/10 bg-white/[0.02] hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 rounded-full bg-violet-600/10 p-4">
|
||||
<svg
|
||||
className="h-8 w-8 text-violet-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mb-1 text-lg font-semibold">
|
||||
{uploading ? "Uploading..." : "Drop your PDF here"}
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-zinc-500">
|
||||
or click to browse · Max 10 MB · {docs.length}/5 slots
|
||||
used
|
||||
</p>
|
||||
<label className="cursor-pointer rounded-full bg-violet-600 px-6 py-2.5 text-sm font-semibold transition hover:bg-violet-500">
|
||||
Choose File
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Document list */}
|
||||
<section className="mt-12">
|
||||
<h2 className="mb-4 text-xl font-bold">Your Documents</h2>
|
||||
{docs.length === 0 ? (
|
||||
<p className="text-zinc-500">
|
||||
No documents yet. Upload a PDF to get started.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{docs.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="group flex items-center justify-between rounded-xl border border-white/5 bg-white/[0.02] p-4 transition hover:border-white/10 hover:bg-white/[0.04]"
|
||||
>
|
||||
<button
|
||||
onClick={() => router.push(`/dashboard/${doc.id}`)}
|
||||
className="flex flex-1 items-center gap-4 text-left"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-violet-600/10">
|
||||
<svg
|
||||
className="h-5 w-5 text-violet-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{doc.originalName}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{formatSize(doc.fileSize)} ·{" "}
|
||||
{new Date(doc.createdAt).toLocaleDateString()} ·{" "}
|
||||
{doc.ocrRan ? "OCR complete" : "OCR pending"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(doc.id)}
|
||||
className="ml-4 rounded-lg p-2 text-zinc-600 opacity-0 transition hover:bg-red-500/10 hover:text-red-400 group-hover:opacity-100"
|
||||
title="Delete"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Instrument_Serif } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Providers from "@/lib/SessionProvider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -23,13 +22,13 @@ const instrumentSerif = Instrument_Serif({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "XRayPDF — Strip hidden text out of PDFs",
|
||||
title: "XRayPDF — Strip hidden text out of PDFs, in your browser",
|
||||
description:
|
||||
"A PDF can carry text nobody sees and every machine reads: white ink on white paper, 0.5pt type, content parked off-page. XRayPDF surfaces it and exports a file with none of it left.",
|
||||
"A PDF can carry text nobody sees and every machine reads: white ink on white paper, 0.5pt type, content parked off-page. XRayPDF finds it and rebuilds the file without it — entirely in your browser, with no upload and no account.",
|
||||
openGraph: {
|
||||
title: "XRayPDF — Strip hidden text out of PDFs",
|
||||
title: "XRayPDF — Strip hidden text out of PDFs, in your browser",
|
||||
description:
|
||||
"Find the invisible text in a PDF, then download a flattened copy with no text layer left to hide in. Open source and self-hostable.",
|
||||
"Find the invisible text in a PDF and download a flattened copy with no text layer left to hide in. Runs entirely client-side: no upload, no account, no server storage.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
@@ -41,13 +40,11 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<Providers>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${instrumentSerif.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</Providers>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+97
-42
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import HiddenTextDemo from "@/components/HiddenTextDemo";
|
||||
|
||||
const REPO_URL = "https://github.com/geneticglitch1/XRayPDF";
|
||||
@@ -12,17 +11,17 @@ const HIDING_SPOTS = [
|
||||
{
|
||||
title: "White text on white paper",
|
||||
desc: "Rendered in #FFFFFF. Invisible on screen and in print, still returned verbatim by every text extractor.",
|
||||
caught: "Contrast normalization drags it into visible gray",
|
||||
caught: "Invisible on white, unmistakable on black",
|
||||
},
|
||||
{
|
||||
title: "Near-zero font sizes",
|
||||
desc: "Text set at half a point reads as a smudge, or as nothing at all, but copies and pastes back at full length.",
|
||||
caught: "Renders at 2× scale, so the smudge becomes glyphs",
|
||||
caught: "Rendered at 2× scale, so the smudge becomes glyphs",
|
||||
},
|
||||
{
|
||||
title: "Text behind images",
|
||||
desc: "A full-page graphic drawn on top of a live text run. Looks like a scan. It is not a scan.",
|
||||
caught: "Flattening rebuilds the page from what is visible",
|
||||
caught: "Flattening rebuilds the page from what is drawn",
|
||||
},
|
||||
{
|
||||
title: "Invisible render modes",
|
||||
@@ -44,39 +43,33 @@ const HIDING_SPOTS = [
|
||||
const STEPS = [
|
||||
{
|
||||
n: "01",
|
||||
title: "Rasterize",
|
||||
tool: "pdfjs-dist + @napi-rs/canvas",
|
||||
desc: "Every page renders to a PNG at 2× scale on a forced white background. This is the honest picture of the document: whatever is actually drawn, and nothing else.",
|
||||
title: "Render it twice",
|
||||
tool: "pdf.js, in your tab",
|
||||
desc: "Each page is rasterized at 2× scale — once on a white backdrop, once on black. Both renders come from the same page, drawn by the same engine, differing only in what sits behind the ink.",
|
||||
},
|
||||
{
|
||||
n: "02",
|
||||
title: "Push the contrast",
|
||||
tool: "sharp",
|
||||
desc: "Flatten alpha onto white, convert to grayscale, stretch the histogram across the full 0–255 range, sharpen. Ink that was one shade off white lands in plain view.",
|
||||
title: "Compare the two",
|
||||
tool: "a per-pixel diff",
|
||||
desc: "White ink disappears into the white render and blazes in the black one; ordinary dark ink does the reverse. Invert the black-backed render, take the darker pixel of the pair, and every mark on the page turns dark on white — whatever colour it was drawn in.",
|
||||
},
|
||||
{
|
||||
n: "03",
|
||||
title: "Read it back",
|
||||
tool: "tesseract.js",
|
||||
desc: "OCR runs over the sanitized image and returns word boxes with per-word confidence. Overlay them on the page to see exactly where the surprises are.",
|
||||
tool: "tesseract.js · optional",
|
||||
desc: "OCR over that combined image transcribes the hidden text alongside the visible copy. It costs an ~8 MB model download, so it is off until you ask for it.",
|
||||
},
|
||||
{
|
||||
n: "04",
|
||||
title: "Flatten the export",
|
||||
tool: "pdf-lib",
|
||||
desc: "Rebuild the document from the page images. No text layer, no annotations, no object history — nothing left for anything to hide inside.",
|
||||
desc: "Rebuild the document from page images. No text layer, no annotations, no object history — nothing left for anything to hide inside.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [xray, setXray] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (session) router.replace("/dashboard");
|
||||
}, [session, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stock text-ink">
|
||||
{/* ---------------------------------------------------------------- Nav */}
|
||||
@@ -108,13 +101,12 @@ export default function Home() {
|
||||
X-ray
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => signIn("google")}
|
||||
disabled={status === "loading"}
|
||||
className="bg-ink px-5 py-2.5 text-sm font-medium text-stock transition-colors hover:bg-signal disabled:opacity-50"
|
||||
<Link
|
||||
href="/scan"
|
||||
className="bg-ink px-5 py-2.5 text-sm font-medium text-stock transition-colors hover:bg-signal"
|
||||
>
|
||||
{status === "loading" ? "…" : "Sign in"}
|
||||
</button>
|
||||
Open the scanner
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -124,7 +116,7 @@ export default function Home() {
|
||||
<div className="grid gap-10 border-b border-rule py-16 md:py-24 lg:grid-cols-[1.6fr_1fr] lg:gap-20">
|
||||
<div>
|
||||
<p className="label mb-8 text-ink-faint">
|
||||
Document sanitizer · Apache-2.0
|
||||
Runs in your browser · No upload · Apache-2.0
|
||||
</p>
|
||||
|
||||
<h1 className="font-display text-[3.25rem] leading-[0.95] tracking-tight sm:text-7xl lg:text-[5.5rem]">
|
||||
@@ -153,12 +145,12 @@ export default function Home() {
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-wrap items-center gap-8">
|
||||
<button
|
||||
onClick={() => signIn("google")}
|
||||
<Link
|
||||
href="/scan"
|
||||
className="bg-ink px-7 py-3.5 font-medium text-stock transition-colors hover:bg-signal"
|
||||
>
|
||||
Scan a PDF
|
||||
</button>
|
||||
</Link>
|
||||
<a
|
||||
href="#self-host"
|
||||
className="border-b border-ink-faint pb-0.5 font-medium text-ink transition-colors hover:border-signal hover:text-signal"
|
||||
@@ -171,9 +163,9 @@ export default function Home() {
|
||||
{/* Colophon — the metadata block a printed document would carry. */}
|
||||
<dl className="self-end space-y-0 border-t border-rule lg:border-t-0 lg:border-l lg:pl-10">
|
||||
{[
|
||||
["Upload cap", "10 MB · 12 pages"],
|
||||
["OCR engine", "Tesseract LSTM, local"],
|
||||
["Your files", "Never leave the host"],
|
||||
["Processing", "Your browser, not a server"],
|
||||
["Account", "None required"],
|
||||
["Your files", "Never leave the tab"],
|
||||
["Image", "ghcr.io · amd64 + arm64"],
|
||||
].map(([k, v]) => (
|
||||
<div
|
||||
@@ -261,9 +253,10 @@ export default function Home() {
|
||||
No model. No guessing.
|
||||
</h2>
|
||||
<p className="mt-5 leading-relaxed text-ink-soft">
|
||||
Nothing here classifies what looks suspicious. Four deterministic
|
||||
steps, and the last one removes the hiding places rather than trying
|
||||
to enumerate every trick.
|
||||
Nothing here classifies what looks suspicious. Turning up the
|
||||
contrast cannot work either: text painted in pure white rasterizes
|
||||
to bytes identical to blank paper, so there is no difference in the
|
||||
image to amplify. You have to ask the renderer a second question.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -295,6 +288,68 @@ export default function Home() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ----------------------------------------------------------- Privacy */}
|
||||
<section className="border-y border-rule bg-sheet">
|
||||
<div className="mx-auto max-w-[70rem] px-6 py-16 md:py-24">
|
||||
<div className="mb-12 max-w-2xl">
|
||||
<p className="label mb-3 text-signal">Where this runs</p>
|
||||
<h2 className="font-display text-4xl leading-tight sm:text-5xl">
|
||||
Your file never leaves the tab
|
||||
</h2>
|
||||
<p className="mt-5 leading-relaxed text-ink-soft">
|
||||
Every stage above happens in your browser. The PDF goes from the
|
||||
file picker into memory, through pdf.js, onto a canvas, and back
|
||||
out as a download. It is never sent anywhere, because there is
|
||||
nowhere to send it — and you do not have to take that on trust.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="grid border-t border-rule md:grid-cols-2">
|
||||
{[
|
||||
[
|
||||
"No upload endpoint",
|
||||
"The server has one route, a health check. There is no code path that receives a document, so there is nothing to misconfigure.",
|
||||
],
|
||||
[
|
||||
"No database, no disk",
|
||||
"Nothing is persisted anywhere. Close the tab and the file, the renders and the OCR output are gone with it.",
|
||||
],
|
||||
[
|
||||
"No account",
|
||||
"No sign-in, no email, no session. Nothing identifies you, so nothing can be associated with what you scanned.",
|
||||
],
|
||||
[
|
||||
"No third-party requests",
|
||||
"The OCR model and WASM are served from this site, not a CDN. Open the network tab while you scan: every request goes to this origin, and none of them carry your file.",
|
||||
],
|
||||
].map(([title, body]) => (
|
||||
<div
|
||||
key={title}
|
||||
className="flex gap-5 border-b border-rule py-7 md:odd:pr-10 md:even:border-l md:even:border-rule md:even:pl-10"
|
||||
>
|
||||
<span aria-hidden="true" className="mt-1 text-verified">
|
||||
✓
|
||||
</span>
|
||||
<div>
|
||||
<dt className="font-display text-2xl leading-snug">{title}</dt>
|
||||
<dd className="mt-2 text-sm leading-relaxed text-ink-soft">
|
||||
{body}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<p className="mt-6 max-w-2xl text-sm leading-relaxed text-ink-faint">
|
||||
<span className="font-medium text-ink-soft">The honest caveat:</span>{" "}
|
||||
this page is still served to you over the network, so you are
|
||||
trusting that the code you received is the code described here. If
|
||||
that is not good enough for your threat model, the image below runs
|
||||
the identical build on hardware you control.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------------------------------------------------------- Self-host */}
|
||||
<section id="self-host" className="scroll-mt-4 bg-ink text-stock">
|
||||
<div className="mx-auto grid max-w-[70rem] gap-12 px-6 py-16 md:py-24 lg:grid-cols-[1fr_1.1fr] lg:gap-20">
|
||||
@@ -354,12 +409,12 @@ export default function Home() {
|
||||
Ten megabytes, twelve pages, about a minute. You will find out
|
||||
whether there was anything in there.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => signIn("google")}
|
||||
className="mt-8 bg-ink px-7 py-3.5 font-medium text-stock transition-colors hover:bg-signal"
|
||||
<Link
|
||||
href="/scan"
|
||||
className="mt-8 inline-block bg-ink px-7 py-3.5 font-medium text-stock transition-colors hover:bg-signal"
|
||||
>
|
||||
Sign in with Google
|
||||
</button>
|
||||
Open the scanner
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
"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<ProcessedPage[]>([]);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [view, setView] = useState<View>("revealed");
|
||||
const [ocrEnabled, setOcrEnabled] = useState(false);
|
||||
const [ocrNote, setOcrNote] = useState<string | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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<ReturnType<typeof createOcrWorker>> | 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 (
|
||||
<div className="min-h-screen bg-stock text-ink">
|
||||
<nav className="border-b border-rule">
|
||||
<div className="mx-auto flex max-w-[76rem] items-center justify-between px-6 py-5">
|
||||
<Link href="/" className="font-display text-xl tracking-tight">
|
||||
XRay<span className="text-signal">PDF</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="label hidden text-verified sm:inline">
|
||||
● Running in your browser
|
||||
</span>
|
||||
{pages.length > 0 && (
|
||||
<button
|
||||
onClick={reset}
|
||||
className="label text-ink-soft transition-colors hover:text-signal"
|
||||
>
|
||||
New file
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="mx-auto max-w-[76rem] px-6 py-10">
|
||||
{/* ------------------------------------------------------ dropzone --- */}
|
||||
{pages.length === 0 && (
|
||||
<div className="mx-auto max-w-2xl py-10 text-center">
|
||||
<h1 className="font-display text-5xl leading-tight">
|
||||
Drop a PDF in.
|
||||
</h1>
|
||||
<p className="mt-4 leading-relaxed text-ink-soft">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
}}
|
||||
/>
|
||||
{busy ? (
|
||||
<div>
|
||||
<p className="font-display text-2xl">Rendering…</p>
|
||||
<p className="label mt-3 text-ink-soft">
|
||||
page {progress.done} of {progress.total}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="font-display text-2xl">
|
||||
Drag a file here, or click to choose
|
||||
</p>
|
||||
<p className="label mt-3 text-ink-faint">
|
||||
Stays on this device
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-6 border-l-2 border-signal pl-4 text-left text-sm text-signal">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* -------------------------------------------------------- results --- */}
|
||||
{pages.length > 0 && (
|
||||
<div className="grid gap-10 lg:grid-cols-[1fr_20rem] lg:gap-14">
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-3">
|
||||
<span className="label text-ink-faint">
|
||||
{fileName} · page {page.pageNumber} of {pages.length}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
{(["delivered", "revealed"] as View[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={`label border-b-2 pb-1 transition-colors ${
|
||||
view === v
|
||||
? "border-ink text-ink"
|
||||
: "border-transparent text-ink-faint hover:text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
{v === "delivered" ? "As delivered" : "Revealed"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-rule bg-white p-2">
|
||||
<canvas ref={canvasRef} className="block h-auto w-full" />
|
||||
</div>
|
||||
|
||||
{pages.length > 1 && (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{pages.map((p, i) => (
|
||||
<button
|
||||
key={p.pageNumber}
|
||||
onClick={() => setCurrent(i)}
|
||||
title={
|
||||
isSuspicious(p.scan)
|
||||
? "Near-invisible ink found on this page"
|
||||
: undefined
|
||||
}
|
||||
className={`label h-8 w-8 border transition-colors ${
|
||||
i === current
|
||||
? "border-ink bg-ink text-stock"
|
||||
: isSuspicious(p.scan)
|
||||
? "border-signal text-signal hover:bg-signal-wash"
|
||||
: "border-rule text-ink-faint hover:border-ink-faint"
|
||||
}`}
|
||||
>
|
||||
{p.pageNumber}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{page.ocr && (
|
||||
<div className="mt-8">
|
||||
<p className="label mb-3 text-ink-faint">
|
||||
OCR transcript · page {page.pageNumber} ·{" "}
|
||||
{Math.round(page.ocr.confidence)}% confidence
|
||||
</p>
|
||||
<pre className="max-h-80 overflow-auto border border-rule bg-ink px-5 py-4 font-mono text-[11px] leading-relaxed whitespace-pre-wrap text-stock/70">
|
||||
{page.ocr.text.trim() || "(no text recognized)"}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------ panel --- */}
|
||||
<aside className="space-y-8">
|
||||
<div
|
||||
className={`border-l-2 pl-5 ${
|
||||
flagged.length > 0 ? "border-signal" : "border-verified"
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`label mb-2 ${
|
||||
flagged.length > 0 ? "text-signal" : "text-verified"
|
||||
}`}
|
||||
>
|
||||
{flagged.length > 0
|
||||
? `Near-invisible ink on ${flagged.length} of ${pages.length} pages`
|
||||
: "No near-invisible ink found"}
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-ink-soft">
|
||||
{flagged.length > 0
|
||||
? "Switch to Revealed to see it. A flattened export removes it either way."
|
||||
: "Nothing on these pages sits in the near-white band. The export still strips the text layer."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-rule pt-6">
|
||||
<p className="label mb-2 text-ink-faint">How this works</p>
|
||||
<p className="text-xs leading-relaxed text-ink-faint">
|
||||
Each page is rendered twice — once on white, once on black.
|
||||
Ink that is white or near-white vanishes into the first and
|
||||
shows up in the second, so comparing them exposes marks that
|
||||
are physically on the page but invisible against it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-rule pt-6">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ocrEnabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => setOcrEnabled(e.target.checked)}
|
||||
className="mt-1 accent-[#bf3517]"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">
|
||||
Read the text back (OCR)
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-relaxed text-ink-faint">
|
||||
Advanced. Downloads an ~8 MB recognition model from this
|
||||
site on first use, then runs locally. Slow — expect a few
|
||||
seconds per page.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{ocrNote && (
|
||||
<p className="label mt-4 text-signal">{ocrNote}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-rule pt-6">
|
||||
<button
|
||||
onClick={exportPdf}
|
||||
disabled={busy}
|
||||
className="w-full bg-ink px-5 py-3 font-medium text-stock transition-colors hover:bg-signal disabled:opacity-40"
|
||||
>
|
||||
Download cleaned PDF
|
||||
</button>
|
||||
{pages.some((p) => p.ocr) && (
|
||||
<button
|
||||
onClick={exportText}
|
||||
className="w-full border border-ink px-5 py-3 font-medium transition-colors hover:border-signal hover:text-signal"
|
||||
>
|
||||
Download OCR text
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs leading-relaxed text-ink-faint">
|
||||
The cleaned PDF is rebuilt from page images, so it has no text
|
||||
layer left to hide in — and it is no longer searchable or
|
||||
selectable. Keep the original if you need that.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="border-l-2 border-signal pl-4 text-sm text-signal">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user