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/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/anim.js +104 -0
- package/dist/animation.js +96 -0
- package/dist/asciinema.js +125 -0
- package/dist/cli.js +648 -0
- package/dist/edits.js +527 -0
- package/dist/fonts.js +87 -0
- package/dist/input.js +136 -0
- package/dist/meta.js +58 -0
- package/dist/palette.js +111 -0
- package/dist/parse.js +659 -0
- package/dist/paths.js +100 -0
- package/dist/record-pty.js +148 -0
- package/dist/record.js +100 -0
- package/dist/server.js +978 -0
- package/dist/svg.js +767 -0
- package/dist/timing.js +112 -0
- package/dist/types.js +2 -0
- package/dist/version.js +232 -0
- package/dist/web/app.js +3312 -0
- package/dist/web/fonts/MonaSansMonoVF-wght.woff2 +0 -0
- package/dist/web/fonts/MonaSansVF-wdth-wght-opsz.woff2 +0 -0
- package/dist/web/fonts/README.md +13 -0
- package/dist/web/index.html +382 -0
- package/dist/web/logo.svg +11 -0
- package/dist/web/styles.css +925 -0
- package/dist/web/timing-model.js +115 -0
- package/dist/web/vendor/mp4-muxer.LICENSE +21 -0
- package/dist/web/vendor/mp4-muxer.js +1885 -0
- package/package.json +61 -0
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
|
+
}
|