From 09533ee368cf1918ab68c3eb0a345597cad35380 Mon Sep 17 00:00:00 2001 From: "hakimi.a" Date: Sat, 18 Jul 2026 16:45:25 +0330 Subject: [PATCH 1/3] feat(FileUpload): add PDF upload support with size validation and text extraction --- apps/web-ui/package.json | 1 + apps/web-ui/src/app/components/FileUpload.tsx | 80 ++++++++++- pnpm-lock.yaml | 129 ++++++++++++++++++ 3 files changed, 204 insertions(+), 6 deletions(-) diff --git a/apps/web-ui/package.json b/apps/web-ui/package.json index 3a699a1..f87008b 100644 --- a/apps/web-ui/package.json +++ b/apps/web-ui/package.json @@ -15,6 +15,7 @@ "@cv-builder/core": "workspace:*", "next": "16.2.7", "next-themes": "^0.4.6", + "pdfjs-dist": "^6.1.200", "react": "19.2.6", "react-dom": "19.2.6" }, diff --git a/apps/web-ui/src/app/components/FileUpload.tsx b/apps/web-ui/src/app/components/FileUpload.tsx index dc0b080..f957971 100644 --- a/apps/web-ui/src/app/components/FileUpload.tsx +++ b/apps/web-ui/src/app/components/FileUpload.tsx @@ -2,6 +2,9 @@ import { type DragEvent, useRef, useState } from "react"; +const MAX_PDF_SIZE = 50 * 1024 * 1024; // 50 MB +const PDF_TIMEOUT_MS = 60_000; // 60 seconds + interface FileUploadProps { inputId: string; onContentLoaded: (content: string) => void; @@ -9,15 +12,18 @@ interface FileUploadProps { export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { const inputRef = useRef(null); + const requestIdRef = useRef(0); const [status, setStatus] = useState(""); async function processFile(file: File) { + const requestId = ++requestIdRef.current; setStatus(""); const extension = file.name.split(".").pop()?.toLowerCase() || ""; if (extension === "txt" || extension === "md") { try { const text = await file.text(); + if (requestId !== requestIdRef.current) return; onContentLoaded(text); setStatus(`Loaded ${file.name}`); } catch (error) { @@ -28,13 +34,62 @@ export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { } if (extension === "pdf") { - setStatus( - "PDF text extraction isn't supported yet. Please paste the content or upload a .txt or .md file." - ); + if (file.size > MAX_PDF_SIZE) { + setStatus( + `File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Maximum size is ${MAX_PDF_SIZE / (1024 * 1024)} MB.` + ); + return; + } + + try { + setStatus("Extracting text from PDF..."); + const { getDocument, GlobalWorkerOptions } = await import( + "pdfjs-dist" + ); + GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.min.mjs", + import.meta.url + ).toString(); + + const arrayBuffer = await file.arrayBuffer(); + if (requestId !== requestIdRef.current) return; + + const loadingTask = getDocument({ data: arrayBuffer }); + const pdf = await withTimeout( + loadingTask.promise, + PDF_TIMEOUT_MS, + "PDF parsing timed out" + ); + const pages: string[] = []; + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const content = await withTimeout( + page.getTextContent(), + PDF_TIMEOUT_MS, + `Page ${i} extraction timed out` + ); + const pageText = content.items + .map((item) => + "str" in item && item.str != null ? String(item.str) : "" + ) + .join(" "); + pages.push(pageText); + } + pdf.cleanup(); + + if (requestId !== requestIdRef.current) return; + onContentLoaded(pages.join("\n\n")); + setStatus(`Loaded ${file.name} (${pdf.numPages} pages)`); + } catch (error) { + console.error("Failed to extract PDF text:", error); + setStatus( + "Failed to extract text from PDF. Please paste the content or upload a .txt or .md file." + ); + } return; } - setStatus("Unsupported file type. Please use .txt or .md."); + setStatus("Unsupported file type. Please use .txt, .md, or .pdf."); } async function handleFiles(files: FileList | null) { @@ -66,7 +121,7 @@ export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { >

Drag & Drop a file here

-

Supports .txt and .md

+

