// Actual glyph extents must stay inside the first print fragment, including accents. const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict'); const req = require('node:module').createRequire(path.resolve(__dirname, '../../job-tracker-ui/package.json')); const { chromium } = req('playwright'); const ts = req('typescript'); const source = fs.readFileSync(path.resolve(__dirname, '../../job-tracker-ui/src/cvPreview.ts'), 'utf8'); const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText; const copy = { exports: {} }; new Function('exports', 'module', compiled)(copy.exports, copy); async function main() { const [corpus, target, kind = 'cv'] = process.argv.slice(2); assert.ok(['cv', 'letter'].includes(kind)); assert.ok(corpus && target && !fs.existsSync(target), 'Use native corpus and a fresh output directory'); fs.mkdirSync(target, { recursive: true }); const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 794, height: 1123 }, offline: true, serviceWorkers: 'block' }); await page.route('**/*', route => route.abort()); const rows = []; try { const fixtures = JSON.parse(fs.readFileSync(path.join(corpus, 'measurements.json'), 'utf8')).fixtures.filter(row => row.fixture.startsWith('short-en-')); assert.equal(fixtures.length, 9); for (const fixture of fixtures) for (const photo of (kind === 'cv' ? [true, false] : [false])) for (const european of [false, true]) { assert.match(fixture.fixture, /^short-en-[a-z-]+$/); let html = fs.readFileSync(path.join(corpus, fixture.fixture, 'render.html'), 'utf8'); if (!photo && kind === 'cv') html = html.replace('', ''); if (european) html = kind === 'cv' ? html.replace(/(

)[^<]+(<\/h1>)/, '$1S01 Åse Émilie Müller Žák Łódź Sørensen$2') : html.replace(/(

)[^<]+(<\/h1>)/, '$1R01 Åse Émilie Müller Žák Łódź Sørensen$2'); await page.setContent(copy.exports.previewDocumentHtml(html, 297 * 96 / 25.4)); await page.waitForFunction(() => document.documentElement.dataset.cvPreviewReady === 'true'); const measured = await page.evaluate(() => { const name = document.querySelector('.name') || document.querySelector('h1'), node = name.firstChild, style = getComputedStyle(name); if (node.nodeType !== Node.TEXT_NODE || !/^[SR]01 /.test(node.textContent)) throw new Error('Unexpected synthetic fixture'); const context = document.createElement('canvas').getContext('2d'); context.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; const clipTop = document.querySelector('[data-cv-preview-flow]').getBoundingClientRect().top; const glyphs = []; for (let index = 0; index < node.length; index++) { const text = node.textContent[index]; if (!text.trim()) continue; const range = document.createRange(); range.setStart(node, index); range.setEnd(node, index + 1); const rect = range.getClientRects()[0], metrics = context.measureText(text); const inkTop = rect.top + metrics.fontBoundingBoxAscent - metrics.actualBoundingBoxAscent; glyphs.push({ text, inkTop, clearancePx: inkTop - clipTop }); } return { clipTop, font: context.font, glyphs, minClearancePx: Math.min(...glyphs.map(row => row.clearancePx)) }; }); const key = `${fixture.fixture}-${photo ? 'photo' : 'no-photo'}-${european ? 'eu' : 'nb'}`; await page.screenshot({ path: path.join(target, key + '.jpg'), type: 'jpeg', quality: 90, clip: { x: 0, y: 0, width: 794, height: 360 } }); rows.push({ fixture: fixture.fixture, photo, european, pass: measured.minClearancePx >= 0, ...measured }); } assert.equal(rows.length, kind === 'cv' ? 36 : 18); const result = { criterion: 'Actual first-page glyph ink cannot extend above the fragment clipping boundary; no epsilon or relaxed tolerance', scope: 'Synthetic layout variants derived from real renderer HTML; display-none photo equivalent and additional European text, not extra API persistence tests', passed: rows.filter(row => row.pass).length, total: rows.length, rows }; fs.writeFileSync(path.join(target, 'result.json'), JSON.stringify(result, null, 2)); console.log(`Name accent clearance ${result.passed}/${result.total}; min ${Math.min(...rows.map(row => row.minClearancePx))}px`); if (result.passed !== result.total) process.exitCode = 1; } finally { await browser.close(); } } main().catch(error => { console.error(error.message); process.exitCode = 1; });