-
Notifications
You must be signed in to change notification settings - Fork 14
Add PDF upload support with size validation and tex… #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,40 +1,142 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const inputRef = useRef<HTMLInputElement>(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(" "); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+90
to
+102
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Bound concurrent page extraction.
Proposed bounded extraction- 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(" ");
- }
- )
- );
+ const pageTexts: string[] = [];
+ for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber++) {
+ const page = await doc.getPage(pageNumber);
+ const content = await page.getTextContent();
+ pageTexts.push(
+ content.items
+ .map((item) =>
+ "str" in item && item.str != null ? String(item.str) : ""
+ )
+ .join(" ")
+ );
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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) { | |||||||||||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| <p className="font-medium">Drag & Drop a file here</p> | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| <p className="mt-2 text-sm text-zinc-500">Supports .txt and .md</p> | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| <p className="mt-2 text-sm text-zinc-500">Supports .txt, .md, and .pdf</p> | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| <p | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| id={`${inputId}-message`} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -82,7 +184,7 @@ export function FileUpload({ inputId, onContentLoaded }: FileUploadProps) { | |||||||||||||||||||||||||||||||||||||||||||||||||||
| ref={inputRef} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| id={inputId} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| type="file" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| accept=".txt,.md" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| accept=".txt,.md,.pdf" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| className="hidden" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| onChange={(e) => handleFiles(e.target.files)} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: TechImmigrants/cv-builder
Length of output: 7535
🏁 Script executed:
Repository: TechImmigrants/cv-builder
Length of output: 449
🏁 Script executed:
Repository: TechImmigrants/cv-builder
Length of output: 592
🌐 Web query:
PDF.js TextItem hasEOL getTextContent line breaks documentation💡 Result:
In PDF.js, the
hasEOLproperty is a boolean field within theTextItemobject that indicates whether a specific text content item is followed by a line break [1][2][3]. This property was formally added to theTextItemAPI documentation to assist with identifying line endings, which are not explicitly defined in the underlying PDF specification [4]. Because PDF files represent text as glyphs positioned at absolute coordinates rather than as structured lines or paragraphs, there is no inherent concept of "lines" or "line breaks" in the file format [5][6][7]. WhilehasEOLprovides a signal for line breaks, developers often rely on additional techniques when extracting text because the accuracy of these markers can vary depending on how the PDF was generated and how PDF.js processes it [8][5][9]. Common strategies include: 1. Usingtransformmatrix data: Thetransformproperty of aTextItemcontains a matrix where the fifth element (index 5) typically represents the vertical (Y) coordinate [6]. By comparing the Y-coordinate of consecutive items, you can programmatically detect line changes [6][7][10]. 2. Combining withdisableCombineTextItems: By default, PDF.js attempts to combine text items into larger strings to improve text selection [9][11]. If you need more granular control over text positioning or line detection, settingdisableCombineTextItems: truein thegetTextContentparameters can prevent the engine from merging these items, giving you access to the raw, uncombined text chunks [3][9][11]. Because of the variable nature of PDF structures, there is no single guaranteed method to extract "raw lines" that works perfectly for every document; detection logic often involves heuristics based on coordinate proximity, font metadata, and thehasEOLproperty [5][6][10].Citations:
hasEOLto theTextItemtypedef in the API (PR 13257 follow-up) mozilla/pdf.js#13489🏁 Script executed:
Repository: TechImmigrants/cv-builder
Length of output: 3718
Preserve PDF line breaks in
FileUpload.tsx:95-99. Joining every text item with" "dropshasEOLline breaks, flattening headings, lists, and section boundaries before evaluation.🤖 Prompt for AI Agents