MVP
Next step 1) Make a Demo video 2) Deploy
This commit is contained in:
1 parent
41186abc57
commit
6db1dc32ec
13 files changed
+566
-80
No files matched your search
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
coverage
|
||||
public/results
|
||||
public/uploads
|
||||
@@ -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"]
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Generated
+44
-1
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 & Tesseract 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 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
|
||||
|
||||
Reference in new issue
Block a user