mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-22 02:56:52 +03:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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); });
|
||||
@@ -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 }; });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` : ''}`);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user