ntk 2.2.0 → 3.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.
Files changed (47) hide show
  1. package/README.md +156 -151
  2. package/lib/app.js +64 -21
  3. package/lib/cleanup.js +14 -0
  4. package/lib/drawable.js +12 -18
  5. package/lib/events_map.js +92 -98
  6. package/lib/fontconfig.js +123 -17
  7. package/lib/glyphset.js +40 -21
  8. package/lib/image.js +138 -0
  9. package/lib/index.js +129 -57
  10. package/lib/path.js +734 -0
  11. package/lib/picture.js +84 -41
  12. package/lib/pixmap.js +50 -39
  13. package/lib/rasterize.js +178 -0
  14. package/lib/renderingcontext_2d.js +1078 -695
  15. package/lib/renderingcontext_opengl.js +53 -46
  16. package/lib/renderingcontext_x11.js +59 -67
  17. package/lib/text/font.js +196 -0
  18. package/lib/text/fontmanager.js +233 -0
  19. package/lib/text/fontsource.js +179 -0
  20. package/lib/text/glyphs.js +456 -0
  21. package/lib/text/layout.js +356 -0
  22. package/lib/text/shape.js +141 -0
  23. package/lib/trapezoid.js +131 -0
  24. package/lib/widgets/css.js +676 -0
  25. package/lib/widgets/highlight.js +97 -0
  26. package/lib/widgets/htmlview.js +879 -0
  27. package/lib/widgets/markdown.js +148 -0
  28. package/lib/widgets/markdownview.js +487 -0
  29. package/lib/widgets/mermaid.js +720 -0
  30. package/lib/widgets/svgview.js +528 -0
  31. package/lib/widgets/tex.js +681 -0
  32. package/lib/window.js +691 -308
  33. package/package.json +60 -56
  34. package/examples/example-await.js +0 -23
  35. package/examples/example-co.js +0 -13
  36. package/examples/example.js +0 -14
  37. package/examples/fill-styles.js +0 -39
  38. package/examples/glclock.js +0 -100
  39. package/examples/glxpixmap.js +0 -12
  40. package/examples/gradient.js +0 -41
  41. package/examples/package.json +0 -17
  42. package/examples/simpletex.js +0 -72
  43. package/examples/teapot.js +0 -104
  44. package/examples/teapot.json +0 -6
  45. package/examples/text-rendering.js +0 -44
  46. package/lib/fontface.js +0 -103
  47. package/lib/widgets/webview.js +0 -33
