/** * Entry point for the ATS resume inspector. * * Everything runs from an in-memory buffer and nothing is written to disk or * cached. The container this runs in is read-only with only /tmp as tmpfs, so * "your resume is never stored" is a property of the deployment rather than a * promise we are asking anyone to take on trust. */ import { extractDocx, CorruptDocxError } from "./extractDocx.ts"; import { extractPdf, EncryptedPdfError, CorruptPdfError, MAX_PAGES } from "./extractPdf.ts"; import { buildStrategies } from "./readingOrder.ts"; import { detectSections } from "./sections.ts"; import { extractFields } from "./fields.ts"; import { runDiagnostics, sortFindings } from "./diagnostics.ts"; import type { AtsReport, DocFacts, SourceKind, TextItem } from "./types.ts"; /** Matches the file size limit Greenhouse documents for resume uploads. */ export const MAX_BYTES = 2.5 * 1024 * 1024; /** Mirrors Textkernel code 411, "parsing had to be stopped". */ export const PARSE_TIMEOUT_MS = 10_000; export { MAX_PAGES }; export class UnsupportedFormatError extends Error {} export class TooLargeError extends Error {} export class ParseTimeoutError extends Error {} /** Plain text has no layout, so synthesise one line per line of input. */ function itemsFromText(text: string): TextItem[] { return text .split(/\r?\n/) .map((line, index) => ({ page: 1, x: 0, y: 1000 - index * 14, width: line.length * 5, height: 11, fontName: "text", bold: false, hasEOL: true, text: line, })) .filter((item) => item.text.trim().length > 0); } function detectKind(fileName: string, data: Uint8Array): SourceKind { const lower = fileName.toLowerCase(); // Sniff magic bytes rather than trusting the extension. const isPdf = data[0] === 0x25 && data[1] === 0x50 && data[2] === 0x44 && data[3] === 0x46; const isZip = data[0] === 0x50 && data[1] === 0x4b; if (isPdf) return "pdf"; if (isZip && lower.endsWith(".docx")) return "docx"; if (lower.endsWith(".txt") || lower.endsWith(".md")) return "text"; if (lower.endsWith(".doc")) { throw new UnsupportedFormatError( "Legacy .doc files are not supported. Open it in Word or LibreOffice and save as .docx or PDF, then try again.", ); } if (isZip) return "docx"; throw new UnsupportedFormatError( "Unrecognised file type. Upload a PDF, a DOCX, or paste the text directly.", ); } async function withTimeout(work: Promise): Promise { let timer: ReturnType; const timeout = new Promise((_, reject) => { timer = setTimeout( () => reject(new ParseTimeoutError("Parsing took too long")), PARSE_TIMEOUT_MS, ); }); try { return await Promise.race([work, timeout]); } finally { clearTimeout(timer!); } } export interface AnalyseInput { data: Uint8Array; fileName: string; /** Set when the user pasted text rather than uploading a file. */ pastedText?: string; } export async function analyseResume(input: AnalyseInput): Promise { const started = Date.now(); if (input.data.byteLength > MAX_BYTES) { throw new TooLargeError( `File is larger than ${(MAX_BYTES / 1024 / 1024).toFixed(1)} MB.`, ); } return withTimeout(runAnalysis(input, started)); } async function runAnalysis( input: AnalyseInput, started: number, ): Promise { const { data, fileName } = input; const kind: SourceKind = input.pastedText ? "text" : detectKind(fileName, data); let items: TextItem[]; let facts: DocFacts; if (kind === "pdf") { let extraction; try { extraction = await extractPdf(data); } catch (err) { if (err instanceof EncryptedPdfError) { return emptyReport(kind, fileName, data.byteLength, started, true); } if (err instanceof CorruptPdfError) { throw new UnsupportedFormatError( "This PDF could not be opened. It may be damaged or incomplete.", ); } throw err; } items = extraction.items; facts = { kind, fileName, byteSize: data.byteLength, ...extraction.facts, }; } else if (kind === "docx") { let extraction; try { extraction = await extractDocx(data); } catch (err) { if (err instanceof CorruptDocxError) { throw new UnsupportedFormatError( "This DOCX could not be opened. It may be damaged, or saved in an older format.", ); } throw err; } items = extraction.items; facts = { kind, fileName, byteSize: data.byteLength, pageCount: 1, pagesParsed: 1, truncated: false, encrypted: false, textItemCount: items.length, fonts: [], nonEmbeddedFonts: [], imageCount: 0, vectorOpCount: 0, hadLigatures: false, pageSizes: [], docx: extraction.docx, }; } else { const text = input.pastedText ?? new TextDecoder().decode(data); items = itemsFromText(text); facts = { kind, fileName: input.pastedText ? "pasted text" : fileName, byteSize: data.byteLength, pageCount: 1, pagesParsed: 1, truncated: false, encrypted: false, textItemCount: items.length, fonts: [], nonEmbeddedFonts: [], imageCount: 0, vectorOpCount: 0, hadLigatures: false, pageSizes: [], }; } const strategies = buildStrategies(items); const visual = strategies.find((s) => s.id === "visual")!; const column = strategies.find((s) => s.id === "column")!; // Column-aware reading only matters if it actually changes the text. const columnsChangeReading = kind === "pdf" && visual.text !== column.text; // Analyse the best available reading, which is what a good parser would use. const primaryStrategy = columnsChangeReading ? column : visual; const { sections, unrecognised } = detectSections(primaryStrategy.lines); const { resume, scores } = extractFields(primaryStrategy.lines, sections); const findings = sortFindings( runDiagnostics({ facts, lines: primaryStrategy.lines, text: primaryStrategy.text, sections, unrecognised, resume, columnsChangeReading, }), ); return { facts, strategies, primary: primaryStrategy.id, sections, resume, findings, scores, columnsChangeReading, parseMs: Date.now() - started, }; } /** Used when the document cannot be opened at all but we still owe a report. */ function emptyReport( kind: SourceKind, fileName: string, byteSize: number, started: number, encrypted: boolean, ): AtsReport { const facts: DocFacts = { kind, fileName, byteSize, pageCount: 0, pagesParsed: 0, truncated: false, encrypted, textItemCount: 0, fonts: [], nonEmbeddedFonts: [], imageCount: 0, vectorOpCount: 0, hadLigatures: false, pageSizes: [], }; return { facts, strategies: [], primary: "visual", sections: [], resume: { basics: { name: null, email: null, phone: null, location: null, urls: [] }, work: [], education: [], skills: [], }, findings: sortFindings( runDiagnostics({ facts, lines: [], text: "", sections: [], unrecognised: [], resume: { basics: { name: null, email: null, phone: null, location: null, urls: [] }, work: [], education: [], skills: [], }, columnsChangeReading: false, }), ), scores: {}, columnsChangeReading: false, parseMs: Date.now() - started, }; }