quiver-cli 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +237 -10
- package/package.json +1 -1
- package/template/.agents/skills/agent-browser/SKILL.md +1 -0
- package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
- package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
- package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
- package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
- package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
- package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
- package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
- package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
- package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
- package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
- package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
- package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
- package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
- package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
- package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
- package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
- package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
- package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
- package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
- package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
- package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
- package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
- package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
- package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
- package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
- package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
- package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
- package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
- package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
- package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
- package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
- package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
- package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
- package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
- package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
- package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
- package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
- package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
- package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
- package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
- package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
- package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
- package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
- package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
- package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
- package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
- package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
- package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
- package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
- package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
- package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
- package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
- package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
- package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
- package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
- package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
- package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
- package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
- package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
- package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
- package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
- package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
- package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
- package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
- package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
- package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
- package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
- package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
- package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
- package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
- package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
- package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
- package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
- package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
- package/template/.agents/skills/hono/SKILL.md +17 -3
- package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
- package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
- package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
- package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
- package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
- package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
- package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
- package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
- package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
- package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
- package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
- package/template/.agents/skills/supabase/SKILL.md +5 -1
- package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
- package/template/.agents/skills/writing/humanizer/README.md +209 -0
- package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
- package/template/.agents/upstreams.json +21 -22
- package/template/.agents/skills/design/impeccable/scripts/live/ui-core.mjs +0 -180
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// node embed-prompt.mjs <image> --prompt "the prompt text"
|
|
6
6
|
// node embed-prompt.mjs <image> --prompt-file prompt.txt
|
|
7
7
|
// node embed-prompt.mjs <image> --read
|
|
8
|
+
// node embed-prompt.mjs --scan <dir...> # list rasters missing a prompt; exit 3 when any
|
|
8
9
|
//
|
|
9
10
|
// Formats: PNG (tEXt chunk, keyword "impeccable:prompt"), JPEG (COM segment).
|
|
10
11
|
// WebP and anything else fall back to a `<image>.json` sidecar; --read checks
|
|
@@ -20,14 +21,56 @@ import zlib from 'node:zlib';
|
|
|
20
21
|
const KEYWORD = 'impeccable:prompt';
|
|
21
22
|
const args = process.argv.slice(2);
|
|
22
23
|
const file = args.find(a => !a.startsWith('--'));
|
|
23
|
-
const readMode = args.includes('--read');
|
|
24
24
|
const argOf = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; };
|
|
25
25
|
|
|
26
|
+
function imageType(buffer) {
|
|
27
|
+
if (buffer.length > 8 && buffer.readUInt32BE(0) === 0x89504e47) return 'png';
|
|
28
|
+
if (buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8) return 'jpeg';
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readPrompt(imagePath, buffer = fs.readFileSync(imagePath)) {
|
|
33
|
+
const type = imageType(buffer);
|
|
34
|
+
let prompt = type === 'png' ? parsePng(buffer).prompt : type === 'jpeg' ? readJpegCom(buffer) : null;
|
|
35
|
+
if (prompt == null && fs.existsSync(`${imagePath}.json`)) {
|
|
36
|
+
try { prompt = JSON.parse(fs.readFileSync(`${imagePath}.json`, 'utf8')).prompt ?? null; } catch { /* stays null */ }
|
|
37
|
+
}
|
|
38
|
+
return prompt;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (args.includes('--scan')) {
|
|
42
|
+
const targets = args.filter(a => !a.startsWith('--'));
|
|
43
|
+
if (targets.length === 0) { console.error('embed-prompt: --scan needs at least one directory'); process.exit(1); }
|
|
44
|
+
const RASTER = /\.(png|jpe?g|webp)$/i;
|
|
45
|
+
const rasters = [];
|
|
46
|
+
const walk = (p, isRoot) => {
|
|
47
|
+
const stat = fs.statSync(p);
|
|
48
|
+
if (stat.isDirectory()) {
|
|
49
|
+
const base = p.replace(/\/+$/, '').split('/').pop();
|
|
50
|
+
// Skip installed deps and hidden dirs found during the walk, but honor a
|
|
51
|
+
// hidden dir the caller passed explicitly (e.g. .impeccable/mocks).
|
|
52
|
+
if (!isRoot && (base === 'node_modules' || base.startsWith('.'))) return;
|
|
53
|
+
for (const entry of fs.readdirSync(p)) walk(`${p.replace(/\/+$/, '')}/${entry}`, false);
|
|
54
|
+
} else if (RASTER.test(p)) {
|
|
55
|
+
rasters.push(p);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
for (const target of targets) {
|
|
59
|
+
if (!fs.existsSync(target)) { console.error(`embed-prompt: no such path ${target}`); process.exit(1); }
|
|
60
|
+
walk(target, true);
|
|
61
|
+
}
|
|
62
|
+
let missing = 0;
|
|
63
|
+
for (const raster of rasters) {
|
|
64
|
+
if (readPrompt(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
|
65
|
+
}
|
|
66
|
+
console.log(`SCAN: ${rasters.length} raster${rasters.length === 1 ? '' : 's'}, ${missing} missing`);
|
|
67
|
+
process.exit(missing > 0 ? 3 : 0);
|
|
68
|
+
}
|
|
69
|
+
|
|
26
70
|
if (!file || !fs.existsSync(file)) { console.error('embed-prompt: image file required'); process.exit(1); }
|
|
27
71
|
|
|
28
72
|
const buf = fs.readFileSync(file);
|
|
29
|
-
const
|
|
30
|
-
const isJpeg = buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8;
|
|
73
|
+
const type = imageType(buf);
|
|
31
74
|
|
|
32
75
|
const crcTable = (() => {
|
|
33
76
|
const t = new Uint32Array(256);
|
|
@@ -45,22 +88,26 @@ function pngChunk(type, data) {
|
|
|
45
88
|
return out;
|
|
46
89
|
}
|
|
47
90
|
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
91
|
+
function parsePng(buffer) {
|
|
92
|
+
const chunks = [];
|
|
93
|
+
let prompt = null;
|
|
94
|
+
let offset = 8;
|
|
95
|
+
while (offset + 12 <= buffer.length) {
|
|
96
|
+
const length = buffer.readUInt32BE(offset);
|
|
97
|
+
const type = buffer.toString('ascii', offset + 4, offset + 8);
|
|
98
|
+
const data = buffer.subarray(offset + 8, offset + 8 + length);
|
|
99
|
+
const nul = data.indexOf(0);
|
|
100
|
+
const promptChunk = (type === 'tEXt' || type === 'zTXt')
|
|
101
|
+
&& nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
|
102
|
+
if (prompt == null && promptChunk) {
|
|
103
|
+
prompt = type === 'tEXt'
|
|
104
|
+
? data.toString('utf8', nul + 1)
|
|
105
|
+
: zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
|
|
60
106
|
}
|
|
61
|
-
|
|
107
|
+
chunks.push({ offset, type, promptChunk, bytes: buffer.subarray(offset, offset + 12 + length) });
|
|
108
|
+
offset += 12 + length;
|
|
62
109
|
}
|
|
63
|
-
return
|
|
110
|
+
return { chunks, prompt };
|
|
64
111
|
}
|
|
65
112
|
|
|
66
113
|
function readJpegCom(b) {
|
|
@@ -79,48 +126,34 @@ function readJpegCom(b) {
|
|
|
79
126
|
}
|
|
80
127
|
|
|
81
128
|
const sidecar = `${file}.json`;
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
if (isPng) prompt = readPngText(buf);
|
|
85
|
-
else if (isJpeg) prompt = readJpegCom(buf);
|
|
86
|
-
if (prompt == null && fs.existsSync(sidecar)) {
|
|
87
|
-
try { prompt = JSON.parse(fs.readFileSync(sidecar, 'utf8')).prompt ?? null; } catch { /* fall through */ }
|
|
88
|
-
}
|
|
129
|
+
if (args.includes('--read')) {
|
|
130
|
+
const prompt = readPrompt(file, buf);
|
|
89
131
|
if (prompt == null) { console.error('embed-prompt: no embedded prompt found'); process.exit(2); }
|
|
90
132
|
console.log(prompt);
|
|
91
133
|
process.exit(0);
|
|
92
134
|
}
|
|
93
135
|
|
|
94
|
-
const
|
|
136
|
+
const promptFile = argOf('--prompt-file');
|
|
137
|
+
const prompt = argOf('--prompt') ?? (promptFile ? fs.readFileSync(promptFile, 'utf8') : null);
|
|
95
138
|
if (!prompt) { console.error('embed-prompt: --prompt or --prompt-file required'); process.exit(1); }
|
|
96
139
|
|
|
97
|
-
if (
|
|
140
|
+
if (type === 'png') {
|
|
98
141
|
// Insert (or replace) our tEXt chunk immediately before IEND.
|
|
99
|
-
const
|
|
142
|
+
const { chunks, prompt: existingPrompt } = parsePng(buf);
|
|
143
|
+
const iend = chunks.find((chunk) => chunk.type === 'IEND')?.offset ?? -1;
|
|
100
144
|
if (iend < 8) { console.error('embed-prompt: malformed PNG'); process.exit(1); }
|
|
101
145
|
// Drop any existing chunk with our keyword to keep embedding idempotent.
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const data = buf.subarray(off + 8, off + 8 + len);
|
|
112
|
-
const nul = data.indexOf(0);
|
|
113
|
-
const ours = (type === 'tEXt' || type === 'zTXt') && nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
|
114
|
-
if (!ours && type !== 'IEND') parts.push(chunk);
|
|
115
|
-
off += 12 + len;
|
|
116
|
-
}
|
|
117
|
-
body = Buffer.concat(parts).subarray(8 * 0); // parts exclude signature
|
|
118
|
-
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), pngChunk('IEND', Buffer.alloc(0))]));
|
|
119
|
-
} else {
|
|
120
|
-
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, iend), pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), buf.subarray(iend)]));
|
|
121
|
-
}
|
|
146
|
+
const replacing = existingPrompt != null;
|
|
147
|
+
const body = replacing
|
|
148
|
+
? Buffer.concat(chunks
|
|
149
|
+
.filter((chunk) => chunk.offset < iend && !chunk.promptChunk)
|
|
150
|
+
.map((chunk) => chunk.bytes))
|
|
151
|
+
: buf.subarray(8, iend);
|
|
152
|
+
const promptChunk = pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')]));
|
|
153
|
+
const end = replacing ? pngChunk('IEND', Buffer.alloc(0)) : buf.subarray(iend);
|
|
154
|
+
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, promptChunk, end]));
|
|
122
155
|
console.log(`EMBEDDED: ${file} (png tEXt, ${prompt.length} chars)`);
|
|
123
|
-
} else if (
|
|
156
|
+
} else if (type === 'jpeg') {
|
|
124
157
|
const seg = Buffer.from(`${KEYWORD}\0${prompt}`, 'utf8');
|
|
125
158
|
if (seg.length + 2 > 0xffff) { console.error('embed-prompt: prompt too long for a JPEG segment'); process.exit(1); }
|
|
126
159
|
const com = Buffer.alloc(4 + seg.length);
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* font-match: measure the lettering in a comp's text region and rank candidate
|
|
4
|
+
* faces against it, so the face is chosen by metrics instead of by name.
|
|
5
|
+
*
|
|
6
|
+
* node font-match.mjs --measure <region-id> [--spec .impeccable/build/spec.json]
|
|
7
|
+
* Fingerprints the comp crop of a text region (lib/font-fingerprint.mjs):
|
|
8
|
+
* cap height (px), glyph width per cap height (width class), stroke
|
|
9
|
+
* density and stem width (weight class), tracking, plus the size-invariant
|
|
10
|
+
* shape vector the ranking uses. Prints the summary and stores it on the
|
|
11
|
+
* region in the spec (`type` block), so build code can set font-size from
|
|
12
|
+
* capHeightPx and the hero gate can name a width/weight miss.
|
|
13
|
+
*
|
|
14
|
+
* node font-match.mjs --rank <region-id> [--candidates "Barlow Condensed:700,Oswald:600"] [--text "The manuals stop."] [--category sans,display]
|
|
15
|
+
* Candidates come from a fingerprint index of the Google Fonts catalog
|
|
16
|
+
* (data/font-index.json, ~3,000 faces at two cap heights; the crop is
|
|
17
|
+
* routed to the 14px or 48px index by its cap height): the 25 nearest
|
|
18
|
+
* faces by fingerprint distance, plus the names you pass. Each candidate
|
|
19
|
+
* is then rendered with the region's text at the comp's cap height in a
|
|
20
|
+
* headless browser (Google Fonts CSS), fingerprinted the same way, and
|
|
21
|
+
* ranked by the same distance on the rendered text. Prints CATALOG (the
|
|
22
|
+
* index's top five), the ranking with per-face width and weight deltas,
|
|
23
|
+
* a proof sheet, and the CSS to use (family, weight, and the font-size
|
|
24
|
+
* that reproduces the comp's cap height). Needs a browser: playwright or
|
|
25
|
+
* puppeteer resolvable from the project or the impeccable CLI; without
|
|
26
|
+
* one, the CATALOG line is the ranking. Without the index the built-in
|
|
27
|
+
* per-width-class shortlist stands in.
|
|
28
|
+
*
|
|
29
|
+
* Why: models pick faces from memory and never measure. Three of the six
|
|
30
|
+
* misses a human called on a first-round build were the same miss: the
|
|
31
|
+
* headline face wider and lighter than the comp's, the parts list smaller,
|
|
32
|
+
* the footer heavier. All three are ratios a script can read off pixels.
|
|
33
|
+
*/
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
import { createHash } from 'node:crypto';
|
|
39
|
+
import { decodePng, encodePng, loadRaster } from './lib/png.mjs';
|
|
40
|
+
import { crop } from './lib/raster.mjs';
|
|
41
|
+
import { fingerprint, distance } from './lib/font-fingerprint.mjs';
|
|
42
|
+
import { loadFontIndex, candidatesFromIndex, MIN_RANK_CAP_PX } from './lib/font-index.mjs';
|
|
43
|
+
import { loadSpec, SPEC_PATH } from './comp-spec.mjs';
|
|
44
|
+
|
|
45
|
+
const require = createRequire(import.meta.url);
|
|
46
|
+
|
|
47
|
+
function arg(name, fallback = null) {
|
|
48
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
49
|
+
if (i === -1) return fallback;
|
|
50
|
+
const v = process.argv[i + 1];
|
|
51
|
+
return v && !v.startsWith('--') ? v : fallback;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- fingerprint ----------------------------------------------------------
|
|
55
|
+
// fingerprint(img) and distance(a, b) live in lib/font-fingerprint.mjs: size-
|
|
56
|
+
// invariant shape features per text line (advance, x-ratio, stem width,
|
|
57
|
+
// contrast, serif, density, ink profiles) and a noise-normalized weighted L1
|
|
58
|
+
// fitted on held-out Google Fonts probes. The class helpers below turn two of
|
|
59
|
+
// those features into the words the MEASURE line prints.
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The feature that reads as width: advX (x-height glyph width / R) on a
|
|
63
|
+
* mixed-case crop, advTall (cap glyph width / R) when the crop is all caps.
|
|
64
|
+
* Thresholds sit on the catalog index (advX 0.20 quantile 0.58, median 0.64,
|
|
65
|
+
* 0.80 quantile 0.71) anchored by named faces: League Gothic 0.36, Oswald 0.42,
|
|
66
|
+
* Anton 0.48, Barlow Condensed 0.54, Roboto Condensed 0.58, Roboto 0.62,
|
|
67
|
+
* Inter 0.65, Space Grotesk 0.71, Montserrat Bold 0.76, Archivo Black 0.87.
|
|
68
|
+
*/
|
|
69
|
+
export function widthMeasure(fp) {
|
|
70
|
+
if (!fp) return null;
|
|
71
|
+
if (fp.advX != null) return { key: 'advX', value: fp.advX };
|
|
72
|
+
if (fp.advTall != null) return { key: 'advTall', value: fp.advTall };
|
|
73
|
+
if (fp.advance != null) return { key: 'advance', value: fp.advance };
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
export function widthClass(fp) {
|
|
77
|
+
const m = typeof fp === 'number' ? { key: 'advX', value: fp } : widthMeasure(fp);
|
|
78
|
+
if (!m) return 'normal';
|
|
79
|
+
// cap widths run ~10% wider than x-height widths against the same R
|
|
80
|
+
const t = m.key === 'advTall' ? [0.45, 0.61, 0.78] : [0.42, 0.585, 0.72];
|
|
81
|
+
if (m.value < t[0]) return 'compressed';
|
|
82
|
+
if (m.value < t[1]) return 'condensed';
|
|
83
|
+
if (m.value < t[2]) return 'normal';
|
|
84
|
+
return 'wide';
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The feature that reads as weight: densTall (ink / bbox area of cap-height
|
|
88
|
+
* glyphs); stemW (stem width / R) when no cap glyph was separable. Catalog
|
|
89
|
+
* anchors for densTall: Lato 300 0.27, Roboto 300 0.32, Playfair 400 0.37,
|
|
90
|
+
* Inter 400 0.44, Roboto 700 0.59, Work Sans 700 0.64, Bebas Neue 0.68,
|
|
91
|
+
* Oswald 700 0.72, League Gothic 0.76, Anton 0.79. For stemW: Roboto 300 0.10,
|
|
92
|
+
* Roboto 400 0.14, Inter 700 0.22, Archivo Black 0.30.
|
|
93
|
+
*/
|
|
94
|
+
export function weightMeasure(fp) {
|
|
95
|
+
if (!fp) return null;
|
|
96
|
+
if (fp.densTall != null) return { key: 'densTall', value: fp.densTall };
|
|
97
|
+
if (fp.densX != null) return { key: 'densX', value: fp.densX };
|
|
98
|
+
if (fp.stemW != null) return { key: 'stemW', value: fp.stemW };
|
|
99
|
+
if (fp.weight != null) return { key: 'weight', value: fp.weight };
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
export function weightClass(fp) {
|
|
103
|
+
const m = typeof fp === 'number' ? { key: 'densTall', value: fp } : weightMeasure(fp);
|
|
104
|
+
if (!m) return 'regular';
|
|
105
|
+
const t = m.key === 'stemW' ? [0.105, 0.165, 0.195, 0.24] : [0.34, 0.48, 0.56, 0.66];
|
|
106
|
+
if (m.value < t[0]) return 'light';
|
|
107
|
+
if (m.value < t[1]) return 'regular';
|
|
108
|
+
if (m.value < t[2]) return 'medium';
|
|
109
|
+
if (m.value < t[3]) return 'bold';
|
|
110
|
+
return 'black';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A starter shortlist per width class, Google Fonts only, chosen to span
|
|
115
|
+
* weight and character inside the class. Used only when the catalog index
|
|
116
|
+
* (data/font-index.json) is missing; with the index, candidates come from
|
|
117
|
+
* the comp's fingerprint and the model's own names.
|
|
118
|
+
*/
|
|
119
|
+
export const SHORTLIST = {
|
|
120
|
+
compressed: ['League Gothic:400', 'Bebas Neue:400', 'Anton:400', 'Six Caps:400', 'Big Shoulders Display:900', 'Antonio:700', 'Saira Extra Condensed:800', 'Oswald:700'],
|
|
121
|
+
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'],
|
|
122
|
+
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'],
|
|
123
|
+
wide: ['Archivo Black:400', 'Syne:800', 'Space Grotesk:700', 'Unbounded:700', 'Bricolage Grotesque:800', 'Sora:800', 'Outfit:800', 'Lexend:800'],
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Weight-shifted variants of a candidate list, one step lighter and heavier; the ranking decides. */
|
|
127
|
+
export function withWeightVariants(list) {
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const c of list) {
|
|
130
|
+
out.push(c);
|
|
131
|
+
const m = /^(.*?):(\d{3})$/.exec(c);
|
|
132
|
+
if (!m) continue;
|
|
133
|
+
const w = parseInt(m[2], 10);
|
|
134
|
+
for (const d of [-200, 200]) { const nw = w + d; if (nw >= 100 && nw <= 900) out.push(`${m[1]}:${nw}`); }
|
|
135
|
+
}
|
|
136
|
+
return [...new Set(out)];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Candidate faces for a comp fingerprint: the nearest index faces (top n by
|
|
141
|
+
* fingerprint distance, routed to the 14px or 48px index by the crop's cap
|
|
142
|
+
* height, optionally filtered by category), the caller's own names first,
|
|
143
|
+
* and the built-in shortlist only when there is no index. Returns
|
|
144
|
+
* { candidates: [{ family, weight }], catalog: [index hits], source }.
|
|
145
|
+
*/
|
|
146
|
+
export function selectCandidates(fp, { own = [], index = null, n = 25, category = null } = {}) {
|
|
147
|
+
const catalog = index ? candidatesFromIndex(fp, index, { n, category }) : [];
|
|
148
|
+
const list = [...own, ...catalog.map((c) => ({ family: c.family, weight: c.weight }))];
|
|
149
|
+
let source = 'index';
|
|
150
|
+
if (!index) {
|
|
151
|
+
source = 'shortlist';
|
|
152
|
+
for (const s of withWeightVariants(SHORTLIST[widthClass(fp)] || SHORTLIST.normal)) list.push(parseCandidates(s)[0]);
|
|
153
|
+
}
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
const candidates = list.filter((c) => { const k = `${c.family}:${c.weight}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
|
156
|
+
return { candidates, catalog, source };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A choice font-match wrote carries a stamp over its own fields, so the spec
|
|
161
|
+
* gate can tell a measured choice from a hand-typed one. Sessions with no
|
|
162
|
+
* browser wrote `"chosen": { "family": "Arial Narrow", "source": "system-fallback" }`
|
|
163
|
+
* straight into spec.json to get past the gate; that is the guess the gate
|
|
164
|
+
* exists to refuse. Not secret, just not something a model reaches for.
|
|
165
|
+
*/
|
|
166
|
+
export function stampChoice(regionId, chosen) {
|
|
167
|
+
const h = createHash('sha1').update(`font-match:${regionId}:${chosen.family}:${chosen.weight}:${chosen.fontSizePx}:${chosen.source}`).digest('hex').slice(0, 12);
|
|
168
|
+
return { ...chosen, stamp: h };
|
|
169
|
+
}
|
|
170
|
+
export function choiceStamped(regionId, chosen) {
|
|
171
|
+
if (!chosen || !chosen.stamp) return false;
|
|
172
|
+
return stampChoice(regionId, { ...chosen, stamp: undefined }).stamp === chosen.stamp;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- browser --------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Playwright and puppeteer write launch artifacts to os.tmpdir(). In a
|
|
179
|
+
* sandbox whose /tmp is not writable (the ninth sweep: EPERM on
|
|
180
|
+
* mkdtemp /tmp/playwright-artifacts-*), every rank silently fell back to the
|
|
181
|
+
* catalog and three of four builds set headlines at twice the comp's cap.
|
|
182
|
+
* Probe once and point TMPDIR at a workspace dir when the system one fails.
|
|
183
|
+
*/
|
|
184
|
+
function ensureWritableTmp() {
|
|
185
|
+
const os = require('node:os');
|
|
186
|
+
try { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'fm-')); fs.rmSync(d, { recursive: true, force: true }); return; } catch { /* not writable */ }
|
|
187
|
+
const local = path.resolve('.impeccable', 'tmp');
|
|
188
|
+
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 */ }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function loadBrowser() {
|
|
192
|
+
ensureWritableTmp();
|
|
193
|
+
// IMPECCABLE_NODE_MODULES: a node_modules dir holding playwright or
|
|
194
|
+
// puppeteer, for harnesses that mount the skill somewhere its own resolution
|
|
195
|
+
// roots cannot see (a sandbox root, a plugin cache). NODE_PATH works too.
|
|
196
|
+
const extra = (process.env.IMPECCABLE_NODE_MODULES || '').split(path.delimiter).filter(Boolean);
|
|
197
|
+
const tries = [
|
|
198
|
+
...extra.map((dir) => () => require(require.resolve('playwright', { paths: [dir, path.dirname(dir)] }))),
|
|
199
|
+
() => require('playwright'),
|
|
200
|
+
() => require(require.resolve('playwright', { paths: [process.cwd()] })),
|
|
201
|
+
() => require(require.resolve('playwright', { paths: [path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')] })),
|
|
202
|
+
];
|
|
203
|
+
for (const t of tries) { try { const pw = t(); if (pw?.chromium) return { kind: 'playwright', mod: pw }; } catch { /* next */ } }
|
|
204
|
+
const tries2 = [
|
|
205
|
+
...extra.map((dir) => () => require(require.resolve('puppeteer', { paths: [dir, path.dirname(dir)] }))),
|
|
206
|
+
() => require('puppeteer'),
|
|
207
|
+
() => require(require.resolve('puppeteer', { paths: [process.cwd()] })),
|
|
208
|
+
() => require(require.resolve('puppeteer', { paths: [path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')] })),
|
|
209
|
+
];
|
|
210
|
+
for (const t of tries2) { try { const pp = t(); if (pp?.launch) return { kind: 'puppeteer', mod: pp }; } catch { /* next */ } }
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseCandidates(s) {
|
|
215
|
+
return String(s || '').split(',').map((x) => x.trim()).filter(Boolean).map((x) => {
|
|
216
|
+
const m = /^(.*?)(?::(\d{3}))?$/.exec(x);
|
|
217
|
+
return { family: m[1].trim(), weight: m[2] ? parseInt(m[2], 10) : 400 };
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Render `text` in each candidate at a font-size whose measured cap height ~= targetCapPx; return fingerprints. */
|
|
222
|
+
export async function renderCandidates(candidates, text, targetCapPx, { transform = 'none' } = {}) {
|
|
223
|
+
const b = await loadBrowser();
|
|
224
|
+
if (!b) return null;
|
|
225
|
+
// A resolvable module whose browser binary is absent (CI, a fresh install
|
|
226
|
+
// without npx playwright install) throws at launch; that is the same
|
|
227
|
+
// situation as no module, and the catalog fallback owns it.
|
|
228
|
+
let browser;
|
|
229
|
+
try { browser = b.kind === 'playwright' ? await b.mod.chromium.launch() : await b.mod.launch({ headless: true }); } catch { return null; }
|
|
230
|
+
// One stylesheet per family+weight: a combined request 400s when any one
|
|
231
|
+
// family lacks the requested axis (Anton has no wght range), and a static
|
|
232
|
+
// family answers only for the weights it ships.
|
|
233
|
+
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('');
|
|
234
|
+
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>`;
|
|
235
|
+
const results = [];
|
|
236
|
+
const size0 = Math.max(12, Math.round(targetCapPx * 1.4));
|
|
237
|
+
if (b.kind === 'playwright') {
|
|
238
|
+
const page = await browser.newPage({ viewport: { width: 1600, height: 400 }, deviceScaleFactor: 1 });
|
|
239
|
+
await page.setContent(html, { waitUntil: 'load' });
|
|
240
|
+
await page.waitForTimeout(800);
|
|
241
|
+
for (const c of candidates) {
|
|
242
|
+
// two passes: measure at size0, then rescale so the fingerprint's cap height matches the comp
|
|
243
|
+
let size = size0, fp = null, ok = true;
|
|
244
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
245
|
+
await page.evaluate(({ family, weight, size, text }) => {
|
|
246
|
+
document.body.innerHTML = `<div class="s" style="font-family:'${family}',sans-serif;font-weight:${weight};font-size:${size}px">${text}</div>`;
|
|
247
|
+
}, { family: c.family, weight: c.weight, size, text });
|
|
248
|
+
let loaded = false;
|
|
249
|
+
// Loaded means a real face of this family covers the requested weight;
|
|
250
|
+
// fonts.check() answers true for a synthetic bold of a lighter file.
|
|
251
|
+
try {
|
|
252
|
+
loaded = await page.evaluate(async (f) => {
|
|
253
|
+
const faces = await document.fonts.load(`${f.weight} 32px '${f.family}'`);
|
|
254
|
+
await document.fonts.ready;
|
|
255
|
+
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; };
|
|
256
|
+
return faces.some((face) => face.family.replace(/["']/g, '') === f.family && face.status === 'loaded' && covers(face));
|
|
257
|
+
}, c);
|
|
258
|
+
} catch { loaded = false; }
|
|
259
|
+
await page.waitForTimeout(100);
|
|
260
|
+
if (!loaded) ok = false;
|
|
261
|
+
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 }; });
|
|
262
|
+
const buf = await page.screenshot({ clip: { x: 0, y: 0, width: Math.min(1600, box.w), height: Math.min(400, box.h) } });
|
|
263
|
+
fp = fingerprint(decodePng(buf));
|
|
264
|
+
if (!fp || pass === 1) break;
|
|
265
|
+
size = Math.max(8, Math.round(size * (targetCapPx / fp.capHeightPx)));
|
|
266
|
+
}
|
|
267
|
+
results.push({ ...c, loaded: ok, fontSizePx: size, fp });
|
|
268
|
+
}
|
|
269
|
+
await browser.close();
|
|
270
|
+
} else {
|
|
271
|
+
const page = await browser.newPage();
|
|
272
|
+
await page.setViewport({ width: 1600, height: 400 });
|
|
273
|
+
await page.setContent(html, { waitUntil: 'load' });
|
|
274
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
275
|
+
for (const c of candidates) {
|
|
276
|
+
let size = size0, fp = null, ok = true;
|
|
277
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
278
|
+
await page.evaluate(({ family, weight, size, text }) => {
|
|
279
|
+
document.body.innerHTML = `<div class="s" style="font-family:'${family}',sans-serif;font-weight:${weight};font-size:${size}px">${text}</div>`;
|
|
280
|
+
}, { family: c.family, weight: c.weight, size, text });
|
|
281
|
+
let loaded = false;
|
|
282
|
+
// Loaded means a real face of this family covers the requested weight;
|
|
283
|
+
// fonts.check() answers true for a synthetic bold of a lighter file.
|
|
284
|
+
try {
|
|
285
|
+
loaded = await page.evaluate(async (f) => {
|
|
286
|
+
const faces = await document.fonts.load(`${f.weight} 32px '${f.family}'`);
|
|
287
|
+
await document.fonts.ready;
|
|
288
|
+
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; };
|
|
289
|
+
return faces.some((face) => face.family.replace(/["']/g, '') === f.family && face.status === 'loaded' && covers(face));
|
|
290
|
+
}, c);
|
|
291
|
+
} catch { loaded = false; }
|
|
292
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
293
|
+
if (!loaded) ok = false;
|
|
294
|
+
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 }; });
|
|
295
|
+
const buf = await page.screenshot({ clip: { x: 0, y: 0, width: Math.min(1600, box.w), height: Math.min(400, box.h) } });
|
|
296
|
+
fp = fingerprint(decodePng(buf));
|
|
297
|
+
if (!fp || pass === 1) break;
|
|
298
|
+
size = Math.max(8, Math.round(size * (targetCapPx / fp.capHeightPx)));
|
|
299
|
+
}
|
|
300
|
+
results.push({ ...c, loaded: ok, fontSizePx: size, fp });
|
|
301
|
+
}
|
|
302
|
+
await browser.close();
|
|
303
|
+
}
|
|
304
|
+
return results;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Comp crop over the top candidates, rendered at the comp's cap height, as one PNG. */
|
|
308
|
+
export async function renderProofSheet(compCrop, top, text, capPx, transform = 'none') {
|
|
309
|
+
const b = await loadBrowser();
|
|
310
|
+
if (!b || b.kind !== 'playwright') return null;
|
|
311
|
+
const compB64 = Buffer.from(encodePng(compCrop)).toString('base64');
|
|
312
|
+
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('');
|
|
313
|
+
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('');
|
|
314
|
+
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>`;
|
|
315
|
+
let browser;
|
|
316
|
+
try { browser = await b.mod.chromium.launch(); } catch { return null; }
|
|
317
|
+
const page = await browser.newPage({ viewport: { width: Math.min(1600, Math.max(600, compCrop.width + 24)), height: 200 } });
|
|
318
|
+
await page.setContent(html, { waitUntil: 'load' });
|
|
319
|
+
try { await page.evaluate(async () => { await document.fonts.ready; }); } catch { /* ignore */ }
|
|
320
|
+
await page.waitForTimeout(600);
|
|
321
|
+
const buf = await page.screenshot({ fullPage: true });
|
|
322
|
+
await browser.close();
|
|
323
|
+
return buf;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- CLI ------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
function describe(fp) {
|
|
329
|
+
const wm = widthMeasure(fp), wt = weightMeasure(fp);
|
|
330
|
+
const wmS = wm ? ` (${wm.key} ${wm.value})` : '';
|
|
331
|
+
const wtS = wt ? ` (${wt.key} ${wt.value})` : '';
|
|
332
|
+
return `capHeight ${fp.capHeightPx}px, width ${widthClass(fp)}${wmS}, weight ${weightClass(fp)}${wtS}, tracking ${fp.gap}${fp.allCaps ? ', all caps' : ''}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Fingerprint fields the spec keeps for a region: the class-bearing features plus the shape summary, not the whole vector. */
|
|
336
|
+
function compactFp(fp) {
|
|
337
|
+
if (!fp) return fp;
|
|
338
|
+
const keep = ['lines', 'glyphs', 'capHeightPx', 'inkIsDark', 'allCaps', 'advance', 'advTall', 'advX', 'gap', 'xRatio', 'stemW', 'contrast', 'serif', 'densTall', 'densX', 'weight'];
|
|
339
|
+
const out = {};
|
|
340
|
+
for (const k of keep) if (fp[k] !== undefined) out[k] = fp[k];
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function main() {
|
|
345
|
+
const specPath = arg('spec', SPEC_PATH);
|
|
346
|
+
const spec = loadSpec(specPath);
|
|
347
|
+
const measureId = arg('measure'), rankId = arg('rank');
|
|
348
|
+
const id = measureId || rankId;
|
|
349
|
+
if (!id) {
|
|
350
|
+
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]');
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
353
|
+
if (!spec) { console.error(`font-match: no spec at ${specPath}; run comp-spec.mjs first`); process.exit(1); }
|
|
354
|
+
const region = spec.regions.find((r) => r.id === id);
|
|
355
|
+
if (!region) { console.error(`font-match: no region ${id}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); }
|
|
356
|
+
const comp = loadRaster(spec.comp).image;
|
|
357
|
+
const c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h);
|
|
358
|
+
const fp = fingerprint(c);
|
|
359
|
+
if (!fp) {
|
|
360
|
+
// Record the attempt so the spec gate does not ask again; a region with
|
|
361
|
+
// no separable glyphs (a rule, a bar of solid ink, a very small label at
|
|
362
|
+
// comp resolution) is measured as "no lettering" and the model sizes it
|
|
363
|
+
// by its box.
|
|
364
|
+
region.type = { ...(region.type || {}), comp: null, measuredAt: new Date().toISOString(), note: 'no separable lettering in the crop; size by the region box' };
|
|
365
|
+
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
|
366
|
+
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.`);
|
|
367
|
+
process.exit(0);
|
|
368
|
+
}
|
|
369
|
+
region.type = { ...(region.type || {}), comp: compactFp(fp), widthClass: widthClass(fp), weightClass: weightClass(fp) };
|
|
370
|
+
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
|
371
|
+
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.`);
|
|
372
|
+
if (!rankId) return;
|
|
373
|
+
if (fp.capHeightPx < MIN_RANK_CAP_PX) {
|
|
374
|
+
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.`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const own = parseCandidates(arg('candidates'));
|
|
378
|
+
const index = loadFontIndex();
|
|
379
|
+
const { candidates, catalog, source } = selectCandidates(fp, { own, index, n: 25, category: arg('category') });
|
|
380
|
+
if (index) {
|
|
381
|
+
const top5 = []; for (const h of catalog) { if (!top5.some((t) => t.family === h.family)) top5.push(h); if (top5.length >= 5) break; }
|
|
382
|
+
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')}` : ''})`);
|
|
383
|
+
console.log(`CANDIDATES ${candidates.length}: ${own.length} yours + ${candidates.length - own.length} nearest in the catalog index`);
|
|
384
|
+
} else {
|
|
385
|
+
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)`);
|
|
386
|
+
}
|
|
387
|
+
const text = arg('text') || region.text || 'The manuals stop. The forum keeps going.';
|
|
388
|
+
const transform = arg('transform', fp.allCaps ? 'uppercase' : 'none');
|
|
389
|
+
const results = await renderCandidates(candidates, text, fp.capHeightPx, { transform });
|
|
390
|
+
if (!results) {
|
|
391
|
+
// No browser: the catalog fingerprint index is the ranking. Its top hit is
|
|
392
|
+
// recorded as the chosen face (source `catalog`) so the spec gate has a
|
|
393
|
+
// measured choice to close on; without this the gate refused forever and
|
|
394
|
+
// sessions forced past it or spent ten turns installing Playwright.
|
|
395
|
+
// font-size is estimated from the cap height at a 0.70 cap/em ratio, the
|
|
396
|
+
// sans display median; the NOTE says to check one rendered word.
|
|
397
|
+
if (index && catalog[0]) {
|
|
398
|
+
const best = catalog[0];
|
|
399
|
+
const fontSizePx = Math.round(fp.capHeightPx / 0.70);
|
|
400
|
+
console.log(`RANK unavailable: no browser (playwright or puppeteer) resolvable from this project or the impeccable CLI; the CATALOG order stands as the ranking.`);
|
|
401
|
+
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.`);
|
|
402
|
+
region.type.chosen = stampChoice(id, { family: best.family, weight: best.weight, fontSizePx, source: 'catalog', estimatedSize: true });
|
|
403
|
+
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
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.`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
// Drop faces that never loaded (a weight the family does not ship falls
|
|
410
|
+
// back to a system face and would rank as that face), then collapse
|
|
411
|
+
// duplicate renders (two requested weights that resolved to one file).
|
|
412
|
+
const seenFp = new Set();
|
|
413
|
+
const rows = results
|
|
414
|
+
.filter((r) => r.fp && r.loaded)
|
|
415
|
+
.map((r) => ({ ...r, d: distance(fp, r.fp) }))
|
|
416
|
+
.filter((r) => Number.isFinite(r.d))
|
|
417
|
+
.sort((a, b) => a.d - b.d)
|
|
418
|
+
.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; });
|
|
419
|
+
const dropped = results.filter((r) => !r.loaded).map((r) => `${r.family}:${r.weight}`);
|
|
420
|
+
if (dropped.length) console.log(`SKIPPED (not available at that weight on Google Fonts): ${dropped.join(', ')}`);
|
|
421
|
+
const wm = widthMeasure(fp), wt = weightMeasure(fp);
|
|
422
|
+
const pctDelta = (m, other) => { if (!m || other?.[m.key] == null) return null; return (other[m.key] - m.value) / m.value; };
|
|
423
|
+
const fmtPct = (v) => (v == null ? 'n/a' : `${v >= 0 ? '+' : ''}${(v * 100).toFixed(0)}%`);
|
|
424
|
+
for (const r of rows) {
|
|
425
|
+
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`);
|
|
426
|
+
}
|
|
427
|
+
// proof sheet: comp crop over the top three renders, so the choice is seen, not only scored
|
|
428
|
+
try {
|
|
429
|
+
const top = rows.slice(0, 3);
|
|
430
|
+
const sheet = await renderProofSheet(c, top, text, fp.capHeightPx, transform);
|
|
431
|
+
if (sheet) {
|
|
432
|
+
const out = path.join(path.dirname(specPath), 'font-match', `${id}.png`);
|
|
433
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
434
|
+
fs.writeFileSync(out, sheet);
|
|
435
|
+
console.log(`PROOF ${out} (comp crop, then the top ${top.length} candidates at the comp's cap height; open it before choosing)`);
|
|
436
|
+
}
|
|
437
|
+
} catch { /* proof sheet is best-effort */ }
|
|
438
|
+
const best = rows[0];
|
|
439
|
+
if (best) {
|
|
440
|
+
const advice = [];
|
|
441
|
+
const dw = pctDelta(wm, best.fp), dwt = pctDelta(wt, best.fp);
|
|
442
|
+
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');
|
|
443
|
+
// a weight step only helps on a family that ships one; a single-cut display face is what it is
|
|
444
|
+
const bestEntry = index?.entries.find((e) => e.family === best.family);
|
|
445
|
+
const variable = bestEntry ? bestEntry.variable : true;
|
|
446
|
+
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)}`);
|
|
447
|
+
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('; ') : ''}`);
|
|
448
|
+
region.type.chosen = stampChoice(id, { family: best.family, weight: best.weight, fontSizePx: best.fontSizePx, source, fp: compactFp(best.fp) });
|
|
449
|
+
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const isMain = (() => {
|
|
454
|
+
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
|
|
455
|
+
catch { return false; }
|
|
456
|
+
})();
|
|
457
|
+
if (isMain) main().catch((e) => { console.error(`font-match: ${e.message}`); process.exit(1); });
|