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
|
@@ -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
|
+
}
|