From 397f6dd802bc2c71bc1335dd7301856a6ae9c7fb Mon Sep 17 00:00:00 2001 From: Yogesh Date: Tue, 16 Jun 2026 21:37:32 +0530 Subject: [PATCH 1/3] feat: pdf.js compression on upload --- src/app/api/upload/route.ts | 35 +++++++++++++++++++++++++++-- src/lib/storage/pdf.ts | 45 +++++++++++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 2fad98e7..337a4942 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,11 +1,14 @@ import { NextResponse } from "next/server"; import { connectToDatabase } from "@/lib/database/mongoose"; import { PaperAdmin } from "@/db/papers"; -import { createPDFfromImages } from "@/lib/storage/pdf"; +import { createPDFfromImages, compressPDF } from "@/lib/storage/pdf"; import { uploadPDF, uploadThumbnail } from "@/lib/storage/gcp"; export const runtime = "nodejs"; +const MAX_COMPRESSED_PDF_SIZE = 5 * 1024 * 1024; // 5MB compressed +const COMPRESS_THRESHOLD = 5 * 1024 * 1024; // 5MB + export async function POST(req: Request) { try { await connectToDatabase(); @@ -29,9 +32,37 @@ export async function POST(req: Request) { { status: 400 }, ); } - pdfBytes = new Uint8Array(await files[0].arrayBuffer()); + + const rawPdfBytes = new Uint8Array(await files[0].arrayBuffer()); + if (rawPdfBytes.length > COMPRESS_THRESHOLD) { + const compressedPdfBytes = await compressPDF(rawPdfBytes); + pdfBytes = compressedPdfBytes.length <= rawPdfBytes.length + ? compressedPdfBytes + : rawPdfBytes; + + if (pdfBytes.length > MAX_COMPRESSED_PDF_SIZE) { + return NextResponse.json( + { + error: + "PDF is too large after compression. The compressed file must be under 5MB.", + }, + { status: 413 }, + ); + } + } else { + pdfBytes = rawPdfBytes; + } } else { pdfBytes = await createPDFfromImages(files); + if (pdfBytes.length > MAX_COMPRESSED_PDF_SIZE) { + return NextResponse.json( + { + error: + "Generated PDF is too large after compression. Please upload fewer or smaller images.", + }, + { status: 413 }, + ); + } } const buffer = Buffer.from(pdfBytes); diff --git a/src/lib/storage/pdf.ts b/src/lib/storage/pdf.ts index 2904bbff..0f736a1a 100644 --- a/src/lib/storage/pdf.ts +++ b/src/lib/storage/pdf.ts @@ -1,20 +1,47 @@ import { PDFDocument } from "pdf-lib"; +import { createCanvas, loadImage } from "canvas"; + +const DEFAULT_PDF_SAVE_OPTIONS = { + useObjectStreams: true, + compress: true, +}; +const MAX_IMAGE_DIMENSION = 1600; +const JPEG_QUALITY = 0.72; + +async function normalizeImage(file: File) { + const rawBytes = Buffer.from(await file.arrayBuffer()); + const image = await loadImage(rawBytes); + + const originalWidth = image.width; + const originalHeight = image.height; + const scale = Math.min(1, MAX_IMAGE_DIMENSION / Math.max(originalWidth, originalHeight)); + const width = Math.max(1, Math.round(originalWidth * scale)); + const height = Math.max(1, Math.round(originalHeight * scale)); + + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0, width, height); + + return canvas.toBuffer("image/jpeg", { + quality: JPEG_QUALITY, + chromaSubsampling: true, + }); +} export async function createPDFfromImages(files: File[]): Promise { const pdfDoc = await PDFDocument.create(); for (const file of files) { - const imgBytes = Buffer.from(await file.arrayBuffer()); - let img; - if (file.type === "image/png") { - img = await pdfDoc.embedPng(imgBytes); - } else if (file.type === "image/jpeg" || file.type === "image/jpg") { - img = await pdfDoc.embedJpg(imgBytes); - } else continue; - + const normalizedBytes = await normalizeImage(file); + const img = await pdfDoc.embedJpg(normalizedBytes); const page = pdfDoc.addPage([img.width, img.height]); page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height }); } - return pdfDoc.save(); + return pdfDoc.save(DEFAULT_PDF_SAVE_OPTIONS); +} + +export async function compressPDF(pdfBytes: Uint8Array): Promise { + const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); + return pdfDoc.save(DEFAULT_PDF_SAVE_OPTIONS); } From ef9ee19a424218f03a49ba1725782a2d1bf72adf Mon Sep 17 00:00:00 2001 From: Yogesh Date: Tue, 16 Jun 2026 21:37:56 +0530 Subject: [PATCH 2/3] feat: increase images count for long question papers --- src/app/upload/page.tsx | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index 18533bb4..622bf100 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -73,7 +73,6 @@ export default function Page() { const fileCheckAndSelect = useCallback( (acceptedFiles: File[]) => { - const maxFileSize = 5 * 1024 * 1024; const allowedFileTypes = [ "application/pdf", "image/jpeg", @@ -131,28 +130,18 @@ export default function Page() { } const allFiles = [...files, ...acceptedFiles]; - if (allFiles.length > 5) { - toast.error("You can upload up to 5 files only", { id: toastId }); - return; - } - - const totalSize = allFiles.reduce( - (sum, f) => sum + f.size, - 0, - ); - if (totalSize > maxFileSize){ - toast.error("The total upload size exceeds 5MB.", { id: toastId }); + if (allFiles.length > 10) { + toast.error("You can upload up to 10 files only", { id: toastId }); return; } const invalidFiles = acceptedFiles.filter( - (file) => - file.size > maxFileSize || !allowedFileTypes.includes(file.type), + (file) => !allowedFileTypes.includes(file.type), ); if (invalidFiles.length > 0) { toast.error( - "Some files are invalid. Make sure the total size is below 5MB and files are of allowed types (PDF, JPEG, PNG, GIF).", + "Some files are invalid. Make sure they are PDFs, JPEGs, PNGs, or GIFs.", { id: toastId }, ); return; From 1f96df20b31fc15bb8b0d967ef7784292a81350a Mon Sep 17 00:00:00 2001 From: Yogesh Date: Tue, 16 Jun 2026 21:51:50 +0530 Subject: [PATCH 3/3] fix: consistent be response --- src/app/api/upload/route.ts | 18 ++++++------------ src/app/upload/page.tsx | 19 +++++++++++-------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index a5d60245..67d4fb50 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -35,12 +35,9 @@ export async function POST(req: Request) { : rawPdfBytes; if (pdfBytes.length > MAX_COMPRESSED_PDF_SIZE) { - return NextResponse.json( - { - error: - "PDF is too large after compression. The compressed file must be under 5MB.", - }, - { status: 413 }, + return failure( + "PDF is too large after compression. The compressed file must be under 5MB.", + 413, ); } } else { @@ -49,12 +46,9 @@ export async function POST(req: Request) { } else { pdfBytes = await createPDFfromImages(files); if (pdfBytes.length > MAX_COMPRESSED_PDF_SIZE) { - return NextResponse.json( - { - error: - "Generated PDF is too large after compression. Please upload fewer or smaller images.", - }, - { status: 413 }, + return failure( + "Generated PDF is too large after compression. Please upload fewer or smaller images.", + 413, ); } } diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index 2c6dd425..dbabed99 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -25,7 +25,7 @@ import { CSS } from "@dnd-kit/utilities"; import Dropzone from "react-dropzone"; import { Upload, XIcon } from "lucide-react"; import { GlobalWorkerOptions } from "pdfjs-dist"; -import type { ApiResponse } from "@/interface" +import type { ApiResponse } from "@/interface"; GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.8.69/pdf.worker.min.mjs"; @@ -268,7 +268,10 @@ export default function Page() { await toast.promise( async () => { try { - await axios.post>("/api/upload", formData); + await axios.post>( + "/api/upload", + formData, + ); return { message: "Papers uploaded successfully!" }; } catch (error) { if (error instanceof AxiosError) { @@ -436,12 +439,14 @@ export default function Page() { ); return ( -
+ className="mt-6 flex w-full flex-col items-center" + >
-
j +
+ j )}
- ) + ); }} - )}