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.
Files changed (121) hide show
  1. package/dist/cli.js +237 -10
  2. package/package.json +1 -1
  3. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  4. package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
  5. package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
  6. package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
  7. package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
  8. package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
  9. package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
  10. package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
  11. package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
  12. package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
  13. package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
  14. package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
  15. package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
  16. package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
  17. package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
  18. package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
  19. package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
  20. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
  21. package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
  22. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
  23. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
  24. package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
  25. package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
  26. package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
  27. package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
  28. package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
  29. package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
  30. package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
  31. package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
  32. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
  33. package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
  34. package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
  35. package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
  36. package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
  37. package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
  38. package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
  39. package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
  40. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
  41. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
  42. package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
  43. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
  44. package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
  45. package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
  46. package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
  47. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
  48. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
  49. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
  50. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
  51. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
  52. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
  53. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
  54. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
  55. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
  56. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
  57. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
  58. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
  59. package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
  60. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
  61. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
  62. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
  63. package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
  64. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
  65. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
  66. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
  67. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
  68. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
  69. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
  70. package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
  71. package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
  72. package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
  73. package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
  74. package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
  75. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
  76. package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
  77. package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
  78. package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
  79. package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
  81. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
  83. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
  84. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
  85. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
  86. package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
  87. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
  88. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
  89. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
  90. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
  91. package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
  92. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
  93. package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
  94. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
  95. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
  96. package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
  97. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
  98. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
  99. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
  100. package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
  101. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
  102. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
  103. package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
  104. package/template/.agents/skills/hono/SKILL.md +17 -3
  105. package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
  106. package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
  107. package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
  108. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
  109. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
  110. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
  111. package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
  112. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
  113. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
  114. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
  115. package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
  116. package/template/.agents/skills/supabase/SKILL.md +5 -1
  117. package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
  118. package/template/.agents/skills/writing/humanizer/README.md +209 -0
  119. package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
  120. package/template/.agents/upstreams.json +21 -22
  121. package/template/.agents/skills/design/impeccable/scripts/live/ui-core.mjs +0 -180
