fix(pdf): preserve accented names in revised CV and letter headings
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user