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/index.js CHANGED
@@ -1,194 +1,760 @@
1
- #!/usr/bin/env node
2
- const {
3
- getFontSuggestions,
4
- normalizeFontName,
5
- pinFont,
6
- unpinFont,
7
- removeFromCache,
8
- exportMetrics,
9
- getEngineStats,
10
- getCacheStats,
11
- cleanupCache,
12
- listPinnedFonts,
13
- updateDatabase
14
- } = require('./intellifont-engine.node');
15
-
16
- /**
17
- * Creates a CSS @font-face string for a given font family and URL.
18
- */
19
- function createFontFace(family, url, weight = 400, italic = false) {
20
- return `
21
- @font-face {
22
- font-family: '${family}';
23
- src: url('${url}') format('woff2');
24
- font-weight: ${weight};
25
- font-style: ${italic ? 'italic' : 'normal'};
26
- font-display: swap;
27
- }
28
- `.trim();
29
- }
30
-
31
- /**
32
- * Enhanced suggestion helper that includes license advice.
33
- */
34
- async function getEnhancedSuggestions(fontName, includeInternet = true) {
35
- const suggestions = await getFontSuggestions(fontName, includeInternet);
36
-
37
- return suggestions.map(s => ({
38
- ...s,
39
- uiAdvice: s.isCriticalLicenseWarning
40
- ? "⚠ Warning: Check license before commercial use."
41
- : "✔ Safe to use.",
42
- fontFace: s.downloadUrl ? createFontFace(s.family, s.downloadUrl, s.weight, s.italic) : null
43
- }));
44
- }
45
-
46
- /**
47
- * CLI Entry Point
48
- */
49
- if (require.main === module) {
50
- const args = process.argv.slice(2);
51
- const command = args[0];
52
- const subCommand = args[1];
53
- const params = args.slice(command === 'cache' || command === 'config' ? 2 : 1);
54
-
55
- async function runCli() {
56
- switch (command) {
57
- case 'resolve':
58
- case 'suggest':
59
- const searchName = params[0] || "Arial";
60
- const useWeb = args.includes('--internet') || args.includes('--web');
61
- const results = await getEnhancedSuggestions(searchName, useWeb);
62
- console.log(`\nSuggestions for "${searchName}":`);
63
- results.forEach(r => {
64
- const status = r.isCriticalLicenseWarning ? '[RESTRICTED]' : '[OK]';
65
- console.log(` ${status} ${r.family} ${r.subfamily} (${r.source}) - Similarity: ${(r.score * 100).toFixed(1)}%`);
66
- });
67
- break;
68
-
69
- case 'tiered':
70
- // For CLI parity, we use the same as suggest but with different UI formatting
71
- const tieredName = params[0] || "Arial";
72
- const tieredWeb = args.includes('--internet');
73
- const tieredResults = await getEnhancedSuggestions(tieredName, tieredWeb);
74
- console.log(`\nTiered Analysis for "${tieredName}":`);
75
- const exact = tieredResults.filter(r => r.score > 0.95);
76
- const similar = tieredResults.filter(r => r.score <= 0.95 && r.score > 0.8);
77
-
78
- if (exact.length) {
79
- console.log("\n[90%+ Match Tier]");
80
- exact.forEach(r => console.log(` - ${r.family} (Exact match)`));
81
- }
82
- if (similar.length) {
83
- console.log("\n[80%+ Similar Tier]");
84
- similar.forEach(r => console.log(` - ${r.family} (${(r.score * 100).toFixed(1)}%)`));
85
- }
86
- break;
87
-
88
- case 'stats':
89
- const eStats = getEngineStats();
90
- const cStats = getCacheStats();
91
- console.log(`\nENGINE STATUS`);
92
- console.log(` Fonts indexed : ${eStats.fontCount.toLocaleString()}`);
93
- console.log(` Binary size : ${eStats.compressedSizeMb.toFixed(2)} MB`);
94
- console.log(` Ratio : ${eStats.compressionRatio.toFixed(1)}%`);
95
- console.log(`\nCACHE STATUS`);
96
- console.log(` Memory/Disk : ${cStats.memoryEntries}/${cStats.diskEntries} entries`);
97
- console.log(` Pinned : ${cStats.pinnedFonts}`);
98
- console.log(` Disk usage : ${cStats.diskUsageMb.toFixed(2)} MB`);
99
- break;
100
-
101
- case 'cache':
102
- switch (subCommand) {
103
- case 'stats':
104
- const cs = getCacheStats();
105
- console.log(`\nCACHE: ${cs.memoryEntries} mem / ${cs.diskEntries} disk (${cs.diskUsageMb.toFixed(2)} MB)`);
106
- break;
107
- case 'cleanup':
108
- const agg = args.includes('--aggressive');
109
- const cleaned = cleanupCache(agg);
110
- console.log(`Cache cleared. Removed ${cleaned} entries.`);
111
- break;
112
- case 'list':
113
- const pinned = listPinnedFonts();
114
- console.log("\nPINNED FONTS");
115
- pinned.length ? pinned.forEach(p => console.log(` - ${p}`)) : console.log(" (None)");
116
- break;
117
- case 'pin':
118
- params.forEach(p => { pinFont(p); console.log(`Pinned: ${p}`); });
119
- break;
120
- case 'unpin':
121
- params.forEach(p => { unpinFont(p); console.log(`Unpinned: ${p}`); });
122
- break;
123
- }
124
- break;
125
-
126
- case 'scan':
127
- const sStats = getEngineStats();
128
- console.log(`\nScan complete. ${sStats.fontCount} fonts available.`);
129
- break;
130
-
131
- case 'update':
132
- process.stdout.write("Updating database... ");
133
- try {
134
- await updateDatabase();
135
- console.log("Done.");
136
- } catch (e) {
137
- console.log("\nFailed.");
138
- console.error(`Error: ${e.message}`);
139
- }
140
- break;
141
-
142
- case 'export-metrics':
143
- try {
144
- console.log(exportMetrics(params[0] || "Arial"));
145
- } catch (e) {
146
- console.error(`Error: ${e.message}`);
147
- }
148
- break;
149
-
150
- case 'setup':
151
- console.log("\nintelliFont Setup Wizard");
152
- console.log("1. Memory Limit: 16MB (Default)");
153
- console.log("2. Web Fonts: Enabled");
154
- console.log("3. License Guard: Active");
155
- console.log("\nConfiguration optimized for your system.");
156
- break;
157
-
158
- case 'normalize':
159
- console.log(normalizeFontName(params[0] || "Arial"));
160
- break;
161
-
162
- case 'help':
163
- default:
164
- console.log("\nintelliFont Engine CLI");
165
- console.log("Usage:");
166
- console.log(" intellifont suggest <name> [--internet] - Find matching fonts");
167
- console.log(" intellifont resolve <name> - Fast lookup");
168
- console.log(" intellifont stats - Engine & Cache metrics");
169
- console.log(" intellifont scan - Refresh system font index");
170
- console.log(" intellifont update - Sync with global CDN signatures");
171
- console.log(" intellifont cache <stats|list|cleanup> - Manage local cache");
172
- console.log(" intellifont cache <pin|unpin> <name> - Manage font pinning");
173
- console.log(" intellifont export-metrics <name> - Get binary DNA of a font");
174
- break;
175
- }
176
- }
177
- runCli().catch(console.error);
178
- }
179
-
180
- module.exports = {
181
- getFontSuggestions,
182
- getEnhancedSuggestions,
183
- normalizeFontName,
184
- createFontFace,
185
- pinFont,
186
- unpinFont,
187
- removeFromCache,
188
- exportMetrics,
189
- getEngineStats,
190
- getCacheStats,
191
- cleanupCache,
192
- listPinnedFonts,
193
- updateDatabase
194
- };
1
+ #!/usr/bin/env node
2
+ const {
3
+ getFontSuggestions,
4
+ normalizeFontName,
5
+ resolveFontBasic,
6
+ pinFont,
7
+ unpinFont,
8
+ removeFromCache,
9
+ exportMetrics,
10
+ getEngineStats,
11
+ getCacheStats,
12
+ cleanupCache,
13
+ listPinnedFonts,
14
+ updateDatabase,
15
+ // Visual identification — file-based
16
+ identifyVisualFont,
17
+ identifyVisualFontBuffer,
18
+ aiSuggestSimilar,
19
+ aiSuggestSimilarBuffer,
20
+ extractGlyphSignature,
21
+ compareGlyphSignatures,
22
+ // Database building
23
+ buildGlyphDatabase,
24
+ identifyFromPixelMetrics: nativeIdentifyFromPixelMetrics,
25
+ // Image OCR preprocessing (Phase 2)
26
+ preprocessImageForFontId,
27
+ // Hybrid ML layer (Phase 4)
28
+ mlAvailable,
29
+ mlIdentifyGlyphs,
30
+ } = require('./intellifont-engine.node');
31
+
32
+ /** Normalize browser/API snake_case metrics to native camelCase (JsPixelMetrics). */
33
+ function _normalizePixelMetrics(m) {
34
+ return {
35
+ character: m.character ?? m.char ?? 'R',
36
+ aspectRatio: m.aspectRatio ?? m.aspect_ratio ?? 0,
37
+ density: m.density ?? 0,
38
+ quadrantNw: m.quadrantNw ?? m.quadrant_nw ?? 0,
39
+ quadrantNe: m.quadrantNe ?? m.quadrant_ne ?? 0,
40
+ quadrantSw: m.quadrantSw ?? m.quadrant_sw ?? 0,
41
+ quadrantSe: m.quadrantSe ?? m.quadrant_se ?? 0,
42
+ curveRatio: m.curveRatio ?? m.curve_ratio ?? 0,
43
+ pointCount: m.pointCount ?? m.point_count ?? 0,
44
+ xBalance: m.xBalance ?? m.x_balance ?? 0,
45
+ yBalance: m.yBalance ?? m.y_balance ?? 0,
46
+ strokeWidth: m.strokeWidth ?? m.stroke_width ?? 0,
47
+ serifScore: m.serifScore ?? m.serif_score ?? 0,
48
+ };
49
+ }
50
+
51
+ // =============================================================================
52
+ // PURE-JS POLYFILL: identifyFromPixelMetrics
53
+ // Reads glyph_signatures.bin and matches pixel metrics via weighted cosine
54
+ // similarity — same algorithm as the Rust MicroSignature matcher.
55
+ // This runs until the Rust binary is rebuilt with the native NAPI function.
56
+ // =============================================================================
57
+ function _findGlyphDbPath() {
58
+ const path = require('path');
59
+ const fs = require('fs');
60
+ const candidates = [
61
+ path.join(process.cwd(), 'data', 'glyph_signatures.bin'),
62
+ path.join(__dirname, 'data', 'glyph_signatures.bin'),
63
+ path.join(__dirname, '..', '..', 'data', 'glyph_signatures.bin'),
64
+ path.join(__dirname, '..', '..', '..', 'data', 'glyph_signatures.bin'),
65
+ ];
66
+ return candidates.find(p => fs.existsSync(p)) || null;
67
+ }
68
+
69
+ function _parseGlyphDb(buf) {
70
+ // Format: b"GLYPHDB1" (8 bytes) + Brotli-compressed bincode(GlyphDatabase)
71
+ const magic = buf.slice(0, 8).toString('ascii');
72
+ if (magic !== 'GLYPHDB1') throw new Error('Invalid glyph DB magic: ' + magic);
73
+
74
+ // Decompress with Node's built-in Brotli
75
+ const zlib = require('zlib');
76
+ const b = zlib.brotliDecompressSync(buf.slice(8));
77
+
78
+ let off = 0;
79
+
80
+ // ---- helpers ----
81
+ function readU8() { return b[off++]; }
82
+ function readU16() { const v = b.readUInt16LE(off); off += 2; return v; }
83
+ function readU32() { const v = b.readUInt32LE(off); off += 4; return v; }
84
+ // bincode u64 as two u32s (JS safe for DB sizes)
85
+ function readU64() { const lo = b.readUInt32LE(off); const hi = b.readUInt32LE(off+4); off += 8; return lo + hi * 0x100000000; }
86
+ function readStr() { const len = readU64(); const s = b.slice(off, off+len).toString('utf8'); off += len; return s; }
87
+ function readOption(readFn) { const tag = readU8(); return tag ? readFn() : null; }
88
+
89
+ // ---- DatabaseHeader (34 bytes total) ----
90
+ const version = readU32();
91
+ const font_count = readU32();
92
+ const char_count = readU8();
93
+ const flags = readU8();
94
+ const lsh_offset = readU64(); // stored but not used by JS
95
+ const signatures_offset = readU64();
96
+ const names_offset = readU64();
97
+
98
+ // ---- LshIndex: Vec<Vec<Vec<u16>>> ----
99
+ // We just skip it — we’ll do brute-force similarity (DB is small)
100
+ const ntables = readU64();
101
+ for (let t = 0; t < ntables; t++) {
102
+ const nbuckets = readU64();
103
+ for (let bkt = 0; bkt < nbuckets; bkt++) {
104
+ const nids = readU64();
105
+ off += nids * 2; // skip u16 font IDs
106
+ }
107
+ }
108
+
109
+ // ---- Vec<FontEntry> ----
110
+ const nfonts = readU64();
111
+ const fonts = [];
112
+
113
+ for (let i = 0; i < nfonts; i++) {
114
+ const family = readStr();
115
+ const subfamily = readOption(readStr);
116
+
117
+ // Vec<(char, MicroSignature)>
118
+ // char is stored as u8 (all ALPHANUMERIC_CHARS are ASCII)
119
+ // MicroSignature: 12×u8 + u16 (feature_hash) + u16 (reserved) = 16 bytes
120
+ const nsigs = readU64();
121
+ const signatures = [];
122
+ for (let s = 0; s < nsigs; s++) {
123
+ const charByte = readU8(); // char stored as 1-byte ASCII
124
+ const sig = {
125
+ aspect_ratio: readU8(),
126
+ density: readU8(),
127
+ quadrant_nw: readU8(),
128
+ quadrant_ne: readU8(),
129
+ quadrant_sw: readU8(),
130
+ quadrant_se: readU8(),
131
+ curve_ratio: readU8(),
132
+ point_count: readU8(),
133
+ x_balance: readU8(),
134
+ y_balance: readU8(),
135
+ stroke_width: readU8(),
136
+ serif_score: readU8(),
137
+ feature_hash: readU16(), // u16
138
+ reserved: readU16(), // u16 (not u8!)
139
+ };
140
+ signatures.push({ char: String.fromCharCode(charByte), sig });
141
+ }
142
+ fonts.push({ family, subfamily, signatures });
143
+ }
144
+
145
+ return fonts;
146
+ }
147
+
148
+ let _cachedDb = null;
149
+ function _loadDb() {
150
+ if (_cachedDb) return _cachedDb;
151
+ const dbPath = _findGlyphDbPath();
152
+ if (!dbPath) return null;
153
+ try {
154
+ const buf = require('fs').readFileSync(dbPath);
155
+ _cachedDb = _parseGlyphDb(buf);
156
+ return _cachedDb;
157
+ } catch (e) {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ function _sigSimilarity(query, dbSig) {
163
+ // Weighted L1 distance — same field weights as Rust implementation
164
+ const weights = [
165
+ ['aspect_ratio', 0.15],
166
+ ['density', 0.15],
167
+ ['quadrant_nw', 0.08],
168
+ ['quadrant_ne', 0.08],
169
+ ['quadrant_sw', 0.08],
170
+ ['quadrant_se', 0.08],
171
+ ['curve_ratio', 0.10],
172
+ ['point_count', 0.05],
173
+ ['x_balance', 0.06],
174
+ ['y_balance', 0.06],
175
+ ['stroke_width', 0.10],
176
+ ['serif_score', 0.11],
177
+ ];
178
+ let dist = 0;
179
+ for (const [field, w] of weights) {
180
+ dist += w * Math.abs((query[field] || 0) - (dbSig[field] || 0)) / 255;
181
+ }
182
+ return Math.max(0, 1 - dist);
183
+ }
184
+
185
+ function identifyFromPixelMetrics(metrics, limit) {
186
+ const normalized = metrics.map(_normalizePixelMetrics);
187
+
188
+ // Prefer native matcher — loads DB in Rust (~35 MB RSS), not a JS object graph (~90+ MB).
189
+ if (typeof nativeIdentifyFromPixelMetrics === 'function') {
190
+ const dbPath = _findGlyphDbPath();
191
+ if (dbPath) {
192
+ const path = require('path');
193
+ const prevCwd = process.cwd();
194
+ try {
195
+ // Native resolves paths relative to cwd (data/glyph_signatures.bin).
196
+ process.chdir(path.dirname(path.dirname(dbPath)));
197
+ return nativeIdentifyFromPixelMetrics(normalized, limit);
198
+ } finally {
199
+ process.chdir(prevCwd);
200
+ }
201
+ }
202
+ try {
203
+ return nativeIdentifyFromPixelMetrics(normalized, limit);
204
+ } catch (err) {
205
+ const msg = err && err.message ? String(err.message) : '';
206
+ if (!msg.includes('GLYPH_DB_NOT_FOUND')) throw err;
207
+ }
208
+ }
209
+
210
+ // Pure-JS fallback (only when native is missing or DB not on disk)
211
+ const db = _loadDb();
212
+ if (!db) {
213
+ throw new Error('GLYPH_DB_NOT_FOUND: Run npx intellifont build-glyph-db <fonts-dir>');
214
+ }
215
+
216
+ const maxResults = (limit || 8);
217
+
218
+ // Score every font in the DB against each provided metric (one per char)
219
+ const familyScores = new Map(); // family+subfamily → { total, count }
220
+
221
+ for (const m of metrics) {
222
+ // Convert camelCase from browser to snake_case for db lookup
223
+ const query = {
224
+ aspect_ratio: m.aspectRatio || m.aspect_ratio || 0,
225
+ density: m.density || 0,
226
+ quadrant_nw: m.quadrantNw || m.quadrant_nw || 0,
227
+ quadrant_ne: m.quadrantNe || m.quadrant_ne || 0,
228
+ quadrant_sw: m.quadrantSw || m.quadrant_sw || 0,
229
+ quadrant_se: m.quadrantSe || m.quadrant_se || 0,
230
+ curve_ratio: m.curveRatio || m.curve_ratio || 0,
231
+ point_count: m.pointCount || m.point_count || 0,
232
+ x_balance: m.xBalance || m.x_balance || 0,
233
+ y_balance: m.yBalance || m.y_balance || 0,
234
+ stroke_width: m.strokeWidth || m.stroke_width || 0,
235
+ serif_score: m.serifScore || m.serif_score || 0,
236
+ };
237
+
238
+ // Find the matching character entries in the DB
239
+ const targetChar = m.character || 'R';
240
+
241
+ for (const font of db) {
242
+ const key = `${font.family}\x00${font.subfamily}`;
243
+ if (!familyScores.has(key)) {
244
+ familyScores.set(key, { family: font.family, subfamily: font.subfamily, total: 0, count: 0 });
245
+ }
246
+ const entry = familyScores.get(key);
247
+
248
+ // Find the matching character's signature (or use best available)
249
+ const charSig = font.signatures.find(s => s.char === targetChar);
250
+ const sigsToScore = charSig ? [charSig] : font.signatures.slice(0, 3);
251
+
252
+ let bestScore = 0;
253
+ for (const { sig } of sigsToScore) {
254
+ const score = _sigSimilarity(query, sig);
255
+ if (score > bestScore) bestScore = score;
256
+ }
257
+ entry.total += bestScore;
258
+ entry.count++;
259
+ }
260
+ }
261
+
262
+ // Average score per font and sort descending
263
+ const results = [...familyScores.values()]
264
+ .map(s => ({
265
+ family: s.family,
266
+ subfamily: s.subfamily,
267
+ confidence: s.total / s.count,
268
+ matchQuality: s.total / s.count >= 0.90 ? 'exact'
269
+ : s.total / s.count >= 0.75 ? 'high'
270
+ : s.total / s.count >= 0.60 ? 'medium' : 'low',
271
+ matchedChars: metrics.map(m => m.character || 'R'),
272
+ }))
273
+ .filter(r => r.confidence > 0.30)
274
+ .sort((a, b) => b.confidence - a.confidence)
275
+ .slice(0, maxResults);
276
+
277
+ return results;
278
+ }
279
+
280
+ /**
281
+ * Creates a CSS @font-face string for a given font family and URL.
282
+ */
283
+ function createFontFace(family, url, weight = 400, italic = false) {
284
+ return `
285
+ @font-face {
286
+ font-family: '${family}';
287
+ src: url('${url}') format('woff2');
288
+ font-weight: ${weight};
289
+ font-style: ${italic ? 'italic' : 'normal'};
290
+ font-display: swap;
291
+ }
292
+ `.trim();
293
+ }
294
+
295
+ /**
296
+ * Enhanced suggestion helper that includes license advice.
297
+ */
298
+ async function getEnhancedSuggestions(fontName, includeInternet = true) {
299
+ const suggestions = await getFontSuggestions(fontName, includeInternet);
300
+
301
+ return suggestions.map(s => ({
302
+ ...s,
303
+ uiAdvice: s.isCriticalLicenseWarning
304
+ ? "⚠ Warning: Check license before commercial use."
305
+ : "✔ Safe to use.",
306
+ fontFace: s.downloadUrl ? createFontFace(s.family, s.downloadUrl, s.weight, s.italic) : null
307
+ }));
308
+ }
309
+
310
+ /**
311
+ * CLI Entry Point
312
+ */
313
+ if (require.main === module) {
314
+ const args = process.argv.slice(2);
315
+ const command = args[0];
316
+ const subCommand = args[1];
317
+ const params = args.slice(command === 'cache' || command === 'config' ? 2 : 1);
318
+
319
+ async function runCli() {
320
+ switch (command) {
321
+ case 'resolve':
322
+ case 'suggest':
323
+ const searchName = params[0] || "Arial";
324
+ const useWeb = args.includes('--internet') || args.includes('--web');
325
+ const results = await getEnhancedSuggestions(searchName, useWeb);
326
+ console.log(`\nSuggestions for "${searchName}":`);
327
+ results.forEach(r => {
328
+ const status = r.isCriticalLicenseWarning ? '[RESTRICTED]' : '[OK]';
329
+ console.log(` ${status} ${r.family} ${r.subfamily} (${r.source}) - Similarity: ${(r.score * 100).toFixed(1)}%`);
330
+ });
331
+ break;
332
+
333
+ case 'tiered':
334
+ // For CLI parity, we use the same as suggest but with different UI formatting
335
+ const tieredName = params[0] || "Arial";
336
+ const tieredWeb = args.includes('--internet');
337
+ const tieredResults = await getEnhancedSuggestions(tieredName, tieredWeb);
338
+ console.log(`\nTiered Analysis for "${tieredName}":`);
339
+ const exact = tieredResults.filter(r => r.score > 0.95);
340
+ const similar = tieredResults.filter(r => r.score <= 0.95 && r.score > 0.8);
341
+
342
+ if (exact.length) {
343
+ console.log("\n[90%+ Match Tier]");
344
+ exact.forEach(r => console.log(` - ${r.family} (Exact match)`));
345
+ }
346
+ if (similar.length) {
347
+ console.log("\n[80%+ Similar Tier]");
348
+ similar.forEach(r => console.log(` - ${r.family} (${(r.score * 100).toFixed(1)}%)`));
349
+ }
350
+ break;
351
+
352
+ case 'stats':
353
+ const eStats = getEngineStats();
354
+ const cStats = getCacheStats();
355
+ console.log(`\nENGINE STATUS`);
356
+ console.log(` Fonts indexed : ${eStats.fontCount.toLocaleString()}`);
357
+ console.log(` Binary size : ${eStats.compressedSizeMb.toFixed(2)} MB`);
358
+ console.log(` Ratio : ${eStats.compressionRatio.toFixed(1)}%`);
359
+ console.log(`\nCACHE STATUS`);
360
+ console.log(` Memory/Disk : ${cStats.memoryEntries}/${cStats.diskEntries} entries`);
361
+ console.log(` Pinned : ${cStats.pinnedFonts}`);
362
+ console.log(` Disk usage : ${cStats.diskUsageMb.toFixed(2)} MB`);
363
+ break;
364
+
365
+ case 'cache':
366
+ switch (subCommand) {
367
+ case 'stats':
368
+ const cs = getCacheStats();
369
+ console.log(`\nCACHE: ${cs.memoryEntries} mem / ${cs.diskEntries} disk (${cs.diskUsageMb.toFixed(2)} MB)`);
370
+ break;
371
+ case 'cleanup':
372
+ const agg = args.includes('--aggressive');
373
+ const cleaned = cleanupCache(agg);
374
+ console.log(`Cache cleared. Removed ${cleaned} entries.`);
375
+ break;
376
+ case 'list':
377
+ const pinned = listPinnedFonts();
378
+ console.log("\nPINNED FONTS");
379
+ pinned.length ? pinned.forEach(p => console.log(` - ${p}`)) : console.log(" (None)");
380
+ break;
381
+ case 'pin':
382
+ params.forEach(p => { pinFont(p); console.log(`Pinned: ${p}`); });
383
+ break;
384
+ case 'unpin':
385
+ params.forEach(p => { unpinFont(p); console.log(`Unpinned: ${p}`); });
386
+ break;
387
+ }
388
+ break;
389
+
390
+ case 'scan':
391
+ const sStats = getEngineStats();
392
+ console.log(`\nScan complete. ${sStats.fontCount} fonts available.`);
393
+ break;
394
+
395
+ case 'update':
396
+ process.stdout.write("Updating database... ");
397
+ try {
398
+ await updateDatabase();
399
+ console.log("Done.");
400
+ } catch (e) {
401
+ console.log("\nFailed.");
402
+ console.error(`Error: ${e.message}`);
403
+ }
404
+ break;
405
+
406
+ case 'export-metrics':
407
+ try {
408
+ console.log(exportMetrics(params[0] || "Arial"));
409
+ } catch (e) {
410
+ console.error(`Error: ${e.message}`);
411
+ }
412
+ break;
413
+
414
+ case 'setup':
415
+ console.log("\nintelliFont Setup Wizard");
416
+ console.log("1. Memory Limit: 16MB (Default)");
417
+ console.log("2. Web Fonts: Enabled");
418
+ console.log("3. License Guard: Active");
419
+ console.log("\nConfiguration optimized for your system.");
420
+ break;
421
+
422
+ case 'normalize':
423
+ console.log(normalizeFontName(params[0] || "Arial"));
424
+ break;
425
+
426
+ case 'inspect-url': {
427
+ // Server-side webpage font scraper — no CORS restrictions
428
+ const targetUrl = params[0];
429
+ if (!targetUrl) {
430
+ console.error('Usage: intellifont inspect-url <url>');
431
+ console.error('Example: intellifont inspect-url https://example.com');
432
+ process.exit(1);
433
+ }
434
+
435
+ console.log(`\n🔍 Scanning fonts on: ${targetUrl}\n`);
436
+
437
+ try {
438
+ const fetchFn = typeof fetch !== 'undefined' ? fetch : (...a) => import('node-fetch').then(m => m.default(...a)).catch(() => {
439
+ throw new Error('Node 18+ required or install node-fetch: npm install node-fetch');
440
+ });
441
+
442
+ // 1. Download the page HTML
443
+ const htmlRes = await fetchFn(targetUrl, { headers: { 'User-Agent': 'intellifont-inspector/1.3' } });
444
+ const html = await htmlRes.text();
445
+ const baseUrl = new URL(targetUrl);
446
+
447
+ // 2. Find all linked stylesheets + inline <style> blocks
448
+ const cssTexts = [];
449
+ const styleTagRe = /<style[^>]*>([\/\s\S]*?)<\/style>/gi;
450
+ let sm;
451
+ while ((sm = styleTagRe.exec(html)) !== null) cssTexts.push(sm[1]);
452
+
453
+ const linkRe = /<link[^>]+rel=["']stylesheet["'][^>]+href=["']([^"']+)["']/gi;
454
+ let lm;
455
+ while ((lm = linkRe.exec(html)) !== null) {
456
+ try {
457
+ const cssUrl = new URL(lm[1], baseUrl).href;
458
+ const cssRes = await fetchFn(cssUrl, { headers: { 'User-Agent': 'intellifont-inspector/1.3' } });
459
+ if (cssRes.ok) cssTexts.push(await cssRes.text());
460
+ } catch (_) {}
461
+ }
462
+
463
+ // 3. Extract all @font-face src URLs
464
+ const allCss = cssTexts.join('\n');
465
+ const fontFaceRe = /@font-face\s*\{([^}]+)\}/gi;
466
+ const srcUrlRe = /src:[^;]*url\(["']?([^"')]+\.(?:woff2?|ttf|otf))["']?\)/i;
467
+ const familyRe = /font-family:\s*["']?([^"';]+)["']?/i;
468
+
469
+ const fontJobs = [];
470
+ let ffm;
471
+ while ((ffm = fontFaceRe.exec(allCss)) !== null) {
472
+ const block = ffm[1];
473
+ const urlMatch = srcUrlRe.exec(block);
474
+ const famMatch = familyRe.exec(block);
475
+ if (urlMatch) {
476
+ fontJobs.push({
477
+ cssName: famMatch ? famMatch[1].trim() : '(unknown)',
478
+ url: new URL(urlMatch[1], baseUrl).href,
479
+ });
480
+ }
481
+ }
482
+
483
+ if (fontJobs.length === 0) {
484
+ console.log(' No @font-face declarations found. The page may use only system fonts or embed fonts differently.');
485
+ } else {
486
+ console.log(` Found ${fontJobs.length} font-face declaration(s). Identifying...\n`);
487
+ for (const job of fontJobs) {
488
+ process.stdout.write(` → "${job.cssName}" (${job.url.split('/').pop()}) ... `);
489
+ try {
490
+ const fontRes = await fetchFn(job.url);
491
+ if (!fontRes.ok) { console.log('(fetch failed)'); continue; }
492
+ const ab = await fontRes.arrayBuffer();
493
+ const buffer = Buffer.from(ab);
494
+ const results = identifyVisualFontBuffer(buffer, 'RQWM', 5);
495
+ if (results.length > 0) {
496
+ const top = results[0];
497
+ const pct = (top.confidence * 100).toFixed(0);
498
+ const quality = top.confidence >= 0.95 ? '✅' : top.confidence >= 0.80 ? '🟡' : '🔴';
499
+ console.log(`${quality} ${top.family} ${top.subfamily || ''} (${pct}% confidence)`);
500
+ if (results.length > 1 && args.includes('--verbose')) {
501
+ results.slice(1, 3).forEach(r => console.log(` also: ${r.family} (${(r.confidence*100).toFixed(0)}%)`));
502
+ }
503
+ } else {
504
+ console.log('(no match in database — try building a larger glyph DB)');
505
+ }
506
+ } catch (e) {
507
+ console.log(`(error: ${e.message})`);
508
+ }
509
+ }
510
+ }
511
+
512
+ // 4. Also list CSS font-family names referenced in the page
513
+ const inlineRe = /font-family:\s*["']?([A-Za-z][^"';,\n]+)/g;
514
+ const foundNames = new Set();
515
+ let im;
516
+ while ((im = inlineRe.exec(allCss)) !== null) {
517
+ const n = im[1].trim().replace(/["']/g, '');
518
+ if (n && !n.startsWith('inherit') && !n.startsWith('var')) foundNames.add(n);
519
+ }
520
+ if (foundNames.size > 0) {
521
+ console.log(`\n CSS font names referenced: ${[...foundNames].join(', ')}`);
522
+ if (args.includes('--suggest')) {
523
+ for (const name of foundNames) {
524
+ const suggestions = await getFontSuggestions(name, false);
525
+ if (suggestions.length > 0) {
526
+ console.log(`\n Suggestions for "${name}":`);
527
+ suggestions.slice(0, 3).forEach(s => console.log(` ${s.family} (${(s.score*100).toFixed(0)}%)`));
528
+ }
529
+ }
530
+ }
531
+ }
532
+ } catch (e) {
533
+ console.error(`Error: ${e.message}`);
534
+ process.exit(1);
535
+ }
536
+ break;
537
+ }
538
+
539
+ case 'serve': {
540
+ // Zero-dependency HTTP server — serves browser files + Canvas DNA API
541
+ const http = require('http');
542
+ const fs = require('fs');
543
+ const path = require('path');
544
+
545
+ const portArg = args.find(a => a.startsWith('--port=')) || args[args.indexOf('--port') + 1];
546
+ const PORT = parseInt(portArg) || 3000;
547
+ const HOST = args.find(a => a.startsWith('--host='))?.split('=')[1] || 'localhost';
548
+
549
+ // Directory where browser assets live (same folder as index.js)
550
+ const BROWSER_DIR = path.join(__dirname, 'browser');
551
+
552
+ // Auto-copy the glyph DB to ./data/ so the precompiled Rust binary can find it
553
+ {
554
+ const localDbDir = path.join(process.cwd(), 'data');
555
+ const localDbPath = path.join(localDbDir, 'glyph_signatures.bin');
556
+ if (!fs.existsSync(localDbPath)) {
557
+ const dbCandidates = [
558
+ path.join(__dirname, 'data', 'glyph_signatures.bin'),
559
+ path.join(__dirname, '..', '..', 'data', 'glyph_signatures.bin'),
560
+ path.join(__dirname, '..', '..', '..', 'data', 'glyph_signatures.bin'),
561
+ ];
562
+ const src = dbCandidates.find(p => fs.existsSync(p));
563
+ if (src) {
564
+ fs.mkdirSync(localDbDir, { recursive: true });
565
+ fs.copyFileSync(src, localDbPath);
566
+ console.log(` 📦 Glyph DB linked from: ${path.relative(process.cwd(), src)}`);
567
+ }
568
+ }
569
+ }
570
+
571
+ const MIME = {
572
+ '.html': 'text/html; charset=utf-8',
573
+ '.js': 'application/javascript; charset=utf-8',
574
+ '.json': 'application/json',
575
+ '.css': 'text/css',
576
+ '.png': 'image/png',
577
+ '.ico': 'image/x-icon',
578
+ };
579
+
580
+ const server = http.createServer(async (req, res) => {
581
+ // CORS headers so the bookmarklet can call from any origin
582
+ res.setHeader('Access-Control-Allow-Origin', '*');
583
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
584
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
585
+
586
+ if (req.method === 'OPTIONS') {
587
+ res.writeHead(204); res.end(); return;
588
+ }
589
+
590
+ const url = new URL(req.url, `http://${HOST}:${PORT}`);
591
+ const pathname = url.pathname;
592
+
593
+ // ── API: POST /api/identify-from-metrics ─────────────────────────
594
+ if (req.method === 'POST' && pathname === '/api/identify-from-metrics') {
595
+ let body = '';
596
+ req.on('data', chunk => { body += chunk; });
597
+ req.on('end', () => {
598
+ try {
599
+ const { metrics, limit } = JSON.parse(body);
600
+ if (!Array.isArray(metrics) || metrics.length === 0) {
601
+ res.writeHead(400, { 'Content-Type': 'application/json' });
602
+ return res.end(JSON.stringify({ error: 'metrics array required' }));
603
+ }
604
+ const matches = identifyFromPixelMetrics(metrics, limit || 8);
605
+ res.writeHead(200, { 'Content-Type': 'application/json' });
606
+ res.end(JSON.stringify({ success: true, matches }));
607
+ } catch (e) {
608
+ // Gracefully handle missing glyph database — return 200 with empty matches
609
+ if (e.message && e.message.includes('GLYPH_DB_NOT_FOUND')) {
610
+ res.writeHead(200, { 'Content-Type': 'application/json' });
611
+ return res.end(JSON.stringify({
612
+ success: true,
613
+ matches: [],
614
+ databaseNotFound: true,
615
+ hint: 'Build the glyph database with: intellifont build-glyph-db <fonts-dir>'
616
+ }));
617
+ }
618
+ res.writeHead(500, { 'Content-Type': 'application/json' });
619
+ res.end(JSON.stringify({ error: e.message }));
620
+ }
621
+ });
622
+ return;
623
+ }
624
+
625
+ // ── API: POST /api/suggest ────────────────────────────────────────
626
+ if (req.method === 'POST' && pathname === '/api/suggest') {
627
+ let body = '';
628
+ req.on('data', chunk => { body += chunk; });
629
+ req.on('end', async () => {
630
+ try {
631
+ const { fontName, includeInternet } = JSON.parse(body);
632
+ const suggestions = await getFontSuggestions(fontName || 'Arial', includeInternet || false);
633
+ res.writeHead(200, { 'Content-Type': 'application/json' });
634
+ res.end(JSON.stringify({ success: true, suggestions }));
635
+ } catch (e) {
636
+ res.writeHead(500, { 'Content-Type': 'application/json' });
637
+ res.end(JSON.stringify({ error: e.message }));
638
+ }
639
+ });
640
+ return;
641
+ }
642
+
643
+ // ── API: GET /api/health ──────────────────────────────────────────
644
+ if (pathname === '/api/health') {
645
+ res.writeHead(200, { 'Content-Type': 'application/json' });
646
+ return res.end(JSON.stringify({ status: 'ok', version: '1.3.0', engine: 'intellifont-engine' }));
647
+ }
648
+
649
+ // ── Static: /browser/* and / → demo.html ─────────────────────────
650
+ let filePath;
651
+ if (pathname === '/' || pathname === '/index.html') {
652
+ filePath = path.join(BROWSER_DIR, 'demo.html');
653
+ } else if (pathname.startsWith('/browser/')) {
654
+ filePath = path.join(BROWSER_DIR, pathname.replace('/browser/', ''));
655
+ } else {
656
+ filePath = path.join(BROWSER_DIR, pathname.substring(1));
657
+ }
658
+
659
+ const ext = path.extname(filePath);
660
+ const contentType = MIME[ext] || 'application/octet-stream';
661
+
662
+ fs.readFile(filePath, (err, data) => {
663
+ if (err) {
664
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
665
+ return res.end(`Not found: ${pathname}`);
666
+ }
667
+ res.writeHead(200, { 'Content-Type': contentType });
668
+ res.end(data);
669
+ });
670
+ });
671
+
672
+ server.listen(PORT, HOST, () => {
673
+ console.log(`\n ╔════════════════════════════════════════════╗`);
674
+ console.log(` ║ 🎨 intelliFont Canvas DNA Server ║`);
675
+ console.log(` ╚════════════════════════════════════════════╝`);
676
+ console.log(`\n 🚀 Server running at: http://${HOST}:${PORT}`);
677
+ console.log(` 🔬 Demo UI: http://${HOST}:${PORT}/`);
678
+ console.log(` 📡 API endpoint: http://${HOST}:${PORT}/api/identify-from-metrics`);
679
+ console.log(` ❤️ Health check: http://${HOST}:${PORT}/api/health`);
680
+ console.log(`\n Press Ctrl+C to stop.\n`);
681
+ });
682
+
683
+ server.on('error', (err) => {
684
+ if (err.code === 'EADDRINUSE') {
685
+ console.error(`\n❌ Port ${PORT} is already in use.`);
686
+ console.error(` Try: intellifont serve --port 3001`);
687
+ } else {
688
+ console.error(`\n❌ Server error: ${err.message}`);
689
+ }
690
+ process.exit(1);
691
+ });
692
+
693
+ // Keep process alive
694
+ process.on('SIGINT', () => {
695
+ console.log('\n Shutting down...');
696
+ server.close(() => process.exit(0));
697
+ });
698
+
699
+ break; // Note: server is async, process stays alive via server.listen
700
+ }
701
+
702
+ case 'help':
703
+ default:
704
+ console.log("\nintelliFont Engine CLI");
705
+ console.log("Usage:");
706
+ console.log(" intellifont suggest <name> [--internet] - Find matching fonts");
707
+ console.log(" intellifont resolve <name> - Fast lookup");
708
+ console.log(" intellifont inspect-url <url> - [NEW] Identify all fonts on a webpage");
709
+ console.log(" intellifont inspect-url <url> --verbose - [NEW] Show top-3 matches per font");
710
+ console.log(" intellifont serve - [NEW] Start Canvas DNA HTTP server (port 3000)");
711
+ console.log(" intellifont serve --port 8080 - [NEW] Start server on a custom port");
712
+ console.log(" intellifont stats - Engine & Cache metrics");
713
+ console.log(" intellifont scan - Refresh system font index");
714
+ console.log(" intellifont update - Sync with global CDN signatures");
715
+ console.log(" intellifont cache <stats|list|cleanup> - Manage local cache");
716
+ console.log(" intellifont cache <pin|unpin> <name> - Manage font pinning");
717
+ console.log(" intellifont export-metrics <name> - Get binary DNA of a font");
718
+ console.log("\n Canvas DNA (browser pixel inspection):");
719
+ console.log(" 1. intellifont serve → starts server + demo UI");
720
+ console.log(" 2. open http://localhost:3000 → click any font sample");
721
+ break;
722
+ }
723
+ }
724
+ runCli().catch(console.error);
725
+ }
726
+
727
+ module.exports = {
728
+ // Name-based resolution
729
+ getFontSuggestions,
730
+ getEnhancedSuggestions,
731
+ normalizeFontName,
732
+ resolveFontBasic,
733
+ createFontFace,
734
+ // Cache management
735
+ pinFont,
736
+ unpinFont,
737
+ removeFromCache,
738
+ exportMetrics,
739
+ getEngineStats,
740
+ getCacheStats,
741
+ cleanupCache,
742
+ listPinnedFonts,
743
+ updateDatabase,
744
+ // Visual identification — file-based
745
+ identifyVisualFont,
746
+ identifyVisualFontBuffer,
747
+ aiSuggestSimilar,
748
+ aiSuggestSimilarBuffer,
749
+ extractGlyphSignature,
750
+ compareGlyphSignatures,
751
+ // Database building
752
+ buildGlyphDatabase,
753
+ // Canvas DNA — pixel metrics from browser Canvas API
754
+ identifyFromPixelMetrics,
755
+ // Image OCR preprocessing (Phase 2)
756
+ preprocessImageForFontId,
757
+ // Hybrid ML layer (Phase 4)
758
+ mlAvailable,
759
+ mlIdentifyGlyphs,
760
+ };