@hncode/hncode 0.5.6 → 0.6.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/package.json +37 -38
- package/src/agent.js +67 -2
- package/src/image-row.js +111 -0
- package/src/latex.js +1557 -0
- package/src/llm.js +19 -4
- package/src/mermaid/ansi.js +55 -0
- package/src/mermaid/canvas.js +449 -0
- package/src/mermaid/class-style.js +57 -0
- package/src/mermaid/css-colors.js +153 -0
- package/src/mermaid/diagrams/class.js +289 -0
- package/src/mermaid/diagrams/er.js +201 -0
- package/src/mermaid/diagrams/flowchart.js +488 -0
- package/src/mermaid/diagrams/gitgraph.js +205 -0
- package/src/mermaid/diagrams/mindmap.js +101 -0
- package/src/mermaid/diagrams/pie.js +104 -0
- package/src/mermaid/diagrams/sequence.js +282 -0
- package/src/mermaid/diagrams/state.js +292 -0
- package/src/mermaid/diagrams/timeline.js +99 -0
- package/src/mermaid/graph.js +138 -0
- package/src/mermaid/index.js +59 -0
- package/src/mermaid/labels.js +339 -0
- package/src/mermaid/layout-seq.js +225 -0
- package/src/mermaid/layout.js +1454 -0
- package/src/mermaid/registry.js +54 -0
- package/src/mermaid/source-box.js +102 -0
- package/src/mermaid/statements.js +235 -0
- package/src/mermaid/types.js +1 -0
- package/src/mermaid/width.js +29 -0
- package/src/screen-diff.js +512 -0
- package/src/screen-paint.js +128 -0
- package/src/screen.js +277 -0
- package/src/subagent-types.js +136 -147
- package/src/term-caps.js +10 -4
- package/src/tex/font.js +292 -0
- package/src/tex/fonts/LICENSE.txt +11 -0
- package/src/tex/fonts/STIX2Math.ttf +0 -0
- package/src/tex/index.js +293 -0
- package/src/tex/kitty.js +186 -0
- package/src/tex/layout.js +446 -0
- package/src/tex/parse.js +331 -0
- package/src/tex/raster.js +216 -0
- package/src/tex/render.js +167 -0
- package/src/tex/sixel.js +326 -0
- package/src/tex/svg.js +237 -0
- package/src/tool-result.js +2 -2
- package/src/tools/agent-swarm.js +3 -15
- package/src/tools/agent.js +454 -464
- package/src/tools/ask-user-question.js +195 -206
- package/src/tools/bash.js +39 -3
- package/src/tools/edit.js +10 -112
- package/src/tools/fetch-url.js +130 -120
- package/src/tools/memory.js +80 -88
- package/src/tools/read.js +27 -23
- package/src/tools/todo.js +8 -13
- package/src/tools/validate-args.js +78 -0
- package/src/tui.js +1346 -502
package/src/tex/font.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A TrueType font reader: the tables a rasteriser needs, and glyph outlines as flat contours.
|
|
3
|
+
*
|
|
4
|
+
* Only the `glyf` outline format is read. STIX Two ships CFF outlines in its OpenType build (`.otf`), whose
|
|
5
|
+
* Type 2 charstrings are a postfix program with subroutines, flex operators and hint masks; the TrueType
|
|
6
|
+
* build (`.ttf`) holds the same outlines as quadratic `glyf` contours, which is a fraction of the surface
|
|
7
|
+
* area. That is the one bundled here, and it is what `tools/otf-to-ttf.py` produces from the CFF original.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here tries to be a full font engine. Hints, kerning and shaping are absent on purpose: a formula
|
|
10
|
+
* is laid out by its own rules, and the size is fixed, so none of them affect the raster.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
|
|
14
|
+
/** Read a big-endian uint16 / int16 / uint32 helpers over a Buffer. */
|
|
15
|
+
const u16 = (b, o) => b.readUInt16BE(o);
|
|
16
|
+
const i16 = (b, o) => b.readInt16BE(o);
|
|
17
|
+
const u32 = (b, o) => b.readUInt32BE(o);
|
|
18
|
+
|
|
19
|
+
/** Decode a UTF-16BE string of `len` bytes at `o`. */
|
|
20
|
+
function utf16be(buf, o, len) {
|
|
21
|
+
let s = '';
|
|
22
|
+
for (let i = 0; i < len; i += 2) s += String.fromCharCode(buf.readUInt16BE(o + i));
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class TrueTypeFont {
|
|
27
|
+
/**
|
|
28
|
+
* @param {Buffer} buf The whole file.
|
|
29
|
+
*/
|
|
30
|
+
constructor(buf) {
|
|
31
|
+
this.buf = buf;
|
|
32
|
+
const numTables = u16(buf, 4);
|
|
33
|
+
this.tables = new Map();
|
|
34
|
+
for (let i = 0; i < numTables; i++) {
|
|
35
|
+
const rec = 12 + i * 16;
|
|
36
|
+
const tag = buf.toString('latin1', rec, rec + 4);
|
|
37
|
+
this.tables.set(tag, { offset: u32(buf, rec + 8), length: u32(buf, rec + 12) });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const head = this.table('head');
|
|
41
|
+
this.unitsPerEm = u16(head, 18);
|
|
42
|
+
this.indexToLocFormat = i16(head, 50);
|
|
43
|
+
|
|
44
|
+
const hhea = this.table('hhea');
|
|
45
|
+
this.numHMetrics = u16(hhea, 34);
|
|
46
|
+
this.ascender = i16(hhea, 4);
|
|
47
|
+
this.descender = i16(hhea, 6);
|
|
48
|
+
|
|
49
|
+
this.numGlyphs = u16(this.table('maxp'), 4);
|
|
50
|
+
this._cmap = this._readCmap();
|
|
51
|
+
this._loca = null;
|
|
52
|
+
this._outlineCache = new Map();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
table(tag) {
|
|
56
|
+
const t = this.tables.get(tag);
|
|
57
|
+
if (!t) throw new Error('font is missing the ' + tag + ' table');
|
|
58
|
+
return this.buf.subarray(t.offset, t.offset + t.length);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The Unicode → glyph id map, from the best `cmap` subtable available.
|
|
63
|
+
* Prefers a full-repertoire format 12 over the BMP-only format 4.
|
|
64
|
+
*/
|
|
65
|
+
_readCmap() {
|
|
66
|
+
const cmap = this.table('cmap');
|
|
67
|
+
const count = u16(cmap, 2);
|
|
68
|
+
let best = null;
|
|
69
|
+
for (let i = 0; i < count; i++) {
|
|
70
|
+
const rec = 4 + i * 8;
|
|
71
|
+
const platform = u16(cmap, rec);
|
|
72
|
+
const encoding = u16(cmap, rec + 2);
|
|
73
|
+
const offset = u32(cmap, rec + 4);
|
|
74
|
+
const format = u16(cmap, offset);
|
|
75
|
+
// Unicode full repertoire: platform 3 encoding 10, or platform 0 encoding 4/6.
|
|
76
|
+
if (format === 12 && ((platform === 3 && encoding === 10) || (platform === 0 && (encoding === 4 || encoding === 6)))) {
|
|
77
|
+
best = { offset, format };
|
|
78
|
+
}
|
|
79
|
+
if (!best && format === 4 && (platform === 3 && encoding === 1 || platform === 0)) {
|
|
80
|
+
best = { offset, format };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!best) throw new Error('font has no usable cmap subtable');
|
|
84
|
+
return best;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Glyph id for a code point, or 0 when the font has none. */
|
|
88
|
+
glyphIndex(cp) {
|
|
89
|
+
const cmap = this.table('cmap');
|
|
90
|
+
const { offset, format } = this._cmap;
|
|
91
|
+
|
|
92
|
+
if (format === 12) {
|
|
93
|
+
const nGroups = u32(cmap, offset + 12);
|
|
94
|
+
let lo = 0;
|
|
95
|
+
let hi = nGroups - 1;
|
|
96
|
+
while (lo <= hi) {
|
|
97
|
+
const mid = (lo + hi) >> 1;
|
|
98
|
+
const g = offset + 16 + mid * 12;
|
|
99
|
+
const start = u32(cmap, g);
|
|
100
|
+
const end = u32(cmap, g + 4);
|
|
101
|
+
if (cp < start) hi = mid - 1;
|
|
102
|
+
else if (cp > end) lo = mid + 1;
|
|
103
|
+
else return u32(cmap, g + 8) + (cp - start);
|
|
104
|
+
}
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Format 4: segmented into 16-bit ranges.
|
|
109
|
+
const segX2 = u16(cmap, offset + 6);
|
|
110
|
+
const segCount = segX2 / 2;
|
|
111
|
+
const endAt = offset + 14;
|
|
112
|
+
const startAt = endAt + segX2 + 2;
|
|
113
|
+
const deltaAt = startAt + segX2;
|
|
114
|
+
const rangeAt = deltaAt + segX2;
|
|
115
|
+
for (let s = 0; s < segCount; s++) {
|
|
116
|
+
if (cp > u16(cmap, endAt + s * 2)) continue;
|
|
117
|
+
const start = u16(cmap, startAt + s * 2);
|
|
118
|
+
if (cp < start) return 0;
|
|
119
|
+
const delta = i16(cmap, deltaAt + s * 2);
|
|
120
|
+
const rangeOffset = u16(cmap, rangeAt + s * 2);
|
|
121
|
+
if (rangeOffset === 0) return (cp + delta) & 0xffff;
|
|
122
|
+
const glyphAt = rangeAt + s * 2 + rangeOffset + (cp - start) * 2;
|
|
123
|
+
const g = u16(cmap, glyphAt);
|
|
124
|
+
return g === 0 ? 0 : (g + delta) & 0xffff;
|
|
125
|
+
}
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Advance width of a glyph, in font units. */
|
|
130
|
+
advance(gid) {
|
|
131
|
+
const hmtx = this.table('hmtx');
|
|
132
|
+
const n = Math.min(gid, this.numHMetrics - 1);
|
|
133
|
+
return u16(hmtx, n * 4);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_locaTable() {
|
|
137
|
+
if (this._loca) return this._loca;
|
|
138
|
+
const buf = this.table('loca');
|
|
139
|
+
const n = this.numGlyphs + 1;
|
|
140
|
+
const out = new Uint32Array(n);
|
|
141
|
+
for (let i = 0; i < n; i++) {
|
|
142
|
+
out[i] = this.indexToLocFormat === 0 ? u16(buf, i * 2) * 2 : u32(buf, i * 4);
|
|
143
|
+
}
|
|
144
|
+
this._loca = out;
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The contours of a glyph in font units, with y pointing UP.
|
|
150
|
+
*
|
|
151
|
+
* Each contour is an array of points `{x, y, on}` where `on` marks a point on the curve; consecutive
|
|
152
|
+
* off-curve points imply a quadratic control point between them, which is how TrueType stores curves.
|
|
153
|
+
* Composite glyphs are resolved by recursing into their components.
|
|
154
|
+
*/
|
|
155
|
+
outline(gid, depth = 0) {
|
|
156
|
+
if (this._outlineCache.has(gid)) return this._outlineCache.get(gid);
|
|
157
|
+
const contours = this._readGlyph(gid, depth);
|
|
158
|
+
this._outlineCache.set(gid, contours);
|
|
159
|
+
return contours;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_readGlyph(gid, depth) {
|
|
163
|
+
if (depth > 8 || gid >= this.numGlyphs) return [];
|
|
164
|
+
const loca = this._locaTable();
|
|
165
|
+
const glyf = this.table('glyf');
|
|
166
|
+
const start = loca[gid];
|
|
167
|
+
const end = loca[gid + 1];
|
|
168
|
+
if (end <= start) return []; // empty glyph, e.g. a space
|
|
169
|
+
|
|
170
|
+
const g = glyf.subarray(start, end);
|
|
171
|
+
const numberOfContours = i16(g, 0);
|
|
172
|
+
|
|
173
|
+
if (numberOfContours < 0) return this._readComposite(g, depth);
|
|
174
|
+
return this._readSimple(g, numberOfContours);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_readSimple(g, numberOfContours) {
|
|
178
|
+
const endPts = [];
|
|
179
|
+
for (let i = 0; i < numberOfContours; i++) endPts.push(u16(g, 10 + i * 2));
|
|
180
|
+
const nPoints = endPts.length ? endPts[endPts.length - 1] + 1 : 0;
|
|
181
|
+
if (nPoints === 0) return [];
|
|
182
|
+
|
|
183
|
+
let p = 10 + numberOfContours * 2;
|
|
184
|
+
const instructionLength = u16(g, p);
|
|
185
|
+
p += 2 + instructionLength;
|
|
186
|
+
|
|
187
|
+
// Flags, with the repeat bit collapsing runs of identical ones.
|
|
188
|
+
const flags = new Uint8Array(nPoints);
|
|
189
|
+
for (let i = 0; i < nPoints;) {
|
|
190
|
+
const f = g[p++];
|
|
191
|
+
flags[i++] = f;
|
|
192
|
+
if (f & 0x08) {
|
|
193
|
+
let repeat = g[p++];
|
|
194
|
+
while (repeat-- > 0 && i < nPoints) flags[i++] = f;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// X coordinates.
|
|
199
|
+
const xs = new Int32Array(nPoints);
|
|
200
|
+
let x = 0;
|
|
201
|
+
for (let i = 0; i < nPoints; i++) {
|
|
202
|
+
const f = flags[i];
|
|
203
|
+
if (f & 0x02) {
|
|
204
|
+
const d = g[p++];
|
|
205
|
+
x += (f & 0x10) ? d : -d;
|
|
206
|
+
} else if (!(f & 0x10)) {
|
|
207
|
+
x += i16(g, p);
|
|
208
|
+
p += 2;
|
|
209
|
+
}
|
|
210
|
+
xs[i] = x;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Y coordinates.
|
|
214
|
+
const ys = new Int32Array(nPoints);
|
|
215
|
+
let y = 0;
|
|
216
|
+
for (let i = 0; i < nPoints; i++) {
|
|
217
|
+
const f = flags[i];
|
|
218
|
+
if (f & 0x04) {
|
|
219
|
+
const d = g[p++];
|
|
220
|
+
y += (f & 0x20) ? d : -d;
|
|
221
|
+
} else if (!(f & 0x20)) {
|
|
222
|
+
y += i16(g, p);
|
|
223
|
+
p += 2;
|
|
224
|
+
}
|
|
225
|
+
ys[i] = y;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const contours = [];
|
|
229
|
+
let first = 0;
|
|
230
|
+
for (const last of endPts) {
|
|
231
|
+
const pts = [];
|
|
232
|
+
for (let i = first; i <= last; i++) pts.push({ x: xs[i], y: ys[i], on: !!(flags[i] & 1) });
|
|
233
|
+
if (pts.length) contours.push(pts);
|
|
234
|
+
first = last + 1;
|
|
235
|
+
}
|
|
236
|
+
return contours;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_readComposite(g, depth) {
|
|
240
|
+
const out = [];
|
|
241
|
+
let p = 10;
|
|
242
|
+
for (;;) {
|
|
243
|
+
const flags = u16(g, p);
|
|
244
|
+
const glyphIndex = u16(g, p + 2);
|
|
245
|
+
p += 4;
|
|
246
|
+
|
|
247
|
+
let dx = 0;
|
|
248
|
+
let dy = 0;
|
|
249
|
+
if (flags & 0x0001) {
|
|
250
|
+
// ARG_1_AND_2_ARE_WORDS
|
|
251
|
+
dx = i16(g, p); dy = i16(g, p + 2); p += 4;
|
|
252
|
+
} else {
|
|
253
|
+
dx = (g[p] << 24) >> 24; dy = (g[p + 1] << 24) >> 24; p += 2;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let a = 1, b = 0, c = 0, d = 1;
|
|
257
|
+
if (flags & 0x0008) {
|
|
258
|
+
a = d = i16(g, p) / 16384; p += 2;
|
|
259
|
+
} else if (flags & 0x0040) {
|
|
260
|
+
a = i16(g, p) / 16384; d = i16(g, p + 2) / 16384; p += 4;
|
|
261
|
+
} else if (flags & 0x0080) {
|
|
262
|
+
a = i16(g, p) / 16384; b = i16(g, p + 2) / 16384;
|
|
263
|
+
c = i16(g, p + 4) / 16384; d = i16(g, p + 6) / 16384;
|
|
264
|
+
p += 8;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ARGS_ARE_XY_VALUES: dx/dy are offsets. Otherwise they are point indices, which no math glyph uses.
|
|
268
|
+
for (const contour of this._readGlyph(glyphIndex, depth + 1)) {
|
|
269
|
+
out.push(contour.map((pt) => ({
|
|
270
|
+
x: a * pt.x + c * pt.y + dx,
|
|
271
|
+
y: b * pt.x + d * pt.y + dy,
|
|
272
|
+
on: pt.on,
|
|
273
|
+
})));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!(flags & 0x0020)) break; // MORE_COMPONENTS
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Load a font from disk. */
|
|
283
|
+
export function loadFont(file) {
|
|
284
|
+
return new TrueTypeFont(fs.readFileSync(file));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The bundled formula font. It is ~900 KB, so it is read on the first formula that needs it, never at startup. */
|
|
288
|
+
export const BUNDLED_FONT = new URL('./fonts/STIX2Math.ttf', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
|
|
289
|
+
|
|
290
|
+
export function loadBundledFont() {
|
|
291
|
+
return loadFont(BUNDLED_FONT);
|
|
292
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
The formula font in this directory is STIX Two Math (TrueType build, "STIX Two Math TT").
|
|
2
|
+
It is distributed under the SIL Open Font License, Version 1.1.
|
|
3
|
+
|
|
4
|
+
Copyright:
|
|
5
|
+
Copyright (c) 2001-2016 by the STI Pub Companies, consisting of the American Chemical Society, the American Institute of Physics, the American Mathematical Society, the American Physical Society, Elsevier, Inc., and The Institute of Electrical and Electronic Engineers, Inc. Portions copyright (c) 1998-2003 by MicroPress, Inc. Portions copyright (c) 1990 by Elsevier, Inc. All rights reserved. Portions copyright (c) Copyright 2010, 2012, 2014 Adobe Systems Incorporated.
|
|
6
|
+
|
|
7
|
+
License:
|
|
8
|
+
As a condition for receiving these fonts at no charge, each person downloading the fonts must agree to some simple license terms. The license is based on the SIL Open Font License <http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL>. The SIL License is a free and open source license specifically designed for fonts and related software. The basic terms are that the recipient will not remove the copyright and trademark statements from the fonts and that, if the person decides to create a derivative work based on the STIX Fonts but incorporating some changes or enhancements, the derivative work ("Modified Version") will carry a different name. The copyright and trademark restrictions are part of the agreement between the STI Pub companies and the typeface designer. The "renaming" restriction results from the desire of the STI Pub companies to assure that the STIX Fonts will continue to function in a predictable fashion for all that use them. No copy of one or more of the individual Font typefaces that form the STIX Fonts(TM) set may be sold by itself, but other than this one restriction, licensees are free to sell the fonts either separately or as part of a package that combines other software or fonts with this font set.
|
|
9
|
+
|
|
10
|
+
License URL:
|
|
11
|
+
http://www.stixfonts.org/user_license.html
|
|
Binary file
|
package/src/tex/index.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public face of the bitmap renderer: the policy that decides whether a formula is drawn as a picture,
|
|
3
|
+
* how large its raster is, and what to do when it cannot be drawn at all.
|
|
4
|
+
*
|
|
5
|
+
* The pieces below it know nothing about terminals. `parse.js` builds the node tree, `layout.js` measures it
|
|
6
|
+
* in font units, `render.js` turns it into RGBA and `sixel.js` encodes that for the terminal. This module is
|
|
7
|
+
* where the terminal's facts arrive — its protocol and its cell size — so the same formula lands on whole
|
|
8
|
+
* cells on every display.
|
|
9
|
+
*
|
|
10
|
+
* The bundled face is STIX Two Math: it carries the repertoire this pipeline uses (fractions, big operators
|
|
11
|
+
* with limits, radicals, braces), so a formula typeset here looks like the one in a paper rather than like
|
|
12
|
+
* assembled ASCII.
|
|
13
|
+
*/
|
|
14
|
+
import { loadBundledFont } from './font.js';
|
|
15
|
+
import { parseTex } from './parse.js';
|
|
16
|
+
import { layout } from './layout.js';
|
|
17
|
+
import { renderBox } from './render.js';
|
|
18
|
+
import { encodeSixel } from './sixel.js';
|
|
19
|
+
import { kittyTransmit, kittyPlace, kittyImageId } from './kitty.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ex per cell height. A formula is set to the same size as the text around it by matching the x-HEIGHT, not
|
|
23
|
+
* the em: a terminal cell is about twice as tall as it is wide, and a font's em is roughly twice its
|
|
24
|
+
* x-height, so one ex per cell height lands the two on the same scale. dsh-tui uses 0.5 of the cell height
|
|
25
|
+
* for body text and 0.72 for display math (src/math/layout.ts there); 0.55 is the display value here.
|
|
26
|
+
*/
|
|
27
|
+
const EX_PER_CELL_HEIGHT = 0.55;
|
|
28
|
+
/** Margin around the ink, in pixels. A bracket's arms sit exactly on the box edge, so this is not optional. */
|
|
29
|
+
const PAD = 4;
|
|
30
|
+
/** A formula taller than this many rows is a matrix to scroll, not a picture to paint. */
|
|
31
|
+
const MAX_ROWS = 12;
|
|
32
|
+
/** Cells to measure with when the terminal never reported its cell size (XTWINOPS 16). */
|
|
33
|
+
const FALLBACK_CELL = { w: 10, h: 20 };
|
|
34
|
+
|
|
35
|
+
/** The font is ~900 KB, so it is read on the first formula that needs it, never at startup. */
|
|
36
|
+
let fontCache = null;
|
|
37
|
+
/** The font's x-height as a fraction of its em, measured from the `x` glyph rather than assumed. */
|
|
38
|
+
let exRatio = null;
|
|
39
|
+
|
|
40
|
+
function font() {
|
|
41
|
+
if (!fontCache) fontCache = loadBundledFont();
|
|
42
|
+
return fontCache;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Fraction of the em occupied by the x-height. Measured once, from the glyph itself. */
|
|
46
|
+
function xHeightRatio(face) {
|
|
47
|
+
if (exRatio !== null) return exRatio;
|
|
48
|
+
let top = 0;
|
|
49
|
+
const gid = face.glyphIndex('x'.codePointAt(0));
|
|
50
|
+
for (const contour of face.outline(gid)) for (const p of contour) if (p.y > top) top = p.y;
|
|
51
|
+
// A font with no usable `x` falls back to a typical maths face's x-height.
|
|
52
|
+
exRatio = top > 0 ? top / face.unitsPerEm : 0.47;
|
|
53
|
+
return exRatio;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Laid-out boxes, keyed by source: parsing and laying out is the expensive half and is size-independent. */
|
|
57
|
+
const layoutCache = new Map();
|
|
58
|
+
const LAYOUT_CACHE_MAX = 128;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether the parse kept a command as literal source text.
|
|
62
|
+
*
|
|
63
|
+
* `parse.js` does not throw on a command it does not know: it emits the command as an atom so nothing is
|
|
64
|
+
* lost. For a BITMAP that is the wrong answer — the picture would spell `\notacommand` in STIX glyphs — so a
|
|
65
|
+
* formula carrying one is declined and the caller falls back to the text form, which shows the same source
|
|
66
|
+
* in the code style the reader expects.
|
|
67
|
+
*/
|
|
68
|
+
function hasUnknownCommand(nodes) {
|
|
69
|
+
for (const node of nodes || []) {
|
|
70
|
+
if (!node || typeof node !== 'object') continue;
|
|
71
|
+
if (node.cls === 'raw') return true;
|
|
72
|
+
if (Array.isArray(node) && hasUnknownCommand(node)) return true;
|
|
73
|
+
for (const key of ['body', 'num', 'den', 'index', 'scripts', 'rows']) {
|
|
74
|
+
const value = node[key];
|
|
75
|
+
if (!value) continue;
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
if (hasUnknownCommand(value)) return true;
|
|
78
|
+
if (value.some((row) => Array.isArray(row) && hasUnknownCommand(row))) return true;
|
|
79
|
+
} else if (typeof value === 'object') {
|
|
80
|
+
if (hasUnknownCommand(value.sup) || hasUnknownCommand(value.sub)) return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function layoutOf(tex) {
|
|
88
|
+
const hit = layoutCache.get(tex);
|
|
89
|
+
if (hit !== undefined) return hit;
|
|
90
|
+
let box = null;
|
|
91
|
+
try {
|
|
92
|
+
const nodes = parseTex(tex);
|
|
93
|
+
box = hasUnknownCommand(nodes) ? null : layout(nodes, font(), 1000);
|
|
94
|
+
if (box && !(box.width > 0 && box.height + box.depth > 0)) box = null;
|
|
95
|
+
} catch {
|
|
96
|
+
box = null;
|
|
97
|
+
}
|
|
98
|
+
if (layoutCache.size >= LAYOUT_CACHE_MAX) {
|
|
99
|
+
const oldest = layoutCache.keys().next().value;
|
|
100
|
+
if (oldest !== undefined) layoutCache.delete(oldest);
|
|
101
|
+
}
|
|
102
|
+
layoutCache.set(tex, box);
|
|
103
|
+
return box;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The cell box a formula occupies at its natural size, without rasterising it.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} tex TeX without delimiters.
|
|
110
|
+
* @param {{cell?: {w: number, h: number}}} [opts] The terminal's cell size in pixels.
|
|
111
|
+
* @returns {{columns: number, rows: number, px: number, width: number, height: number}|null} Null when the
|
|
112
|
+
* TeX is outside the subset.
|
|
113
|
+
*/
|
|
114
|
+
export function measureTex(tex, opts = {}) {
|
|
115
|
+
const box = layoutOf(tex);
|
|
116
|
+
if (!box) return null;
|
|
117
|
+
const cell = opts.cell && opts.cell.w > 0 && opts.cell.h > 0 ? opts.cell : FALLBACK_CELL;
|
|
118
|
+
const face = font();
|
|
119
|
+
// One ex is this many pixels, and the em the rasteriser wants is the em that PRODUCES that x-height.
|
|
120
|
+
const exPx = Math.max(3, cell.h * EX_PER_CELL_HEIGHT);
|
|
121
|
+
const px = Math.max(6, Math.round(exPx / xHeightRatio(face)));
|
|
122
|
+
// Round UP: a picture a pixel taller than its box must claim the extra row rather than be clipped.
|
|
123
|
+
const width = Math.ceil(box.width * px / face.unitsPerEm) + PAD * 2;
|
|
124
|
+
const height = Math.ceil((box.height + box.depth) * px / face.unitsPerEm) + PAD * 2;
|
|
125
|
+
return {
|
|
126
|
+
columns: Math.max(1, Math.ceil(width / cell.w)),
|
|
127
|
+
rows: Math.max(1, Math.ceil(height / cell.h)),
|
|
128
|
+
px,
|
|
129
|
+
width,
|
|
130
|
+
height,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Rendered rasters, keyed by source + size + colour. A formula is redrawn on every frame it stays on screen. */
|
|
135
|
+
const rasterCache = new Map();
|
|
136
|
+
const RASTER_CACHE_MAX = 48;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Encoded Sixel sequences, keyed by the raster they came from.
|
|
140
|
+
*
|
|
141
|
+
* The raster cache saves the layout and the glyph rasterising; this one saves the ENCODING, which walks every
|
|
142
|
+
* pixel again with a palette pass and run-length coding. Scrolling moves a formula up and down without changing
|
|
143
|
+
* what it depicts, so without this the same picture was re-encoded on every frame of a scroll.
|
|
144
|
+
*/
|
|
145
|
+
const sequenceCache = new Map();
|
|
146
|
+
const SEQUENCE_CACHE_MAX = 64;
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
/** Below this fraction of the natural size a formula is unreadable; the caller keeps the text form instead. */
|
|
150
|
+
const MIN_SCALE = 0.55;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Render a formula as an RGBA image, shrunk uniformly when it would not fit.
|
|
154
|
+
*
|
|
155
|
+
* The size policy is dsh-tui's (src/math/layout.ts there): set the formula at ONE base size tied to the
|
|
156
|
+
* terminal's cell, then shrink it UNIFORMLY — never stretch it, never enlarge it — only as far as it must to
|
|
157
|
+
* fit the columns and rows it is allowed. Below a legibility floor the picture is refused, so the caller
|
|
158
|
+
* falls back to text instead of drawing something unreadable.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} tex TeX without delimiters.
|
|
161
|
+
* @param {object} [opts]
|
|
162
|
+
* @param {{w: number, h: number}} [opts.cell] The terminal's cell size in pixels.
|
|
163
|
+
* @param {{r:number,g:number,b:number}} [opts.colour] Ink colour.
|
|
164
|
+
* @param {number} [opts.maxWidth] Columns the picture may occupy.
|
|
165
|
+
* @param {number} [opts.maxRows] Rows the picture may occupy.
|
|
166
|
+
* @returns {{rgba: Uint8ClampedArray, width: number, height: number, columns: number, rows: number}|null}
|
|
167
|
+
*/
|
|
168
|
+
export function renderTexImage(tex, opts = {}) {
|
|
169
|
+
const cell = opts.cell && opts.cell.w > 0 && opts.cell.h > 0 ? opts.cell : FALLBACK_CELL;
|
|
170
|
+
const natural = measureTex(tex, { cell });
|
|
171
|
+
if (!natural) return null;
|
|
172
|
+
const maxColumns = opts.maxWidth || Infinity;
|
|
173
|
+
const maxRows = Math.min(MAX_ROWS, opts.maxRows || MAX_ROWS);
|
|
174
|
+
|
|
175
|
+
// Shrink by whichever axis overflows, and only by the amount needed.
|
|
176
|
+
const fit = Math.min(
|
|
177
|
+
natural.columns > maxColumns ? maxColumns / natural.columns : 1,
|
|
178
|
+
natural.rows > maxRows ? maxRows / natural.rows : 1,
|
|
179
|
+
);
|
|
180
|
+
if (fit < MIN_SCALE) return null;
|
|
181
|
+
const px = Math.max(6, Math.round(natural.px * fit));
|
|
182
|
+
// Measured again at the FINAL size, so the cells claimed are the cells the raster covers.
|
|
183
|
+
const box = layoutOf(tex);
|
|
184
|
+
const em = font().unitsPerEm;
|
|
185
|
+
const columns = Math.max(1, Math.ceil((Math.ceil(box.width * px / em) + PAD * 2) / cell.w));
|
|
186
|
+
const rows = Math.max(1, Math.ceil((Math.ceil((box.height + box.depth) * px / em) + PAD * 2) / cell.h));
|
|
187
|
+
if (columns > maxColumns || rows > maxRows) return null;
|
|
188
|
+
|
|
189
|
+
const colour = opts.colour || { r: 220, g: 220, b: 220 };
|
|
190
|
+
// A crop is part of a raster's identity: the same formula cut at a different pixel row is a different
|
|
191
|
+
// picture, and serving the uncropped one from the cache would put it back where it started.
|
|
192
|
+
const skipTop = Math.max(0, Math.round(opts.skipTop || 0));
|
|
193
|
+
// `maxHeight` is an exact PIXEL limit, not a row limit: it is what a picture clipped by the transcript panel
|
|
194
|
+
// needs, and it has to cut the raster itself — a band crop cannot express it.
|
|
195
|
+
const maxHeight = opts.maxHeight > 0 ? Math.round(opts.maxHeight) : 0;
|
|
196
|
+
const key = [tex, px, skipTop, maxHeight, colour.r, colour.g, colour.b].join('\u0000');
|
|
197
|
+
const hit = rasterCache.get(key);
|
|
198
|
+
if (hit) return hit;
|
|
199
|
+
|
|
200
|
+
let image = null;
|
|
201
|
+
try {
|
|
202
|
+
const rendered = renderBox(box, font(), { px, colour, padding: PAD, skipTop, maxHeight });
|
|
203
|
+
// The claimed cell box shrinks with the crop: what is left is what gets drawn.
|
|
204
|
+
const drawnRows = Math.max(1, Math.ceil(rendered.height / cell.h));
|
|
205
|
+
image = { ...rendered, columns, rows: drawnRows };
|
|
206
|
+
} catch {
|
|
207
|
+
image = null;
|
|
208
|
+
}
|
|
209
|
+
if (rasterCache.size >= RASTER_CACHE_MAX) {
|
|
210
|
+
const oldest = rasterCache.keys().next().value;
|
|
211
|
+
if (oldest !== undefined) rasterCache.delete(oldest);
|
|
212
|
+
}
|
|
213
|
+
rasterCache.set(key, image);
|
|
214
|
+
return image;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A formula as the terminal sequence that draws it, with the cell box it covers.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} tex TeX without delimiters.
|
|
221
|
+
* @param {object} opts As `renderTexImage`, plus `protocol` ('sixel' or 'kitty').
|
|
222
|
+
* @returns {{sequence: string, width: number, height: number, columns: number, rows: number, kittyId?: number,
|
|
223
|
+
* kittyPlacement?: string}|null}
|
|
224
|
+
*/
|
|
225
|
+
export function encodeTexImage(tex, opts = {}) {
|
|
226
|
+
const image = renderTexImage(tex, opts);
|
|
227
|
+
if (!image) return null;
|
|
228
|
+
const protocol = opts.protocol || 'sixel';
|
|
229
|
+
if (protocol !== 'sixel' && protocol !== 'kitty') return null;
|
|
230
|
+
const colour = opts.colour || { r: 220, g: 220, b: 220 };
|
|
231
|
+
|
|
232
|
+
// THE SEQUENCE IS CACHED TOO, keyed by the raster it comes from.
|
|
233
|
+
//
|
|
234
|
+
// Encoding is the expensive half of the pipeline — a palette pass for Sixel, a base64 stream for Kitty —
|
|
235
|
+
// and it was paid on EVERY frame even when the raster itself came from a cache. Scrolling changes a
|
|
236
|
+
// placement's ROW, not the picture it draws, so the same formula was re-encoded dozens of times while the
|
|
237
|
+
// view moved. The key is the raster's own identity, so a hit can only return bytes for exactly these pixels.
|
|
238
|
+
const key = `${protocol}\u0000${image.width}x${image.height}\u0000${tex}\u0000${Math.round(opts.skipTop || 0)}`
|
|
239
|
+
+ `\u0000${colour.r},${colour.g},${colour.b}`;
|
|
240
|
+
const hit = sequenceCache.get(key);
|
|
241
|
+
if (hit) {
|
|
242
|
+
sequenceCache.delete(key); // refresh its position: the least recently used goes first
|
|
243
|
+
sequenceCache.set(key, hit);
|
|
244
|
+
return { ...hit, columns: image.columns, rows: image.rows };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// KITTY IS TWO PARTS, AND ONLY ONE OF THEM IS RESENT. `kittyId` owns the uploaded pixels; `kittyPlacement`
|
|
248
|
+
// is the twenty-byte command that puts them at a cell. A frame that only moves a picture re-sends the
|
|
249
|
+
// placement and leaves the pixels alone — which is the whole point of the protocol.
|
|
250
|
+
const built = protocol === 'kitty'
|
|
251
|
+
? {
|
|
252
|
+
sequence: kittyTransmit(kittyImageId(image.rgba, image.width, image.height), image.rgba, image.width, image.height),
|
|
253
|
+
kittyId: kittyImageId(image.rgba, image.width, image.height),
|
|
254
|
+
kittyPlacement: (x, y, columns, rows) => kittyPlace(kittyImageId(image.rgba, image.width, image.height), 1, x, y, columns, rows),
|
|
255
|
+
width: image.width,
|
|
256
|
+
height: image.height,
|
|
257
|
+
columns: image.columns,
|
|
258
|
+
rows: image.rows,
|
|
259
|
+
}
|
|
260
|
+
: {
|
|
261
|
+
// A FORMULA IS LINE ART: one ink colour, and the raster is its antialiased coverage. Encoding that as a
|
|
262
|
+
// grey ramp dissolves hairline strokes into speckle once the terminal fits it, so the coverage is
|
|
263
|
+
// promoted to a one-bit mask in that single ink colour — the rule dsh-tui applies for the same reason
|
|
264
|
+
// (src/ink/sixel-codec.ts, `lineArt`).
|
|
265
|
+
sequence: encodeSixel(image.rgba, image.width, image.height, {
|
|
266
|
+
transparent: true,
|
|
267
|
+
lineArt: true,
|
|
268
|
+
ink: colour,
|
|
269
|
+
}),
|
|
270
|
+
width: image.width,
|
|
271
|
+
height: image.height,
|
|
272
|
+
columns: image.columns,
|
|
273
|
+
rows: image.rows,
|
|
274
|
+
};
|
|
275
|
+
sequenceCache.set(key, built);
|
|
276
|
+
while (sequenceCache.size > SEQUENCE_CACHE_MAX) {
|
|
277
|
+
const oldest = sequenceCache.keys().next().value;
|
|
278
|
+
if (oldest === undefined) break;
|
|
279
|
+
sequenceCache.delete(oldest);
|
|
280
|
+
}
|
|
281
|
+
return built;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
/** Drop the font and the caches; for tests and for a settings change. */
|
|
286
|
+
export function resetTexRenderer() {
|
|
287
|
+
fontCache = null;
|
|
288
|
+
layoutCache.clear();
|
|
289
|
+
rasterCache.clear();
|
|
290
|
+
sequenceCache.clear();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export { MAX_ROWS as TEX_MAX_ROWS };
|