diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..89ad053 Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md index 111faef..776f139 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,93 @@ -# XRayPDF 🔍✨ +# XRayPDF -**XRayPDF** is a powerful web application designed to help students uncover hidden text in homework PDFs. Teachers sometimes embed text in tricky ways—using near‑invisible colors, extremely small fonts, or even steganographic tricks. PixelReveal renders each PDF page as an image, then applies a combination of pixel‑level analysis and Optical Character Recognition (OCR) to detect and reveal any hidden content. The result is a new, enhanced image where the hidden text becomes clearly visible. +XRayPDF is a web app for sanitizing PDFs by extracting suspicious hidden content and producing cleaner outputs. -![XRayPDF Demo](https://via.placeholder.com/800x400.png?text=PixelReveal+Demo+Screenshot) -*will replace with actual demo screenshot)* +Instead of only "finding" hidden text, the pipeline is designed to help you remove or neutralize hidden layers by generating processed page images and exportable PDFs. ---- +## What the app does -## 🚀 Features +- Uploads a PDF (up to 10MB) +- Renders each page server-side into images +- Runs pixel sanitization on each page to neutralize low-contrast/hidden overlays +- Runs per-page Tesseract OCR for text verification +- Lets you review outputs in a 3-panel viewer (Original / Pixel / OCR) +- Lets you download per-page files or convert processed pages into a single PDF -- 📤 **Upload PDF** – Drag‑and‑drop or select a PDF file. -- 🖼️ **Page‑by‑Page Rendering** – Each page is converted to a high‑resolution image. -- 🔎 **Pixel‑Level Analysis** – Detects text with near‑background colors, low contrast, or steganographic patterns. -- 🔡 **OCR for Tiny Text** – Identifies and extracts extremely small fonts (e.g., <8pt). -- 🎨 **Revealed Image Generation** – Enhances low‑contrast areas, magnifies tiny text regions, or overlays OCR‑extracted text. -- 👁️ **Side‑by‑Side Comparison** – View original and revealed pages together. -- ⬇️ **Download Results** – Save the enhanced image for offline study. +## Extraction + removal workflow ---- +1. **Render original pages** + - Each PDF page is rasterized to a PNG on the server. + - This gives a deterministic image baseline for analysis. -## 🧠 How It Works +2. **Pixel sanitization (removal path)** + - Every rendered page goes through image processing (`sharp`): + - flatten alpha to white background + - grayscale + - normalize contrast + - sharpen edges + - Goal: make suspicious low-contrast hidden layers obvious and easier to remove/ignore in sanitized exports. -1. **Upload PDF** - The user uploads a PDF file. The backend uses `pdf.js` to render each page to a canvas at a high DPI, producing a sharp image buffer. +3. **OCR verification (trust but verify)** + - Tesseract runs page-by-page on server-rendered images. + - OCR text output helps validate what was present, even when visuals are ambiguous. + - If a processed image is not convincing, use OCR text as the secondary evidence channel. -2. **Pixel Analysis** - The image is processed with `sharp` to: - - Stretch contrast to amplify subtle differences. - - Invert colors to reveal light‑on‑light text. - - Detect edges that may indicate character shapes. +4. **Download + PDF conversion** + - Download all per-page PNG/TXT files directly. + - Use the **PDF** button (Original and Pixel panels) to convert page images into one downloadable PDF. -3. **OCR for Tiny Text** - The same page image is fed to `tesseract.js`. The OCR engine identifies text regions and estimates font sizes. Text smaller than a configurable threshold (e.g., 8pt equivalent) is flagged as “tiny hidden text.” +## How to use OCR output to find hidden text -4. **Revealed Image Generation** - Based on the analysis: - - Low‑contrast areas are enhanced. - - Tiny text regions are cropped, upscaled, and blended back into the image. - - Optionally, the OCR‑extracted text can be overlaid in a large, clear font. +If the image result looks unclear: -5. **Display Results** - The original and revealed images are shown side‑by‑side. Users can download the revealed image or toggle different enhancement modes. +- Open the OCR panel and inspect text per page. +- Check low-confidence pages manually in the image panels. +- Compare OCR text against expected visible text from the original. +- Unexpected words, extra lines, or mismatched answers can indicate hidden content that the eye missed. ---- +## Stack -## 🛠️ Tech Stack +- Next.js App Router +- Prisma + PostgreSQL +- NextAuth (Google) +- `pdfjs-dist` for PDF parsing +- `@napi-rs/canvas` for server-side rendering +- `sharp` for image processing +- `tesseract.js` for OCR +- `pdf-lib` for image-to-PDF export -| Area | Technology | -|--------------------|----------------------------------------------------------------------------| -| Frontend | [Next.js](https://nextjs.org/) (App Router), [React](https://reactjs.org/), [Tailwind CSS](https://tailwindcss.com/) | -| Backend | Next.js API routes | -| PDF Rendering | [pdf.js](https://mozilla.github.io/pdf.js/) (Mozilla) | -| Image Processing | [sharp](https://sharp.pixelplumbing.com/) | -| OCR | [tesseract.js](https://tesseract.projectnaptha.com/) | -| Deployment | [Vercel](https://vercel.com/) (frontend) + optional self‑hosted worker | +## Local development ---- +```bash +cd client +npm install +npm run dev +``` -## 📦 Installation +Open http://localhost:3000 -1. **Clone the repository** - ```bash - git clone https://github.com/yourusername/pixelrevel.git - cd pixelrevel \ No newline at end of file +## Production Docker + +Build: + +```bash +cd client +docker build -t xraypdf:latest . +``` + +Run: + +```bash +docker run --rm -p 3000:3000 \ + -e DATABASE_URL="postgresql://user:pass@host:5432/db" \ + -e NEXTAUTH_SECRET="your-secret" \ + -e NEXTAUTH_URL="http://localhost:3000" \ + -e GOOGLE_CLIENT_ID="your-google-client-id" \ + -e GOOGLE_CLIENT_SECRET="your-google-client-secret" \ + xraypdf:latest +``` + +## Notes + +- OCR is designed as a verification step and should be reviewed with page images. +- For best results, re-run analysis on a fresh upload if source PDF structure changes. diff --git a/client/.dockerignore b/client/.dockerignore new file mode 100644 index 0000000..242f7c8 --- /dev/null +++ b/client/.dockerignore @@ -0,0 +1,13 @@ +node_modules +npm-debug.log +.next +.git +.gitignore +Dockerfile +.dockerignore +.env +.env.* +*.local +coverage +public/results +public/uploads diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 0000000..a517ffc --- /dev/null +++ b/client/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:20-bookworm-slim AS base +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +FROM base AS deps +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:20-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 + +RUN groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/client/next.config.ts b/client/next.config.ts index 4687ad7..aeddcb3 100644 --- a/client/next.config.ts +++ b/client/next.config.ts @@ -3,7 +3,13 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ reactCompiler: true, - serverExternalPackages: ["canvas", "sharp", "tesseract.js", "pdfjs-dist"], + output: "standalone", + serverExternalPackages: [ + "@napi-rs/canvas", + "sharp", + "tesseract.js", + "pdfjs-dist", + ], images: { remotePatterns: [ { diff --git a/client/package-lock.json b/client/package-lock.json index d8626f6..e335652 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,12 +8,14 @@ "name": "client", "version": "0.1.0", "dependencies": { + "@napi-rs/canvas": "^0.1.95", "@prisma/adapter-pg": "^7.4.1", "@prisma/client": "^7.4.1", "canvas": "^3.2.1", "multer": "^2.0.2", "next": "16.1.6", "next-auth": "^4.24.13", + "pdf-lib": "^1.17.1", "pdfjs-dist": "^5.4.624", "pg": "^8.19.0", "react": "19.2.3", @@ -1585,7 +1587,6 @@ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.95.tgz", "integrity": "sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==", "license": "MIT", - "optional": true, "workspaces": [ "e2e/*" ], @@ -2044,6 +2045,24 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.1.tgz", @@ -7289,6 +7308,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7336,6 +7361,24 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/pdfjs-dist": { "version": "5.4.624", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.624.tgz", diff --git a/client/package.json b/client/package.json index c58f8e5..5b894ff 100644 --- a/client/package.json +++ b/client/package.json @@ -9,12 +9,14 @@ "lint": "eslint" }, "dependencies": { + "@napi-rs/canvas": "^0.1.95", "@prisma/adapter-pg": "^7.4.1", "@prisma/client": "^7.4.1", "canvas": "^3.2.1", "multer": "^2.0.2", "next": "16.1.6", "next-auth": "^4.24.13", + "pdf-lib": "^1.17.1", "pdfjs-dist": "^5.4.624", "pg": "^8.19.0", "react": "19.2.3", diff --git a/client/src/app/api/documents/[id]/export/route.ts b/client/src/app/api/documents/[id]/export/route.ts new file mode 100644 index 0000000..9ab40dd --- /dev/null +++ b/client/src/app/api/documents/[id]/export/route.ts @@ -0,0 +1,93 @@ +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"; + +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 resultsDir = path.join( + process.cwd(), + "public", + "results", + user.id, + id, + type + ); + + const files = (await readdir(resultsDir)) + .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(resultsDir, 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 }); + } +} diff --git a/client/src/app/api/documents/[id]/ocr/route.ts b/client/src/app/api/documents/[id]/ocr/route.ts index 9b6f36d..3b8488a 100644 --- a/client/src/app/api/documents/[id]/ocr/route.ts +++ b/client/src/app/api/documents/[id]/ocr/route.ts @@ -80,7 +80,7 @@ export async function POST( const stream = new ReadableStream({ async start(controller) { try { - const { createCanvas } = await import("canvas"); + const { createCanvas } = await import("@napi-rs/canvas"); const Tesseract = await import("tesseract.js"); const allPages: { page: number; path: string }[] = []; @@ -106,6 +106,10 @@ export async function POST( const canvas = createCanvas(width, height); const ctx = canvas.getContext("2d"); + // Fill white background for accurate OCR + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + await page.render({ // eslint-disable-next-line @typescript-eslint/no-explicit-any canvasContext: ctx as any, diff --git a/client/src/app/api/documents/[id]/pixel/route.ts b/client/src/app/api/documents/[id]/pixel/route.ts index 28e326e..6a2a1dc 100644 --- a/client/src/app/api/documents/[id]/pixel/route.ts +++ b/client/src/app/api/documents/[id]/pixel/route.ts @@ -34,14 +34,7 @@ export async function POST( return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - // If already processed, return existing results - if (doc.pixelResultDir && doc.pixelPageCount > 0) { - const pages = Array.from({ length: doc.pixelPageCount }, (_, i) => ({ - page: i + 1, - path: `${doc.pixelResultDir}/page_${i + 1}.png`, - })); - return NextResponse.json({ pages, pageCount: doc.pixelPageCount }); - } + // Always regenerate so stale cached outputs don't persist. try { const pdfPath = path.join(process.cwd(), "public", doc.filePath); @@ -67,7 +60,7 @@ export async function POST( const publicDir = `/results/${user.id}/${id}/pixel`; const pages: { page: number; path: string }[] = []; - const { createCanvas } = await import("canvas"); + const { createCanvas } = await import("@napi-rs/canvas"); for (let pageNum = 1; pageNum <= numPages; pageNum++) { const page = await pdfDoc.getPage(pageNum); @@ -79,6 +72,11 @@ export async function POST( const canvas = createCanvas(width, height); const ctx = canvas.getContext("2d"); + // Fill with white background first — node-canvas starts transparent + // which causes the image to appear all-black after processing + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + await page.render({ // eslint-disable-next-line @typescript-eslint/no-explicit-any canvasContext: ctx as any, @@ -92,10 +90,17 @@ export async function POST( const resultFileName = `page_${pageNum}.png`; const resultPath = path.join(resultsDir, resultFileName); + // Pixel analysis pipeline: + // 1. flatten alpha to white (ensures no transparent areas) + // 2. grayscale for clarity + // 3. normalize stretches histogram to full 0-255 range, + // revealing subtle near-white hidden text + // 4. sharpen edges to make text crisp await sharp(imageBuffer) - .negate({ alpha: false }) + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .grayscale() .normalize() - .sharpen() + .sharpen({ sigma: 3 }) .toFile(resultPath); pages.push({ diff --git a/client/src/app/api/documents/[id]/render/route.ts b/client/src/app/api/documents/[id]/render/route.ts new file mode 100644 index 0000000..61ac2d0 --- /dev/null +++ b/client/src/app/api/documents/[id]/render/route.ts @@ -0,0 +1,109 @@ +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, mkdir } from "fs/promises"; +import path from "path"; + +/** + * Server-side PDF page renderer: renders EVERY page of the PDF as a + * clean PNG image (no processing). Used by the "Original" panel so + * all three panels display consistently via server-rendered images. + */ +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 }); + } + + const publicDir = `/results/${user.id}/${id}/original`; + const resultsDir = path.join(process.cwd(), "public", publicDir); + + // Always regenerate so stale cached renders don't persist. + + try { + const pdfPath = path.join(process.cwd(), "public", doc.filePath); + const pdfBuffer = await readFile(pdfPath); + + const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs"); + const loadingTask = pdfjsLib.getDocument({ + data: new Uint8Array(pdfBuffer), + }); + const pdfDoc = await loadingTask.promise; + const numPages = pdfDoc.numPages; + + await mkdir(resultsDir, { recursive: true }); + + const pages: { page: number; path: string }[] = []; + const { createCanvas } = await import("@napi-rs/canvas"); + + for (let pageNum = 1; pageNum <= numPages; pageNum++) { + const page = await pdfDoc.getPage(pageNum); + const scale = 2.0; + const viewport = page.getViewport({ scale }); + const width = Math.floor(viewport.width); + const height = Math.floor(viewport.height); + + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + + // Fill white background — node-canvas defaults to transparent + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + + await page.render({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + canvasContext: ctx as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + canvas: canvas as any, + viewport, + }).promise; + + const resultFileName = `page_${pageNum}.png`; + const resultPath = path.join(resultsDir, resultFileName); + + // Write the raw rendered PNG — no sharp processing + const buffer = canvas.toBuffer("image/png"); + const { writeFile } = await import("fs/promises"); + await writeFile(resultPath, buffer); + + pages.push({ + page: pageNum, + path: `${publicDir}/${resultFileName}`, + }); + } + + // Update pageCount if not set + 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 } + ); + } +} diff --git a/client/src/app/dashboard/[id]/page.tsx b/client/src/app/dashboard/[id]/page.tsx index b19a103..85e0021 100644 --- a/client/src/app/dashboard/[id]/page.tsx +++ b/client/src/app/dashboard/[id]/page.tsx @@ -17,6 +17,7 @@ type DocData = { createdAt: string; }; +type OriginalPage = { page: number; path: string }; type PixelPage = { page: number; path: string }; type OcrPage = { page: number; @@ -34,6 +35,11 @@ export default function ViewerPage({ const [doc, setDoc] = useState(null); const [loading, setLoading] = useState(true); + // Original server-rendered state + const [originalLoading, setOriginalLoading] = useState(false); + const [originalPages, setOriginalPages] = useState([]); + const [originalCurrentPage, setOriginalCurrentPage] = useState(1); + // Pixel state const [pixelLoading, setPixelLoading] = useState(false); const [pixelPages, setPixelPages] = useState([]); @@ -57,6 +63,8 @@ export default function ViewerPage({ const found = data.documents.find((d: DocData) => d.id === id); if (found) { setDoc(found); + // Auto-trigger original render + renderOriginalPages(found.id); // Auto-trigger pixel if not done yet if (!found.pixelResultDir || found.pixelPageCount === 0) { runPixelAnalysis(found.id); @@ -85,6 +93,22 @@ export default function ViewerPage({ 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 { @@ -227,6 +251,29 @@ export default function ViewerPage({ }); }; + 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); + } + }; + if (loading) { return (
@@ -301,18 +348,108 @@ export default function ViewerPage({ {/* 3-Panel Grid */}
- {/* Panel 1: Original */} + {/* Panel 1: Original (server-rendered) */} 0 ? `${doc.pageCount} pages` : "Source"} + 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 + ? () => { + originalPages.forEach((p) => { + const a = document.createElement("a"); + a.href = p.path; + a.download = p.path.split("/").pop() || `page_${p.page}.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }); + } + : undefined + } + onDownloadPdf={ + originalPages.length > 0 + ? () => downloadAsPdf("original") + : undefined + } > -