mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
font-match: choose the face by metrics; chroma-keyed plates; ink-box report; verbatim words in the hero
font-match.mjs fingerprints a text region's lettering in the comp (cap height, glyph advance per cap = width class, ink fraction = weight class, tracking), renders candidate faces at that cap height in a headless browser (yours plus a shortlist for the width class), and ranks them by distance with a proof sheet; the spec gate refuses to close until the lead text region is measured and ranked. generate-image --plate keys ink-on- ground plates to alpha (chroma) so the drawing sits on the page's own ground; the plates gate scores keyed plates composited over the region's ground. comp-diff reports each region's ink box; the hero gate names a control whose box height or width differs from the comp. The hero instruction copies the comp's words verbatim; rewording is a stated decision after the hero passes. Driven by a human review of the r3 side-by-sides: face width and weight, plate ground, control row height, and content substitution. AI-assisted (Claude). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
Abdul Wahab
co-authored by
Claude
parent
b015e26ddf
commit
f5751c4d75
@@ -26,7 +26,7 @@ export const SUITES = {
|
||||
triggers: [
|
||||
...COMMON_INFRA_PATTERNS,
|
||||
/^scripts\/(?!benchmark-detector|build-browser-detector|build-extension)/,
|
||||
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|comp-diff|comp-spec|build-phase|concept-seed|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|png|raster|image-metrics|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|pin|surface-brief))/,
|
||||
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|comp-diff|comp-spec|build-phase|font-match|concept-seed|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|png|raster|image-metrics|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|pin|surface-brief))/,
|
||||
/^README(\.npm)?\.md$/,
|
||||
/^cli\/bin\//,
|
||||
],
|
||||
|
||||
@@ -101,9 +101,9 @@ When an approved comp exists, it is a spatial contract, not a mood board: only t
|
||||
Then, in order, each closed by `node {{scripts_path}}/build-phase.mjs advance` (every script below lives under `{{scripts_path}}/` and runs with `node`; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open):
|
||||
|
||||
0. **comps.** The comp round from [visualize.md](visualize.md): three compositional comps of the requested surface at its own viewport under `.impeccable/mocks/`, each with a prompt sidecar, put in front of the user; the chosen one's sidecar gets `"approved": true`. The gate counts them and reads the approval; a `start --comp` skips this phase because it already happened.
|
||||
1. **spec.** Measure the comp: `comp-spec.mjs --comp <comp> --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws), and run `comp-spec.mjs --comp <comp> --regions <file>`. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user chose an icon library), and genuine defects in the comp such as spelling errors. <!-- rule:skill-comp-spec -->
|
||||
2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path; a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `generate-image.mjs --plate <id>` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop <id>`) as its input image and `comp-spec.mjs --plate-prompt <id>` as its prompt, then `embed-prompt.mjs`. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. <!-- rule:skill-plates-before-page -->
|
||||
3. **hero.** Build only the first viewport, at the comp's own dimensions, plates first: place every plate at its spec box (`object-fit: cover`, an `<img>`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `build-phase.mjs record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no region missing. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. <!-- rule:skill-hero-gate -->
|
||||
1. **spec.** Measure the comp: `comp-spec.mjs --comp <comp> --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws), and run `comp-spec.mjs --comp <comp> --regions <file>`. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. Type is measured, not guessed: `font-match.mjs --measure <text region>` reads the comp's cap height, width class, and weight off the pixels, and `font-match.mjs --rank <region> --text "..."` renders candidate faces at that cap height and ranks them by width and weight distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); the spec gate refuses to close until the lead text region is measured and ranked. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user chose an icon library), and genuine defects in the comp such as spelling errors. <!-- rule:skill-comp-spec -->
|
||||
2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (ink on flat ground is generated on a chroma key and keyed to alpha, so it sits on the page's own ground rather than a second paper); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `generate-image.mjs --plate <id>` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop <id>`) as its input image and `comp-spec.mjs --plate-prompt <id>` as its prompt, then `embed-prompt.mjs`. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. <!-- rule:skill-plates-before-page -->
|
||||
3. **hero.** Build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an `<img>`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `build-phase.mjs record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no region missing. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. <!-- rule:skill-hero-gate -->
|
||||
4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system.
|
||||
5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered.
|
||||
6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame.
|
||||
|
||||
@@ -68,7 +68,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { decodePng } from './lib/png.mjs';
|
||||
const require = createRequire(import.meta.url);
|
||||
import { crop } from './lib/raster.mjs';
|
||||
import { crop, createImage, blit } from './lib/raster.mjs';
|
||||
import { compare, verdictFor } from './comp-diff.mjs';
|
||||
import { SPEC_PATH, BUILD_DIR, loadSpec, plateReference } from './comp-spec.mjs';
|
||||
|
||||
@@ -192,7 +192,22 @@ export function gateSpec(state, { specPath = SPEC_PATH } = {}) {
|
||||
return { ok: false, reasons: [`spec measures ${spec.comp}, but this build started on ${state.comp}; re-run comp-spec on the approved comp`] };
|
||||
}
|
||||
const plates = spec.regions.filter((r) => r.medium === 'raster').length;
|
||||
return { ok: true, reasons: [], summary: `${spec.regions.length} regions, ${plates} plates` };
|
||||
// Type is measured, not guessed: the largest text region must carry a
|
||||
// font-match measurement and a ranked choice before page code exists.
|
||||
// Three of six misses a human called on a first-round build were the
|
||||
// headline face wider and lighter than the comp's, the parts list smaller,
|
||||
// the footer heavier: ratios font-match reads off pixels.
|
||||
const textRegions = spec.regions.filter((r) => r.kind === 'text').sort((a, b) => (b.px.w * b.px.h) - (a.px.w * a.px.h));
|
||||
const reasons = [];
|
||||
if (textRegions.length) {
|
||||
const lead = textRegions[0];
|
||||
if (!lead.type) reasons.push(`the lead text region ${lead.id} has no type measurement: run node ${HERE}/font-match.mjs --measure ${lead.id} (and --rank ${lead.id} --text "<its first words>" to choose the face by metrics). Set font-size from the printed cap height; do not pick a face by name.`);
|
||||
else if (lead.type.comp && !lead.type.chosen) reasons.push(`the lead text region ${lead.id} is measured (${lead.type.widthClass} ${lead.type.weightClass}, cap ${lead.type.comp.capHeightPx}px) but no face is ranked: run node ${HERE}/font-match.mjs --rank ${lead.id} --text "<its first words>" [--candidates "Family:weight,..."] and use the USE line.`);
|
||||
const unmeasured = textRegions.slice(1).filter((r) => !r.type).map((r) => r.id);
|
||||
if (unmeasured.length && !reasons.length) reasons.push(`measure the other text regions too, each sets its own font-size and weight class: node ${HERE}/font-match.mjs --measure <id> for ${unmeasured.join(', ')}`);
|
||||
}
|
||||
if (reasons.length) return { ok: false, reasons };
|
||||
return { ok: true, reasons: [], summary: `${spec.regions.length} regions, ${plates} plates, ${textRegions.length} text regions measured` };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,7 +253,19 @@ export function gatePlates(state, { specPath = SPEC_PATH } = {}) {
|
||||
let score = null;
|
||||
if (comp) {
|
||||
const ref = plateReference(comp, spec, r);
|
||||
const res = compare({ comp: ref, build: img, align: 'cover', spec: null, kind: r.kind });
|
||||
// a keyed (alpha) plate ships over the page ground: score it composited
|
||||
// over the region's sampled ground, the way it will show
|
||||
let build = img;
|
||||
let transparent = 0; for (let i = 3; i < img.data.length; i += 4) if (img.data[i] < 128) transparent++;
|
||||
if (transparent > (img.data.length / 4) * 0.05) {
|
||||
const g = (r.palette && r.palette[0] && r.palette[0].hex) || '#ffffff';
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(g);
|
||||
const ground = m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16), 255] : [255, 255, 255, 255];
|
||||
const over = createImage(img.width, img.height, ground);
|
||||
blit(over, img, 0, 0);
|
||||
build = over;
|
||||
}
|
||||
const res = compare({ comp: ref, build, align: 'cover', spec: null, kind: r.kind });
|
||||
score = res.whole;
|
||||
const v = plateVerdict(r, score);
|
||||
for (const reason of v.reasons) reasons.push(`plate ${file}: ${reason}`);
|
||||
@@ -358,6 +385,15 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH,
|
||||
// the mean says; chrome and controls get the one-third allowance.
|
||||
const directionContradicted = contradicted.filter((r) => r.kind === 'plate' || r.kind === 'image' || r.kind === 'text');
|
||||
for (const r of directionContradicted) reasons.push(`region ${r.id} (${r.kind}) is contradicted (structure ${(r.score.structure * 100).toFixed(0)}%, detail added ${(r.score.detailAdded * 100).toFixed(0)}%): ${r.kind === 'text' ? 'the composition of this text region differs from the comp; re-derive it from the spec box' : 'the plate here does not read as the comp region; regenerate it with the crop as reference (generate-image.mjs --plate ' + r.id + ') and place it at its box'}`);
|
||||
// Controls: report the ink box in comp vs build so a button in a 63px row
|
||||
// built into a 41px row is named as numbers, not as a drift score.
|
||||
for (const r of report.regions) {
|
||||
if (r.kind !== 'control' || r.verdict === 'match') continue;
|
||||
if (r.inkBox && r.inkBox.comp && r.inkBox.build) {
|
||||
const dh = r.inkBox.build.h - r.inkBox.comp.h, dw = r.inkBox.build.w - r.inkBox.comp.w;
|
||||
if (Math.abs(dh) > Math.max(6, r.inkBox.comp.h * 0.15) || Math.abs(dw) > Math.max(12, r.inkBox.comp.w * 0.15)) reasons.push(`region ${r.id}: its ink sits in a ${r.inkBox.comp.w}x${r.inkBox.comp.h}px box in the comp and ${r.inkBox.build.w}x${r.inkBox.build.h}px in the build (padding, row height, or size); match the box, not only the position`);
|
||||
}
|
||||
}
|
||||
const otherContradicted = contradicted.filter((r) => !directionContradicted.includes(r));
|
||||
if (otherContradicted.length > Math.max(1, Math.floor(report.regions.length / 3))) reasons.push(`${otherContradicted.length} of ${report.regions.length} regions contradicted: ${otherContradicted.map((r) => r.id).join(', ')}`);
|
||||
// A CSS-drawn organic contour sitting on a raster region's box is the plate
|
||||
@@ -502,9 +538,9 @@ export function advance(state, { force = false, reason = null, gateOpts = {} } =
|
||||
export function nextInstruction(state) {
|
||||
switch (state.phase) {
|
||||
case 'comps': return `Comp round for the chosen direction${state.direction ? ` (seed ${state.direction})` : ''}: read reference/visualize.md, generate three compositional comps of the requested surface at its own viewport into ${MOCKS_DIR}/ (each with a prompt sidecar), put them in front of the user, and set "approved": true in the chosen comp's sidecar. Then build-phase.mjs advance. No page code before this closes.`;
|
||||
case 'spec': return `Measure the comp: node comp-spec.mjs --comp ${state.comp} --grid, open ${path.join(BUILD_DIR, 'comp-grid.png')}, write regions.json (every illustration, photo, texture as its own plate region), run comp-spec.mjs --comp ${state.comp} --regions regions.json, then build-phase.mjs advance.`;
|
||||
case 'plates': return 'Produce every plate in the spec (comp-spec.mjs --print lists them). Illustrations, photos, figures: comp-spec.mjs --crop <id>, then generate-image.mjs --plate <id> (or the harness image tool with the crop as reference and the comp-spec plate prompt). Textures (paper, cloth, grain): do not generate first; crop a clean patch of the comp region (comp-spec.mjs --crop <id> --raw, then cut a patch free of ink), mirror-tile it to the plate size, and save it as the plate; generate only when no clean patch exists. The gate scores a texture against its whole region box, so a texture region should be drawn around clean ground (a sample cell), not around the ink it sits under; the page tiles it wherever the material goes. Then build-phase.mjs advance. Write no page code before this passes.';
|
||||
case 'hero': return `Build only the first viewport at ${state.breakpoint || 'the comp size'}, plates first: place every plate at its spec box (comp-spec.mjs --print lists boxes as percentages of the viewport) with object-fit: cover before writing a line of text or a control, capture into ${HERO_REPRO}, and run build-phase.mjs record hero (not advance) once so you see the plate regions read as match before text exists; then lay the semantic layer (text, controls, rules) over the plates from the spec's palette and boxes, capture, advance. When it fails, open the region crops it lists first, in order, then fix; do not build past the hero until it passes.`;
|
||||
case 'spec': return `Measure the comp: node comp-spec.mjs --comp ${state.comp} --grid, open ${path.join(BUILD_DIR, 'comp-grid.png')}, write regions.json (every illustration, photo, texture as its own plate region; every text block its own text region), run comp-spec.mjs --comp ${state.comp} --regions regions.json. Then measure the type: node font-match.mjs --measure <id> for each text region (cap height, width class, weight class) and font-match.mjs --rank <lead text region> --text "<its first words>" to choose the headline face by metrics (the USE line is the CSS). Then build-phase.mjs advance.`;
|
||||
case 'plates': return 'Produce every plate in the spec (comp-spec.mjs --print lists them). Illustrations, photos, figures: comp-spec.mjs --crop <id>, then generate-image.mjs --plate <id> (or the harness image tool with the crop as reference and the comp-spec plate prompt). A line drawing or figure on flat ground is keyed to alpha automatically (PLATE-CHROMA): place it with a plain <img> over the page\'s own ground, never on a second paper. An opaque plate whose ground differs from the page goes in with mix-blend-mode: multiply. Textures (paper, cloth, grain): do not generate first; crop a clean patch of the comp region (comp-spec.mjs --crop <id> --raw, then cut a patch free of ink), mirror-tile it to the plate size, and save it as the plate; generate only when no clean patch exists. The gate scores a texture against its whole region box, so a texture region should be drawn around clean ground (a sample cell), not around the ink it sits under; the page tiles it wherever the material goes. Then build-phase.mjs advance. Write no page code before this passes.';
|
||||
case 'hero': return `Build only the first viewport at ${state.breakpoint || 'the comp size'}. Copy the comp's words verbatim in this phase (headline, labels, table cells, footer): the user approved that comp with those words, and rewriting is a later, stated decision, never a silent one here. Set every text region's font-size from its measured cap height and its face from the ranking. Plates first: place every plate at its spec box (comp-spec.mjs --print lists boxes as percentages of the viewport) with object-fit: cover before writing a line of text or a control, capture into ${HERO_REPRO}, and run build-phase.mjs record hero (not advance) once so you see the plate regions read as match before text exists; then lay the semantic layer (text, controls, rules) over the plates from the spec's palette and boxes, capture, advance. When it fails, open the region crops it lists first, in order, then fix; do not build past the hero until it passes.`;
|
||||
case 'sections': return 'Build the remaining sections inside the spec system (same corner language, rules, and palette; nothing the comp does not show). Then build-phase.mjs advance.';
|
||||
case 'motion': return 'Add the signature interaction, reveals, and motion. Then build-phase.mjs advance.';
|
||||
case 'responsive': return 'Build the other viewports (mobile first if the surface is mobile). The first viewport must hold at common desktop widths (1280 to 1600), not only at the comp\'s exact size: fluid columns, no fixed-px grid that wraps 96px narrower. Settle or disable entrance motion before capturing (an element mid-animation reads as missing). Capture desktop.png (1440 wide, full page) and mobile.png (390 wide, full page) into .impeccable/review/; the gate diffs the top of desktop.png (scaled to the comp\'s width) against the comp. Then build-phase.mjs advance.';
|
||||
|
||||
@@ -190,6 +190,20 @@ export function resolveRegions(comp, spec) {
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -261,7 +275,7 @@ export function compare({ comp, build, spec = null, align = 'top', label = '', k
|
||||
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), _a: a, _b: b };
|
||||
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, compPalette, buildPalette, _whole: whole };
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
#!/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: cap height (px), average
|
||||
* glyph advance per cap height (width class), stroke weight (ink fraction
|
||||
* per glyph area), and letter density. Prints them and stores them on the
|
||||
* region in the spec (`type` block), so build code can set font-size from
|
||||
* capHeightPx and the hero gate can name a width/weight miss.
|
||||
*
|
||||
* node font-match.mjs --rank <region-id> [--candidates "Barlow Condensed:700,Oswald:600"] [--text "The manuals stop."]
|
||||
* Renders the region's text (or --text) in every candidate face (yours
|
||||
* plus a built-in shortlist for the comp's width class) at the
|
||||
* comp's cap height in a headless browser (Google Fonts CSS, or any
|
||||
* locally installed face), measures the same fingerprint, and ranks the
|
||||
* candidates by distance. Prints the ranking with per-face width and
|
||||
* weight deltas and the CSS to use (family, weight, and the font-size
|
||||
* that reproduces the comp's cap height). Needs a browser: playwright or
|
||||
* puppeteer resolvable from the project or the impeccable CLI; without
|
||||
* one, prints the comp fingerprint and how to read it.
|
||||
*
|
||||
* 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 { decodePng, encodePng } from './lib/png.mjs';
|
||||
import { crop } from './lib/raster.mjs';
|
||||
import { toGray } from './lib/image-metrics.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 ----------------------------------------------------------
|
||||
|
||||
/** Otsu threshold on a gray image: ink vs ground, whichever is darker is ink. */
|
||||
function otsu(gray) {
|
||||
const hist = new Float64Array(256);
|
||||
for (let i = 0; i < gray.data.length; i++) hist[Math.max(0, Math.min(255, Math.round(gray.data[i])))]++;
|
||||
const total = gray.data.length;
|
||||
let sum = 0; for (let i = 0; i < 256; i++) sum += i * hist[i];
|
||||
let sumB = 0, wB = 0, best = 0, thr = 128;
|
||||
for (let t = 0; t < 256; t++) {
|
||||
wB += hist[t]; if (!wB) continue;
|
||||
const wF = total - wB; if (!wF) break;
|
||||
sumB += t * hist[t];
|
||||
const mB = sumB / wB, mF = (sum - sumB) / wF;
|
||||
const between = wB * wF * (mB - mF) ** 2;
|
||||
if (between > best) { best = between; thr = t; }
|
||||
}
|
||||
return thr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprint the lettering in an RGBA image:
|
||||
* - lines: text lines found by row-projection of ink
|
||||
* - capHeightPx: median line ink height (cap/x-height blend; consistent across comp and render)
|
||||
* - advance: mean glyph width / capHeightPx (width class; condensed < 0.45, normal ~0.55-0.65, wide > 0.7)
|
||||
* - weight: ink pixels / (glyph bbox area) (light ~0.2, regular ~0.3, bold ~0.4+)
|
||||
* - gap: mean inter-glyph gap / capHeightPx (tracking)
|
||||
* Returns null when no ink lines are found.
|
||||
*/
|
||||
export function fingerprint(img) {
|
||||
const g = toGray(img);
|
||||
const thr = otsu(g);
|
||||
// ink is the minority side of the threshold
|
||||
let dark = 0; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++;
|
||||
const inkIsDark = dark <= g.data.length / 2;
|
||||
const isInk = (v) => (inkIsDark ? v < thr : v >= thr);
|
||||
const W = g.width, H = g.height;
|
||||
const rowInk = new Uint32Array(H);
|
||||
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (isInk(g.data[y * W + x])) rowInk[y]++;
|
||||
// lines: runs of rows with ink above a small floor, then split each run at
|
||||
// interior valleys (descender/ascender bridges keep adjacent lines joined
|
||||
// at a trickle of ink; a valley under 15% of the run's peak is a line break)
|
||||
const floor = Math.max(1, W * 0.004);
|
||||
const runs = [];
|
||||
let y = 0;
|
||||
while (y < H) {
|
||||
if (rowInk[y] > floor) {
|
||||
let y0 = y; while (y < H && (rowInk[y] > floor || (y + 1 < H && rowInk[y + 1] > floor))) y++;
|
||||
if (y - y0 >= 4) runs.push({ y0, y1: y });
|
||||
} else y++;
|
||||
}
|
||||
const lines = [];
|
||||
for (const run of runs) {
|
||||
let peak = 0; for (let yy = run.y0; yy < run.y1; yy++) peak = Math.max(peak, rowInk[yy]);
|
||||
const valley = peak * 0.15;
|
||||
let start = run.y0, inValley = false, valleyStart = 0;
|
||||
for (let yy = run.y0; yy < run.y1; yy++) {
|
||||
const low = rowInk[yy] < valley;
|
||||
if (low && !inValley) { inValley = true; valleyStart = yy; }
|
||||
if (!low && inValley) {
|
||||
inValley = false;
|
||||
// a valley at least 3 rows deep splits; shorter ones are letter gaps
|
||||
if (yy - valleyStart >= 3 && valleyStart - start >= 4) { lines.push({ y0: start, y1: valleyStart }); start = yy; }
|
||||
}
|
||||
}
|
||||
if (run.y1 - start >= 4) lines.push({ y0: start, y1: run.y1 });
|
||||
}
|
||||
if (!lines.length) return null;
|
||||
const glyphs = [];
|
||||
const gapsAll = [];
|
||||
for (const ln of lines) {
|
||||
// column projection inside the line -> glyph runs
|
||||
const colInk = new Uint32Array(W);
|
||||
for (let yy = ln.y0; yy < ln.y1; yy++) for (let x = 0; x < W; x++) if (isInk(g.data[yy * W + x])) colInk[x]++;
|
||||
let x = 0; const runs = [];
|
||||
while (x < W) {
|
||||
if (colInk[x] > 0) { const x0 = x; while (x < W && colInk[x] > 0) x++; runs.push({ x0, x1: x }); } else x++;
|
||||
}
|
||||
// ink height per line: rows in the line whose ink is above 20% of the line's max (drops ascender/descender tails)
|
||||
let maxRow = 0; for (let yy = ln.y0; yy < ln.y1; yy++) maxRow = Math.max(maxRow, rowInk[yy]);
|
||||
let hy0 = ln.y0, hy1 = ln.y1;
|
||||
while (hy0 < ln.y1 && rowInk[hy0] < maxRow * 0.2) hy0++;
|
||||
while (hy1 > hy0 && rowInk[hy1 - 1] < maxRow * 0.2) hy1--;
|
||||
const lineH = Math.max(1, hy1 - hy0);
|
||||
for (let i = 0; i < runs.length; i++) {
|
||||
const r = runs[i];
|
||||
const w = r.x1 - r.x0;
|
||||
if (w < lineH * 0.15) continue; // dots, punctuation, thin rules
|
||||
let ink = 0; for (let yy = hy0; yy < hy1; yy++) for (let xx = r.x0; xx < r.x1; xx++) if (isInk(g.data[yy * W + xx])) ink++;
|
||||
glyphs.push({ w, h: lineH, ink });
|
||||
if (i + 1 < runs.length) { const gap = runs[i + 1].x0 - r.x1; if (gap < lineH * 0.6) gapsAll.push(gap / lineH); }
|
||||
}
|
||||
}
|
||||
if (!glyphs.length) return null;
|
||||
const med = (a) => { const s = [...a].sort((p, q) => p - q); return s[Math.floor(s.length / 2)]; };
|
||||
const capHeightPx = med(glyphs.map((x) => x.h));
|
||||
const advance = med(glyphs.map((x) => x.w / x.h));
|
||||
const weight = med(glyphs.map((x) => x.ink / (x.w * x.h)));
|
||||
const gap = gapsAll.length ? med(gapsAll) : 0;
|
||||
return { lines: lines.length, glyphs: glyphs.length, capHeightPx: +capHeightPx.toFixed(1), advance: +advance.toFixed(3), weight: +weight.toFixed(3), gap: +gap.toFixed(3), inkIsDark };
|
||||
}
|
||||
|
||||
export function widthClass(advance) {
|
||||
if (advance < 0.42) return 'compressed';
|
||||
if (advance < 0.52) return 'condensed';
|
||||
if (advance < 0.66) return 'normal';
|
||||
return 'wide';
|
||||
}
|
||||
export function weightClass(weight) {
|
||||
if (weight < 0.22) return 'light';
|
||||
if (weight < 0.3) return 'regular';
|
||||
if (weight < 0.38) return 'medium';
|
||||
if (weight < 0.46) return 'bold';
|
||||
return 'black';
|
||||
}
|
||||
|
||||
export function distance(a, b) {
|
||||
// width and weight both decide whether a face reads as the same face;
|
||||
// tracking is a CSS knob (letter-spacing) so it counts least.
|
||||
return Math.abs(a.advance - b.advance) * 2.5 + Math.abs(a.weight - b.weight) * 2.5 + Math.abs(a.gap - b.gap) * 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* A starter shortlist per width class, Google Fonts only, chosen to span
|
||||
* weight and character inside the class. The model adds its own names on
|
||||
* top; the point is that a ranking never runs against one guessed family.
|
||||
*/
|
||||
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 toward the comp's weight class. */
|
||||
export function withWeightVariants(list, targetWeight) {
|
||||
const out = [];
|
||||
for (const c of list) {
|
||||
out.push(c);
|
||||
const m = /^(.*?):(\d{3})$/.exec(c);
|
||||
if (!m) continue;
|
||||
const w = parseInt(m[2], 10);
|
||||
// always offer one step lighter and one heavier; the ranking decides
|
||||
for (const d of [-200, 200]) { const nw = w + d; if (nw >= 100 && nw <= 900) out.push(`${m[1]}:${nw}`); }
|
||||
}
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
// ---- browser --------------------------------------------------------------
|
||||
|
||||
async function loadBrowser() {
|
||||
const tries = [
|
||||
() => 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 = [
|
||||
() => 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;
|
||||
// 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 browser = await b.mod.chromium.launch();
|
||||
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 browser = await b.mod.launch({ headless: true });
|
||||
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>`;
|
||||
const browser = await b.mod.chromium.launch();
|
||||
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) {
|
||||
return `capHeight ${fp.capHeightPx}px, width ${widthClass(fp.advance)} (advance ${fp.advance}), weight ${weightClass(fp.weight)} (ink ${fp.weight}), tracking ${fp.gap}`;
|
||||
}
|
||||
|
||||
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]');
|
||||
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 = decodePng(fs.readFileSync(spec.comp));
|
||||
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: fp, widthClass: widthClass(fp.advance), weightClass: weightClass(fp.weight) };
|
||||
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
||||
console.log(`MEASURE ${id}: ${describe(fp)} over ${fp.lines} line${fp.lines === 1 ? '' : 's'}, ${fp.glyphs} glyphs. Set this region's font-size so its cap height renders at ${fp.capHeightPx}px; choose a ${widthClass(fp.advance)} ${weightClass(fp.weight)} face.`);
|
||||
if (!rankId) return;
|
||||
const own = parseCandidates(arg('candidates'));
|
||||
const wc = widthClass(fp.advance);
|
||||
const short = withWeightVariants(SHORTLIST[wc] || SHORTLIST.normal, fp.weight).map((x) => parseCandidates(x)[0]);
|
||||
const seen = new Set();
|
||||
const candidates = [...own, ...short].filter((c) => { const k = `${c.family}:${c.weight}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
||||
console.log(`CANDIDATES ${candidates.length}: ${own.length} yours + ${candidates.length - own.length} from the ${wc} shortlist`);
|
||||
const text = arg('text') || region.text || 'The manuals stop. The forum keeps going.';
|
||||
const transform = arg('transform', 'none');
|
||||
const results = await renderCandidates(candidates, text, fp.capHeightPx, { transform });
|
||||
if (!results) {
|
||||
console.log('RANK unavailable: no browser (playwright or puppeteer) resolvable from this project or the impeccable CLI. Choose by the MEASURE line: match the width class first, then the weight class; render one headline word against the comp before building on it.');
|
||||
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) }))
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.filter((r) => { const k = `${r.family}|${r.fp.advance}|${r.fp.weight}|${r.fp.gap}`; if (seenFp.has(k)) return false; seenFp.add(k); return true; });
|
||||
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(', ')}`);
|
||||
for (const r of rows) {
|
||||
const dw = r.fp.advance - fp.advance, dwt = r.fp.weight - fp.weight;
|
||||
console.log(`RANK ${r.family}:${r.weight}${r.loaded ? '' : ' (NOT LOADED, fallback measured)'} distance ${r.d.toFixed(3)} width ${widthClass(r.fp.advance)} (${dw >= 0 ? '+' : ''}${(dw * 100).toFixed(0)}% advance) weight ${weightClass(r.fp.weight)} (${dwt >= 0 ? '+' : ''}${(dwt * 100).toFixed(0)}% ink) font-size ${r.fontSizePx}px for cap ${fp.capHeightPx}px`);
|
||||
}
|
||||
// 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 = [];
|
||||
if (Math.abs(best.fp.advance - fp.advance) > 0.06) advice.push(best.fp.advance > fp.advance ? 'still too wide: try a more condensed face or a variable font with a wdth axis' : 'still too narrow: try a wider face');
|
||||
if (Math.abs(best.fp.weight - fp.weight) > 0.06) advice.push(best.fp.weight > fp.weight ? `too heavy: drop to weight ${Math.max(100, best.weight - 200)}` : `too light: raise to weight ${Math.min(900, best.weight + 200)}`);
|
||||
console.log(`USE font-family: '${best.family}'; font-weight: ${best.weight}; font-size: ${best.fontSizePx}px;${advice.length ? ' NOTE ' + advice.join('; ') : ''}`);
|
||||
region.type.chosen = { family: best.family, weight: best.weight, fontSizePx: best.fontSizePx, fp: best.fp };
|
||||
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); });
|
||||
@@ -235,8 +235,16 @@ if (plateId) {
|
||||
else size = needW > 1024 ? '1536x1024' : '1024x1024';
|
||||
}
|
||||
const extra = arg('prompt') || (arg('prompt-file') ? fs.readFileSync(arg('prompt-file'), 'utf8') : '');
|
||||
const prompt = [platePrompt(spec, region), extra].filter(Boolean).join(' ');
|
||||
plateCtx = { spec, specPath, region, ref, refPath, out, size, prompt, comp, encodePng, resize };
|
||||
// Chroma: an ink-on-ground plate (a line drawing, a figure on flat ground)
|
||||
// is generated on a flat key color and keyed to alpha, so the page's own
|
||||
// ground shows through instead of a second, mismatched paper. Default on
|
||||
// for kind plate when the comp region reads as ink over one flat ground;
|
||||
// --chroma / --no-chroma force it.
|
||||
const wantsChroma = process.argv.includes('--chroma') ? true : process.argv.includes('--no-chroma') ? false : (region.kind === 'plate' && inkOnGround(region));
|
||||
const chromaColor = '#00ff00';
|
||||
const chromaLine = wantsChroma ? ` Render the artwork on a perfectly flat, uniform bright green background (${chromaColor}) that fills every pixel not covered by the artwork; no paper texture, no vignette, no shadow on the green; the green will be removed and the artwork composited onto the page's own surface.` : '';
|
||||
const prompt = [platePrompt(spec, region), extra, chromaLine].filter(Boolean).join(' ');
|
||||
plateCtx = { spec, specPath, region, ref, refPath, out, size, prompt, comp, encodePng, resize, chroma: wantsChroma ? chromaColor : null };
|
||||
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
|
||||
const up = resize(ref, ref.width * 2, ref.height * 2);
|
||||
fs.writeFileSync(out, encodePng(up, { text: { 'impeccable:prompt': prompt } }));
|
||||
@@ -247,11 +255,66 @@ if (plateId) {
|
||||
// fall through to the real call below with the crop as the single --ref
|
||||
}
|
||||
|
||||
/** A region whose crop is dominated by one ground color with a dark second: ink on ground. */
|
||||
function inkOnGround(region) {
|
||||
const pal = region.palette || [];
|
||||
if (pal.length < 2) return false;
|
||||
return pal[0].coverage >= 0.55;
|
||||
}
|
||||
|
||||
function hexRgb(h) { const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(h); return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [0, 255, 0]; }
|
||||
|
||||
/**
|
||||
* Key a flat color to alpha with a soft edge: pixels within `hard` of the key
|
||||
* go fully transparent, within `soft` fade, and green spill on edge pixels is
|
||||
* pulled toward the ink color. Writes back in place. Returns keyed fraction.
|
||||
*/
|
||||
async function keyChroma(file, keyHex) {
|
||||
const { decodePng, encodePng } = await import('./lib/png.mjs');
|
||||
const img = decodePng(fs.readFileSync(file));
|
||||
const [kr, kg, kb] = hexRgb(keyHex);
|
||||
// sample the actual key from the corners: generators shift the green
|
||||
const corners = [[2, 2], [img.width - 3, 2], [2, img.height - 3], [img.width - 3, img.height - 3]];
|
||||
let sr = 0, sg = 0, sb = 0;
|
||||
for (const [x, y] of corners) { const p = (y * img.width + x) * 4; sr += img.data[p]; sg += img.data[p + 1]; sb += img.data[p + 2]; }
|
||||
const key = [sr / 4, sg / 4, sb / 4];
|
||||
const isGreenish = key[1] > 120 && key[1] > key[0] * 1.4 && key[1] > key[2] * 1.4;
|
||||
const K = isGreenish ? key : [kr, kg, kb];
|
||||
const hard = 60, soft = 120;
|
||||
let keyed = 0;
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const r = img.data[i], g = img.data[i + 1], b = img.data[i + 2];
|
||||
const d = Math.sqrt((r - K[0]) ** 2 + (g - K[1]) ** 2 + (b - K[2]) ** 2);
|
||||
// also treat "greener than both other channels by a margin" as key, for gradients the generator adds
|
||||
const greenDom = g > 150 && g - Math.max(r, b) > 60;
|
||||
if (d < hard || greenDom) { img.data[i + 3] = 0; keyed++; continue; }
|
||||
if (d < soft) {
|
||||
const a = (d - hard) / (soft - hard);
|
||||
img.data[i + 3] = Math.round(img.data[i + 3] * a);
|
||||
// despill: pull green down to the mean of the others on the fringe
|
||||
const m = (r + b) / 2; img.data[i + 1] = Math.round(g * a + m * (1 - a));
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(file, encodePng(img));
|
||||
return keyed / (img.data.length / 4);
|
||||
}
|
||||
|
||||
async function scorePlate(ctx, outFile) {
|
||||
try {
|
||||
const { compare } = await import('./comp-diff.mjs');
|
||||
const { decodePng } = await import('./lib/png.mjs');
|
||||
const plate = decodePng(fs.readFileSync(outFile));
|
||||
let plate = decodePng(fs.readFileSync(outFile));
|
||||
// a keyed plate ships over the page ground: composite it over the region's
|
||||
// sampled ground before scoring, the way it will show
|
||||
if (ctx.chroma) {
|
||||
const { createImage, blit } = await import('./lib/raster.mjs');
|
||||
const g = (ctx.region.palette && ctx.region.palette[0] && ctx.region.palette[0].hex) || '#ffffff';
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(g);
|
||||
const ground = m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16), 255] : [255, 255, 255, 255];
|
||||
const over = createImage(plate.width, plate.height, ground);
|
||||
blit(over, plate, 0, 0);
|
||||
plate = over;
|
||||
}
|
||||
// a plate ships under object-fit: cover, so score it the way it will show
|
||||
const res = compare({ comp: ctx.ref, build: plate, align: 'cover', kind: ctx.region.kind });
|
||||
const s = res.whole;
|
||||
@@ -356,4 +419,8 @@ try {
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||
} catch { /* embedding is best-effort */ }
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||
if (plateCtx && plateCtx.chroma) {
|
||||
const frac = await keyChroma(out, plateCtx.chroma);
|
||||
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
|
||||
}
|
||||
if (plateCtx) await scorePlate(plateCtx, out);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { gridToBox, measureRegions, platePrompt } from '../skill/scripts/comp-sp
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SPEC_SCRIPT = path.join(ROOT, 'skill', 'scripts', 'comp-spec.mjs');
|
||||
const PHASE_SCRIPT = path.join(ROOT, 'skill', 'scripts', 'build-phase.mjs');
|
||||
const FONT_SCRIPT = path.join(ROOT, 'skill', 'scripts', 'font-match.mjs');
|
||||
|
||||
function lcg(seed) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0xffffffff); }
|
||||
|
||||
@@ -157,6 +158,13 @@ describe('build-phase state machine (CLI)', () => {
|
||||
res = run(SPEC_SCRIPT, ['--comp', 'comp.png', '--regions', 'regions.json'], dir);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.match(res.stdout, /PLATES 1 to produce: art/);
|
||||
// type must be measured before spec closes
|
||||
res = run(PHASE_SCRIPT, ['advance'], dir);
|
||||
assert.equal(res.status, 2, res.stdout);
|
||||
assert.match(res.stdout, /has no type measurement/);
|
||||
res = run(FONT_SCRIPT, ['--measure', 'headline'], dir);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.match(res.stdout, /MEASURE headline/);
|
||||
res = run(PHASE_SCRIPT, ['advance'], dir);
|
||||
assert.equal(res.status, 0, res.stdout + res.stderr);
|
||||
assert.match(res.stdout, /ADVANCED spec -> plates/);
|
||||
|
||||
Reference in New Issue
Block a user