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
+566 -80

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)
*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
## 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.
+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 = {
/* 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: [
{
+44 -1
View File
@@ -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",
+2
View File
@@ -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",
@@ -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({
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,
@@ -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({
@@ -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 }
);
}
}
+158 -7
View File
@@ -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<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[]>([]);
@@ -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 (
<main className="flex min-h-[60vh] items-center justify-center">
@@ -301,18 +348,108 @@ export default function ViewerPage({
{/* 3-Panel Grid */}
<div className="grid gap-4 sm:grid-cols-3">
{/* Panel 1: Original */}
{/* Panel 1: Original (server-rendered) */}
<Panel
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"
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
}
>
<iframe
src={doc.filePath}
className="h-full w-full rounded-lg"
title="Original PDF"
/>
{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
src={doc.filePath}
className="h-full w-full rounded-lg"
title="Original PDF"
/>
)}
</Panel>
{/* Panel 2: Pixel Analysis */}
@@ -330,6 +467,9 @@ export default function ViewerPage({
onDownload={
pixelPages.length > 0 ? () => downloadAll("pixel") : undefined
}
onDownloadPdf={
pixelPages.length > 0 ? () => downloadAsPdf("pixel") : undefined
}
>
{pixelLoading ? (
<div className="flex h-full items-center justify-center">
@@ -582,6 +722,7 @@ function Panel({
children,
visible,
onDownload,
onDownloadPdf,
}: {
title: string;
badge: string;
@@ -589,6 +730,7 @@ function Panel({
children: React.ReactNode;
visible: boolean;
onDownload?: () => void;
onDownloadPdf?: () => void;
}) {
return (
<div
@@ -621,6 +763,15 @@ function Panel({
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}`}
>
+12 -12
View File
@@ -42,17 +42,17 @@ export default function Home() {
</div>
<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">
Hidden Text
Hidden Content
</span>{" "}
in Any PDF
from Any PDF
</h1>
<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
with white fonts, tiny sizes, or steganographic tricks — powered by
pixel analysis &amp; Tesseract&nbsp;OCR.
Upload your PDF and clean it page-by-page by stripping suspicious
hidden layers, faint overlays, and stealth text patterns using
server-side pixel processing and Tesseract&nbsp;OCR.
</p>
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
@@ -94,18 +94,18 @@ export default function Home() {
{[
{
icon: "🔍",
title: "Pixel Analysis",
desc: "Inverts and contrast-boosts every page to expose hidden white-on-white text instantly.",
title: "Pixel Sanitization",
desc: "Processes each page to isolate and neutralize hidden low-contrast content before export.",
},
{
icon: "🔡",
title: "Tesseract OCR",
desc: "Server-side OCR detects tiny or cleverly-colored text that pixel analysis alone can't catch.",
title: "OCR Verification",
desc: "Runs OCR across every page so hidden text can be reviewed and removed confidently.",
},
{
icon: "📄",
title: "Side-by-Side View",
desc: "Compare original, pixel-revealed, and OCR results in a beautiful three-panel layout.",
title: "Clean Export Workflow",
desc: "Review original vs processed outputs side-by-side and download sanitized per-page results.",
},
].map((f) => (
<div