@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/kitty.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RGBA → a Kitty graphics sequence.
|
|
3
|
+
*
|
|
4
|
+
* The Kitty protocol carries an image in two parts, and they are kept apart on purpose:
|
|
5
|
+
*
|
|
6
|
+
* a=t TRANSMIT. Uploads the pixels once and gives them an id. Nothing appears on screen.
|
|
7
|
+
* a=p PLACE. Puts an already-uploaded image at a cell, at a size in cells, croppable, with a z-index.
|
|
8
|
+
*
|
|
9
|
+
* That split is the whole reason a formula is cheap to scroll: the pixels are uploaded once and every frame
|
|
10
|
+
* after that is a twenty-byte placement. Re-uploading on every frame would move bytes the terminal already has
|
|
11
|
+
* and, on some terminals, redraw the picture mid-frame.
|
|
12
|
+
*
|
|
13
|
+
* The image is sent as RAW RGBA (`f=32`), not PNG: there is no image encoder in this project and none is
|
|
14
|
+
* needed — `a=t,f=32` is a documented format, and `o=z` lets zlib shrink it. `q=2` keeps the terminal from
|
|
15
|
+
* answering every chunk; `q=0` would make it answer each one.
|
|
16
|
+
*
|
|
17
|
+
* CHUNKING IS MANDATORY. A single APC cannot exceed a few kilobytes on most terminals, so the base64 is split
|
|
18
|
+
* and every chunk but the last says `m=1` (more coming). A chunk that is split wrong, or a stream that loses
|
|
19
|
+
* its `m` flag, leaves the terminal in the middle of an escape sequence.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The APC introducer every Kitty graphics command starts with. */
|
|
23
|
+
const APC = '\u001b_G';
|
|
24
|
+
/** String terminator. */
|
|
25
|
+
const ST = '\u001b\\';
|
|
26
|
+
/**
|
|
27
|
+
* Base64 characters per chunk. The protocol allows 4096 bytes of payload per chunk; 4096 base64 characters is
|
|
28
|
+
* 3072 bytes of input, comfortably inside that.
|
|
29
|
+
*/
|
|
30
|
+
const CHUNK = 4096;
|
|
31
|
+
/** Behind text and panel backgrounds: a formula must never cover the interface drawn over it. */
|
|
32
|
+
const Z_INDEX = -0x80000000;
|
|
33
|
+
|
|
34
|
+
/** One Kitty graphics command: the control key=value list, then a `;`, then the payload. */
|
|
35
|
+
function command(control, payload = '') {
|
|
36
|
+
return APC + control + (payload ? ';' + payload : '') + ST;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Upload an RGBA raster under an image id.
|
|
41
|
+
*
|
|
42
|
+
* @param {number} id The image id. Must be stable for the same pixels so placements can reuse it.
|
|
43
|
+
* @param {Uint8ClampedArray|Uint8Array} rgba Pixels, width*height*4, straight alpha.
|
|
44
|
+
* @param {number} width
|
|
45
|
+
* @param {number} height
|
|
46
|
+
* @returns {string} The sequence, ready to write.
|
|
47
|
+
*/
|
|
48
|
+
export function kittyTransmit(id, rgba, width, height) {
|
|
49
|
+
const w = Math.max(0, width | 0);
|
|
50
|
+
const h = Math.max(0, height | 0);
|
|
51
|
+
if (!w || !h) return '';
|
|
52
|
+
const base64 = Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength).toString('base64');
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for (let at = 0; at < base64.length; at += CHUNK) chunks.push(base64.slice(at, at + CHUNK));
|
|
55
|
+
if (chunks.length === 0) chunks.push('');
|
|
56
|
+
return chunks.map((chunk, i) => {
|
|
57
|
+
const more = i + 1 < chunks.length ? 1 : 0;
|
|
58
|
+
// The first chunk carries the format, the size and the id; the rest carry only the continuation flag.
|
|
59
|
+
const control = i === 0
|
|
60
|
+
? `a=t,t=d,f=32,s=${w},v=${h},i=${id},q=2,m=${more}`
|
|
61
|
+
: `m=${more},q=2`;
|
|
62
|
+
return command(control, chunk);
|
|
63
|
+
}).join('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Place an uploaded image at a cell.
|
|
68
|
+
*
|
|
69
|
+
* @param {number} id Image id from `kittyTransmit`.
|
|
70
|
+
* @param {number} placementId Distinguishes several placements of the same image.
|
|
71
|
+
* @param {number} x Column, 0-based.
|
|
72
|
+
* @param {number} y Row, 0-based.
|
|
73
|
+
* @param {number} columns Width in cells.
|
|
74
|
+
* @param {number} rows Height in cells.
|
|
75
|
+
* @param {{x:number, y:number, width:number, height:number}} [source] Pixel rectangle of the raster to show.
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
export function kittyPlace(id, placementId, x, y, columns, rows, source) {
|
|
79
|
+
const crop = source
|
|
80
|
+
? `,x=${Math.max(0, source.x | 0)},y=${Math.max(0, source.y | 0)},w=${Math.max(1, source.width | 0)},h=${Math.max(1, source.height | 0)}`
|
|
81
|
+
: '';
|
|
82
|
+
// `C=1` leaves the cursor where it is afterwards: a placement must not move it, or every byte after is
|
|
83
|
+
// positioned relative to somewhere nobody expected.
|
|
84
|
+
return `\x1b[${y + 1};${x + 1}H`
|
|
85
|
+
+ command(`a=p,i=${id},p=${placementId},c=${Math.max(1, columns | 0)},r=${Math.max(1, rows | 0)}${crop},z=${Z_INDEX},C=1,q=1`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Delete a placement, leaving the image data uploaded so it can be placed again.
|
|
90
|
+
*
|
|
91
|
+
* @param {number} id
|
|
92
|
+
* @param {number} placementId
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function kittyDeletePlacement(id, placementId) {
|
|
96
|
+
return command(`a=d,d=i,i=${id},p=${placementId},q=2`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Delete the image data itself, for a picture that will not be seen again.
|
|
101
|
+
*
|
|
102
|
+
* @param {number} id
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
export function kittyDeleteImage(id) {
|
|
106
|
+
return command(`a=d,d=I,i=${id},q=2`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether a chunk of terminal input is a Kitty graphics reply rather than something to paint.
|
|
111
|
+
*
|
|
112
|
+
* The terminal answers with its own APC: `ESC _ G i=<id>,p=<placement>,... ESC \`. A reply nothing consumes
|
|
113
|
+
* would be printed as text — the `Gi=31;OK` that shows up in the middle of the interface when a transmission
|
|
114
|
+
* is acknowledged and no one is listening.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} text
|
|
117
|
+
* @returns {boolean}
|
|
118
|
+
*/
|
|
119
|
+
export function isKittyReply(text) {
|
|
120
|
+
return typeof text === 'string' && /^\u001b_G[\s\S]*\u001b\\$/.test(text);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read the fields of a Kitty reply.
|
|
125
|
+
*
|
|
126
|
+
* A reply body is `G` followed by the id and optional placement, then `;` and the status:
|
|
127
|
+
*
|
|
128
|
+
* Gi=31;OK
|
|
129
|
+
* Gi=7,p=2;ENOENT: image data not found
|
|
130
|
+
*
|
|
131
|
+
* The STATUS lives after the semicolon, at the end of the LAST field — taking it from the first field would
|
|
132
|
+
* read `i=31` and call a failure a success.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} text A string that satisfies `isKittyReply`.
|
|
135
|
+
* @returns {{id:number|null, placement:number|null, ok:boolean, error:string}}
|
|
136
|
+
*/
|
|
137
|
+
export function parseKittyReply(text) {
|
|
138
|
+
const body = String(text).slice(APC.length, -ST.length);
|
|
139
|
+
// Everything after the LAST `;` is the status; everything before it is the key list.
|
|
140
|
+
const semi = body.lastIndexOf(';');
|
|
141
|
+
const keys = semi >= 0 ? body.slice(0, semi) : body;
|
|
142
|
+
const status = (semi >= 0 ? body.slice(semi + 1) : '').trim();
|
|
143
|
+
const fields = {};
|
|
144
|
+
for (const part of keys.split(',')) {
|
|
145
|
+
const eq = part.indexOf('=');
|
|
146
|
+
if (eq < 0) continue;
|
|
147
|
+
fields[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
|
|
148
|
+
}
|
|
149
|
+
const id = fields.i !== undefined && fields.i !== '' ? Number(fields.i) : null;
|
|
150
|
+
const placement = fields.p !== undefined && fields.p !== '' ? Number(fields.p) : null;
|
|
151
|
+
return {
|
|
152
|
+
id: Number.isFinite(id) ? id : null,
|
|
153
|
+
placement: Number.isFinite(placement) ? placement : null,
|
|
154
|
+
ok: status === '' || status.toUpperCase() === 'OK',
|
|
155
|
+
error: status.toUpperCase() === 'OK' ? '' : status,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A stable image id for a raster.
|
|
161
|
+
*
|
|
162
|
+
* FNV-1a over the pixels, folded into the range the protocol reserves for images (1..0xFFFFFFFE). The same
|
|
163
|
+
* formula drawn at the same size always uploads under the same id, so scrolling never re-transmits it.
|
|
164
|
+
*
|
|
165
|
+
* @param {Uint8ClampedArray|Uint8Array} rgba
|
|
166
|
+
* @param {number} width
|
|
167
|
+
* @param {number} height
|
|
168
|
+
* @returns {number}
|
|
169
|
+
*/
|
|
170
|
+
export function kittyImageId(rgba, width, height) {
|
|
171
|
+
let h = 0x811c9dc5;
|
|
172
|
+
h = (h ^ (width & 0xff)) >>> 0;
|
|
173
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
174
|
+
h = (h ^ (height & 0xff)) >>> 0;
|
|
175
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
176
|
+
for (let i = 0; i < rgba.length; i += 4) {
|
|
177
|
+
h = (h ^ rgba[i]) >>> 0;
|
|
178
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
179
|
+
h = (h ^ rgba[i + 3]) >>> 0;
|
|
180
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
181
|
+
}
|
|
182
|
+
// 1..0xFFFFFFFF, never 0 (which the protocol reserves for "no id").
|
|
183
|
+
return (h >>> 0) || 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export { APC as KITTY_APC, ST as KITTY_ST };
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layout pass: node tree → measured boxes.
|
|
3
|
+
*
|
|
4
|
+
* Every box carries `width`, `height` (above the baseline) and `depth` (below it) in font units, plus the
|
|
5
|
+
* glyph placements that paint it. A row stacks its children left to right on a shared baseline; a fraction
|
|
6
|
+
* centres a bar between two shifted rows; a script raises or lowers a scaled-down row.
|
|
7
|
+
*
|
|
8
|
+
* The numbers follow TeX's own proportions closely enough that a formula reads correctly: a superscript is
|
|
9
|
+
* 0.7 of the base, a sub-subscript 0.55, a fraction's numerator is raised by its own depth plus a gap.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
FRAC_GAP, LIMIT_GAP, BIN_SPACE, PUNCT_SPACE,
|
|
13
|
+
SCRIPT_RATIO, SCRIPT_SCRIPT_RATIO, BINARY, RELATION,
|
|
14
|
+
} from './parse.js';
|
|
15
|
+
|
|
16
|
+
/** The advance and outline of one glyph, resolved through the font. */
|
|
17
|
+
function glyphFor(font, ch, size) {
|
|
18
|
+
const cp = ch.codePointAt(0);
|
|
19
|
+
const gid = font.glyphIndex(cp);
|
|
20
|
+
if (!gid) return null;
|
|
21
|
+
const units = font.advance(gid);
|
|
22
|
+
// The glyph's real ink extents differ from the em box: a radical's tail rises above the advance height,
|
|
23
|
+
// and a bar placed at the em height would sit below that tail instead of continuing it.
|
|
24
|
+
const ink = inkExtent(font, gid, size);
|
|
25
|
+
return {
|
|
26
|
+
kind: 'glyph',
|
|
27
|
+
ch,
|
|
28
|
+
gid,
|
|
29
|
+
size,
|
|
30
|
+
width: Math.round(units * size / font.unitsPerEm),
|
|
31
|
+
height: Math.round(font.ascender * size / font.unitsPerEm),
|
|
32
|
+
depth: Math.round(-font.descender * size / font.unitsPerEm),
|
|
33
|
+
inkTop: ink.top,
|
|
34
|
+
inkBottom: ink.bottom,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Measure a glyph's real ink extent, so a row's height is the ink it draws and not the em box. */
|
|
39
|
+
function inkExtent(font, gid, size) {
|
|
40
|
+
const outline = font.outline(gid);
|
|
41
|
+
if (!outline.length) return { top: 0, bottom: 0 };
|
|
42
|
+
let top = -Infinity;
|
|
43
|
+
let bottom = Infinity;
|
|
44
|
+
for (const contour of outline) {
|
|
45
|
+
for (const p of contour) {
|
|
46
|
+
if (p.y > top) top = p.y;
|
|
47
|
+
if (p.y < bottom) bottom = p.y;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (top === -Infinity) return { top: 0, bottom: 0 };
|
|
51
|
+
const k = size / font.unitsPerEm;
|
|
52
|
+
return { top: Math.round(top * k), bottom: Math.round(bottom * k) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function emptyBox() {
|
|
56
|
+
return { width: 0, height: 0, depth: 0, items: [] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hbox(items, width, height, depth) {
|
|
60
|
+
return { width, height, depth, items };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Lay out a list of nodes as a horizontal row on one baseline. */
|
|
64
|
+
function layoutRow(nodes, font, size, opts, ctx) {
|
|
65
|
+
const items = [];
|
|
66
|
+
let pen = 0;
|
|
67
|
+
let height = 0;
|
|
68
|
+
let depth = 0;
|
|
69
|
+
|
|
70
|
+
// Spacing depends on the pair of classes around each atom, which TeX decides by looking on both sides.
|
|
71
|
+
const clsOf = (node) => {
|
|
72
|
+
if (node.cls) return node.cls;
|
|
73
|
+
if (node.kind === 'atom') {
|
|
74
|
+
if (BINARY.has(node.value)) return 'bin';
|
|
75
|
+
if (RELATION.has(node.value)) return 'rel';
|
|
76
|
+
if (node.value === ',') return 'punct';
|
|
77
|
+
}
|
|
78
|
+
if (node.kind === 'bigop') return 'op';
|
|
79
|
+
return 'ord';
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let prevCls = 'ord';
|
|
83
|
+
let prevNode = null;
|
|
84
|
+
|
|
85
|
+
for (let idx = 0; idx < nodes.length; idx++) {
|
|
86
|
+
const node = nodes[idx];
|
|
87
|
+
const cls = clsOf(node);
|
|
88
|
+
|
|
89
|
+
// Space before, from the pair (previous, this). Relations and binaries are the two that open up.
|
|
90
|
+
if (prevNode !== null) {
|
|
91
|
+
if (cls === 'rel' || prevCls === 'rel') pen += Math.round(BIN_SPACE * 1.1 * size);
|
|
92
|
+
else if (cls === 'bin' || prevCls === 'bin') pen += Math.round(BIN_SPACE * size);
|
|
93
|
+
else if (cls === 'punct' && prevNode.kind === 'atom' && prevNode.value === ',') pen += Math.round(PUNCT_SPACE * size);
|
|
94
|
+
else if (cls === 'op') pen += Math.round(BIN_SPACE * 0.6 * size);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const box = layoutNode(node, font, size, opts, ctx);
|
|
98
|
+
if (!box) continue;
|
|
99
|
+
|
|
100
|
+
box.x = pen;
|
|
101
|
+
items.push(box);
|
|
102
|
+
pen += box.width;
|
|
103
|
+
if (box.height > height) height = box.height;
|
|
104
|
+
if (box.depth > depth) depth = box.depth;
|
|
105
|
+
|
|
106
|
+
prevCls = cls;
|
|
107
|
+
prevNode = node;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return hbox(items, pen, height, depth);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Lay out one node, with its scripts if it has any. */
|
|
114
|
+
function layoutNode(node, font, size, opts, ctx) {
|
|
115
|
+
if (!node) return null;
|
|
116
|
+
|
|
117
|
+
if (node.kind === 'space') {
|
|
118
|
+
return hbox([], Math.round(node.width * size), 0, 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (node.kind === 'align') {
|
|
122
|
+
return hbox([], Math.round(1.5 * size), 0, 0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (node.kind === 'atom') {
|
|
126
|
+
const base = node.value ? glyphFor(font, node.value, size) : emptyBox();
|
|
127
|
+
if (!base) return null;
|
|
128
|
+
if (!node.value) { base.height = 0; base.depth = 0; }
|
|
129
|
+
else {
|
|
130
|
+
const ink = inkExtent(font, base.gid, size);
|
|
131
|
+
base.height = Math.max(base.height, ink.top);
|
|
132
|
+
base.depth = Math.max(base.depth, -ink.bottom);
|
|
133
|
+
}
|
|
134
|
+
return attachScripts(base, node.scripts, font, size, opts, ctx);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (node.kind === 'delim') {
|
|
138
|
+
// Delimiters are drawn at the base size; growing them to the content needs MATH table data, which is
|
|
139
|
+
// out of scope — a slightly small bracket reads better than a mis-sized one.
|
|
140
|
+
const g = glyphFor(font, node.value, size);
|
|
141
|
+
if (!g) return null;
|
|
142
|
+
const ink = inkExtent(font, g.gid, size);
|
|
143
|
+
g.height = Math.max(g.height, ink.top);
|
|
144
|
+
g.depth = Math.max(g.depth, -ink.bottom);
|
|
145
|
+
return g;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (node.kind === 'row') {
|
|
149
|
+
const inner = layoutRow(node.body, font, size, opts, ctx);
|
|
150
|
+
return attachScripts(inner, node.scripts, font, size, opts, ctx);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (node.kind === 'frac') {
|
|
154
|
+
const numSize = Math.round(size * 0.85);
|
|
155
|
+
const denSize = Math.round(size * 0.85);
|
|
156
|
+
const num = layoutRow(node.num, font, numSize, opts, ctx);
|
|
157
|
+
const den = layoutRow(node.den, font, denSize, opts, ctx);
|
|
158
|
+
const gap = Math.round(FRAC_GAP * size);
|
|
159
|
+
const pad = Math.round(size * 0.12);
|
|
160
|
+
const width = Math.max(num.width, den.width) + pad * 2;
|
|
161
|
+
|
|
162
|
+
// The bar is the origin. The numerator's BASELINE goes above it by its depth plus the gap; the
|
|
163
|
+
// denominator's by its ascender plus the gap on the other side.
|
|
164
|
+
const numShift = num.depth + gap; // positive y raises
|
|
165
|
+
const denShift = -(den.height + gap); // negative y lowers
|
|
166
|
+
|
|
167
|
+
const items = [
|
|
168
|
+
{ ...num, x: Math.round((width - num.width) / 2), y: numShift },
|
|
169
|
+
{ kind: 'rule', x: pad, y: 0, width: width - pad * 2, height: 1, depth: 0 },
|
|
170
|
+
{ ...den, x: Math.round((width - den.width) / 2), y: denShift },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const box = hbox(
|
|
174
|
+
items,
|
|
175
|
+
width,
|
|
176
|
+
Math.max(num.height + numShift, Math.round(size * 0.6)),
|
|
177
|
+
Math.max(den.depth - denShift, Math.round(size * 0.5)),
|
|
178
|
+
);
|
|
179
|
+
return attachScripts(box, node.scripts, font, size, opts, ctx);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (node.kind === 'root') {
|
|
183
|
+
const body = layoutRow(node.body, font, size, opts, ctx);
|
|
184
|
+
const rad = glyphFor(font, '√', size) || emptyBox();
|
|
185
|
+
|
|
186
|
+
// THE BAR COVERS THE RADICAND'S BASE, NOT ITS SCRIPTS.
|
|
187
|
+
//
|
|
188
|
+
// `body.width` includes any superscript the radicand carries — `\sqrt{x^2}` measures `x` plus the `2`
|
|
189
|
+
// raised beside it — so a bar drawn across the full width runs straight over the exponent. That is the
|
|
190
|
+
// overlap: the tail of the radical meeting the `2`, not two bars meeting each other.
|
|
191
|
+
//
|
|
192
|
+
// TeX ends the bar where the base glyph ends. The base width is recovered here as the right edge of the
|
|
193
|
+
// widest item sitting on the baseline; a raised script is excluded because it has a non-zero y.
|
|
194
|
+
// TeX ends the bar where the base glyph ends. The base width is recovered by walking the radicand's
|
|
195
|
+
// items and taking the right edge of everything sitting ON the baseline; a raised script is excluded
|
|
196
|
+
// because it carries a non-zero y. Nested boxes are recursed into, since `layoutRow` wraps a run of
|
|
197
|
+
// glyphs in a box of its own.
|
|
198
|
+
const baseRight = (node, offset) => {
|
|
199
|
+
let right = 0;
|
|
200
|
+
for (const item of node.items || []) {
|
|
201
|
+
const x = offset + (item.x || 0);
|
|
202
|
+
if (item.y) continue; // raised or lowered: a script, not the base
|
|
203
|
+
if (item.kind === 'glyph' || item.kind === 'rule') {
|
|
204
|
+
right = Math.max(right, x + (item.width || 0));
|
|
205
|
+
} else if (item.items) {
|
|
206
|
+
right = Math.max(right, baseRight(item, x));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return right;
|
|
210
|
+
};
|
|
211
|
+
let baseW = baseRight(body, 0);
|
|
212
|
+
if (baseW === 0) baseW = body.width;
|
|
213
|
+
|
|
214
|
+
// THE BAR MUST MEET THE RADICAL'S TAIL AT ITS HEIGHT.
|
|
215
|
+
//
|
|
216
|
+
// Measured on STIX Two Math, the radical's ink top is at y=922 while the em is 1000, and its tail ends
|
|
217
|
+
// at x=829 — past the 794 advance. A bar placed at the radicand's height sits BELOW that tail, which is
|
|
218
|
+
// the gap this fixes.
|
|
219
|
+
//
|
|
220
|
+
// The height is looked up from the FONT rather than taken from the glyph item: `layoutRow` may re-wrap
|
|
221
|
+
// the item in a box that carries only its metrics, and a missing `inkTop` silently became zero.
|
|
222
|
+
const radGid = font.glyphIndex(0x221a);
|
|
223
|
+
const radInk = radGid ? inkExtent(font, radGid, size) : null;
|
|
224
|
+
const radTop = radInk && radInk.top > 0 ? radInk.top : Math.round(size * 0.92);
|
|
225
|
+
const radW = rad.width ? Math.max(rad.width, Math.round(size * 0.5)) : Math.round(size * 0.5);
|
|
226
|
+
const overlap = Math.round(size * 0.1);
|
|
227
|
+
const barPad = Math.round(size * 0.06);
|
|
228
|
+
const barW = baseW + barPad;
|
|
229
|
+
const width = radW + Math.max(barW, body.width);
|
|
230
|
+
|
|
231
|
+
// THE RADICAND SITS BELOW THE BAR, INCLUDING ITS SCRIPTS.
|
|
232
|
+
//
|
|
233
|
+
// The bar is drawn at the radical's tail height (922 on STIX). The radicand's own height already exceeds
|
|
234
|
+
// that once it carries a superscript — `x^2` measures 1099 — so placing it at y=0 put the exponent above
|
|
235
|
+
// the bar and the bar cut through the formula instead of capping it.
|
|
236
|
+
//
|
|
237
|
+
// The whole radicand is therefore lowered so its top clears the bar by a small gap.
|
|
238
|
+
const barY = radTop;
|
|
239
|
+
const bodyGap = Math.round(size * 0.06);
|
|
240
|
+
const bodyTop = body.height;
|
|
241
|
+
const bodyDrop = Math.max(0, bodyTop + bodyGap - barY);
|
|
242
|
+
|
|
243
|
+
const items = [
|
|
244
|
+
{ ...body, x: radW, y: -bodyDrop },
|
|
245
|
+
{
|
|
246
|
+
kind: 'rule',
|
|
247
|
+
x: radW - overlap,
|
|
248
|
+
y: barY,
|
|
249
|
+
width: barW + overlap,
|
|
250
|
+
height: 1,
|
|
251
|
+
depth: 0,
|
|
252
|
+
},
|
|
253
|
+
{ ...rad, x: 0, y: 0 },
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
const box = hbox(items, width, barY + 1, body.depth + bodyDrop);
|
|
257
|
+
return attachScripts(box, node.scripts, font, size, opts, ctx);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (node.kind === 'bigop') {
|
|
261
|
+
// Big operators are drawn larger than body text and centre their limits above and below.
|
|
262
|
+
const opSize = Math.round(size * 1.35);
|
|
263
|
+
const op = glyphFor(font, node.value, opSize);
|
|
264
|
+
if (!op) return null;
|
|
265
|
+
const ink = inkExtent(font, op.gid, opSize);
|
|
266
|
+
op.height = Math.max(op.height, ink.top);
|
|
267
|
+
op.depth = Math.max(op.depth, -ink.bottom);
|
|
268
|
+
|
|
269
|
+
const s = node.scripts;
|
|
270
|
+
if (!s) return op;
|
|
271
|
+
|
|
272
|
+
const limitSize = Math.round(size * 0.65);
|
|
273
|
+
const upper = s.sup ? layoutRow(s.sup, font, limitSize, opts, ctx) : null;
|
|
274
|
+
const lower = s.sub ? layoutRow(s.sub, font, limitSize, opts, ctx) : null;
|
|
275
|
+
const width = Math.max(op.width, upper ? upper.width : 0, lower ? lower.width : 0);
|
|
276
|
+
const gap = Math.round(LIMIT_GAP * size);
|
|
277
|
+
|
|
278
|
+
const items = [{ ...op, x: Math.round((width - op.width) / 2), y: 0 }];
|
|
279
|
+
let height = op.height;
|
|
280
|
+
let depth = op.depth;
|
|
281
|
+
|
|
282
|
+
if (upper) {
|
|
283
|
+
const y = op.height + gap + upper.depth; // positive: above the operator
|
|
284
|
+
items.push({ ...upper, x: Math.round((width - upper.width) / 2), y });
|
|
285
|
+
height = y + upper.height;
|
|
286
|
+
}
|
|
287
|
+
if (lower) {
|
|
288
|
+
const y = -(op.depth + gap + lower.height); // negative: below the operator
|
|
289
|
+
items.push({ ...lower, x: Math.round((width - lower.width) / 2), y });
|
|
290
|
+
depth = -y + lower.depth;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return hbox(items, width, height, depth);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (node.kind === 'accent') {
|
|
297
|
+
const body = layoutRow(node.body, font, size, opts, ctx);
|
|
298
|
+
const mark = glyphFor(font, node.accent, size);
|
|
299
|
+
const width = body.width;
|
|
300
|
+
const items = [{ ...body, x: 0, y: 0 }];
|
|
301
|
+
let height = body.height;
|
|
302
|
+
if (mark) {
|
|
303
|
+
const y = body.height + Math.round(size * 0.12); // above the base
|
|
304
|
+
items.push({ ...mark, x: Math.round((width - mark.width) / 2), y });
|
|
305
|
+
height = y + mark.height;
|
|
306
|
+
}
|
|
307
|
+
return hbox(items, width, height, body.depth);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (node.kind === 'env') {
|
|
311
|
+
return layoutEnvironment(node, font, size, opts, ctx);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Raise a box by a superscript, lower it by a subscript, or both. */
|
|
318
|
+
function attachScripts(base, scripts, font, size, opts, ctx) {
|
|
319
|
+
if (!scripts) return base;
|
|
320
|
+
|
|
321
|
+
const supSize = Math.round(size * SCRIPT_RATIO);
|
|
322
|
+
const sup = scripts.sup ? layoutRow(scripts.sup, font, supSize, opts, ctx) : null;
|
|
323
|
+
const sub = scripts.sub ? layoutRow(scripts.sub, font, subSizeFor(size), opts, ctx) : null;
|
|
324
|
+
|
|
325
|
+
const items = [{ ...base, x: 0, y: 0 }];
|
|
326
|
+
let width = base.width;
|
|
327
|
+
let height = base.height;
|
|
328
|
+
let depth = base.depth;
|
|
329
|
+
|
|
330
|
+
// TeX's placement: a superscript clears the base's height by about three quarters of its own, and a
|
|
331
|
+
// subscript drops by about a fifth of the base size. Both then grow the box.
|
|
332
|
+
if (sup) {
|
|
333
|
+
const y = base.height - Math.round(supSize * 0.28); // already positive: raises
|
|
334
|
+
items.push({ ...sup, x: base.width + Math.round(size * 0.02), y });
|
|
335
|
+
width = Math.max(width, base.width + Math.round(size * 0.02) + sup.width);
|
|
336
|
+
height = Math.max(height, y + sup.height);
|
|
337
|
+
}
|
|
338
|
+
if (sub) {
|
|
339
|
+
const y = -(Math.round(size * 0.18) + sub.height); // negative: lowers
|
|
340
|
+
items.push({ ...sub, x: base.width + Math.round(size * 0.02), y });
|
|
341
|
+
width = Math.max(width, base.width + Math.round(size * 0.02) + sub.width);
|
|
342
|
+
depth = Math.max(depth, -y + sub.depth);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return hbox(items, width, height, depth);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function subSizeFor(size) {
|
|
349
|
+
return Math.round(size * SCRIPT_RATIO);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Lay out a matrix-like environment: cells on a grid, equal column widths, rows on fixed pitch.
|
|
354
|
+
*/
|
|
355
|
+
function layoutEnvironment(node, font, size, opts, ctx) {
|
|
356
|
+
const cellSize = Math.round(size * 0.9);
|
|
357
|
+
const grid = node.rows.map((row) => row.map((cell) => layoutRow(cell, font, cellSize, opts, ctx)));
|
|
358
|
+
|
|
359
|
+
const cols = Math.max(...grid.map((r) => r.length), 1);
|
|
360
|
+
const colW = new Array(cols).fill(0);
|
|
361
|
+
for (const row of grid) {
|
|
362
|
+
row.forEach((cell, c) => { colW[c] = Math.max(colW[c], cell.width); });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const colGap = Math.round(size * 0.7);
|
|
366
|
+
const rowGap = Math.round(size * 0.35);
|
|
367
|
+
const pad = Math.round(size * 0.25);
|
|
368
|
+
|
|
369
|
+
const innerW = colW.reduce((a, b) => a + b, 0) + colGap * (cols - 1);
|
|
370
|
+
|
|
371
|
+
// Row pitch is a row's own height plus the gap BETWEEN rows. Using `height + depth` for both the box and
|
|
372
|
+
// the step counted the descent twice, so each row landed further down than the box was tall and the last
|
|
373
|
+
// one fell outside the bitmap — the matrix was clipped at its bottom edge.
|
|
374
|
+
const rowTops = grid.map((row) => Math.max(...row.map((c) => c.height), Math.round(size * 0.6)));
|
|
375
|
+
const rowBottoms = grid.map((row) => Math.max(...row.map((c) => c.depth), Math.round(size * 0.2)));
|
|
376
|
+
const innerH = rowTops.reduce((a, b) => a + b, 0)
|
|
377
|
+
+ rowBottoms.reduce((a, b) => a + b, 0)
|
|
378
|
+
+ rowGap * (grid.length - 1);
|
|
379
|
+
|
|
380
|
+
const items = [];
|
|
381
|
+
// The first row's baseline sits just below the top of the content block.
|
|
382
|
+
let y = Math.round(innerH / 2) - rowTops[0];
|
|
383
|
+
for (let r = 0; r < grid.length; r++) {
|
|
384
|
+
let x = 0;
|
|
385
|
+
for (let c = 0; c < grid[r].length; c++) {
|
|
386
|
+
const cell = grid[r][c];
|
|
387
|
+
items.push({ ...cell, x: x + Math.round((colW[c] - cell.width) / 2), y });
|
|
388
|
+
x += colW[c] + colGap;
|
|
389
|
+
}
|
|
390
|
+
// The next row's baseline is one row height plus one gap lower.
|
|
391
|
+
y -= rowTops[r] + rowBottoms[r] + rowGap;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const width = innerW + pad * 2;
|
|
395
|
+
|
|
396
|
+
// THE BOX IS THE UNION OF WHAT THE CELLS OCCUPY, PLUS A MARGIN.
|
|
397
|
+
//
|
|
398
|
+
// Sizing it as `innerH/2 + pad` on both sides assumed the content is symmetric about the baseline, which
|
|
399
|
+
// it is not: two rows of digits leave more ink above than below, so the depth came out too small and the
|
|
400
|
+
// bottom bracket arm was clipped. Measuring the placed items is exact for any number of rows.
|
|
401
|
+
let top = 0;
|
|
402
|
+
let bottom = 0;
|
|
403
|
+
for (const it of items) {
|
|
404
|
+
if (it.y + (it.height || 0) > top) top = it.y + (it.height || 0);
|
|
405
|
+
if (-it.y + (it.depth || 0) > bottom) bottom = -it.y + (it.depth || 0);
|
|
406
|
+
}
|
|
407
|
+
const height = top + pad;
|
|
408
|
+
const depth = bottom + pad;
|
|
409
|
+
|
|
410
|
+
// THE BRACKETS ARE DELIBERATELY TALLER THAN THE CELLS.
|
|
411
|
+
//
|
|
412
|
+
// Their arms are drawn as horizontal strokes two pixels thick, and a stroke whose top edge coincides with
|
|
413
|
+
// the content's bounding row puts half of itself — plus its antialiased edge — outside the bitmap. A small
|
|
414
|
+
// overshoot keeps the arms fully inside, which is what TeX does too: a matrix bracket always overhangs its
|
|
415
|
+
// contents slightly.
|
|
416
|
+
const bracketPad = Math.round(size * 0.12);
|
|
417
|
+
const bracketH = height + bracketPad;
|
|
418
|
+
const bracketD = depth + bracketPad;
|
|
419
|
+
|
|
420
|
+
const bracketW = Math.round(size * 0.3);
|
|
421
|
+
const totalW = width + bracketW * 2;
|
|
422
|
+
const left = node.env === 'cases'
|
|
423
|
+
? { kind: 'brace', x: 0, y: 0, width: bracketW, height: bracketH, depth: bracketD }
|
|
424
|
+
: { kind: 'bracket', side: 'left', x: 0, y: 0, width: bracketW, height: bracketH, depth: bracketD };
|
|
425
|
+
const right = node.env === 'cases'
|
|
426
|
+
? null
|
|
427
|
+
: { kind: 'bracket', side: 'right', x: totalW - bracketW, y: 0, width: bracketW, height: bracketH, depth: bracketD };
|
|
428
|
+
|
|
429
|
+
const shifted = items.map((it) => ({ ...it, x: it.x + bracketW }));
|
|
430
|
+
const out = [left, ...shifted];
|
|
431
|
+
if (right) out.push(right);
|
|
432
|
+
|
|
433
|
+
return hbox(out, totalW, bracketH, bracketD);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Lay out a parsed formula.
|
|
438
|
+
*
|
|
439
|
+
* @param {object[]} nodes Output of `parseTex`.
|
|
440
|
+
* @param {object} font A `TrueTypeFont`.
|
|
441
|
+
* @param {number} size Em size in font units (1000 for Noto Sans Math).
|
|
442
|
+
* @returns {{width:number,height:number,depth:number,items:object[]}}
|
|
443
|
+
*/
|
|
444
|
+
export function layout(nodes, font, size = 1000) {
|
|
445
|
+
return layoutRow(nodes, font, size, {}, {});
|
|
446
|
+
}
|