Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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";
import { success, failure } from "@/lib/utils/response";

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();
Expand All @@ -23,9 +26,31 @@ export async function POST(req: Request) {
if (!files[0]) {
return failure("No PDF file provided.", 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 failure(
"PDF is too large after compression. The compressed file must be under 5MB.",
413,
);
}
} else {
pdfBytes = rawPdfBytes;
}
} else {
pdfBytes = await createPDFfromImages(files);
if (pdfBytes.length > MAX_COMPRESSED_PDF_SIZE) {
return failure(
"Generated PDF is too large after compression. Please upload fewer or smaller images.",
413,
);
}
}

const buffer = Buffer.from(pdfBytes);
Expand Down
38 changes: 15 additions & 23 deletions src/app/upload/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -79,7 +79,6 @@ export default function Page() {

const fileCheckAndSelect = useCallback(
(acceptedFiles: File[]) => {
const maxFileSize = 5 * 1024 * 1024;
const allowedFileTypes = [
"application/pdf",
"image/jpeg",
Expand Down Expand Up @@ -137,28 +136,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;
Expand Down Expand Up @@ -279,7 +268,10 @@ export default function Page() {
await toast.promise(
async () => {
try {
await axios.post<ApiResponse<uploadResponse>>("/api/upload", formData);
await axios.post<ApiResponse<uploadResponse>>(
"/api/upload",
formData,
);
return { message: "Papers uploaded successfully!" };
} catch (error) {
if (error instanceof AxiosError) {
Expand Down Expand Up @@ -447,12 +439,14 @@ export default function Page() {
);

return (
<section
<section
{...getRootProps()}
className="mt-6 flex w-full flex-col items-center">
className="mt-6 flex w-full flex-col items-center"
>
<input {...getInputProps()} />
<div className="flex w-max gap-4">
<div className="scrollbar-hide flex w-[80vw] max-w-4xl flex-col justify-between overflow-x-auto overflow-y-hidden rounded-[40px] border-[6px] border-[#A78BFA] bg-indigo-900/10 p-4 dark:border-indigo-900 sm:p-6 md:w-max md:p-8">j
<div className="scrollbar-hide flex w-[80vw] max-w-4xl flex-col justify-between overflow-x-auto overflow-y-hidden rounded-[40px] border-[6px] border-[#A78BFA] bg-indigo-900/10 p-4 dark:border-indigo-900 sm:p-6 md:w-max md:p-8">
j
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
Expand Down Expand Up @@ -528,10 +522,9 @@ export default function Page() {
</div>
)}
</section>
)
);
}}
</Dropzone>

)}

<Button
Expand All @@ -543,7 +536,6 @@ export default function Page() {
</Button>
</div>
</div>


{zoomIndex !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70">
Expand Down
45 changes: 36 additions & 9 deletions src/lib/storage/pdf.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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<Uint8Array> {
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
return pdfDoc.save(DEFAULT_PDF_SAVE_OPTIONS);
}
Loading