@@ -0,0 +1,681 @@
1
+ // TeX math rendering on top of KaTeX (pure JS) and the ntk text stack.
2
+ //
3
+ // KaTeX parses TeX and produces a DOM-like tree (spans, vertical lists,
4
+ // symbol nodes, rules, SVG surds) that browsers lay out with CSS. This
5
+ // module reimplements the small CSS subset that tree actually uses —
6
+ // inline flow with em margins, vlist absolute positioning, border-bottom
7
+ // rules, font-size/font-family classes — directly in pixel space, using
8
+ // the bundled KaTeX .ttf fonts for glyphs and metrics.
9
+ //
10
+ // Wire efficiency: adjacent symbols that resolve to the same font/size/color
11
+ // are concatenated and shaped as single runs, and drawing routes every run
12
+ // of a formula into ONE batched CompositeGlyphs request per color (via
13
+ // drawGlyphRuns) — not one placement per character. Radical/stretchy SVG
14
+ // paths are filled server-side as trapezoids through the same scratch mask
15
+ // as the vector text path.
16
+ //
17
+ // const box = layoutTex('\\frac{a+b}{2}', { size: 24 });
18
+ // box.draw(ctx, x, y); // x, y = top-left; box.width/height/baseline
19
+ //
20
+ // or as a widget: new TexView(wnd, { tex: '...', size: 32 }).
21
+ import { parseSvgPath } from '../path.js';
22
+ import { flatten } from '../rasterize.js';
23
+ import { trapezoidize } from '../trapezoid.js';
24
+ import Font from '../text/font.js';
25
+ import { compositeTraps, drawGlyphRuns } from '../text/glyphs.js';
26
+
27
+ // node builtins are fetched lazily so browser bundles never resolve them;
28
+ // there, inject the assets with configureTex() instead.
29
+ function nodeRequire() {
30
+ const mod = globalThis.process?.getBuiltinModule?.('node:module');
31
+ return mod ? mod.createRequire(import.meta.url) : null;
32
+ }
33
+
34
+ // katex is sizable — load it on first formula, not on package import
35
+ let katex = null;
36
+ function loadKatex() {
37
+ if (!katex) {
38
+ const require = nodeRequire();
39
+ if (!require) {
40
+ throw new Error(
41
+ 'TeX rendering: the katex package cannot be required here — inject it with configureTex({ katex })'
42
+ );
43
+ }
44
+ katex = require('katex');
45
+ }
46
+ return katex;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // KaTeX font files
51
+
52
+ let fontDir = null; // node path to katex/dist/fonts (null when injected)
53
+ let fontFiles = null; // Set of available .ttf file names
54
+ let injectedFonts = null; // Map file name -> bytes | Font
55
+
56
+ /**
57
+ * Provide the KaTeX assets explicitly instead of resolving them from
58
+ * node_modules — required in environments without node builtins (browser
59
+ * bundles), harmless elsewhere.
60
+ *
61
+ * @param {object} opts { katex: the katex module, fonts: map/object of
62
+ * 'KaTeX_Main-Regular.ttf' -> font bytes (or Font) for the .ttf files
63
+ * shipped in katex/dist/fonts (a subset is fine) }
64
+ */
65
+ export function configureTex({ katex: katexModule, fonts } = {}) {
66
+ if (katexModule) katex = katexModule;
67
+ if (fonts) {
68
+ injectedFonts = fonts instanceof Map ? fonts : new Map(Object.entries(fonts));
69
+ fontDir = null;
70
+ fontFiles = null;
71
+ fontCache.clear();
72
+ }
73
+ }
74
+
75
+ function katexFontFiles() {
76
+ if (!fontFiles) {
77
+ if (injectedFonts) {
78
+ fontFiles = new Set(injectedFonts.keys());
79
+ } else {
80
+ const require = nodeRequire();
81
+ if (!require) {
82
+ throw new Error(
83
+ 'TeX rendering: KaTeX fonts cannot be read here — inject them with configureTex({ fonts })'
84
+ );
85
+ }
86
+ const { readdirSync } = process.getBuiltinModule('node:fs');
87
+ const { dirname, join } = process.getBuiltinModule('node:path');
88
+ fontDir = join(dirname(require.resolve('katex/package.json')), 'dist', 'fonts');
89
+ fontFiles = new Set(readdirSync(fontDir).filter((f) => f.endsWith('.ttf')));
90
+ }
91
+ }
92
+ return fontFiles;
93
+ }
94
+
95
+ const fontCache = new Map(); // file name -> Font
96
+
97
+ /** resolve a KaTeX family/weight/style to a bundled Font, with graceful
98
+ * degradation when the exact variant does not exist (e.g. Math-Regular) */
99
+ function katexFont(family, bold, italic) {
100
+ const files = katexFontFiles();
101
+ const variants = [
102
+ bold && italic ? 'BoldItalic' : null,
103
+ bold ? 'Bold' : null,
104
+ italic ? 'Italic' : null,
105
+ 'Regular',
106
+ 'Italic' // KaTeX_Math ships italic faces only
107
+ ];
108
+ for (const variant of variants) {
109
+ if (!variant) continue;
110
+ const file = `KaTeX_${family}-${variant}.ttf`;
111
+ if (!files.has(file)) continue;
112
+ let font = fontCache.get(file);
113
+ if (!font) {
114
+ if (injectedFonts) {
115
+ const value = injectedFonts.get(file);
116
+ font = value instanceof Font ? value : Font.fromData(value, { key: `katex:${file}` });
117
+ } else {
118
+ font = Font.loadSync(`${fontDir}/${file}`);
119
+ }
120
+ fontCache.set(file, font);
121
+ }
122
+ return font;
123
+ }
124
+ throw new Error(`no KaTeX font for family ${family}`);
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // class / style interpretation (mirrors katex.css)
129
+
130
+ // KaTeX size multipliers, sizes 1..11 (src/Options.js)
131
+ const SIZE_MULT = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.44, 1.728, 2.074, 2.488];
132
+
133
+ const FONT_CLASSES = {
134
+ mathnormal: { family: 'Math', italic: true },
135
+ mathit: { family: 'Main', italic: true },
136
+ mathrm: { italic: false },
137
+ mathbf: { family: 'Main', bold: true },
138
+ boldsymbol: { family: 'Math', bold: true, italic: true },
139
+ amsrm: { family: 'AMS' },
140
+ mathbb: { family: 'AMS' },
141
+ textbb: { family: 'AMS' },
142
+ mathcal: { family: 'Caligraphic' },
143
+ mathfrak: { family: 'Fraktur' },
144
+ textfrak: { family: 'Fraktur' },
145
+ mathboldfrak: { family: 'Fraktur', bold: true },
146
+ textboldfrak: { family: 'Fraktur', bold: true },
147
+ mathtt: { family: 'Typewriter' },
148
+ texttt: { family: 'Typewriter' },
149
+ mathscr: { family: 'Script' },
150
+ textscr: { family: 'Script' },
151
+ mathsf: { family: 'SansSerif' },
152
+ textsf: { family: 'SansSerif' },
153
+ mathboldsf: { family: 'SansSerif', bold: true },
154
+ textboldsf: { family: 'SansSerif', bold: true },
155
+ mathsfit: { family: 'SansSerif', italic: true },
156
+ mathitsf: { family: 'SansSerif', italic: true },
157
+ textitsf: { family: 'SansSerif', italic: true },
158
+ mainrm: { family: 'Main', italic: false },
159
+ textrm: { family: 'Main' },
160
+ textbf: { bold: true },
161
+ textit: { italic: true }
162
+ };
163
+
164
+ // css lengths on domTree nodes are 'Xem' (occasionally 'Xpx'); px stays px
165
+ function parseLen(value, em) {
166
+ if (!value) return 0;
167
+ const n = parseFloat(value);
168
+ if (Number.isNaN(n)) return 0;
169
+ return String(value).endsWith('px') ? n : n * em;
170
+ }
171
+
172
+ /** apply a node's classes and inline style to the inherited state */
173
+ function applyState(node, st) {
174
+ const classes = node.classes || [];
175
+ let out = st;
176
+ const own = () => (out === st ? (out = { ...st }) : out);
177
+
178
+ const sizing =
179
+ classes.includes('katex-sizing') ||
180
+ classes.includes('sizing') ||
181
+ classes.includes('fontsize-ensurer');
182
+ const delimsizing = classes.includes('delimsizing');
183
+
184
+ let resetSize = null;
185
+ let toSize = null;
186
+ for (const c of classes) {
187
+ const font = FONT_CLASSES[c];
188
+ if (font) Object.assign(own(), font);
189
+ let m;
190
+ if ((m = /^reset-size(\d+)$/.exec(c))) {
191
+ resetSize = +m[1];
192
+ } else if (sizing && (m = /^size(\d+)$/.exec(c))) {
193
+ toSize = +m[1];
194
+ } else if (delimsizing && (m = /^size(\d+)$/.exec(c))) {
195
+ own().family = `Size${m[1]}`;
196
+ own().italic = false;
197
+ own().bold = false;
198
+ } else if ((m = /^delim-size(\d+)$/.exec(c))) {
199
+ own().family = `Size${m[1]}`;
200
+ own().italic = false;
201
+ own().bold = false;
202
+ } else if (c === 'small-op') {
203
+ own().family = 'Size1';
204
+ } else if (c === 'large-op') {
205
+ own().family = 'Size2';
206
+ }
207
+ }
208
+ if (resetSize !== null && toSize !== null) {
209
+ own().em = st.em * (SIZE_MULT[toSize - 1] / SIZE_MULT[resetSize - 1]);
210
+ }
211
+ if (node.style && node.style.color) {
212
+ own().color = node.style.color;
213
+ }
214
+ return out;
215
+ }
216
+
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // layout walker
220
+
221
+ const STRETCH = -1; // width placeholder resolved by the containing vlist
222
+
223
+ function isSymbol(node) {
224
+ return node.constructor && node.constructor.name === 'SymbolNode';
225
+ }
226
+
227
+ function isSvg(node) {
228
+ return node.constructor && node.constructor.name === 'SvgNode';
229
+ }
230
+
231
+ function classSet(node) {
232
+ return node.classes || [];
233
+ }
234
+
235
+ function has(node, cls) {
236
+ return classSet(node).includes(cls);
237
+ }
238
+
239
+ class Walker {
240
+ constructor() {
241
+ this.warnings = [];
242
+ }
243
+
244
+ /** dispatch any child node -> box { width, items, stretch? } */
245
+ layoutAny(node, st, align) {
246
+ if (isSymbol(node)) {
247
+ // bare symbol outside an hbox (e.g. directly inside a vlist wrap)
248
+ return this.hbox([node], st, 0);
249
+ }
250
+ if (isSvg(node)) return this.svg(node, st, null);
251
+ if (node.children) return this.span(node, st, align);
252
+ return { width: 0, items: [] };
253
+ }
254
+
255
+ span(node, st0, align = 'left') {
256
+ const st = applyState(node, st0);
257
+ const em = st.em;
258
+ const cls = classSet(node);
259
+ const style = node.style || {};
260
+
261
+ if (cls.includes('katex-strut') || cls.includes('pstrut') || cls.includes('vlist-s')) {
262
+ return { width: 0, items: [] };
263
+ }
264
+
265
+ // rules drawn with borders: frac-line, overline/underline-line, hlines
266
+ if (style.borderBottomWidth !== undefined) {
267
+ const h = Math.max(parseLen(style.borderBottomWidth, em), 1);
268
+ return {
269
+ width: 0,
270
+ stretch: true,
271
+ items: [{ type: 'rect', x: 0, y: -h, w: STRETCH, h, color: st.color }]
272
+ };
273
+ }
274
+ // \rule: content-less span sized by borders, lifted by style.bottom
275
+ if (cls.includes('katex-rule') || cls.includes('rule')) {
276
+ const w = parseLen(style.borderRightWidth ?? style.width, em);
277
+ const h = Math.max(parseLen(style.borderTopWidth ?? style.height, em), 1);
278
+ const shift = parseLen(style.bottom, em);
279
+ return {
280
+ width: w,
281
+ items: w > 0 ? [{ type: 'rect', x: 0, y: -shift - h, w, h, color: st.color }] : []
282
+ };
283
+ }
284
+
285
+ if (cls.includes('vlist-t')) {
286
+ return this.vlist(node, st, align);
287
+ }
288
+
289
+ // spans hosting an absolutely-positioned svg (sqrt surds, stretchy fills)
290
+ const svgChild = (node.children || []).find(isSvg);
291
+ if (svgChild) {
292
+ const cssH = parseLen(style.height, em) || parseLen(svgChild.attributes?.height, em);
293
+ const box = this.svg(svgChild, st, cssH);
294
+ const minW = parseLen(style.minWidth, em);
295
+ return {
296
+ width: Math.max(parseLen(style.width, em), minW),
297
+ stretch: true, // hide-tail spans are width:100% of their column
298
+ stretchMin: minW,
299
+ items: box.items
300
+ };
301
+ }
302
+
303
+ // generic inline flow
304
+ const box = this.hbox(node.children || [], st, parseLen(style.paddingLeft, em), align);
305
+
306
+ // explicit width overrides (mspace \hspace etc.) and width:0 hacks
307
+ if (style.width !== undefined) box.width = parseLen(style.width, em);
308
+ if (cls.includes('nulldelimiter')) box.width = 0.12 * em;
309
+ if (cls.includes('llap') || cls.includes('clap')) {
310
+ const shift = cls.includes('llap') ? -box.width : -box.width / 2;
311
+ for (const item of box.items) item.x += shift;
312
+ box.width = 0;
313
+ } else if (cls.includes('rlap')) {
314
+ box.width = 0;
315
+ } else if (cls.includes('accent-body') && !cls.includes('accent-full')) {
316
+ box.width = 0;
317
+ }
318
+ return box;
319
+ }
320
+
321
+ /** children in inline flow; merges adjacent same-style symbols into runs */
322
+ hbox(children, st, x0 = 0, align = 'left') {
323
+ const items = [];
324
+ let x = x0;
325
+ let group = null;
326
+
327
+ const flush = () => {
328
+ if (!group) return;
329
+ const run = group.font.shape(group.text, group.size);
330
+ items.push({ type: 'run', run, x: group.x0, y: 0, color: group.color });
331
+ x = group.x0 + run.width + group.italic;
332
+ group = null;
333
+ };
334
+
335
+ for (const child of children) {
336
+ if (isSymbol(child)) {
337
+ const cst = applyState(child, st);
338
+ // strip zero-width space / BOM (vlist alignment hacks)
339
+ const text = child.text.replace(/[\u200b\ufeff]/g, '');
340
+ if (!text) continue;
341
+ const style = child.style || {};
342
+ const ml = parseLen(style.marginLeft, cst.em);
343
+ const mr = parseLen(style.marginRight, cst.em);
344
+ if (ml) {
345
+ flush();
346
+ x += ml;
347
+ }
348
+ const font = katexFont(cst.family, cst.bold, cst.italic);
349
+ if (
350
+ group &&
351
+ (group.font !== font || group.size !== cst.em || group.color !== cst.color)
352
+ ) {
353
+ flush();
354
+ }
355
+ if (!group) group = { font, size: cst.em, color: cst.color, text: '', x0: x, italic: 0 };
356
+ group.text += text;
357
+ group.italic = (child.italic || 0) * cst.em;
358
+ // italic correction / explicit margin ends the mergeable sequence
359
+ if (child.italic || mr) {
360
+ flush();
361
+ x += mr;
362
+ }
363
+ continue;
364
+ }
365
+ if (isSvg(child)) {
366
+ // absolutely positioned inside its span: draws but does not advance
367
+ flush();
368
+ const box = this.svg(child, st, null);
369
+ for (const item of box.items) {
370
+ item.x += x;
371
+ items.push(item);
372
+ }
373
+ continue;
374
+ }
375
+ if (!child.children && !child.style) continue; // text/doc nodes
376
+ flush();
377
+ const style = child.style || {};
378
+ const cem = applyState(child, st).em;
379
+ x += parseLen(style.marginLeft, cem);
380
+ const box = this.span(child, st, this.childAlign(child, align));
381
+ const dy = parseLen(style.top, cem) - parseLen(style.verticalAlign, cem);
382
+ for (const item of box.items) {
383
+ item.x += x;
384
+ item.y += dy;
385
+ items.push(item);
386
+ }
387
+ x += box.width + parseLen(style.marginRight, cem);
388
+ }
389
+ flush();
390
+ return { width: x, items };
391
+ }
392
+
393
+ /** text-align context for a span's children (inherits; a few classes set it) */
394
+ childAlign(node, inherited) {
395
+ const cls = classSet(node);
396
+ if (cls.includes('mfrac') || cls.includes('op-limits') || cls.includes('katex-accent')) {
397
+ return 'center';
398
+ }
399
+ if (cls.includes('col-align-c')) return 'center';
400
+ if (cls.includes('col-align-r')) return 'right';
401
+ if (cls.includes('col-align-l') || cls.includes('msupsub') || cls.includes('svg-align')) {
402
+ return 'left';
403
+ }
404
+ return inherited;
405
+ }
406
+
407
+ /** KaTeX vertical list: rows absolutely positioned via pstrut + top */
408
+ vlist(vtable, st, align) {
409
+ const em = st.em;
410
+ const rows = (vtable.children || []).filter((c) => has(c, 'vlist-r'));
411
+ const column = rows.length ? (rows[0].children || []).find((c) => has(c, 'vlist')) : null;
412
+ if (!column) return { width: 0, items: [] };
413
+
414
+ const entries = [];
415
+ for (const wrap of column.children || []) {
416
+ const kids = wrap.children || [];
417
+ const pstrut = kids.find((k) => has(k, 'pstrut'));
418
+ const elem = kids.find((k) => k !== pstrut);
419
+ if (!elem) continue;
420
+ const style = wrap.style || {};
421
+ const P = parseLen(pstrut?.style?.height, em);
422
+ const T = parseLen(style.top, em);
423
+ const wrapAlign = this.childAlign(wrap, align);
424
+ // wrapper classes carry state for stacked delimiters (delim-sizeN)
425
+ const box = this.layoutAny(elem, applyState(wrap, st), wrapAlign);
426
+ entries.push({
427
+ box,
428
+ dy: P + T,
429
+ ml: parseLen(style.marginLeft, em),
430
+ mr: parseLen(style.marginRight, em),
431
+ align: has(wrap, 'svg-align') ? 'left' : wrapAlign
432
+ });
433
+ }
434
+
435
+ let colWidth = 0;
436
+ for (const e of entries) {
437
+ if (e.box.stretch) {
438
+ if (e.box.stretchMin) colWidth = Math.max(colWidth, e.box.stretchMin);
439
+ continue;
440
+ }
441
+ colWidth = Math.max(colWidth, e.ml + e.box.width + e.mr);
442
+ }
443
+
444
+ const items = [];
445
+ for (const e of entries) {
446
+ const w = e.box.stretch ? colWidth : e.box.width;
447
+ let ox = e.ml;
448
+ if (!e.box.stretch) {
449
+ if (e.align === 'center') ox += (colWidth - e.ml - e.box.width - e.mr) / 2;
450
+ else if (e.align === 'right') ox += colWidth - e.ml - e.box.width - e.mr;
451
+ }
452
+ for (const item of e.box.items) {
453
+ if (item.w === STRETCH) item.w = w;
454
+ if (item.clipW === STRETCH) item.clipW = w;
455
+ item.x += ox;
456
+ item.y += e.dy;
457
+ items.push(item);
458
+ }
459
+ }
460
+ return { width: colWidth, items };
461
+ }
462
+
463
+ /** SvgNode -> filled path item; box is bottom-aligned to the baseline */
464
+ svg(node, st, cssH) {
465
+ const attrs = node.attributes || {};
466
+ const viewBox = String(attrs.viewBox || '0 0 1 1')
467
+ .split(/[\s,]+/)
468
+ .map(Number);
469
+ const vbH = viewBox[3] || 1;
470
+ const heightPx = cssH ?? parseLen(attrs.height, st.em);
471
+ const scale = vbH ? heightPx / vbH : 0;
472
+
473
+ const polys = [];
474
+ for (const child of node.children || []) {
475
+ let d = child.alternate;
476
+ if (!d && typeof child.toMarkup === 'function') {
477
+ const m = / d="([^"]*)"/.exec(child.toMarkup());
478
+ if (m) d = m[1];
479
+ }
480
+ if (!d) continue;
481
+ for (const poly of flatten(parseSvgPath(d))) {
482
+ const scaled = new Array(poly.length);
483
+ for (let i = 0; i < poly.length; i += 2) {
484
+ scaled[i] = poly[i] * scale;
485
+ scaled[i + 1] = poly[i + 1] * scale - heightPx; // bottom on the baseline
486
+ }
487
+ polys.push(scaled);
488
+ }
489
+ }
490
+ return {
491
+ width: 0,
492
+ items: polys.length
493
+ ? [{ type: 'path', polys, x: 0, y: 0, clipW: STRETCH, color: st.color }]
494
+ : []
495
+ };
496
+ }
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // public API
501
+
502
+ /**
503
+ * Parse and lay out a TeX formula with KaTeX. Fully headless (no X
504
+ * connection needed) — the result can be measured, then drawn any number of
505
+ * times onto 2d contexts.
506
+ *
507
+ * @param {string} tex
508
+ * @param {object} [options]
509
+ * - size: base font size in px (the em of the formula), default 16
510
+ * - displayMode: KaTeX display mode (block, \displaystyle), default false
511
+ * - color: default ink color (spans with explicit \color override it)
512
+ * - katex: extra options passed through to KaTeX (macros, strict, ...)
513
+ * @returns {TexBox}
514
+ */
515
+ export function layoutTex(tex, options = {}) {
516
+ const em = options.size ?? 16;
517
+ const tree = loadKatex().__renderToDomTree(String(tex ?? ''), {
518
+ output: 'html',
519
+ displayMode: !!options.displayMode,
520
+ ...options.katex
521
+ });
522
+
523
+ // find the .katex span ( __renderToDomTree may wrap it in .katex-display )
524
+ let root = tree;
525
+ if (has(root, 'katex-display')) root = root.children.find((c) => has(c, 'katex'));
526
+ const html = (root.children || []).find((c) => has(c, 'katex-html')) ?? root;
527
+
528
+ const st = {
529
+ em,
530
+ family: 'Main',
531
+ bold: false,
532
+ italic: false,
533
+ color: options.color ?? null
534
+ };
535
+ const walker = new Walker();
536
+ const box = walker.hbox(html.children || [], st);
537
+
538
+ const height = (root.height || 0) * em;
539
+ const depth = (root.depth || 0) * em;
540
+ return new TexBox(box.items, box.width, height, depth, em);
541
+ }
542
+
543
+ /**
544
+ * A laid-out formula. Coordinates are relative to the top-left corner;
545
+ * `baseline` px below the top is the alphabetic baseline.
546
+ */
547
+ export class TexBox {
548
+ constructor(items, width, height, depth, em) {
549
+ this.items = items;
550
+ this.width = width;
551
+ this.baseline = height;
552
+ this.height = height + depth;
553
+ this.depth = depth;
554
+ this.em = em;
555
+ }
556
+
557
+ /**
558
+ * Draw at (x, y) = top-left. Text is batched into one CompositeGlyphs
559
+ * request per color; rules become FillRectangles; radical strokes become
560
+ * trapezoids. The context fillStyle is used for items without an explicit
561
+ * color and is left untouched.
562
+ */
563
+ draw(ctx, x = 0, y = 0) {
564
+ const app = ctx.window.app;
565
+ const Render = app.display.Render;
566
+ const base = y + this.baseline;
567
+
568
+ for (const item of this.items) {
569
+ if (item.type !== 'rect') continue;
570
+ const src = item.color ? ctx._stylePicture(item.color) : ctx._backgroundPicture;
571
+ Render.Composite(
572
+ Render.PictOp.Over,
573
+ src.id,
574
+ ctx.clipMask ? ctx.clipMask.id : 0,
575
+ ctx.picture.id,
576
+ Math.round(x + item.x),
577
+ Math.round(base + item.y),
578
+ Math.round(x + item.x),
579
+ Math.round(base + item.y),
580
+ Math.round(x + item.x),
581
+ Math.round(base + item.y),
582
+ Math.max(1, Math.round(item.w)),
583
+ Math.max(1, Math.round(item.h))
584
+ );
585
+ }
586
+
587
+ for (const item of this.items) {
588
+ if (item.type !== 'path') continue;
589
+ const src = item.color ? ctx._stylePicture(item.color) : ctx._backgroundPicture;
590
+ const traps = [];
591
+ for (const poly of item.polys) {
592
+ let clipped = poly;
593
+ if (item.clipW !== STRETCH && item.clipW !== undefined) {
594
+ clipped = poly.slice();
595
+ for (let i = 0; i < clipped.length; i += 2) {
596
+ if (clipped[i] > item.clipW) clipped[i] = item.clipW;
597
+ }
598
+ }
599
+ trapezoidize([clipped], x + item.x, base + item.y, traps);
600
+ }
601
+ compositeTraps(app, Render.PictOp.Over, src.id, ctx.picture.id, traps);
602
+ }
603
+
604
+ // batch every run of the same color into a single CompositeGlyphs
605
+ const byColor = new Map();
606
+ for (const item of this.items) {
607
+ if (item.type !== 'run') continue;
608
+ const key = item.color ?? '';
609
+ let list = byColor.get(key);
610
+ if (!list) byColor.set(key, (list = { color: item.color, runs: [] }));
611
+ list.runs.push({ run: item.run, x: x + item.x, y: base + item.y });
612
+ }
613
+ for (const { color, runs } of byColor.values()) {
614
+ const src = color ? ctx._stylePicture(color) : ctx._backgroundPicture;
615
+ drawGlyphRuns(app, Render.PictOp.Over, src.id, ctx.picture.id, runs);
616
+ }
617
+
618
+ ctx._markDirty();
619
+ return this;
620
+ }
621
+ }
622
+
623
+ /**
624
+ * A widget that renders one TeX formula into a window, KaTeX-style.
625
+ *
626
+ * const view = new TexView(wnd, { tex: 'e^{i\\pi} + 1 = 0', size: 40 });
627
+ * wnd.map();
628
+ *
629
+ * Options: tex, size, color, background, displayMode, padding, katex
630
+ * (pass-through options). `setTex(tex)` updates and re-renders.
631
+ */
632
+ export default class TexView {
633
+ constructor(window, opts = {}) {
634
+ this.window = window ?? null;
635
+ this.size = opts.size ?? 24;
636
+ this.color = opts.color ?? '#222222';
637
+ this.background = opts.background ?? 'white';
638
+ this.displayMode = opts.displayMode ?? true;
639
+ this.padding = opts.padding ?? 16;
640
+ this.katexOptions = opts.katex;
641
+ this._box = null;
642
+ this._tex = '';
643
+ if (opts.tex) this.setTex(opts.tex);
644
+
645
+ if (this.window) {
646
+ this._ctx = this.window.getContext('2d');
647
+ this.window.on('expose', () => this.render());
648
+ }
649
+ }
650
+
651
+ setTex(tex) {
652
+ this._tex = String(tex ?? '');
653
+ this._box = layoutTex(this._tex, {
654
+ size: this.size,
655
+ color: this.color,
656
+ displayMode: this.displayMode,
657
+ katex: this.katexOptions
658
+ });
659
+ if (this.window && this.window._mapped) this.render();
660
+ return this;
661
+ }
662
+
663
+ /** the laid-out TexBox (width/height/baseline) for the current tex */
664
+ get box() {
665
+ return this._box;
666
+ }
667
+
668
+ render() {
669
+ if (!this.window) throw new Error('TexView.render() needs a window');
670
+ const ctx = this._ctx;
671
+ ctx.fillStyle = this.background;
672
+ ctx.fillRect(0, 0, this.window.width, this.window.height);
673
+ if (!this._box) return this;
674
+ // centered, like KaTeX display mode
675
+ const x = Math.max(this.padding, (this.window.width - this._box.width) / 2);
676
+ const y = Math.max(this.padding, (this.window.height - this._box.height) / 2);
677
+ ctx.fillStyle = this.color;
678
+ this._box.draw(ctx, x, y);
679
+ return this;
680
+ }
681
+ }