No comps outside the state: generate-image refuses .impeccable/mocks/ output while a roll is pending and build-phase has not started

The first paid confirmation sweep showed the failure: models rendered the
three comps first and ran build-phase.mjs start after, so a session cut at
the composition pick carried no state.json and the resumed model followed
the conversation ('translate the comp into HTML now') instead of the
phases. Decision comps (.impeccable/mocks/decision/) are unaffected;
--force-mock overrides.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-08-28 06:13:59 +05:00
committed by Abdul Wahab
co-authored by Claude
parent 62f59a3934
commit cacb2868ac
6 changed files with 685 additions and 147 deletions
+2
View File
@@ -6,6 +6,8 @@ A probe tests composition, narrative, hierarchy, density, focal moment, signatur
## Generate three compositional options
The comp round runs inside the build's phase state: `build-phase.mjs start --direction <seed key> --kind <...>` has already run (the roll's output names the command) and its `comps` phase is open before the first comp is generated; a comp rendered before that sits outside the state, and a session resumed from that point has no phases to follow. `generate-image.mjs` refuses to write under `.impeccable/mocks/` until start has run; a harness-native image tool is bound by the same order.
Render three distinct high-fidelity north-star comps of the requested surface, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything is built against it. Comps are the build thread's own work, never delegated: the thread that writes the prompts holds the direction's full context and has seen every comp when the build starts. Open every image by its workspace-relative path; sandboxed viewers reject absolute paths, and everything under the project root has a relative one. Base the comps on real content and the surface concepts already developed with the user. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`); the prompt leads with the new surface's structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page's own content does not, and a banner, hero, or card lifted verbatim is the reference leaking, not fidelity. Three is the number: one comp invites rubber-stamping; the spread between three surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this discipline, so generate two more that vary what the first held fixed, and send all three to the approval point together. Only a round arriving with no decision comp (a degraded roll, an identity-mode page, a direction pinned without the decision round) renders all three here.
- A comp is a designed surface, not a picture of the subject. Lead the prompt with the surface's own structure: the regions this design has, named in order with their scale relationships; a page with no navigation says so instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
File diff suppressed because one or more lines are too long
+137 -147
View File
@@ -4,22 +4,27 @@
* 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
* Fingerprints the comp crop of a text region (lib/font-fingerprint.mjs):
* cap height (px), glyph width per cap height (width class), stroke
* density and stem width (weight class), tracking, plus the size-invariant
* shape vector the ranking uses. Prints the summary and stores it 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
* node font-match.mjs --rank <region-id> [--candidates "Barlow Condensed:700,Oswald:600"] [--text "The manuals stop."] [--category sans,display]
* Candidates come from a fingerprint index of the Google Fonts catalog
* (data/font-index.json, ~3,000 faces at two cap heights; the crop is
* routed to the 14px or 48px index by its cap height): the 25 nearest
* faces by fingerprint distance, plus the names you pass. Each candidate
* is then rendered with the region's text at the comp's cap height in a
* headless browser (Google Fonts CSS), fingerprinted the same way, and
* ranked by the same distance on the rendered text. Prints CATALOG (the
* index's top five), the ranking with per-face width and weight deltas,
* a proof sheet, 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.
* one, the CATALOG line is the ranking. Without the index the built-in
* per-width-class shortlist stands in.
*
* 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
@@ -32,7 +37,8 @@ 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 { fingerprint, distance } from './lib/font-fingerprint.mjs';
import { loadFontIndex, candidatesFromIndex, MIN_RANK_CAP_PX } from './lib/font-index.mjs';
import { loadSpec, SPEC_PATH } from './comp-spec.mjs';
const require = createRequire(import.meta.url);
@@ -45,131 +51,69 @@ function arg(name, fallback = null) {
}
// ---- 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(img) and distance(a, b) live in lib/font-fingerprint.mjs: size-
// invariant shape features per text line (advance, x-ratio, stem width,
// contrast, serif, density, ink profiles) and a noise-normalized weighted L1
// fitted on held-out Google Fonts probes. The class helpers below turn two of
// those features into the words the MEASURE line prints.
/**
* 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.
* The feature that reads as width: advX (x-height glyph width / R) on a
* mixed-case crop, advTall (cap glyph width / R) when the crop is all caps.
* Thresholds sit on the catalog index (advX 0.20 quantile 0.58, median 0.64,
* 0.80 quantile 0.71) anchored by named faces: League Gothic 0.36, Oswald 0.42,
* Anton 0.48, Barlow Condensed 0.54, Roboto Condensed 0.58, Roboto 0.62,
* Inter 0.65, Space Grotesk 0.71, Montserrat Bold 0.76, Archivo Black 0.87.
*/
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 widthMeasure(fp) {
if (!fp) return null;
if (fp.advX != null) return { key: 'advX', value: fp.advX };
if (fp.advTall != null) return { key: 'advTall', value: fp.advTall };
if (fp.advance != null) return { key: 'advance', value: fp.advance };
return null;
}
export function widthClass(advance) {
if (advance < 0.42) return 'compressed';
if (advance < 0.52) return 'condensed';
if (advance < 0.66) return 'normal';
export function widthClass(fp) {
const m = typeof fp === 'number' ? { key: 'advX', value: fp } : widthMeasure(fp);
if (!m) return 'normal';
// cap widths run ~10% wider than x-height widths against the same R
const t = m.key === 'advTall' ? [0.45, 0.61, 0.78] : [0.42, 0.585, 0.72];
if (m.value < t[0]) return 'compressed';
if (m.value < t[1]) return 'condensed';
if (m.value < t[2]) 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';
/**
* The feature that reads as weight: densTall (ink / bbox area of cap-height
* glyphs); stemW (stem width / R) when no cap glyph was separable. Catalog
* anchors for densTall: Lato 300 0.27, Roboto 300 0.32, Playfair 400 0.37,
* Inter 400 0.44, Roboto 700 0.59, Work Sans 700 0.64, Bebas Neue 0.68,
* Oswald 700 0.72, League Gothic 0.76, Anton 0.79. For stemW: Roboto 300 0.10,
* Roboto 400 0.14, Inter 700 0.22, Archivo Black 0.30.
*/
export function weightMeasure(fp) {
if (!fp) return null;
if (fp.densTall != null) return { key: 'densTall', value: fp.densTall };
if (fp.densX != null) return { key: 'densX', value: fp.densX };
if (fp.stemW != null) return { key: 'stemW', value: fp.stemW };
if (fp.weight != null) return { key: 'weight', value: fp.weight };
return null;
}
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;
export function weightClass(fp) {
const m = typeof fp === 'number' ? { key: 'densTall', value: fp } : weightMeasure(fp);
if (!m) return 'regular';
const t = m.key === 'stemW' ? [0.105, 0.165, 0.195, 0.24] : [0.34, 0.48, 0.56, 0.66];
if (m.value < t[0]) return 'light';
if (m.value < t[1]) return 'regular';
if (m.value < t[2]) return 'medium';
if (m.value < t[3]) return 'bold';
return 'black';
}
/**
* 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.
* weight and character inside the class. Used only when the catalog index
* (data/font-index.json) is missing; with the index, candidates come from
* the comp's fingerprint and the model's own names.
*/
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'],
@@ -178,20 +122,39 @@ export const SHORTLIST = {
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) {
/** Weight-shifted variants of a candidate list, one step lighter and heavier; the ranking decides. */
export function withWeightVariants(list) {
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)];
}
/**
* Candidate faces for a comp fingerprint: the nearest index faces (top n by
* fingerprint distance, routed to the 14px or 48px index by the crop's cap
* height, optionally filtered by category), the caller's own names first,
* and the built-in shortlist only when there is no index. Returns
* { candidates: [{ family, weight }], catalog: [index hits], source }.
*/
export function selectCandidates(fp, { own = [], index = null, n = 25, category = null } = {}) {
const catalog = index ? candidatesFromIndex(fp, index, { n, category }) : [];
const list = [...own, ...catalog.map((c) => ({ family: c.family, weight: c.weight }))];
let source = 'index';
if (!index) {
source = 'shortlist';
for (const s of withWeightVariants(SHORTLIST[widthClass(fp)] || SHORTLIST.normal)) list.push(parseCandidates(s)[0]);
}
const seen = new Set();
const candidates = list.filter((c) => { const k = `${c.family}:${c.weight}`; if (seen.has(k)) return false; seen.add(k); return true; });
return { candidates, catalog, source };
}
// ---- browser --------------------------------------------------------------
async function loadBrowser() {
@@ -321,7 +284,19 @@ export async function renderProofSheet(compCrop, top, text, capPx, transform = '
// ---- 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}`;
const wm = widthMeasure(fp), wt = weightMeasure(fp);
const wmS = wm ? ` (${wm.key} ${wm.value})` : '';
const wtS = wt ? ` (${wt.key} ${wt.value})` : '';
return `capHeight ${fp.capHeightPx}px, width ${widthClass(fp)}${wmS}, weight ${weightClass(fp)}${wtS}, tracking ${fp.gap}${fp.allCaps ? ', all caps' : ''}`;
}
/** Fingerprint fields the spec keeps for a region: the class-bearing features plus the shape summary, not the whole vector. */
function compactFp(fp) {
if (!fp) return fp;
const keep = ['lines', 'glyphs', 'capHeightPx', 'inkIsDark', 'allCaps', 'advance', 'advTall', 'advX', 'gap', 'xRatio', 'stemW', 'contrast', 'serif', 'densTall', 'densX', 'weight'];
const out = {};
for (const k of keep) if (fp[k] !== undefined) out[k] = fp[k];
return out;
}
async function main() {
@@ -330,7 +305,7 @@ async function main() {
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]');
console.error('usage: font-match.mjs --measure <text-region-id> | --rank <text-region-id> [--candidates "Family:700,Family2:400,..."] [--text "..."] [--transform uppercase] [--category sans,serif,display,handwriting,mono]');
process.exit(1);
}
if (!spec) { console.error(`font-match: no spec at ${specPath}; run comp-spec.mjs first`); process.exit(1); }
@@ -349,21 +324,29 @@ async function main() {
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) };
region.type = { ...(region.type || {}), comp: compactFp(fp), widthClass: widthClass(fp), weightClass: weightClass(fp) };
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.`);
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)} ${weightClass(fp)} face.`);
if (!rankId) return;
if (fp.capHeightPx < MIN_RANK_CAP_PX) {
console.log(`RANK skipped: cap height ${fp.capHeightPx}px is under ${MIN_RANK_CAP_PX}px, too small at comp resolution for a face fingerprint to mean anything. Size this text by its box (${region.px.w}x${region.px.h}px) and inherit face and weight from the nearest measured region.`);
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 index = loadFontIndex();
const { candidates, catalog, source } = selectCandidates(fp, { own, index, n: 25, category: arg('category') });
if (index) {
const top5 = []; for (const h of catalog) { if (!top5.some((t) => t.family === h.family)) top5.push(h); if (top5.length >= 5) break; }
console.log(`CATALOG top-5 by fingerprint: ${top5.map((t) => `${t.family}:${t.weight}`).join(', ')} (from ${index.entries.length} indexed faces${catalog[0] ? `, ${catalog[0].size}px index` : ''}${arg('category') ? `, category ${arg('category')}` : ''})`);
console.log(`CANDIDATES ${candidates.length}: ${own.length} yours + ${candidates.length - own.length} nearest in the catalog index`);
} else {
console.log(`CANDIDATES ${candidates.length}: ${own.length} yours + ${candidates.length - own.length} from the ${widthClass(fp)} shortlist (no catalog index at data/font-index.json)`);
}
const text = arg('text') || region.text || 'The manuals stop. The forum keeps going.';
const transform = arg('transform', 'none');
const transform = arg('transform', fp.allCaps ? 'uppercase' : '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.');
console.log(`RANK unavailable: no browser (playwright or puppeteer) resolvable from this project or the impeccable CLI. ${index ? 'Take the CATALOG line as the ranking' : '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
@@ -373,13 +356,16 @@ async function main() {
const rows = results
.filter((r) => r.fp && r.loaded)
.map((r) => ({ ...r, d: distance(fp, r.fp) }))
.filter((r) => Number.isFinite(r.d))
.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; });
.filter((r) => { const k = `${r.family}|${r.fp.advX}|${r.fp.advTall}|${r.fp.densTall}|${r.fp.stemW}`; 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(', ')}`);
const wm = widthMeasure(fp), wt = weightMeasure(fp);
const pctDelta = (m, other) => { if (!m || other?.[m.key] == null) return null; return (other[m.key] - m.value) / m.value; };
const fmtPct = (v) => (v == null ? 'n/a' : `${v >= 0 ? '+' : ''}${(v * 100).toFixed(0)}%`);
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`);
console.log(`RANK ${r.family}:${r.weight} distance ${r.d.toFixed(3)} width ${widthClass(r.fp)} (${fmtPct(pctDelta(wm, r.fp))} ${wm?.key || 'advance'}) weight ${weightClass(r.fp)} (${fmtPct(pctDelta(wt, r.fp))} ${wt?.key || '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 {
@@ -395,10 +381,14 @@ async function main() {
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 };
const dw = pctDelta(wm, best.fp), dwt = pctDelta(wt, best.fp);
if (dw != null && Math.abs(dw) > 0.1) advice.push(dw > 0 ? 'still too wide: try a more condensed face or a variable font with a wdth axis' : 'still too narrow: try a wider face');
// a weight step only helps on a family that ships one; a single-cut display face is what it is
const bestEntry = index?.entries.find((e) => e.family === best.family);
const variable = bestEntry ? bestEntry.variable : true;
if (dwt != null && Math.abs(dwt) > 0.15 && variable) advice.push(dwt > 0 ? `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;${transform !== 'none' ? ` text-transform: ${transform};` : ''}${advice.length ? ' NOTE ' + advice.join('; ') : ''}`);
region.type.chosen = { family: best.family, weight: best.weight, fontSizePx: best.fontSizePx, source, fp: compactFp(best.fp) };
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
}
}
+17
View File
@@ -30,6 +30,7 @@
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import zlib from 'node:zlib';
function arg(name, fallback = null) {
@@ -330,6 +331,22 @@ async function scorePlate(ctx, outFile) {
}
}
// A comp written into .impeccable/mocks/ while a direction is dealt but the
// build phases never started is a comp round happening outside the state
// file, and every session cut after it resumes with no state to follow. The
// roll writes .impeccable/build/pending.json; build-phase.mjs start clears
// it. Refuse mock output until start has run (or --force-mock).
{
const outArg = arg('out') || (plateCtx && plateCtx.out) || '';
const intoMocks = /(^|[\\/])\.impeccable[\\/]mocks[\\/]/.test(outArg) && !/[\\/]decision[\\/]/.test(outArg);
const pending = fs.existsSync(path.join('.impeccable', 'build', 'pending.json'));
const state = fs.existsSync(path.join('.impeccable', 'build', 'state.json'));
if (intoMocks && pending && !state && !process.argv.includes('--force-mock')) {
console.error(`generate-image: a direction was chosen (concept-seed rolled) but build-phase.mjs start has not run, so this comp would be generated outside the build's state. Run: node ${path.dirname(fileURLToPath(import.meta.url))}/build-phase.mjs start --direction <seed key> --kind <assigned|pick|challenger|canon> first (it opens the comps phase), then generate. --force-mock overrides.`);
process.exit(4);
}
}
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
const fakePromptFile = arg('prompt-file');
const fakePrompt = fakePromptFile ? fs.readFileSync(fakePromptFile, 'utf8') : arg('prompt');
+413
View File
@@ -0,0 +1,413 @@
/**
* font-fingerprint: size-invariant, text-robust shape features for lettering
* in a raster (a comp crop or a rendered sample). fingerprint(img) returns the
* feature vector; distance(a, b) compares two vectors over noise-normalized,
* weighted features. Used by font-match.mjs (comp measurement and ranking)
* and by the catalog index build (scripts/build-font-index.mjs at the repo root). Depends only on
* lib/image-metrics.mjs and lib/raster.mjs.
*
* Every measure is taken per text line and normalized by R, the line's
* reference height (median of the tallest column heights above the baseline:
* the cap line on an all-caps line, the ascender line on a mixed line), so
* the same face gives the same numbers at any point size; per-glyph measures
* are medians so the numbers survive a change of text. Small crops are
* upsampled (bilinear) so R is at least 24px, and stroke runs are measured
* with antialiased edge pixels counted by coverage, so stem widths do not
* fatten at small sizes.
*
* Features (all in R units unless noted; null when not measurable):
* advance/advTall/advX median glyph width over baseline glyphs / tall glyphs / x-height glyphs
* advCV spread of glyph widths (std/median): mono ~0.15, sans ~0.3, script > 0.5
* gap median inter-glyph gap
* xRatio x-line / R (null on all-caps lines)
* descRatio descender depth (90th pct)
* stemW median horizontal ink run in the x band (stem width)
* contrast stem width / median thin (vertical) run: didone high, grotesque ~1
* serif foot width / mid-stem width on stems that reach the baseline
* roundFrac fraction of glyphs with bbox aspect > 0.9
* densTall / densX ink / bbox area for tall / x-height glyphs (weight)
* runDensity horizontal ink runs per row per R of line width (stroke busyness)
* vprof0..9 normalized vertical ink profile from 0.35R below baseline to 1.05R above
* hrun25/50/75/90 quantiles of horizontal run lengths over the letter body
* vrun25/50/75/90 quantiles of vertical run lengths over the whole line
* colq25/75 quantiles of column heights above the baseline
* wq25/75 quantiles of glyph widths
* Also returned: lines, glyphs, capHeightPx (R in source pixels), allCaps, inkIsDark,
* upsampled, weight (densTall, so v1 callers keep a weight field).
*/
import { toGray } from './image-metrics.mjs';
import { resize } from './raster.mjs';
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;
}
const med = (a) => { if (!a.length) return null; const s = [...a].sort((p, q) => p - q); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
const pct = (a, p) => { if (!a.length) return null; const s = [...a].sort((p, q) => p - q); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
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 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 ink = new Uint8Array(g.data.length);
let sI = 0, nI = 0, sG = 0, nG = 0;
for (let i = 0; i < g.data.length; i++) {
const on = (inkIsDark ? g.data[i] < thr : g.data[i] >= thr) ? 1 : 0;
ink[i] = on;
if (on) { sI += g.data[i]; nI++; } else { sG += g.data[i]; nG++; }
}
const inkLevel = nI ? sI / nI : (inkIsDark ? 0 : 255), groundLevel = nG ? sG / nG : (inkIsDark ? 255 : 0);
// coverage per pixel: 0 = ground, 1 = ink, linear between the two class means, so
// antialiased edge pixels count fractionally and stroke widths do not fatten at small sizes
const covA = new Float32Array(g.data.length);
const den = groundLevel - inkLevel || 1;
for (let i = 0; i < g.data.length; i++) covA[i] = Math.max(0, Math.min(1, (groundLevel - g.data[i]) / den));
const cov = (i) => covA[i];
return { W: g.width, H: g.height, ink, inkIsDark, cov, covA };
}
/** Text lines from the row-ink profile (same rules as font-match v1). */
function findLines(bin) {
const { W, H, ink } = bin;
const rowInk = new Uint32Array(H);
for (let y = 0; y < H; y++) { let c = 0; const o = y * W; for (let x = 0; x < W; x++) c += ink[o + x]; rowInk[y] = c; }
const floor = Math.max(1, W * 0.004);
const runs = [];
let y = 0;
while (y < H) {
if (rowInk[y] > floor) {
const 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;
if (yy - valleyStart >= 3 && valleyStart - start >= 4) { lines.push({ y0: start, y1: valleyStart, run }); start = yy; }
}
}
if (run.y1 - start >= 4) lines.push({ y0: start, y1: run.y1, run });
}
// A piece split off inside one run with a fraction of the ink of the text
// lines is not a line: a thin band of ascenders or tittles above the x band
// (few letters reach it, so the valley rule fires) or a stray rule. Ascender
// bands merge back into the line below them; anything else is dropped.
for (const ln of lines) { let m = 0; for (let yy = ln.y0; yy < ln.y1; yy++) m += rowInk[yy]; ln.mass = m; }
const maxMass = Math.max(0, ...lines.map((l) => l.mass));
const merged = [];
for (let i = 0; i < lines.length; i++) {
const ln = lines[i];
if (ln.mass >= maxMass * 0.3) { merged.push({ y0: ln.y0, y1: ln.y1 }); continue; }
const next = lines[i + 1];
if (next && next.run === ln.run && next.mass >= maxMass * 0.3 && (ln.y1 - ln.y0) <= (next.y1 - next.y0) * 0.5) { next.y0 = ln.y0; }
}
return { lines: merged, rowInk };
}
/** Feature names in fingerprint order (used by distance). */
const VBINS = 10, HQ = [0.25, 0.5, 0.75, 0.9];
export const FEATURES = ['advance', 'advTall', 'advX', 'advCV', 'gap', 'xRatio', 'descRatio', 'stemW', 'contrast', 'serif', 'roundFrac', 'densTall', 'densX', 'runDensity',
...Array.from({ length: VBINS }, (_, i) => `vprof${i}`), ...HQ.map((q) => `hrun${Math.round(q * 100)}`), ...HQ.map((q) => `vrun${Math.round(q * 100)}`), 'colq25', 'colq75', 'wq25', 'wq75'];
/** Center of the densest window of width tol in a list of values, and its count. */
function modeOf(vals, tol) {
let best = null, bestC = -1;
const s = [...vals].sort((a, b) => a - b);
let j = 0;
for (let i = 0; i < s.length; i++) {
while (s[i] - s[j] > tol) j++;
const c = i - j + 1;
if (c > bestC) { bestC = c; best = (s[i] + s[j]) / 2; }
}
return { v: best, n: bestC };
}
/**
* Per-line vertical metrics from column extrema, which do not need glyphs to
* be separable. baseline = mode of column bottoms. R (the reference height)
* is the top line of the tallest cluster: the cap line on an all-caps line,
* the ascender line (or the cap line when caps are taller) on a mixed line.
* The x-line is a second mode of column heights well below R; when there is
* none the line is read as all-caps.
*/
function lineMetrics(bin, ln) {
const { W, ink, cov } = bin;
const cols = [];
for (let x = 0; x < W; x++) {
let top = -1, bot = -1;
for (let yy = ln.y0; yy < ln.y1; yy++) if (ink[yy * W + x]) { if (top < 0) top = yy; bot = yy + 1; }
if (top < 0) continue;
// sub-pixel edges from the antialiased boundary pixel's coverage
const t = top > 0 ? top - cov((top - 1) * W + x) : top;
const b = bot < bin.H ? bot + cov(bot * W + x) : bot;
cols.push({ x, top: t, bot: b });
}
if (cols.length < 8) return null;
const roughH = pct(cols.map((c) => c.bot - c.top), 0.9);
const tol = Math.max(1, Math.round(roughH * 0.04));
const baseF = modeOf(cols.map((c) => c.bot), tol).v;
const base = Math.round(baseF);
const hs = cols.filter((c) => c.bot <= baseF + tol * 1.5).map((c) => baseF - c.top).filter((h) => h > 0);
if (hs.length < 8) return null;
const hMaxAbs = pct(hs, 0.995);
const topCluster = hs.filter((h) => h >= hMaxAbs * 0.94);
const R = med(topCluster);
if (!R || R < 4) return null;
const lowHs = hs.filter((h) => h >= R * 0.3 && h <= R * 0.86);
let xh = null;
if (lowHs.length >= Math.max(6, hs.length * 0.12)) {
const m = modeOf(lowHs, tol);
if (m.n >= Math.max(4, lowHs.length * 0.25)) xh = m.v;
}
const dsc = cols.filter((c) => c.bot > baseF + tol * 1.5 && c.top < baseF - R * 0.3).map((c) => (c.bot - baseF) / R);
const descRatio = dsc.length >= 4 ? pct(dsc, 0.9) : null;
return { base, R, cap: R, xh, descRatio, tol, xL: cols[0].x, xR: cols[cols.length - 1].x + 1, hs };
}
/** Glyph boxes: column runs of ink inside the x band, so ascender/descender bridges do not merge letters. */
function segment(bin, ln, m) {
const { W, ink } = bin;
const bandTop = Math.max(ln.y0, Math.round(m.base - (m.xh || m.cap * 0.6)));
const bandH = m.base - bandTop;
const thr = 1;
const colBand = new Uint32Array(W);
for (let yy = bandTop; yy < m.base; yy++) { const o = yy * W; for (let x = m.xL; x < m.xR; x++) colBand[x] += ink[o + x]; }
const runs = [];
let x = m.xL;
while (x < m.xR) {
if (colBand[x] >= thr) { const x0 = x; while (x < m.xR && colBand[x] >= thr) x++; runs.push({ x0, x1: x }); } else x++;
}
const out = [];
for (const r of runs) {
let top = -1, bot = -1, area = 0;
for (let yy = ln.y0; yy < ln.y1; yy++) {
let c = 0, cv = 0; const o = yy * W; for (let xx = r.x0; xx < r.x1; xx++) { c += ink[o + xx]; cv += bin.covA[o + xx]; }
if (c) { if (top < 0) top = yy; bot = yy + 1; }
area += cv;
}
if (top >= 0) out.push({ x0: r.x0, x1: r.x1, w: r.x1 - r.x0, top, bot, h: bot - top, area });
}
return out;
}
function measure(bin, lines) {
const { W, H, ink, covA } = bin;
// run lengths with the antialiased edge pixels counted by coverage
const hLen = (o, x0, x1) => { let s = 0; for (let x = Math.max(0, x0 - 1); x < Math.min(W, x1 + 1); x++) s += covA[o + x]; return s; };
const vLen = (x, y0, y1) => { let s = 0; for (let y = Math.max(0, y0 - 1); y < Math.min(H, y1 + 1); y++) s += covA[y * W + x]; return s; };
let glyphN = 0;
const per = { xh: [], desc: [], runDensity: [] };
let allCapsLines = 0;
const vprof = new Float64Array(VBINS); const hruns = [], vruns = [], colHs = [], widths = [];
const advTall = [], advAll = [], advX = [], gaps = [], stems = [], thins = [], serifR = [], round = [], densTall = [], densX = [];
let capSum = 0, capN = 0;
for (const ln of lines) {
const m = lineMetrics(bin, ln);
if (!m) continue;
const { base, cap, xh, tol, xL, xR } = m;
capSum += cap; capN++;
if (xh) per.xh.push(xh / cap);
if (!xh) allCapsLines++;
if (m.descRatio != null) per.desc.push(m.descRatio);
for (const h of m.hs) colHs.push(h / cap);
// vertical ink profile from 0.35R below the baseline to 1.05R above, VBINS bins
for (let yy = ln.y0; yy < ln.y1; yy++) {
const u = (base - yy - 0.5) / cap; // height above baseline in R units
const bi = Math.floor((u + 0.35) / 1.4 * VBINS);
if (bi < 0 || bi >= VBINS) continue;
let c = 0; const o = yy * W; for (let x = xL; x < xR; x++) c += ink[o + x];
vprof[bi] += c;
}
// horizontal run lengths over the whole line body (x band to cap line), vertical run lengths over all columns
for (let yy = Math.max(ln.y0, Math.round(base - cap)); yy < base; yy++) {
const o = yy * W; let x = xL;
while (x < xR) { if (ink[o + x]) { const x0 = x; while (x < xR && ink[o + x]) x++; hruns.push(hLen(o, x0, x) / cap); } else x++; }
}
for (let x = xL; x < xR; x++) {
let yy = ln.y0;
while (yy < ln.y1) { if (ink[yy * W + x]) { const y0 = yy; while (yy < ln.y1 && ink[yy * W + x]) yy++; vruns.push(vLen(x, y0, yy) / cap); } else yy++; }
}
const gl = segment(bin, ln, m);
const G = gl.filter((g) => g.w >= cap * 0.12 && (base - g.top) >= cap * 0.3);
glyphN += G.length;
const onBase = G.filter((g) => Math.abs(g.bot - base) <= tol * 1.5);
const capG = onBase.filter((g) => base - g.top >= cap * 0.88);
const xs = xh ? onBase.filter((g) => Math.abs(base - g.top - xh) <= Math.max(tol * 1.5, cap * 0.05)) : [];
for (const g of capG) { advTall.push(g.w / cap); densTall.push(g.area / (g.w * g.h)); }
for (const g of xs) { densX.push(g.area / (g.w * g.h)); advX.push(g.w / cap); }
for (const g of onBase) { advAll.push(g.w / cap); widths.push(g.w / cap); round.push(g.w / (base - g.top) > 0.9 ? 1 : 0); }
for (let i = 0; i + 1 < G.length; i++) { const gap = G[i + 1].x0 - G[i].x1; if (gap >= 0 && gap < cap * 0.6) gaps.push(gap / cap); }
const xTop = base - (xh || cap * 0.55);
const bandTop = Math.round(xTop + (base - xTop) * 0.2), bandBot = Math.round(base - (base - xTop) * 0.2);
let runCount = 0, runRows = 0;
for (let yy = bandTop; yy < bandBot; yy++) {
const o = yy * W; let x = xL; runRows++;
while (x < xR) { if (ink[o + x]) { const x0 = x; while (x < xR && ink[o + x]) x++; const L = hLen(o, x0, x); runCount++; if (L < cap * 0.5) stems.push(L / cap); } else x++; }
}
if (runRows) per.runDensity.push((runCount / runRows) / ((xR - xL) / cap));
for (let x = xL; x < xR; x++) {
let yy = ln.y0;
while (yy < ln.y1) { if (ink[yy * W + x]) { const y0 = yy; while (yy < ln.y1 && ink[yy * W + x]) yy++; const L = vLen(x, y0, yy); if (L < cap * 0.35) thins.push(L / cap); } else yy++; }
}
// serif: stems that run straight to the baseline; foot width vs mid-stem width
const runAt = (yy, x) => { const o = yy * W; if (!ink[o + x]) return 0; let a = x, b = x; while (a > xL && ink[o + a - 1]) a--; while (b + 1 < xR && ink[o + b + 1]) b++; return hLen(o, a, b + 1); };
const yMid = Math.round(base - cap * 0.4), yHi = Math.round(base - cap * 0.18), yFoot = base - Math.max(1, Math.round(cap * 0.04));
let x = xL;
while (x < xR) {
let yy = base - 1; if (!ink[yy * W + x]) { x++; continue; }
while (yy > ln.y0 && ink[(yy - 1) * W + x]) yy--;
if (yy > yMid) { x++; continue; }
const x0 = x; x++; while (x < xR && ink[(base - 1) * W + x] && ink[yMid * W + x]) x++;
const xc = Math.round((x0 + x - 1) / 2);
const wMid = runAt(yMid, xc), wHi = runAt(yHi, xc), wFoot = runAt(yFoot, xc);
if (wMid > 0 && wMid < cap * 0.5 && wHi <= wMid * 1.3 && wHi >= wMid * 0.7) serifR.push(wFoot / wMid);
}
}
if (!capN) return null;
const stemW = med(stems), thinW = med(thins);
const advM = med(advAll);
const advSd = advAll.length > 3 ? Math.sqrt(advAll.reduce((s, v) => s + (v - advM) ** 2, 0) / advAll.length) : null;
const vsum = vprof.reduce((s, x) => s + x, 0) || 1;
const extra = {};
for (let i = 0; i < VBINS; i++) extra[`vprof${i}`] = vprof[i] / vsum;
for (const q of HQ) { extra[`hrun${Math.round(q * 100)}`] = pct(hruns, q); extra[`vrun${Math.round(q * 100)}`] = pct(vruns, q); }
extra.colq25 = pct(colHs, 0.25); extra.colq75 = pct(colHs, 0.75);
extra.wq25 = pct(widths, 0.25); extra.wq75 = pct(widths, 0.75);
return {
...extra,
capHeightPx: capSum / capN,
glyphs: glyphN,
advance: advM,
advTall: advTall.length ? med(advTall) : null,
advX: advX.length ? med(advX) : null,
advCV: advSd != null && advM ? advSd / advM : null,
gap: gaps.length ? med(gaps) : 0,
xRatio: per.xh.length ? med(per.xh) : null,
descRatio: per.desc.length ? med(per.desc) : null,
allCaps: allCapsLines * 2 > capN,
runDensity: med(per.runDensity),
stemW,
contrast: stemW && thinW ? stemW / thinW : null,
serif: serifR.length >= 3 ? med(serifR) : null,
roundFrac: round.length ? mean(round) : null,
densTall: densTall.length ? med(densTall) : null,
densX: densX.length ? med(densX) : null,
};
}
/**
* fingerprint(img) -> features or null. Upsamples 2x (bilinear) when the
* cap height is under 24px so runs and edges are measured on finer pixels.
*/
export function fingerprint(img, { minCap = 24 } = {}) {
let bin = binarize(img);
let { lines } = findLines(bin);
if (!lines.length) return null;
let f = measure(bin, lines);
if (!f) return null;
let scale = 1;
if (f.capHeightPx < minCap && f.capHeightPx >= 4) {
scale = Math.min(4, Math.ceil(minCap / f.capHeightPx));
const up = resize(img, img.width * scale, img.height * scale);
bin = binarize(up);
lines = findLines(bin).lines;
const f2 = lines.length ? measure(bin, lines) : null;
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 };
for (const k of FEATURES) r[k] = f[k] == null ? null : +f[k].toFixed(4);
return r;
}
/**
* Distance normalization fitted on 299 held-out probes (150 at ~30px cap, 149
* at ~14px, text different from the index text) against a 3,092-entry Google
* Fonts index: std = within-family noise (1.4826 x median |probe - own index
* entry|, floored at 5% of the catalog IQR spread), w = group weight from
* coordinate descent on top-5 family recall. mean is unused by the distance.
*/
export const STATS = {
advance: { std: 0.07648, w: 0 },
advTall: { std: 0.25331, w: 0 },
advX: { std: 0.05144, w: 1.5 },
advCV: { std: 0.0857, w: 1 },
gap: { std: 0.02668, w: 1 },
xRatio: { std: 0.02315, w: 1 },
descRatio: { std: 0.17831, w: 1 },
stemW: { std: 0.01922, w: 1 },
contrast: { std: 0.05969, w: 3 },
serif: { std: 0.31477, w: 0.5 },
roundFrac: { std: 0.09341, w: 1 },
densTall: { std: 0.05708, w: 2 },
densX: { std: 0.07666, w: 0 },
runDensity: { std: 0.18199, w: 1 },
vprof0: { std: 0.01178, w: 1 },
vprof1: { std: 0.01331, w: 1 },
vprof2: { std: 0.02745, w: 1 },
vprof3: { std: 0.03046, w: 1 },
vprof4: { std: 0.01933, w: 1 },
vprof5: { std: 0.01737, w: 1 },
vprof6: { std: 0.0336, w: 1 },
vprof7: { std: 0.03195, w: 1 },
vprof8: { std: 0.03271, w: 1 },
vprof9: { std: 0.02951, w: 1 },
hrun25: { std: 0.01751, w: 1 },
hrun50: { std: 0.02124, w: 1 },
hrun75: { std: 0.04503, w: 1 },
hrun90: { std: 0.06844, w: 1 },
vrun25: { std: 0.01895, w: 1 },
vrun50: { std: 0.02405, w: 1 },
vrun75: { std: 0.06199, w: 1 },
vrun90: { std: 0.09486, w: 1 },
colq25: { std: 0.02906, w: 1 },
colq75: { std: 0.18204, w: 1 },
wq25: { std: 0.19862, w: 1 },
wq75: { std: 0.09687, w: 1 },
};
export const Z_CLIP = 3;
/** Weighted L1 over z-scored features; a feature missing on either side is skipped and the weight mass renormalized. */
export function distance(a, b, stats = STATS, { p = 1, zClip = Z_CLIP } = {}) {
let d = 0, wsum = 0;
for (const k of FEATURES) {
const s = stats[k]; if (!s || !s.w) continue;
const av = a[k], bv = b[k];
if (av == null || bv == null) continue;
const z = Math.min(zClip, Math.abs(av - bv) / s.std);
d += s.w * (p === 1 ? z : z * z); wsum += s.w;
}
if (!wsum) return Infinity;
const v = d / wsum;
return p === 1 ? v : Math.sqrt(v);
}
/** Debug: per-line metrics (base, R, xh, mode counts) for a raster. */
export function _debugLines(img) {
const bin = binarize(img);
const { lines } = findLines(bin);
return lines.map((ln) => { const m = lineMetrics(bin, ln); if (!m) return { ln, m: null }; const hs = m.hs.map((h) => +(h / m.R).toFixed(2)).sort((a, b) => a - b); const hist = {}; for (const h of hs) { const b = Math.round(h * 20) / 20; hist[b] = (hist[b] || 0) + 1; } return { y0: ln.y0, y1: ln.y1, base: m.base, R: +m.R.toFixed(1), xh: m.xh && +m.xh.toFixed(1), hist }; });
}
+115
View File
@@ -0,0 +1,115 @@
/**
* font-index: the fingerprint index of the Google Fonts catalog that
* font-match.mjs --rank uses as its candidate generator, and the pack/unpack
* helpers the release-time build (repo scripts/build-font-index.mjs) shares with it.
*
* File: skill/scripts/data/font-index.json
* {
* schema: 1,
* text: "<index text every face was rendered with>",
* sizes: [48, 14], // cap heights (px) the catalog was rendered at
* features: [...], // feature names, in vector order (the fitted-nonzero subset of FEATURES)
* categories: ["sans", ...], // category index -> name
* entries: [[family, weight, categoryIdx, variable(0|1), vec48, vec14], ...]
* }
* A vector is a string of 3-char base-36 numbers, one per feature, each the
* feature value x 1000 (three decimals, clipped at 46.655); "___" is null.
* That packing keeps ~3,000 faces x 2 sizes x 33 features under 750 KB on
* disk, which is what makes it shippable inside the skill without gzip.
*
* Two sizes because the fingerprint's features are stable within a factor of
* ~2 in size but not from 48px down to 14px: a crop is routed to the index
* rendered nearer its own cap height (ROUTE_CAP_PX is the boundary).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { FEATURES, STATS, distance } from './font-fingerprint.mjs';
export const INDEX_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'data', 'font-index.json');
export const INDEX_SIZES = [48, 14];
/** Crops with a cap height under this many px query the 14px index. */
export const ROUTE_CAP_PX = 22;
/** Below this cap height the fingerprint is not trustworthy; callers size by the box instead. */
export const MIN_RANK_CAP_PX = 10;
export const CATEGORIES = ['sans', 'serif', 'display', 'handwriting', 'mono'];
/** The features the index stores: the ones the fitted distance gives nonzero weight. */
export const INDEX_FEATURES = FEATURES.filter((k) => STATS[k] && STATS[k].w > 0);
const NULL_TOKEN = '___';
const MAX_Q = 36 ** 3 - 1;
export function packVector(fp, features = INDEX_FEATURES) {
return features.map((k) => {
const v = fp?.[k];
if (v == null || !Number.isFinite(v)) return NULL_TOKEN;
return Math.min(MAX_Q, Math.max(0, Math.round(v * 1000))).toString(36).padStart(3, '0');
}).join('');
}
export function unpackVector(s, features = INDEX_FEATURES) {
const out = {};
for (let i = 0; i < features.length; i++) {
const t = s.slice(i * 3, i * 3 + 3);
out[features[i]] = t === NULL_TOKEN || t.length < 3 ? null : parseInt(t, 36) / 1000;
}
return out;
}
let cached = null;
/**
* Load and decode the index. Returns null when the file is missing (callers
* fall back to their built-in shortlist). Result: { schema, text, sizes,
* features, entries: [{ family, weight, category, variable, fp: { 48: {...}, 14: {...}|null } }] }.
*/
export function loadFontIndex(file = INDEX_PATH) {
if (cached && cached.file === file) return cached.index;
if (!fs.existsSync(file)) return null;
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
const features = raw.features || INDEX_FEATURES;
const cats = raw.categories || CATEGORIES;
const sizes = raw.sizes || INDEX_SIZES;
const entries = raw.entries.map((e) => {
const fp = {};
sizes.forEach((sz, i) => { const v = e[4 + i]; fp[sz] = v ? unpackVector(v, features) : null; });
return { family: e[0], weight: e[1], category: cats[e[2]] ?? String(e[2]), variable: !!e[3], fp };
});
const index = { schema: raw.schema, text: raw.text, sizes, features, entries };
cached = { file, index };
return index;
}
/** Which of the index's cap sizes a crop with this cap height should query. */
export function routeSize(capHeightPx, sizes = INDEX_SIZES) {
const sorted = [...sizes].sort((a, b) => a - b);
return capHeightPx < ROUTE_CAP_PX ? sorted[0] : sorted[sorted.length - 1];
}
/**
* The n nearest catalog faces to a comp fingerprint, routed by cap height.
* Returns [{ family, weight, category, variable, d, size }] sorted by distance;
* at most `perFamily` entries of one family so the shortlist spans faces, not weights.
*/
export function candidatesFromIndex(fp, index, { n = 25, category = null, perFamily = 2 } = {}) {
if (!fp || !index) return [];
const size = routeSize(fp.capHeightPx, index.sizes);
const wantCat = category ? String(category).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean) : null;
const scored = [];
for (const e of index.entries) {
if (wantCat && !wantCat.includes(e.category)) continue;
const v = e.fp[size];
if (!v) continue;
const d = distance(fp, v);
if (!Number.isFinite(d)) continue;
scored.push({ family: e.family, weight: e.weight, category: e.category, variable: e.variable, d, size });
}
scored.sort((a, b) => a.d - b.d);
const perFam = new Map(); const out = [];
for (const s of scored) {
const c = perFam.get(s.family) || 0;
if (c >= perFamily) continue;
perFam.set(s.family, c + 1); out.push(s);
if (out.length >= n) break;
}
return out;
}