diff --git a/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs b/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs index 2ba4ff4..5c89101 100644 --- a/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs +++ b/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs @@ -60,7 +60,8 @@ public sealed class CoverLetterDocumentRenderer """; if (s.DesignVersion == 2) { - var css = $"h1,h2{{font-family:{CvDocumentFonts.Stack(s.HeadingFont!)};}}h1{{font-size:{F(theme.NameSizePt - 2)}pt;}}header{{border-bottom-width:.4mm;}}"; + // Sender-name ascenders must clear the first-page clipping boundary, as in revised CVs. + var css = $"h1,h2{{font-family:{CvDocumentFonts.Stack(s.HeadingFont!)};}}h1{{font-size:{F(theme.NameSizePt - 2)}pt;padding-top:2mm;}}header{{border-bottom-width:.4mm;}}"; css += theme.HeaderStyle switch { "band" when theme.Layout == "header-band" => $"header{{background:{s.AccentColor};color:{ThemedCvRenderer.ContrastInk(s.AccentColor!)};padding:6mm;margin-bottom:8mm;border:0;}}header h1{{color:inherit;}}", diff --git a/JobTrackerApi/Services/ThemedCvRenderer.cs b/JobTrackerApi/Services/ThemedCvRenderer.cs index 409d78a..c165502 100644 --- a/JobTrackerApi/Services/ThemedCvRenderer.cs +++ b/JobTrackerApi/Services/ThemedCvRenderer.cs @@ -276,8 +276,9 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer ? $@".main{{counter-reset:cv-section;}} .main>.section>.section-title{{counter-increment:cv-section;font-family:{CvDocumentFonts.Stack(t.UtilityFont)};font-weight:600;}} .main>.section>.section-title::before{{content:counter(cv-section,decimal-leading-zero) ' — ';color:var(--cv-accent-color);}}" : string.Empty; + // Reserve ink clearance for accented capitals above the tight name line box at the first print fragment. var revisedCss = settings.DesignVersion == 2 - ? $".header .name{{margin-bottom:{F(t.NameGapMm * density)}mm;}}.header + .main{{padding-top:{F(t.HeaderContentGapMm * density)}mm;}}" : string.Empty; + ? $".name{{padding-top:2mm;}}.header .name{{margin-bottom:{F(t.NameGapMm * density)}mm;}}.header + .main{{padding-top:{F(t.HeaderContentGapMm * density)}mm;}}" : string.Empty; if (settings.DesignVersion == 2 && twoColumn) { var edge = layout == "sidebar-right" ? "right" : "left"; diff --git a/docs/p10-entry-final/.gitattributes b/docs/p10-entry-final/.gitattributes index 70b7f0c..fa1385d 100644 --- a/docs/p10-entry-final/.gitattributes +++ b/docs/p10-entry-final/.gitattributes @@ -1 +1 @@ -REQUEST.md -text +* -text diff --git a/docs/p10-entry-final/DIAGNOSIS.md b/docs/p10-entry-final/DIAGNOSIS.md index 00e2aa2..10fb54e 100644 --- a/docs/p10-entry-final/DIAGNOSIS.md +++ b/docs/p10-entry-final/DIAGNOSIS.md @@ -13,3 +13,15 @@ Windows and Linux emit different descriptor metadata for the same bundled fonts. No layout change is justified merely to force unequal coordinate conventions to agree: it would move correctly aligned visible text or alter intended title sizing. No clipping, lost content, misplaced photo/sidebar or pagination defect was established by these S01 failures. Finite automated findings do not establish human typography/whitespace/print acceptance. Remediation proposed for owner decision: retain the historical oracle and results; adopt a like-for-like native text-baseline diagnostic with the **same2mm bound** for revised S01, alongside actual-raster evidence and all existing font/content/page/color/other-marker checks. Keep this supplementary until explicit approval. No golden regeneration, arbitrary threshold increase, renderer change or automatic Phase10 entry is performed. + +Direct read-only inspection of the shipped SFNT tables also explains the metadata choice: for Selawik/Roboto (USE_TYPO_METRICS=false), the Windows descriptors match OS/2 usWinAscent/usWinDescent, while Linux matches hhea ascent/descent. For Arimo/Gelasio/Poppins (USE_TYPO_METRICS=true), both platforms match their equal typo/hhea ascent/descent magnitudes. This is measured font-table correspondence, not a claim about uninspected browser implementation internals. No font was substituted or edited. + +## Separate defect found by full-name inspection + +The S01-only raster crop does not cover name diacritics. A new real-browser glyph-clearance regression found24/36 failures across nine themes, photo/no-photo and Norwegian/additional European name variants (eight themes affected). The glyph ink could extend1–5px above the first print fragment; both preview/export can therefore agree while clipping the same accent. This is an actual rendering defect, independent of the S01 metric mismatch, not a cosmetic waiver. Candidate-only preview tests with revised name line-height1.4 pass36/36 with at least1px clearance. The production correction and fresh native before/after verification are still required; version1 must remain unchanged. + +The matching executive letter heading also reproduces2/18 failed glyph-clearance cases (up to4px above the clipping edge). A smaller candidate correction adds2mm block-start padding to revised CV names and revised letter sender headings. It preserves existing inter-line leading and measures36/36 CV variants (minimum2.546875px clearance) and18/18 letter variants (minimum3.546875px) in candidate-only previews. Those are preparation results; actual shared-renderer/native export verification remains separate. + +## Native version boundary + +The retained Windows native/preview Chromium is151.0.7922.34; the pinned Linux fixture reports native Chromium156.0.8078.47 on Debian12. Its preview capture still uses the Windows browser. Thus operating system and native browser release both vary. The descriptor-table differences and their numeric effect are directly verified, but attributing the selection solely to the operating system would require a matched-version experiment. No additional browser artifact was downloaded. Qualification is bounded to the exact recorded fixtures. diff --git a/docs/p10-entry-final/OWNER_REVIEW.md b/docs/p10-entry-final/OWNER_REVIEW.md new file mode 100644 index 0000000..0306562 --- /dev/null +++ b/docs/p10-entry-final/OWNER_REVIEW.md @@ -0,0 +1,16 @@ +# Owner decisions still required + +**No human result or approval has been supplied. Phase10 remains NO-GO.** Use synthetic data; do not upload a personal CV or commit credentials/signatures. The final ENTRY_PROPOSAL.json names the exact published candidate and pending decisions. A model-written status cannot approve it. + +| Priority / decision | Exact action and expected result | Evidence / blocking scope | +|---|---|---| +| 1. PDF metric disposition | Read DIAGNOSIS.md and the per-document classification; inspect annotated preview/PDF name crops and full PDFs in the review package. Decide whether to approve the **same2mm bound measured baseline-to-baseline for revised S01**, retaining the original range-vs-cell oracle/results plus all other font/content/page/color checks. Reject or request revision if visible text, title gaps or layout are wrong | Concrete recorded decision naming metric, design version2, source/report hashes and accepted samples. **Blocks disposition of the mandatory failed PDF criterion** until approved or corrected; not a general visual/release waiver | +| 2. Template appearance | Review all nine short first pages and long/dense first/final pages, including modern, ATS classic, code and sidebar/photo themes; compare historical originals and revised designs. In http://jobbjakt.local create a new CV or explicitly Apply updated design2. Check name/title gap, section/paragraph rhythm, margins, bullets, columns, photo alignment, page breaks, diacritics and final-page whitespace | Annotated synthetic PDF or screenshot with template/profile/page/platform and specific PASS/revise decision. Human aesthetic approval pending; applicability to a cleanup candidate must be explicit. Existing designs must not change automatically | +| 3. Exact cleanup entry/scope | Review final candidate SHA/publication, retained19-item inventory and all mandatory blockers. Approve only a concrete scope with callers/routes/old-data/rollback/tests identified. Current inventory establishes **no safe product deletion**. Assessment-only permission must not become implementation permission | Protected controller entry/candidate decision bound to exact baseline and policy/artifact hashes; **mandatory before cleanup implementation**. This workstream will not start it | +| 4. Runtime/adapter | In your own operator terminal, follow the separate controller's current instructions for dedicated same-account sign-in, actual protected owner key and fresh current-context qualification. Qualify the actual composite application test adapter; review signed baseline/policy activation separately | Real-key host-positive/worker-negative evidence, current qualification and independently approved test adapter. **Blocks automatic execution**, not evidence-only PDF inspection. No login/key/activation performed here | +| 5. Physical/AT/language | On a real phone/tablet, use touch/rotation and keyboard-only/NVDA or VoiceOver through CV editing/import/template dialogs/dashboard; have proficient EN/Bokmål reviewers inspect labels/errors/dates | Device/OS/browser/AT version, exact steps, result and safe screenshot/notes. Genuine unfinished checks may remain source-authorised **release debt** for unrelated assessment; affected candidate controls still require preservation/disposition. Automation is not human approval | +| 6. Actual print | Print short and multipage A4/Letter CV/letter at intended scale. Compare actual margins, fonts, breaks, sidebar continuity and full content with the PDF | Printer/driver/settings and synthetic print photos. **Production print gate**; PDF rendering/download is insufficient | + +Possible conditional-development deferrals are individually requested in ENTRY_PROPOSAL.json: unfinished human checks, disabled CV AI qualification, deterministic recall breadth, scoped Python vendor-audit debt and quarantined hosted CI. Existing source authority permits only their stated scope. They are **not granted by this document**; the current controller has no declared executable checkpoint deferrals. A protected policy decision is required before any automatic conditional advancement, and it cannot hide a mandatory automated failure. + +Production-only MySqlConnector/TLS, native/image security, filesystem/off-disk backup/recovery and release/operator work stays blocked independently. Broader models, generic-AI canonical-profile compatibility, later product features, provider integrations and roadmap initiatives remain separate. No real CV-model quality acceptance, production risk acceptance or deployment permission is requested through a typography decision. diff --git a/docs/p10-entry-final/audit-s01.py b/docs/p10-entry-final/audit-s01.py index 7ae00e9..1317a0c 100644 --- a/docs/p10-entry-final/audit-s01.py +++ b/docs/p10-entry-final/audit-s01.py @@ -6,6 +6,7 @@ import argparse import hashlib import importlib.util import json +import re from pathlib import Path import pdfplumber @@ -30,7 +31,9 @@ def main(): ink = json.loads((args.corpus/'name-box-metrics.json').read_text(encoding='utf-8')) rows = [] for dom in measured['rows']: + assert re.fullmatch(r'(short-en|medium-nb|long-en|dense-nb)-[a-z-]+',dom['fixture']) file = args.corpus/dom['fixture']/'export.pdf' + assert file.stat().st_size <= 8*1024*1024 assert sha(file) == dom['pdfSha256'], 'Native PDF changed since DOM measurement binding' original = next(row for row in oracle['fixtures'] if row['fixture'] == dom['fixture']) comparison = next(row for row in original['comparisons'] if row['token'] == 'S01') diff --git a/docs/p10-entry-final/check-name-clearance.cjs b/docs/p10-entry-final/check-name-clearance.cjs new file mode 100644 index 0000000..59385e2 --- /dev/null +++ b/docs/p10-entry-final/check-name-clearance.cjs @@ -0,0 +1,59 @@ +// 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; }); diff --git a/docs/p10-entry-final/inspect-font-tables.py b/docs/p10-entry-final/inspect-font-tables.py new file mode 100644 index 0000000..edad255 --- /dev/null +++ b/docs/p10-entry-final/inspect-font-tables.py @@ -0,0 +1,35 @@ +"""Read shipped font tables and explain platform descriptor differences; no font edits.""" +import argparse +import hashlib +import json +import struct +from pathlib import Path + +parser=argparse.ArgumentParser(description=__doc__) +parser.add_argument('output',type=Path) +args=parser.parse_args() +if args.output.exists():raise ValueError('Keep earlier font inventories immutable') +rows=[] +for name in ['selawkb.ttf','Roboto-Bold.ttf','Arimo-Bold.ttf','Gelasio-Bold.ttf','Gelasio-SemiBold.ttf','Poppins-Bold.ttf']: + file=Path(__file__).resolve().parents[2]/'JobTrackerApi/DocumentFonts'/name + data=file.read_bytes() + assert data[:4]==b'\x00\x01\x00\x00' and len(data)<5*1024*1024 + count=struct.unpack_from('>H',data,4)[0];assert 1<=count<=64 + tables={} + for index in range(count): + tag,checksum,offset,length=struct.unpack_from('>4sIII',data,12+index*16) + assert offset+length<=len(data) + tables[tag]=data[offset:offset+length] + assert len(tables[b'head'])>=20 and len(tables[b'hhea'])>=8 and len(tables[b'OS/2'])>=78 + units=struct.unpack_from('>H',tables[b'head'],18)[0];assert units>0 + ascent,descent=struct.unpack_from('>hh',tables[b'hhea'],4) + typo_ascent,typo_descent=struct.unpack_from('>hh',tables[b'OS/2'],68) + win_ascent,win_descent=struct.unpack_from('>HH',tables[b'OS/2'],74) + selection=struct.unpack_from('>H',tables[b'OS/2'],62)[0] + rows.append(dict(file=file.name,sha256=hashlib.sha256(data).hexdigest(),unitsPerEm=units, + hheaAscent1000=ascent/units*1000,hheaDescent1000=descent/units*1000, + typoAscent1000=typo_ascent/units*1000,typoDescent1000=typo_descent/units*1000, + windowsAscent1000=win_ascent/units*1000,windowsDescent1000=win_descent/units*1000, + useTypoMetrics=bool(selection & (1<<7)))) +args.output.write_bytes((json.dumps(dict(evidenceOnly=True,fonts=rows),indent=2)+'\n').encode()) +print('Inspected6 bundled font metric tables without modifying fonts') diff --git a/docs/p10-entry-final/measure-baselines.cjs b/docs/p10-entry-final/measure-baselines.cjs index 6755b7c..c16f04b 100644 --- a/docs/p10-entry-final/measure-baselines.cjs +++ b/docs/p10-entry-final/measure-baselines.cjs @@ -26,7 +26,9 @@ async function main() { const rows = []; try { for (const fixture of fixtures) { + assert.match(fixture.fixture, /^(?:short-en|medium-nb|long-en|dense-nb)-[a-z-]+$/); const html = fs.readFileSync(path.join(source, fixture.fixture, 'render.html')); + assert.ok(html.length <= 8 * 1024 * 1024, 'Excessive synthetic HTML'); await page.setContent(copy.exports.previewDocumentHtml(html.toString('utf8'), 297 * 96 / 25.4)); await page.waitForFunction(() => document.documentElement.dataset.cvPreviewReady === 'true'); const measured = await page.evaluate(() => { diff --git a/docs/p10-entry-final/verify-european-controls.py b/docs/p10-entry-final/verify-european-controls.py new file mode 100644 index 0000000..6bda86d --- /dev/null +++ b/docs/p10-entry-final/verify-european-controls.py @@ -0,0 +1,51 @@ +"""Check actual API-exported European-name controls; keep human visual approval separate.""" +import argparse +from collections import Counter +import hashlib +import json +from pathlib import Path +import re +import subprocess +import unicodedata +import pdfplumber + +parser=argparse.ArgumentParser(description=__doc__) +parser.add_argument('directory',type=Path) +parser.add_argument('--kind',choices=['cv','letter'],required=True) +parser.add_argument('--poppler',type=Path,required=True) +args=parser.parse_args() +source=json.loads((args.directory/'measurements.json').read_text(encoding='utf-8')) +rows=source['europeanControls'] +assert len(rows)==9 and {row['settings']['themeId'] for row in rows}=={'modern','minimal','executive','technical','ats-classic','nordic','elegant','creative','code'} +results=[] +for row in rows: + assert re.fullmatch(r'european-(no-photo|en)-[a-z-]+',row['fixture']) + folder=args.directory/row['fixture'];file=folder/'export.pdf' + assert file.stat().st_size<=8*1024*1024 + assert hashlib.sha256(file.read_bytes()).hexdigest()==row['pdfSha256'] + assert hashlib.sha256((folder/'render.html').read_bytes()).hexdigest()==row['htmlSha256'] + assert row['nameInkClearancePx']>=0 + if args.kind=='cv':assert row['settings']['showPhoto'] is False and row['measured']['photo'] is False + name=row['expectedFullName'] if args.kind=='cv' else row['expectedSender'] + failures=[];texts=[];pages=[] + with pdfplumber.open(file) as document: + assert len(document.pages)==row['previewPages'] and 1<=len(document.pages)<=64 + for number,page in enumerate(document.pages,1): + text=unicodedata.normalize('NFC',page.extract_text() or '') + texts.append(text) + outside=[word for word in page.extract_words() if word['x0'] < -1.42 or word['top'] < -1.42 or word['x1'] > page.width+1.42 or word['bottom'] > page.height+1.42] + if not text.strip() or outside:failures.append(f'Page{number} blank or text outside physical page bounds') + if abs(page.width*25.4/72-210)>.5 or abs(page.height*25.4/72-297)>.5:failures.append('Unexpected page dimensions') + pages.append(dict(page=number,widthPt=page.width,heightPt=page.height)) + text='\n'.join(texts) + if Counter(re.findall(r'\b[SEBPR]\d{2}\b',text))!=Counter(row['expectedMarkers']):failures.append('Source marker multiset differs') + for word in name.split(): + if word not in text.split():failures.append('Missing European name word: '+word) + for number in sorted({1,len(pages)}): + subprocess.run([str(args.poppler),'-f',str(number),'-l',str(number),'-singlefile','-scale-to','1400','-png',str(file),str(folder/f'poppler-{number}')],check=True,capture_output=True,timeout=30) + results.append(dict(fixture=row['fixture'],pass_=not failures,failures=failures,pdfSha256=row['pdfSha256'],expectedName=name,previewGlyphClearancePx=row['nameInkClearancePx'],pages=pages)) +report=dict(kind=args.kind,total=9,passed=sum(row['pass_'] for row in results),pages=sum(len(row['pages']) for row in results), + qualification='Actual API native PDF source-word/marker retention and physical bounds; separate real-preview glyph extents. Text extraction alone cannot certify painted diacritics; native raster/owner approval remains separate.',fixtures=results) +(args.directory/'european-proof.json').write_bytes((json.dumps(report,ensure_ascii=False,indent=2)+'\n').encode()) +print(f"European {args.kind} controls: {report['passed']}/9 native documents; {report['pages']} pages") +raise SystemExit(0 if report['passed']==9 else 1) diff --git a/job-tracker-ui/e2e/helpers/nameInkClearance.ts b/job-tracker-ui/e2e/helpers/nameInkClearance.ts new file mode 100644 index 0000000..f0a4d4d --- /dev/null +++ b/job-tracker-ui/e2e/helpers/nameInkClearance.ts @@ -0,0 +1,27 @@ +// Run in the preview frame after fonts/pagination settle, rather than using text-cell tops. +export function minimumNameInkClearance(selector: string): number { + const name = document.querySelector(selector); + const flow = document.querySelector('[data-cv-preview-flow]'); + const context = document.createElement('canvas').getContext('2d'); + if (!name || !flow || !context) throw new Error('Missing name or settled print fragment'); + const clipTop = flow.getBoundingClientRect().top; + const clearance: number[] = []; + const walker = document.createTreeWalker(name, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + const style = getComputedStyle(node.parentElement!); + context.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + let offset = 0; + for (const character of node.textContent || '') { + const range = document.createRange(); + range.setStart(node, offset); range.setEnd(node, offset + character.length); + offset += character.length; + if (!character.trim()) continue; + const rect = range.getClientRects()[0], glyph = context.measureText(character); + if (!rect) throw new Error('Name glyph has no rendered position'); + clearance.push(rect.top + glyph.fontBoundingBoxAscent - glyph.actualBoundingBoxAscent - clipTop); + } + } + if (!clearance.length) throw new Error('Empty name cannot qualify glyph clearance'); + return Math.min(...clearance); +} diff --git a/job-tracker-ui/e2e/pre-p10-letter-library.spec.ts b/job-tracker-ui/e2e/pre-p10-letter-library.spec.ts index 6243371..4bcdf9d 100644 --- a/job-tracker-ui/e2e/pre-p10-letter-library.spec.ts +++ b/job-tracker-ui/e2e/pre-p10-letter-library.spec.ts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { previewDocumentHtml } from '../src/cvPreview'; +import { minimumNameInkClearance } from './helpers/nameInkClearance'; const api = 'http://localhost:5302/api'; const output = path.resolve(process.env.LETTER_LIBRARY_OUTPUT || '../.artifacts/pre-phase10-ux/matching-letters-01'); @@ -26,7 +27,8 @@ test('[PRE-P10 B] nine matching letter designs retain short EN and long NB physi const company = await companyResponse.json(); await fs.mkdir(output, { recursive: true }); const fixtures = []; - for (const language of ['en', 'nb'] as const) { + const europeanControls = []; + for (const { language, european } of [{ language: 'en', european: false }, { language: 'nb', european: false }, { language: 'en', european: true }] as const) { const nb = language === 'nb'; const paragraphs = Array.from({ length: nb ? 20 : 3 }, (_, index) => { const sentence = nb ? 'Jeg dokumenterte pålitelige arbeidsflyter, tilgjengelige grensesnitt og tydelig ansvar i et oppdiktet prosjekt. ' @@ -37,11 +39,11 @@ test('[PRE-P10 B] nine matching letter designs retain short EN and long NB physi : 'R99 Kind regards, Åse Ødegård-Sørensen. These are synthetic examples only.'); const text = paragraphs.join('\n\n'); for (const theme of themes) { - const fixture = `${nb ? 'long-nb' : 'short-en'}-${theme.id}`; + const fixture = `${european ? 'european-en' : nb ? 'long-nb' : 'short-en'}-${theme.id}`; const directory = path.join(output, fixture); await fs.mkdir(directory, { recursive: true }); const documentSettings = { schemaVersion: 1, designVersion: 2, language: nb ? 'nb-NO' : 'en', themeId: theme.id, - senderName: 'R01 Åse Ødegård-Sørensen', senderTitle: nb ? 'P01 Plattformingeniør' : 'P01 Platform engineer', + senderName: european ? 'R01 Åse Émilie Müller Žák Łódź Sørensen' : 'R01 Åse Ødegård-Sørensen', senderTitle: nb ? 'P01 Plattformingeniør' : 'P01 Platform engineer', senderEmail: 'fictional.ase@example.test', senderLocation: 'Tromsø, Norge', recipient: 'R03 Fictional hiring team\nFictional Fjord Systems AS\nTromsø, Norge', subject: nb ? 'R02 Søknad om plattformingeniør' : 'R02 Application for platform engineer', date: '2026-10-27', pageSize: 'a4' }; @@ -66,6 +68,8 @@ test('[PRE-P10 B] nine matching letter designs retain short EN and long NB physi await page.setViewportSize({ width: 794, height: 1123 }); await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4)); await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady), { message: fixture }).toBe('true'); + const nameInkClearancePx = await page.evaluate(minimumNameInkClearance, 'h1'); + expect(nameInkClearancePx, `${fixture} full sender name ink stays inside print fragment`).toBeGreaterThanOrEqual(0); const measured = await page.evaluate(() => { const data = document.documentElement.dataset; const width = Number(data.cvPreviewWidth), height = Number(data.cvPreviewHeight); @@ -112,13 +116,14 @@ test('[PRE-P10 B] nine matching letter designs retain short EN and long NB physi await page.screenshot({ path: path.join(directory, `preview-${index + 1}.png`) }); } expect(await (await page.request.get(letterUrl)).json()).toEqual(saved); - fixtures.push({ fixture, settings: saved.documentSettings, expectedMarkers, expectedParagraphs: paragraphs, + (european ? europeanControls : fixtures).push({ fixture, settings: saved.documentSettings, expectedMarkers, expectedParagraphs: paragraphs, nameInkClearancePx, expectedRecipient: documentSettings.recipient, expectedSender: documentSettings.senderName, expectedSubject: documentSettings.subject, previewPages: measured.pages, measured, savedRevision: saved.revision, htmlSha256: sha256(rendered.html), pdfSha256: sha256(bytes) }); - await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), fixtures }, null, 2)); + await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), fixtures, europeanControls }, null, 2)); } } expect(fixtures).toHaveLength(18); + expect(europeanControls).toHaveLength(9); expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(sourceBefore); await info.attach('matching-letter-library-measurements', { path: path.join(output, 'measurements.json'), contentType: 'application/json' }); }); diff --git a/job-tracker-ui/e2e/pre-p10-template-library.spec.ts b/job-tracker-ui/e2e/pre-p10-template-library.spec.ts index 7b7edec..2db0c80 100644 --- a/job-tracker-ui/e2e/pre-p10-template-library.spec.ts +++ b/job-tracker-ui/e2e/pre-p10-template-library.spec.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { createHash } from 'node:crypto'; import { previewDocumentHtml } from '../src/cvPreview'; import { templateFixtures, templateProfile } from './pre-p10-template-fixtures'; +import { minimumNameInkClearance } from './helpers/nameInkClearance'; const api = 'http://localhost:5302/api'; const output = path.resolve(process.env.CV_TEMPLATE_OUTPUT || '../.artifacts/pre-phase10-ux/templates-legacy'); @@ -36,18 +37,22 @@ test('[PRE-P10 B] equivalent synthetic histories retain preview and native PDF e await fs.writeFile(path.join(output, 'synthetic-nonhuman-color-sample.png'), avatar); expect((await page.request.post(`${api}/auth/avatar`, { headers, multipart: { file: { name: 'synthetic-nonhuman.png', mimeType: 'image/png', buffer: avatar } } })).status()).toBe(200); const results: unknown[] = []; - for (const sample of templateFixtures) { + const europeanControls: unknown[] = []; + const scenarios = [...templateFixtures.map(sample => ({ sample, european: false })), + ...(designVersion === 2 ? [{ sample: templateFixtures[0], european: true }] : [])]; + for (const { sample, european } of scenarios) { const { profile, markers } = templateProfile(sample); + if (european) profile.contact.fullName = 'S01 Åse Émilie Müller Žák Łódź Sørensen'; const current = await (await page.request.get(`${api}/career/profile`)).json(); expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: current.revision, profile } })).status()).toBe(200); const source = await (await page.request.get(`${api}/career/profile`)).json(); for (const theme of themes) { - const fixture = `${sample.key}-${theme.id}`; + const fixture = `${european ? 'european-no-photo' : sample.key}-${theme.id}`; const directory = path.join(output, fixture); await fs.mkdir(directory, { recursive: true }); const started = performance.now(); const created = await page.request.post(`${api}/cv/variants`, { headers, data: { name: fixture, settings: { - themeId: theme.id, language: sample.language, showPhoto: true, showIcons: true, + themeId: theme.id, language: sample.language, showPhoto: !european, showIcons: true, ...(designVersion === undefined ? {} : { designVersion }), } } }); expect(created.status(), fixture).toBe(200); @@ -59,6 +64,8 @@ test('[PRE-P10 B] equivalent synthetic histories retain preview and native PDF e await page.setViewportSize({ width: 794, height: 1123 }); await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4)); await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady), { message: fixture }).toBe('true'); + const nameInkClearancePx = await page.evaluate(minimumNameInkClearance, '.name'); + if (designVersion === 2) expect(nameInkClearancePx, `${fixture} full name ink stays inside print fragment`).toBeGreaterThanOrEqual(0); const measured = await page.evaluate(() => { const data = document.documentElement.dataset; const width = Number(data.cvPreviewWidth), height = Number(data.cvPreviewHeight); @@ -92,6 +99,7 @@ test('[PRE-P10 B] equivalent synthetic histories retain preview and native PDF e }); expect(measured.tokens.map(token => token.token).sort(), `${fixture} preview marker completeness`).toEqual([...markers].sort()); expect(measured.previewPages, fixture).toBeGreaterThan(0); + expect(measured.photo, fixture).toBe(!european && theme.photoShape !== 'none'); if (designVersion === 2) expect(measured.splitNameWords.filter(word => !word.includes('-')), `${fixture} unhyphenated name words`).toEqual([]); const exported = await page.request.post(`${api}/cv/variants/${variant.id}/export-pdf?expectedVersion=${variant.version}`, { headers }); expect(exported.status(), fixture).toBe(200); @@ -105,11 +113,13 @@ test('[PRE-P10 B] equivalent synthetic histories retain preview and native PDF e } expect((await (await page.request.get(`${api}/cv/variants/${variant.id}`)).json()).version).toBe(variant.version); expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(source); - results.push({ fixture, settings: variant.settings, expectedMarkers: markers, profileSha256: hash(JSON.stringify(profile)), htmlSha256: hash(rendered.html), pdfSha256: hash(bytes), + (european ? europeanControls : results).push({ fixture, settings: variant.settings, expectedMarkers: markers, expectedFullName: profile.contact.fullName, nameInkClearancePx, + profileSha256: hash(JSON.stringify(profile)), htmlSha256: hash(rendered.html), pdfSha256: hash(bytes), durationMs: performance.now() - started, previewPages: measured.previewPages, measured }); - await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), designVersion: designVersion ?? 'legacy-absent', syntheticPhoto: 'Colored rectangles only; no human portrait', fixtures: results }, null, 2)); + await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), designVersion: designVersion ?? 'legacy-absent', syntheticPhoto: 'Colored rectangles only; no human portrait', fixtures: results, europeanControls }, null, 2)); } } expect(results).toHaveLength(36); + expect(europeanControls).toHaveLength(designVersion === 2 ? 9 : 0); await info.attach('template-library-measurements', { path: path.join(output, 'measurements.json'), contentType: 'application/json' }); });