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

@@ -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