mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 01:26:29 +03:00
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:
committed by
Abdul Wahab
co-authored by
Claude
parent
62f59a3934
commit
cacb2868ac
File diff suppressed because one or more lines are too long
+137
-147
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }; });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user