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,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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user