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
+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.
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 importlib.util
import json
import re
from pathlib import Path
import pdfplumber
@@ -30,7 +31,9 @@ def main():
ink = json.loads((args.corpus/'name-box-metrics.json').read_text(encoding='utf-8'))
rows = []
for dom in measured['rows']:
assert re.fullmatch(r'(short-en|medium-nb|long-en|dense-nb)-[a-z-]+',dom['fixture'])
file = args.corpus/dom['fixture']/'export.pdf'
assert file.stat().st_size <= 8*1024*1024
assert sha(file) == dom['pdfSha256'], 'Native PDF changed since DOM measurement binding'
original = next(row for row in oracle['fixtures'] if row['fixture'] == dom['fixture'])
comparison = next(row for row in original['comparisons'] if row['token'] == 'S01')
@@ -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 = [];
try {
for (const fixture of fixtures) {
assert.match(fixture.fixture, /^(?:short-en|medium-nb|long-en|dense-nb)-[a-z-]+$/);
const html = fs.readFileSync(path.join(source, fixture.fixture, 'render.html'));
assert.ok(html.length <= 8 * 1024 * 1024, 'Excessive synthetic HTML');
await page.setContent(copy.exports.previewDocumentHtml(html.toString('utf8'), 297 * 96 / 25.4));
await page.waitForFunction(() => document.documentElement.dataset.cvPreviewReady === 'true');
const measured = await page.evaluate(() => {
@@ -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)