Add XRayPDF client app with OCR routes
Introduce a new Next.js `client` app for XRayPDF: adds frontend pages (dashboard, upload, layout, Navbar, globals) and API routes for documents (upload, OCR, pixel processing, delete, list). Include Tesseract traineddata (eng.traineddata), update next.config.ts to allow native server packages and remote images, update package.json to add dependencies (canvas, sharp, tesseract.js, pdfjs-dist, multer, uuid), and update Prisma schema. Also add .gitignore entries for user uploads/results and a README describing the project and architecture.
This commit is contained in:
1 parent
50bc2eea74
commit
41186abc57
20 files changed
+2586
-81
No files matched your search
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function DELETE(
|
||||
_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: "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 });
|
||||
}
|
||||
|
||||
await prisma.pdfDocument.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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, writeFile, mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Tesseract OCR: runs OCR on EVERY page of the PDF.
|
||||
* Streams progress back to the client via newline-delimited JSON.
|
||||
* Only allowed ONCE per document. Must be explicitly triggered.
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
|
||||
if (doc.ocrRan) {
|
||||
// Return existing results
|
||||
if (doc.ocrResultDir && doc.ocrPageCount > 0) {
|
||||
const pages = Array.from({ length: doc.ocrPageCount }, (_, i) => ({
|
||||
page: i + 1,
|
||||
path: `${doc.ocrResultDir}/page_${i + 1}.txt`,
|
||||
}));
|
||||
return NextResponse.json({
|
||||
pages,
|
||||
pageCount: doc.ocrPageCount,
|
||||
alreadyRan: true,
|
||||
});
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "OCR has already been run", alreadyRan: true },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
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 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;
|
||||
|
||||
const resultsDir = path.join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"results",
|
||||
user.id,
|
||||
id,
|
||||
"ocr"
|
||||
);
|
||||
await mkdir(resultsDir, { recursive: true });
|
||||
|
||||
const publicDir = `/results/${user.id}/${id}/ocr`;
|
||||
|
||||
// Stream progress to the client via newline-delimited JSON
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
const { createCanvas } = await import("canvas");
|
||||
const Tesseract = await import("tesseract.js");
|
||||
|
||||
const allPages: { page: number; path: string }[] = [];
|
||||
|
||||
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
|
||||
// Send progress event
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: "progress",
|
||||
page: pageNum,
|
||||
totalPages: numPages,
|
||||
}) + "\n"
|
||||
)
|
||||
);
|
||||
|
||||
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");
|
||||
|
||||
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 imageBuffer = canvas.toBuffer("image/png");
|
||||
const tempImgPath = path.join(
|
||||
resultsDir,
|
||||
`page_${pageNum}_temp.png`
|
||||
);
|
||||
await writeFile(tempImgPath, imageBuffer);
|
||||
|
||||
const { data } = await Tesseract.recognize(tempImgPath, "eng");
|
||||
|
||||
// Save per-page OCR text
|
||||
const ocrFileName = `page_${pageNum}.txt`;
|
||||
const ocrPath = path.join(resultsDir, ocrFileName);
|
||||
await writeFile(ocrPath, data.text, "utf-8");
|
||||
|
||||
const publicPath = `${publicDir}/${ocrFileName}`;
|
||||
allPages.push({ page: pageNum, path: publicPath });
|
||||
|
||||
// Send page-complete event
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: "page_done",
|
||||
page: pageNum,
|
||||
totalPages: numPages,
|
||||
text: data.text,
|
||||
confidence: data.confidence,
|
||||
path: publicPath,
|
||||
}) + "\n"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Update DB
|
||||
await prisma.pdfDocument.update({
|
||||
where: { id },
|
||||
data: {
|
||||
ocrRan: true,
|
||||
ocrResultDir: publicDir,
|
||||
ocrPageCount: numPages,
|
||||
pageCount: numPages,
|
||||
},
|
||||
});
|
||||
|
||||
// Send final complete event
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: "complete",
|
||||
pages: allPages,
|
||||
pageCount: numPages,
|
||||
}) + "\n"
|
||||
)
|
||||
);
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
console.error("OCR streaming error:", err);
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: "OCR processing failed",
|
||||
}) + "\n"
|
||||
)
|
||||
);
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Transfer-Encoding": "chunked",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("OCR error:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "OCR processing failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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 sharp from "sharp";
|
||||
import { readFile, mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Pixel-level analysis: renders EVERY page of the PDF as a PNG,
|
||||
* inverts colors and boosts contrast to reveal hidden white-on-white text.
|
||||
* Returns an array of per-page result image paths.
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const resultsDir = path.join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"results",
|
||||
user.id,
|
||||
id,
|
||||
"pixel"
|
||||
);
|
||||
await mkdir(resultsDir, { recursive: true });
|
||||
|
||||
const publicDir = `/results/${user.id}/${id}/pixel`;
|
||||
const pages: { page: number; path: string }[] = [];
|
||||
|
||||
const { createCanvas } = await import("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");
|
||||
|
||||
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 imageBuffer = canvas.toBuffer("image/png");
|
||||
|
||||
const resultFileName = `page_${pageNum}.png`;
|
||||
const resultPath = path.join(resultsDir, resultFileName);
|
||||
|
||||
await sharp(imageBuffer)
|
||||
.negate({ alpha: false })
|
||||
.normalize()
|
||||
.sharpen()
|
||||
.toFile(resultPath);
|
||||
|
||||
pages.push({
|
||||
page: pageNum,
|
||||
path: `${publicDir}/${resultFileName}`,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.pdfDocument.update({
|
||||
where: { id },
|
||||
data: {
|
||||
pixelResultDir: publicDir,
|
||||
pixelPageCount: numPages,
|
||||
pageCount: numPages,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ pages, pageCount: numPages });
|
||||
} catch (e) {
|
||||
console.error("Pixel analysis error:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Pixel analysis failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: {
|
||||
documents: { orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ documents: user.documents });
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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 { writeFile, mkdir } from "fs/promises";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import path from "path";
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
const MAX_DOCUMENTS = 5;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: { documents: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("pdf") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: "File exceeds 10 MB limit" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!file.name.toLowerCase().endsWith(".pdf")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only PDF files are accepted" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce 5-document limit — delete oldest if at cap
|
||||
if (user.documents.length >= MAX_DOCUMENTS) {
|
||||
const oldest = user.documents[0];
|
||||
await prisma.pdfDocument.delete({ where: { id: oldest.id } });
|
||||
// Note: could also delete files from disk here
|
||||
}
|
||||
|
||||
const fileId = uuidv4();
|
||||
const uploadsDir = path.join(process.cwd(), "public", "uploads", user.id);
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
|
||||
const fileName = `${fileId}.pdf`;
|
||||
const filePath = path.join(uploadsDir, fileName);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const publicPath = `/uploads/${user.id}/${fileName}`;
|
||||
|
||||
// Detect page count from the uploaded PDF
|
||||
let pageCount = 0;
|
||||
try {
|
||||
const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buffer) });
|
||||
const pdfDoc = await loadingTask.promise;
|
||||
pageCount = pdfDoc.numPages;
|
||||
} catch {
|
||||
// fallback: if pdfjs fails, just store 0
|
||||
}
|
||||
|
||||
const doc = await prisma.pdfDocument.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
originalName: file.name,
|
||||
filePath: publicPath,
|
||||
fileSize: file.size,
|
||||
pageCount,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ document: doc }, { status: 201 });
|
||||
}
|
||||
Reference in new issue
Block a user