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