fix(pdf): preserve accented names in revised CV and letter headings

This commit is contained in:
cesnimda
2026-10-29 23:38:43 +01:00
parent 38d7133d20
commit 154a9e89f5
13 changed files with 235 additions and 13 deletions
@@ -60,7 +60,8 @@ public sealed class CoverLetterDocumentRenderer
"""; """;
if (s.DesignVersion == 2) 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 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;}}", "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;}}",
+2 -1
View File
@@ -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);}}" ? $@".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; : string.Empty;
// Reserve ink clearance for accented capitals above the tight name line box at the first print fragment.
var revisedCss = settings.DesignVersion == 2 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) if (settings.DesignVersion == 2 && twoColumn)
{ {
var edge = layout == "sidebar-right" ? "right" : "left"; var edge = layout == "sidebar-right" ? "right" : "left";
+1 -1
View File
@@ -1 +1 @@
REQUEST.md -text * -text
+12
View File
@@ -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. 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. 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.
+16
View File
@@ -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.
+3
View File
@@ -6,6 +6,7 @@ import argparse
import hashlib import hashlib
import importlib.util import importlib.util
import json import json
import re
from pathlib import Path from pathlib import Path
import pdfplumber import pdfplumber
@@ -30,7 +31,9 @@ def main():
ink = json.loads((args.corpus/'name-box-metrics.json').read_text(encoding='utf-8')) ink = json.loads((args.corpus/'name-box-metrics.json').read_text(encoding='utf-8'))
rows = [] rows = []
for dom in measured['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' 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' 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']) 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') comparison = next(row for row in original['comparisons'] if row['token'] == 'S01')
@@ -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('</head>', '<style>.photo{display:none!important}</style></head>');
if (european) html = kind === 'cv'
? html.replace(/(<h1 class="name">)[^<]+(<\/h1>)/, '$1S01 Åse Émilie Müller Žák Łódź Sørensen$2')
: html.replace(/(<h1>)[^<]+(<\/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; });
@@ -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')
@@ -26,7 +26,9 @@ async function main() {
const rows = []; const rows = [];
try { try {
for (const fixture of fixtures) { 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')); 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.setContent(copy.exports.previewDocumentHtml(html.toString('utf8'), 297 * 96 / 25.4));
await page.waitForFunction(() => document.documentElement.dataset.cvPreviewReady === 'true'); await page.waitForFunction(() => document.documentElement.dataset.cvPreviewReady === 'true');
const measured = await page.evaluate(() => { const measured = await page.evaluate(() => {
@@ -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)
@@ -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);
}
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { previewDocumentHtml } from '../src/cvPreview'; import { previewDocumentHtml } from '../src/cvPreview';
import { minimumNameInkClearance } from './helpers/nameInkClearance';
const api = 'http://localhost:5302/api'; const api = 'http://localhost:5302/api';
const output = path.resolve(process.env.LETTER_LIBRARY_OUTPUT || '../.artifacts/pre-phase10-ux/matching-letters-01'); 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(); const company = await companyResponse.json();
await fs.mkdir(output, { recursive: true }); await fs.mkdir(output, { recursive: true });
const fixtures = []; 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 nb = language === 'nb';
const paragraphs = Array.from({ length: nb ? 20 : 3 }, (_, index) => { 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. ' 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.'); : 'R99 Kind regards, Åse Ødegård-Sørensen. These are synthetic examples only.');
const text = paragraphs.join('\n\n'); const text = paragraphs.join('\n\n');
for (const theme of themes) { 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); const directory = path.join(output, fixture);
await fs.mkdir(directory, { recursive: true }); await fs.mkdir(directory, { recursive: true });
const documentSettings = { schemaVersion: 1, designVersion: 2, language: nb ? 'nb-NO' : 'en', themeId: theme.id, 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', senderEmail: 'fictional.ase@example.test', senderLocation: 'Tromsø, Norge',
recipient: 'R03 Fictional hiring team\nFictional Fjord Systems AS\nTromsø, 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' }; 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.setViewportSize({ width: 794, height: 1123 });
await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4)); await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4));
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady), { message: fixture }).toBe('true'); 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 measured = await page.evaluate(() => {
const data = document.documentElement.dataset; const data = document.documentElement.dataset;
const width = Number(data.cvPreviewWidth), height = Number(data.cvPreviewHeight); 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`) }); await page.screenshot({ path: path.join(directory, `preview-${index + 1}.png`) });
} }
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved); 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, expectedRecipient: documentSettings.recipient, expectedSender: documentSettings.senderName, expectedSubject: documentSettings.subject,
previewPages: measured.pages, measured, savedRevision: saved.revision, htmlSha256: sha256(rendered.html), pdfSha256: sha256(bytes) }); 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(fixtures).toHaveLength(18);
expect(europeanControls).toHaveLength(9);
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(sourceBefore); 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' }); await info.attach('matching-letter-library-measurements', { path: path.join(output, 'measurements.json'), contentType: 'application/json' });
}); });
@@ -4,6 +4,7 @@ import path from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { previewDocumentHtml } from '../src/cvPreview'; import { previewDocumentHtml } from '../src/cvPreview';
import { templateFixtures, templateProfile } from './pre-p10-template-fixtures'; import { templateFixtures, templateProfile } from './pre-p10-template-fixtures';
import { minimumNameInkClearance } from './helpers/nameInkClearance';
const api = 'http://localhost:5302/api'; const api = 'http://localhost:5302/api';
const output = path.resolve(process.env.CV_TEMPLATE_OUTPUT || '../.artifacts/pre-phase10-ux/templates-legacy'); 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); 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); 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[] = []; 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); 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(); 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); 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(); const source = await (await page.request.get(`${api}/career/profile`)).json();
for (const theme of themes) { 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); const directory = path.join(output, fixture);
await fs.mkdir(directory, { recursive: true }); await fs.mkdir(directory, { recursive: true });
const started = performance.now(); const started = performance.now();
const created = await page.request.post(`${api}/cv/variants`, { headers, data: { name: fixture, settings: { 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 }), ...(designVersion === undefined ? {} : { designVersion }),
} } }); } } });
expect(created.status(), fixture).toBe(200); 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.setViewportSize({ width: 794, height: 1123 });
await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4)); await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4));
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady), { message: fixture }).toBe('true'); 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 measured = await page.evaluate(() => {
const data = document.documentElement.dataset; const data = document.documentElement.dataset;
const width = Number(data.cvPreviewWidth), height = Number(data.cvPreviewHeight); 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.tokens.map(token => token.token).sort(), `${fixture} preview marker completeness`).toEqual([...markers].sort());
expect(measured.previewPages, fixture).toBeGreaterThan(0); 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([]); 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 }); const exported = await page.request.post(`${api}/cv/variants/${variant.id}/export-pdf?expectedVersion=${variant.version}`, { headers });
expect(exported.status(), fixture).toBe(200); 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}/cv/variants/${variant.id}`)).json()).version).toBe(variant.version);
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(source); 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 }); 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(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' }); await info.attach('template-library-measurements', { path: path.join(output, 'measurements.json'), contentType: 'application/json' });
}); });