mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
font-match v2: fingerprint the comp lettering and pick candidates from a Google Fonts catalog index
lib/font-fingerprint.mjs replaces the three-number fingerprint with size-invariant shape features (x-height ratio, stroke contrast, stem width, run-length quantiles, roundness, serif signal, width spread) and a noise-normalized distance; family recall on a held-out self-test rose from 13% to 72% top-5. data/font-index.json carries the whole Google Fonts catalog (3,092 faces at two cap sizes, 707 KB); font-match --rank fingerprints the comp crop, takes the 25 nearest faces from the index (plus the model's own names), renders them at the comp's cap height, ranks by the same distance, and prints a proof sheet and the CSS to use. scripts/build-font- index.mjs rebuilds the index at release time. AI-assisted (Claude). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
Abdul Wahab
co-authored by
Claude
parent
cacb2868ac
commit
95294e464a
@@ -50,6 +50,14 @@ One raster region end to end: crop the comp region, send the crop as the edits-e
|
||||
|
||||
The asset producer agent's job shrinks to: produce the spec's plates, one line per plate, `blockers`, `assumptions`. No inventory of its own (the spec is the inventory), no strategy taxonomy.
|
||||
|
||||
### 4b. Type: `font-match.mjs` and the catalog fingerprint index
|
||||
|
||||
Faces used to be chosen by name, and the first-round misses said so: headline wider and lighter than the comp, footer heavier. `font-match.mjs --measure <region>` fingerprints the comp crop with `lib/font-fingerprint.mjs`: per-line, size-invariant shape features (glyph width and x-height against the reference height, stem width, stroke contrast, serif ratio, ink density, vertical ink profile, run-length quantiles), all normalized so the same face gives the same numbers at any point size and on different text. The MEASURE line prints cap height, width class, weight class, and tracking, and the spec keeps the summary on the region.
|
||||
|
||||
`--rank <region>` no longer starts from a hand-written shortlist. `skill/scripts/data/font-index.json` holds the same fingerprint for ~3,100 Google Fonts faces (every latin family at 300 / 400 / 700 where shipped) rendered at two cap heights, 48px and 14px, because the features hold within a factor of two in size but not across that span; a crop under 22px cap queries the 14px index. The 25 nearest faces by a noise-normalized weighted distance (fitted on 299 held-out probes with different text; 42% top-1 and 72% top-5 family recall at ~30px cap, 52 / 71 at 14px) become the candidates, together with whatever names the model passes in. Those are then rendered with the region's own text at the comp's cap height, fingerprinted again, and ranked by the same distance, so the CATALOG line is the index's guess and the RANK lines are measured on the actual words. Below 10px cap the script says to size by the box and stops. The index is ~700 KB, packed base-36, rebuilt at release time by `scripts/build-font-index.mjs` (network + Playwright); the per-width-class shortlist stays only as the fallback when the index file is missing.
|
||||
|
||||
On the moto comp: headline (72px cap, condensed heavy mixed case) ranks League Gothic first with Karantina and Medula One behind it, where the old width/weight formula gave Anton SC and BBH Bogle; for the subhead the index puts Akshar 300 and Reddit Sans Condensed 300 on top, credible condensed light faces where before the class was wrong altogether.
|
||||
|
||||
### 5. Two detector rules
|
||||
|
||||
- `organic-clip-path`: `clip-path: polygon()` with 10+ off-grid vertices, or `clip-path: path()` with 3+ curve segments. Geometric clips (cut corners, diagonals, hexagons, arrows) pass; `circle()`/`inset()` pass.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-font-index: rebuild skill/scripts/data/font-index.json, the fingerprint
|
||||
* index of the Google Fonts catalog that `font-match.mjs --rank` uses as its
|
||||
* candidate generator.
|
||||
*
|
||||
* This is a RELEASE-TIME step, not part of `bun run build`. It needs the
|
||||
* network (Google Fonts metadata + CSS) and Playwright Chromium, renders every
|
||||
* latin family at up to three weights (300 / 400 / 700, whichever the family
|
||||
* ships) at two cap heights (48px and 14px), fingerprints each render with
|
||||
* skill/scripts/lib/font-fingerprint.mjs, and packs the vectors with
|
||||
* skill/scripts/lib/font-index.mjs. A full run is ~6,000 renders and takes
|
||||
* 20-40 minutes. Rerun it when the fingerprint's FEATURES or STATS change
|
||||
* (the index stores only the features the fitted distance weights) or when
|
||||
* the catalog has moved on enough to matter; commit the regenerated JSON.
|
||||
*
|
||||
* node scripts/build-font-index.mjs [--out skill/scripts/data/font-index.json]
|
||||
* [--sample N] # first N families only (tests, smoke)
|
||||
* [--families "Inter,Oswald"]
|
||||
* [--metadata path] # cached fonts.google.com/metadata/fonts JSON
|
||||
* [--resume] # keep entries already in --out
|
||||
*
|
||||
* One-time setup: `npx playwright install chromium`.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fingerprint } from '../skill/scripts/lib/font-fingerprint.mjs';
|
||||
import { decodePng } from '../skill/scripts/lib/png.mjs';
|
||||
import { INDEX_PATH, INDEX_SIZES, INDEX_FEATURES, CATEGORIES, packVector } from '../skill/scripts/lib/font-index.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/** Text every catalog face is rendered with; covers caps, x-height letters, ascenders, descenders, digits. */
|
||||
export const INDEX_TEXT = 'The quick brown fox jumps over the lazy dog 0123456789 HAMBURGEVONS';
|
||||
export const INDEX_WEIGHTS = [300, 400, 700];
|
||||
const METADATA_URL = 'https://fonts.google.com/metadata/fonts';
|
||||
const CATEGORY_MAP = { 'Sans Serif': 'sans', Serif: 'serif', Display: 'display', Handwriting: 'handwriting', Monospace: 'mono' };
|
||||
|
||||
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;
|
||||
}
|
||||
const has = (name) => process.argv.includes(`--${name}`);
|
||||
|
||||
/** Catalog entries [{ family, weight, category, variable }] from the Google Fonts metadata document. */
|
||||
export function entriesFromMetadata(meta, { weights = INDEX_WEIGHTS } = {}) {
|
||||
const list = (meta.familyMetadataList || []).filter((x) => (x.subsets || []).includes('latin'));
|
||||
const out = [];
|
||||
for (const x of list) {
|
||||
const ax = (x.axes || []).find((a) => a.tag === 'wght');
|
||||
const ks = Object.keys(x.fonts || {}).filter((k) => !k.endsWith('i')).map(Number);
|
||||
const ws = weights.filter((t) => (ax ? t >= ax.min && t <= ax.max : ks.includes(t)));
|
||||
for (const w of ws) out.push({ family: x.family, weight: w, category: CATEGORY_MAP[x.category] || String(x.category || '').toLowerCase(), variable: !!ax });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Serialize decoded entries into the on-disk shape (see lib/font-index.mjs). */
|
||||
export function serializeIndex(entries, { text = INDEX_TEXT, sizes = INDEX_SIZES } = {}) {
|
||||
const rows = entries.map((e) => [e.family, e.weight, Math.max(0, CATEGORIES.indexOf(e.category)), e.variable ? 1 : 0, ...sizes.map((sz) => (e.fp?.[sz] ? packVector(e.fp[sz]) : null))]);
|
||||
return JSON.stringify({ schema: 1, text, sizes, features: INDEX_FEATURES, categories: CATEGORIES, entries: rows });
|
||||
}
|
||||
|
||||
const gfHref = (f, w) => `https://fonts.googleapis.com/css2?family=${encodeURIComponent(f).replace(/%20/g, '+')}:wght@${w}&display=block`;
|
||||
|
||||
/** Render one face at a target cap height on an open page and return its fingerprint (or null when the face did not load). */
|
||||
async function fingerprintFace(page, e, targetCap, text) {
|
||||
let size = Math.round(targetCap * 1.4), fp = null;
|
||||
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: e.family, weight: e.weight, size, text });
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await Promise.race([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));
|
||||
}, e), new Promise((r) => setTimeout(() => r(false), 8000))]);
|
||||
} catch { loaded = false; }
|
||||
if (!loaded) return null;
|
||||
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(3000, box.w), height: Math.min(300, box.h) } });
|
||||
fp = fingerprint(decodePng(buf));
|
||||
if (!fp || pass === 1) break;
|
||||
size = Math.max(6, Math.round(size * (targetCap / fp.capHeightPx)));
|
||||
}
|
||||
return fp;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const out = path.resolve(ROOT, arg('out', INDEX_PATH));
|
||||
const sample = arg('sample') ? parseInt(arg('sample'), 10) : null;
|
||||
const only = arg('families') ? new Set(arg('families').split(',').map((s) => s.trim())) : null;
|
||||
const metaPath = arg('metadata');
|
||||
const meta = metaPath ? JSON.parse(fs.readFileSync(metaPath, 'utf8')) : await (await fetch(METADATA_URL)).json();
|
||||
let entries = entriesFromMetadata(meta);
|
||||
if (only) entries = entries.filter((e) => only.has(e.family));
|
||||
if (sample) { const fams = [...new Set(entries.map((e) => e.family))].slice(0, sample); const keep = new Set(fams); entries = entries.filter((e) => keep.has(e.family)); }
|
||||
const done = new Map();
|
||||
if (has('resume') && fs.existsSync(out)) {
|
||||
const { loadFontIndex } = await import('../skill/scripts/lib/font-index.mjs');
|
||||
for (const e of loadFontIndex(out).entries) done.set(`${e.family}:${e.weight}`, e);
|
||||
}
|
||||
const pw = require('playwright');
|
||||
const browser = await pw.chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 3000, height: 300 }, deviceScaleFactor: 1 });
|
||||
const results = [...done.values()];
|
||||
const failures = [];
|
||||
const byFam = new Map();
|
||||
for (const e of entries) { if (done.has(`${e.family}:${e.weight}`)) continue; if (!byFam.has(e.family)) byFam.set(e.family, []); byFam.get(e.family).push(e); }
|
||||
const fams = [...byFam.keys()];
|
||||
const t0 = Date.now(); let n = 0;
|
||||
for (let i = 0; i < fams.length; i += 20) {
|
||||
const todo = fams.slice(i, i + 20).flatMap((f) => byFam.get(f));
|
||||
const links = todo.map((e) => `<link rel="stylesheet" href="${gfHref(e.family, e.weight)}">`).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}</style></head><body></body></html>`;
|
||||
try { await page.setContent(html, { waitUntil: 'load', timeout: 30000 }); } catch (err) { console.error(`batch at ${i}: ${err.message}`); }
|
||||
for (const e of todo) {
|
||||
n++;
|
||||
try {
|
||||
const fp = {};
|
||||
for (const sz of INDEX_SIZES) fp[sz] = await fingerprintFace(page, e, sz, INDEX_TEXT);
|
||||
if (!fp[INDEX_SIZES[0]]) { failures.push({ ...e, reason: 'not loaded or no lettering' }); continue; }
|
||||
results.push({ ...e, fp });
|
||||
} catch (err) { failures.push({ ...e, reason: err.message.slice(0, 120) }); }
|
||||
}
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
fs.writeFileSync(out, serializeIndex(results));
|
||||
const el = (Date.now() - t0) / 1000;
|
||||
console.error(`${n}/${entries.length - done.size} rendered, ${results.length} indexed, ${failures.length} failed, ${el.toFixed(0)}s`);
|
||||
}
|
||||
await browser.close();
|
||||
fs.writeFileSync(out, serializeIndex(results));
|
||||
if (failures.length) fs.writeFileSync(out.replace(/\.json$/, '-failures.json'), JSON.stringify(failures, null, 1));
|
||||
console.error(`DONE ${results.length} faces -> ${out} (${(fs.statSync(out).size / 1024).toFixed(0)} KB), ${failures.length} failed`);
|
||||
}
|
||||
|
||||
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(`build-font-index: ${e.message}`); process.exit(1); });
|
||||
@@ -26,7 +26,7 @@ export const SUITES = {
|
||||
triggers: [
|
||||
...COMMON_INFRA_PATTERNS,
|
||||
/^scripts\/(?!benchmark-detector|build-browser-detector|build-extension)/,
|
||||
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|comp-diff|comp-spec|build-phase|font-match|concept-seed|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|png|raster|image-metrics|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|pin|surface-brief))/,
|
||||
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|comp-diff|comp-spec|build-phase|font-match|data\/font-index|concept-seed|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|png|raster|image-metrics|font-fingerprint|font-index|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|pin|surface-brief))/,
|
||||
/^README(\.npm)?\.md$/,
|
||||
/^cli\/bin\//,
|
||||
],
|
||||
@@ -56,6 +56,7 @@ export const SUITES = {
|
||||
'tests/concept-seed.test.mjs',
|
||||
'tests/comp-diff.test.mjs',
|
||||
'tests/build-phase.test.mjs',
|
||||
'tests/font-match.test.mjs',
|
||||
'tests/serve-question.test.mjs',
|
||||
'tests/context.test.mjs',
|
||||
'tests/context-signals.test.mjs',
|
||||
|
||||
@@ -101,7 +101,7 @@ When an approved comp exists, it is a spatial contract, not a mood board: only t
|
||||
Then, in order, each closed by `node {{scripts_path}}/build-phase.mjs advance` (every script below lives under `{{scripts_path}}/` and runs with `node`; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open):
|
||||
|
||||
0. **comps.** The comp round from [visualize.md](visualize.md): three compositional comps of the requested surface at its own viewport under `.impeccable/mocks/`, each with a prompt sidecar, put in front of the user; the chosen one's sidecar gets `"approved": true`. The gate counts them and reads the approval; a `start --comp` skips this phase because it already happened.
|
||||
1. **spec.** Measure the comp: `comp-spec.mjs --comp <comp> --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws), and run `comp-spec.mjs --comp <comp> --regions <file>`. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. Type is measured, not guessed: `font-match.mjs --measure <text region>` reads the comp's cap height, width class, and weight off the pixels, and `font-match.mjs --rank <region> --text "..."` renders candidate faces at that cap height and ranks them by width and weight distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); the spec gate refuses to close until the lead text region is measured and ranked. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user chose an icon library), and genuine defects in the comp such as spelling errors. <!-- rule:skill-comp-spec -->
|
||||
1. **spec.** Measure the comp: `comp-spec.mjs --comp <comp> --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws), and run `comp-spec.mjs --comp <comp> --regions <file>`. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. Type is measured, not guessed: `font-match.mjs --measure <text region>` reads the comp's cap height, width class, and weight off the pixels, and `font-match.mjs --rank <region> --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); the spec gate refuses to close until the lead text region is measured and ranked. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user chose an icon library), and genuine defects in the comp such as spelling errors. <!-- rule:skill-comp-spec -->
|
||||
2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (ink on flat ground is generated on a chroma key and keyed to alpha, so it sits on the page's own ground rather than a second paper); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `generate-image.mjs --plate <id>` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop <id>`) as its input image and `comp-spec.mjs --plate-prompt <id>` as its prompt, then `embed-prompt.mjs`. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. <!-- rule:skill-plates-before-page -->
|
||||
3. **hero.** Build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an `<img>`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `build-phase.mjs record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no region missing. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. <!-- rule:skill-hero-gate -->
|
||||
4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system.
|
||||
|
||||
@@ -62,8 +62,11 @@ const mean = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : null);
|
||||
/** Binarize; returns { W, H, ink: Uint8Array, inkIsDark }. */
|
||||
function binarize(img) {
|
||||
const g = toGray(img);
|
||||
const thr = otsu(g);
|
||||
let thr = otsu(g);
|
||||
let dark = 0; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++;
|
||||
// a two-level raster (no antialiasing) puts the Otsu threshold on the dark
|
||||
// level itself; step it up so that level counts as ink
|
||||
if (!dark) { thr += 1; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++; }
|
||||
const inkIsDark = dark <= g.data.length / 2;
|
||||
const ink = new Uint8Array(g.data.length);
|
||||
let sI = 0, nI = 0, sG = 0, nG = 0;
|
||||
@@ -319,15 +322,17 @@ function measure(bin, lines) {
|
||||
}
|
||||
|
||||
/**
|
||||
* fingerprint(img) -> features or null. Upsamples 2x (bilinear) when the
|
||||
* fingerprint(img) -> features, or null when no lettering is found. Upsamples (bilinear) when the
|
||||
* cap height is under 24px so runs and edges are measured on finer pixels.
|
||||
*/
|
||||
export function fingerprint(img, { minCap = 24 } = {}) {
|
||||
export function fingerprint(img, { minCap = 24, minGlyphs = 3 } = {}) {
|
||||
let bin = binarize(img);
|
||||
let { lines } = findLines(bin);
|
||||
if (!lines.length) return null;
|
||||
let f = measure(bin, lines);
|
||||
if (!f) return null;
|
||||
// fewer than minGlyphs separable glyphs is not lettering (a rule, a solid
|
||||
// bar, one letterform): callers read null as "no separable lettering"
|
||||
if (!f || f.glyphs < minGlyphs) return null;
|
||||
let scale = 1;
|
||||
if (f.capHeightPx < minCap && f.capHeightPx >= 4) {
|
||||
scale = Math.min(4, Math.ceil(minCap / f.capHeightPx));
|
||||
@@ -338,7 +343,7 @@ export function fingerprint(img, { minCap = 24 } = {}) {
|
||||
if (f2) f = f2;
|
||||
else scale = 1;
|
||||
}
|
||||
const r = { lines: lines.length, glyphs: f.glyphs, capHeightPx: +(f.capHeightPx / scale).toFixed(1), inkIsDark: bin.inkIsDark, upsampled: scale > 1, allCaps: f.allCaps, weight: f.densTall ?? f.densX };
|
||||
const r = { lines: lines.length, glyphs: f.glyphs, capHeightPx: +(f.capHeightPx / scale).toFixed(1), inkIsDark: bin.inkIsDark, upsampled: scale > 1, allCaps: f.allCaps, weight: f.densTall == null && f.densX == null ? null : +(f.densTall ?? f.densX).toFixed(4) };
|
||||
for (const k of FEATURES) r[k] = f[k] == null ? null : +f[k].toFixed(4);
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createImage, drawText } from '../skill/scripts/lib/raster.mjs';
|
||||
import { fingerprint, distance, FEATURES, STATS } from '../skill/scripts/lib/font-fingerprint.mjs';
|
||||
import { loadFontIndex, candidatesFromIndex, routeSize, packVector, unpackVector, INDEX_PATH, INDEX_FEATURES, ROUTE_CAP_PX } from '../skill/scripts/lib/font-index.mjs';
|
||||
import { widthClass, weightClass, selectCandidates, SHORTLIST, renderCandidates } from '../skill/scripts/font-match.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const hasPlaywright = (() => { try { return !!require('playwright').chromium; } catch { return false; } })();
|
||||
|
||||
/** A white raster with a line of the bitmap font drawn on it, cap height 7 * scale px. */
|
||||
function textSample(text, scale = 6, { x = 20, y = 20 } = {}) {
|
||||
const w = text.length * 6 * scale + 40, h = 7 * scale + 40;
|
||||
const img = createImage(w, h, [255, 255, 255, 255]);
|
||||
drawText(img, text, x, y, [0, 0, 0, 255], scale);
|
||||
return img;
|
||||
}
|
||||
|
||||
describe('font-fingerprint', () => {
|
||||
it('fingerprints a synthetic rendered-text PNG with the expected fields', () => {
|
||||
const fp = fingerprint(textSample('HAMBURGEVONS THE QUICK BROWN FOX', 6));
|
||||
assert.ok(fp, 'fingerprint returned null');
|
||||
for (const k of ['lines', 'glyphs', 'capHeightPx', 'inkIsDark', 'allCaps', 'weight', ...FEATURES]) assert.ok(k in fp, `missing ${k}`);
|
||||
assert.equal(fp.lines, 1);
|
||||
assert.ok(fp.glyphs >= 20, `glyphs ${fp.glyphs}`);
|
||||
assert.ok(Math.abs(fp.capHeightPx - 42) <= 2, `capHeightPx ${fp.capHeightPx}`);
|
||||
assert.equal(fp.inkIsDark, true);
|
||||
assert.equal(typeof fp.allCaps, 'boolean');
|
||||
assert.ok(fp.advTall > 0.5 && fp.advTall < 0.9, `advTall ${fp.advTall}`);
|
||||
assert.ok(fp.densTall > 0.2 && fp.densTall < 0.9, `densTall ${fp.densTall}`);
|
||||
assert.ok(fp.stemW > 0, `stemW ${fp.stemW}`);
|
||||
});
|
||||
|
||||
it('is size-stable: the same text at 4x and 6x scale lands close, and a bolder sample farther', () => {
|
||||
const a = fingerprint(textSample('HAMBURGEVONS THE QUICK BROWN FOX', 6));
|
||||
const b = fingerprint(textSample('HAMBURGEVONS THE QUICK BROWN FOX', 4));
|
||||
assert.equal(distance(a, a), 0);
|
||||
const dSame = distance(a, b);
|
||||
assert.ok(dSame > 0 && Number.isFinite(dSame), `dSame ${dSame}`);
|
||||
// a different sample: same glyph set but drawn twice offset by one scale step, so every stem doubles (a heavier face)
|
||||
const heavy = textSample('HAMBURGEVONS THE QUICK BROWN FOX', 6);
|
||||
drawText(heavy, 'HAMBURGEVONS THE QUICK BROWN FOX', 20 + 4, 20, [0, 0, 0, 255], 6);
|
||||
const c = fingerprint(heavy);
|
||||
const dOther = distance(a, c);
|
||||
assert.ok(dOther > dSame, `heavier sample (${dOther}) should be farther than a rescale (${dSame})`);
|
||||
});
|
||||
|
||||
it('every weighted feature has a positive std, and distance skips features missing on either side', () => {
|
||||
for (const k of FEATURES) { assert.ok(STATS[k], `no STATS for ${k}`); assert.ok(STATS[k].std > 0); }
|
||||
const a = { advX: 0.5, densTall: 0.5, contrast: 1 }, b = { advX: 0.5, densTall: 0.5, contrast: null };
|
||||
assert.equal(distance(a, b), 0);
|
||||
assert.equal(distance({}, {}), Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('font-index', () => {
|
||||
it('packs and unpacks a vector to three decimals, nulls preserved', () => {
|
||||
const fp = { advX: 0.3649, densTall: 0.6719, contrast: null, serif: 12.3456 };
|
||||
const back = unpackVector(packVector(fp));
|
||||
assert.equal(back.advX, 0.365);
|
||||
assert.equal(back.densTall, 0.672);
|
||||
assert.equal(back.contrast, null);
|
||||
assert.equal(back.serif, 12.346);
|
||||
assert.equal(back.gap, null, 'absent feature reads as null');
|
||||
});
|
||||
|
||||
it('stores only the features the fitted distance weights', () => {
|
||||
assert.ok(INDEX_FEATURES.length >= 30);
|
||||
for (const k of INDEX_FEATURES) assert.ok(STATS[k].w > 0);
|
||||
for (const k of FEATURES) if (STATS[k].w === 0) assert.ok(!INDEX_FEATURES.includes(k), `${k} has zero weight and should not be indexed`);
|
||||
});
|
||||
|
||||
it('ships a two-size catalog index under 1 MB with > 2500 entries and the expected keys', () => {
|
||||
assert.ok(fs.existsSync(INDEX_PATH), `missing ${INDEX_PATH}`);
|
||||
assert.ok(fs.statSync(INDEX_PATH).size < 1024 * 1024, 'index over 1 MB');
|
||||
const index = loadFontIndex();
|
||||
assert.ok(index.entries.length > 2500, `entries ${index.entries.length}`);
|
||||
assert.deepEqual([...index.sizes].sort((a, b) => a - b), [14, 48]);
|
||||
assert.deepEqual(index.features, INDEX_FEATURES);
|
||||
for (const e of index.entries.slice(0, 50)) {
|
||||
for (const k of ['family', 'weight', 'category', 'variable', 'fp']) assert.ok(k in e, `entry missing ${k}`);
|
||||
assert.ok(['sans', 'serif', 'display', 'handwriting', 'mono'].includes(e.category), e.category);
|
||||
assert.ok(e.fp[48], 'no 48px vector');
|
||||
for (const k of INDEX_FEATURES) assert.ok(k in e.fp[48]);
|
||||
}
|
||||
const lg = index.entries.find((e) => e.family === 'League Gothic');
|
||||
assert.ok(lg && lg.fp[48] && lg.fp[14], 'League Gothic at both sizes');
|
||||
assert.ok(lg.fp[48].advX < 0.45, `League Gothic reads condensed: advX ${lg.fp[48].advX}`);
|
||||
const with14 = index.entries.filter((e) => e.fp[14]).length;
|
||||
assert.ok(with14 > 2500, `14px vectors ${with14}`);
|
||||
});
|
||||
|
||||
it('routes candidate selection by cap height and returns 25 entries', () => {
|
||||
const index = loadFontIndex();
|
||||
const lg = index.entries.find((e) => e.family === 'League Gothic');
|
||||
assert.equal(routeSize(72), 48);
|
||||
assert.equal(routeSize(ROUTE_CAP_PX - 1), 14);
|
||||
const big = candidatesFromIndex({ ...lg.fp[48], capHeightPx: 72 }, index, { n: 25 });
|
||||
assert.equal(big.length, 25);
|
||||
assert.equal(big[0].family, 'League Gothic');
|
||||
assert.equal(big[0].size, 48);
|
||||
const small = candidatesFromIndex({ ...lg.fp[14], capHeightPx: 14 }, index, { n: 25 });
|
||||
assert.equal(small.length, 25);
|
||||
assert.equal(small[0].family, 'League Gothic');
|
||||
assert.equal(small[0].size, 14);
|
||||
for (const c of big) for (const k of ['family', 'weight', 'category', 'variable', 'd', 'size']) assert.ok(k in c);
|
||||
const sans = candidatesFromIndex({ ...lg.fp[48], capHeightPx: 72 }, index, { n: 10, category: 'serif' });
|
||||
assert.equal(sans.length, 10);
|
||||
assert.ok(sans.every((c) => c.category === 'serif'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('font-match', () => {
|
||||
it('reads width and weight classes off the v2 features with catalog-anchored thresholds', () => {
|
||||
const index = loadFontIndex();
|
||||
const at = (family, weight) => index.entries.find((e) => e.family === family && e.weight === weight).fp[48];
|
||||
assert.equal(widthClass(at('League Gothic', 400)), 'compressed');
|
||||
assert.equal(widthClass(at('Oswald', 700)), 'condensed');
|
||||
assert.equal(widthClass(at('Inter', 400)), 'normal');
|
||||
assert.equal(widthClass(at('Archivo Black', 400)), 'wide');
|
||||
assert.equal(weightClass(at('Lato', 300)), 'light');
|
||||
assert.equal(weightClass(at('Inter', 400)), 'regular');
|
||||
assert.equal(weightClass(at('Roboto', 700)), 'bold');
|
||||
assert.equal(weightClass(at('Anton', 400)), 'black');
|
||||
// all-caps crop: falls through to advTall
|
||||
assert.equal(widthClass({ advX: null, advTall: 0.48 }), 'condensed');
|
||||
assert.equal(weightClass({ densTall: null, densX: null, stemW: 0.09 }), 'light');
|
||||
});
|
||||
|
||||
it('selects candidates from the index, keeps the caller names first, and uses the shortlist only without an index', () => {
|
||||
const index = loadFontIndex();
|
||||
const lg = { ...index.entries.find((e) => e.family === 'League Gothic').fp[48], capHeightPx: 72 };
|
||||
const own = [{ family: 'Bebas Neue', weight: 400 }];
|
||||
const r = selectCandidates(lg, { own, index, n: 25 });
|
||||
assert.equal(r.source, 'index');
|
||||
assert.equal(r.candidates[0].family, 'Bebas Neue');
|
||||
assert.equal(r.catalog.length, 25);
|
||||
assert.ok(r.candidates.length >= 25 && r.candidates.length <= 26);
|
||||
assert.ok(!r.candidates.some((c) => SHORTLIST.wide.includes(`${c.family}:${c.weight}`)), 'shortlist must not leak in when the index is present');
|
||||
const noIdx = selectCandidates(lg, { own, index: null });
|
||||
assert.equal(noIdx.source, 'shortlist');
|
||||
assert.ok(noIdx.candidates.some((c) => c.family === 'Six Caps'), 'compressed shortlist used');
|
||||
});
|
||||
|
||||
it('renders and ranks candidates against a League Gothic sample (browser)', { skip: !hasPlaywright && 'playwright not resolvable' }, async () => {
|
||||
const results = await renderCandidates([{ family: 'League Gothic', weight: 400 }, { family: 'Inter', weight: 400 }], 'The manuals stop.', 48);
|
||||
assert.ok(results, 'no browser');
|
||||
const ok = results.filter((r) => r.loaded && r.fp);
|
||||
if (ok.length < 2) return; // offline: Google Fonts unreachable
|
||||
const index = loadFontIndex();
|
||||
const lg = index.entries.find((e) => e.family === 'League Gothic').fp[48];
|
||||
const inter = index.entries.find((e) => e.family === 'Inter' && e.weight === 400).fp[48];
|
||||
const rLg = ok.find((r) => r.family === 'League Gothic'), rIn = ok.find((r) => r.family === 'Inter');
|
||||
assert.ok(distance(rLg.fp, lg) < distance(rLg.fp, inter), 'rendered League Gothic is nearer its own index entry');
|
||||
assert.ok(distance(rIn.fp, inter) < distance(rIn.fp, lg), 'rendered Inter is nearer its own index entry');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user