Supports .txt, .md, and .pdf

handleFiles(e.target.files)} /> ); } + +function withTimeout( + promise: Promise, + ms: number, + message: string +): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(message)), ms) + ), + ]); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00d8fc7..42f6e11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + pdfjs-dist: + specifier: ^6.1.200 + version: 6.1.200 react: specifier: 19.2.6 version: 19.2.6 @@ -848,6 +851,76 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@napi-rs/canvas-android-arm64@1.0.2': + resolution: {integrity: sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@1.0.2': + resolution: {integrity: sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@1.0.2': + resolution: {integrity: sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': + resolution: {integrity: sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@1.0.2': + resolution: {integrity: sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==} + engines: {node: '>= 10'} + '@next/env@16.2.7': resolution: {integrity: sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==} @@ -1501,6 +1574,10 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pdfjs-dist@6.1.200: + resolution: {integrity: sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==} + engines: {node: '>=22.13.0 || >=24'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2225,6 +2302,54 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/canvas-android-arm64@1.0.2': + optional: true + + '@napi-rs/canvas-darwin-arm64@1.0.2': + optional: true + + '@napi-rs/canvas-darwin-x64@1.0.2': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@1.0.2': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@1.0.2': + optional: true + + '@napi-rs/canvas-linux-x64-musl@1.0.2': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@1.0.2': + optional: true + + '@napi-rs/canvas@1.0.2': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.2 + '@napi-rs/canvas-darwin-arm64': 1.0.2 + '@napi-rs/canvas-darwin-x64': 1.0.2 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.2 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.2 + '@napi-rs/canvas-linux-arm64-musl': 1.0.2 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-musl': 1.0.2 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.2 + '@napi-rs/canvas-win32-x64-msvc': 1.0.2 + optional: true + '@next/env@16.2.7': {} '@next/swc-darwin-arm64@16.2.7': @@ -2776,6 +2901,10 @@ snapshots: pathval@2.0.1: {} + pdfjs-dist@6.1.200: + optionalDependencies: + '@napi-rs/canvas': 1.0.2 + picocolors@1.1.1: {} picomatch@4.0.4: {} From c8491068024382218bb3c7f6520d3a69660a9f52 Mon Sep 17 00:00:00 2001 From: "hakimi.a" Date: Sat, 18 Jul 2026 17:15:02 +0330 Subject: [PATCH 2/3] fix(FileUpload): improve PDF text extraction error handling and timeout messages --- apps/web-ui/src/app/components/FileUpload.tsx | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/apps/web-ui/src/app/components/FileUpload.tsx b/apps/web-ui/src/app/components/FileUpload.tsx index f957971..a837d31 100644 --- a/apps/web-ui/src/app/components/FileUpload.tsx +++ b/apps/web-ui/src/app/components/FileUpload.tsx @@ -28,6 +28,7 @@ export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { setStatus(`Loaded ${file.name}`); } catch (error) { console.error("Failed to read file:", error); + if (requestId !== requestIdRef.current) return; setStatus("Failed to read file. Please try again."); } return; @@ -54,34 +55,40 @@ export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { const arrayBuffer = await file.arrayBuffer(); if (requestId !== requestIdRef.current) return; - const loadingTask = getDocument({ data: arrayBuffer }); - const pdf = await withTimeout( - loadingTask.promise, + await withTimeout( + (async () => { + const loadingTask = getDocument({ data: arrayBuffer }); + try { + const pdf = await loadingTask.promise; + try { + const pages: string[] = []; + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const content = await page.getTextContent(); + const pageText = content.items + .map((item) => + "str" in item && item.str != null ? String(item.str) : "" + ) + .join(" "); + pages.push(pageText); + } + + if (requestId !== requestIdRef.current) return; + onContentLoaded(pages.join("\n\n")); + setStatus(`Loaded ${file.name} (${pdf.numPages} pages)`); + } finally { + pdf.cleanup(); + } + } finally { + loadingTask.destroy(); + } + })(), PDF_TIMEOUT_MS, - "PDF parsing timed out" + "PDF extraction timed out" ); - const pages: string[] = []; - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const content = await withTimeout( - page.getTextContent(), - PDF_TIMEOUT_MS, - `Page ${i} extraction timed out` - ); - const pageText = content.items - .map((item) => - "str" in item && item.str != null ? String(item.str) : "" - ) - .join(" "); - pages.push(pageText); - } - pdf.cleanup(); - - if (requestId !== requestIdRef.current) return; - onContentLoaded(pages.join("\n\n")); - setStatus(`Loaded ${file.name} (${pdf.numPages} pages)`); } catch (error) { console.error("Failed to extract PDF text:", error); + if (requestId !== requestIdRef.current) return; setStatus( "Failed to extract text from PDF. Please paste the content or upload a .txt or .md file." ); From 9c0ed2bc63b76877f461296e99893b856303fc99 Mon Sep 17 00:00:00 2001 From: Sahar Pakseresht Date: Sat, 18 Jul 2026 17:29:38 +0300 Subject: [PATCH 3/3] fix(FileUpload): release pdfjs resources on timeout and detect empty PDFs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation used Promise.race for the 60s timeout, which meant that on expiry the underlying extraction kept running to completion in the background — pdf.cleanup() and loadingTask.destroy() were never reached, and the worker kept processing pages nobody would ever read. This commit replaces the Promise.race pattern with an explicit timeout flag and a setTimeout handle. On expiry we call loadingTask.destroy() to release the worker; on success we run pdf.cleanup() and loadingTask.destroy() in a finally block. Both cleanup calls are best-effort (errors are swallowed) so they cannot mask the original extraction error. Also addressed while here: - Resolve the pdfjs worker URL once at module scope (PDF_WORKER_URL) instead of constructing it on every PDF drop. - Fetch pages concurrently via Promise.all. pdfjs's worker handles pages in parallel and caches them, so this is significantly faster than the previous sequential loop on multi-page resumes. - Surface scanned-image PDFs: pdfjs returns an empty string silently when a PDF has no text layer, so we now detect that case and tell the user instead of accepting a blank resume for evaluation. - Consolidate the stale-request check into an isStale(requestId) helper used by both the text and pdf branches. --- apps/web-ui/src/app/components/FileUpload.tsx | 141 +++++++++++------- 1 file changed, 84 insertions(+), 57 deletions(-) diff --git a/apps/web-ui/src/app/components/FileUpload.tsx b/apps/web-ui/src/app/components/FileUpload.tsx index a837d31..fa0c708 100644 --- a/apps/web-ui/src/app/components/FileUpload.tsx +++ b/apps/web-ui/src/app/components/FileUpload.tsx @@ -1,10 +1,19 @@ "use client"; +import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist"; import { type DragEvent, useRef, useState } from "react"; const MAX_PDF_SIZE = 50 * 1024 * 1024; // 50 MB const PDF_TIMEOUT_MS = 60_000; // 60 seconds +// Resolved once at module load so the pdfjs worker URL is not reconstructed +// on every PDF drop. `import.meta.url` is evaluated synchronously when the +// module graph is built. +const PDF_WORKER_URL = new URL( + "pdfjs-dist/build/pdf.worker.min.mjs", + import.meta.url +).toString(); + interface FileUploadProps { inputId: string; onContentLoaded: (content: string) => void; @@ -15,6 +24,9 @@ export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { const requestIdRef = useRef(0); const [status, setStatus] = useState(""); + // True if a newer upload has started and we should stop touching state. + const isStale = (requestId: number) => requestId !== requestIdRef.current; + async function processFile(file: File) { const requestId = ++requestIdRef.current; setStatus(""); @@ -23,12 +35,12 @@ export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { if (extension === "txt" || extension === "md") { try { const text = await file.text(); - if (requestId !== requestIdRef.current) return; + if (isStale(requestId)) return; onContentLoaded(text); setStatus(`Loaded ${file.name}`); } catch (error) { console.error("Failed to read file:", error); - if (requestId !== requestIdRef.current) return; + if (isStale(requestId)) return; setStatus("Failed to read file. Please try again."); } return; @@ -42,56 +54,84 @@ export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { return; } + // A timeout flag (rather than `Promise.race`) so we can also release + // pdfjs resources on expiry. With `Promise.race` the underlying + // extraction keeps running to completion, leaving the worker to process + // pages nobody will ever read. + let timedOut = false; + let loadingTask: PDFDocumentLoadingTask | undefined; + let pdfDoc: PDFDocumentProxy | undefined; + const timeoutHandle = setTimeout(() => { + timedOut = true; + // destroy() before the doc loads cancels the load; after, it + // releases the worker reference. Swallow rejection — cleanup is + // best-effort. + loadingTask?.destroy().catch(() => {}); + }, PDF_TIMEOUT_MS); + try { setStatus("Extracting text from PDF..."); - const { getDocument, GlobalWorkerOptions } = await import( - "pdfjs-dist" - ); - GlobalWorkerOptions.workerSrc = new URL( - "pdfjs-dist/build/pdf.worker.min.mjs", - import.meta.url - ).toString(); + const { getDocument, GlobalWorkerOptions } = await import("pdfjs-dist"); + GlobalWorkerOptions.workerSrc = PDF_WORKER_URL; const arrayBuffer = await file.arrayBuffer(); - if (requestId !== requestIdRef.current) return; - - await withTimeout( - (async () => { - const loadingTask = getDocument({ data: arrayBuffer }); - try { - const pdf = await loadingTask.promise; - try { - const pages: string[] = []; - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const content = await page.getTextContent(); - const pageText = content.items - .map((item) => - "str" in item && item.str != null ? String(item.str) : "" - ) - .join(" "); - pages.push(pageText); - } - - if (requestId !== requestIdRef.current) return; - onContentLoaded(pages.join("\n\n")); - setStatus(`Loaded ${file.name} (${pdf.numPages} pages)`); - } finally { - pdf.cleanup(); - } - } finally { - loadingTask.destroy(); + if (isStale(requestId) || timedOut) return; + + loadingTask = getDocument({ data: arrayBuffer }); + pdfDoc = await loadingTask.promise; + if (isStale(requestId) || timedOut) return; + + // Capture in a local so the closure doesn't need a `!` assertion. + const doc = pdfDoc; + + // Fetch pages concurrently — pdfjs's worker processes them in + // parallel and pages are cached, so this is much faster than a + // sequential loop for multi-page resumes. + const pageTexts = await Promise.all( + Array.from({ length: doc.numPages }, (_, i) => i + 1).map( + async (pageNumber) => { + const page = await doc.getPage(pageNumber); + const content = await page.getTextContent(); + return content.items + .map((item) => + "str" in item && item.str != null ? String(item.str) : "" + ) + .join(" "); } - })(), - PDF_TIMEOUT_MS, - "PDF extraction timed out" + ) ); + if (isStale(requestId) || timedOut) return; + + const fullText = pageTexts.join("\n\n"); + if (!fullText.trim()) { + // pdfjs returns empty strings silently for scanned-image PDFs. + // Surface it instead of accepting a blank resume for evaluation. + setStatus( + "PDF loaded, but no text was extracted. It may be a scanned image — try OCR or paste the text." + ); + return; + } + + onContentLoaded(fullText); + setStatus(`Loaded ${file.name} (${pdfDoc.numPages} pages)`); } catch (error) { console.error("Failed to extract PDF text:", error); - if (requestId !== requestIdRef.current) return; - setStatus( - "Failed to extract text from PDF. Please paste the content or upload a .txt or .md file." - ); + if (isStale(requestId)) return; + if (timedOut) { + setStatus( + "PDF extraction took too long. The file may be complex or malformed — please try a smaller one." + ); + } else { + setStatus( + "Failed to extract text from PDF. Please paste the content or upload a .txt or .md file." + ); + } + } finally { + clearTimeout(timeoutHandle); + // Best-effort cleanup. Errors are swallowed because we may already be + // in error handling and a second throw would mask the original. + await pdfDoc?.cleanup().catch(() => {}); + await loadingTask?.destroy().catch(() => {}); } return; } @@ -151,16 +191,3 @@ export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { ); } - -function withTimeout( - promise: Promise, - ms: number, - message: string -): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error(message)), ms) - ), - ]); -}