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..fa0c708 100644 --- a/apps/web-ui/src/app/components/FileUpload.tsx +++ b/apps/web-ui/src/app/components/FileUpload.tsx @@ -1,7 +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; @@ -9,32 +21,122 @@ interface FileUploadProps { export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { const inputRef = useRef(null); + 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(""); const extension = file.name.split(".").pop()?.toLowerCase() || ""; if (extension === "txt" || extension === "md") { try { const text = await file.text(); + if (isStale(requestId)) return; onContentLoaded(text); setStatus(`Loaded ${file.name}`); } catch (error) { console.error("Failed to read file:", error); + if (isStale(requestId)) return; setStatus("Failed to read file. Please try again."); } return; } 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; + } + + // 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 = PDF_WORKER_URL; + + const arrayBuffer = await file.arrayBuffer(); + 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(" "); + } + ) + ); + 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 (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; } - 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 +168,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)} /> 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: {}