Next step
1) Make a Demo video
2) Deploy
This commit is contained in:
Aryan Singh committed 2026-02-25 23:23:23 -06:00
1 parent 41186abc57
commit 6db1dc32ec
13 files changed
+561 -75

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+75 -47
View File
@@ -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) 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.
*will replace with actual demo screenshot)*
--- ## 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. ## Extraction + removal workflow
- 🖼️ **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.
--- 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** 3. **OCR verification (trust but verify)**
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. - 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** 4. **Download + PDF conversion**
The image is processed with `sharp` to: - Download all per-page PNG/TXT files directly.
- Stretch contrast to amplify subtle differences. - Use the **PDF** button (Original and Pixel panels) to convert page images into one downloadable PDF.
- Invert colors to reveal light‑on‑light text.
- Detect edges that may indicate character shapes.
3. **OCR for Tiny Text** ## How to use OCR output to find hidden 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.”
4. **Revealed Image Generation** If the image result looks unclear:
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.
5. **Display Results** - Open the OCR panel and inspect text per page.
The original and revealed images are shown side‑by‑side. Users can download the revealed image or toggle different enhancement modes. - 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 | ## Local development
|--------------------|----------------------------------------------------------------------------|
| 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 |
--- ```bash
cd client
npm install
npm run dev
```
## 📦 Installation Open http://localhost:3000
1. **Clone the repository** ## Production Docker
```bash
git clone https://github.com/yourusername/pixelrevel.git Build:
cd pixelrevel
```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.
+13
View File
@@ -0,0 +1,13 @@
node_modules
npm-debug.log
.next
.git
.gitignore
Dockerfile
.dockerignore
.env
.env.*
*.local
coverage
public/results
public/uploads
+32
View File
@@ -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"]
+7 -1
View File
@@ -3,7 +3,13 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
reactCompiler: true, reactCompiler: true,
serverExternalPackages: ["canvas", "sharp", "tesseract.js", "pdfjs-dist"], output: "standalone",
serverExternalPackages: [
"@napi-rs/canvas",
"sharp",
"tesseract.js",
"pdfjs-dist",
],
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
+44 -1
View File
@@ -8,12 +8,14 @@
"name": "client", "name": "client",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^0.1.95",
"@prisma/adapter-pg": "^7.4.1", "@prisma/adapter-pg": "^7.4.1",
"@prisma/client": "^7.4.1", "@prisma/client": "^7.4.1",
"canvas": "^3.2.1", "canvas": "^3.2.1",
"multer": "^2.0.2", "multer": "^2.0.2",
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.624", "pdfjs-dist": "^5.4.624",
"pg": "^8.19.0", "pg": "^8.19.0",
"react": "19.2.3", "react": "19.2.3",
@@ -1585,7 +1587,6 @@
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.95.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.95.tgz",
"integrity": "sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==", "integrity": "sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==",
"license": "MIT", "license": "MIT",
"optional": true,
"workspaces": [ "workspaces": [
"e2e/*" "e2e/*"
], ],
@@ -2044,6 +2045,24 @@
"url": "https://github.com/sponsors/panva" "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": { "node_modules/@prisma/adapter-pg": {
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.1.tgz",
@@ -7289,6 +7308,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7336,6 +7361,24 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/pdfjs-dist": {
"version": "5.4.624", "version": "5.4.624",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.624.tgz", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.624.tgz",
+2
View File
@@ -9,12 +9,14 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^0.1.95",
"@prisma/adapter-pg": "^7.4.1", "@prisma/adapter-pg": "^7.4.1",
"@prisma/client": "^7.4.1", "@prisma/client": "^7.4.1",
"canvas": "^3.2.1", "canvas": "^3.2.1",
"multer": "^2.0.2", "multer": "^2.0.2",
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.624", "pdfjs-dist": "^5.4.624",
"pg": "^8.19.0", "pg": "^8.19.0",
"react": "19.2.3", "react": "19.2.3",
@@ -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 });
}
}
@@ -80,7 +80,7 @@ export async function POST(
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
try { try {
const { createCanvas } = await import("canvas"); const { createCanvas } = await import("@napi-rs/canvas");
const Tesseract = await import("tesseract.js"); const Tesseract = await import("tesseract.js");
const allPages: { page: number; path: string }[] = []; const allPages: { page: number; path: string }[] = [];
@@ -106,6 +106,10 @@ export async function POST(
const canvas = createCanvas(width, height); const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
// Fill white background for accurate OCR
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
await page.render({ await page.render({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
canvasContext: ctx as any, canvasContext: ctx as any,
@@ -34,14 +34,7 @@ export async function POST(
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
// If already processed, return existing results // Always regenerate so stale cached outputs don't persist.
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 });
}
try { try {
const pdfPath = path.join(process.cwd(), "public", doc.filePath); 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 publicDir = `/results/${user.id}/${id}/pixel`;
const pages: { page: number; path: string }[] = []; 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++) { for (let pageNum = 1; pageNum <= numPages; pageNum++) {
const page = await pdfDoc.getPage(pageNum); const page = await pdfDoc.getPage(pageNum);
@@ -79,6 +72,11 @@ export async function POST(
const canvas = createCanvas(width, height); const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d"); 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({ await page.render({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
canvasContext: ctx as any, canvasContext: ctx as any,
@@ -92,10 +90,17 @@ export async function POST(
const resultFileName = `page_${pageNum}.png`; const resultFileName = `page_${pageNum}.png`;
const resultPath = path.join(resultsDir, resultFileName); 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) await sharp(imageBuffer)
.negate({ alpha: false }) .flatten({ background: { r: 255, g: 255, b: 255 } })
.grayscale()
.normalize() .normalize()
.sharpen() .sharpen({ sigma: 3 })
.toFile(resultPath); .toFile(resultPath);
pages.push({ pages.push({
@@ -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 }
);
}
}
+153 -2
View File
@@ -17,6 +17,7 @@ type DocData = {
createdAt: string; createdAt: string;
}; };
type OriginalPage = { page: number; path: string };
type PixelPage = { page: number; path: string }; type PixelPage = { page: number; path: string };
type OcrPage = { type OcrPage = {
page: number; page: number;
@@ -34,6 +35,11 @@ export default function ViewerPage({
const [doc, setDoc] = useState<DocData | null>(null); const [doc, setDoc] = useState<DocData | null>(null);
const [loading, setLoading] = useState(true); 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 // Pixel state
const [pixelLoading, setPixelLoading] = useState(false); const [pixelLoading, setPixelLoading] = useState(false);
const [pixelPages, setPixelPages] = useState<PixelPage[]>([]); const [pixelPages, setPixelPages] = useState<PixelPage[]>([]);
@@ -57,6 +63,8 @@ export default function ViewerPage({
const found = data.documents.find((d: DocData) => d.id === id); const found = data.documents.find((d: DocData) => d.id === id);
if (found) { if (found) {
setDoc(found); setDoc(found);
// Auto-trigger original render
renderOriginalPages(found.id);
// Auto-trigger pixel if not done yet // Auto-trigger pixel if not done yet
if (!found.pixelResultDir || found.pixelPageCount === 0) { if (!found.pixelResultDir || found.pixelPageCount === 0) {
runPixelAnalysis(found.id); runPixelAnalysis(found.id);
@@ -85,6 +93,22 @@ export default function ViewerPage({
fetchDoc(); fetchDoc();
}, [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) => { const runPixelAnalysis = async (docId: string) => {
setPixelLoading(true); setPixelLoading(true);
try { 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) { if (loading) {
return ( return (
<main className="flex min-h-[60vh] items-center justify-center"> <main className="flex min-h-[60vh] items-center justify-center">
@@ -301,18 +348,108 @@ export default function ViewerPage({
{/* 3-Panel Grid */} {/* 3-Panel Grid */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
{/* Panel 1: Original */} {/* Panel 1: Original (server-rendered) */}
<Panel <Panel
title="Original PDF" title="Original PDF"
badge={doc.pageCount > 0 ? `${doc.pageCount} pages` : "Source"} badge={
originalLoading
? "Rendering..."
: originalPages.length > 0
? `${originalPages.length} pages`
: "Source"
}
badgeColor="bg-blue-500/10 text-blue-400" badgeColor="bg-blue-500/10 text-blue-400"
visible={activeTab === "original"} 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
}
> >
{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">
{/* Page navigator */}
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2">
<button
onClick={() =>
setOriginalCurrentPage((p) => Math.max(1, p - 1))
}
disabled={originalCurrentPage <= 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 {originalCurrentPage} / {originalPages.length}
</span>
<button
onClick={() =>
setOriginalCurrentPage((p) =>
Math.min(originalPages.length, p + 1)
)
}
disabled={originalCurrentPage >= originalPages.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>
</div>
{/* Image display */}
<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>
{/* Page thumbnails */}
{originalPages.length > 1 && (
<div className="flex gap-1 overflow-x-auto border-t border-white/5 p-2">
{originalPages.map((p) => (
<button
key={p.page}
onClick={() => setOriginalCurrentPage(p.page)}
className={`flex-shrink-0 rounded-md border px-2 py-1 text-[10px] font-semibold transition ${
originalCurrentPage === p.page
? "border-blue-500 bg-blue-500/20 text-blue-300"
: "border-white/10 text-zinc-500 hover:text-zinc-300"
}`}
>
{p.page}
</button>
))}
</div>
)}
</div>
) : (
<iframe <iframe
src={doc.filePath} src={doc.filePath}
className="h-full w-full rounded-lg" className="h-full w-full rounded-lg"
title="Original PDF" title="Original PDF"
/> />
)}
</Panel> </Panel>
{/* Panel 2: Pixel Analysis */} {/* Panel 2: Pixel Analysis */}
@@ -330,6 +467,9 @@ export default function ViewerPage({
onDownload={ onDownload={
pixelPages.length > 0 ? () => downloadAll("pixel") : undefined pixelPages.length > 0 ? () => downloadAll("pixel") : undefined
} }
onDownloadPdf={
pixelPages.length > 0 ? () => downloadAsPdf("pixel") : undefined
}
> >
{pixelLoading ? ( {pixelLoading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@@ -582,6 +722,7 @@ function Panel({
children, children,
visible, visible,
onDownload, onDownload,
onDownloadPdf,
}: { }: {
title: string; title: string;
badge: string; badge: string;
@@ -589,6 +730,7 @@ function Panel({
children: React.ReactNode; children: React.ReactNode;
visible: boolean; visible: boolean;
onDownload?: () => void; onDownload?: () => void;
onDownloadPdf?: () => void;
}) { }) {
return ( return (
<div <div
@@ -621,6 +763,15 @@ function Panel({
All All
</button> </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 <span
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${badgeColor}`} className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${badgeColor}`}
> >
+12 -12
View File
@@ -42,17 +42,17 @@ export default function Home() {
</div> </div>
<h1 className="max-w-3xl text-5xl font-extrabold leading-[1.1] tracking-tight md:text-7xl"> <h1 className="max-w-3xl text-5xl font-extrabold leading-[1.1] tracking-tight md:text-7xl">
Reveal{" "} Remove{" "}
<span className="bg-gradient-to-r from-violet-400 via-fuchsia-400 to-pink-400 bg-clip-text text-transparent"> <span className="bg-gradient-to-r from-violet-400 via-fuchsia-400 to-pink-400 bg-clip-text text-transparent">
Hidden Text Hidden Content
</span>{" "} </span>{" "}
in Any PDF from Any PDF
</h1> </h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-zinc-400 md:text-xl"> <p className="mt-6 max-w-xl text-lg leading-relaxed text-zinc-400 md:text-xl">
Upload your homework PDF and instantly uncover invisible text hidden Upload your PDF and clean it page-by-page by stripping suspicious
with white fonts, tiny sizes, or steganographic tricks — powered by hidden layers, faint overlays, and stealth text patterns using
pixel analysis &amp; Tesseract&nbsp;OCR. server-side pixel processing and Tesseract&nbsp;OCR.
</p> </p>
<div className="mt-10 flex flex-col gap-4 sm:flex-row"> <div className="mt-10 flex flex-col gap-4 sm:flex-row">
@@ -94,18 +94,18 @@ export default function Home() {
{[ {[
{ {
icon: "🔍", icon: "🔍",
title: "Pixel Analysis", title: "Pixel Sanitization",
desc: "Inverts and contrast-boosts every page to expose hidden white-on-white text instantly.", desc: "Processes each page to isolate and neutralize hidden low-contrast content before export.",
}, },
{ {
icon: "🔡", icon: "🔡",
title: "Tesseract OCR", title: "OCR Verification",
desc: "Server-side OCR detects tiny or cleverly-colored text that pixel analysis alone can't catch.", desc: "Runs OCR across every page so hidden text can be reviewed and removed confidently.",
}, },
{ {
icon: "📄", icon: "📄",
title: "Side-by-Side View", title: "Clean Export Workflow",
desc: "Compare original, pixel-revealed, and OCR results in a beautiful three-panel layout.", desc: "Review original vs processed outputs side-by-side and download sanitized per-page results.",
}, },
].map((f) => ( ].map((f) => (
<div <div