@@ -0,0 +1,564 @@
1
+ /**
2
+ * font-fingerprint: size-invariant, text-robust shape features for lettering
3
+ * in a raster (a comp crop or a rendered sample). fingerprint(img) returns the
4
+ * feature vector; distance(a, b) compares two vectors over noise-normalized,
5
+ * weighted features. Used by font-match.mjs (comp measurement and ranking)
6
+ * and by the catalog index build (scripts/build-font-index.mjs at the repo root). Depends only on
7
+ * lib/image-metrics.mjs and lib/raster.mjs.
8
+ *
9
+ * Every measure is taken per text line and normalized by R, the line's
10
+ * reference height (median of the tallest column heights above the baseline:
11
+ * the cap line on an all-caps line, the ascender line on a mixed line), so
12
+ * the same face gives the same numbers at any point size; per-glyph measures
13
+ * are medians so the numbers survive a change of text. Small crops are
14
+ * upsampled (bilinear) so R is at least 24px, and stroke runs are measured
15
+ * with antialiased edge pixels counted by coverage, so stem widths do not
16
+ * fatten at small sizes.
17
+ *
18
+ * Features (all in R units unless noted; null when not measurable):
19
+ * advance/advTall/advX median glyph width over baseline glyphs / tall glyphs / x-height glyphs
20
+ * advCV spread of glyph widths (std/median): mono ~0.15, sans ~0.3, script > 0.5
21
+ * gap median inter-glyph gap
22
+ * xRatio x-line / R (null on all-caps lines)
23
+ * descRatio descender depth (90th pct)
24
+ * stemW median horizontal ink run in the x band (stem width)
25
+ * contrast stem width / median thin (vertical) run: didone high, grotesque ~1
26
+ * serif foot width / mid-stem width on stems that reach the baseline
27
+ * roundFrac fraction of glyphs with bbox aspect > 0.9
28
+ * densTall / densX ink / bbox area for tall / x-height glyphs (weight)
29
+ * runDensity horizontal ink runs per row per R of line width (stroke busyness)
30
+ * vprof0..9 normalized vertical ink profile from 0.35R below baseline to 1.05R above
31
+ * hrun25/50/75/90 quantiles of horizontal run lengths over the letter body
32
+ * vrun25/50/75/90 quantiles of vertical run lengths over the whole line
33
+ * colq25/75 quantiles of column heights above the baseline
34
+ * wq25/75 quantiles of glyph widths
35
+ * Also returned: lines, glyphs, capHeightPx (R in source pixels), allCaps, inkIsDark,
36
+ * upsampled, weight (densTall, so v1 callers keep a weight field).
37
+ */
38
+ import { toGray } from './image-metrics.mjs';
39
+ import { resize } from './raster.mjs';
40
+
41
+ function otsu(gray) {
42
+ const hist = new Float64Array(256);
43
+ for (let i = 0; i < gray.data.length; i++) hist[Math.max(0, Math.min(255, Math.round(gray.data[i])))]++;
44
+ const total = gray.data.length;
45
+ let sum = 0; for (let i = 0; i < 256; i++) sum += i * hist[i];
46
+ let sumB = 0, wB = 0, best = 0, thr = 128;
47
+ for (let t = 0; t < 256; t++) {
48
+ wB += hist[t]; if (!wB) continue;
49
+ const wF = total - wB; if (!wF) break;
50
+ sumB += t * hist[t];
51
+ const mB = sumB / wB, mF = (sum - sumB) / wF;
52
+ const between = wB * wF * (mB - mF) ** 2;
53
+ if (between > best) { best = between; thr = t; }
54
+ }
55
+ return thr;
56
+ }
57
+
58
+ const med = (a) => { if (!a.length) return null; const s = [...a].sort((p, q) => p - q); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
59
+ const pct = (a, p) => { if (!a.length) return null; const s = [...a].sort((p, q) => p - q); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
60
+ const mean = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : null);
61
+
62
+ /** Binarize; returns { W, H, ink: Uint8Array, inkIsDark }. */
63
+ function binarize(img) {
64
+ const g = toGray(img);
65
+ let thr = otsu(g);
66
+ let dark = 0; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++;
67
+ // a two-level raster (no antialiasing) puts the Otsu threshold on the dark
68
+ // level itself; step it up so that level counts as ink
69
+ if (!dark) { thr += 1; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++; }
70
+ const inkIsDark = dark <= g.data.length / 2;
71
+ const ink = new Uint8Array(g.data.length);
72
+ let sI = 0, nI = 0, sG = 0, nG = 0;
73
+ for (let i = 0; i < g.data.length; i++) {
74
+ const on = (inkIsDark ? g.data[i] < thr : g.data[i] >= thr) ? 1 : 0;
75
+ ink[i] = on;
76
+ if (on) { sI += g.data[i]; nI++; } else { sG += g.data[i]; nG++; }
77
+ }
78
+ const inkLevel = nI ? sI / nI : (inkIsDark ? 0 : 255), groundLevel = nG ? sG / nG : (inkIsDark ? 255 : 0);
79
+ // coverage per pixel: 0 = ground, 1 = ink, linear between the two class means, so
80
+ // antialiased edge pixels count fractionally and stroke widths do not fatten at small sizes
81
+ const covA = new Float32Array(g.data.length);
82
+ const den = groundLevel - inkLevel || 1;
83
+ for (let i = 0; i < g.data.length; i++) covA[i] = Math.max(0, Math.min(1, (groundLevel - g.data[i]) / den));
84
+ const cov = (i) => covA[i];
85
+ return { W: g.width, H: g.height, ink, inkIsDark, cov, covA };
86
+ }
87
+
88
+ /** Text lines from the row-ink profile (same rules as font-match v1). */
89
+ function findLines(bin) {
90
+ const { W, H, ink } = bin;
91
+ // Columns inked top to bottom (a rule, a black margin, a page edge) span
92
+ // every line and would fuse them into one run: leave them out of the row
93
+ // profile. Lettering never fills a column for more than ~85% of the crop.
94
+ const colInk = new Uint32Array(W);
95
+ for (let y = 0; y < H; y++) { const o = y * W; for (let x = 0; x < W; x++) colInk[x] += ink[o + x]; }
96
+ const colOk = new Uint8Array(W);
97
+ let okCount = 0;
98
+ for (let x = 0; x < W; x++) { if (colInk[x] < H * 0.85) { colOk[x] = 1; okCount++; } }
99
+ if (!okCount) return { lines: [], rowInk: new Uint32Array(H) };
100
+ const rowInk = new Uint32Array(H);
101
+ for (let y = 0; y < H; y++) { let c = 0; const o = y * W; for (let x = 0; x < W; x++) if (colOk[x]) c += ink[o + x]; rowInk[y] = c; }
102
+ const floor = Math.max(1, W * 0.004);
103
+ const runs = [];
104
+ let y = 0;
105
+ while (y < H) {
106
+ if (rowInk[y] > floor) {
107
+ const y0 = y; while (y < H && (rowInk[y] > floor || (y + 1 < H && rowInk[y + 1] > floor))) y++;
108
+ if (y - y0 >= 4) runs.push({ y0, y1: y });
109
+ } else y++;
110
+ }
111
+ const lines = [];
112
+ for (const run of runs) {
113
+ let peak = 0; for (let yy = run.y0; yy < run.y1; yy++) peak = Math.max(peak, rowInk[yy]);
114
+ const valley = peak * 0.15;
115
+ let start = run.y0, inValley = false, valleyStart = 0;
116
+ for (let yy = run.y0; yy < run.y1; yy++) {
117
+ const low = rowInk[yy] < valley;
118
+ if (low && !inValley) { inValley = true; valleyStart = yy; }
119
+ if (!low && inValley) {
120
+ inValley = false;
121
+ if (yy - valleyStart >= 3 && valleyStart - start >= 4) { lines.push({ y0: start, y1: valleyStart, run }); start = yy; }
122
+ }
123
+ }
124
+ if (run.y1 - start >= 4) lines.push({ y0: start, y1: run.y1, run });
125
+ }
126
+ // A piece split off inside one run with a fraction of the ink of the text
127
+ // lines is not a line: a thin band of ascenders or tittles above the x band
128
+ // (few letters reach it, so the valley rule fires) or a stray rule. Ascender
129
+ // bands merge back into the line below them; anything else is dropped.
130
+ for (const ln of lines) { let m = 0; for (let yy = ln.y0; yy < ln.y1; yy++) m += rowInk[yy]; ln.mass = m; }
131
+ // A drawing or photo sharing the crop with body copy is one tall, massive
132
+ // 'line' that would carry maxMass and drop every real line under the 30%
133
+ // rule (a 461x307 thread crop measured as one 160px 'cap' off a
134
+ // carburetor drawing). When several lines exist, ones far taller than the
135
+ // median are not lettering: leave them out of the mass reference and out
136
+ // of the result.
137
+ // The median is taken over lines carrying real mass (rule slivers and
138
+ // tittles do not vote), and needs three of them: two 145px headline lines
139
+ // above a 26px artist line were dropped as 'tall' against a median pulled
140
+ // to 28 by three slivers.
141
+ const massMax = Math.max(1, ...lines.map((l) => l.mass));
142
+ const real = lines.filter((l) => l.mass >= massMax * 0.05);
143
+ if (real.length >= 3) {
144
+ const hs = real.map((l) => l.y1 - l.y0).sort((a, b) => a - b);
145
+ const medH = hs[Math.floor(hs.length / 2)];
146
+ for (const ln of lines) if (ln.y1 - ln.y0 > medH * 3) ln.tall = true;
147
+ }
148
+ const maxMass = Math.max(0, ...lines.filter((l) => !l.tall).map((l) => l.mass));
149
+ const merged = [];
150
+ for (let i = 0; i < lines.length; i++) {
151
+ const ln = lines[i];
152
+ if (ln.tall) continue;
153
+ if (ln.mass >= maxMass * 0.3) { merged.push({ y0: ln.y0, y1: ln.y1, mass: ln.mass }); continue; }
154
+ const next = lines[i + 1];
155
+ 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; }
156
+ }
157
+ return { lines: merged, rowInk };
158
+ }
159
+
160
+ /** Feature names in fingerprint order (used by distance). */
161
+ const VBINS = 10, HQ = [0.25, 0.5, 0.75, 0.9];
162
+ export const FEATURES = ['advance', 'advTall', 'advX', 'advCV', 'gap', 'xRatio', 'descRatio', 'stemW', 'contrast', 'serif', 'roundFrac', 'densTall', 'densX', 'runDensity',
163
+ ...Array.from({ length: VBINS }, (_, i) => `vprof${i}`), ...HQ.map((q) => `hrun${Math.round(q * 100)}`), ...HQ.map((q) => `vrun${Math.round(q * 100)}`), 'colq25', 'colq75', 'wq25', 'wq75'];
164
+
165
+ /** Center of the densest window of width tol in a list of values, and its count. */
166
+ function modeOf(vals, tol) {
167
+ let best = null, bestC = -1;
168
+ const s = [...vals].sort((a, b) => a - b);
169
+ let j = 0;
170
+ for (let i = 0; i < s.length; i++) {
171
+ while (s[i] - s[j] > tol) j++;
172
+ const c = i - j + 1;
173
+ if (c > bestC) { bestC = c; best = (s[i] + s[j]) / 2; }
174
+ }
175
+ return { v: best, n: bestC };
176
+ }
177
+
178
+ /**
179
+ * Per-line vertical metrics from column extrema, which do not need glyphs to
180
+ * be separable. baseline = mode of column bottoms. R (the reference height)
181
+ * is the top line of the tallest cluster: the cap line on an all-caps line,
182
+ * the ascender line (or the cap line when caps are taller) on a mixed line.
183
+ * The x-line is a second mode of column heights well below R; when there is
184
+ * none the line is read as all-caps.
185
+ */
186
+ function lineMetrics(bin, ln) {
187
+ const { W, ink, cov } = bin;
188
+ const cols = [];
189
+ for (let x = 0; x < W; x++) {
190
+ let top = -1, bot = -1;
191
+ for (let yy = ln.y0; yy < ln.y1; yy++) if (ink[yy * W + x]) { if (top < 0) top = yy; bot = yy + 1; }
192
+ if (top < 0) continue;
193
+ // sub-pixel edges from the antialiased boundary pixel's coverage
194
+ const t = top > 0 ? top - cov((top - 1) * W + x) : top;
195
+ const b = bot < bin.H ? bot + cov(bot * W + x) : bot;
196
+ cols.push({ x, top: t, bot: b });
197
+ }
198
+ if (cols.length < 8) return null;
199
+ const roughH = pct(cols.map((c) => c.bot - c.top), 0.9);
200
+ const tol = Math.max(1, Math.round(roughH * 0.04));
201
+ const baseF = modeOf(cols.map((c) => c.bot), tol).v;
202
+ const base = Math.round(baseF);
203
+ const hs = cols.filter((c) => c.bot <= baseF + tol * 1.5).map((c) => baseF - c.top).filter((h) => h > 0);
204
+ if (hs.length < 8) return null;
205
+ const hMaxAbs = pct(hs, 0.995);
206
+ const topCluster = hs.filter((h) => h >= hMaxAbs * 0.94);
207
+ const R = med(topCluster);
208
+ if (!R || R < 4) return null;
209
+ const lowHs = hs.filter((h) => h >= R * 0.3 && h <= R * 0.86);
210
+ let xh = null;
211
+ if (lowHs.length >= Math.max(6, hs.length * 0.12)) {
212
+ const m = modeOf(lowHs, tol);
213
+ if (m.n >= Math.max(4, lowHs.length * 0.25)) xh = m.v;
214
+ }
215
+ const dsc = cols.filter((c) => c.bot > baseF + tol * 1.5 && c.top < baseF - R * 0.3).map((c) => (c.bot - baseF) / R);
216
+ const descRatio = dsc.length >= 4 ? pct(dsc, 0.9) : null;
217
+ return { base, R, cap: R, xh, descRatio, tol, xL: cols[0].x, xR: cols[cols.length - 1].x + 1, hs, ln };
218
+ }
219
+
220
+ /** Glyph boxes: column runs of ink inside the x band, so ascender/descender bridges do not merge letters. */
221
+ function segment(bin, ln, m) {
222
+ const { W, ink } = bin;
223
+ const bandTop = Math.max(ln.y0, Math.round(m.base - (m.xh || m.cap * 0.6)));
224
+ const bandH = m.base - bandTop;
225
+ const thr = 1;
226
+ const colBand = new Uint32Array(W);
227
+ for (let yy = bandTop; yy < m.base; yy++) { const o = yy * W; for (let x = m.xL; x < m.xR; x++) colBand[x] += ink[o + x]; }
228
+ const runs = [];
229
+ let x = m.xL;
230
+ while (x < m.xR) {
231
+ if (colBand[x] >= thr) { const x0 = x; while (x < m.xR && colBand[x] >= thr) x++; runs.push({ x0, x1: x }); } else x++;
232
+ }
233
+ const out = [];
234
+ for (const r of runs) {
235
+ let top = -1, bot = -1, area = 0;
236
+ for (let yy = ln.y0; yy < ln.y1; yy++) {
237
+ let c = 0, cv = 0; const o = yy * W; for (let xx = r.x0; xx < r.x1; xx++) { c += ink[o + xx]; cv += bin.covA[o + xx]; }
238
+ if (c) { if (top < 0) top = yy; bot = yy + 1; }
239
+ area += cv;
240
+ }
241
+ if (top >= 0) out.push({ x0: r.x0, x1: r.x1, w: r.x1 - r.x0, top, bot, h: bot - top, area });
242
+ }
243
+ return out;
244
+ }
245
+
246
+ function measure(bin, lines) {
247
+ const { W, H, ink, covA } = bin;
248
+ // run lengths with the antialiased edge pixels counted by coverage
249
+ const hLen = (o, x0, x1) => { let s = 0; for (let x = Math.max(0, x0 - 1); x < Math.min(W, x1 + 1); x++) s += covA[o + x]; return s; };
250
+ const vLen = (x, y0, y1) => { let s = 0; for (let y = Math.max(0, y0 - 1); y < Math.min(H, y1 + 1); y++) s += covA[y * W + x]; return s; };
251
+ let glyphN = 0;
252
+ const per = { xh: [], desc: [], runDensity: [] };
253
+ let allCapsLines = 0;
254
+ const vprof = new Float64Array(VBINS); const hruns = [], vruns = [], colHs = [], widths = [];
255
+ const advTall = [], advAll = [], advX = [], gaps = [], stems = [], thins = [], serifR = [], round = [], densTall = [], densX = [];
256
+ let capSum = 0, capN = 0;
257
+ // One crop, one case. In a multi-line all-caps headline one line can grow a
258
+ // spurious x-height from crossbars (the A and E arms of "JAPANESE" at 0.32R)
259
+ // while its neighbours report none; that line then measures its stems and
260
+ // its x band on the crossbar zone. Lines vote: when most lines see no
261
+ // x-height, none does.
262
+ const metrics = lines.map((ln) => lineMetrics(bin, ln)).filter(Boolean);
263
+ if (metrics.length >= 2) {
264
+ const withX = metrics.filter((m) => m.xh).length;
265
+ if (withX * 2 <= metrics.length) for (const m of metrics) m.xh = null;
266
+ }
267
+ for (const m of metrics) {
268
+ const ln = m.ln;
269
+ const { base, cap, xh, tol, xL, xR } = m;
270
+ capSum += cap; capN++;
271
+ if (xh) per.xh.push(xh / cap);
272
+ if (!xh) allCapsLines++;
273
+ if (m.descRatio != null) per.desc.push(m.descRatio);
274
+ for (const h of m.hs) colHs.push(h / cap);
275
+ // vertical ink profile from 0.35R below the baseline to 1.05R above, VBINS bins
276
+ for (let yy = ln.y0; yy < ln.y1; yy++) {
277
+ const u = (base - yy - 0.5) / cap; // height above baseline in R units
278
+ const bi = Math.floor((u + 0.35) / 1.4 * VBINS);
279
+ if (bi < 0 || bi >= VBINS) continue;
280
+ let c = 0; const o = yy * W; for (let x = xL; x < xR; x++) c += ink[o + x];
281
+ vprof[bi] += c;
282
+ }
283
+ // horizontal run lengths over the whole line body (x band to cap line), vertical run lengths over all columns
284
+ for (let yy = Math.max(ln.y0, Math.round(base - cap)); yy < base; yy++) {
285
+ const o = yy * W; let x = xL;
286
+ while (x < xR) { if (ink[o + x]) { const x0 = x; while (x < xR && ink[o + x]) x++; hruns.push(hLen(o, x0, x) / cap); } else x++; }
287
+ }
288
+ for (let x = xL; x < xR; x++) {
289
+ let yy = ln.y0;
290
+ while (yy < ln.y1) { if (ink[yy * W + x]) { const y0 = yy; while (yy < ln.y1 && ink[yy * W + x]) yy++; vruns.push(vLen(x, y0, yy) / cap); } else yy++; }
291
+ }
292
+ const gl = segment(bin, ln, m);
293
+ const G = gl.filter((g) => g.w >= cap * 0.12 && (base - g.top) >= cap * 0.3);
294
+ glyphN += G.length;
295
+ const onBase = G.filter((g) => Math.abs(g.bot - base) <= tol * 1.5);
296
+ const capG = onBase.filter((g) => base - g.top >= cap * 0.88);
297
+ const xs = xh ? onBase.filter((g) => Math.abs(base - g.top - xh) <= Math.max(tol * 1.5, cap * 0.05)) : [];
298
+ for (const g of capG) { advTall.push(g.w / cap); densTall.push(g.area / (g.w * g.h)); }
299
+ for (const g of xs) { densX.push(g.area / (g.w * g.h)); advX.push(g.w / cap); }
300
+ for (const g of onBase) { advAll.push(g.w / cap); widths.push(g.w / cap); round.push(g.w / (base - g.top) > 0.9 ? 1 : 0); }
301
+ for (let i = 0; i + 1 < G.length; i++) { const gap = G[i + 1].x0 - G[i].x1; if (gap >= 0 && gap < cap * 0.6) gaps.push(gap / cap); }
302
+ const xTop = base - (xh || cap * 0.55);
303
+ const bandTop = Math.round(xTop + (base - xTop) * 0.2), bandBot = Math.round(base - (base - xTop) * 0.2);
304
+ let runCount = 0, runRows = 0;
305
+ for (let yy = bandTop; yy < bandBot; yy++) {
306
+ const o = yy * W; let x = xL; runRows++;
307
+ while (x < xR) { if (ink[o + x]) { const x0 = x; while (x < xR && ink[o + x]) x++; const L = hLen(o, x0, x); runCount++; if (L < cap * 0.5) stems.push(L / cap); } else x++; }
308
+ }
309
+ if (runRows) per.runDensity.push((runCount / runRows) / ((xR - xL) / cap));
310
+ for (let x = xL; x < xR; x++) {
311
+ let yy = ln.y0;
312
+ while (yy < ln.y1) { if (ink[yy * W + x]) { const y0 = yy; while (yy < ln.y1 && ink[yy * W + x]) yy++; const L = vLen(x, y0, yy); if (L < cap * 0.35) thins.push(L / cap); } else yy++; }
313
+ }
314
+ // serif: stems that run straight to the baseline; foot width vs mid-stem width
315
+ const runAt = (yy, x) => { const o = yy * W; if (!ink[o + x]) return 0; let a = x, b = x; while (a > xL && ink[o + a - 1]) a--; while (b + 1 < xR && ink[o + b + 1]) b++; return hLen(o, a, b + 1); };
316
+ const yMid = Math.round(base - cap * 0.4), yHi = Math.round(base - cap * 0.18), yFoot = base - Math.max(1, Math.round(cap * 0.04));
317
+ let x = xL;
318
+ while (x < xR) {
319
+ let yy = base - 1; if (!ink[yy * W + x]) { x++; continue; }
320
+ while (yy > ln.y0 && ink[(yy - 1) * W + x]) yy--;
321
+ if (yy > yMid) { x++; continue; }
322
+ const x0 = x; x++; while (x < xR && ink[(base - 1) * W + x] && ink[yMid * W + x]) x++;
323
+ const xc = Math.round((x0 + x - 1) / 2);
324
+ const wMid = runAt(yMid, xc), wHi = runAt(yHi, xc), wFoot = runAt(yFoot, xc);
325
+ if (wMid > 0 && wMid < cap * 0.5 && wHi <= wMid * 1.3 && wHi >= wMid * 0.7) serifR.push(wFoot / wMid);
326
+ }
327
+ }
328
+ if (!capN) return null;
329
+ const stemW = med(stems), thinW = med(thins);
330
+ const advM = med(advAll);
331
+ const advSd = advAll.length > 3 ? Math.sqrt(advAll.reduce((s, v) => s + (v - advM) ** 2, 0) / advAll.length) : null;
332
+ const vsum = vprof.reduce((s, x) => s + x, 0) || 1;
333
+ const extra = {};
334
+ for (let i = 0; i < VBINS; i++) extra[`vprof${i}`] = vprof[i] / vsum;
335
+ for (const q of HQ) { extra[`hrun${Math.round(q * 100)}`] = pct(hruns, q); extra[`vrun${Math.round(q * 100)}`] = pct(vruns, q); }
336
+ extra.colq25 = pct(colHs, 0.25); extra.colq75 = pct(colHs, 0.75);
337
+ extra.wq25 = pct(widths, 0.25); extra.wq75 = pct(widths, 0.75);
338
+ return {
339
+ ...extra,
340
+ capHeightPx: capSum / capN,
341
+ glyphs: glyphN,
342
+ advance: advM,
343
+ advTall: advTall.length ? med(advTall) : null,
344
+ advX: advX.length ? med(advX) : null,
345
+ advCV: advSd != null && advM ? advSd / advM : null,
346
+ gap: gaps.length ? med(gaps) : 0,
347
+ xRatio: per.xh.length ? med(per.xh) : null,
348
+ descRatio: per.desc.length ? med(per.desc) : null,
349
+ allCaps: allCapsLines * 2 > capN,
350
+ runDensity: med(per.runDensity),
351
+ stemW,
352
+ contrast: stemW && thinW ? stemW / thinW : null,
353
+ serif: serifR.length >= 3 ? med(serifR) : null,
354
+ roundFrac: round.length ? mean(round) : null,
355
+ densTall: densTall.length ? med(densTall) : null,
356
+ densX: densX.length ? med(densX) : null,
357
+ };
358
+ }
359
+
360
+ /**
361
+ * fingerprint(img) -> features, or null when no lettering is found. Upsamples (bilinear) when the
362
+ * cap height is under 24px so runs and edges are measured on finer pixels.
363
+ */
364
+ /**
365
+ * Keep the dominant lettering in a region crop: the lines whose cap height is
366
+ * within `tol` of the tallest, clipped horizontally to their own ink. A comp
367
+ * region drawn on a 10x10 grid over-covers: the headline crop carries the
368
+ * first line of body copy below it and a slice of the neighbouring column,
369
+ * and every one of those small letters pulls stem width, run lengths and the
370
+ * x-height vote toward a lighter, wider face. Returns { lines, x0, x1 } in
371
+ * the binarized image, or null when nothing survives.
372
+ */
373
+ export function isolateDominant(bin, lines, { tol = 0.28 } = {}) {
374
+ const ms = lines.map((ln) => ({ ln, m: lineMetrics(bin, ln) })).filter((x) => x.m);
375
+ if (!ms.length) return null;
376
+ // The dominant class is the one holding most of the ink, not the tallest
377
+ // line: a body-copy crop that clips the last line of the headline above it
378
+ // is body copy. Cluster caps within tol of each other and pick the cluster
379
+ // with the most ink mass; the tallest wins only a tie.
380
+ const clusters = [];
381
+ for (const x of [...ms].sort((a, b) => b.m.cap - a.m.cap)) {
382
+ const c = clusters.find((cl) => Math.abs(cl.cap - x.m.cap) <= cl.cap * tol);
383
+ 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 });
384
+ }
385
+ // Mass per line-height, so one heavy display line does not outvote five
386
+ // lines of body copy; and a cluster of a single clipped line never wins
387
+ // over a cluster of three or more.
388
+ 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; }
389
+ clusters.sort((a, b) => {
390
+ const aMulti = a.n >= 3, bMulti = b.n >= 3;
391
+ if (aMulti !== bMulti) return aMulti ? -1 : 1;
392
+ return (b.mass - a.mass) || (b.cap - a.cap);
393
+ });
394
+ const keep = clusters[0].items;
395
+ const capMax = Math.max(...keep.map((x) => x.m.cap));
396
+ // horizontal extent of the kept lines' tallest ink columns only: a small
397
+ // column of body text beside the headline shares its rows but not its height
398
+ const { W, ink } = bin;
399
+ let x0 = W, x1 = 0;
400
+ for (const { ln, m } of keep) {
401
+ const top = Math.round(m.base - m.cap * 0.75);
402
+ for (let x = m.xL; x < m.xR; x++) {
403
+ let tall = false;
404
+ for (let y = top; y < m.base && !tall; y++) if (ink[y * W + x]) tall = true;
405
+ if (!tall) continue;
406
+ // a column is headline ink when a run of at least 0.5 cap of ink stands in it
407
+ let run = 0, best = 0;
408
+ for (let y = ln.y0; y < ln.y1; y++) { if (ink[y * W + x]) { run++; if (run > best) best = run; } else run = 0; }
409
+ if (best >= m.cap * 0.5) { if (x < x0) x0 = x; if (x + 1 > x1) x1 = x + 1; }
410
+ }
411
+ }
412
+ if (x1 <= x0) return null;
413
+ // grow the box by half a cap so glyph sides and the tracking gap survive
414
+ const pad = Math.round(capMax * 0.5);
415
+ return { lines: keep.map((x) => x.ln), x0: Math.max(0, x0 - pad), x1: Math.min(W, x1 + pad), dropped: ms.length - keep.length };
416
+ }
417
+
418
+ function maskOutside(bin, x0, x1, lines) {
419
+ const { W, H, ink, covA } = bin;
420
+ const keepRow = new Uint8Array(H);
421
+ for (const ln of lines) for (let y = ln.y0; y < ln.y1; y++) keepRow[y] = 1;
422
+ const ink2 = new Uint8Array(ink.length), cov2 = new Float32Array(covA.length);
423
+ for (let y = 0; y < H; y++) {
424
+ if (!keepRow[y]) continue;
425
+ for (let x = x0; x < x1; x++) { const i = y * W + x; ink2[i] = ink[i]; cov2[i] = covA[i]; }
426
+ }
427
+ return { ...bin, ink: ink2, covA: cov2, cov: (i) => cov2[i] };
428
+ }
429
+
430
+ export function fingerprint(img, { minCap = 24, minGlyphs = 3, isolate = true } = {}) {
431
+ let bin = binarize(img);
432
+ let { lines } = findLines(bin);
433
+ if (!lines.length) return null;
434
+ let isolated = 0;
435
+ if (isolate && lines.length > 1) {
436
+ const iso = isolateDominant(bin, lines);
437
+ if (iso && (iso.dropped > 0 || iso.x1 - iso.x0 < bin.W * 0.9)) {
438
+ bin = maskOutside(bin, iso.x0, iso.x1, iso.lines);
439
+ lines = iso.lines;
440
+ isolated = iso.dropped;
441
+ }
442
+ }
443
+ let f = measure(bin, lines);
444
+ // fewer than minGlyphs separable glyphs is not lettering (a rule, a solid
445
+ // bar, one letterform): callers read null as "no separable lettering"
446
+ if (!f || f.glyphs < minGlyphs) return null;
447
+ let scale = 1;
448
+ if (f.capHeightPx < minCap && f.capHeightPx >= 4) {
449
+ scale = Math.min(4, Math.ceil(minCap / f.capHeightPx));
450
+ const up = resize(img, img.width * scale, img.height * scale);
451
+ bin = binarize(up);
452
+ lines = findLines(bin).lines;
453
+ // the upsample re-reads the whole crop: isolate again so the clipped
454
+ // headline or the drawing does not come back at scale
455
+ if (isolate && lines.length > 1) {
456
+ const iso2 = isolateDominant(bin, lines);
457
+ 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); }
458
+ }
459
+ const f2 = lines.length ? measure(bin, lines) : null;
460
+ if (f2) f = f2;
461
+ else scale = 1;
462
+ }
463
+ const r = { lines: lines.length, glyphs: f.glyphs, capHeightPx: +(f.capHeightPx / scale).toFixed(1), inkIsDark: bin.inkIsDark, upsampled: scale > 1, allCaps: f.allCaps, isolatedFrom: isolated, weight: f.densTall == null && f.densX == null ? null : +(f.densTall ?? f.densX).toFixed(4) };
464
+ for (const k of FEATURES) r[k] = f[k] == null ? null : +f[k].toFixed(4);
465
+ return r;
466
+ }
467
+
468
+ /**
469
+ * Distance normalization fitted on 299 held-out probes (150 at ~30px cap, 149
470
+ * at ~14px, text different from the index text) against a 3,092-entry Google
471
+ * Fonts index: std = within-family noise (1.4826 x median |probe - own index
472
+ * entry|, floored at 5% of the catalog IQR spread), w = group weight from
473
+ * coordinate descent on top-5 family recall. mean is unused by the distance.
474
+ */
475
+ export const STATS = {
476
+ advance: { std: 0.07648, w: 0 },
477
+ advTall: { std: 0.25331, w: 0 },
478
+ advX: { std: 0.05144, w: 1.5 },
479
+ advCV: { std: 0.0857, w: 1 },
480
+ gap: { std: 0.02668, w: 1 },
481
+ xRatio: { std: 0.02315, w: 1 },
482
+ descRatio: { std: 0.17831, w: 1 },
483
+ stemW: { std: 0.01922, w: 1 },
484
+ contrast: { std: 0.05969, w: 3 },
485
+ serif: { std: 0.31477, w: 0.5 },
486
+ roundFrac: { std: 0.09341, w: 1 },
487
+ densTall: { std: 0.05708, w: 2 },
488
+ densX: { std: 0.07666, w: 0 },
489
+ runDensity: { std: 0.18199, w: 1 },
490
+ vprof0: { std: 0.01178, w: 1 },
491
+ vprof1: { std: 0.01331, w: 1 },
492
+ vprof2: { std: 0.02745, w: 1 },
493
+ vprof3: { std: 0.03046, w: 1 },
494
+ vprof4: { std: 0.01933, w: 1 },
495
+ vprof5: { std: 0.01737, w: 1 },
496
+ vprof6: { std: 0.0336, w: 1 },
497
+ vprof7: { std: 0.03195, w: 1 },
498
+ vprof8: { std: 0.03271, w: 1 },
499
+ vprof9: { std: 0.02951, w: 1 },
500
+ hrun25: { std: 0.01751, w: 1 },
501
+ hrun50: { std: 0.02124, w: 1 },
502
+ hrun75: { std: 0.04503, w: 1 },
503
+ hrun90: { std: 0.06844, w: 1 },
504
+ vrun25: { std: 0.01895, w: 1 },
505
+ vrun50: { std: 0.02405, w: 1 },
506
+ vrun75: { std: 0.06199, w: 1 },
507
+ vrun90: { std: 0.09486, w: 1 },
508
+ colq25: { std: 0.02906, w: 1 },
509
+ colq75: { std: 0.18204, w: 1 },
510
+ wq25: { std: 0.19862, w: 1 },
511
+ wq75: { std: 0.09687, w: 1 },
512
+ };
513
+ export const Z_CLIP = 3;
514
+
515
+ /** Weighted L1 over z-scored features; a feature missing on either side is skipped and the weight mass renormalized. */
516
+ /**
517
+ * The two readings a designer makes before any detail: how wide, how heavy.
518
+ * Width from the advance of tall glyphs (all-caps crops) or x-height glyphs;
519
+ * weight from ink density of tall glyphs. Both are on the same scale in the
520
+ * comp crop and in a catalog render, so their gap is a plain ratio. Distance
521
+ * adds a penalty that grows with the ratio's log: a face 50% wider or 35%
522
+ * lighter than the comp cannot rank above one that is right on both, whatever
523
+ * its run-length profile says. Weighted like three fine features (the width
524
+ * gap and the weight gap each score up to zClip x 1.5).
525
+ */
526
+ export function grossGap(a, b) {
527
+ const pick = (f, keys) => { for (const k of keys) if (f[k] != null) return { k, v: f[k] }; return null; };
528
+ const wa = pick(a, ['advX', 'advTall', 'advance']), wb = wa ? (b[wa.k] != null ? { k: wa.k, v: b[wa.k] } : null) : null;
529
+ const ha = pick(a, ['densTall', 'densX', 'stemW']), hb = ha ? (b[ha.k] != null ? { k: ha.k, v: b[ha.k] } : null) : null;
530
+ const gap = (x, y) => (x && y && x.v > 0 && y.v > 0 ? Math.abs(Math.log(y.v / x.v)) : null);
531
+ return { width: gap(wa, wb), weight: gap(ha, hb) };
532
+ }
533
+
534
+ export const GROSS_STD = { width: 0.12, weight: 0.12 }; // one "step" of width class or weight class, in log ratio
535
+ export const GROSS_W = 1.5;
536
+
537
+ export function distance(a, b, stats = STATS, { p = 1, zClip = Z_CLIP, gross = true } = {}) {
538
+ let d = 0, wsum = 0;
539
+ if (gross) {
540
+ const g = grossGap(a, b);
541
+ for (const k of ['width', 'weight']) {
542
+ if (g[k] == null) continue;
543
+ const z = Math.min(zClip, g[k] / GROSS_STD[k]);
544
+ d += GROSS_W * (p === 1 ? z : z * z); wsum += GROSS_W;
545
+ }
546
+ }
547
+ for (const k of FEATURES) {
548
+ const s = stats[k]; if (!s || !s.w) continue;
549
+ const av = a[k], bv = b[k];
550
+ if (av == null || bv == null) continue;
551
+ const z = Math.min(zClip, Math.abs(av - bv) / s.std);
552
+ d += s.w * (p === 1 ? z : z * z); wsum += s.w;
553
+ }
554
+ if (!wsum) return Infinity;
555
+ const v = d / wsum;
556
+ return p === 1 ? v : Math.sqrt(v);
557
+ }
558
+
559
+ /** Debug: per-line metrics (base, R, xh, mode counts) for a raster. */
560
+ export function _debugLines(img) {
561
+ const bin = binarize(img);
562
+ const { lines } = findLines(bin);
563
+ return lines.map((ln) => { const m = lineMetrics(bin, ln); if (!m) return { ln, m: null }; const hs = m.hs.map((h) => +(h / m.R).toFixed(2)).sort((a, b) => a - b); const hist = {}; for (const h of hs) { const b = Math.round(h * 20) / 20; hist[b] = (hist[b] || 0) + 1; } return { y0: ln.y0, y1: ln.y1, base: m.base, R: +m.R.toFixed(1), xh: m.xh && +m.xh.toFixed(1), hist }; });
564
+ }