// resume.ts extracts plain TEXT from a PDF or DOCX file ENTIRELY IN THE BROWSER. The file bytes never // leave the tab and are never written to storage; only the extracted text is later POSTed to our own // authed API for structured extraction and scoring. This keeps resume bytes (potential CUI) off the // wire and needs no API key, so nothing here can leak a credential into the bundle (CLAUDE.md rule 3). import * as pdfjsLib from 'pdfjs-dist' import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url' import mammoth from 'mammoth' // Resolve the pdf.js worker from the SAME installed package via Vite's ?url, so the worker version // always matches the API version (a version mismatch is the most common pdf.js failure) and the worker // is self hosted as its own chunk, never a CDN. pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl export type ExtractedSource = 'pdf' | 'docx' export interface Extracted { text: string source: ExtractedSource // lowConfidence is set when almost no selectable text was found, the signature of a scanned or image // only document. The caller then routes the user to the paste text fallback rather than scoring noise. lowConfidence: boolean } // ResumeParseError carries a user facing message; the modal shows it and offers the paste fallback. export class ResumeParseError extends Error {} const PDF_MAX = 15 * 1024 * 1024 const DOCX_MAX = 10 * 1024 * 1024 const LOW_CONFIDENCE_CHARS = 80 // sniff classifies the file by its magic bytes, not its extension: PDF starts with %PDF, a DOCX is a // zip and starts with PK\x03\x04. A renamed file is rejected rather than mis parsed. function sniff(bytes: Uint8Array): ExtractedSource | null { if (bytes.length >= 4 && bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) { return 'pdf' } if (bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04) { return 'docx' } return null } // extractResumeText reads the file in memory, validates it, and returns the extracted text. It throws a // ResumeParseError with a friendly message on anything it cannot handle. export async function extractResumeText(file: File): Promise { if (file.size === 0) { throw new ResumeParseError('That file is empty.') } const buf = await file.arrayBuffer() const bytes = new Uint8Array(buf) const kind = sniff(bytes) if (kind === null) { throw new ResumeParseError('Unsupported file. Upload a PDF or DOCX resume, or paste the text.') } if (kind === 'pdf') { if (file.size > PDF_MAX) { throw new ResumeParseError('That PDF is too large. The limit is 15 MB.') } return extractPdf(bytes) } if (file.size > DOCX_MAX) { throw new ResumeParseError('That DOCX is too large. The limit is 10 MB.') } return extractDocx(buf) } async function extractPdf(bytes: Uint8Array): Promise { const loadingTask = pdfjsLib.getDocument({ data: bytes }) let doc try { doc = await loadingTask.promise } catch { throw new ResumeParseError('Could not read that PDF. It may be encrypted or corrupt. Paste the text instead.') } try { const parts: string[] = [] for (let i = 1; i <= doc.numPages; i++) { const page = await doc.getPage(i) const content = await page.getTextContent() for (const item of content.items) { if ('str' in item) { parts.push(item.str) if (item.hasEOL) parts.push('\n') } } parts.push('\n') page.cleanup() } const text = normalize(parts.join(' ')) return { text, source: 'pdf', lowConfidence: nonWhitespace(text) < LOW_CONFIDENCE_CHARS } } finally { // Releases the document and tears down the worker resources for this parse. await loadingTask.destroy() } } async function extractDocx(buf: ArrayBuffer): Promise { let value: string try { const result = await mammoth.extractRawText({ arrayBuffer: buf }) value = result.value } catch { throw new ResumeParseError('Could not read that DOCX. Paste the text instead.') } const text = normalize(value) return { text, source: 'docx', lowConfidence: nonWhitespace(text) < LOW_CONFIDENCE_CHARS } } // normalize collapses runs of spaces and tabs but keeps newlines, which the name detector relies on. function normalize(s: string): string { return s.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim() } function nonWhitespace(s: string): number { let n = 0 for (const c of s) { if (!/\s/.test(c)) n++ } return n }