import { createHash } from 'node:crypto' import { copyFile, readFile, writeFile } from 'node:fs/promises' import { basename, dirname, resolve } from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' import pdfParse from 'pdf-parse/lib/pdf-parse.js' const here = dirname(fileURLToPath(import.meta.url)) const outputDir = process.env.PDF_STUDY_OUTPUT_DIR ? resolve(process.env.PDF_STUDY_OUTPUT_DIR) : resolve(here, '../../public/research/pdf-extraction') const cases = [ { id: 'singleColumn', file: 'synthetic-single-column.pdf', layout: 'single-column text layer', expected: [ 'single-contact', 'SUMMARY', 'single-summary', 'EXPERIENCE', 'single-role', 'single-impact', 'EDUCATION', 'single-school', 'SKILLS', 'single-skills', ], }, { id: 'twoColumn', file: 'synthetic-two-column.pdf', layout: 'two-column text layer', expected: [ 'LEFT-SUMMARY', 'left-focus', 'LEFT-EXPERIENCE', 'left-role', 'left-result', 'RIGHT-SKILLS', 'right-skill SQL', 'right-skill Forecasting', 'RIGHT-EDUCATION', 'right-school', ], }, { id: 'korean', file: 'synthetic-korean.pdf', layout: 'Korean native text layer', expected: [ '한글-이름', '한글-연락처', '경력', '한글-직무', '한글-성과', '기술', '한글-기술', ], }, { id: 'mixedLanguage', file: 'synthetic-mixed-language.pdf', layout: 'Korean and English native text layer', expected: [ '혼합-이름', 'mixed-contact', 'EXPERIENCE 경력', 'mixed-role', '혼합-성과', 'SKILLS 기술', 'mixed-skills', ], }, { id: 'tableLinks', file: 'synthetic-table-links.pdf', layout: 'table with visible URL and labeled link', expected: [ 'table-header-year', 'table-header-role', 'table-year-2024', 'table-role Research lead', 'table-year-2022', 'table-role Analyst', 'link-visible', 'https://example.invalid/portfolio', 'link-label', ], expectedLinks: [ 'https://example.invalid/portfolio', 'https://example.invalid/case-study', ], }, { id: 'imageOnly', file: 'synthetic-scanned-image.pdf', layout: 'image-only scan negative control', expected: [ 'scan-contact', 'EXPERIENCE', 'scan-role', 'scan-result', 'SKILLS', 'scan-skills', ], }, ] const results = [] for (const testCase of cases) { const path = resolve(outputDir, testCase.file) const bytes = await readFile(path) const parsed = await pdfParse({ data: new Uint8Array(bytes) }) const text = parsed.text.replace(/\r/g, '').trim() const found = testCase.expected.filter((token) => text.includes(token)) const observedOrder = testCase.expected .map((token) => ({ token, index: text.indexOf(token) })) .filter(({ index }) => index >= 0) .sort((a, b) => a.index - b.index) .map(({ token }) => token) const expectedFoundOrder = testCase.expected.filter((token) => found.includes(token) ) results.push({ id: testCase.id, file: testCase.file, layout: testCase.layout, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), pages: parsed.numpages, extractedCharacters: text.length, expectedAnchorCount: testCase.expected.length, foundAnchorCount: found.length, missingAnchors: testCase.expected.filter((token) => !found.includes(token)), expectedAnchorOrder: testCase.expected, observedAnchorOrder: observedOrder, anchorOrderMatches: found.length === testCase.expected.length ? JSON.stringify(observedOrder) === JSON.stringify(expectedFoundOrder) : null, expectedLinkTargets: testCase.expectedLinks ?? [], extractedText: text, }) } const report = { schemaVersion: 2, generatedAt: new Date().toISOString(), method: { parser: 'pdf-parse 1.1.1 default text extraction', fixtureTools: 'ReportLab 4.4.9 and Pillow 12.3.0', scope: 'Native PDF text layer only; no OCR, ATS test, or resume field inference.', linkScope: 'Visible link text is measured. Link annotation destinations are present in the fixture but are not evaluated by pdf-parse.', productionDifference: 'This controlled parser test does not exercise or make claims about the refresh.cv production importer.', baseline: '/research/pdf-extraction/native-text-results-v1.json', reproductionFiles: [ '/research/pdf-extraction/generate-fixtures.py.txt', '/research/pdf-extraction/measure-native-text.mjs.txt', ], }, results, } await writeFile( resolve(outputDir, 'native-text-results.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8' ) await copyFile( fileURLToPath(import.meta.url), resolve(outputDir, 'measure-native-text.mjs.txt') ) for (const result of results) console.log( `${basename(result.file)}: ${result.foundAnchorCount}/${result.expectedAnchorCount} anchors, ${result.extractedCharacters} chars, order=${result.anchorOrderMatches}` )