intellifont-engine 1.0.0 → 2.0.0
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/README.md +355 -173
- package/browser/canvas-dna.js +626 -0
- package/browser/demo.html +759 -0
- package/browser/image-pipeline.js +1182 -0
- package/browser/intellifont-inspector.js +299 -0
- package/index.d.ts +177 -0
- package/index.js +760 -194
- package/intellifont-engine.node +0 -0
- package/package.json +63 -57
- package/setup.js +34 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intelliFont Canvas DNA Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Computes visual font metrics (matching the Rust MicroSignature format)
|
|
5
|
+
* by rendering characters to a hidden <canvas> and analyzing the pixel bitmap.
|
|
6
|
+
*
|
|
7
|
+
* No file upload. No CORS. Works on any font currently loaded in the browser.
|
|
8
|
+
*
|
|
9
|
+
* Metrics computed (all u8 range 0-255):
|
|
10
|
+
* aspect_ratio — width ÷ height × 64
|
|
11
|
+
* density — ink pixel density in bounding box
|
|
12
|
+
* quadrant_nw/ne/sw/se — ink distribution across quadrants
|
|
13
|
+
* curve_ratio — estimated curviness of strokes
|
|
14
|
+
* point_count — outline complexity (direction changes)
|
|
15
|
+
* x_balance — horizontal center of mass
|
|
16
|
+
* y_balance — vertical center of mass
|
|
17
|
+
* stroke_width — estimated stroke thickness
|
|
18
|
+
* serif_score — serif vs sans classification
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
(() => {
|
|
22
|
+
if (window.CanvasDNA) return;
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// RENDER SIZE — larger = more accurate, slower
|
|
27
|
+
// =============================================================================
|
|
28
|
+
const RENDER_SIZE = 256;
|
|
29
|
+
const DARK_THRESHOLD = 128; // pixel brightness below this = "ink"
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// INTERNAL: Render a single character to pixel data
|
|
33
|
+
// =============================================================================
|
|
34
|
+
function _renderChar(fontFamily, character, size) {
|
|
35
|
+
const canvas = document.createElement('canvas');
|
|
36
|
+
canvas.width = size;
|
|
37
|
+
canvas.height = size;
|
|
38
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
39
|
+
|
|
40
|
+
// White background
|
|
41
|
+
ctx.fillStyle = '#ffffff';
|
|
42
|
+
ctx.fillRect(0, 0, size, size);
|
|
43
|
+
|
|
44
|
+
// Draw the character centered
|
|
45
|
+
ctx.fillStyle = '#000000';
|
|
46
|
+
ctx.font = `${Math.floor(size * 0.72)}px "${fontFamily}", serif`;
|
|
47
|
+
ctx.textBaseline = 'middle';
|
|
48
|
+
ctx.textAlign = 'center';
|
|
49
|
+
ctx.fillText(character, size / 2, size / 2);
|
|
50
|
+
|
|
51
|
+
return ctx.getImageData(0, 0, size, size);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// INTERNAL: Find bounding box and collect dark pixel list
|
|
56
|
+
// =============================================================================
|
|
57
|
+
function _collectDarkPixels(imageData, size) {
|
|
58
|
+
const { data } = imageData;
|
|
59
|
+
let minX = size, minY = size, maxX = -1, maxY = -1;
|
|
60
|
+
const dark = [];
|
|
61
|
+
|
|
62
|
+
for (let y = 0; y < size; y++) {
|
|
63
|
+
for (let x = 0; x < size; x++) {
|
|
64
|
+
const i = (y * size + x) * 4;
|
|
65
|
+
const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
|
66
|
+
if (brightness < DARK_THRESHOLD) {
|
|
67
|
+
dark.push({ x, y });
|
|
68
|
+
if (x < minX) minX = x;
|
|
69
|
+
if (y < minY) minY = y;
|
|
70
|
+
if (x > maxX) maxX = x;
|
|
71
|
+
if (y > maxY) maxY = y;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { dark, minX, minY, maxX, maxY };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// =============================================================================
|
|
80
|
+
// INTERNAL: Get edge pixels (pixels with at least one white neighbour)
|
|
81
|
+
// =============================================================================
|
|
82
|
+
function _getEdgePixels(data, size, minX, minY, maxX, maxY) {
|
|
83
|
+
const edges = [];
|
|
84
|
+
for (let y = minY; y <= maxY; y++) {
|
|
85
|
+
for (let x = minX; x <= maxX; x++) {
|
|
86
|
+
const i = (y * size + x) * 4;
|
|
87
|
+
const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
|
88
|
+
if (brightness >= DARK_THRESHOLD) continue; // not a dark pixel
|
|
89
|
+
|
|
90
|
+
// Check 4-connected neighbours
|
|
91
|
+
const neighbors = [
|
|
92
|
+
[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]
|
|
93
|
+
];
|
|
94
|
+
for (const [nx, ny] of neighbors) {
|
|
95
|
+
if (nx < 0 || ny < 0 || nx >= size || ny >= size) {
|
|
96
|
+
edges.push({ x, y });
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const ni = (ny * size + nx) * 4;
|
|
100
|
+
const nb = (data[ni] + data[ni + 1] + data[ni + 2]) / 3;
|
|
101
|
+
if (nb >= DARK_THRESHOLD) {
|
|
102
|
+
edges.push({ x, y });
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return edges;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// =============================================================================
|
|
112
|
+
// INTERNAL: Estimate curve ratio from edge pixel direction changes
|
|
113
|
+
// A straight edge has consistent dx/dy; a curve has varying direction.
|
|
114
|
+
// =============================================================================
|
|
115
|
+
function _estimateCurveRatio(edgePixels) {
|
|
116
|
+
if (edgePixels.length < 8) return 128;
|
|
117
|
+
|
|
118
|
+
// Sort edge pixels into an approximate contour order by angle from centroid
|
|
119
|
+
const cx = edgePixels.reduce((s, p) => s + p.x, 0) / edgePixels.length;
|
|
120
|
+
const cy = edgePixels.reduce((s, p) => s + p.y, 0) / edgePixels.length;
|
|
121
|
+
const sorted = [...edgePixels].sort((a, b) =>
|
|
122
|
+
Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx)
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Count direction changes between consecutive edge pixels
|
|
126
|
+
let dirChanges = 0;
|
|
127
|
+
const step = Math.max(1, Math.floor(sorted.length / 32)); // sample up to 32 points
|
|
128
|
+
let prevAngle = null;
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < sorted.length; i += step) {
|
|
131
|
+
const next = sorted[(i + step) % sorted.length];
|
|
132
|
+
const angle = Math.atan2(next.y - sorted[i].y, next.x - sorted[i].x);
|
|
133
|
+
if (prevAngle !== null) {
|
|
134
|
+
let delta = Math.abs(angle - prevAngle);
|
|
135
|
+
if (delta > Math.PI) delta = 2 * Math.PI - delta;
|
|
136
|
+
if (delta > 0.3) dirChanges++; // threshold ~17 degrees
|
|
137
|
+
}
|
|
138
|
+
prevAngle = angle;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const samples = Math.ceil(sorted.length / step);
|
|
142
|
+
// More direction changes per sample → more curved
|
|
143
|
+
return Math.min(255, Math.floor((dirChanges / samples) * 255 * 1.5));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// =============================================================================
|
|
147
|
+
// INTERNAL: Estimate stroke width from horizontal run lengths
|
|
148
|
+
// =============================================================================
|
|
149
|
+
function _estimateStrokeWidth(data, size, minX, minY, maxX, maxY) {
|
|
150
|
+
const width = maxX - minX + 1;
|
|
151
|
+
let totalRunLen = 0;
|
|
152
|
+
let runCount = 0;
|
|
153
|
+
|
|
154
|
+
for (let y = minY; y <= maxY; y++) {
|
|
155
|
+
let runLen = 0;
|
|
156
|
+
for (let x = minX; x <= maxX; x++) {
|
|
157
|
+
const i = (y * size + x) * 4;
|
|
158
|
+
const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
|
159
|
+
if (brightness < DARK_THRESHOLD) {
|
|
160
|
+
runLen++;
|
|
161
|
+
} else {
|
|
162
|
+
// Only count short-to-medium runs (strokes, not large filled areas)
|
|
163
|
+
if (runLen > 1 && runLen < width * 0.75) {
|
|
164
|
+
totalRunLen += runLen;
|
|
165
|
+
runCount++;
|
|
166
|
+
}
|
|
167
|
+
runLen = 0;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (runLen > 1 && runLen < width * 0.75) {
|
|
171
|
+
totalRunLen += runLen;
|
|
172
|
+
runCount++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const avgRun = runCount > 0 ? totalRunLen / runCount : width / 6;
|
|
177
|
+
// Normalize so that a typical stroke (~12px at 256px render) maps to midrange
|
|
178
|
+
return Math.min(255, Math.floor((avgRun / RENDER_SIZE) * 1400));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// =============================================================================
|
|
182
|
+
// INTERNAL: Estimate serif score from horizontal structure at baseline
|
|
183
|
+
// =============================================================================
|
|
184
|
+
function _estimateSerifScore(data, size, minX, minY, maxX, maxY) {
|
|
185
|
+
const height = maxY - minY + 1;
|
|
186
|
+
|
|
187
|
+
// Looking at the bottom 15% of the bounding box (where serifs appear)
|
|
188
|
+
const bottomStart = Math.floor(minY + height * 0.80);
|
|
189
|
+
const bottomEnd = Math.floor(minY + height * 0.95);
|
|
190
|
+
const topStart = Math.floor(minY + height * 0.05);
|
|
191
|
+
const topEnd = Math.floor(minY + height * 0.20);
|
|
192
|
+
|
|
193
|
+
let bottomRuns = 0;
|
|
194
|
+
let topRuns = 0;
|
|
195
|
+
|
|
196
|
+
function countHorizRuns(yStart, yEnd) {
|
|
197
|
+
let runs = 0;
|
|
198
|
+
for (let y = yStart; y <= Math.min(yEnd, maxY); y++) {
|
|
199
|
+
let runLen = 0;
|
|
200
|
+
for (let x = minX; x <= maxX; x++) {
|
|
201
|
+
const i = (y * size + x) * 4;
|
|
202
|
+
const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
|
203
|
+
if (brightness < DARK_THRESHOLD) {
|
|
204
|
+
runLen++;
|
|
205
|
+
} else {
|
|
206
|
+
if (runLen >= 2) runs++;
|
|
207
|
+
runLen = 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (runLen >= 2) runs++;
|
|
211
|
+
}
|
|
212
|
+
return runs;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
bottomRuns = countHorizRuns(bottomStart, bottomEnd);
|
|
216
|
+
topRuns = countHorizRuns(topStart, topEnd);
|
|
217
|
+
|
|
218
|
+
// Serifs add extra horizontal runs at the top and bottom of strokes
|
|
219
|
+
const rows = (bottomEnd - bottomStart + 1) + (topEnd - topStart + 1);
|
|
220
|
+
return Math.min(255, Math.floor(((bottomRuns + topRuns) / Math.max(1, rows)) * 200));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// =============================================================================
|
|
224
|
+
// INTERNAL: Sobel gradient — detect italic angle from a glyph image.
|
|
225
|
+
//
|
|
226
|
+
// Computes the gradient direction at every strong edge pixel.
|
|
227
|
+
// Near-vertical edges (±30° of 90°) are collected; their median deviation
|
|
228
|
+
// from true-vertical gives the slant angle.
|
|
229
|
+
// Returns degrees: 0 = upright, positive = slants right (italic), negative = back-slant.
|
|
230
|
+
// =============================================================================
|
|
231
|
+
function _detectItalicAngle(data, size) {
|
|
232
|
+
const deviations = [];
|
|
233
|
+
|
|
234
|
+
for (let y = 1; y < size - 1; y++) {
|
|
235
|
+
for (let x = 1; x < size - 1; x++) {
|
|
236
|
+
// Luminance for each of the 3×3 neighbourhood pixels
|
|
237
|
+
const lum = (r, c) => {
|
|
238
|
+
const i = ((y + r) * size + (x + c)) * 4;
|
|
239
|
+
return (data[i] * 77 + data[i + 1] * 150 + data[i + 2] * 29) >> 8;
|
|
240
|
+
};
|
|
241
|
+
// Sobel
|
|
242
|
+
const gx = -lum(-1,-1) + lum(-1,1) - 2*lum(0,-1) + 2*lum(0,1) - lum(1,-1) + lum(1,1);
|
|
243
|
+
const gy = lum(-1,-1) + 2*lum(-1,0) + lum(-1,1) - lum(1,-1) - 2*lum(1,0) - lum(1,1);
|
|
244
|
+
|
|
245
|
+
const mag = Math.sqrt(gx * gx + gy * gy);
|
|
246
|
+
if (mag < 25) continue; // ignore weak / noise edges
|
|
247
|
+
|
|
248
|
+
const angleDeg = Math.atan2(gy, gx) * 180 / Math.PI;
|
|
249
|
+
const absAngle = Math.abs(angleDeg);
|
|
250
|
+
|
|
251
|
+
// Keep only near-vertical edges (60°–120° from horizontal axis)
|
|
252
|
+
if (absAngle >= 60 && absAngle <= 120) {
|
|
253
|
+
// Deviation from 90° (positive = slants right)
|
|
254
|
+
deviations.push(angleDeg > 0 ? angleDeg - 90 : angleDeg + 90);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (deviations.length < 20) return 0;
|
|
260
|
+
|
|
261
|
+
// Median is more robust than mean against noise
|
|
262
|
+
deviations.sort((a, b) => a - b);
|
|
263
|
+
return deviations[Math.floor(deviations.length / 2)];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// =============================================================================
|
|
267
|
+
// INTERNAL: Map stroke_width metric (0-255) to CSS font-weight (100-900)
|
|
268
|
+
// Calibrated against rendered Google Fonts at RENDER_SIZE=256
|
|
269
|
+
// =============================================================================
|
|
270
|
+
function _mapToWeight(strokeWidth) {
|
|
271
|
+
if (strokeWidth < 18) return 100; // Thin
|
|
272
|
+
if (strokeWidth < 27) return 200; // ExtraLight
|
|
273
|
+
if (strokeWidth < 38) return 300; // Light
|
|
274
|
+
if (strokeWidth < 52) return 400; // Regular
|
|
275
|
+
if (strokeWidth < 68) return 500; // Medium
|
|
276
|
+
if (strokeWidth < 86) return 600; // SemiBold
|
|
277
|
+
if (strokeWidth < 108) return 700; // Bold
|
|
278
|
+
if (strokeWidth < 135) return 800; // ExtraBold
|
|
279
|
+
return 900; // Black
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// =============================================================================
|
|
283
|
+
// PUBLIC: Detect style properties (italic, weight) from raw pixel data.
|
|
284
|
+
// Call after analyzeImageData to get a full style profile for one glyph.
|
|
285
|
+
//
|
|
286
|
+
// Returns:
|
|
287
|
+
// italic — true if slant angle > 8°
|
|
288
|
+
// italicAngle — exact measured slant in degrees
|
|
289
|
+
// cssWeight — one of 100/200/…/900
|
|
290
|
+
// weightLabel — "Thin"|"Light"|"Regular"|"Bold"|etc.
|
|
291
|
+
// =============================================================================
|
|
292
|
+
function analyzeStyle(imageData, size) {
|
|
293
|
+
const { data } = imageData;
|
|
294
|
+
|
|
295
|
+
// Compute stroke width via the existing helper (operates on raw RGBA data)
|
|
296
|
+
// We need bounding box first — reuse _collectDarkPixels
|
|
297
|
+
const { dark, minX, minY, maxX, maxY } = _collectDarkPixels(imageData, size);
|
|
298
|
+
if (!dark.length || maxX < minX) {
|
|
299
|
+
return { italic: false, italicAngle: 0, cssWeight: 400, weightLabel: 'Regular' };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const strokeWidth = _estimateStrokeWidth(data, size, minX, minY, maxX, maxY);
|
|
303
|
+
const cssWeight = _mapToWeight(strokeWidth);
|
|
304
|
+
const italicAngle = _detectItalicAngle(data, size);
|
|
305
|
+
|
|
306
|
+
const WEIGHT_LABELS = {
|
|
307
|
+
100: 'Thin', 200: 'ExtraLight', 300: 'Light', 400: 'Regular',
|
|
308
|
+
500: 'Medium', 600: 'SemiBold', 700: 'Bold', 800: 'ExtraBold', 900: 'Black',
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
italic: Math.abs(italicAngle) >= 8,
|
|
313
|
+
italicAngle: Math.round(italicAngle * 10) / 10,
|
|
314
|
+
cssWeight,
|
|
315
|
+
weightLabel: WEIGHT_LABELS[cssWeight],
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// =============================================================================
|
|
320
|
+
// INTERNAL: Count closed inner counter regions by BFS flood-fill from the image border.
|
|
321
|
+
// Returns how many enclosed white regions are NOT reachable from outside the glyph.
|
|
322
|
+
// 0 = H/L/stems, 1 = O/D/C, 2 = g/B, 3+ = rare.
|
|
323
|
+
// =============================================================================
|
|
324
|
+
function _countCounters(data, size, minX, minY, maxX, maxY) {
|
|
325
|
+
// Build binary map over just the bounding box (0=ink, 1=bg)
|
|
326
|
+
const bW = maxX - minX + 1;
|
|
327
|
+
const bH = maxY - minY + 1;
|
|
328
|
+
if (bW <= 0 || bH <= 0) return 0;
|
|
329
|
+
|
|
330
|
+
const bw = new Uint8Array(bW * bH);
|
|
331
|
+
for (let y = 0; y < bH; y++) {
|
|
332
|
+
for (let x = 0; x < bW; x++) {
|
|
333
|
+
const i = ((minY + y) * size + (minX + x)) * 4;
|
|
334
|
+
const lum = (data[i] + data[i+1] + data[i+2]) / 3;
|
|
335
|
+
bw[y * bW + x] = lum < 128 ? 0 : 1; // 0=ink, 1=bg
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Flood-fill from all border pixels to mark exterior white
|
|
340
|
+
const ext = new Uint8Array(bW * bH);
|
|
341
|
+
const queue = [];
|
|
342
|
+
const enqueue = (x, y) => {
|
|
343
|
+
if (x < 0 || y < 0 || x >= bW || y >= bH) return;
|
|
344
|
+
const idx = y * bW + x;
|
|
345
|
+
if (bw[idx] === 1 && !ext[idx]) { ext[idx] = 1; queue.push(x, y); }
|
|
346
|
+
};
|
|
347
|
+
for (let x = 0; x < bW; x++) { enqueue(x, 0); enqueue(x, bH - 1); }
|
|
348
|
+
for (let y = 0; y < bH; y++) { enqueue(0, y); enqueue(bW - 1, y); }
|
|
349
|
+
while (queue.length) {
|
|
350
|
+
const y = queue.pop(), x = queue.pop();
|
|
351
|
+
enqueue(x-1, y); enqueue(x+1, y); enqueue(x, y-1); enqueue(x, y+1);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Count connected white regions not reached = counters
|
|
355
|
+
const vis = new Uint8Array(bW * bH);
|
|
356
|
+
let counters = 0;
|
|
357
|
+
for (let i = 0; i < bW * bH; i++) {
|
|
358
|
+
if (bw[i] === 1 && !ext[i] && !vis[i]) {
|
|
359
|
+
counters++;
|
|
360
|
+
const q2 = [i];
|
|
361
|
+
while (q2.length) {
|
|
362
|
+
const idx = q2.pop();
|
|
363
|
+
if (vis[idx]) continue;
|
|
364
|
+
vis[idx] = 1;
|
|
365
|
+
const x = idx % bW, y = (idx / bW) | 0;
|
|
366
|
+
const check = (cx, cy) => {
|
|
367
|
+
if (cx < 0 || cy < 0 || cx >= bW || cy >= bH) return;
|
|
368
|
+
const ni = cy * bW + cx;
|
|
369
|
+
if (bw[ni] === 1 && !ext[ni] && !vis[ni]) q2.push(ni);
|
|
370
|
+
};
|
|
371
|
+
check(x-1,y); check(x+1,y); check(x,y-1); check(x,y+1);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return Math.min(counters, 3);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// =============================================================================
|
|
379
|
+
// PUBLIC: Compute metrics directly from raw ImageData (no rendering).
|
|
380
|
+
// Used by image-pipeline.js to analyze cropped glyphs from real images.
|
|
381
|
+
// character — the label to attach (caller's responsibility to assign correctly)
|
|
382
|
+
// size — the canvas dimension the imageData was drawn at
|
|
383
|
+
// =============================================================================
|
|
384
|
+
function analyzeImageData(imageData, size, character) {
|
|
385
|
+
const { data } = imageData;
|
|
386
|
+
const { dark, minX, minY, maxX, maxY } = _collectDarkPixels(imageData, size);
|
|
387
|
+
|
|
388
|
+
if (dark.length === 0 || maxX < minX || maxY < minY) return null;
|
|
389
|
+
|
|
390
|
+
const width = maxX - minX + 1;
|
|
391
|
+
const height = maxY - minY + 1;
|
|
392
|
+
const total = dark.length;
|
|
393
|
+
|
|
394
|
+
const aspectRatio = Math.min(255, Math.floor((width / height) * 64));
|
|
395
|
+
const boxArea = Math.max(1, width * height);
|
|
396
|
+
const density = Math.min(255, Math.floor((total / boxArea) * 255));
|
|
397
|
+
|
|
398
|
+
const cx = minX + width / 2;
|
|
399
|
+
const cy = minY + height / 2;
|
|
400
|
+
let nw = 0, ne = 0, sw = 0, se = 0;
|
|
401
|
+
for (const { x, y } of dark) {
|
|
402
|
+
if (x < cx && y < cy) nw++;
|
|
403
|
+
else if (x >= cx && y < cy) ne++;
|
|
404
|
+
else if (x < cx && y >= cy) sw++;
|
|
405
|
+
else se++;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const sumX = dark.reduce((s, p) => s + p.x, 0);
|
|
409
|
+
const sumY = dark.reduce((s, p) => s + p.y, 0);
|
|
410
|
+
const xBalance = Math.min(255, Math.floor(((sumX / total) - minX) / width * 255));
|
|
411
|
+
const yBalance = Math.min(255, Math.floor(((sumY / total) - minY) / height * 255));
|
|
412
|
+
|
|
413
|
+
const edgePixels = _getEdgePixels(data, size, minX, minY, maxX, maxY);
|
|
414
|
+
const serifScore = _estimateSerifScore(data, size, minX, minY, maxX, maxY);
|
|
415
|
+
|
|
416
|
+
// ── Phase-3 features ───────────────────────────────────────────────────────
|
|
417
|
+
const quadrantNw = Math.floor((nw / total) * 255);
|
|
418
|
+
const quadrantNe = Math.floor((ne / total) * 255);
|
|
419
|
+
const quadrantSw = Math.floor((sw / total) * 255);
|
|
420
|
+
const quadrantSe = Math.floor((se / total) * 255);
|
|
421
|
+
|
|
422
|
+
// counter_count: BFS flood fill from border to find enclosed white regions
|
|
423
|
+
const counterCount = _countCounters(data, size, minX, minY, maxX, maxY);
|
|
424
|
+
|
|
425
|
+
// counter_complexity: approximated from curve ratio (curved counters = complex)
|
|
426
|
+
const curveRatio = _estimateCurveRatio(edgePixels);
|
|
427
|
+
const counterComplexity = counterCount > 0 ? curveRatio : 0;
|
|
428
|
+
|
|
429
|
+
// terminal_angle_dist: inverse of serif score (horizontal terminals = serif)
|
|
430
|
+
const terminalAngleDist = Math.max(0, 255 - serifScore);
|
|
431
|
+
|
|
432
|
+
// weight_left_bias: left half vs right half ink fraction
|
|
433
|
+
const leftInk = quadrantNw + quadrantSw;
|
|
434
|
+
const rightInk = quadrantNe + quadrantSe;
|
|
435
|
+
const weightLeftBias = (leftInk + rightInk) > 0
|
|
436
|
+
? Math.round(leftInk * 255 / (leftInk + rightInk)) : 128;
|
|
437
|
+
|
|
438
|
+
// vertical_symmetry: 255 = perfectly symmetric top/bottom
|
|
439
|
+
const topInk = quadrantNw + quadrantNe;
|
|
440
|
+
const botInk = quadrantSw + quadrantSe;
|
|
441
|
+
const verticalSymmetry = Math.max(0, 255 - Math.abs(topInk - botInk));
|
|
442
|
+
|
|
443
|
+
// baseline_drop: ink below the vertical midpoint (g/p/y vs A/H)
|
|
444
|
+
const baselineDrop = yBalance > 128 ? Math.min(255, (yBalance - 128) * 2) : 0;
|
|
445
|
+
|
|
446
|
+
// cap_height_fill: top 25% ink density
|
|
447
|
+
const capHeightFill = Math.round((quadrantNw + quadrantNe) / 2);
|
|
448
|
+
|
|
449
|
+
// junction_count: use pointCount as proxy (more outline changes = more junctions)
|
|
450
|
+
const pointCount = Math.min(255, Math.floor(edgePixels.length / 5));
|
|
451
|
+
const junctionCount = Math.min(255, Math.round(pointCount * 0.4));
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
character,
|
|
455
|
+
aspectRatio,
|
|
456
|
+
density,
|
|
457
|
+
quadrantNw,
|
|
458
|
+
quadrantNe,
|
|
459
|
+
quadrantSw,
|
|
460
|
+
quadrantSe,
|
|
461
|
+
curveRatio,
|
|
462
|
+
pointCount,
|
|
463
|
+
xBalance,
|
|
464
|
+
yBalance,
|
|
465
|
+
strokeWidth: _estimateStrokeWidth(data, size, minX, minY, maxX, maxY),
|
|
466
|
+
serifScore,
|
|
467
|
+
// Phase-3 features
|
|
468
|
+
counterCount,
|
|
469
|
+
counterComplexity,
|
|
470
|
+
terminalAngleDist,
|
|
471
|
+
weightLeftBias,
|
|
472
|
+
verticalSymmetry,
|
|
473
|
+
baselineDrop,
|
|
474
|
+
capHeightFill,
|
|
475
|
+
junctionCount,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// =============================================================================
|
|
480
|
+
// PUBLIC: Analyze one character — returns a JsPixelMetrics-compatible object
|
|
481
|
+
// =============================================================================
|
|
482
|
+
function analyzeChar(fontFamily, character, size = RENDER_SIZE) {
|
|
483
|
+
const imageData = _renderChar(fontFamily, character, size);
|
|
484
|
+
const metrics = analyzeImageData(imageData, size, character);
|
|
485
|
+
return metrics; // null if font doesn't contain this glyph
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// =============================================================================
|
|
489
|
+
// PUBLIC: Analyze multiple characters for a given font family name
|
|
490
|
+
// Returns array of JsPixelMetrics-compatible objects (one per char)
|
|
491
|
+
// =============================================================================
|
|
492
|
+
function analyzeFont(fontFamily, characters = 'RQWM', size = RENDER_SIZE) {
|
|
493
|
+
const results = [];
|
|
494
|
+
for (const char of characters) {
|
|
495
|
+
const metrics = analyzeChar(fontFamily, char, size);
|
|
496
|
+
if (metrics) results.push(metrics);
|
|
497
|
+
}
|
|
498
|
+
return results;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// =============================================================================
|
|
502
|
+
// PUBLIC: Analyze a DOM element's font
|
|
503
|
+
// Returns { fontFamily, metrics[] }
|
|
504
|
+
// =============================================================================
|
|
505
|
+
function analyzeElement(element, characters = 'RQWM') {
|
|
506
|
+
const style = window.getComputedStyle(element);
|
|
507
|
+
// fontFamily may be a comma-separated list like '"Inter", sans-serif'
|
|
508
|
+
const raw = style.fontFamily || 'sans-serif';
|
|
509
|
+
// Take the first family name, strip quotes
|
|
510
|
+
const fontFamily = raw.split(',')[0].trim().replace(/['"]/g, '');
|
|
511
|
+
const metrics = analyzeFont(fontFamily, characters);
|
|
512
|
+
return { fontFamily, cssRaw: raw, metrics };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// =============================================================================
|
|
516
|
+
// Variable Font Axis Detection
|
|
517
|
+
//
|
|
518
|
+
// Detects whether a font is variable and estimates its current axis values
|
|
519
|
+
// from measured pixel metrics.
|
|
520
|
+
//
|
|
521
|
+
// Strategy:
|
|
522
|
+
// 1. Render the font at CSS weight 300 and 700 — if stroke widths differ
|
|
523
|
+
// by more than a threshold the font is variable (responds to wght axis).
|
|
524
|
+
// 2. Binary-search the wght axis to find the value that best matches the
|
|
525
|
+
// measured stroke_width from an image or DOM element.
|
|
526
|
+
// 3. Detect optical-size (opsz) axis by comparing x-height ratios at
|
|
527
|
+
// different opsz values (requires font-variation-settings support).
|
|
528
|
+
//
|
|
529
|
+
// @param {string} fontFamily
|
|
530
|
+
// @param {Object} measuredMetrics — JsPixelMetrics from analyzeImageData/analyzeFont
|
|
531
|
+
// @returns {{ variable: boolean, axes: Record<string, number> | null, note?: string }}
|
|
532
|
+
// =============================================================================
|
|
533
|
+
|
|
534
|
+
function _renderCharWithWeight(fontFamily, character, size, weight) {
|
|
535
|
+
const canvas = document.createElement('canvas');
|
|
536
|
+
canvas.width = size;
|
|
537
|
+
canvas.height = size;
|
|
538
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
539
|
+
ctx.fillStyle = '#ffffff';
|
|
540
|
+
ctx.fillRect(0, 0, size, size);
|
|
541
|
+
ctx.fillStyle = '#000000';
|
|
542
|
+
ctx.font = `${weight} ${Math.floor(size * 0.72)}px "${fontFamily}", serif`;
|
|
543
|
+
ctx.textBaseline = 'middle';
|
|
544
|
+
ctx.textAlign = 'center';
|
|
545
|
+
ctx.fillText(character, size / 2, size / 2);
|
|
546
|
+
return ctx.getImageData(0, 0, size, size);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function _avgStrokeWidth(metrics) {
|
|
550
|
+
if (!metrics || !metrics.length) return 0;
|
|
551
|
+
return metrics.reduce((s, m) => s + (m.strokeWidth ?? m.stroke_width ?? 0), 0) / metrics.length;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function detectVariableAxes(fontFamily, measuredMetrics) {
|
|
555
|
+
if (typeof document === 'undefined') {
|
|
556
|
+
return { variable: false, axes: null, note: 'detectVariableAxes requires a browser environment' };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const SIZE = RENDER_SIZE;
|
|
560
|
+
const CHARS = 'RHn';
|
|
561
|
+
|
|
562
|
+
// Render at thin (300) and bold (700) to test wght response
|
|
563
|
+
const thin = CHARS.split('').map(ch => {
|
|
564
|
+
const id = _renderCharWithWeight(fontFamily, ch, SIZE, 300);
|
|
565
|
+
return analyzeImageData(id, SIZE, ch);
|
|
566
|
+
}).filter(Boolean);
|
|
567
|
+
|
|
568
|
+
const bold = CHARS.split('').map(ch => {
|
|
569
|
+
const id = _renderCharWithWeight(fontFamily, ch, SIZE, 700);
|
|
570
|
+
return analyzeImageData(id, SIZE, ch);
|
|
571
|
+
}).filter(Boolean);
|
|
572
|
+
|
|
573
|
+
if (!thin.length || !bold.length) {
|
|
574
|
+
return { variable: false, axes: null, note: 'Font did not render — may not be loaded' };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const swThin = _avgStrokeWidth(thin);
|
|
578
|
+
const swBold = _avgStrokeWidth(bold);
|
|
579
|
+
|
|
580
|
+
// Non-variable fonts: static weights snap to nearest defined weight.
|
|
581
|
+
// Stroke width difference between 300 and 700 on a static font is typically < 8 units.
|
|
582
|
+
// Variable fonts show smooth continuous change, typically > 15 units difference.
|
|
583
|
+
if (Math.abs(swBold - swThin) < 8) {
|
|
584
|
+
return { variable: false, axes: null };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── Variable detected — binary-search wght axis ───────────────────────────
|
|
588
|
+
const targetSw = measuredMetrics
|
|
589
|
+
? (measuredMetrics.strokeWidth ?? measuredMetrics.stroke_width ?? (swThin + swBold) / 2)
|
|
590
|
+
: (swThin + swBold) / 2;
|
|
591
|
+
|
|
592
|
+
let lo = 100, hi = 900, wght = 400;
|
|
593
|
+
|
|
594
|
+
for (let iter = 0; iter < 7; iter++) {
|
|
595
|
+
const mid = Math.round((lo + hi) / 2);
|
|
596
|
+
const mids = CHARS.split('').map(ch => {
|
|
597
|
+
const id = _renderCharWithWeight(fontFamily, ch, SIZE, mid);
|
|
598
|
+
return analyzeImageData(id, SIZE, ch);
|
|
599
|
+
}).filter(Boolean);
|
|
600
|
+
|
|
601
|
+
const swMid = _avgStrokeWidth(mids);
|
|
602
|
+
wght = mid;
|
|
603
|
+
|
|
604
|
+
if (swMid < targetSw) lo = mid + 1;
|
|
605
|
+
else if (swMid > targetSw) hi = mid - 1;
|
|
606
|
+
else break;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Round to nearest 50 (CSS weight scale)
|
|
610
|
+
wght = Math.round(wght / 50) * 50;
|
|
611
|
+
wght = Math.max(100, Math.min(900, wght));
|
|
612
|
+
|
|
613
|
+
return { variable: true, axes: { wght } };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// =============================================================================
|
|
617
|
+
// EXPORTS (works as both ES module and CommonJS in browser/Node contexts)
|
|
618
|
+
// =============================================================================
|
|
619
|
+
const CanvasDNA = { analyzeChar, analyzeFont, analyzeElement, analyzeImageData, analyzeStyle, detectVariableAxes };
|
|
620
|
+
|
|
621
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
622
|
+
module.exports = CanvasDNA;
|
|
623
|
+
} else if (typeof window !== 'undefined') {
|
|
624
|
+
window.CanvasDNA = CanvasDNA;
|
|
625
|
+
}
|
|
626
|
+
})();
|