Files
pbakaus_impeccable/skill/scripts/font-match.mjs
T
f5751c4d75 font-match: choose the face by metrics; chroma-keyed plates; ink-box report; verbatim words in the hero
font-match.mjs fingerprints a text region's lettering in the comp (cap
height, glyph advance per cap = width class, ink fraction = weight class,
tracking), renders candidate faces at that cap height in a headless
browser (yours plus a shortlist for the width class), and ranks them by
distance with a proof sheet; the spec gate refuses to close until the lead
text region is measured and ranked. generate-image --plate keys ink-on-
ground plates to alpha (chroma) so the drawing sits on the page's own
ground; the plates gate scores keyed plates composited over the region's
ground. comp-diff reports each region's ink box; the hero gate names a
control whose box height or width differs from the comp. The hero
instruction copies the comp's words verbatim; rewording is a stated
decision after the hero passes.

Driven by a human review of the r3 side-by-sides: face width and weight,
plate ground, control row height, and content substitution.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00

411 lines
24 KiB
JavaScript

#!/usr/bin/env node
/**
* font-match: measure the lettering in a comp's text region and rank candidate
* faces against it, so the face is chosen by metrics instead of by name.
*
* node font-match.mjs --measure <region-id> [--spec .impeccable/build/spec.json]
* Fingerprints the comp crop of a text region: cap height (px), average
* glyph advance per cap height (width class), stroke weight (ink fraction
* per glyph area), and letter density. Prints them and stores them on the
* region in the spec (`type` block), so build code can set font-size from
* capHeightPx and the hero gate can name a width/weight miss.
*
* node font-match.mjs --rank <region-id> [--candidates "Barlow Condensed:700,Oswald:600"] [--text "The manuals stop."]
* Renders the region's text (or --text) in every candidate face (yours
* plus a built-in shortlist for the comp's width class) at the
* comp's cap height in a headless browser (Google Fonts CSS, or any
* locally installed face), measures the same fingerprint, and ranks the
* candidates by distance. Prints the ranking with per-face width and
* weight deltas and the CSS to use (family, weight, and the font-size
* that reproduces the comp's cap height). Needs a browser: playwright or
* puppeteer resolvable from the project or the impeccable CLI; without
* one, prints the comp fingerprint and how to read it.
*
* Why: models pick faces from memory and never measure. Three of the six
* misses a human called on a first-round build were the same miss: the
* headline face wider and lighter than the comp's, the parts list smaller,
* the footer heavier. All three are ratios a script can read off pixels.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { decodePng, encodePng } from './lib/png.mjs';
import { crop } from './lib/raster.mjs';
import { toGray } from './lib/image-metrics.mjs';
import { loadSpec, SPEC_PATH } from './comp-spec.mjs';
const require = createRequire(import.meta.url);
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = process.argv[i + 1];
return v && !v.startsWith('--') ? v : fallback;
}
// ---- fingerprint ----------------------------------------------------------
/** Otsu threshold on a gray image: ink vs ground, whichever is darker is ink. */
function otsu(gray) {
const hist = new Float64Array(256);
for (let i = 0; i < gray.data.length; i++) hist[Math.max(0, Math.min(255, Math.round(gray.data[i])))]++;
const total = gray.data.length;
let sum = 0; for (let i = 0; i < 256; i++) sum += i * hist[i];
let sumB = 0, wB = 0, best = 0, thr = 128;
for (let t = 0; t < 256; t++) {
wB += hist[t]; if (!wB) continue;
const wF = total - wB; if (!wF) break;
sumB += t * hist[t];
const mB = sumB / wB, mF = (sum - sumB) / wF;
const between = wB * wF * (mB - mF) ** 2;
if (between > best) { best = between; thr = t; }
}
return thr;
}
/**
* Fingerprint the lettering in an RGBA image:
* - lines: text lines found by row-projection of ink
* - capHeightPx: median line ink height (cap/x-height blend; consistent across comp and render)
* - advance: mean glyph width / capHeightPx (width class; condensed < 0.45, normal ~0.55-0.65, wide > 0.7)
* - weight: ink pixels / (glyph bbox area) (light ~0.2, regular ~0.3, bold ~0.4+)
* - gap: mean inter-glyph gap / capHeightPx (tracking)
* Returns null when no ink lines are found.
*/
export function fingerprint(img) {
const g = toGray(img);
const thr = otsu(g);
// ink is the minority side of the threshold
let dark = 0; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++;
const inkIsDark = dark <= g.data.length / 2;
const isInk = (v) => (inkIsDark ? v < thr : v >= thr);
const W = g.width, H = g.height;
const rowInk = new Uint32Array(H);
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (isInk(g.data[y * W + x])) rowInk[y]++;
// lines: runs of rows with ink above a small floor, then split each run at
// interior valleys (descender/ascender bridges keep adjacent lines joined
// at a trickle of ink; a valley under 15% of the run's peak is a line break)
const floor = Math.max(1, W * 0.004);
const runs = [];
let y = 0;
while (y < H) {
if (rowInk[y] > floor) {
let y0 = y; while (y < H && (rowInk[y] > floor || (y + 1 < H && rowInk[y + 1] > floor))) y++;
if (y - y0 >= 4) runs.push({ y0, y1: y });
} else y++;
}
const lines = [];
for (const run of runs) {
let peak = 0; for (let yy = run.y0; yy < run.y1; yy++) peak = Math.max(peak, rowInk[yy]);
const valley = peak * 0.15;
let start = run.y0, inValley = false, valleyStart = 0;
for (let yy = run.y0; yy < run.y1; yy++) {
const low = rowInk[yy] < valley;
if (low && !inValley) { inValley = true; valleyStart = yy; }
if (!low && inValley) {
inValley = false;
// a valley at least 3 rows deep splits; shorter ones are letter gaps
if (yy - valleyStart >= 3 && valleyStart - start >= 4) { lines.push({ y0: start, y1: valleyStart }); start = yy; }
}
}
if (run.y1 - start >= 4) lines.push({ y0: start, y1: run.y1 });
}
if (!lines.length) return null;
const glyphs = [];
const gapsAll = [];
for (const ln of lines) {
// column projection inside the line -> glyph runs
const colInk = new Uint32Array(W);
for (let yy = ln.y0; yy < ln.y1; yy++) for (let x = 0; x < W; x++) if (isInk(g.data[yy * W + x])) colInk[x]++;
let x = 0; const runs = [];
while (x < W) {
if (colInk[x] > 0) { const x0 = x; while (x < W && colInk[x] > 0) x++; runs.push({ x0, x1: x }); } else x++;
}
// ink height per line: rows in the line whose ink is above 20% of the line's max (drops ascender/descender tails)
let maxRow = 0; for (let yy = ln.y0; yy < ln.y1; yy++) maxRow = Math.max(maxRow, rowInk[yy]);
let hy0 = ln.y0, hy1 = ln.y1;
while (hy0 < ln.y1 && rowInk[hy0] < maxRow * 0.2) hy0++;
while (hy1 > hy0 && rowInk[hy1 - 1] < maxRow * 0.2) hy1--;
const lineH = Math.max(1, hy1 - hy0);
for (let i = 0; i < runs.length; i++) {
const r = runs[i];
const w = r.x1 - r.x0;
if (w < lineH * 0.15) continue; // dots, punctuation, thin rules
let ink = 0; for (let yy = hy0; yy < hy1; yy++) for (let xx = r.x0; xx < r.x1; xx++) if (isInk(g.data[yy * W + xx])) ink++;
glyphs.push({ w, h: lineH, ink });
if (i + 1 < runs.length) { const gap = runs[i + 1].x0 - r.x1; if (gap < lineH * 0.6) gapsAll.push(gap / lineH); }
}
}
if (!glyphs.length) return null;
const med = (a) => { const s = [...a].sort((p, q) => p - q); return s[Math.floor(s.length / 2)]; };
const capHeightPx = med(glyphs.map((x) => x.h));
const advance = med(glyphs.map((x) => x.w / x.h));
const weight = med(glyphs.map((x) => x.ink / (x.w * x.h)));
const gap = gapsAll.length ? med(gapsAll) : 0;
return { lines: lines.length, glyphs: glyphs.length, capHeightPx: +capHeightPx.toFixed(1), advance: +advance.toFixed(3), weight: +weight.toFixed(3), gap: +gap.toFixed(3), inkIsDark };
}
export function widthClass(advance) {
if (advance < 0.42) return 'compressed';
if (advance < 0.52) return 'condensed';
if (advance < 0.66) return 'normal';
return 'wide';
}
export function weightClass(weight) {
if (weight < 0.22) return 'light';
if (weight < 0.3) return 'regular';
if (weight < 0.38) return 'medium';
if (weight < 0.46) return 'bold';
return 'black';
}
export function distance(a, b) {
// width and weight both decide whether a face reads as the same face;
// tracking is a CSS knob (letter-spacing) so it counts least.
return Math.abs(a.advance - b.advance) * 2.5 + Math.abs(a.weight - b.weight) * 2.5 + Math.abs(a.gap - b.gap) * 0.5;
}
/**
* A starter shortlist per width class, Google Fonts only, chosen to span
* weight and character inside the class. The model adds its own names on
* top; the point is that a ranking never runs against one guessed family.
*/
export const SHORTLIST = {
compressed: ['League Gothic:400', 'Bebas Neue:400', 'Anton:400', 'Six Caps:400', 'Big Shoulders Display:900', 'Antonio:700', 'Saira Extra Condensed:800', 'Oswald:700'],
condensed: ['League Gothic:400', 'Fjalla One:400', 'Anton:400', 'Bebas Neue:400', 'Oswald:600', 'Barlow Condensed:700', 'Roboto Condensed:800', 'Archivo Narrow:700', 'Pathway Gothic One:400', 'Big Shoulders Display:800', 'Teko:600', 'Sofia Sans Condensed:800'],
normal: ['Inter:700', 'Work Sans:700', 'IBM Plex Sans:700', 'Archivo:800', 'Public Sans:700', 'Source Sans 3:700', 'Roboto:900', 'Barlow:800', 'Manrope:800', 'Rubik:800'],
wide: ['Archivo Black:400', 'Syne:800', 'Space Grotesk:700', 'Unbounded:700', 'Bricolage Grotesque:800', 'Sora:800', 'Outfit:800', 'Lexend:800'],
};
/** Weight-shifted variants of a candidate list toward the comp's weight class. */
export function withWeightVariants(list, targetWeight) {
const out = [];
for (const c of list) {
out.push(c);
const m = /^(.*?):(\d{3})$/.exec(c);
if (!m) continue;
const w = parseInt(m[2], 10);
// always offer one step lighter and one heavier; the ranking decides
for (const d of [-200, 200]) { const nw = w + d; if (nw >= 100 && nw <= 900) out.push(`${m[1]}:${nw}`); }
}
return [...new Set(out)];
}
// ---- browser --------------------------------------------------------------
async function loadBrowser() {
const tries = [
() => require('playwright'),
() => require(require.resolve('playwright', { paths: [process.cwd()] })),
() => require(require.resolve('playwright', { paths: [path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')] })),
];
for (const t of tries) { try { const pw = t(); if (pw?.chromium) return { kind: 'playwright', mod: pw }; } catch { /* next */ } }
const tries2 = [
() => require('puppeteer'),
() => require(require.resolve('puppeteer', { paths: [process.cwd()] })),
() => require(require.resolve('puppeteer', { paths: [path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')] })),
];
for (const t of tries2) { try { const pp = t(); if (pp?.launch) return { kind: 'puppeteer', mod: pp }; } catch { /* next */ } }
return null;
}
function parseCandidates(s) {
return String(s || '').split(',').map((x) => x.trim()).filter(Boolean).map((x) => {
const m = /^(.*?)(?::(\d{3}))?$/.exec(x);
return { family: m[1].trim(), weight: m[2] ? parseInt(m[2], 10) : 400 };
});
}
/** Render `text` in each candidate at a font-size whose measured cap height ~= targetCapPx; return fingerprints. */
export async function renderCandidates(candidates, text, targetCapPx, { transform = 'none' } = {}) {
const b = await loadBrowser();
if (!b) return null;
// One stylesheet per family+weight: a combined request 400s when any one
// family lacks the requested axis (Anton has no wght range), and a static
// family answers only for the weights it ships.
const links = candidates.map((c) => `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(c.family).replace(/%20/g, '+')}:wght@${c.weight}&display=block">`).join('');
const html = `<!doctype html><html><head><meta charset="utf-8">${links}<style>body{margin:0;background:#fff}div.s{position:absolute;left:0;top:0;white-space:nowrap;color:#000;line-height:1;padding:8px;text-transform:${transform}}</style></head><body></body></html>`;
const results = [];
const size0 = Math.max(12, Math.round(targetCapPx * 1.4));
if (b.kind === 'playwright') {
const browser = await b.mod.chromium.launch();
const page = await browser.newPage({ viewport: { width: 1600, height: 400 }, deviceScaleFactor: 1 });
await page.setContent(html, { waitUntil: 'load' });
await page.waitForTimeout(800);
for (const c of candidates) {
// two passes: measure at size0, then rescale so the fingerprint's cap height matches the comp
let size = size0, fp = null, ok = true;
for (let pass = 0; pass < 2; pass++) {
await page.evaluate(({ family, weight, size, text }) => {
document.body.innerHTML = `<div class="s" style="font-family:'${family}',sans-serif;font-weight:${weight};font-size:${size}px">${text}</div>`;
}, { family: c.family, weight: c.weight, size, text });
let loaded = false;
// Loaded means a real face of this family covers the requested weight;
// fonts.check() answers true for a synthetic bold of a lighter file.
try {
loaded = await page.evaluate(async (f) => {
const faces = await document.fonts.load(`${f.weight} 32px '${f.family}'`);
await document.fonts.ready;
const covers = (face) => { const w = String(face.weight || '400').split(/\s+/).map(Number); const lo = w[0], hi = w[1] ?? w[0]; return f.weight >= lo - 50 && f.weight <= hi + 50; };
return faces.some((face) => face.family.replace(/["']/g, '') === f.family && face.status === 'loaded' && covers(face));
}, c);
} catch { loaded = false; }
await page.waitForTimeout(100);
if (!loaded) ok = false;
const box = await page.evaluate(() => { const r = document.querySelector('div.s').getBoundingClientRect(); return { w: Math.ceil(r.width) + 8, h: Math.ceil(r.height) + 8 }; });
const buf = await page.screenshot({ clip: { x: 0, y: 0, width: Math.min(1600, box.w), height: Math.min(400, box.h) } });
fp = fingerprint(decodePng(buf));
if (!fp || pass === 1) break;
size = Math.max(8, Math.round(size * (targetCapPx / fp.capHeightPx)));
}
results.push({ ...c, loaded: ok, fontSizePx: size, fp });
}
await browser.close();
} else {
const browser = await b.mod.launch({ headless: true });
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 400 });
await page.setContent(html, { waitUntil: 'load' });
await new Promise((r) => setTimeout(r, 800));
for (const c of candidates) {
let size = size0, fp = null, ok = true;
for (let pass = 0; pass < 2; pass++) {
await page.evaluate(({ family, weight, size, text }) => {
document.body.innerHTML = `<div class="s" style="font-family:'${family}',sans-serif;font-weight:${weight};font-size:${size}px">${text}</div>`;
}, { family: c.family, weight: c.weight, size, text });
let loaded = false;
// Loaded means a real face of this family covers the requested weight;
// fonts.check() answers true for a synthetic bold of a lighter file.
try {
loaded = await page.evaluate(async (f) => {
const faces = await document.fonts.load(`${f.weight} 32px '${f.family}'`);
await document.fonts.ready;
const covers = (face) => { const w = String(face.weight || '400').split(/\s+/).map(Number); const lo = w[0], hi = w[1] ?? w[0]; return f.weight >= lo - 50 && f.weight <= hi + 50; };
return faces.some((face) => face.family.replace(/["']/g, '') === f.family && face.status === 'loaded' && covers(face));
}, c);
} catch { loaded = false; }
await new Promise((r) => setTimeout(r, 100));
if (!loaded) ok = false;
const box = await page.evaluate(() => { const r = document.querySelector('div.s').getBoundingClientRect(); return { w: Math.ceil(r.width) + 8, h: Math.ceil(r.height) + 8 }; });
const buf = await page.screenshot({ clip: { x: 0, y: 0, width: Math.min(1600, box.w), height: Math.min(400, box.h) } });
fp = fingerprint(decodePng(buf));
if (!fp || pass === 1) break;
size = Math.max(8, Math.round(size * (targetCapPx / fp.capHeightPx)));
}
results.push({ ...c, loaded: ok, fontSizePx: size, fp });
}
await browser.close();
}
return results;
}
/** Comp crop over the top candidates, rendered at the comp's cap height, as one PNG. */
export async function renderProofSheet(compCrop, top, text, capPx, transform = 'none') {
const b = await loadBrowser();
if (!b || b.kind !== 'playwright') return null;
const compB64 = Buffer.from(encodePng(compCrop)).toString('base64');
const links = top.map((c) => `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(c.family).replace(/%20/g, '+')}:wght@${c.weight}&display=block">`).join('');
const rowsHtml = top.map((c) => `<div class="row"><div class="lab">${c.family} ${c.weight} · ${c.fontSizePx}px</div><div class="s" style="font-family:'${c.family}';font-weight:${c.weight};font-size:${c.fontSizePx}px;text-transform:${transform}">${text}</div></div>`).join('');
const html = `<!doctype html><html><head><meta charset="utf-8">${links}<style>body{margin:0;background:#fff;padding:12px;font-family:system-ui}img{display:block;max-width:100%}.lab{font:12px system-ui;color:#666;margin:10px 0 2px}.s{white-space:nowrap;line-height:1.05;color:#111}</style></head><body><div class="lab">COMP</div><img src="data:image/png;base64,${compB64}">${rowsHtml}</body></html>`;
const browser = await b.mod.chromium.launch();
const page = await browser.newPage({ viewport: { width: Math.min(1600, Math.max(600, compCrop.width + 24)), height: 200 } });
await page.setContent(html, { waitUntil: 'load' });
try { await page.evaluate(async () => { await document.fonts.ready; }); } catch { /* ignore */ }
await page.waitForTimeout(600);
const buf = await page.screenshot({ fullPage: true });
await browser.close();
return buf;
}
// ---- CLI ------------------------------------------------------------------
function describe(fp) {
return `capHeight ${fp.capHeightPx}px, width ${widthClass(fp.advance)} (advance ${fp.advance}), weight ${weightClass(fp.weight)} (ink ${fp.weight}), tracking ${fp.gap}`;
}
async function main() {
const specPath = arg('spec', SPEC_PATH);
const spec = loadSpec(specPath);
const measureId = arg('measure'), rankId = arg('rank');
const id = measureId || rankId;
if (!id) {
console.error('usage: font-match.mjs --measure <text-region-id> | --rank <text-region-id> --candidates "Family:700,Family2:400,..." [--text "..."] [--transform uppercase]');
process.exit(1);
}
if (!spec) { console.error(`font-match: no spec at ${specPath}; run comp-spec.mjs first`); process.exit(1); }
const region = spec.regions.find((r) => r.id === id);
if (!region) { console.error(`font-match: no region ${id}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); }
const comp = decodePng(fs.readFileSync(spec.comp));
const c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h);
const fp = fingerprint(c);
if (!fp) {
// Record the attempt so the spec gate does not ask again; a region with
// no separable glyphs (a rule, a bar of solid ink, a very small label at
// comp resolution) is measured as "no lettering" and the model sizes it
// by its box.
region.type = { ...(region.type || {}), comp: null, measuredAt: new Date().toISOString(), note: 'no separable lettering in the crop; size by the region box' };
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
console.log(`MEASURE ${id}: no separable lettering in the region crop at comp resolution; size this text by its box (${region.px.w}x${region.px.h}px) and inherit face and weight from the nearest measured region.`);
process.exit(0);
}
region.type = { ...(region.type || {}), comp: fp, widthClass: widthClass(fp.advance), weightClass: weightClass(fp.weight) };
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
console.log(`MEASURE ${id}: ${describe(fp)} over ${fp.lines} line${fp.lines === 1 ? '' : 's'}, ${fp.glyphs} glyphs. Set this region's font-size so its cap height renders at ${fp.capHeightPx}px; choose a ${widthClass(fp.advance)} ${weightClass(fp.weight)} face.`);
if (!rankId) return;
const own = parseCandidates(arg('candidates'));
const wc = widthClass(fp.advance);
const short = withWeightVariants(SHORTLIST[wc] || SHORTLIST.normal, fp.weight).map((x) => parseCandidates(x)[0]);
const seen = new Set();
const candidates = [...own, ...short].filter((c) => { const k = `${c.family}:${c.weight}`; if (seen.has(k)) return false; seen.add(k); return true; });
console.log(`CANDIDATES ${candidates.length}: ${own.length} yours + ${candidates.length - own.length} from the ${wc} shortlist`);
const text = arg('text') || region.text || 'The manuals stop. The forum keeps going.';
const transform = arg('transform', 'none');
const results = await renderCandidates(candidates, text, fp.capHeightPx, { transform });
if (!results) {
console.log('RANK unavailable: no browser (playwright or puppeteer) resolvable from this project or the impeccable CLI. Choose by the MEASURE line: match the width class first, then the weight class; render one headline word against the comp before building on it.');
return;
}
// Drop faces that never loaded (a weight the family does not ship falls
// back to a system face and would rank as that face), then collapse
// duplicate renders (two requested weights that resolved to one file).
const seenFp = new Set();
const rows = results
.filter((r) => r.fp && r.loaded)
.map((r) => ({ ...r, d: distance(fp, r.fp) }))
.sort((a, b) => a.d - b.d)
.filter((r) => { const k = `${r.family}|${r.fp.advance}|${r.fp.weight}|${r.fp.gap}`; if (seenFp.has(k)) return false; seenFp.add(k); return true; });
const dropped = results.filter((r) => !r.loaded).map((r) => `${r.family}:${r.weight}`);
if (dropped.length) console.log(`SKIPPED (not available at that weight on Google Fonts): ${dropped.join(', ')}`);
for (const r of rows) {
const dw = r.fp.advance - fp.advance, dwt = r.fp.weight - fp.weight;
console.log(`RANK ${r.family}:${r.weight}${r.loaded ? '' : ' (NOT LOADED, fallback measured)'} distance ${r.d.toFixed(3)} width ${widthClass(r.fp.advance)} (${dw >= 0 ? '+' : ''}${(dw * 100).toFixed(0)}% advance) weight ${weightClass(r.fp.weight)} (${dwt >= 0 ? '+' : ''}${(dwt * 100).toFixed(0)}% ink) font-size ${r.fontSizePx}px for cap ${fp.capHeightPx}px`);
}
// proof sheet: comp crop over the top three renders, so the choice is seen, not only scored
try {
const top = rows.slice(0, 3);
const sheet = await renderProofSheet(c, top, text, fp.capHeightPx, transform);
if (sheet) {
const out = path.join(path.dirname(specPath), 'font-match', `${id}.png`);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, sheet);
console.log(`PROOF ${out} (comp crop, then the top ${top.length} candidates at the comp's cap height; open it before choosing)`);
}
} catch { /* proof sheet is best-effort */ }
const best = rows[0];
if (best) {
const advice = [];
if (Math.abs(best.fp.advance - fp.advance) > 0.06) advice.push(best.fp.advance > fp.advance ? 'still too wide: try a more condensed face or a variable font with a wdth axis' : 'still too narrow: try a wider face');
if (Math.abs(best.fp.weight - fp.weight) > 0.06) advice.push(best.fp.weight > fp.weight ? `too heavy: drop to weight ${Math.max(100, best.weight - 200)}` : `too light: raise to weight ${Math.min(900, best.weight + 200)}`);
console.log(`USE font-family: '${best.family}'; font-weight: ${best.weight}; font-size: ${best.fontSizePx}px;${advice.length ? ' NOTE ' + advice.join('; ') : ''}`);
region.type.chosen = { family: best.family, weight: best.weight, fontSizePx: best.fontSizePx, fp: best.fp };
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
}
}
const isMain = (() => {
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
catch { return false; }
})();
if (isMain) main().catch((e) => { console.error(`font-match: ${e.message}`); process.exit(1); });