diff --git a/skill/scripts/build-phase.mjs b/skill/scripts/build-phase.mjs index 897843122..d57a31f9b 100644 --- a/skill/scripts/build-phase.mjs +++ b/skill/scripts/build-phase.mjs @@ -626,7 +626,7 @@ 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; 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 for each text region (cap height, width class, weight class) and font-match.mjs --rank --text "" to choose the headline face by metrics (the USE line is the CSS; with no browser it records the catalog's nearest face, which is the choice; do not install one, and do not write a chosen face into the spec by hand). 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 , then generate-image.mjs --plate (or the harness image tool with the crop as reference and the comp-spec plate prompt). A generation takes 30 to 90 seconds: run it with a long wait (a 90 s yield, or all plates in one command joined with &&) rather than polling an open session turn after turn. A line drawing or figure on flat ground is keyed to alpha automatically (PLATE-CHROMA): place it with a plain 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 --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 'plates': return 'Produce every plate in the spec (comp-spec.mjs --print lists them). Illustrations, photos, figures: node generate-image.mjs --plate , one call per plate. It crops the comp region itself, sends the crop as the edit reference, sizes the plate, keys ink-on-ground to alpha, scores the result against the crop (PLATE-SCORE) and embeds the prompt; nothing else does all of that. Only when it errors (no key, no network) fall back to the harness image tool with comp-spec.mjs --crop as its reference image and comp-spec.mjs --plate-prompt as its prompt, then embed-prompt.mjs; do not post-process a plate with magick or write your own keying. A generation takes 30 to 90 seconds: run it with a long wait (a 90 s yield, or all plates in one command joined with &&) rather than polling an open session turn after turn. A line drawing or figure on flat ground is keyed to alpha automatically (PLATE-CHROMA): place it with a plain 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 --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). The hero passed with the comp\'s words verbatim; from here, content beyond the comp is yours to author at full fidelity, and any change to words the comp showed is a stated decision in your report, never silent. Then build-phase.mjs advance.'; case 'motion': return 'Add the signature interaction, reveals, and motion. Then build-phase.mjs advance.'; diff --git a/skill/scripts/lib/font-fingerprint.mjs b/skill/scripts/lib/font-fingerprint.mjs index 1a89efde6..46707d532 100644 --- a/skill/scripts/lib/font-fingerprint.mjs +++ b/skill/scripts/lib/font-fingerprint.mjs @@ -119,11 +119,23 @@ function findLines(bin) { // (few letters reach it, so the valley rule fires) or a stray rule. Ascender // bands merge back into the line below them; anything else is dropped. for (const ln of lines) { let m = 0; for (let yy = ln.y0; yy < ln.y1; yy++) m += rowInk[yy]; ln.mass = m; } - const maxMass = Math.max(0, ...lines.map((l) => l.mass)); + // 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. + if (lines.length >= 3) { + const hs = lines.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.mass >= maxMass * 0.3) { merged.push({ y0: ln.y0, y1: ln.y1 }); continue; } + 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; } } @@ -346,8 +358,26 @@ function measure(bin, lines) { 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; - const capMax = Math.max(...ms.map((x) => x.m.cap)); - const keep = ms.filter((x) => x.m.cap >= capMax * (1 - tol)); + // 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; @@ -391,7 +421,7 @@ export function fingerprint(img, { minCap = 24, minGlyphs = 3, isolate = true } 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 = findLines(bin).lines.length ? findLines(bin).lines : iso.lines; + lines = iso.lines; isolated = iso.dropped; } } @@ -405,6 +435,12 @@ export function fingerprint(img, { minCap = 24, minGlyphs = 3, isolate = true } 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; diff --git a/tests/font-match.test.mjs b/tests/font-match.test.mjs index e68200003..70a1ffb45 100644 --- a/tests/font-match.test.mjs +++ b/tests/font-match.test.mjs @@ -173,3 +173,20 @@ describe('font-match', () => { assert.ok(distance(rIn.fp, inter) < distance(rIn.fp, lg), 'rendered Inter is nearer its own index entry'); }); }); + +describe('font-fingerprint on mixed crops', () => { + it('measures the body copy, not the drawing beside it or the headline clipped above it', () => { + // 460x300 crop: one clipped headline line at the top (cap ~34), five lines + // of body copy (cap ~10), and a dense drawing (a noise block) at the bottom + const img = createImage(460, 300, [235, 232, 220, 255]); + drawText(img, 'THE MANIFOLDS', 4, 2, [20, 20, 20, 255], 5); + for (let i = 0; i < 5; i++) drawText(img, 'fresh cables slides return cleanly and the', 4, 60 + i * 24, [20, 20, 20, 255], 2); + let seed = 7; const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 0xffffffff); + for (let y = 190; y < 300; y++) for (let x = 0; x < 300; x++) { const v = 40 + Math.floor(rnd() * 180); const p = (y * 460 + x) * 4; img.data[p] = v; img.data[p + 1] = v; img.data[p + 2] = v; } + const fp = fingerprint(img); + assert.ok(fp, 'lettering found'); + assert.ok(fp.capHeightPx >= 8 && fp.capHeightPx <= 14, `body cap measured, got ${fp.capHeightPx}`); + assert.ok(fp.lines >= 4, `body lines, got ${fp.lines}`); + assert.ok(fp.isolatedFrom >= 1, 'the headline line was set aside'); + }); +});