tui-cap 0.1.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/dist/edits.js ADDED
@@ -0,0 +1,527 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.EDITS_SCHEMA_VERSION = void 0;
7
+ exports.defaultEdits = defaultEdits;
8
+ exports.editsPathFor = editsPathFor;
9
+ exports.normalizeEdits = normalizeEdits;
10
+ exports.readEdits = readEdits;
11
+ exports.writeEdits = writeEdits;
12
+ exports.fingerprintMatches = fingerprintMatches;
13
+ exports.rowText = rowText;
14
+ exports.inkedEndCol = inkedEndCol;
15
+ exports.rowIsBlank = rowIsBlank;
16
+ exports.scoreRow = scoreRow;
17
+ exports.matchRows = matchRows;
18
+ exports.buildDisplay = buildDisplay;
19
+ exports.toDisplayGrid = toDisplayGrid;
20
+ const promises_1 = require("node:fs/promises");
21
+ const node_path_1 = __importDefault(require("node:path"));
22
+ /**
23
+ * Per-recording content-edit sidecar (`<name>.edits.json`). Like `.anim.json`,
24
+ * the capture's `.ans`/`.timing.json` stay immutable raw data; this file holds
25
+ * the user's non-destructive edits to the *content* of a recording: recoloured
26
+ * or rewritten runs of text, and inserted/removed vertical spacing.
27
+ *
28
+ * Edits cannot be keyed by `(frame,row,col)` because frames scroll, repaint and
29
+ * stream in over time. Each edit is instead anchored to the *text content* of
30
+ * the row it was created on (raw line text + absolute column span + surrounding
31
+ * context) and re-matched against every frame at render time, so a single edit
32
+ * follows its line across the whole recording.
33
+ */
34
+ exports.EDITS_SCHEMA_VERSION = 1;
35
+ /* ------------------------------------------------------------- defaults/io */
36
+ function defaultEdits(fp) {
37
+ return {
38
+ schemaVersion: exports.EDITS_SCHEMA_VERSION,
39
+ recording: {
40
+ ansMtime: fp?.ansMtime ?? 0,
41
+ cols: fp?.cols ?? 0,
42
+ rows: fp?.rows ?? 0,
43
+ },
44
+ revision: 0,
45
+ edits: [],
46
+ };
47
+ }
48
+ /** Sibling sidecar path for a capture: `foo.ans` -> `foo.edits.json`. */
49
+ function editsPathFor(captureFile) {
50
+ const dir = node_path_1.default.dirname(captureFile);
51
+ const base = node_path_1.default.basename(captureFile, node_path_1.default.extname(captureFile));
52
+ return node_path_1.default.join(dir, `${base}.edits.json`);
53
+ }
54
+ const HEX = /^#[0-9a-fA-F]{6}$/;
55
+ function str(value, fallback = '') {
56
+ return typeof value === 'string' ? value : fallback;
57
+ }
58
+ function int(value, fallback) {
59
+ return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback;
60
+ }
61
+ function hexOrNull(value) {
62
+ return typeof value === 'string' && HEX.test(value) ? value.toLowerCase() : null;
63
+ }
64
+ let idCounter = 0;
65
+ function freshId() {
66
+ idCounter += 1;
67
+ return `e${Date.now().toString(36)}${idCounter.toString(36)}`;
68
+ }
69
+ function normalizeAnchor(src) {
70
+ const startCol = Math.max(0, int(src.startCol, 0));
71
+ const endColRaw = Math.max(0, int(src.endCol, startCol));
72
+ return {
73
+ id: str(src.id) || freshId(),
74
+ rawLineText: str(src.rawLineText),
75
+ startCol,
76
+ endCol: Math.max(startCol, endColRaw),
77
+ anchorText: str(src.anchorText),
78
+ leftContext: str(src.leftContext),
79
+ rightContext: str(src.rightContext),
80
+ lineAbove: str(src.lineAbove),
81
+ lineBelow: str(src.lineBelow),
82
+ srcFrame: Math.max(0, int(src.srcFrame, 0)),
83
+ srcRow: Math.max(0, int(src.srcRow, 0)),
84
+ applyToAll: src.applyToAll === true,
85
+ };
86
+ }
87
+ function normalizeEdit(input) {
88
+ if (!input || typeof input !== 'object')
89
+ return null;
90
+ const src = input;
91
+ const anchor = normalizeAnchor(src);
92
+ if (src.kind === 'spacing') {
93
+ const blankLines = int(src.blankLines, 0);
94
+ if (blankLines === 0)
95
+ return null; // a no-op spacing edit carries no intent
96
+ return { ...anchor, kind: 'spacing', blankLines };
97
+ }
98
+ // default to a text edit
99
+ const text = typeof src.text === 'string' ? src.text : null;
100
+ const fg = hexOrNull(src.fg);
101
+ const bg = hexOrNull(src.bg);
102
+ // A text edit with neither a replacement nor a colour is a no-op; drop it.
103
+ if (text === null && fg === null && bg === null)
104
+ return null;
105
+ return { ...anchor, kind: 'text', text, fg, bg };
106
+ }
107
+ /**
108
+ * Coerce arbitrary input (e.g. a request body) into a valid edits document,
109
+ * clamping fields and dropping malformed/no-op edits. Never throws.
110
+ */
111
+ function normalizeEdits(input, fp) {
112
+ const src = (input && typeof input === 'object' ? input : {});
113
+ const base = defaultEdits(fp);
114
+ const rec = (src.recording && typeof src.recording === 'object'
115
+ ? src.recording
116
+ : {});
117
+ const recording = {
118
+ ansMtime: Math.max(0, int(rec.ansMtime, base.recording.ansMtime)),
119
+ cols: Math.max(0, int(rec.cols, base.recording.cols)),
120
+ rows: Math.max(0, int(rec.rows, base.recording.rows)),
121
+ };
122
+ const edits = [];
123
+ if (Array.isArray(src.edits)) {
124
+ for (const raw of src.edits) {
125
+ const e = normalizeEdit(raw);
126
+ if (e)
127
+ edits.push(e);
128
+ }
129
+ }
130
+ return {
131
+ schemaVersion: exports.EDITS_SCHEMA_VERSION,
132
+ recording,
133
+ revision: Math.max(0, int(src.revision, 0)),
134
+ edits,
135
+ };
136
+ }
137
+ /**
138
+ * Read a recording's edits, or null when there's no sidecar yet. Never throws —
139
+ * a garbled file is treated as absent so the GUI falls back to defaults.
140
+ */
141
+ async function readEdits(captureFile) {
142
+ try {
143
+ const raw = await (0, promises_1.readFile)(editsPathFor(captureFile), 'utf8');
144
+ return normalizeEdits(JSON.parse(raw));
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
150
+ /** Write a recording's edits sidecar. Returns the sidecar path. */
151
+ async function writeEdits(captureFile, doc) {
152
+ const out = editsPathFor(captureFile);
153
+ await (0, promises_1.writeFile)(out, `${JSON.stringify(normalizeEdits(doc), null, 2)}\n`, 'utf8');
154
+ return out;
155
+ }
156
+ /**
157
+ * True when a saved edits document still applies to the current capture. A
158
+ * re-record changes the `.ans` mtime or replay dims; mismatched edits are
159
+ * skipped rather than smeared across new content. A zeroed fingerprint (older
160
+ * sidecar, or freshly created in-memory) is treated as "unknown" → compatible.
161
+ */
162
+ function fingerprintMatches(doc, fp) {
163
+ const r = doc.recording;
164
+ if (r.ansMtime === 0 && r.cols === 0 && r.rows === 0)
165
+ return true;
166
+ return r.cols === fp.cols && r.rows === fp.rows;
167
+ }
168
+ /* ----------------------------------------------------------- grid helpers */
169
+ /** Whether a cell carries visible ink (a non-space glyph). */
170
+ function isInked(cell) {
171
+ return cell.char.trim() !== '';
172
+ }
173
+ /** The row's text in column order; trailing whitespace trimmed. */
174
+ function rowText(cells) {
175
+ let s = '';
176
+ let col = 0;
177
+ for (const cell of cells) {
178
+ while (col < cell.col) {
179
+ s += ' ';
180
+ col += 1;
181
+ }
182
+ s += cell.char;
183
+ col = cell.col + Math.max(1, cell.width);
184
+ }
185
+ return s.replace(/\s+$/, '');
186
+ }
187
+ /** Rightmost inked column (exclusive); 0 for a blank row. */
188
+ function inkedEndCol(cells) {
189
+ let end = 0;
190
+ for (const cell of cells) {
191
+ if (isInked(cell))
192
+ end = Math.max(end, cell.col + Math.max(1, cell.width));
193
+ }
194
+ return end;
195
+ }
196
+ /** A row is blank when it has no inked cells. */
197
+ function rowIsBlank(cells) {
198
+ return cells.every((c) => !isInked(c));
199
+ }
200
+ /** The substring of a row covering [start,end) absolute columns, space-padded. */
201
+ function columnSlice(cells, start, end) {
202
+ const out = [];
203
+ for (let c = start; c < end; c++)
204
+ out.push(' ');
205
+ for (const cell of cells) {
206
+ const w = Math.max(1, cell.width);
207
+ if (cell.col + w <= start || cell.col >= end)
208
+ continue;
209
+ const idx = cell.col - start;
210
+ if (idx >= 0 && idx < out.length)
211
+ out[idx] = cell.char;
212
+ // blank out the continuation column of a wide cell within the slice
213
+ for (let k = 1; k < w; k++) {
214
+ const j = idx + k;
215
+ if (j >= 0 && j < out.length)
216
+ out[j] = '';
217
+ }
218
+ }
219
+ return out.join('');
220
+ }
221
+ /* ----------------------------------------------------- grapheme + widths */
222
+ const SEGMENTER = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
223
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
224
+ : null;
225
+ function graphemes(text) {
226
+ if (SEGMENTER)
227
+ return Array.from(SEGMENTER.segment(text), (s) => s.segment);
228
+ return Array.from(text);
229
+ }
230
+ const WIDE = /[\u1100-\u115F\u2329\u232A\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/;
231
+ const PICTO = /\p{Extended_Pictographic}/u;
232
+ /** Column width of a single grapheme: 2 for wide/emoji, else 1. */
233
+ function graphemeWidth(g) {
234
+ const cp = g.codePointAt(0);
235
+ if (cp === undefined)
236
+ return 1;
237
+ if (cp >= 0x1f300 || PICTO.test(g) || WIDE.test(g))
238
+ return 2;
239
+ return 1;
240
+ }
241
+ const SCORE_THRESHOLD = 0.5;
242
+ const MIN_VISIBLE = 6;
243
+ function trimmed(s) {
244
+ return s.replace(/\s+$/, '');
245
+ }
246
+ /**
247
+ * Score how well `cells` (a candidate row) matches an edit's anchor, and report
248
+ * the visible column span the edit should apply to. Returns score 0 when the row
249
+ * is not a credible match (so it is skipped).
250
+ */
251
+ function scoreRow(grid, row, edit) {
252
+ const none = { row, score: 0, visibleStart: edit.startCol, visibleEnd: edit.endCol };
253
+ const cells = grid.lines[row];
254
+ if (!cells)
255
+ return none;
256
+ const text = rowText(cells);
257
+ const raw = trimmed(edit.rawLineText);
258
+ if (raw.length === 0)
259
+ return none;
260
+ const ink = inkedEndCol(cells);
261
+ let score = 0;
262
+ let visibleStart = edit.startCol;
263
+ let visibleEnd = edit.endCol;
264
+ if (text === raw) {
265
+ score = 1; // exact content
266
+ }
267
+ else if (raw.startsWith(text) && text.length > 0) {
268
+ // Typing-in / streaming: this frame shows a prefix of the final line. Only
269
+ // trust it once enough has streamed in to be unambiguous, and only apply to
270
+ // the part of the span that has actually appeared.
271
+ const guard = Math.max(MIN_VISIBLE, Math.floor(raw.length * 0.4));
272
+ if (ink < guard)
273
+ return none;
274
+ visibleEnd = Math.min(edit.endCol, ink);
275
+ if (visibleEnd <= edit.startCol)
276
+ return none; // span not yet visible
277
+ score = 0.45 + 0.25 * (ink / raw.length);
278
+ }
279
+ else if (text.startsWith(raw) && raw.length >= MIN_VISIBLE) {
280
+ // This frame shows the anchor line plus trailing growth (still the line).
281
+ score = 0.7;
282
+ }
283
+ else {
284
+ return none;
285
+ }
286
+ // Context bonuses (cheap disambiguation for repeated/shared-prefix lines).
287
+ if (edit.leftContext) {
288
+ const before = columnSlice(cells, Math.max(0, edit.startCol - edit.leftContext.length), edit.startCol);
289
+ if (before.endsWith(edit.leftContext))
290
+ score += 0.1;
291
+ }
292
+ if (edit.rightContext) {
293
+ const after = columnSlice(cells, edit.endCol, edit.endCol + edit.rightContext.length);
294
+ if (after.startsWith(edit.rightContext))
295
+ score += 0.1;
296
+ }
297
+ if (edit.lineAbove && row > 0 && trimmed(rowText(grid.lines[row - 1])) === edit.lineAbove) {
298
+ score += 0.15;
299
+ }
300
+ if (edit.lineBelow && row + 1 < grid.lines.length && trimmed(rowText(grid.lines[row + 1])) === edit.lineBelow) {
301
+ score += 0.15;
302
+ }
303
+ if (edit.anchorText) {
304
+ const span = columnSlice(cells, edit.startCol, Math.min(edit.endCol, visibleEnd));
305
+ if (trimmed(span) && edit.anchorText.startsWith(trimmed(span)))
306
+ score += 0.1;
307
+ }
308
+ // Row proximity: a tiny tiebreaker only (rows scroll, so weight content far higher).
309
+ const rowCount = Math.max(1, grid.lines.length);
310
+ score += 0.05 * (1 - Math.min(1, Math.abs(row - edit.srcRow) / rowCount));
311
+ return { row, score, visibleStart, visibleEnd };
312
+ }
313
+ /**
314
+ * Find the row(s) an edit applies to within a single frame. Returns the single
315
+ * best match above the confidence threshold, or — when `applyToAll` is set —
316
+ * every match above it (for intentionally repeated lines).
317
+ */
318
+ function matchRows(grid, edit) {
319
+ const matches = [];
320
+ for (let row = 0; row < grid.lines.length; row++) {
321
+ const m = scoreRow(grid, row, edit);
322
+ if (m.score >= SCORE_THRESHOLD)
323
+ matches.push(m);
324
+ }
325
+ if (matches.length === 0)
326
+ return [];
327
+ if (edit.applyToAll)
328
+ return matches;
329
+ let best = matches[0];
330
+ for (const m of matches) {
331
+ if (m.score > best.score || (m.score === best.score && Math.abs(m.row - edit.srcRow) < Math.abs(best.row - edit.srcRow))) {
332
+ best = m;
333
+ }
334
+ }
335
+ return [best];
336
+ }
337
+ /* ----------------------------------------------------------------- apply */
338
+ function cloneCell(cell) {
339
+ return { ...cell };
340
+ }
341
+ function spaceCell(col, style = {}) {
342
+ return {
343
+ char: ' ',
344
+ col,
345
+ width: 1,
346
+ fg: style.fg ?? null,
347
+ bg: style.bg ?? null,
348
+ bold: false,
349
+ italic: false,
350
+ underline: false,
351
+ dim: false,
352
+ strike: false,
353
+ inverse: false,
354
+ invisible: false,
355
+ };
356
+ }
357
+ /**
358
+ * Recolour the cells fully inside [start,end). Cells that only partially overlap
359
+ * a boundary (a wide glyph straddling the edge) are left untouched so we never
360
+ * recolour half a cell. Returns a new row; never mutates the input.
361
+ */
362
+ function recolorRow(cells, start, end, fg, bg) {
363
+ return cells.map((cell) => {
364
+ const w = Math.max(1, cell.width);
365
+ const inside = cell.col >= start && cell.col + w <= end;
366
+ if (!inside)
367
+ return cell;
368
+ const next = cloneCell(cell);
369
+ if (fg !== undefined)
370
+ next.fg = fg;
371
+ if (bg !== undefined)
372
+ next.bg = bg;
373
+ return next;
374
+ });
375
+ }
376
+ /**
377
+ * Overwrite the characters in [start,end) with `text`, laid out grapheme by
378
+ * grapheme in terminal columns and clipped/padded to the span (layout
379
+ * preserving). Wide glyphs are never split: a width-2 grapheme that would
380
+ * overflow the last column is dropped, and any pre-existing wide cell straddling
381
+ * a boundary is cleared to spaces. Returns a new row; never mutates the input.
382
+ */
383
+ function overwriteRow(cells, start, end, text, fg, bg) {
384
+ if (end <= start)
385
+ return cells;
386
+ let width = end;
387
+ for (const cell of cells)
388
+ width = Math.max(width, cell.col + Math.max(1, cell.width));
389
+ // Slot model: index === column. null = empty, 'cont' = wide continuation.
390
+ const slots = new Array(width).fill(null);
391
+ for (const cell of cells) {
392
+ slots[cell.col] = cell;
393
+ for (let k = 1; k < Math.max(1, cell.width); k++)
394
+ slots[cell.col + k] = 'cont';
395
+ }
396
+ // Style inherited by the replacement + padding: the first cell of the span.
397
+ const head = cells.find((c) => c.col <= start && c.col + Math.max(1, c.width) > start);
398
+ const inherit = head
399
+ ? { fg: fg ?? head.fg, bg: bg ?? head.bg }
400
+ : { fg, bg };
401
+ // Free any cell whose footprint overlaps the span (including straddlers), and
402
+ // refill the columns it vacated *outside* the span with spaces.
403
+ for (let c = 0; c < width; c++) {
404
+ const slot = slots[c];
405
+ if (slot && slot !== 'cont') {
406
+ const w = Math.max(1, slot.width);
407
+ if (slot.col < end && slot.col + w > start) {
408
+ for (let k = 0; k < w; k++) {
409
+ const col = slot.col + k;
410
+ slots[col] = col >= start && col < end ? null : spaceCell(col, { fg: slot.fg, bg: slot.bg });
411
+ }
412
+ }
413
+ }
414
+ }
415
+ // Lay the replacement graphemes from `start`, clipped at `end`.
416
+ let col = start;
417
+ for (const g of graphemes(text)) {
418
+ const gw = graphemeWidth(g);
419
+ if (col + gw > end)
420
+ break;
421
+ slots[col] = {
422
+ char: g,
423
+ col,
424
+ width: gw,
425
+ fg: inherit.fg ?? null,
426
+ bg: inherit.bg ?? null,
427
+ bold: head?.bold ?? false,
428
+ italic: head?.italic ?? false,
429
+ underline: head?.underline ?? false,
430
+ dim: head?.dim ?? false,
431
+ strike: head?.strike ?? false,
432
+ inverse: head?.inverse ?? false,
433
+ invisible: head?.invisible ?? false,
434
+ };
435
+ for (let k = 1; k < gw; k++)
436
+ slots[col + k] = 'cont';
437
+ col += gw;
438
+ }
439
+ // Pad the remainder of the span with inheriting spaces.
440
+ for (; col < end; col++)
441
+ slots[col] = spaceCell(col, inherit);
442
+ const out = [];
443
+ for (let c = 0; c < width; c++) {
444
+ const slot = slots[c];
445
+ if (slot === 'cont')
446
+ continue;
447
+ out.push(slot ?? spaceCell(c));
448
+ }
449
+ return out;
450
+ }
451
+ /** Apply one text edit to a single matched row. */
452
+ function applyTextEditToRow(cells, edit, match) {
453
+ const start = match.visibleStart;
454
+ const end = match.visibleEnd;
455
+ let next = cells;
456
+ if (edit.text !== null) {
457
+ next = overwriteRow(next, start, end, edit.text, edit.fg, edit.bg);
458
+ }
459
+ else if (edit.fg !== null || edit.bg !== null) {
460
+ next = recolorRow(next, start, end, edit.fg ?? undefined, edit.bg ?? undefined);
461
+ }
462
+ return next;
463
+ }
464
+ /**
465
+ * Apply every edit in `edits` to `grid`, returning a new display grid plus the
466
+ * raw-row provenance of each display row. Text edits run first (they never
467
+ * change row count), then spacing edits (which do). Pure: the input grid and its
468
+ * cells are never mutated.
469
+ */
470
+ function buildDisplay(grid, edits) {
471
+ const lines = grid.lines.map((row) => row);
472
+ const sourceRows = grid.lines.map((_, i) => i);
473
+ if (edits.length === 0) {
474
+ return { grid: { cols: grid.cols, rows: grid.rows, lines }, sourceRows };
475
+ }
476
+ let work = { cols: grid.cols, rows: grid.rows, lines };
477
+ // 1. Text edits.
478
+ for (const edit of edits) {
479
+ if (edit.kind !== 'text')
480
+ continue;
481
+ const matches = matchRows(work, edit);
482
+ for (const m of matches) {
483
+ lines[m.row] = applyTextEditToRow(lines[m.row], edit, m);
484
+ }
485
+ }
486
+ // 2. Spacing edits (row-count changes). Apply from the bottom up so earlier
487
+ // insertions don't shift the indices of later matches within one edit.
488
+ for (const edit of edits) {
489
+ if (edit.kind !== 'spacing')
490
+ continue;
491
+ work = { cols: work.cols, rows: lines.length, lines };
492
+ const matches = matchRows(work, edit).sort((a, b) => b.row - a.row);
493
+ for (const m of matches) {
494
+ if (edit.blankLines > 0) {
495
+ const blanks = [];
496
+ const provenance = [];
497
+ for (let k = 0; k < edit.blankLines; k++) {
498
+ blanks.push([]);
499
+ provenance.push(null);
500
+ }
501
+ lines.splice(m.row + 1, 0, ...blanks);
502
+ sourceRows.splice(m.row + 1, 0, ...provenance);
503
+ }
504
+ else if (edit.blankLines < 0) {
505
+ let removable = 0;
506
+ const max = -edit.blankLines;
507
+ while (removable < max && lines[m.row + 1 + removable] && rowIsBlank(lines[m.row + 1 + removable])) {
508
+ removable += 1;
509
+ }
510
+ if (removable > 0) {
511
+ lines.splice(m.row + 1, removable);
512
+ sourceRows.splice(m.row + 1, removable);
513
+ }
514
+ }
515
+ }
516
+ }
517
+ return { grid: { cols: grid.cols, rows: lines.length, lines }, sourceRows };
518
+ }
519
+ /**
520
+ * Apply every edit in `edits` to `grid`, returning a new display grid. Thin
521
+ * wrapper over {@link buildDisplay} for callers that don't need row provenance.
522
+ */
523
+ function toDisplayGrid(grid, edits) {
524
+ if (edits.length === 0)
525
+ return grid;
526
+ return buildDisplay(grid, edits).grid;
527
+ }
package/dist/fonts.js ADDED
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FONT_ADVANCE_TABLE = void 0;
4
+ exports.primaryFamily = primaryFamily;
5
+ exports.advanceForFont = advanceForFont;
6
+ exports.buildFontFaceCss = buildFontFaceCss;
7
+ const node_fs_1 = require("node:fs");
8
+ /**
9
+ * Per-column advance as a fraction of font size, for common monospace fonts.
10
+ * These should be each font's TRUE metric (advanceWidth / unitsPerEm), not a
11
+ * hand-tuned value: renderers that honour `textLength` (Chrome, Illustrator, the
12
+ * raster exporter) flow text to the grid regardless, but Figma ignores
13
+ * `textLength` and draws glyphs at the font's real advance — so only a
14
+ * real-metric grid keeps Figma's text aligned with the column-pinned background
15
+ * rects and borders. SF Mono is measured exact (1266/2048); others are close
16
+ * approximations pending per-font measurement. Keys are lower-cased.
17
+ */
18
+ exports.FONT_ADVANCE_TABLE = {
19
+ 'monaspace neon': 0.6,
20
+ 'monaspace argon': 0.6,
21
+ 'monaspace xenon': 0.6,
22
+ 'jetbrains mono': 0.6,
23
+ 'fira code': 0.601,
24
+ 'sf mono': 0.618164,
25
+ 'sfmono-regular': 0.618164,
26
+ 'cascadia code': 0.6,
27
+ 'cascadia mono': 0.6,
28
+ menlo: 0.602,
29
+ monaco: 0.602,
30
+ consolas: 0.5,
31
+ 'source code pro': 0.6,
32
+ 'roboto mono': 0.6,
33
+ 'ibm plex mono': 0.6,
34
+ };
35
+ /** The first family in a CSS font-family stack, with quotes/space trimmed. */
36
+ function primaryFamily(stack) {
37
+ const first = stack.split(',')[0] ?? stack;
38
+ return first.trim().replace(/^['"]|['"]$/g, '').trim();
39
+ }
40
+ /** Known advance for a font stack's primary family, or undefined if unknown. */
41
+ function advanceForFont(stack) {
42
+ return exports.FONT_ADVANCE_TABLE[primaryFamily(stack).toLowerCase()];
43
+ }
44
+ function isRemoteOrData(source) {
45
+ return /^(https?:|data:)/i.test(source);
46
+ }
47
+ /** CSS `format(...)` hint and MIME type derived from a font file extension. */
48
+ function fontKind(source) {
49
+ const ext = (source.split(/[?#]/)[0].match(/\.([a-z0-9]+)$/i)?.[1] ?? '').toLowerCase();
50
+ switch (ext) {
51
+ case 'woff2':
52
+ return { format: 'woff2', mime: 'font/woff2' };
53
+ case 'woff':
54
+ return { format: 'woff', mime: 'font/woff' };
55
+ case 'otf':
56
+ return { format: 'opentype', mime: 'font/otf' };
57
+ case 'ttf':
58
+ default:
59
+ return { format: 'truetype', mime: 'font/ttf' };
60
+ }
61
+ }
62
+ /**
63
+ * Build an `@font-face` CSS rule embedding (or referencing) the given font so
64
+ * Figma-free targets (Illustrator, Chrome, raster) resolve the exact metrics.
65
+ *
66
+ * - `data:` / `http(s):` sources are referenced as-is (the SVG is not
67
+ * self-contained for URLs, by design).
68
+ * - A local file path is base64-embedded into a `data:` URI by default so the
69
+ * SVG stays portable; pass `{ embed: false }` to reference the path instead.
70
+ */
71
+ function buildFontFaceCss(family, source, opts = {}) {
72
+ const { format, mime } = fontKind(source);
73
+ let src;
74
+ if (isRemoteOrData(source)) {
75
+ src = `url('${source}') format('${format}')`;
76
+ }
77
+ else if (opts.embed === false) {
78
+ src = `url('${source}') format('${format}')`;
79
+ }
80
+ else {
81
+ const b64 = (0, node_fs_1.readFileSync)(source).toString('base64');
82
+ src = `url('data:${mime};base64,${b64}') format('${format}')`;
83
+ }
84
+ return (`@font-face{font-family:'${family}';` +
85
+ `font-style:normal;font-weight:400 700;font-display:swap;` +
86
+ `src:${src};}`);
87
+ }