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.
@@ -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
+ })();