Node-free swap: comp-fidelity verbs move to the engine

The four comp-fidelity scripts (comp-spec, comp-diff, font-match, build-phase)
and their six libs are ported into the impeccable-engine binary. This removes
the last Node .mjs from the skill: `git ls-files skill/scripts | grep '\.mjs$'`
now returns nothing.

- reference/new-work.md, reference/visualize.md, and the asset-producer /
  finish-reviewer agents now invoke `{{scripts_path}}/impeccable <verb>` instead
  of `node <script>.mjs`.
- Deleted the ten ported .mjs and the four JS unit tests that imported them
  (their behavior is now covered by the engine's Rust tests and the oracle);
  removed those files from scripts/test-suites.mjs.
- Added oracle cases (comp-*, font-match-*, build-phase-*) over a comp-basic
  workspace, recorded from the engine binary; the deterministic outputs are
  byte-identical to the JS the scripts left behind.
- docs/CLI-CONTRACT.md documents the four verbs, the CDP font rendering, and
  the runtime-resolved (never-committed) font-index catalog.

The font-index catalog JSON stays shipped in the skill (data/font-index.json);
the engine resolves it at run time and never vendors it.

Prepared with AI assistance (Claude Code).
This commit is contained in:
Paul Bakaus
2026-09-01 12:29:17 -07:00
parent 4a04f6afea
commit 6c474b1c79
40 changed files with 504 additions and 5152 deletions
File diff suppressed because it is too large Load Diff
-391
View File
@@ -1,391 +0,0 @@
#!/usr/bin/env node
/**
* comp-diff: measure a build screenshot against its approved comp and produce
* the evidence a reviewer (human or model) needs to judge fidelity without
* trusting anyone's memory of the image.
*
* node comp-diff.mjs --comp .impeccable/mocks/approved.png --build .impeccable/review/hero-repro.png
* node comp-diff.mjs --comp comp.png --build desktop.png --spec .impeccable/build/spec.json --out-dir .impeccable/review/diff
* node comp-diff.mjs ... --json # machine-readable report on stdout
* node comp-diff.mjs ... --threshold 0.75 # exit 3 when the overall score is below
*
* Inputs: two PNGs. The build capture may be taller than the comp (a full-page
* screenshot); it is scaled to the comp's width and the top comp-height rows
* are compared, because the comp is the first viewport. `--align stretch`
* squashes the whole build onto the comp instead, for a comp that covers a
* whole page.
*
* Outputs (in --out-dir, default .impeccable/review/diff):
* side-by-side.png comp | build, same size, labeled, with the score
* heatmap.png build with the difference painted over it (red = wrong)
* regions/<id>.png paired crops per region at legible scale, scored
* report.json every number below, plus per-region rows
*
* Scores (0..1): structure (blurred SSIM: is the composition the same?),
* color (histogram + dominant palette: is it the same palette at the same
* coverage?), detail (high-frequency energy ratio: did the material survive,
* or did an illustration become a gradient?), bands (do the horizontal
* sections line up?). `overall` weights them 0.35 / 0.25 / 0.25 / 0.15.
*
* Regions come from --spec (comp-spec.mjs output: normalized boxes) or, with
* none, from the comp's own horizontal bands, so the per-region crops exist
* either way. Every region row carries the same four scores plus `verdict`:
* match (>= 0.8), drift (>= 0.6), missing (detail ratio < 0.35 with structure
* < 0.6), or contradicted (everything else). The words are the finish
* reviewer's fidelity vocabulary on purpose.
*
* Exit codes: 0 measured (and above threshold when one is given), 1 usage or
* unreadable input, 3 below threshold.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { decodePng, encodePng, loadRaster } from './lib/png.mjs';
import { crop, resize, fit, blit, createImage, fillRect, strokeRect, drawLabel } from './lib/raster.mjs';
import { structureScore, colorScore, detailScore, diffMap, horizontalBands, bandScore, dominantColors, toGray, blurGray, ssimShifted } from './lib/image-metrics.mjs';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = process.argv[i + 1];
return v && !v.startsWith('--') ? v : fallback;
}
const flag = (name) => process.argv.includes(`--${name}`);
export function readPng(file) {
return loadRaster(file).image;
}
/**
* Scale the build to the comp's width; take the top comp-height rows
* (align=top), squash the whole build onto the comp (align=stretch), or scale
* to cover and center-crop (align=cover, the way `object-fit: cover` will show
* a plate whose aspect differs from its region).
*/
export function alignBuild(comp, build, align = 'top') {
if (align === 'stretch') return resize(build, comp.width, comp.height);
if (align === 'cover') {
const s = Math.max(comp.width / build.width, comp.height / build.height);
const scaled = resize(build, build.width * s, build.height * s);
return crop(scaled, (scaled.width - comp.width) / 2, (scaled.height - comp.height) / 2, comp.width, comp.height);
}
const scaled = build.width === comp.width ? build : resize(build, comp.width, Math.round((build.height / build.width) * comp.width));
if (scaled.height === comp.height) return scaled;
if (scaled.height > comp.height) return crop(scaled, 0, 0, comp.width, comp.height);
// shorter than the comp: pad with white so a short page reads as missing content, not as a resize
const out = createImage(comp.width, comp.height, [255, 255, 255, 255]);
blit(out, scaled, 0, 0);
return out;
}
/** Weights per region kind: what a region is made of decides what losing it looks like. */
const WEIGHTS = {
default: { structure: 0.35, color: 0.25, detail: 0.25, bands: 0.15 },
plate: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 },
image: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 },
texture: { structure: 0.15, color: 0.35, detail: 0.5, bands: 0 },
text: { structure: 0.5, color: 0.25, detail: 0.15, bands: 0.1 },
control: { structure: 0.45, color: 0.35, detail: 0.2, bands: 0 },
};
export function scorePair(a, b, kind = null) {
const structure = structureScore(a, b);
const color = colorScore(a, b);
const detail = detailScore(a, b);
const bandsA = horizontalBands(a), bandsB = horizontalBands(b);
const bands = bandScore(bandsA, bandsB);
const w = WEIGHTS[kind] || WEIGHTS.default;
const overall = w.structure * structure + w.color * color.score + w.detail * detail.score + w.bands * bands;
return {
overall: r4(overall),
structure: r4(structure),
color: r4(color.score),
colorIntersection: r4(color.intersection),
paletteMatch: r4(color.paletteMatch),
detail: r4(detail.score),
detailRaw: r4(detail.rawScore ?? detail.score),
detailAdded: r4(detail.addedFraction),
bands: r4(bands),
_detail: detail,
_bands: { comp: bandsA, build: bandsB },
};
}
/** Kinds that carry the direction: a wrong one is the wrong page, whatever the mean says. */
export const DIRECTION_KINDS = new Set(['plate', 'image', 'text']);
/** Best small global translation (build relative to comp), in pixels, by blurred-gray SSIM. */
export function bestShift(comp, build, workWidth = 256) {
const h = Math.max(8, Math.round((comp.height / comp.width) * workWidth));
const a = blurGray(toGray(resize(comp, workWidth, h)), 2);
const b = blurGray(toGray(resize(build, workWidth, h)), 2);
const maxShift = Math.max(2, Math.round(workWidth * 0.04));
let best = { dx: 0, dy: 0, score: ssimShifted(a, b, 0, 0) };
for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
const sc = ssimShifted(a, b, Math.round(dx), Math.round(dy));
if (sc > best.score + 0.01) best = { dx: Math.round(dx), dy: Math.round(dy), score: sc };
}
}
const scale = comp.width / workWidth;
return { dx: Math.round(best.dx * scale), dy: Math.round(best.dy * scale), score: best.score };
}
export function verdictFor(s, kind = null) {
const painted = kind === 'plate' || kind === 'image' || kind === 'texture';
// Nothing drawn where the comp drew something is missing whatever the
// palette says: a footer strip the build pushed below the fold read as
// 'drift' on ground colour alone (detail 4%, structure 92%). detailRaw is
// 1 when the comp region itself is calm, so a low value already means the
// comp had material there.
if (s.detailRaw != null && s.detailRaw < 0.15) return 'missing';
if (painted && s.detail < 0.5) return 'missing';
// For text, chrome, and controls "missing" means the build has nothing
// there, not that a thin strip sits a few pixels off: require the build's
// own energy to be near zero relative to the comp (rawScore, before the
// added-detail penalty), and drift for a mere misalignment.
if (!painted && s.detail < 0.35 && s.structure < 0.6) {
if (s.detailRaw != null && s.detailRaw < 0.2) return 'missing';
// low detail with structure and palette both holding is grain the build
// renders flatter (a spine of rotated type on textured red), not a
// different composition
if (s.structure >= 0.5 && s.color >= 0.5) return 'drift';
return 'contradicted';
}
if (s.detail < 0.35 && s.structure < 0.6) return 'missing';
// Structure is the one thing a wrong-but-busy region cannot fake: noise,
// a mirrored crop, a swapped column, a tile shuffle all keep color and
// energy and lose structure. Below the floor it is contradicted whatever
// the weighted mean says; painted regions with invented detail likewise.
if (s.structure < 0.3) return 'contradicted';
if (painted && (s.structure < 0.45 || s.detailAdded > 0.4)) return 'contradicted';
// Text is set in a substitute face at a slightly different metric almost
// always, and blurred SSIM reads glyph shape; a text region with its
// structure above the swap floor and its palette intact is drift at worst.
// Chasing it past that point is what burned eight to thirteen hero attempts
// per build in the first simulated round.
if (kind === 'text' && s.color >= 0.5) return s.overall >= 0.8 ? 'match' : 'drift';
// Chrome and controls are thin strips whose "detail" is mostly ground grain
// (a paper texture the build renders flatter, a scanline). When their
// structure and palette hold, low detail is drift, not contradiction.
if ((kind === 'chrome' || kind === 'control') && s.structure >= 0.5 && s.color >= 0.5) return s.overall >= 0.8 ? 'match' : 'drift';
if (s.overall >= 0.8) return 'match';
if (s.overall >= 0.6) return 'drift';
return 'contradicted';
}
const r4 = (v) => Math.round(v * 10000) / 10000;
/** Regions from a spec (normalized boxes) or derived from the comp's bands. */
export function resolveRegions(comp, spec) {
const regions = [];
if (spec && Array.isArray(spec.regions) && spec.regions.length) {
for (const r of spec.regions) {
const box = r.box || r;
if ([box.x, box.y, box.w, box.h].some((v) => typeof v !== 'number')) continue;
regions.push({ id: r.id || `region-${regions.length + 1}`, x: box.x, y: box.y, w: box.w, h: box.h, kind: r.kind || null });
}
if (regions.length) return regions;
}
const bands = horizontalBands(comp).filter((b) => b.strength > 0.2);
const cuts = [0, ...bands.map((b) => b.y), 1].filter((v, i, arr) => i === 0 || v - arr[i - 1] > 0.06);
if (cuts[cuts.length - 1] !== 1) cuts.push(1);
for (let i = 0; i + 1 < cuts.length; i++) {
regions.push({ id: `band-${i + 1}`, x: 0, y: cuts[i], w: 1, h: cuts[i + 1] - cuts[i], kind: 'band' });
}
if (regions.length < 2) {
return [
{ id: 'top', x: 0, y: 0, w: 1, h: 0.5, kind: 'band' },
{ id: 'bottom', x: 0, y: 0.5, w: 1, h: 0.5, kind: 'band' },
];
}
return regions;
}
/** Crop a normalized region; regions thinner than 48px in either axis are grown to that so tiny strips do not swing on subpixel noise. */
/** Bounding box of ink (pixels darker/lighter than the region's ground by a margin) within a crop, in px. */
export function inkBox(img) {
const g = toGray(img);
// ground = median gray; ink = |v - ground| > 48
const sample = []; for (let i = 0; i < g.data.length; i += Math.max(1, Math.floor(g.data.length / 4000))) sample.push(g.data[i]);
sample.sort((p, q) => p - q); const ground = sample[Math.floor(sample.length / 2)] || 255;
let x0 = img.width, y0 = img.height, x1 = -1, y1 = -1;
for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) {
if (Math.abs(g.data[y * img.width + x] - ground) > 48) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
}
if (x1 < 0) return null;
return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
}
function regionCrop(img, r) {
const minPx = 48;
let x = r.x * img.width, y = r.y * img.height, w = r.w * img.width, h = r.h * img.height;
if (h < minPx) { y -= (minPx - h) / 2; h = minPx; }
if (w < minPx) { x -= (minPx - w) / 2; w = minPx; }
return crop(img, x, y, w, h);
}
const HEAT_LABEL = { match: [40, 160, 80, 255], drift: [220, 160, 30, 255], missing: [200, 40, 40, 255], contradicted: [200, 40, 40, 255] };
export function renderSideBySide(comp, build, label, score) {
const gap = 24, pad = 48;
const targetW = Math.min(comp.width, 1400);
const a = fit(comp, targetW, 100000), b = resize(build, a.width, a.height);
const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 24, [24, 24, 28, 255]);
blit(out, a, pad, pad + 24);
blit(out, b, pad + a.width + gap, pad + 24);
drawLabel(out, 'COMP', pad, pad - 4, { scale: 2 });
drawLabel(out, `BUILD ${label ? label.toUpperCase() : ''}`.trim(), pad + a.width + gap, pad - 4, { scale: 2 });
const s = `OVERALL ${(score.overall * 100).toFixed(0)}% STRUCT ${(score.structure * 100).toFixed(0)}% COLOR ${(score.color * 100).toFixed(0)}% DETAIL ${(score.detail * 100).toFixed(0)}% BANDS ${(score.bands * 100).toFixed(0)}%`;
drawLabel(out, s, pad, out.height - pad + 8, { scale: 2, bg: HEAT_LABEL[verdictFor(score)] });
return out;
}
export function renderHeatmap(comp, build) {
const map = diffMap(comp, build);
const base = resize(build, map.width, map.height);
const out = { width: base.width, height: base.height, data: new Uint8Array(base.data) };
for (let i = 0, p = 0; i < map.data.length; i++, p += 4) {
const d = map.data[i];
if (d < 0.12) { // dim what matches so wrong stands out
out.data[p] = out.data[p] * 0.55 + 255 * 0.45 * 0.2; out.data[p + 1] = out.data[p + 1] * 0.55; out.data[p + 2] = out.data[p + 2] * 0.55; continue;
}
const a = Math.min(1, (d - 0.12) / 0.5);
out.data[p] = out.data[p] * (1 - a) + 235 * a; out.data[p + 1] = out.data[p + 1] * (1 - a) + 40 * a; out.data[p + 2] = out.data[p + 2] * (1 - a) + 40 * a;
}
const scaled = resize(out, comp.width, comp.height);
drawLabel(scaled, 'DIFF: RED = DIFFERS FROM COMP', 12, 12, { scale: 2 });
return scaled;
}
export function renderRegionPair(compCrop, buildCrop, id, score) {
const gap = 16, pad = 12;
const maxW = 700;
const a = fit(compCrop, maxW, 700, true), b = resize(buildCrop, a.width, a.height);
const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 30, [24, 24, 28, 255]);
blit(out, a, pad, pad + 30);
blit(out, b, pad + a.width + gap, pad + 30);
const v = verdictFor(score);
drawLabel(out, `${id.toUpperCase()} COMP`, pad, pad, { scale: 2 });
drawLabel(out, `BUILD ${v.toUpperCase()} ${(score.overall * 100).toFixed(0)}%`, pad + a.width + gap, pad, { scale: 2, bg: HEAT_LABEL[v] });
return out;
}
export function compare({ comp, build, spec = null, align = 'top', label = '', kind = null }) {
let aligned = alignBuild(comp, build, align);
const whole = scorePair(comp, aligned, kind);
// Region crops are taken at fixed boxes, so a small global offset (a
// taller masthead, a scrollbar) would read every thin region as
// contradicted while the whole-image search forgives it. Find the best
// global translation once and shift the aligned build by it before
// cropping regions; the whole score above stays as measured.
// The side-by-side and heatmap show the build as captured; only the
// region crops read the shifted copy. (The shifted copy used to be what the
// side-by-side drew, and its padding read as a white "letterbox" on the
// build in every human review.)
const asCaptured = aligned;
const shift = bestShift(comp, aligned);
if (shift.dx || shift.dy) {
const shifted = createImage(aligned.width, aligned.height, [255, 255, 255, 255]);
blit(shifted, aligned, -shift.dx, -shift.dy);
aligned = shifted;
}
const regions = resolveRegions(comp, spec).map((r) => {
const a = regionCrop(comp, r), b = regionCrop(aligned, r);
const s = scorePair(a, b, r.kind);
return { ...r, score: strip(s), verdict: verdictFor(s, r.kind), inkBox: { comp: inkBox(a), build: inkBox(b) }, _a: a, _b: b };
});
const compPalette = dominantColors(comp), buildPalette = dominantColors(aligned);
return { label, align, whole: strip(whole), regions, aligned: asCaptured, alignedShifted: aligned, shift, compPalette, buildPalette, _whole: whole };
}
function strip(s) {
const { _detail, _bands, ...rest } = s;
return rest;
}
export function writeArtifacts(result, comp, outDir) {
fs.mkdirSync(path.join(outDir, 'regions'), { recursive: true });
const side = renderSideBySide(comp, result.aligned, result.label, result.whole);
fs.writeFileSync(path.join(outDir, 'side-by-side.png'), encodePng(side));
fs.writeFileSync(path.join(outDir, 'heatmap.png'), encodePng(renderHeatmap(comp, result.aligned)));
const regionFiles = [];
for (const r of result.regions) {
const file = path.join(outDir, 'regions', `${r.id}.png`);
fs.writeFileSync(file, encodePng(renderRegionPair(r._a, r._b, r.id, r.score)));
regionFiles.push(file);
}
return { sideBySide: path.join(outDir, 'side-by-side.png'), heatmap: path.join(outDir, 'heatmap.png'), regionFiles };
}
export function buildReport(result, files, meta) {
return {
tool: 'comp-diff',
version: 1,
createdAt: new Date().toISOString(),
...meta,
align: result.align,
overall: result.whole.overall,
verdict: verdictFor(result.whole),
scores: result.whole,
palette: { comp: result.compPalette.map(({ hex, coverage }) => ({ hex, coverage })), build: result.buildPalette.map(({ hex, coverage }) => ({ hex, coverage })) },
regions: result.regions.map(({ _a, _b, ...r }) => r),
files,
};
}
function summarize(report) {
const lines = [];
lines.push(`COMP-DIFF ${report.label ? `[${report.label}] ` : ''}overall ${(report.overall * 100).toFixed(0)}% (${report.verdict}) structure ${(report.scores.structure * 100).toFixed(0)}% color ${(report.scores.color * 100).toFixed(0)}% detail ${(report.scores.detail * 100).toFixed(0)}% bands ${(report.scores.bands * 100).toFixed(0)}%`);
lines.push(`PALETTE comp ${report.palette.comp.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
lines.push(`PALETTE build ${report.palette.build.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
for (const r of report.regions) {
lines.push(`REGION ${r.id.padEnd(18)} ${r.verdict.padEnd(12)} ${(r.score.overall * 100).toFixed(0).padStart(3)}% structure ${(r.score.structure * 100).toFixed(0).padStart(3)}% color ${(r.score.color * 100).toFixed(0).padStart(3)}% detail ${(r.score.detail * 100).toFixed(0).padStart(3)}%${r.score.detailAdded > 0.25 ? ' +invented detail' : ''}`);
}
if (report.files) {
lines.push(`FILES side-by-side ${report.files.sideBySide}`);
lines.push(`FILES heatmap ${report.files.heatmap}`);
lines.push(`FILES regions ${report.files.regionFiles.length} under ${path.dirname(report.files.regionFiles[0] || report.files.heatmap)}`);
}
const worst = [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3);
if (worst.length) lines.push(`WORST ${worst.map((r) => `${r.id} (${r.verdict}, ${(r.score.overall * 100).toFixed(0)}%)`).join('; ')}`);
lines.push('OPEN the side-by-side and the worst region pairs before deciding anything; the numbers rank, the crops decide.');
return lines.join('\n');
}
async function main() {
const compPath = arg('comp'), buildPath = arg('build');
if (!compPath || !buildPath) {
console.error('usage: comp-diff.mjs --comp <png> --build <png> [--spec spec.json] [--out-dir dir] [--align top|stretch] [--label name] [--threshold 0.75] [--json]');
process.exit(1);
}
let comp, build;
try { comp = readPng(compPath); } catch (e) { console.error(`comp-diff: cannot read comp ${compPath}: ${e.message}`); process.exit(1); }
try { build = readPng(buildPath); } catch (e) { console.error(`comp-diff: cannot read build ${buildPath}: ${e.message}`); process.exit(1); }
let spec = null;
const specPath = arg('spec');
if (specPath) {
try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); } catch (e) { console.error(`comp-diff: cannot read spec ${specPath}: ${e.message}`); process.exit(1); }
}
const outDir = arg('out-dir', path.join(path.dirname(buildPath), 'diff'));
const label = arg('label', path.basename(buildPath, '.png'));
const result = compare({ comp, build, spec, align: arg('align', 'top'), label });
const files = flag('no-files') ? null : writeArtifacts(result, comp, outDir);
const report = buildReport(result, files, { label, comp: compPath, build: buildPath, spec: specPath || null, compSize: `${comp.width}x${comp.height}`, buildSize: `${build.width}x${build.height}` });
if (files) fs.writeFileSync(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2));
if (flag('json')) console.log(JSON.stringify(report, null, 2));
else console.log(summarize(report));
const threshold = arg('threshold') ? parseFloat(arg('threshold')) : null;
if (threshold != null && report.overall < threshold) {
if (!flag('json')) console.log(`BELOW THRESHOLD ${(threshold * 100).toFixed(0)}%: the reproduction is not done. Fix the worst regions and re-run; do not build past the hero.`);
process.exit(3);
}
}
// realpath on both sides: a skill mounted through a symlink (Cursor, a
// worktree, an eval stage) must still run as a CLI.
const isMain = (() => {
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
catch { return !!process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); }
})();
if (isMain) main();
-513
View File
@@ -1,513 +0,0 @@
#!/usr/bin/env node
/**
* comp-spec: turn an approved comp into a measured build spec, so the build
* codes against numbers and crops instead of a memory of the image.
*
* Step 1, look at the comp with a coordinate grid on it:
* node comp-spec.mjs --comp .impeccable/mocks/approved.png --grid
* writes .impeccable/build/comp-grid.png (10x10 labeled grid, A-J / 0-9)
* and prints the measured palette and horizontal bands. Open the grid
* image and name every salient region by its grid span.
*
* Step 2, write the regions file (JSON) and measure it:
* node comp-spec.mjs --comp <comp> --regions regions.json
* regions.json: { "regions": [ { "id": "exploded-plate", "kind": "plate",
* "grid": "E0:J4", "note": "exploded carburetor line drawing" }, ... ] }
* `grid` is "<colrow>:<colrow>" inclusive (A0 top-left cell to J9 bottom
* right); `box` { x, y, w, h } normalized 0..1 is accepted instead. `kind`
* is one of plate | image | texture | text | control | chrome | band.
* Writes .impeccable/build/spec.json: every region with its normalized
* box, pixel box, sampled palette, detail energy, and its medium: raster
* for plate / image / texture (produced as a plate, never CSS), semantic
* for text / control / chrome. `--auto` proposes band regions from the
* comp itself when you have no regions file yet.
*
* Step 3, use it:
* node comp-spec.mjs --print # compact spec for the build thread
* node comp-spec.mjs --crop exploded-plate --out tmp/plate-src.png [--scale 2] [--raw]
* crops the region from the comp (reference for a plate regeneration; a
* crop is never a shipping asset, its resolution is comp grade). For a
* raster region the crop has overlapping text/control/chrome regions
* painted out, matching what the plate prompt asks the generator to
* remove; --raw keeps them.
* node comp-spec.mjs --plate-prompt exploded-plate # the regeneration prompt for that region
*
* comp-diff.mjs reads the same spec (`--spec`) so its region rows and this
* file's rows are the same rows.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { decodePng, encodePng, loadRaster } from './lib/png.mjs';
import { crop, resize, fillRect, strokeRect, drawLabel, drawText } from './lib/raster.mjs';
import { dominantColors, horizontalBands, detailGrid } from './lib/image-metrics.mjs';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = process.argv[i + 1];
return v && !v.startsWith('--') ? v : fallback;
}
const flag = (name) => process.argv.includes(`--${name}`);
export const BUILD_DIR = path.join('.impeccable', 'build');
export const SPEC_PATH = path.join(BUILD_DIR, 'spec.json');
export const GRID_PATH = path.join(BUILD_DIR, 'comp-grid.png');
export const PLATES_DIR = path.join('assets', 'plates');
export const RASTER_KINDS = new Set(['plate', 'image', 'texture']);
export const KINDS = new Set(['plate', 'image', 'texture', 'text', 'control', 'chrome', 'band']);
const COLS = 'ABCDEFGHIJ';
/** "E0:J4" -> normalized box (inclusive cell span on a 10x10 grid). */
export function gridToBox(span) {
const m = /^([A-J])(\d):([A-J])(\d)$/i.exec(String(span).trim());
if (!m) throw new Error(`grid span "${span}" is not <colrow>:<colrow>, e.g. E0:J4`);
const c0 = COLS.indexOf(m[1].toUpperCase()), r0 = +m[2], c1 = COLS.indexOf(m[3].toUpperCase()), r1 = +m[4];
const x0 = Math.min(c0, c1), x1 = Math.max(c0, c1), y0 = Math.min(r0, r1), y1 = Math.max(r0, r1);
return { x: x0 / 10, y: y0 / 10, w: (x1 - x0 + 1) / 10, h: (y1 - y0 + 1) / 10 };
}
export function renderGrid(comp) {
const targetW = Math.min(1536, comp.width);
const img = resize(comp, targetW, Math.round((comp.height / comp.width) * targetW));
const cw = img.width / 10, ch = img.height / 10;
const line = [255, 40, 40, 200];
for (let i = 1; i < 10; i++) {
fillRect(img, Math.round(i * cw), 0, 1, img.height, line);
fillRect(img, 0, Math.round(i * ch), img.width, 1, line);
}
for (let r = 0; r < 10; r++) for (let c = 0; c < 10; c++) {
drawLabel(img, `${COLS[c]}${r}`, Math.round(c * cw) + 3, Math.round(r * ch) + 3, { scale: 2, bg: [0, 0, 0, 170], fg: [255, 230, 120, 255] });
}
return img;
}
function paletteOf(img) {
return dominantColors(img, 5).map(({ hex, coverage }) => ({ hex, coverage }));
}
/** Words in a region note that name painted material rather than code-drawn UI. */
export const PAINTED_NOTE = /\b(diagram|drawing|drawn|illustration|illustrations|illustrated|figure|schematic|exploded|photo|photos|photograph\w*|picture|painting|painted|render|rendered|rendering|artwork|engraving|etching|linework|line art|texture|textured|textures|grain|fabric|halftone|watercolou?r|sketch|sketched|blueprint|geometry|leader lines?|callout lines?|thumbnail|silhouette|product shot|hero image|3d)\b/i;
/** A text/control/chrome region larger than this fraction of the comp is a column, not an element. */
export const MAX_CODE_REGION_AREA = 0.25;
/** Fraction of an edge's length the artwork's dark mass has to touch to count as running off the box. */
export const EDGE_CONTACT_MIN = 0.35;
/**
* Which edges of a plate region crop the artwork touches. 'Artwork' is the
* region's non-ground mass: pixels far from the crop's median gray. A margin
* of paper along an edge means the shape ends inside the box; a long run of
* ink along it means the shape continues past it.
*/
export function artworkTouchesEdges(img, { contact = EDGE_CONTACT_MIN, band = 2, ground = null } = {}) {
const W = img.width, H = img.height;
const gray = new Float32Array(W * H);
for (let i = 0, j = 0; i < img.data.length; i += 4, j++) gray[j] = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
// ground is the page's, not the crop's: a region that is mostly a black
// arch on paper has a mid-gray median and every edge reads as ink
if (ground == null) {
const sample = []; for (let i = 0; i < gray.length; i += Math.max(1, Math.floor(gray.length / 5000))) sample.push(gray[i]);
sample.sort((a, b) => a - b); ground = sample[Math.floor(sample.length / 2)];
}
const ink = (x, y) => Math.abs(gray[y * W + x] - ground) > 60;
const sides = [];
// the longest contiguous run of ink along the edge, as a fraction of it:
// an arch cut by the box leaves a long unbroken contact; grain, a rule
// crossing, or a line of small type leave short ones
const run = (n, at) => { let best = 0, cur = 0; for (let i = 0; i < n; i++) { if (at(i)) { cur++; if (cur > best) best = cur; } else cur = 0; } return best / n; };
if (run(H, (y) => { for (let x = 0; x < band; x++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('left');
if (run(H, (y) => { for (let x = W - band; x < W; x++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('right');
if (run(W, (x) => { for (let y = 0; y < band; y++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('top');
if (run(W, (x) => { for (let y = H - band; y < H; y++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('bottom');
return sides;
}
/**
* Shrink a normalized box to the ink inside it (pixels far from the page
* ground), padded by `pad` px, never grown. Returns null when the crop has no
* ink or the ink fills the box already.
*/
export function snapBoxToInk(comp, box, ground, { pad = 6, minShrink = 0.06 } = {}) {
const px = { x: Math.round(box.x * comp.width), y: Math.round(box.y * comp.height), w: Math.round(box.w * comp.width), h: Math.round(box.h * comp.height) };
if (px.w < 8 || px.h < 8) return null;
const c = crop(comp, px.x, px.y, px.w, px.h);
const W = c.width, H = c.height;
let x0 = W, y0 = H, x1 = -1, y1 = -1;
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
const g = 0.299 * c.data[i] + 0.587 * c.data[i + 1] + 0.114 * c.data[i + 2];
if (Math.abs(g - ground) > 60) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
}
if (x1 < 0) return null;
// The bounding box of all ink cannot shed a neighbour that shares the
// span (a spine at the left edge, the next column's text at the right).
// Take the largest connected ink mass instead: cells of `cell` px are
// inked when 4% of their pixels are; 8-connected components; the one
// with the most inked cells is the element the region names.
const cell = Math.max(6, Math.round(Math.min(W, H) / 40));
const cw = Math.ceil(W / cell), ch = Math.ceil(H / cell);
const on = new Uint8Array(cw * ch), cnt = new Uint16Array(cw * ch);
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
const g = 0.299 * c.data[i] + 0.587 * c.data[i + 1] + 0.114 * c.data[i + 2];
if (Math.abs(g - ground) > 60) cnt[Math.floor(y / cell) * cw + Math.floor(x / cell)]++;
}
for (let i = 0; i < on.length; i++) on[i] = cnt[i] >= cell * cell * 0.04 ? 1 : 0;
// dilate by one cell so the letters of a word and the lines of a block
// join into one mass; a neighbouring column a few cells away stays apart
const grown = new Uint8Array(on.length);
for (let y = 0; y < ch; y++) for (let x = 0; x < cw; x++) {
if (!on[y * cw + x]) continue;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { const nx = x + dx, ny = y + dy; if (nx >= 0 && ny >= 0 && nx < cw && ny < ch) grown[ny * cw + nx] = 1; }
}
const mask = grown;
const label = new Int32Array(cw * ch).fill(-1);
let best = null;
for (let s0 = 0; s0 < on.length; s0++) {
if (!mask[s0] || label[s0] >= 0) continue;
const stack = [s0]; label[s0] = s0; let n = 0, bx0 = cw, by0 = ch, bx1 = -1, by1 = -1;
while (stack.length) {
const k = stack.pop();
const kx = k % cw, ky = (k / cw) | 0;
if (on[k]) { n += cnt[k]; if (kx < bx0) bx0 = kx; if (kx > bx1) bx1 = kx; if (ky < by0) by0 = ky; if (ky > by1) by1 = ky; }
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
const nx = kx + dx, ny = ky + dy; if (nx < 0 || ny < 0 || nx >= cw || ny >= ch) continue;
const nk = ny * cw + nx; if (mask[nk] && label[nk] < 0) { label[nk] = s0; stack.push(nk); }
}
}
// a mass touching the span's left or right edge continues past it (the
// spine, the next column); the element the region names sits inside.
// Prefer an inside mass unless the edge mass is far heavier.
const touchesSide = bx0 === 0 || bx1 === cw - 1;
const cand = { n, bx0, by0, bx1, by1, touchesSide };
if (!best) best = cand;
else if (best.touchesSide && !cand.touchesSide && cand.n * 3 >= best.n) best = cand;
else if (!best.touchesSide && cand.touchesSide && cand.n < best.n * 3) { /* keep inside */ }
else if (cand.n > best.n) best = cand;
}
if (best) { x0 = best.bx0 * cell; y0 = best.by0 * cell; x1 = Math.min(W - 1, (best.bx1 + 1) * cell - 1); y1 = Math.min(H - 1, (best.by1 + 1) * cell - 1); }
const nx0 = Math.max(0, x0 - pad), ny0 = Math.max(0, y0 - pad), nx1 = Math.min(W, x1 + 1 + pad), ny1 = Math.min(H, y1 + 1 + pad);
const shrink = 1 - ((nx1 - nx0) * (ny1 - ny0)) / (W * H);
if (shrink < minShrink) return null;
return { x: (px.x + nx0) / comp.width, y: (px.y + ny0) / comp.height, w: (nx1 - nx0) / comp.width, h: (ny1 - ny0) / comp.height };
}
function medianGray(img) {
const sample = [];
const step = Math.max(1, Math.floor((img.width * img.height) / 6000));
for (let j = 0; j < img.width * img.height; j += step) { const i = j * 4; sample.push(0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2]); }
sample.sort((a, b) => a - b);
return sample[Math.floor(sample.length / 2)];
}
function energyOf(img) {
const g = detailGrid(img, 4, 4, 256);
let s = 0; for (const v of g.cells) s += v;
return s / g.cells.length;
}
/**
* Grid cells (10x10) that carry ink the regions do not name. A regions file
* that omits the comp's callouts, notes block, or parts table makes those
* elements invisible to every later gate (they are never 'missing' if they
* were never named), so the spec refuses to close over them. Texture and
* band regions do not cover: a full-bleed paper texture names the ground,
* not the drawing on it.
*/
export function uncoveredInkCells(comp, regions) {
const grid = detailGrid(comp, 10, 10, 512);
const cells = [];
// The ground's own energy (paper grain, gradient) is the quietest tenth of
// cells; ink is anything clearly above that. Median-relative thresholds
// fail on textured comps where every cell carries grain.
const energies = [...grid.cells].sort((a, b) => a - b);
const ground = energies[Math.floor(energies.length * 0.1)] || 0;
const threshold = Math.max(4, ground * 2.2, ground + 12);
for (let r = 0; r < 10; r++) for (let c = 0; c < 10; c++) {
const e = grid.cells[r * 10 + c];
if (e < threshold) continue;
const cx = (c + 0.5) / 10, cy = (r + 0.5) / 10;
const covered = regions.some((reg) => { const b = reg.coverBox || reg.box; return reg.kind !== 'texture' && reg.kind !== 'band' && cx >= b.x && cx <= b.x + b.w && cy >= b.y && cy <= b.y + b.h; });
if (!covered) cells.push(`${COLS[c]}${r}`);
}
return cells;
}
export function measureRegions(comp, regionsInput, compPath) {
const regions = [];
const warnings = [];
const seen = new Set();
const pageGround = medianGray(comp);
for (const raw of regionsInput.regions || []) {
if (!raw.id) throw new Error('every region needs an id');
if (seen.has(raw.id)) throw new Error(`duplicate region id ${raw.id}`);
seen.add(raw.id);
const kind = raw.kind && KINDS.has(raw.kind) ? raw.kind : 'band';
// Every region says what it is. The note is what the plate prompt, the
// gate messages, and the painted-material check read; a regions file of
// bare ids and kinds is a list of boxes, and a session that named a
// carburetor drawing "chrome" with no note was caught by nothing.
if (kind !== 'band' && !(raw.note && String(raw.note).trim().length >= 8)) {
throw new Error(`region ${raw.id} has no note. Say in a few words what the comp shows there (the element, its material, its role): the note drives the plate prompt and the gate's messages, and a drawing named as chrome is only caught by what its note says.`);
}
// The note is the model's own reading of the region. A note that names
// painted material (a drawing, diagram, photo, illustration, texture)
// filed under a code kind is a plate about to be redrawn in SVG: the
// exploded carburetor "chrome" that the hero gate then scores missing.
// Refuse at the spec, where the fix is one word, not at the hero.
// Escape hatches persist into the spec and announce themselves: a
// refusal overridden in regions.json used to vanish from spec.json, so
// the shipped spec showed a clean classification with no trace (found in
// the ninth sweep, where both carburetor illustrations were filed as
// chrome behind codeDrawn: true).
for (const key of ['codeDrawn', 'container', 'bleed']) {
if (raw[key]) warnings.push(`region ${raw.id}: "${key}": true set in the regions file${key === 'codeDrawn' ? ' (the painted-material refusal is overridden: code draws this region)' : key === 'container' ? ' (the region-size refusal is overridden: one undivided element)' : ' (the clipped-artwork refusal is overridden: the page crops it there)'}`);
}
if (raw.note && !RASTER_KINDS.has(kind) && kind !== 'band' && PAINTED_NOTE.test(raw.note) && !raw.codeDrawn) {
throw new Error(`region ${raw.id} is kind "${kind}" but its note describes painted material ("${raw.note}"). Anything drawn, photographed, or textured ships as a raster plate: set kind to plate (illustration, diagram, figure), image (photograph), or texture (ground). If the note is wrong and code really draws it (a table, a rule, a chrome bar), reword the note or set "codeDrawn": true on the region.`);
}
let box = raw.box && typeof raw.box.x === 'number' ? raw.box : gridToBox(raw.grid);
// A grid span over-covers: a headline named B1:E4 carries the deck below
// it and a slice of the next column, and every measurement downstream
// (cap height, line count, structure) inherits that slop; a session
// wrote a note saying its hero sat at 67 because the boxes straddled
// elements, and it was right. Text and control regions snap to the ink
// inside their span (page ground as the reference, a small pad); plates,
// textures, chrome, and any region given an explicit box are left as
// drawn. The grid stays on the record.
let coverBox = null;
if (!raw.box && raw.grid && (kind === 'text' || kind === 'control') && raw.snap !== false) {
const snapped = snapBoxToInk(comp, box, pageGround);
if (snapped) { coverBox = box; box = snapped; }
}
// A code region is one element the page draws: a headline, a table, a
// button, a bar. A "chrome" region covering a third of the comp is a
// column, and a column scored as one region hides everything inside it
// (a session named seven regions for a page with three plates, a table,
// a note, callouts and a spine, and the hero gate could name nothing).
// Raster regions may be as large as the material; a texture is a sample.
const area = box.w * box.h;
if (!RASTER_KINDS.has(kind) && kind !== 'band' && area > MAX_CODE_REGION_AREA && !raw.container) {
throw new Error(`region ${raw.id} (${kind}) covers ${Math.round(area * 100)}% of the comp; a code region is one element (a headline, a table, a control, a rule, a bar), and one this large is a column holding several. Name each element inside it as its own region (every illustration or photo as a plate), or set "container": true on the region if it truly is one undivided element.`);
}
const px = { x: Math.round(box.x * comp.width), y: Math.round(box.y * comp.height), w: Math.round(box.w * comp.width), h: Math.round(box.h * comp.height) };
const c = crop(comp, px.x, px.y, px.w, px.h);
const energy = energyOf(c);
const raster = RASTER_KINDS.has(kind);
// A plate box that cuts through its own artwork is a plate the page will
// crop: object-fit: cover on that box shows the artwork with the side the
// box lost, and the hero passed a cover arch cut flat on the left and
// bleeding into the footer at 87%. Measure the artwork's edge contact
// and say it here, where the fix is a wider grid span.
// sides on the comp's own edge do not count: the comp crops there too
const atCompEdge = { left: px.x <= 1, top: px.y <= 1, right: px.x + px.w >= comp.width - 1, bottom: px.y + px.h >= comp.height - 1 };
const clipped = raster && kind !== 'texture' && !raw.bleed ? artworkTouchesEdges(c, { ground: pageGround }).filter((side) => !atCompEdge[side]) : [];
if (clipped.length) warnings.push(`region ${raw.id}: the artwork runs off the box on the ${clipped.join(' and ')} (its ink reaches the edge over ${EDGE_CONTACT_MIN * 100}% of that side). Widen the region so the box holds the whole shape with a margin; a plate placed with object-fit: cover on this box would be cut there.`);
regions.push({
id: raw.id,
kind,
note: raw.note || null,
grid: raw.grid || null,
codeDrawn: raw.codeDrawn ? true : undefined,
container: raw.container ? true : undefined,
bleed: raw.bleed ? true : undefined,
snap: raw.snap === false ? false : undefined,
coverBox: coverBox ? { x: r4(coverBox.x), y: r4(coverBox.y), w: r4(coverBox.w), h: r4(coverBox.h) } : undefined,
box: { x: r4(box.x), y: r4(box.y), w: r4(box.w), h: r4(box.h) },
px,
aspect: r4(px.w / px.h),
palette: paletteOf(c),
detail: { energy: r4(energy) },
medium: raw.medium || (raster ? 'raster' : 'semantic'),
clipped: clipped.length ? clipped : undefined,
plate: raster ? (raw.plate || path.join(PLATES_DIR, `${raw.id}.png`)) : null,
text: raw.text || null,
});
}
const uncovered = uncoveredInkCells(comp, regions);
if (uncovered.length > 3 && !regionsInput.allowUncovered) {
throw new Error(`grid cells ${uncovered.join(', ')} carry ink no region names. Every element the comp shows must be in a region (text, control, chrome, or a plate) so its absence in the build can be measured; add regions for them, or set "allowUncovered": true in the regions file after confirming those cells are empty ground.`);
}
return {
tool: 'comp-spec',
version: 1,
createdAt: new Date().toISOString(),
comp: compPath,
warnings,
uncoveredInkCells: uncovered,
compSize: { width: comp.width, height: comp.height },
aspect: r4(comp.width / comp.height),
orientation: comp.width >= comp.height ? 'landscape' : 'portrait',
palette: paletteOf(comp),
bands: horizontalBands(comp).filter((b) => b.strength > 0.2).map((b) => ({ y: r4(b.y), strength: r4(b.strength) })),
regions,
};
}
/** Propose regions from the comp's bands when no regions file exists yet. */
export function autoRegions(comp) {
const bands = horizontalBands(comp).filter((b) => b.strength > 0.2);
const cuts = [0, ...bands.map((b) => b.y), 1].filter((v, i, arr) => i === 0 || v - arr[i - 1] > 0.06);
if (cuts[cuts.length - 1] !== 1) cuts.push(1);
const regions = [];
for (let i = 0; i + 1 < cuts.length; i++) regions.push({ id: `band-${i + 1}`, kind: 'band', box: { x: 0, y: cuts[i], w: 1, h: cuts[i + 1] - cuts[i] } });
return { regions };
}
const r4 = (v) => Math.round(v * 10000) / 10000;
/**
* The comp crop of a raster region, with every overlapping semantic region
* (text, control, chrome) painted out in the crop's own ground color. The
* plate prompt tells the generator to remove UI text and chrome, so a good
* plate must be scored against a crop that has them removed too; otherwise
* the plate loses structure points for obeying the spec.
*/
export function plateReference(comp, spec, region) {
const c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h);
const ground = (region.palette && region.palette[0] && hexToRgb(region.palette[0].hex)) || [255, 255, 255];
for (const other of spec.regions || []) {
if (other.id === region.id || RASTER_KINDS.has(other.kind) || other.kind === 'band') continue;
const ox = Math.max(0, other.px.x - region.px.x), oy = Math.max(0, other.px.y - region.px.y);
const ox2 = Math.min(region.px.w, other.px.x + other.px.w - region.px.x), oy2 = Math.min(region.px.h, other.px.y + other.px.h - region.px.y);
if (ox2 <= ox || oy2 <= oy) continue;
fillRect(c, ox, oy, ox2 - ox, oy2 - oy, [...ground, 255]);
}
return c;
}
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || '');
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null;
}
export function platePrompt(spec, region) {
const world = spec.palette.slice(0, 3).map((c) => c.hex).join(', ');
const kindLine = region.kind === 'texture'
? 'This is a seamless surface texture. Output a tileable texture plate with no objects, no text, no vignette.'
: region.kind === 'image'
? 'This is a photographic or illustrated image region. Output the same subject, same framing, same lighting.'
: 'This is a designed illustration plate. Output the same drawing, same style, same line weight and shading.';
return [
'Use the provided crop as the approved visual reference and recreate it as a clean production asset at the target aspect ratio.',
kindLine,
`Preserve silhouette, composition, perspective, palette (${world}), lighting, material, and texture exactly.`,
'Remove every piece of UI text, label, caption, button, and interface chrome that is not part of the artwork itself.',
'Remove letterboxing, borders, card corners, drop shadows, and any layout background that the page will draw in code.',
'Do not add objects. Do not change the concept. Do not restyle. The artwork fills the whole frame edge to edge at the same scale as the reference; no margins, no border, no background band.',
region.note ? `Region: ${region.note}.` : '',
].filter(Boolean).join(' ');
}
export function printSpec(spec) {
const lines = [];
lines.push(`SPEC comp ${spec.comp} ${spec.compSize.width}x${spec.compSize.height} ${spec.orientation}`);
lines.push(`PALETTE ${spec.palette.map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
lines.push(`BANDS ${spec.bands.map((b) => `${Math.round(b.y * 100)}%`).join(' ') || 'none'}`);
for (const r of spec.regions) {
const b = r.box;
lines.push(`REGION ${r.id.padEnd(18)} ${r.kind.padEnd(8)} ${r.medium.padEnd(8)} box x${Math.round(b.x * 100)}% y${Math.round(b.y * 100)}% w${Math.round(b.w * 100)}% h${Math.round(b.h * 100)}% (${r.px.w}x${r.px.h}px, ${r.aspect}:1) palette ${r.palette.slice(0, 3).map((c) => c.hex).join(' ')}${r.plate ? ` plate ${r.plate}` : ''}${r.note ? ` # ${r.note}` : ''}`);
}
const plates = spec.regions.filter((r) => r.medium === 'raster');
lines.push(`PLATES ${plates.length} to produce: ${plates.map((r) => r.id).join(', ') || 'none'}`);
for (const w of spec.warnings || []) lines.push(`WARN ${w}`);
lines.push('RULE anything not in this list does not exist on the page: no borders, rules, chrome, or containers the comp does not show. Every raster region ships as its plate, never as CSS.');
return lines.join('\n');
}
export function loadSpec(specPath = SPEC_PATH) {
if (!fs.existsSync(specPath)) return null;
return JSON.parse(fs.readFileSync(specPath, 'utf8'));
}
async function main() {
const specPath = arg('spec', SPEC_PATH);
if (flag('help') || process.argv.length <= 2) {
console.log(`usage: comp-spec.mjs --comp <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands
comp-spec.mjs --comp <png> --regions <json> measure regions -> .impeccable/build/spec.json
regions json: { "regions": [ { "id": "art", "kind": "plate|image|texture|text|control|chrome", "grid": "E0:J4", "note": "..." } ] }
comp-spec.mjs --comp <png> --auto band regions when you have no regions file
comp-spec.mjs --print the compact spec
comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)
comp-spec.mjs --plate-prompt <id> the regeneration prompt for a raster region`);
return;
}
if (flag('print')) {
const spec = loadSpec(specPath);
if (!spec) { console.error(`comp-spec: no spec at ${specPath}; run with --comp <png> --regions <json> first`); process.exit(1); }
console.log(printSpec(spec));
return;
}
if (arg('plate-prompt')) {
const spec = loadSpec(specPath);
if (!spec) { console.error(`comp-spec: no spec at ${specPath}`); process.exit(1); }
const region = spec.regions.find((r) => r.id === arg('plate-prompt'));
if (!region) { console.error(`comp-spec: no region ${arg('plate-prompt')}`); process.exit(1); }
console.log(platePrompt(spec, region));
return;
}
if (arg('crop')) {
const spec = loadSpec(specPath);
if (!spec) { console.error(`comp-spec: no spec at ${specPath}`); process.exit(1); }
const region = spec.regions.find((r) => r.id === arg('crop'));
if (!region) { console.error(`comp-spec: no region ${arg('crop')}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); }
const comp = loadRaster(spec.comp).image;
let c = region.medium === 'raster' && !flag('raw') ? plateReference(comp, spec, region) : crop(comp, region.px.x, region.px.y, region.px.w, region.px.h);
const scale = parseFloat(arg('scale', '1'));
if (scale > 1) c = resize(c, c.width * scale, c.height * scale);
const out = arg('out', path.join(BUILD_DIR, 'crops', `${region.id}.png`));
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, encodePng(c, { text: { 'impeccable:crop-of': `${spec.comp}#${region.id}` } }));
console.log(`CROP ${out} (${c.width}x${c.height}) region ${region.id} of ${spec.comp}. Reference only: regenerate the plate from it, never ship it.`);
return;
}
const compPath = arg('comp');
if (!compPath) {
console.error('usage: comp-spec.mjs --comp <png> (--grid | --regions <json> | --auto) [--spec out.json]\n comp-spec.mjs --print | --crop <id> [--out file] [--scale n] | --plate-prompt <id>');
process.exit(1);
}
let comp;
try { comp = loadRaster(compPath).image; } catch (e) { console.error(`comp-spec: cannot read ${compPath}: ${e.message}`); process.exit(1); }
if (flag('grid')) {
fs.mkdirSync(path.dirname(GRID_PATH), { recursive: true });
fs.writeFileSync(GRID_PATH, encodePng(renderGrid(comp)));
console.log(`GRID ${GRID_PATH} (${comp.width}x${comp.height} comp; cells A0 top-left to J9 bottom-right)`);
console.log(`PALETTE ${paletteOf(comp).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
console.log(`BANDS ${horizontalBands(comp).filter((b) => b.strength > 0.2).map((b) => `${Math.round(b.y * 100)}%`).join(' ') || 'none'}`);
console.log('NEXT open the grid image, then write regions.json in exactly this shape and run --regions regions.json:');
console.log(' { "regions": [ { "id": "exploded-plate", "kind": "plate", "grid": "E0:H4", "note": "exploded carburetor drawing" }, { "id": "masthead", "kind": "chrome", "grid": "A0:J0", "note": "navy bar" } ] }');
console.log(' kind: plate | image | texture (painted material: every illustration, photograph, figure, product object, texture; each ships as a raster plate) or text | control | chrome (code draws it). grid: <colrow>:<colrow>, A0 top-left to J9 bottom-right, inclusive.');
console.log(' A texture region is a clean sample cell of the material (ground with no ink on it), not the whole band it covers; the page tiles it. Ink that sits on the material gets its own text/control region.');
return;
}
let regionsInput;
if (arg('regions')) {
try { regionsInput = JSON.parse(fs.readFileSync(arg('regions'), 'utf8')); } catch (e) { console.error(`comp-spec: cannot read regions ${arg('regions')}: ${e.message}`); process.exit(1); }
} else if (flag('auto')) {
regionsInput = autoRegions(comp);
} else {
console.error('comp-spec: pass --grid to get the coordinate grid, then --regions <json> (or --auto for band regions)');
process.exit(1);
}
let spec;
try { spec = measureRegions(comp, regionsInput, compPath); } catch (e) { console.error(`comp-spec: ${e.message}`); process.exit(1); }
fs.mkdirSync(path.dirname(specPath), { recursive: true });
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
console.log(`WROTE ${specPath}`);
console.log(printSpec(spec));
}
// realpath on both sides: a skill mounted through a symlink (Cursor, a
// worktree, an eval stage) must still run as a CLI.
const isMain = (() => {
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
catch { return !!process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); }
})();
if (isMain) main();
-457
View File
@@ -1,457 +0,0 @@
#!/usr/bin/env node
/**
* font-match: measure the lettering in a comp's text region and rank candidate
* faces against it, so the face is chosen by metrics instead of by name.
*
* node font-match.mjs --measure <region-id> [--spec .impeccable/build/spec.json]
* Fingerprints the comp crop of a text region (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."] [--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, 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
* headline face wider and lighter than the comp's, the parts list smaller,
* the footer heavier. All three are ratios a script can read off pixels.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { createHash } from 'node:crypto';
import { decodePng, encodePng, loadRaster } from './lib/png.mjs';
import { crop } from './lib/raster.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);
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = process.argv[i + 1];
return v && !v.startsWith('--') ? v : fallback;
}
// ---- fingerprint ----------------------------------------------------------
// 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.
/**
* 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 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(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';
}
/**
* 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 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. 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'],
condensed: ['League Gothic:400', 'Fjalla One:400', 'Anton:400', 'Bebas Neue:400', 'Oswald:600', 'Barlow Condensed:700', 'Roboto Condensed:800', 'Archivo Narrow:700', 'Pathway Gothic One:400', 'Big Shoulders Display:800', 'Teko:600', 'Sofia Sans Condensed:800'],
normal: ['Inter:700', 'Work Sans:700', 'IBM Plex Sans:700', 'Archivo:800', 'Public Sans:700', 'Source Sans 3:700', 'Roboto:900', 'Barlow:800', 'Manrope:800', 'Rubik:800'],
wide: ['Archivo Black:400', 'Syne:800', 'Space Grotesk:700', 'Unbounded:700', 'Bricolage Grotesque:800', 'Sora:800', 'Outfit:800', 'Lexend:800'],
};
/** Weight-shifted variants of a candidate list, 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);
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 };
}
/**
* A choice font-match wrote carries a stamp over its own fields, so the spec
* gate can tell a measured choice from a hand-typed one. Sessions with no
* browser wrote `"chosen": { "family": "Arial Narrow", "source": "system-fallback" }`
* straight into spec.json to get past the gate; that is the guess the gate
* exists to refuse. Not secret, just not something a model reaches for.
*/
export function stampChoice(regionId, chosen) {
const h = createHash('sha1').update(`font-match:${regionId}:${chosen.family}:${chosen.weight}:${chosen.fontSizePx}:${chosen.source}`).digest('hex').slice(0, 12);
return { ...chosen, stamp: h };
}
export function choiceStamped(regionId, chosen) {
if (!chosen || !chosen.stamp) return false;
return stampChoice(regionId, { ...chosen, stamp: undefined }).stamp === chosen.stamp;
}
// ---- browser --------------------------------------------------------------
/**
* Playwright and puppeteer write launch artifacts to os.tmpdir(). In a
* sandbox whose /tmp is not writable (the ninth sweep: EPERM on
* mkdtemp /tmp/playwright-artifacts-*), every rank silently fell back to the
* catalog and three of four builds set headlines at twice the comp's cap.
* Probe once and point TMPDIR at a workspace dir when the system one fails.
*/
function ensureWritableTmp() {
const os = require('node:os');
try { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'fm-')); fs.rmSync(d, { recursive: true, force: true }); return; } catch { /* not writable */ }
const local = path.resolve('.impeccable', 'tmp');
try { fs.mkdirSync(local, { recursive: true }); process.env.TMPDIR = local; process.env.TMP = local; process.env.TEMP = local; } catch { /* leave as is; launch will say why */ }
}
async function loadBrowser() {
ensureWritableTmp();
// IMPECCABLE_NODE_MODULES: a node_modules dir holding playwright or
// puppeteer, for harnesses that mount the skill somewhere its own resolution
// roots cannot see (a sandbox root, a plugin cache). NODE_PATH works too.
const extra = (process.env.IMPECCABLE_NODE_MODULES || '').split(path.delimiter).filter(Boolean);
const tries = [
...extra.map((dir) => () => require(require.resolve('playwright', { paths: [dir, path.dirname(dir)] }))),
() => require('playwright'),
() => require(require.resolve('playwright', { paths: [process.cwd()] })),
() => require(require.resolve('playwright', { paths: [path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')] })),
];
for (const t of tries) { try { const pw = t(); if (pw?.chromium) return { kind: 'playwright', mod: pw }; } catch { /* next */ } }
const tries2 = [
...extra.map((dir) => () => require(require.resolve('puppeteer', { paths: [dir, path.dirname(dir)] }))),
() => require('puppeteer'),
() => require(require.resolve('puppeteer', { paths: [process.cwd()] })),
() => require(require.resolve('puppeteer', { paths: [path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')] })),
];
for (const t of tries2) { try { const pp = t(); if (pp?.launch) return { kind: 'puppeteer', mod: pp }; } catch { /* next */ } }
return null;
}
function parseCandidates(s) {
return String(s || '').split(',').map((x) => x.trim()).filter(Boolean).map((x) => {
const m = /^(.*?)(?::(\d{3}))?$/.exec(x);
return { family: m[1].trim(), weight: m[2] ? parseInt(m[2], 10) : 400 };
});
}
/** Render `text` in each candidate at a font-size whose measured cap height ~= targetCapPx; return fingerprints. */
export async function renderCandidates(candidates, text, targetCapPx, { transform = 'none' } = {}) {
const b = await loadBrowser();
if (!b) return null;
// A resolvable module whose browser binary is absent (CI, a fresh install
// without npx playwright install) throws at launch; that is the same
// situation as no module, and the catalog fallback owns it.
let browser;
try { browser = b.kind === 'playwright' ? await b.mod.chromium.launch() : await b.mod.launch({ headless: true }); } catch { return null; }
// One stylesheet per family+weight: a combined request 400s when any one
// family lacks the requested axis (Anton has no wght range), and a static
// family answers only for the weights it ships.
const links = candidates.map((c) => `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(c.family).replace(/%20/g, '+')}:wght@${c.weight}&display=block">`).join('');
const html = `<!doctype html><html><head><meta charset="utf-8">${links}<style>body{margin:0;background:#fff}div.s{position:absolute;left:0;top:0;white-space:nowrap;color:#000;line-height:1;padding:8px;text-transform:${transform}}</style></head><body></body></html>`;
const results = [];
const size0 = Math.max(12, Math.round(targetCapPx * 1.4));
if (b.kind === 'playwright') {
const page = await browser.newPage({ viewport: { width: 1600, height: 400 }, deviceScaleFactor: 1 });
await page.setContent(html, { waitUntil: 'load' });
await page.waitForTimeout(800);
for (const c of candidates) {
// two passes: measure at size0, then rescale so the fingerprint's cap height matches the comp
let size = size0, fp = null, ok = true;
for (let pass = 0; pass < 2; pass++) {
await page.evaluate(({ family, weight, size, text }) => {
document.body.innerHTML = `<div class="s" style="font-family:'${family}',sans-serif;font-weight:${weight};font-size:${size}px">${text}</div>`;
}, { family: c.family, weight: c.weight, size, text });
let loaded = false;
// Loaded means a real face of this family covers the requested weight;
// fonts.check() answers true for a synthetic bold of a lighter file.
try {
loaded = await page.evaluate(async (f) => {
const faces = await document.fonts.load(`${f.weight} 32px '${f.family}'`);
await document.fonts.ready;
const covers = (face) => { const w = String(face.weight || '400').split(/\s+/).map(Number); const lo = w[0], hi = w[1] ?? w[0]; return f.weight >= lo - 50 && f.weight <= hi + 50; };
return faces.some((face) => face.family.replace(/["']/g, '') === f.family && face.status === 'loaded' && covers(face));
}, c);
} catch { loaded = false; }
await page.waitForTimeout(100);
if (!loaded) ok = false;
const box = await page.evaluate(() => { const r = document.querySelector('div.s').getBoundingClientRect(); return { w: Math.ceil(r.width) + 8, h: Math.ceil(r.height) + 8 }; });
const buf = await page.screenshot({ clip: { x: 0, y: 0, width: Math.min(1600, box.w), height: Math.min(400, box.h) } });
fp = fingerprint(decodePng(buf));
if (!fp || pass === 1) break;
size = Math.max(8, Math.round(size * (targetCapPx / fp.capHeightPx)));
}
results.push({ ...c, loaded: ok, fontSizePx: size, fp });
}
await browser.close();
} else {
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 400 });
await page.setContent(html, { waitUntil: 'load' });
await new Promise((r) => setTimeout(r, 800));
for (const c of candidates) {
let size = size0, fp = null, ok = true;
for (let pass = 0; pass < 2; pass++) {
await page.evaluate(({ family, weight, size, text }) => {
document.body.innerHTML = `<div class="s" style="font-family:'${family}',sans-serif;font-weight:${weight};font-size:${size}px">${text}</div>`;
}, { family: c.family, weight: c.weight, size, text });
let loaded = false;
// Loaded means a real face of this family covers the requested weight;
// fonts.check() answers true for a synthetic bold of a lighter file.
try {
loaded = await page.evaluate(async (f) => {
const faces = await document.fonts.load(`${f.weight} 32px '${f.family}'`);
await document.fonts.ready;
const covers = (face) => { const w = String(face.weight || '400').split(/\s+/).map(Number); const lo = w[0], hi = w[1] ?? w[0]; return f.weight >= lo - 50 && f.weight <= hi + 50; };
return faces.some((face) => face.family.replace(/["']/g, '') === f.family && face.status === 'loaded' && covers(face));
}, c);
} catch { loaded = false; }
await new Promise((r) => setTimeout(r, 100));
if (!loaded) ok = false;
const box = await page.evaluate(() => { const r = document.querySelector('div.s').getBoundingClientRect(); return { w: Math.ceil(r.width) + 8, h: Math.ceil(r.height) + 8 }; });
const buf = await page.screenshot({ clip: { x: 0, y: 0, width: Math.min(1600, box.w), height: Math.min(400, box.h) } });
fp = fingerprint(decodePng(buf));
if (!fp || pass === 1) break;
size = Math.max(8, Math.round(size * (targetCapPx / fp.capHeightPx)));
}
results.push({ ...c, loaded: ok, fontSizePx: size, fp });
}
await browser.close();
}
return results;
}
/** Comp crop over the top candidates, rendered at the comp's cap height, as one PNG. */
export async function renderProofSheet(compCrop, top, text, capPx, transform = 'none') {
const b = await loadBrowser();
if (!b || b.kind !== 'playwright') return null;
const compB64 = Buffer.from(encodePng(compCrop)).toString('base64');
const links = top.map((c) => `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(c.family).replace(/%20/g, '+')}:wght@${c.weight}&display=block">`).join('');
const rowsHtml = top.map((c) => `<div class="row"><div class="lab">${c.family} ${c.weight} · ${c.fontSizePx}px</div><div class="s" style="font-family:'${c.family}';font-weight:${c.weight};font-size:${c.fontSizePx}px;text-transform:${transform}">${text}</div></div>`).join('');
const html = `<!doctype html><html><head><meta charset="utf-8">${links}<style>body{margin:0;background:#fff;padding:12px;font-family:system-ui}img{display:block;max-width:100%}.lab{font:12px system-ui;color:#666;margin:10px 0 2px}.s{white-space:nowrap;line-height:1.05;color:#111}</style></head><body><div class="lab">COMP</div><img src="data:image/png;base64,${compB64}">${rowsHtml}</body></html>`;
let browser;
try { browser = await b.mod.chromium.launch(); } catch { return null; }
const page = await browser.newPage({ viewport: { width: Math.min(1600, Math.max(600, compCrop.width + 24)), height: 200 } });
await page.setContent(html, { waitUntil: 'load' });
try { await page.evaluate(async () => { await document.fonts.ready; }); } catch { /* ignore */ }
await page.waitForTimeout(600);
const buf = await page.screenshot({ fullPage: true });
await browser.close();
return buf;
}
// ---- CLI ------------------------------------------------------------------
function describe(fp) {
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() {
const specPath = arg('spec', SPEC_PATH);
const spec = loadSpec(specPath);
const measureId = arg('measure'), rankId = arg('rank');
const id = measureId || rankId;
if (!id) {
console.error('usage: font-match.mjs --measure <text-region-id> | --rank <text-region-id> [--candidates "Family:700,Family2:400,..."] [--text "..."] [--transform uppercase] [--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); }
const region = spec.regions.find((r) => r.id === id);
if (!region) { console.error(`font-match: no region ${id}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); }
const comp = loadRaster(spec.comp).image;
const c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h);
const fp = fingerprint(c);
if (!fp) {
// Record the attempt so the spec gate does not ask again; a region with
// no separable glyphs (a rule, a bar of solid ink, a very small label at
// comp resolution) is measured as "no lettering" and the model sizes it
// by its box.
region.type = { ...(region.type || {}), comp: null, measuredAt: new Date().toISOString(), note: 'no separable lettering in the crop; size by the region box' };
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
console.log(`MEASURE ${id}: no separable lettering in the region crop at comp resolution; size this text by its box (${region.px.w}x${region.px.h}px) and inherit face and weight from the nearest measured region.`);
process.exit(0);
}
region.type = { ...(region.type || {}), comp: 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)} ${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 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', fp.allCaps ? 'uppercase' : 'none');
const results = await renderCandidates(candidates, text, fp.capHeightPx, { transform });
if (!results) {
// No browser: the catalog fingerprint index is the ranking. Its top hit is
// recorded as the chosen face (source `catalog`) so the spec gate has a
// measured choice to close on; without this the gate refused forever and
// sessions forced past it or spent ten turns installing Playwright.
// font-size is estimated from the cap height at a 0.70 cap/em ratio, the
// sans display median; the NOTE says to check one rendered word.
if (index && catalog[0]) {
const best = catalog[0];
const fontSizePx = Math.round(fp.capHeightPx / 0.70);
console.log(`RANK unavailable: no browser (playwright or puppeteer) resolvable from this project or the impeccable CLI; the CATALOG order stands as the ranking.`);
console.log(`USE font-family: '${best.family}'; font-weight: ${best.weight}; font-size: ${fontSizePx}px;${transform !== 'none' ? ` text-transform: ${transform};` : ''} NOTE font-size is estimated (cap ${fp.capHeightPx}px / 0.70); render one headline word at that size, compare its cap height to the comp crop, and correct the size before building on it.`);
region.type.chosen = stampChoice(id, { family: best.family, weight: best.weight, fontSizePx, source: 'catalog', estimatedSize: true });
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
return;
}
console.log(`RANK unavailable: no browser (playwright or puppeteer) resolvable from this project or the impeccable CLI, and no catalog index. Choose by the MEASURE line: match the width class first, then the weight class; render one headline word against the comp before building on it.`);
return;
}
// Drop faces that never loaded (a weight the family does not ship falls
// back to a system face and would rank as that face), then collapse
// duplicate renders (two requested weights that resolved to one file).
const seenFp = new Set();
const rows = results
.filter((r) => r.fp && r.loaded)
.map((r) => ({ ...r, d: distance(fp, r.fp) }))
.filter((r) => Number.isFinite(r.d))
.sort((a, b) => a.d - b.d)
.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) {
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 {
const top = rows.slice(0, 3);
const sheet = await renderProofSheet(c, top, text, fp.capHeightPx, transform);
if (sheet) {
const out = path.join(path.dirname(specPath), 'font-match', `${id}.png`);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, sheet);
console.log(`PROOF ${out} (comp crop, then the top ${top.length} candidates at the comp's cap height; open it before choosing)`);
}
} catch { /* proof sheet is best-effort */ }
const best = rows[0];
if (best) {
const advice = [];
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 = stampChoice(id, { family: best.family, weight: best.weight, fontSizePx: best.fontSizePx, source, fp: compactFp(best.fp) });
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
}
}
const isMain = (() => {
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
catch { return false; }
})();
if (isMain) main().catch((e) => { console.error(`font-match: ${e.message}`); process.exit(1); });
-564
View File
@@ -1,564 +0,0 @@
/**
* 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);
let thr = otsu(g);
let dark = 0; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++;
// a two-level raster (no antialiasing) puts the Otsu threshold on the dark
// level itself; step it up so that level counts as ink
if (!dark) { thr += 1; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++; }
const inkIsDark = dark <= g.data.length / 2;
const ink = new Uint8Array(g.data.length);
let sI = 0, nI = 0, sG = 0, nG = 0;
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;
// Columns inked top to bottom (a rule, a black margin, a page edge) span
// every line and would fuse them into one run: leave them out of the row
// profile. Lettering never fills a column for more than ~85% of the crop.
const colInk = new Uint32Array(W);
for (let y = 0; y < H; y++) { const o = y * W; for (let x = 0; x < W; x++) colInk[x] += ink[o + x]; }
const colOk = new Uint8Array(W);
let okCount = 0;
for (let x = 0; x < W; x++) { if (colInk[x] < H * 0.85) { colOk[x] = 1; okCount++; } }
if (!okCount) return { lines: [], rowInk: new Uint32Array(H) };
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++) if (colOk[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; }
// A drawing or photo sharing the crop with body copy is one tall, massive
// 'line' that would carry maxMass and drop every real line under the 30%
// rule (a 461x307 thread crop measured as one 160px 'cap' off a
// carburetor drawing). When several lines exist, ones far taller than the
// median are not lettering: leave them out of the mass reference and out
// of the result.
// The median is taken over lines carrying real mass (rule slivers and
// tittles do not vote), and needs three of them: two 145px headline lines
// above a 26px artist line were dropped as 'tall' against a median pulled
// to 28 by three slivers.
const massMax = Math.max(1, ...lines.map((l) => l.mass));
const real = lines.filter((l) => l.mass >= massMax * 0.05);
if (real.length >= 3) {
const hs = real.map((l) => l.y1 - l.y0).sort((a, b) => a - b);
const medH = hs[Math.floor(hs.length / 2)];
for (const ln of lines) if (ln.y1 - ln.y0 > medH * 3) ln.tall = true;
}
const maxMass = Math.max(0, ...lines.filter((l) => !l.tall).map((l) => l.mass));
const merged = [];
for (let i = 0; i < lines.length; i++) {
const ln = lines[i];
if (ln.tall) continue;
if (ln.mass >= maxMass * 0.3) { merged.push({ y0: ln.y0, y1: ln.y1, mass: ln.mass }); 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, ln };
}
/** 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;
// One crop, one case. In a multi-line all-caps headline one line can grow a
// spurious x-height from crossbars (the A and E arms of "JAPANESE" at 0.32R)
// while its neighbours report none; that line then measures its stems and
// its x band on the crossbar zone. Lines vote: when most lines see no
// x-height, none does.
const metrics = lines.map((ln) => lineMetrics(bin, ln)).filter(Boolean);
if (metrics.length >= 2) {
const withX = metrics.filter((m) => m.xh).length;
if (withX * 2 <= metrics.length) for (const m of metrics) m.xh = null;
}
for (const m of metrics) {
const ln = m.ln;
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 when no lettering is found. Upsamples (bilinear) when the
* cap height is under 24px so runs and edges are measured on finer pixels.
*/
/**
* Keep the dominant lettering in a region crop: the lines whose cap height is
* within `tol` of the tallest, clipped horizontally to their own ink. A comp
* region drawn on a 10x10 grid over-covers: the headline crop carries the
* first line of body copy below it and a slice of the neighbouring column,
* and every one of those small letters pulls stem width, run lengths and the
* x-height vote toward a lighter, wider face. Returns { lines, x0, x1 } in
* the binarized image, or null when nothing survives.
*/
export function isolateDominant(bin, lines, { tol = 0.28 } = {}) {
const ms = lines.map((ln) => ({ ln, m: lineMetrics(bin, ln) })).filter((x) => x.m);
if (!ms.length) return null;
// The dominant class is the one holding most of the ink, not the tallest
// line: a body-copy crop that clips the last line of the headline above it
// is body copy. Cluster caps within tol of each other and pick the cluster
// with the most ink mass; the tallest wins only a tie.
const clusters = [];
for (const x of [...ms].sort((a, b) => b.m.cap - a.m.cap)) {
const c = clusters.find((cl) => Math.abs(cl.cap - x.m.cap) <= cl.cap * tol);
if (c) { c.items.push(x); c.mass += x.ln.mass || 0; } else clusters.push({ cap: x.m.cap, items: [x], mass: x.ln.mass || 0 });
}
// Mass per line-height, so one heavy display line does not outvote five
// lines of body copy; and a cluster of a single clipped line never wins
// over a cluster of three or more.
for (const c of clusters) { c.rows = c.items.reduce((n, x) => n + (x.ln.y1 - x.ln.y0), 0); c.density = c.mass / Math.max(1, c.rows); c.n = c.items.length; }
clusters.sort((a, b) => {
const aMulti = a.n >= 3, bMulti = b.n >= 3;
if (aMulti !== bMulti) return aMulti ? -1 : 1;
return (b.mass - a.mass) || (b.cap - a.cap);
});
const keep = clusters[0].items;
const capMax = Math.max(...keep.map((x) => x.m.cap));
// horizontal extent of the kept lines' tallest ink columns only: a small
// column of body text beside the headline shares its rows but not its height
const { W, ink } = bin;
let x0 = W, x1 = 0;
for (const { ln, m } of keep) {
const top = Math.round(m.base - m.cap * 0.75);
for (let x = m.xL; x < m.xR; x++) {
let tall = false;
for (let y = top; y < m.base && !tall; y++) if (ink[y * W + x]) tall = true;
if (!tall) continue;
// a column is headline ink when a run of at least 0.5 cap of ink stands in it
let run = 0, best = 0;
for (let y = ln.y0; y < ln.y1; y++) { if (ink[y * W + x]) { run++; if (run > best) best = run; } else run = 0; }
if (best >= m.cap * 0.5) { if (x < x0) x0 = x; if (x + 1 > x1) x1 = x + 1; }
}
}
if (x1 <= x0) return null;
// grow the box by half a cap so glyph sides and the tracking gap survive
const pad = Math.round(capMax * 0.5);
return { lines: keep.map((x) => x.ln), x0: Math.max(0, x0 - pad), x1: Math.min(W, x1 + pad), dropped: ms.length - keep.length };
}
function maskOutside(bin, x0, x1, lines) {
const { W, H, ink, covA } = bin;
const keepRow = new Uint8Array(H);
for (const ln of lines) for (let y = ln.y0; y < ln.y1; y++) keepRow[y] = 1;
const ink2 = new Uint8Array(ink.length), cov2 = new Float32Array(covA.length);
for (let y = 0; y < H; y++) {
if (!keepRow[y]) continue;
for (let x = x0; x < x1; x++) { const i = y * W + x; ink2[i] = ink[i]; cov2[i] = covA[i]; }
}
return { ...bin, ink: ink2, covA: cov2, cov: (i) => cov2[i] };
}
export function fingerprint(img, { minCap = 24, minGlyphs = 3, isolate = true } = {}) {
let bin = binarize(img);
let { lines } = findLines(bin);
if (!lines.length) return null;
let isolated = 0;
if (isolate && lines.length > 1) {
const iso = isolateDominant(bin, lines);
if (iso && (iso.dropped > 0 || iso.x1 - iso.x0 < bin.W * 0.9)) {
bin = maskOutside(bin, iso.x0, iso.x1, iso.lines);
lines = iso.lines;
isolated = iso.dropped;
}
}
let f = measure(bin, lines);
// fewer than minGlyphs separable glyphs is not lettering (a rule, a solid
// bar, one letterform): callers read null as "no separable lettering"
if (!f || f.glyphs < minGlyphs) return null;
let scale = 1;
if (f.capHeightPx < minCap && f.capHeightPx >= 4) {
scale = Math.min(4, Math.ceil(minCap / f.capHeightPx));
const up = resize(img, img.width * scale, img.height * scale);
bin = binarize(up);
lines = findLines(bin).lines;
// the upsample re-reads the whole crop: isolate again so the clipped
// headline or the drawing does not come back at scale
if (isolate && lines.length > 1) {
const iso2 = isolateDominant(bin, lines);
if (iso2 && (iso2.dropped > 0 || iso2.x1 - iso2.x0 < bin.W * 0.9)) { bin = maskOutside(bin, iso2.x0, iso2.x1, iso2.lines); lines = iso2.lines; isolated = Math.max(isolated, iso2.dropped); }
}
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, isolatedFrom: isolated, weight: f.densTall == null && f.densX == null ? null : +(f.densTall ?? f.densX).toFixed(4) };
for (const k of FEATURES) r[k] = f[k] == null ? null : +f[k].toFixed(4);
return r;
}
/**
* 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. */
/**
* The two readings a designer makes before any detail: how wide, how heavy.
* Width from the advance of tall glyphs (all-caps crops) or x-height glyphs;
* weight from ink density of tall glyphs. Both are on the same scale in the
* comp crop and in a catalog render, so their gap is a plain ratio. Distance
* adds a penalty that grows with the ratio's log: a face 50% wider or 35%
* lighter than the comp cannot rank above one that is right on both, whatever
* its run-length profile says. Weighted like three fine features (the width
* gap and the weight gap each score up to zClip x 1.5).
*/
export function grossGap(a, b) {
const pick = (f, keys) => { for (const k of keys) if (f[k] != null) return { k, v: f[k] }; return null; };
const wa = pick(a, ['advX', 'advTall', 'advance']), wb = wa ? (b[wa.k] != null ? { k: wa.k, v: b[wa.k] } : null) : null;
const ha = pick(a, ['densTall', 'densX', 'stemW']), hb = ha ? (b[ha.k] != null ? { k: ha.k, v: b[ha.k] } : null) : null;
const gap = (x, y) => (x && y && x.v > 0 && y.v > 0 ? Math.abs(Math.log(y.v / x.v)) : null);
return { width: gap(wa, wb), weight: gap(ha, hb) };
}
export const GROSS_STD = { width: 0.12, weight: 0.12 }; // one "step" of width class or weight class, in log ratio
export const GROSS_W = 1.5;
export function distance(a, b, stats = STATS, { p = 1, zClip = Z_CLIP, gross = true } = {}) {
let d = 0, wsum = 0;
if (gross) {
const g = grossGap(a, b);
for (const k of ['width', 'weight']) {
if (g[k] == null) continue;
const z = Math.min(zClip, g[k] / GROSS_STD[k]);
d += GROSS_W * (p === 1 ? z : z * z); wsum += GROSS_W;
}
}
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 }; });
}
-130
View File
@@ -1,130 +0,0 @@
/**
* 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');
/** Cap heights the catalog is rendered at. `48c` is the same 48px cap in ALL
* CAPS text (schema 2), queried for caps crops; a schema-1 index without it
* routes caps crops to the mixed-case 48 as before. */
export const INDEX_SIZES = [48, 14, '48c'];
/** 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 GROSS_FEATURES = ['advance', 'advTall', 'advX', 'densTall', 'densX', 'stemW'];
export const INDEX_FEATURES = FEATURES.filter((k) => (STATS[k] && STATS[k].w > 0) || GROSS_FEATURES.includes(k));
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, { allCaps = false } = {}) {
const numeric = sizes.filter((s) => typeof s === 'number').sort((a, b) => a - b);
if (allCaps && capHeightPx >= ROUTE_CAP_PX && sizes.includes('48c')) return '48c';
return capHeightPx < ROUTE_CAP_PX ? numeric[0] : numeric[numeric.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.
*/
/**
* Faces that are not lettering: barcodes, redaction bars, placeholder "flow"
* text, dingbats, symbol fonts, and effect faces (outlines, shades, glitch,
* pixel, 3D) whose fingerprint lands near heavy condensed text without being
* usable as it. A comp headline never wants them; a caller who does can pass
* them by name in `--candidates`.
*/
export const NON_TEXT_FAMILY = /barcode|^redacted|^flow (block|circular|rounded)|dings|symbols|^bungee (hairline|outline|shade|spice)|^rubik (80s|beastly|broken|bubbles|burned|dirt|distressed|doodle|gemstones|glitch|iso|lines|marker|maze|microbe|moonrocks|pixels|puddles|scribble|spray|storm|vinyl|wet)|^(nabla|honk|kablammo|sixtyfour|workbench|codystar|rock 3d|zen dots|ballet|butcherman|creepster|eater|faster one|frijole|nosifer|metal mania|miltonian)/i;
export function candidatesFromIndex(fp, index, { n = 25, category = null, perFamily = 2, includeNonText = false } = {}) {
if (!fp || !index) return [];
const size = routeSize(fp.capHeightPx, index.sizes, { allCaps: !!fp.allCaps });
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;
if (!includeNonText && NON_TEXT_FAMILY.test(e.family)) 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;
}
-246
View File
@@ -1,246 +0,0 @@
/**
* Hero-gate checks that name a miss as a number the model can act on.
*
* comp-diff scores regions; these read what a designer reads when the two
* frames sit side by side and says it as numbers: the headline is set at
* cap 78px where the comp's is 103; it wraps to four lines where the comp
* has three; its ink is #2a2a2a where the comp's is #a72f1b; it starts
* 60px lower in its box; the masthead is 92px tall where the comp's is 58;
* these grid cells carry ink the comp does not have (a kicker, a divider, a
* second nav row). Every one of those was a pin in the first human review
* of the third sweep, on builds the region scores had already let through.
*
* All functions are pure over decoded rasters and the spec; the gate wires
* them and decides what vetoes.
*/
import { fingerprint } from './font-fingerprint.mjs';
import { crop } from './raster.mjs';
import { dominantColors, deltaE, detailGrid } from './image-metrics.mjs';
import { inkBox } from '../comp-diff.mjs';
/** Dominant ink colour of a crop: the heaviest cluster that is not the ground. */
export function inkColor(img) {
const cols = dominantColors(img, 4);
if (!cols.length) return null;
const ground = cols[0];
const ink = cols.find((c) => c !== ground && deltaE(c.lab, ground.lab) > 20) || null;
return { ground, ink };
}
/**
* Compare one text region's build crop against the comp's measurement.
* `region` is a spec region with `type.comp` (font-match --measure) and
* `px`; `compCrop` / `buildCrop` are the region crops at comp scale.
* Returns { findings: string[], metrics }.
*/
export function textRegionCheck(region, compCrop, buildCrop, { capTol = 0.22, minCap = 10 } = {}) {
const findings = [];
// Measure the comp crop now rather than trusting spec.type.comp: the spec
// may carry an older fingerprint's reading, and this check has to agree
// with itself on both sides.
const comp = fingerprint(compCrop);
// colour reads on any text region, measured or not: a spine set vertical
// (unmeasurable) came back white on red where the comp had black on red
// in five builds
const colourOnly = () => {
const ca = inkColor(compCrop), cb = inkColor(buildCrop);
if (ca && cb && ca.ink && cb.ink && deltaE(ca.ink.lab, cb.ink.lab) > 22) findings.push(`text ${region.id}: ink is ${cb.ink.hex} in the build, ${ca.ink.hex} in the comp; use the comp's colour`);
return { findings, metrics: null };
};
if (!comp || !comp.capHeightPx || comp.capHeightPx < minCap || comp.glyphs < 6) return colourOnly();
// rotated type (a spine set vertical) reads as many short 'lines' of one
// or two glyphs; the fingerprint has nothing to say about it
if (comp.lines >= 5 && comp.glyphs / comp.lines < 3) return colourOnly();
// a cap taller than half the box is a drawing read as a glyph, not type
if (comp.capHeightPx > compCrop.height * 0.6) return colourOnly();
const bfp = fingerprint(buildCrop);
const metrics = { comp: { cap: comp.capHeightPx, lines: comp.lines, glyphs: comp.glyphs }, build: bfp ? { cap: bfp.capHeightPx, lines: bfp.lines, glyphs: bfp.glyphs } : null };
if (!bfp || bfp.glyphs < 4) {
// nothing legible in the box: comp-diff's missing/contradicted covers it
return { findings, metrics };
}
const capDelta = (bfp.capHeightPx - comp.capHeightPx) / comp.capHeightPx;
if (Math.abs(capDelta) > capTol) {
findings.push(`text ${region.id}: cap height ${bfp.capHeightPx}px in the build, ${comp.capHeightPx}px in the comp (${capDelta > 0 ? '+' : ''}${Math.round(capDelta * 100)}%); set font-size so the cap height renders at ${comp.capHeightPx}px${region.type.chosen ? ` (font-match ranked ${region.type.chosen.family} ${region.type.chosen.weight} at ${region.type.chosen.fontSizePx}px)` : ''}`);
}
if (comp.lines >= 2 && bfp.lines !== comp.lines && Math.abs(bfp.lines - comp.lines) >= 1) {
findings.push(`text ${region.id}: ${bfp.lines} line${bfp.lines === 1 ? '' : 's'} in the build, ${comp.lines} in the comp; the measure (max-width, font-size, letter-spacing) wraps it differently, so the block is a different shape`);
} else if (comp.lines >= 3 && bfp.lines === comp.lines && Math.abs(capDelta) <= capTol) {
// same lines at the same size: the leading is the remaining shape
const ba0 = inkBox(compCrop), bb0 = inkBox(buildCrop);
if (ba0 && bb0) {
const pa = ba0.h / comp.lines, pb = bb0.h / bfp.lines;
const dp = (pb - pa) / pa;
if (Math.abs(dp) > 0.2) findings.push(`text ${region.id}: line pitch ${Math.round(pb)}px in the build, ${Math.round(pa)}px in the comp (${dp > 0 ? '+' : ''}${Math.round(dp * 100)}%); set line-height so ${comp.lines} lines stand ${Math.round(ba0.h)}px tall`);
}
}
// tracking: the gap between glyphs in cap units, when both sides read it
if (comp.gap != null && bfp.gap != null && Math.abs(capDelta) <= capTol && comp.glyphs >= 8 && bfp.glyphs >= 8) {
const dg = bfp.gap - comp.gap;
if (Math.abs(dg) > Math.max(0.03, comp.gap * 0.5)) findings.push(`text ${region.id}: letter-spacing is ${dg > 0 ? 'wider' : 'tighter'} than the comp's (gap ${bfp.gap.toFixed(3)} vs ${comp.gap.toFixed(3)} of the cap height); set letter-spacing to ${dg > 0 ? 'close' : 'open'} it by about ${Math.abs(Math.round(dg * comp.capHeightPx))}px`);
}
// weight: compare ink density of tall glyphs when both sides have it and
// the sizes agree (density at a different cap is a different reading)
if (comp.densTall != null && bfp.densTall != null && Math.abs(capDelta) <= capTol) {
const r = bfp.densTall / comp.densTall;
if (r > 1.25) findings.push(`text ${region.id}: the face renders ${Math.round((r - 1) * 100)}% heavier than the comp's (ink density ${bfp.densTall.toFixed(2)} vs ${comp.densTall.toFixed(2)}); drop a weight step or use the ranked face`);
else if (r < 0.75) findings.push(`text ${region.id}: the face renders ${Math.round((1 - r) * 100)}% lighter than the comp's (ink density ${bfp.densTall.toFixed(2)} vs ${comp.densTall.toFixed(2)}); raise a weight step or use the ranked face`);
}
// colour: dominant ink of each crop. Small type on a ruled or grainy
// ground (a track row across staff lines at cap 14) has no reliable ink
// cluster; the reading fired both ways on neighbouring rows of one list.
if (comp.capHeightPx >= 16) {
const ca = inkColor(compCrop), cb = inkColor(buildCrop);
if (ca && cb && ca.ink && cb.ink) {
const d = deltaE(ca.ink.lab, cb.ink.lab);
if (d > 22) findings.push(`text ${region.id}: ink is ${cb.ink.hex} in the build, ${ca.ink.hex} in the comp; use the comp's colour`);
}
}
// vertical placement inside the box: top of ink
const ba = inkBox(compCrop), bb = inkBox(buildCrop);
if (ba && bb) {
const dy = bb.y - ba.y;
if (Math.abs(dy) > Math.max(12, compCrop.height * 0.15)) findings.push(`text ${region.id}: its first line starts ${Math.abs(Math.round(dy))}px ${dy > 0 ? 'lower' : 'higher'} than in the comp (${bb.y}px vs ${ba.y}px into the region box); the spacing above it is ${dy > 0 ? 'too large' : 'too small'}`);
const dx = bb.x - ba.x;
if (Math.abs(dx) > Math.max(12, compCrop.width * 0.15)) findings.push(`text ${region.id}: it starts ${Math.abs(Math.round(dx))}px ${dx > 0 ? 'further right' : 'further left'} than in the comp`);
}
metrics.capDelta = +capDelta.toFixed(3);
return { findings, metrics };
}
/**
* Rows of a crop that carry a horizontal rule: a row whose gray step from
* the row above (or below) is strong across at least `span` of the width.
* Returns row indices sorted top to bottom.
*/
export function ruleRows(img, { span = 0.5, step = 28 } = {}) {
const W = img.width, H = img.height;
const gray = (x, y) => { const i = (y * W + x) * 4; return 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2]; };
const rows = [];
for (let y = 1; y < H - 1; y++) {
let strong = 0;
for (let x = 0; x < W; x++) { const d = Math.max(Math.abs(gray(x, y) - gray(x, y - 1)), Math.abs(gray(x, y) - gray(x, y + 1))); if (d > step) strong++; }
if (strong >= W * span) rows.push(y);
}
// collapse adjacent rows into one edge
const out = [];
for (const y of rows) if (!out.length || y - out[out.length - 1] > 3) out.push(y);
return out;
}
/**
* A thin chrome region (masthead, nav bar, footer strip) has a height, and
* its height is where its rule sits. Compare the first horizontal rule's row
* in comp vs build; fall back to the ink extents when neither has a rule.
*/
export function chromeStripCheck(region, compCrop, buildCrop) {
const findings = [];
const strip = compCrop.height <= compCrop.width * 0.35;
if (!strip) return { findings };
// a control that is one link or one button, not a bar across its box, has
// no strip height to compare (its underline read as a 'rule' for 27
// attempts in one session)
if (region.kind === 'control') {
const ib = inkBox(compCrop);
if (!ib || ib.w < compCrop.width * 0.6) return { findings };
}
const ra = ruleRows(compCrop), rb = ruleRows(buildCrop);
if (ra.length && rb.length) {
// the rule that closes the strip is the first one from the top (a grid
// row often carries the next element's top edge lower down)
const ya = ra[0], yb = rb[0];
const dy = yb - ya;
if (Math.abs(dy) > Math.max(5, compCrop.height * 0.06)) findings.push(`${region.kind} ${region.id}: its rule sits ${ya}px into the box in the comp and ${yb}px in the build (${dy > 0 ? '+' : ''}${dy}px), so the strip is ${dy > 0 ? 'taller' : 'shorter'} than the comp's; match the height, not only the position`);
return { findings, comp: ya, build: yb };
}
const ba = inkBox(compCrop), bb = inkBox(buildCrop);
if (!ba || !bb) return { findings };
if (ba.w >= compCrop.width * 0.6 && ba.h <= compCrop.height * 0.6) {
const dh = bb.h - ba.h;
if (Math.abs(dh) > Math.max(10, ba.h * 0.25)) findings.push(`${region.kind} ${region.id}: its ink is ${bb.h}px tall in the build and ${ba.h}px in the comp (${dh > 0 ? '+' : ''}${dh}px); match the height, not only the position`);
}
return { findings, comp: ba, build: bb };
}
/**
* Cells of the frame where the build carries ink and the comp is calm.
* Returns { cells: [{col,row,label}], fraction } on a cols x rows grid.
* `floor` is the comp energy under which a cell counts as calm; `added` is
* the build energy over which the build counts as inked.
*/
export function inventedInk(comp, build, { cols = 10, rows = 10, floor = 10, added = 12, ratio = 2.5 } = {}) {
const a = detailGrid(comp, cols, rows, 512), b = detailGrid(build, cols, rows, 512);
const cells = [];
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const i = r * cols + c;
// calm in the comp (grain, flat ground) and inked in the build well past
// what grain would give: a kicker over paper, a divider, a nav row
if (!(a.cells[i] < floor && b.cells[i] > Math.max(added, a.cells[i] * ratio))) continue;
// the comp must be calm around the cell too: a hard edge one pixel over
// the cell boundary in the build (a bar shifted by a subpixel of the
// alignment) reads as invented otherwise
let neighbourhood = 0, n = 0;
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) { const rr = r + dr, cc = c + dc; if (rr < 0 || cc < 0 || rr >= rows || cc >= cols) continue; neighbourhood += a.cells[rr * cols + cc]; n++; }
if (neighbourhood / n >= floor * 2) continue;
cells.push({ col: c, row: r, label: `${String.fromCharCode(65 + c)}${r}`, comp: +a.cells[i].toFixed(1), build: +b.cells[i].toFixed(1) });
}
return { cells, fraction: cells.length / (cols * rows) };
}
/**
* A plate cropped by its box: the comp's artwork keeps a margin inside the
* region on some side and the build's ink runs flush to that edge (object-fit:
* cover on a box smaller than the artwork's aspect, or an <img> sized to the
* column). The best build of the fifth sweep passed the hero at 87% with the
* cover arch cut off at the left and bottom; the human review called it a
* bug in one word. Returns the sides clipped, or [].
*/
export function plateClipCheck(region, compCrop, buildCrop, { margin = 6 } = {}) {
const a = inkBox(compCrop), b = inkBox(buildCrop);
if (!a || !b) return { sides: [] };
const W = compCrop.width, H = compCrop.height;
const sides = [];
const flush = (v) => v <= 1;
if (a.x >= margin && flush(b.x)) sides.push('left');
if (a.y >= margin && flush(b.y)) sides.push('top');
if (W - (a.x + a.w) >= margin && flush(W - (b.x + b.w))) sides.push('right');
if (H - (a.y + a.h) >= margin && flush(H - (b.y + b.h))) sides.push('bottom');
return { sides, comp: a, build: b };
}
/**
* Inline SVG that is an illustration, not an icon. An icon is small (a
* viewBox or box under `iconPx` on its long side) with a few paths; anything
* with a real path budget is a drawing in code: a diagram, a rack of
* carburetors, staff notation, leader lines with arrows, a "terrible svg
* approximation of the asset". Those ship as plates or as part of the plate
* they annotate. Returns one entry per offending <svg> with a snippet.
*
* `html` is the artifact source. `pathBudget` counts characters of path
* data (d="..."), points, and polyline/polygon points across the element.
*/
export function svgIllustrations(html, { iconPx = 64, pathBudget = 480, maxPaths = 8 } = {}) {
const out = [];
const re = /<svg\b([^>]*)>([\s\S]*?)<\/svg>/gi;
let m;
while ((m = re.exec(html))) {
const attrs = m[1], body = m[2];
const paths = (body.match(/<path\b/gi) || []).length + (body.match(/<(polyline|polygon|line|circle|ellipse|rect)\b/gi) || []).length;
let budget = 0;
for (const d of body.matchAll(/\sd="([^"]*)"/g)) budget += d[1].length;
for (const pts of body.matchAll(/\spoints="([^"]*)"/g)) budget += pts[1].length;
const vb = /viewBox="\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)/.exec(attrs);
const w = /\swidth="([\d.]+)(px)?"/.exec(attrs), h = /\sheight="([\d.]+)(px)?"/.exec(attrs);
const long = Math.max(vb ? Math.max(+vb[1], +vb[2]) : 0, w ? +w[1] : 0, h ? +h[1] : 0);
const iconSized = long > 0 && long <= iconPx && paths <= maxPaths;
const uses = /<use\b/i.test(body) && paths === 0; // a sprite reference
if (uses) continue;
if (iconSized && budget <= pathBudget) continue;
if (budget <= pathBudget && paths <= maxPaths && long === 0 && !/<(text|image)\b/i.test(body)) continue; // a tiny inline glyph with no size hint
if (budget > pathBudget || paths > maxPaths || (long > iconPx && paths > 0)) {
const id = /\b(id|class|aria-label|data-region)="([^"]+)"/i.exec(attrs);
out.push({ snippet: `<svg${attrs.slice(0, 80).replace(/\s+/g, ' ')}...> (${paths} shapes, ${budget} chars of path data${long ? `, ${long}px` : ''})`, label: id ? id[2] : null, paths, budget, long });
}
}
return out;
}
-306
View File
@@ -1,306 +0,0 @@
/**
* Perceptual measures for comparing a comp with a build screenshot. Pure
* functions over `{ width, height, data }` RGBA images; no I/O.
*
* Three families, because a build fails a comp in three separable ways:
*
* - structure: is the composition the same? Measured as SSIM over a blurred
* grayscale downsample, which forgives font hinting and a few pixels of
* drift and punishes a moved, missing, or invented region.
* - color: is the palette and its distribution the same? Histogram
* intersection in a coarse quantized space plus a dominant-color extraction,
* so a navy page built from a bone comp fails even if the shapes match.
* - detail: is the material there? Local high-frequency energy per cell. A
* comp with an illustration, texture, or photograph carries energy a flat
* CSS stand-in does not; the ratio build/comp per region is the most direct
* measure of "the plate got replaced by a gradient".
*/
import { resize } from './raster.mjs';
export function toGray(img) {
const g = new Float32Array(img.width * img.height);
for (let i = 0, p = 0; i < g.length; i++, p += 4) {
const a = img.data[p + 3] / 255;
// composite over white so transparent regions read as the page ground
const r = img.data[p] * a + 255 * (1 - a), gg = img.data[p + 1] * a + 255 * (1 - a), b = img.data[p + 2] * a + 255 * (1 - a);
g[i] = 0.2126 * r + 0.7152 * gg + 0.0722 * b;
}
return { width: img.width, height: img.height, data: g };
}
/** Separable box blur on a float gray image, radius r. */
export function blurGray(gray, r) {
if (r <= 0) return gray;
const { width, height, data } = gray;
const tmp = new Float32Array(data.length), out = new Float32Array(data.length);
const win = 2 * r + 1;
for (let y = 0; y < height; y++) {
let acc = 0;
for (let x = -r; x <= r; x++) acc += data[y * width + Math.min(width - 1, Math.max(0, x))];
for (let x = 0; x < width; x++) {
tmp[y * width + x] = acc / win;
const outX = x - r, inX = x + r + 1;
acc += data[y * width + Math.min(width - 1, inX)] - data[y * width + Math.max(0, outX)];
}
}
for (let x = 0; x < width; x++) {
let acc = 0;
for (let y = -r; y <= r; y++) acc += tmp[Math.min(height - 1, Math.max(0, y)) * width + x];
for (let y = 0; y < height; y++) {
out[y * width + x] = acc / win;
const outY = y - r, inY = y + r + 1;
acc += tmp[Math.min(height - 1, inY) * width + x] - tmp[Math.max(0, outY) * width + x];
}
}
return { width, height, data: out };
}
/** Global SSIM between two same-size gray images using an 8x8 window grid. */
export function ssim(a, b, win = 8) {
if (a.width !== b.width || a.height !== b.height) throw new Error('ssim: size mismatch');
const C1 = (0.01 * 255) ** 2, C2 = (0.03 * 255) ** 2;
let total = 0, n = 0;
for (let y = 0; y + win <= a.height; y += win) {
for (let x = 0; x + win <= a.width; x += win) {
let ma = 0, mb = 0;
for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; ma += a.data[i]; mb += b.data[i]; }
ma /= win * win; mb /= win * win;
let va = 0, vb = 0, cov = 0;
for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; const da = a.data[i] - ma, db = b.data[i] - mb; va += da * da; vb += db * db; cov += da * db; }
va /= win * win - 1; vb /= win * win - 1; cov /= win * win - 1;
total += ((2 * ma * mb + C1) * (2 * cov + C2)) / ((ma * ma + mb * mb + C1) * (va + vb + C2));
n++;
}
}
return n ? total / n : 1;
}
/** SSIM of `a` against `b` shifted by (dx, dy); the overlap is compared, edges dropped. */
export function ssimShifted(a, b, dx, dy, win = 8) {
const w = a.width - Math.abs(dx), h = a.height - Math.abs(dy);
if (w < win || h < win) return 0;
const sa = { width: w, height: h, data: new Float32Array(w * h) };
const sb = { width: w, height: h, data: new Float32Array(w * h) };
const ax = Math.max(0, -dx), ay = Math.max(0, -dy), bx = Math.max(0, dx), by = Math.max(0, dy);
for (let y = 0; y < h; y++) {
sa.data.set(a.data.subarray((y + ay) * a.width + ax, (y + ay) * a.width + ax + w), y * w);
sb.data.set(b.data.subarray((y + by) * b.width + bx, (y + by) * b.width + bx + w), y * w);
}
return ssim(sa, sb, win);
}
/**
* Structure score 0..1: SSIM over blurred grayscale at a fixed working width,
* taking the best of a small translation search so a composition that sits a
* few pixels off (a different masthead height, a scrollbar) is not read as a
* different composition. Shifts up to ~4% of the width are forgiven; a moved
* region is not.
*/
export function structureScore(imgA, imgB, workWidth = 256) {
const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth));
const a = blurGray(toGray(resize(imgA, workWidth, h)), 2);
const b = blurGray(toGray(resize(imgB, workWidth, h)), 2);
const win = Math.min(8, Math.max(2, Math.floor(Math.min(workWidth, h) / 8)));
let best = ssim(a, b, win);
const maxShift = Math.max(2, Math.round(workWidth * 0.04));
for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
if (!dx && !dy) continue;
best = Math.max(best, ssimShifted(a, b, Math.round(dx), Math.round(dy), win));
}
}
return Math.max(0, Math.min(1, best));
}
// ---- color -----------------------------------------------------------------
function rgbToLab(r, g, b) {
const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const R = lin(r), G = lin(g), B = lin(b);
const X = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
const Y = (R * 0.2126 + G * 0.7152 + B * 0.0722) / 1.0;
const Z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
const fx = f(X), fy = f(Y), fz = f(Z);
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
}
export function deltaE(lab1, lab2) {
return Math.hypot(lab1[0] - lab2[0], lab1[1] - lab2[1], lab1[2] - lab2[2]);
}
/** Quantized color histogram (4 bits per channel = 4096 bins), normalized. */
export function colorHistogram(img, sampleStep = 2) {
const bins = new Float32Array(4096);
let n = 0;
for (let y = 0; y < img.height; y += sampleStep) {
for (let x = 0; x < img.width; x += sampleStep) {
const p = (y * img.width + x) * 4;
if (img.data[p + 3] < 16) continue;
const key = ((img.data[p] >> 4) << 8) | ((img.data[p + 1] >> 4) << 4) | (img.data[p + 2] >> 4);
bins[key]++; n++;
}
}
if (n) for (let i = 0; i < bins.length; i++) bins[i] /= n;
return bins;
}
export function histogramIntersection(h1, h2) {
let s = 0;
for (let i = 0; i < h1.length; i++) s += Math.min(h1[i], h2[i]);
return s;
}
/**
* Dominant colors: merge histogram bins greedily by Lab distance into up to
* `k` clusters and return them sorted by coverage.
*/
export function dominantColors(img, k = 6, sampleStep = 3) {
const hist = colorHistogram(img, sampleStep);
const entries = [];
for (let i = 0; i < hist.length; i++) if (hist[i] > 0.0005) entries.push({ key: i, w: hist[i] });
entries.sort((a, b) => b.w - a.w);
const clusters = [];
for (const e of entries) {
const r = ((e.key >> 8) & 15) * 16 + 8, g = ((e.key >> 4) & 15) * 16 + 8, b = (e.key & 15) * 16 + 8;
const lab = rgbToLab(r, g, b);
let best = null, bestD = Infinity;
for (const c of clusters) { const d = deltaE(c.lab, lab); if (d < bestD) { bestD = d; best = c; } }
if (best && bestD < 14) {
const tw = best.w + e.w;
best.rgb = [(best.rgb[0] * best.w + r * e.w) / tw, (best.rgb[1] * best.w + g * e.w) / tw, (best.rgb[2] * best.w + b * e.w) / tw];
best.lab = rgbToLab(...best.rgb); best.w = tw;
} else clusters.push({ rgb: [r, g, b], lab, w: e.w });
}
clusters.sort((a, b) => b.w - a.w);
const top = clusters.slice(0, k);
const covered = top.reduce((s, c) => s + c.w, 0) || 1;
return top.map((c) => ({ hex: toHex(c.rgb), coverage: +(c.w / covered).toFixed(4), lab: c.lab }));
}
export function toHex(rgb) {
return '#' + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
}
/**
* Palette match 0..1: for each dominant comp color, coverage-weighted best
* Lab match in the build's dominant set (dE 0 -> 1, dE >= 25 -> 0).
*/
export function paletteMatch(compColors, buildColors) {
if (!compColors.length) return 1;
let s = 0, wsum = 0;
for (const c of compColors) {
let best = Infinity;
for (const b of buildColors) best = Math.min(best, deltaE(c.lab, b.lab));
s += c.coverage * Math.max(0, 1 - best / 25); wsum += c.coverage;
}
return wsum ? s / wsum : 1;
}
/** Color score 0..1: blend of histogram intersection and dominant-palette match. */
export function colorScore(imgA, imgB) {
const inter = histogramIntersection(colorHistogram(imgA), colorHistogram(imgB));
const pm = paletteMatch(dominantColors(imgA), dominantColors(imgB));
return { score: 0.35 * inter + 0.65 * pm, intersection: inter, paletteMatch: pm };
}
// ---- detail ----------------------------------------------------------------
/** Mean absolute gradient (Sobel-lite) per cell over a cols x rows grid. */
export function detailGrid(img, cols = 12, rows = 8, workWidth = 512) {
const h = Math.max(rows, Math.round((img.height / img.width) * workWidth));
const g = toGray(resize(img, workWidth, h));
const grid = new Float32Array(cols * rows);
const counts = new Float32Array(cols * rows);
for (let y = 1; y < g.height - 1; y++) {
const cy = Math.min(rows - 1, Math.floor((y / g.height) * rows));
for (let x = 1; x < g.width - 1; x++) {
const cx = Math.min(cols - 1, Math.floor((x / g.width) * cols));
const i = y * g.width + x;
const gx = Math.abs(g.data[i + 1] - g.data[i - 1]);
const gy = Math.abs(g.data[i + g.width] - g.data[i - g.width]);
grid[cy * cols + cx] += gx + gy; counts[cy * cols + cx]++;
}
}
for (let i = 0; i < grid.length; i++) grid[i] = counts[i] ? grid[i] / counts[i] : 0;
return { cols, rows, cells: grid };
}
/**
* Detail score 0..1 and per-cell ratio. Cells where the comp is nearly flat
* are ignored (nothing to lose); the score is the coverage-weighted mean of
* min(1, build/comp) over cells with comp energy, so extra detail in the
* build (invented chrome) is reported separately as `added`.
*/
export function detailScore(imgA, imgB, cols = 12, rows = 8) {
const a = detailGrid(imgA, cols, rows), b = detailGrid(imgB, cols, rows);
const floor = 1.5; // energy below this is a flat field at the 512px working width
let s = 0, w = 0, added = 0, addedW = 0;
const ratios = new Float32Array(cols * rows);
for (let i = 0; i < a.cells.length; i++) {
const ca = a.cells[i], cb = b.cells[i];
ratios[i] = ca > floor ? cb / ca : (cb > floor ? Infinity : 1);
// Signed: too much energy is as wrong as too little. Noise, a tile
// shuffle, or a mosaic saturate a one-sided ratio; a real plate does not.
if (ca > floor) { s += Math.min(cb / ca, ca / cb) * ca; w += ca; }
if (cb > ca * 1.8 && cb > floor * 2) { added += 1; }
addedW += 1;
}
const addedFraction = addedW ? added / addedW : 0;
const raw = w ? s / w : 1;
return { score: Math.max(0, raw - 0.5 * addedFraction), rawScore: raw, addedFraction, comp: a, build: b, ratios };
}
// ---- pixel diff -----------------------------------------------------------
/** Per-pixel Lab-ish difference map (0..1) at a working width; blurred a little. */
export function diffMap(imgA, imgB, workWidth = 384) {
const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth));
const a = resize(imgA, workWidth, h), b = resize(imgB, workWidth, h);
const out = new Float32Array(workWidth * h);
for (let i = 0, p = 0; i < out.length; i++, p += 4) {
const dr = a.data[p] - b.data[p], dg = a.data[p + 1] - b.data[p + 1], db = a.data[p + 2] - b.data[p + 2];
out[i] = Math.min(1, Math.sqrt(dr * dr + dg * dg + db * db) / 200);
}
return blurGray({ width: workWidth, height: h, data: out }, 1);
}
// ---- bands (horizontal layout structure) ---------------------------------
/**
* Detect horizontal band boundaries: rows where the mean color changes
* sharply. Returns normalized y positions (0..1) with strengths. This is the
* "layout grid" read of a page: header / hero / index / footer as bands.
*/
export function horizontalBands(img, workWidth = 128, minGap = 0.02) {
const h = Math.max(16, Math.round((img.height / img.width) * workWidth));
const s = resize(img, workWidth, h);
const rowMean = new Float32Array(h * 3);
for (let y = 0; y < h; y++) {
let r = 0, g = 0, b = 0;
for (let x = 0; x < workWidth; x++) { const p = (y * workWidth + x) * 4; r += s.data[p]; g += s.data[p + 1]; b += s.data[p + 2]; }
rowMean[y * 3] = r / workWidth; rowMean[y * 3 + 1] = g / workWidth; rowMean[y * 3 + 2] = b / workWidth;
}
const edges = [];
for (let y = 1; y < h; y++) {
const d = Math.hypot(rowMean[y * 3] - rowMean[(y - 1) * 3], rowMean[y * 3 + 1] - rowMean[(y - 1) * 3 + 1], rowMean[y * 3 + 2] - rowMean[(y - 1) * 3 + 2]);
if (d > 18) edges.push({ y: y / h, strength: Math.min(1, d / 120) });
}
// merge close edges
const merged = [];
for (const e of edges) {
const last = merged[merged.length - 1];
if (last && e.y - last.y < minGap) { if (e.strength > last.strength) { last.y = e.y; last.strength = e.strength; } }
else merged.push({ ...e });
}
return merged;
}
/** Band agreement 0..1: fraction of comp bands with a build band within tolerance, and vice versa. */
export function bandScore(bandsA, bandsB, tol = 0.04) {
if (!bandsA.length && !bandsB.length) return 1;
const match = (from, to) => from.filter((a) => to.some((b) => Math.abs(a.y - b.y) <= tol)).length;
const recall = bandsA.length ? match(bandsA, bandsB) / bandsA.length : 1;
const precision = bandsB.length ? match(bandsB, bandsA) / bandsB.length : 1;
return 0.6 * recall + 0.4 * precision;
}
-281
View File
@@ -1,281 +0,0 @@
/**
* Dependency-free PNG decode/encode for the skill scripts.
*
* decodePng(buffer) -> { width, height, data } where data is RGBA8 (Uint8Array,
* width*height*4). Handles every color type (0, 2, 3, 4, 6), bit depths 1-16
* (16-bit is reduced to 8), all five filters, and Adam7 interlacing.
*
* encodePng({ width, height, data }) -> Buffer, RGBA8 in, 8-bit RGBA PNG out.
*
* Kept small on purpose: the skill scripts ship without npm dependencies, and
* comps (gpt-image PNGs) and screenshots (Playwright / harness PNGs) are the
* only formats the comp-fidelity tooling has to read.
*/
import zlib from 'node:zlib';
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const crcTable = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(data) {
let c = 0xffffffff;
for (let i = 0; i < data.length; i++) c = crcTable[(c ^ data[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
export function isPng(buf) {
return buf && buf.length > 8 && buf.subarray(0, 8).equals(SIGNATURE);
}
function readChunks(buf) {
const chunks = [];
let pos = 8;
while (pos + 8 <= buf.length) {
const length = buf.readUInt32BE(pos);
const type = buf.toString('latin1', pos + 4, pos + 8);
const data = buf.subarray(pos + 8, pos + 8 + length);
chunks.push({ type, data });
pos += 12 + length;
if (type === 'IEND') break;
}
return chunks;
}
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
function paeth(a, b, c) {
const p = a + b - c;
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
/** Unfilter one pass of scanlines in place; returns the raw (unfiltered) bytes. */
function unfilter(raw, width, height, bpp, bitDepth, channels) {
const stride = Math.ceil((width * channels * bitDepth) / 8);
const out = new Uint8Array(stride * height);
let inPos = 0;
let prev = null;
for (let y = 0; y < height; y++) {
const filter = raw[inPos++];
const line = out.subarray(y * stride, (y + 1) * stride);
line.set(raw.subarray(inPos, inPos + stride));
inPos += stride;
switch (filter) {
case 0: break;
case 1: for (let i = bpp; i < stride; i++) line[i] = (line[i] + line[i - bpp]) & 0xff; break;
case 2: if (prev) for (let i = 0; i < stride; i++) line[i] = (line[i] + prev[i]) & 0xff; break;
case 3:
for (let i = 0; i < stride; i++) {
const left = i >= bpp ? line[i - bpp] : 0;
const up = prev ? prev[i] : 0;
line[i] = (line[i] + ((left + up) >> 1)) & 0xff;
}
break;
case 4:
for (let i = 0; i < stride; i++) {
const left = i >= bpp ? line[i - bpp] : 0;
const up = prev ? prev[i] : 0;
const ul = prev && i >= bpp ? prev[i - bpp] : 0;
line[i] = (line[i] + paeth(left, up, ul)) & 0xff;
}
break;
default: throw new Error(`png: unknown filter ${filter} on row ${y}`);
}
prev = line;
}
return { bytes: out, stride, consumed: inPos };
}
/** Read sample `index` (0-based across the row) from a packed scanline. */
function sampleReader(bitDepth) {
if (bitDepth === 8) return (line, i) => line[i];
if (bitDepth === 16) return (line, i) => line[i * 2]; // high byte
const perByte = 8 / bitDepth;
const mask = (1 << bitDepth) - 1;
const scale = 255 / mask;
return (line, i) => {
const byte = line[(i / perByte) | 0];
const shift = 8 - bitDepth * ((i % perByte) + 1);
return Math.round(((byte >> shift) & mask) * scale);
};
}
function writePixels(dst, dstWidth, bytes, stride, passWidth, passHeight, colorType, bitDepth, palette, trns, mapX, mapY) {
const channels = CHANNELS[colorType];
const read = sampleReader(bitDepth);
const rawIndex = bitDepth < 8 ? (line, i) => {
const perByte = 8 / bitDepth;
const mask = (1 << bitDepth) - 1;
const byte = line[(i / perByte) | 0];
const shift = 8 - bitDepth * ((i % perByte) + 1);
return (byte >> shift) & mask;
} : read;
for (let y = 0; y < passHeight; y++) {
const line = bytes.subarray(y * stride, (y + 1) * stride);
const dy = mapY(y);
for (let x = 0; x < passWidth; x++) {
const dx = mapX(x);
const o = (dy * dstWidth + dx) * 4;
let r, g, b, a = 255;
switch (colorType) {
case 0: {
r = g = b = read(line, x);
if (trns && trns.gray === rawIndex(line, x)) a = 0;
break;
}
case 2: {
r = read(line, x * 3); g = read(line, x * 3 + 1); b = read(line, x * 3 + 2);
break;
}
case 3: {
const idx = rawIndex(line, x);
r = palette[idx * 3]; g = palette[idx * 3 + 1]; b = palette[idx * 3 + 2];
if (trns && trns.alpha && idx < trns.alpha.length) a = trns.alpha[idx];
break;
}
case 4: {
r = g = b = read(line, x * 2); a = read(line, x * 2 + 1);
break;
}
case 6: {
r = read(line, x * 4); g = read(line, x * 4 + 1); b = read(line, x * 4 + 2); a = read(line, x * 4 + 3);
break;
}
default: throw new Error(`png: unsupported color type ${colorType}`);
}
dst[o] = r; dst[o + 1] = g; dst[o + 2] = b; dst[o + 3] = a;
}
}
return channels;
}
export function decodePng(buf) {
if (!isPng(buf)) throw new Error('png: not a PNG (bad signature)');
const chunks = readChunks(buf);
const ihdr = chunks.find((c) => c.type === 'IHDR');
if (!ihdr) throw new Error('png: missing IHDR');
const width = ihdr.data.readUInt32BE(0);
const height = ihdr.data.readUInt32BE(4);
const bitDepth = ihdr.data[8];
const colorType = ihdr.data[9];
const interlace = ihdr.data[12];
const channels = CHANNELS[colorType];
if (!channels) throw new Error(`png: unsupported color type ${colorType}`);
const palChunk = chunks.find((c) => c.type === 'PLTE');
const palette = palChunk ? palChunk.data : null;
const trnsChunk = chunks.find((c) => c.type === 'tRNS');
let trns = null;
if (trnsChunk) {
if (colorType === 3) trns = { alpha: trnsChunk.data };
else if (colorType === 0) trns = { gray: trnsChunk.data.readUInt16BE(0) >> (bitDepth === 16 ? 8 : 0) };
}
const idat = Buffer.concat(chunks.filter((c) => c.type === 'IDAT').map((c) => c.data));
const raw = zlib.inflateSync(idat);
const bpp = Math.max(1, Math.ceil((channels * bitDepth) / 8));
const data = new Uint8Array(width * height * 4);
const text = {};
for (const c of chunks) {
if (c.type === 'tEXt') {
const z = c.data.indexOf(0);
if (z > 0) text[c.data.toString('latin1', 0, z)] = c.data.toString('utf8', z + 1);
}
}
if (interlace === 0) {
const { bytes, stride } = unfilter(raw, width, height, bpp, bitDepth, channels);
writePixels(data, width, bytes, stride, width, height, colorType, bitDepth, palette, trns, (x) => x, (y) => y);
} else {
// Adam7
const passes = [
[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2],
];
let offset = 0;
for (const [sx, sy, dx, dy] of passes) {
const pw = Math.ceil((width - sx) / dx);
const ph = Math.ceil((height - sy) / dy);
if (pw <= 0 || ph <= 0) continue;
const { bytes, stride, consumed } = unfilter(raw.subarray(offset), pw, ph, bpp, bitDepth, channels);
offset += consumed;
writePixels(data, width, bytes, stride, pw, ph, colorType, bitDepth, palette, trns, (x) => sx + x * dx, (y) => sy + y * dy);
}
}
return { width, height, data, text };
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'latin1');
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crc]);
}
/**
* Encode RGBA8 to PNG. `text` (optional) is a map of tEXt keyword -> value.
* Uses filter type 0 on every row: comps and screenshots compress fine and the
* encoder stays trivial.
*/
export function encodePng({ width, height, data }, { text = null, level = 6 } = {}) {
if (data.length !== width * height * 4) throw new Error(`png: data length ${data.length} != ${width}x${height}x4`);
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (stride + 1)] = 0;
raw.set(data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
const parts = [SIGNATURE, chunk('IHDR', ihdr)];
if (text) {
for (const [k, v] of Object.entries(text)) {
parts.push(chunk('tEXt', Buffer.concat([Buffer.from(k, 'latin1'), Buffer.from([0]), Buffer.from(String(v), 'utf8')])));
}
}
parts.push(chunk('IDAT', zlib.deflateSync(raw, { level })));
parts.push(chunk('IEND', Buffer.alloc(0)));
return Buffer.concat(parts);
}
/**
* Read any raster the comp pipeline meets (PNG natively; WebP / JPEG / GIF /
* AVIF through a converter on PATH) as RGBA. Non-PNG input is converted to a
* sibling cache file `<name>.<ext>.png` next to the source, never in place:
* a session that overwrites `comp.webp` with PNG bytes leaves a file the
* next tool cannot trust and a transcript replay cannot reconstruct.
* Returns { image, path } where path is the PNG actually decoded.
*/
export function loadRaster(file) {
const buf = fs.readFileSync(file);
if (isPng(buf)) return { image: decodePng(buf), path: file };
const cache = `${file}.png`;
if (fs.existsSync(cache)) {
try { const b = fs.readFileSync(cache); if (isPng(b)) return { image: decodePng(b), path: cache }; } catch { /* reconvert */ }
}
const attempts = [
['dwebp', [file, '-o', cache]],
['sips', ['-s', 'format', 'png', file, '--out', cache]],
['magick', [file, cache]],
['convert', [file, cache]],
];
let lastErr = null;
for (const [cmd, args] of attempts) {
try { execFileSync(cmd, args, { stdio: 'ignore' }); const b = fs.readFileSync(cache); if (isPng(b)) return { image: decodePng(b), path: cache }; }
catch (e) { lastErr = e; }
}
throw new Error(`png: ${file} is not a PNG and no converter (dwebp, sips, magick, convert) could produce ${cache}${lastErr ? `: ${lastErr.message}` : ''}`);
}
-194
View File
@@ -1,194 +0,0 @@
/**
* Small RGBA raster toolkit shared by the comp-fidelity scripts: crop, resize
* (area-averaging down, bilinear up), composite, fills, rectangles, and a
* bitmap-font label so composites can be captioned without a font stack.
*
* An image is `{ width, height, data }` with RGBA8 data (Uint8Array).
*/
export function createImage(width, height, fill = [0, 0, 0, 0]) {
const data = new Uint8Array(width * height * 4);
if (fill[0] || fill[1] || fill[2] || fill[3]) {
for (let i = 0; i < data.length; i += 4) { data[i] = fill[0]; data[i + 1] = fill[1]; data[i + 2] = fill[2]; data[i + 3] = fill[3]; }
}
return { width, height, data };
}
export function clampRect(img, x, y, w, h) {
const x0 = Math.max(0, Math.min(img.width, Math.round(x)));
const y0 = Math.max(0, Math.min(img.height, Math.round(y)));
const x1 = Math.max(x0, Math.min(img.width, Math.round(x + w)));
const y1 = Math.max(y0, Math.min(img.height, Math.round(y + h)));
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
export function crop(img, x, y, w, h) {
const r = clampRect(img, x, y, w, h);
const out = createImage(Math.max(1, r.w), Math.max(1, r.h));
for (let yy = 0; yy < r.h; yy++) {
const src = ((r.y + yy) * img.width + r.x) * 4;
out.data.set(img.data.subarray(src, src + r.w * 4), yy * out.width * 4);
}
return out;
}
/** Resize with area averaging when shrinking and bilinear when growing. */
export function resize(img, width, height) {
width = Math.max(1, Math.round(width));
height = Math.max(1, Math.round(height));
if (width === img.width && height === img.height) return { width, height, data: new Uint8Array(img.data) };
const out = createImage(width, height);
const sx = img.width / width, sy = img.height / height;
if (sx >= 1 && sy >= 1) {
for (let y = 0; y < height; y++) {
const y0 = Math.floor(y * sy), y1 = Math.min(img.height, Math.max(y0 + 1, Math.floor((y + 1) * sy)));
for (let x = 0; x < width; x++) {
const x0 = Math.floor(x * sx), x1 = Math.min(img.width, Math.max(x0 + 1, Math.floor((x + 1) * sx)));
let r = 0, g = 0, b = 0, a = 0, n = 0;
for (let yy = y0; yy < y1; yy++) {
let p = (yy * img.width + x0) * 4;
for (let xx = x0; xx < x1; xx++, p += 4) { r += img.data[p]; g += img.data[p + 1]; b += img.data[p + 2]; a += img.data[p + 3]; n++; }
}
const o = (y * width + x) * 4;
out.data[o] = r / n; out.data[o + 1] = g / n; out.data[o + 2] = b / n; out.data[o + 3] = a / n;
}
}
return out;
}
for (let y = 0; y < height; y++) {
const fy = Math.min(img.height - 1, (y + 0.5) * sy - 0.5);
const y0 = Math.max(0, Math.floor(fy)), y1 = Math.min(img.height - 1, y0 + 1), wy = fy - y0;
for (let x = 0; x < width; x++) {
const fx = Math.min(img.width - 1, (x + 0.5) * sx - 0.5);
const x0 = Math.max(0, Math.floor(fx)), x1 = Math.min(img.width - 1, x0 + 1), wx = fx - x0;
const o = (y * width + x) * 4;
for (let c = 0; c < 4; c++) {
const p00 = img.data[(y0 * img.width + x0) * 4 + c], p10 = img.data[(y0 * img.width + x1) * 4 + c];
const p01 = img.data[(y1 * img.width + x0) * 4 + c], p11 = img.data[(y1 * img.width + x1) * 4 + c];
out.data[o + c] = (p00 * (1 - wx) + p10 * wx) * (1 - wy) + (p01 * (1 - wx) + p11 * wx) * wy;
}
}
}
return out;
}
/** Scale to fit inside (maxW x maxH) preserving aspect; never upscale unless `allowUpscale`. */
export function fit(img, maxW, maxH, allowUpscale = false) {
const s = Math.min(maxW / img.width, maxH / img.height);
if (s >= 1 && !allowUpscale) return img;
return resize(img, img.width * s, img.height * s);
}
/** Alpha-composite `src` onto `dst` at (x, y). */
export function blit(dst, src, x, y) {
x = Math.round(x); y = Math.round(y);
for (let yy = 0; yy < src.height; yy++) {
const dy = y + yy; if (dy < 0 || dy >= dst.height) continue;
for (let xx = 0; xx < src.width; xx++) {
const dx = x + xx; if (dx < 0 || dx >= dst.width) continue;
const s = (yy * src.width + xx) * 4, d = (dy * dst.width + dx) * 4;
const a = src.data[s + 3] / 255;
if (a >= 1) { dst.data[d] = src.data[s]; dst.data[d + 1] = src.data[s + 1]; dst.data[d + 2] = src.data[s + 2]; dst.data[d + 3] = 255; continue; }
if (a <= 0) continue;
const da = dst.data[d + 3] / 255, oa = a + da * (1 - a);
for (let c = 0; c < 3; c++) dst.data[d + c] = (src.data[s + c] * a + dst.data[d + c] * da * (1 - a)) / (oa || 1);
dst.data[d + 3] = oa * 255;
}
}
}
export function fillRect(img, x, y, w, h, rgba) {
const r = clampRect(img, x, y, w, h);
const a = (rgba[3] ?? 255) / 255;
for (let yy = r.y; yy < r.y + r.h; yy++) {
for (let xx = r.x; xx < r.x + r.w; xx++) {
const o = (yy * img.width + xx) * 4;
if (a >= 1) { img.data[o] = rgba[0]; img.data[o + 1] = rgba[1]; img.data[o + 2] = rgba[2]; img.data[o + 3] = 255; }
else { for (let c = 0; c < 3; c++) img.data[o + c] = rgba[c] * a + img.data[o + c] * (1 - a); img.data[o + 3] = Math.max(img.data[o + 3], a * 255); }
}
}
}
export function strokeRect(img, x, y, w, h, rgba, thickness = 2) {
fillRect(img, x, y, w, thickness, rgba);
fillRect(img, x, y + h - thickness, w, thickness, rgba);
fillRect(img, x, y, thickness, h, rgba);
fillRect(img, x + w - thickness, y, thickness, h, rgba);
}
// 5x7 bitmap font, uppercase + digits + a little punctuation. Enough for labels.
const GLYPHS = {
A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'],
X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
0: ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
2: ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
3: ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
4: ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
5: ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
6: ['00110', '01000', '10000', '11110', '10001', '10001', '01110'],
7: ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
8: ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
9: ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
'.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'],
':': ['00000', '01100', '01100', '00000', '01100', '01100', '00000'],
'-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
'/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'],
'%': ['11001', '11010', '00010', '00100', '01000', '01011', '10011'],
'(': ['00010', '00100', '01000', '01000', '01000', '00100', '00010'],
')': ['01000', '00100', '00010', '00010', '00010', '00100', '01000'],
'#': ['01010', '01010', '11111', '01010', '11111', '01010', '01010'],
'_': ['00000', '00000', '00000', '00000', '00000', '00000', '11111'],
'?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'],
'=': ['00000', '00000', '11111', '00000', '11111', '00000', '00000'],
'+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'],
',': ['00000', '00000', '00000', '00000', '01100', '00100', '01000'],
};
export function textWidth(text, scale = 2) {
return text.length * 6 * scale;
}
/** Draw uppercase bitmap text. Returns width drawn. */
export function drawText(img, text, x, y, rgba, scale = 2) {
let cx = Math.round(x);
for (const chRaw of String(text).toUpperCase()) {
const g = GLYPHS[chRaw] || GLYPHS['?'];
for (let r = 0; r < 7; r++) for (let c = 0; c < 5; c++) if (g[r][c] === '1') fillRect(img, cx + c * scale, y + r * scale, scale, scale, rgba);
cx += 6 * scale;
}
return cx - x;
}
/** Draw a label with a background pill. */
export function drawLabel(img, text, x, y, { fg = [255, 255, 255, 255], bg = [0, 0, 0, 220], scale = 2, pad = 4 } = {}) {
const w = textWidth(text, scale) + pad * 2, h = 7 * scale + pad * 2;
fillRect(img, x, y, w, h, bg);
drawText(img, text, x + pad, y + pad, fg, scale);
return { w, h };
}