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
@@ -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 { 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' });
});
@@ -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' });
});