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