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
1 change: 1 addition & 0 deletions apps/web-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
114 changes: 108 additions & 6 deletions apps/web-ui/src/app/components/FileUpload.tsx
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 +95 to +99

Copy link
Copy Markdown

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:

#!/bin/bash
set -euo pipefail

# Inspect the target file and nearby context.
FILE="apps/web-ui/src/app/components/FileUpload.tsx"
wc -l "$FILE"
sed -n '1,180p' "$FILE"

# Find any usage of PDF.js text items and line-break handling in the repo.
rg -n "hasEOL|TextItem|getTextContent|pdfjs" apps/web-ui/src/app -S

Repository: TechImmigrants/cv-builder

Length of output: 7535


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the pdfjs-dist version and any local type definitions if present.
rg -n '"pdfjs-dist"|pdfjs-dist' package.json apps/web-ui/package.json pnpm-lock.yaml yarn.lock package-lock.json -S || true

Repository: TechImmigrants/cv-builder

Length of output: 449


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Pull authoritative docs for TextItem.hasEOL semantics.
python3 - <<'PY'
import sys, urllib.request
urls = [
    "https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib.html",
    "https://github.com/mozilla/pdf.js/blob/master/src/display/api.js",
]
for url in urls:
    try:
        with urllib.request.urlopen(url, timeout=20) as r:
            data = r.read(2000).decode("utf-8", "ignore")
        print(f"URL: {url}\n{data[:1200]}\n---")
    except Exception as e:
        print(f"URL: {url}\nERROR: {e}\n---")
PY

Repository: TechImmigrants/cv-builder

Length of output: 592


🌐 Web query:

PDF.js TextItem hasEOL getTextContent line breaks documentation

💡 Result:

In PDF.js, the hasEOL property is a boolean field within the TextItem object that indicates whether a specific text content item is followed by a line break [1][2][3]. This property was formally added to the TextItem API 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]. While hasEOL provides 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. Using transform matrix data: The transform property of a TextItem contains 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 with disableCombineTextItems: 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, setting disableCombineTextItems: true in the getTextContent parameters 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 the hasEOL property [5][6][10].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect how FileUpload content is consumed to judge impact.
rg -n "onContentLoaded|FileUpload" apps/web-ui/src -S

# If there's a parent component, print its relevant section.
PARENT="$(rg -l "onContentLoaded" apps/web-ui/src | head -n 1)"
echo "PARENT=$PARENT"
[ -n "${PARENT:-}" ] && sed -n '1,220p' "$PARENT"

Repository: TechImmigrants/cv-builder

Length of output: 3718


Preserve PDF line breaks in FileUpload.tsx:95-99. Joining every text item with " " drops hasEOL line breaks, flattening headings, lists, and section boundaries before evaluation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-ui/src/app/components/FileUpload.tsx` around lines 95 - 99, Update
the text extraction logic in FileUpload to preserve PDF line breaks indicated by
each item's hasEOL metadata instead of joining all items with spaces. Keep
spaces between items on the same line, insert newline separators after items
marked hasEOL, and retain the existing empty-string handling for items without
text.

}
)
);
Comment on lines +90 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound concurrent page extraction.

Promise.all starts every page extraction at once. A page-heavy PDF can create excessive worker activity and memory pressure despite the 50 MB file limit. Process pages sequentially or use a small concurrency limit.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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(" ")
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-ui/src/app/components/FileUpload.tsx` around lines 90 - 102, Update
the page extraction logic in FileUpload’s Promise.all block to bound concurrency
instead of starting extraction for every page simultaneously. Process pages
sequentially or through a small worker/concurrency limit, while preserving page
order and the existing text extraction and joining behavior.

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) {
Expand Down Expand Up @@ -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`}
Expand All @@ -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)}
/>
Expand Down
129 changes: 129 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading