carouselbot 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -3
- package/guidance/design.md +1 -0
- package/package.json +2 -1
- package/skill/carouselbot/SKILL.md +17 -4
- package/src/companion.mjs +159 -44
- package/src/config.mjs +20 -0
- package/src/daemon.mjs +200 -12
- package/src/local-fonts.mjs +574 -0
- package/src/mcp-server.mjs +31 -11
- package/src/setup.mjs +5 -4
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
mkdir,
|
|
6
|
+
open,
|
|
7
|
+
readFile,
|
|
8
|
+
readdir,
|
|
9
|
+
realpath,
|
|
10
|
+
rename,
|
|
11
|
+
stat,
|
|
12
|
+
unlink,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from "node:fs/promises";
|
|
15
|
+
import { homedir, platform } from "node:os";
|
|
16
|
+
import { dirname, extname, join } from "node:path";
|
|
17
|
+
import * as fontkit from "fontkit";
|
|
18
|
+
|
|
19
|
+
const INDEX_VERSION = 1;
|
|
20
|
+
const CURSOR_VERSION = 1;
|
|
21
|
+
const DEFAULT_REFRESH_INTERVAL_MS = 30_000;
|
|
22
|
+
const MAX_FONT_FILE_BYTES = 256 * 1024 * 1024;
|
|
23
|
+
const MAX_EXTRACTED_FONT_BYTES = 128 * 1024 * 1024;
|
|
24
|
+
const MAX_DISCOVERED_FILES = 20_000;
|
|
25
|
+
const MAX_COLLECTION_FACES = 512;
|
|
26
|
+
const MAX_SFNT_TABLES = 4_096;
|
|
27
|
+
const PUBLIC_FONT_KEYS = [
|
|
28
|
+
"localFontId",
|
|
29
|
+
"family",
|
|
30
|
+
"fullName",
|
|
31
|
+
"postscriptName",
|
|
32
|
+
"subfamily",
|
|
33
|
+
"weight",
|
|
34
|
+
"italic",
|
|
35
|
+
"lastUsedAt",
|
|
36
|
+
"variableAxes",
|
|
37
|
+
];
|
|
38
|
+
const FONT_EXTENSIONS = new Set([".ttf", ".otf", ".ttc", ".woff", ".woff2"]);
|
|
39
|
+
const STYLE_WEIGHTS = [
|
|
40
|
+
[/\b(?:thin|hairline)\b/i, 100],
|
|
41
|
+
[/\b(?:extra[ -]?light|ultra[ -]?light)\b/i, 200],
|
|
42
|
+
[/\blight\b/i, 300],
|
|
43
|
+
[/\b(?:medium)\b/i, 500],
|
|
44
|
+
[/\b(?:semi[ -]?bold|demi[ -]?bold)\b/i, 600],
|
|
45
|
+
[/\b(?:extra[ -]?bold|ultra[ -]?bold)\b/i, 800],
|
|
46
|
+
[/\b(?:black|heavy)\b/i, 900],
|
|
47
|
+
[/\bbold\b/i, 700],
|
|
48
|
+
];
|
|
49
|
+
const STYLE_ORDER = [
|
|
50
|
+
/\bregular\b/i,
|
|
51
|
+
/\bbook\b/i,
|
|
52
|
+
/\bmedium\b/i,
|
|
53
|
+
/\b(?:semi[ -]?bold|demi[ -]?bold)\b/i,
|
|
54
|
+
/\bbold\b/i,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export function defaultMacFontDirectories() {
|
|
58
|
+
if (platform() !== "darwin") return [];
|
|
59
|
+
return [
|
|
60
|
+
join(homedir(), "Library", "Fonts"),
|
|
61
|
+
"/Library/Fonts",
|
|
62
|
+
"/System/Library/Fonts",
|
|
63
|
+
"/System/Library/Fonts/Supplemental",
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function codedError(code, message) {
|
|
68
|
+
const error = new Error(`[${code}] ${message}`);
|
|
69
|
+
error.code = code;
|
|
70
|
+
return error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sha256(value, encoding = "hex") {
|
|
74
|
+
return createHash("sha256").update(value).digest(encoding);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function usageGenerationFor(usage) {
|
|
78
|
+
return sha256(JSON.stringify([...usage.entries()].sort(([left], [right]) => left.localeCompare(right))), "base64url").slice(0, 24);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stableFontId(pathInternal, faceIndex, size, mtimeMs) {
|
|
82
|
+
const identity = `${pathInternal}\0${faceIndex}\0${size}\0${mtimeMs}`;
|
|
83
|
+
return `font_${sha256(identity, "base64url").slice(0, 24)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cleanString(value, fallback = "") {
|
|
87
|
+
const normalized = String(value ?? "")
|
|
88
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
89
|
+
.replace(/\s+/g, " ")
|
|
90
|
+
.trim()
|
|
91
|
+
.slice(0, 240);
|
|
92
|
+
return normalized || fallback;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function inferredWeight(font, subfamily) {
|
|
96
|
+
const os2Weight = Number(font?.["OS/2"]?.usWeightClass);
|
|
97
|
+
if (Number.isFinite(os2Weight) && os2Weight >= 1 && os2Weight <= 1_000) return Math.round(os2Weight);
|
|
98
|
+
const variationWeight = Number(font?.variationAxes?.wght?.default);
|
|
99
|
+
if (Number.isFinite(variationWeight) && variationWeight >= 1 && variationWeight <= 1_000) return Math.round(variationWeight);
|
|
100
|
+
const match = STYLE_WEIGHTS.find(([pattern]) => pattern.test(subfamily));
|
|
101
|
+
return match?.[1] || 400;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function variableAxes(font) {
|
|
105
|
+
return Object.entries(font?.variationAxes || {}).flatMap(([rawTag, value]) => {
|
|
106
|
+
const tag = cleanString(rawTag).slice(0, 4);
|
|
107
|
+
const min = Number(value?.min);
|
|
108
|
+
const max = Number(value?.max);
|
|
109
|
+
const defaultValue = Number(value?.default);
|
|
110
|
+
if (!/^[\x20-\x7e]{4}$/.test(tag) || ![min, max, defaultValue].every(Number.isFinite) || min > max) return [];
|
|
111
|
+
return [{
|
|
112
|
+
tag,
|
|
113
|
+
name: cleanString(value?.name, tag),
|
|
114
|
+
min,
|
|
115
|
+
max,
|
|
116
|
+
default: Math.min(max, Math.max(min, defaultValue)),
|
|
117
|
+
}];
|
|
118
|
+
}).sort((left, right) => left.tag.localeCompare(right.tag, "en"));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function metadataForFont(font, source, faceIndex) {
|
|
122
|
+
const postscriptName = cleanString(font?.postscriptName);
|
|
123
|
+
const fullName = cleanString(font?.fullName, postscriptName);
|
|
124
|
+
const family = cleanString(font?.familyName, fullName || postscriptName || "Unknown font");
|
|
125
|
+
const subfamily = cleanString(font?.subfamilyName, "Regular");
|
|
126
|
+
const fsSelection = font?.["OS/2"]?.fsSelection || {};
|
|
127
|
+
const italic = Boolean(fsSelection.italic || fsSelection.oblique || Number(font?.italicAngle));
|
|
128
|
+
const localFontId = stableFontId(source.pathInternal, faceIndex, source.size, source.mtimeMs);
|
|
129
|
+
return {
|
|
130
|
+
localFontId,
|
|
131
|
+
family,
|
|
132
|
+
fullName: fullName || family,
|
|
133
|
+
postscriptName: postscriptName || fullName || family,
|
|
134
|
+
sourcePostscriptName: postscriptName || null,
|
|
135
|
+
subfamily,
|
|
136
|
+
weight: inferredWeight(font, subfamily),
|
|
137
|
+
italic,
|
|
138
|
+
variableAxes: variableAxes(font),
|
|
139
|
+
pathInternal: source.pathInternal,
|
|
140
|
+
faceIndex,
|
|
141
|
+
size: source.size,
|
|
142
|
+
mtimeMs: source.mtimeMs,
|
|
143
|
+
fileFingerprint: source.fileFingerprint,
|
|
144
|
+
fingerprint: sha256(`${source.fileFingerprint}\0${faceIndex}\0${postscriptName}`),
|
|
145
|
+
extension: source.extension,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function publicFont(font, usage) {
|
|
150
|
+
const value = {
|
|
151
|
+
localFontId: font.localFontId,
|
|
152
|
+
family: font.family,
|
|
153
|
+
fullName: font.fullName,
|
|
154
|
+
postscriptName: font.postscriptName,
|
|
155
|
+
subfamily: font.subfamily,
|
|
156
|
+
weight: font.weight,
|
|
157
|
+
italic: Boolean(font.italic),
|
|
158
|
+
lastUsedAt: usage.get(font.localFontId) || null,
|
|
159
|
+
variableAxes: Array.isArray(font.variableAxes) ? font.variableAxes.map((axis) => ({ ...axis })) : [],
|
|
160
|
+
};
|
|
161
|
+
return Object.fromEntries(PUBLIC_FONT_KEYS.map((key) => [key, value[key]]));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function styleRank(font) {
|
|
165
|
+
const style = `${font.subfamily} ${font.italic ? "Italic" : ""}`;
|
|
166
|
+
const base = STYLE_ORDER.findIndex((pattern) => pattern.test(style));
|
|
167
|
+
return (font.italic ? 100 : 0) + (base < 0 ? 5 : base);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true });
|
|
171
|
+
|
|
172
|
+
function alphabetical(left, right) {
|
|
173
|
+
return collator.compare(left.family, right.family)
|
|
174
|
+
|| styleRank(left) - styleRank(right)
|
|
175
|
+
|| collator.compare(left.subfamily, right.subfamily)
|
|
176
|
+
|| collator.compare(left.fullName, right.fullName)
|
|
177
|
+
|| left.localFontId.localeCompare(right.localFontId);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normalizedQuery(value) {
|
|
181
|
+
return cleanString(value).normalize("NFKC").toLocaleLowerCase("en-US").slice(0, 240);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function cursorValue(value) {
|
|
185
|
+
try {
|
|
186
|
+
if (typeof value !== "string" || !value || value.length > 2_048) throw new Error("invalid");
|
|
187
|
+
const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
188
|
+
if (decoded?.v !== CURSOR_VERSION || !Number.isInteger(decoded.offset) || decoded.offset < 0) throw new Error("invalid");
|
|
189
|
+
return decoded;
|
|
190
|
+
} catch {
|
|
191
|
+
throw codedError("INVALID_FONT_CURSOR", "The local-font cursor is invalid or expired. Start listing again without a cursor.");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function encodeCursor(value) {
|
|
196
|
+
return Buffer.from(JSON.stringify({ v: CURSOR_VERSION, ...value })).toString("base64url");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function readJson(path) {
|
|
200
|
+
if (!path) return null;
|
|
201
|
+
try { return JSON.parse(await readFile(path, "utf8")); }
|
|
202
|
+
catch { return null; }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function writePrivateJson(path, value) {
|
|
206
|
+
if (!path) return;
|
|
207
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
208
|
+
const temporary = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
209
|
+
try {
|
|
210
|
+
await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
211
|
+
await rename(temporary, path);
|
|
212
|
+
await chmod(path, 0o600).catch(() => {});
|
|
213
|
+
} finally {
|
|
214
|
+
await unlink(temporary).catch(() => {});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function fontPaths(directories) {
|
|
219
|
+
const roots = [];
|
|
220
|
+
for (const candidate of directories) {
|
|
221
|
+
try { roots.push(await realpath(candidate)); }
|
|
222
|
+
catch { /* Missing font roots are normal. */ }
|
|
223
|
+
}
|
|
224
|
+
roots.sort();
|
|
225
|
+
const discovered = new Map();
|
|
226
|
+
const pending = [...new Set(roots)];
|
|
227
|
+
while (pending.length) {
|
|
228
|
+
const directory = pending.shift();
|
|
229
|
+
let entries;
|
|
230
|
+
try { entries = await readdir(directory, { withFileTypes: true }); }
|
|
231
|
+
catch { continue; }
|
|
232
|
+
entries.sort((left, right) => left.name.localeCompare(right.name, "en"));
|
|
233
|
+
for (const entry of entries) {
|
|
234
|
+
const path = join(directory, entry.name);
|
|
235
|
+
if (entry.isDirectory()) {
|
|
236
|
+
pending.push(path);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!entry.isFile() || !FONT_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
|
|
240
|
+
let canonical;
|
|
241
|
+
let metadata;
|
|
242
|
+
try {
|
|
243
|
+
canonical = await realpath(path);
|
|
244
|
+
metadata = await stat(canonical);
|
|
245
|
+
} catch { continue; }
|
|
246
|
+
if (!metadata.isFile() || metadata.size <= 0 || metadata.size > MAX_FONT_FILE_BYTES) continue;
|
|
247
|
+
discovered.set(canonical, {
|
|
248
|
+
pathInternal: canonical,
|
|
249
|
+
size: metadata.size,
|
|
250
|
+
mtimeMs: Number(metadata.mtimeMs),
|
|
251
|
+
extension: extname(canonical).toLowerCase(),
|
|
252
|
+
});
|
|
253
|
+
if (discovered.size > MAX_DISCOVERED_FILES) {
|
|
254
|
+
throw codedError("FONT_INDEX_TOO_LARGE", "Too many local font files were found to index safely.");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return [...discovered.values()].sort((left, right) => left.pathInternal.localeCompare(right.pathInternal));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function readFileSafely(pathInternal) {
|
|
262
|
+
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0);
|
|
263
|
+
const handle = await open(pathInternal, flags);
|
|
264
|
+
try {
|
|
265
|
+
const metadata = await handle.stat();
|
|
266
|
+
if (!metadata.isFile() || metadata.size <= 0 || metadata.size > MAX_FONT_FILE_BYTES) {
|
|
267
|
+
throw codedError("FONT_UNAVAILABLE", "The selected local font is unavailable or too large.");
|
|
268
|
+
}
|
|
269
|
+
return { buffer: await handle.readFile(), metadata };
|
|
270
|
+
} finally {
|
|
271
|
+
await handle.close();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function mimeTypeFor(buffer, extension = "") {
|
|
276
|
+
const signature = buffer.subarray(0, 4).toString("latin1");
|
|
277
|
+
if (signature === "wOF2") return "font/woff2";
|
|
278
|
+
if (signature === "wOFF") return "font/woff";
|
|
279
|
+
if (signature === "OTTO") return "font/otf";
|
|
280
|
+
if (["\u0000\u0001\u0000\u0000", "true", "typ1"].includes(signature)) return "font/ttf";
|
|
281
|
+
if (extension === ".woff2") return "font/woff2";
|
|
282
|
+
if (extension === ".woff") return "font/woff";
|
|
283
|
+
if (extension === ".otf") return "font/otf";
|
|
284
|
+
return "font/ttf";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function checksum(buffer) {
|
|
288
|
+
let sum = 0;
|
|
289
|
+
for (let offset = 0; offset < buffer.length; offset += 4) {
|
|
290
|
+
let word = 0;
|
|
291
|
+
for (let index = 0; index < 4; index += 1) word = ((word << 8) | (buffer[offset + index] || 0)) >>> 0;
|
|
292
|
+
sum = (sum + word) >>> 0;
|
|
293
|
+
}
|
|
294
|
+
return sum;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function checkedRange(buffer, offset, length, label) {
|
|
298
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0 || offset + length > buffer.length) {
|
|
299
|
+
throw codedError("INVALID_FONT_COLLECTION", `The selected collection has an invalid ${label}.`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function align4(value) {
|
|
304
|
+
return Math.ceil(value / 4) * 4;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Repack one TTC/OTC face as a standalone, browser-loadable SFNT font. */
|
|
308
|
+
export function extractTtcFace(value, faceIndex) {
|
|
309
|
+
const source = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
310
|
+
checkedRange(source, 0, 12, "header");
|
|
311
|
+
if (source.subarray(0, 4).toString("ascii") !== "ttcf") {
|
|
312
|
+
throw codedError("INVALID_FONT_COLLECTION", "The selected font is not a TrueType/OpenType collection.");
|
|
313
|
+
}
|
|
314
|
+
const faceCount = source.readUInt32BE(8);
|
|
315
|
+
if (!faceCount || faceCount > MAX_COLLECTION_FACES) throw codedError("INVALID_FONT_COLLECTION", "The collection has an invalid face count.");
|
|
316
|
+
if (!Number.isInteger(faceIndex) || faceIndex < 0 || faceIndex >= faceCount) {
|
|
317
|
+
throw codedError("FONT_FACE_NOT_FOUND", `Font face ${faceIndex} does not exist in this collection.`);
|
|
318
|
+
}
|
|
319
|
+
checkedRange(source, 12, faceCount * 4, "face directory");
|
|
320
|
+
const faceOffset = source.readUInt32BE(12 + faceIndex * 4);
|
|
321
|
+
checkedRange(source, faceOffset, 12, "face header");
|
|
322
|
+
const sfntVersion = source.subarray(faceOffset, faceOffset + 4);
|
|
323
|
+
const signature = sfntVersion.toString("latin1");
|
|
324
|
+
if (!["\u0000\u0001\u0000\u0000", "true", "typ1", "OTTO"].includes(signature)) {
|
|
325
|
+
throw codedError("INVALID_FONT_COLLECTION", "The selected collection face is not an SFNT font.");
|
|
326
|
+
}
|
|
327
|
+
const tableCount = source.readUInt16BE(faceOffset + 4);
|
|
328
|
+
if (!tableCount || tableCount > MAX_SFNT_TABLES) throw codedError("INVALID_FONT_COLLECTION", "The collection face has an invalid table count.");
|
|
329
|
+
checkedRange(source, faceOffset + 12, tableCount * 16, "table directory");
|
|
330
|
+
const records = [];
|
|
331
|
+
const tags = new Set();
|
|
332
|
+
for (let index = 0; index < tableCount; index += 1) {
|
|
333
|
+
const recordOffset = faceOffset + 12 + index * 16;
|
|
334
|
+
const tagBytes = source.subarray(recordOffset, recordOffset + 4);
|
|
335
|
+
const tag = tagBytes.toString("latin1");
|
|
336
|
+
const offset = source.readUInt32BE(recordOffset + 8);
|
|
337
|
+
const length = source.readUInt32BE(recordOffset + 12);
|
|
338
|
+
checkedRange(source, offset, length, `table ${JSON.stringify(tag)}`);
|
|
339
|
+
if (tags.has(tag)) throw codedError("INVALID_FONT_COLLECTION", "The collection face contains duplicate table tags.");
|
|
340
|
+
tags.add(tag);
|
|
341
|
+
if (tag !== "DSIG") records.push({ tag, tagBytes: Buffer.from(tagBytes), offset, length });
|
|
342
|
+
}
|
|
343
|
+
const head = records.find((record) => record.tag === "head");
|
|
344
|
+
if (!head || head.length < 12) throw codedError("INVALID_FONT_COLLECTION", "The collection face has no valid head table.");
|
|
345
|
+
|
|
346
|
+
let outputLength = 12 + records.length * 16;
|
|
347
|
+
for (const record of records) {
|
|
348
|
+
outputLength = align4(outputLength);
|
|
349
|
+
record.outputOffset = outputLength;
|
|
350
|
+
outputLength += align4(record.length);
|
|
351
|
+
if (outputLength > MAX_EXTRACTED_FONT_BYTES) throw codedError("FONT_TOO_LARGE", "The selected font face is too large to transfer safely.");
|
|
352
|
+
}
|
|
353
|
+
const output = Buffer.alloc(outputLength);
|
|
354
|
+
sfntVersion.copy(output, 0);
|
|
355
|
+
output.writeUInt16BE(records.length, 4);
|
|
356
|
+
const highestPowerOfTwo = 2 ** Math.floor(Math.log2(records.length));
|
|
357
|
+
output.writeUInt16BE(highestPowerOfTwo * 16, 6);
|
|
358
|
+
output.writeUInt16BE(Math.log2(highestPowerOfTwo), 8);
|
|
359
|
+
output.writeUInt16BE(records.length * 16 - highestPowerOfTwo * 16, 10);
|
|
360
|
+
|
|
361
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
362
|
+
const record = records[index];
|
|
363
|
+
source.copy(output, record.outputOffset, record.offset, record.offset + record.length);
|
|
364
|
+
if (record.tag === "head") output.writeUInt32BE(0, record.outputOffset + 8);
|
|
365
|
+
const directoryOffset = 12 + index * 16;
|
|
366
|
+
record.tagBytes.copy(output, directoryOffset);
|
|
367
|
+
output.writeUInt32BE(checksum(output.subarray(record.outputOffset, record.outputOffset + record.length)), directoryOffset + 4);
|
|
368
|
+
output.writeUInt32BE(record.outputOffset, directoryOffset + 8);
|
|
369
|
+
output.writeUInt32BE(record.length, directoryOffset + 12);
|
|
370
|
+
}
|
|
371
|
+
const adjustment = (0xb1b0afba - checksum(output)) >>> 0;
|
|
372
|
+
output.writeUInt32BE(adjustment, head.outputOffset + 8);
|
|
373
|
+
if (checksum(output) !== 0xb1b0afba) throw codedError("INVALID_FONT_COLLECTION", "The extracted font checksum could not be repaired.");
|
|
374
|
+
return output;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function safeFilename(font, mimeType) {
|
|
378
|
+
const extension = mimeType === "font/woff2" ? ".woff2" : mimeType === "font/woff" ? ".woff" : mimeType === "font/otf" ? ".otf" : ".ttf";
|
|
379
|
+
const stem = cleanString(font.postscriptName || font.fullName || "local-font", "local-font")
|
|
380
|
+
.replace(/[^a-z0-9._-]+/gi, "-")
|
|
381
|
+
.replace(/^-+|-+$/g, "")
|
|
382
|
+
.slice(0, 120) || "local-font";
|
|
383
|
+
return `${stem}${extension}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function createLocalFontService({
|
|
387
|
+
directories = defaultMacFontDirectories(),
|
|
388
|
+
cacheDirectory = null,
|
|
389
|
+
cachePath = cacheDirectory ? join(cacheDirectory, "local-font-index-v1.json") : null,
|
|
390
|
+
usagePath = cacheDirectory ? join(cacheDirectory, "local-font-usage-v1.json") : null,
|
|
391
|
+
refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS,
|
|
392
|
+
now = () => Date.now(),
|
|
393
|
+
} = {}) {
|
|
394
|
+
let records = new Map();
|
|
395
|
+
let files = [];
|
|
396
|
+
let generation = "empty";
|
|
397
|
+
let initialized = false;
|
|
398
|
+
let lastRefreshAt = -Infinity;
|
|
399
|
+
let refreshPromise = null;
|
|
400
|
+
let usagePromise = null;
|
|
401
|
+
let usage = new Map();
|
|
402
|
+
let usageGeneration = usageGenerationFor(usage);
|
|
403
|
+
let usageWrite = Promise.resolve();
|
|
404
|
+
|
|
405
|
+
async function loadUsage() {
|
|
406
|
+
if (usagePromise) return usagePromise;
|
|
407
|
+
usagePromise = (async () => {
|
|
408
|
+
const cached = await readJson(usagePath);
|
|
409
|
+
usage = new Map(Object.entries(cached?.usage || {}).flatMap(([id, timestamp]) => (
|
|
410
|
+
/^font_[A-Za-z0-9_-]{8,}$/.test(id) && Number.isFinite(Number(timestamp)) && Number(timestamp) > 0
|
|
411
|
+
? [[id, Number(timestamp)]]
|
|
412
|
+
: []
|
|
413
|
+
)));
|
|
414
|
+
usageGeneration = usageGenerationFor(usage);
|
|
415
|
+
return usage;
|
|
416
|
+
})();
|
|
417
|
+
return usagePromise;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function buildIndex() {
|
|
421
|
+
const candidates = await fontPaths(directories);
|
|
422
|
+
const cached = await readJson(cachePath);
|
|
423
|
+
const cachedFiles = new Map((cached?.version === INDEX_VERSION && Array.isArray(cached.files) ? cached.files : [])
|
|
424
|
+
.map((file) => [file.pathInternal, file]));
|
|
425
|
+
const nextFiles = [];
|
|
426
|
+
const seenFingerprints = new Set();
|
|
427
|
+
const nextRecords = new Map();
|
|
428
|
+
for (const candidate of candidates) {
|
|
429
|
+
const previous = cachedFiles.get(candidate.pathInternal);
|
|
430
|
+
let indexed;
|
|
431
|
+
if (previous && previous.size === candidate.size && previous.mtimeMs === candidate.mtimeMs && previous.extension === candidate.extension) {
|
|
432
|
+
indexed = previous;
|
|
433
|
+
} else {
|
|
434
|
+
try {
|
|
435
|
+
const { buffer } = await readFileSafely(candidate.pathInternal);
|
|
436
|
+
const fileFingerprint = sha256(buffer);
|
|
437
|
+
const parsed = fontkit.create(buffer);
|
|
438
|
+
const parsedFaces = Array.isArray(parsed?.fonts) ? parsed.fonts : [parsed];
|
|
439
|
+
if (!parsedFaces.length || parsedFaces.length > MAX_COLLECTION_FACES) throw new Error("Invalid face count");
|
|
440
|
+
const source = { ...candidate, fileFingerprint };
|
|
441
|
+
indexed = {
|
|
442
|
+
...candidate,
|
|
443
|
+
fileFingerprint,
|
|
444
|
+
faces: parsedFaces.map((font, faceIndex) => metadataForFont(font, source, faceIndex)),
|
|
445
|
+
};
|
|
446
|
+
} catch {
|
|
447
|
+
indexed = { ...candidate, fileFingerprint: null, faces: [], invalid: true };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
nextFiles.push(indexed);
|
|
451
|
+
if (!indexed.fileFingerprint || seenFingerprints.has(indexed.fileFingerprint)) continue;
|
|
452
|
+
seenFingerprints.add(indexed.fileFingerprint);
|
|
453
|
+
for (const face of indexed.faces || []) nextRecords.set(face.localFontId, face);
|
|
454
|
+
}
|
|
455
|
+
files = nextFiles;
|
|
456
|
+
records = nextRecords;
|
|
457
|
+
generation = sha256([...records.keys()].sort().join("\0"), "base64url").slice(0, 24);
|
|
458
|
+
lastRefreshAt = now();
|
|
459
|
+
initialized = true;
|
|
460
|
+
await writePrivateJson(cachePath, { version: INDEX_VERSION, generatedAt: lastRefreshAt, files });
|
|
461
|
+
return records;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function refresh({ force = false } = {}) {
|
|
465
|
+
if (!force && initialized && now() - lastRefreshAt < refreshIntervalMs) return records;
|
|
466
|
+
if (!refreshPromise) refreshPromise = buildIndex().finally(() => { refreshPromise = null; });
|
|
467
|
+
return refreshPromise;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function list({ query = "", limit = 50, cursor = null, sort = "recent_then_alphabetical" } = {}) {
|
|
471
|
+
await Promise.all([refresh(), loadUsage()]);
|
|
472
|
+
const safeQuery = normalizedQuery(query);
|
|
473
|
+
const safeSort = sort === "alphabetical" ? "alphabetical" : "recent_then_alphabetical";
|
|
474
|
+
const safeLimit = Math.max(1, Math.min(200, Number.isFinite(Number(limit)) ? Math.trunc(Number(limit)) : 50));
|
|
475
|
+
let offset = 0;
|
|
476
|
+
if (cursor) {
|
|
477
|
+
const decoded = cursorValue(cursor);
|
|
478
|
+
if (
|
|
479
|
+
decoded.generation !== generation
|
|
480
|
+
|| decoded.query !== safeQuery
|
|
481
|
+
|| decoded.sort !== safeSort
|
|
482
|
+
|| (safeSort === "recent_then_alphabetical" && decoded.usageGeneration !== usageGeneration)
|
|
483
|
+
) {
|
|
484
|
+
throw codedError("INVALID_FONT_CURSOR", "The local-font cursor is invalid or expired. Start listing again without a cursor.");
|
|
485
|
+
}
|
|
486
|
+
offset = decoded.offset;
|
|
487
|
+
}
|
|
488
|
+
let matches = [...records.values()].filter((font) => {
|
|
489
|
+
if (!safeQuery) return true;
|
|
490
|
+
return [font.family, font.fullName, font.postscriptName, font.subfamily]
|
|
491
|
+
.some((value) => normalizedQuery(value).includes(safeQuery));
|
|
492
|
+
});
|
|
493
|
+
if (safeSort === "alphabetical") matches.sort(alphabetical);
|
|
494
|
+
else {
|
|
495
|
+
const recent = matches
|
|
496
|
+
.filter((font) => usage.has(font.localFontId))
|
|
497
|
+
.sort((left, right) => usage.get(right.localFontId) - usage.get(left.localFontId) || alphabetical(left, right))
|
|
498
|
+
.slice(0, 8);
|
|
499
|
+
const recentIds = new Set(recent.map((font) => font.localFontId));
|
|
500
|
+
matches = [...recent, ...matches.filter((font) => !recentIds.has(font.localFontId)).sort(alphabetical)];
|
|
501
|
+
}
|
|
502
|
+
const page = matches.slice(offset, offset + safeLimit);
|
|
503
|
+
const nextOffset = offset + page.length;
|
|
504
|
+
return {
|
|
505
|
+
fonts: page.map((font) => publicFont(font, usage)),
|
|
506
|
+
nextCursor: nextOffset < matches.length
|
|
507
|
+
? encodeCursor({
|
|
508
|
+
generation,
|
|
509
|
+
query: safeQuery,
|
|
510
|
+
sort: safeSort,
|
|
511
|
+
offset: nextOffset,
|
|
512
|
+
...(safeSort === "recent_then_alphabetical" ? { usageGeneration } : {}),
|
|
513
|
+
})
|
|
514
|
+
: null,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function resolve(localFontId) {
|
|
519
|
+
await Promise.all([refresh(), loadUsage()]);
|
|
520
|
+
const font = records.get(String(localFontId || ""));
|
|
521
|
+
return font ? { ...font, lastUsedAt: usage.get(font.localFontId) || null } : null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function readFace(localFontId) {
|
|
525
|
+
let font = await resolve(localFontId);
|
|
526
|
+
if (!font) return null;
|
|
527
|
+
let read;
|
|
528
|
+
try { read = await readFileSafely(font.pathInternal); }
|
|
529
|
+
catch {
|
|
530
|
+
await refresh({ force: true });
|
|
531
|
+
throw codedError("FONT_UNAVAILABLE", `${font.fullName} is not available on this device.`);
|
|
532
|
+
}
|
|
533
|
+
const currentMtime = Number(read.metadata.mtimeMs);
|
|
534
|
+
const currentFingerprint = sha256(read.buffer);
|
|
535
|
+
if (read.metadata.size !== font.size || currentMtime !== font.mtimeMs || currentFingerprint !== font.fileFingerprint) {
|
|
536
|
+
await refresh({ force: true });
|
|
537
|
+
throw codedError("FONT_UNAVAILABLE", `${font.fullName} changed on this device. List local fonts again and use its new ID.`);
|
|
538
|
+
}
|
|
539
|
+
let buffer = read.buffer;
|
|
540
|
+
if (buffer.subarray(0, 4).toString("ascii") === "ttcf") buffer = extractTtcFace(buffer, font.faceIndex);
|
|
541
|
+
const mimeType = mimeTypeFor(buffer, font.extension);
|
|
542
|
+
try {
|
|
543
|
+
const parsed = fontkit.create(buffer);
|
|
544
|
+
if (Array.isArray(parsed?.fonts) || (font.sourcePostscriptName && cleanString(parsed?.postscriptName) !== font.sourcePostscriptName)) {
|
|
545
|
+
throw new Error("Extracted face mismatch");
|
|
546
|
+
}
|
|
547
|
+
} catch {
|
|
548
|
+
throw codedError("FONT_UNAVAILABLE", `${font.fullName} could not be prepared for the browser.`);
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
font: publicFont(font, usage),
|
|
552
|
+
buffer,
|
|
553
|
+
mimeType,
|
|
554
|
+
filename: safeFilename(font, mimeType),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function markUsed(localFontId, at = now()) {
|
|
559
|
+
await Promise.all([refresh(), loadUsage()]);
|
|
560
|
+
const font = records.get(String(localFontId || ""));
|
|
561
|
+
if (!font) return null;
|
|
562
|
+
const timestamp = Number.isFinite(Number(at)) && Number(at) > 0 ? Number(at) : now();
|
|
563
|
+
usage.set(font.localFontId, timestamp);
|
|
564
|
+
usageGeneration = usageGenerationFor(usage);
|
|
565
|
+
usageWrite = usageWrite.then(() => writePrivateJson(usagePath, {
|
|
566
|
+
version: INDEX_VERSION,
|
|
567
|
+
usage: Object.fromEntries([...usage.entries()].sort(([left], [right]) => left.localeCompare(right))),
|
|
568
|
+
}));
|
|
569
|
+
await usageWrite;
|
|
570
|
+
return publicFont(font, usage);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return { list, resolve, readFace, markUsed, refresh };
|
|
574
|
+
}
|