ntk 2.2.0 → 3.0.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 +54 -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 +123 -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 +642 -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,879 @@
1
+ import { selectAll } from 'css-select';
2
+ import { textContent } from 'domutils';
3
+ import { parseDocument } from 'htmlparser2';
4
+ import Yoga from 'yoga-layout';
5
+
6
+ import { decodeImage, Image } from '../image.js';
7
+ import { TextLayout } from '../text/layout.js';
8
+ import { computeStyles, cssColor } from './css.js';
9
+ import SvgView from './svgview.js';
10
+
11
+ const DEFAULT_THEME = {
12
+ family: 'sans-serif',
13
+ size: 16,
14
+ color: '#222222',
15
+ background: 'white'
16
+ };
17
+
18
+ const FLEX_DIRECTION = {
19
+ column: Yoga.FLEX_DIRECTION_COLUMN,
20
+ 'column-reverse': Yoga.FLEX_DIRECTION_COLUMN_REVERSE,
21
+ row: Yoga.FLEX_DIRECTION_ROW,
22
+ 'row-reverse': Yoga.FLEX_DIRECTION_ROW_REVERSE
23
+ };
24
+ const JUSTIFY = {
25
+ 'flex-start': Yoga.JUSTIFY_FLEX_START,
26
+ center: Yoga.JUSTIFY_CENTER,
27
+ 'flex-end': Yoga.JUSTIFY_FLEX_END,
28
+ 'space-between': Yoga.JUSTIFY_SPACE_BETWEEN,
29
+ 'space-around': Yoga.JUSTIFY_SPACE_AROUND,
30
+ 'space-evenly': Yoga.JUSTIFY_SPACE_EVENLY
31
+ };
32
+ const ALIGN = {
33
+ auto: Yoga.ALIGN_AUTO,
34
+ 'flex-start': Yoga.ALIGN_FLEX_START,
35
+ center: Yoga.ALIGN_CENTER,
36
+ 'flex-end': Yoga.ALIGN_FLEX_END,
37
+ stretch: Yoga.ALIGN_STRETCH,
38
+ baseline: Yoga.ALIGN_BASELINE
39
+ };
40
+ const WRAP = {
41
+ nowrap: Yoga.WRAP_NO_WRAP,
42
+ wrap: Yoga.WRAP_WRAP,
43
+ 'wrap-reverse': Yoga.WRAP_WRAP_REVERSE
44
+ };
45
+ const EDGES = [Yoga.EDGE_TOP, Yoga.EDGE_RIGHT, Yoga.EDGE_BOTTOM, Yoga.EDGE_LEFT];
46
+
47
+ const VOID_SKIP = new Set(['script', 'style', 'head', 'title', 'meta', 'link', 'template', 'noscript', 'iframe', 'object', 'embed', 'audio', 'video', 'canvas', 'input', 'select', 'textarea', 'button']);
48
+
49
+ /**
50
+ * A widget that renders a static subset of HTML + CSS into a window (or any
51
+ * 2d context target). No scripting, no network: documents are provided as
52
+ * strings, resources resolve only through an explicit allow-path (see
53
+ * "Resources" in docs/html.md).
54
+ *
55
+ * const view = new HtmlView(wnd, {
56
+ * onLink: (href) => console.log('clicked', href)
57
+ * });
58
+ * view.setHtml('<h1>Hello</h1><p>Some <b>html</b>.</p>');
59
+ * wnd.map();
60
+ *
61
+ * Layout: block flow and flexbox are computed by yoga-layout; inline runs
62
+ * are shaped and wrapped by the ntk text pipeline (TextLayout). Supported
63
+ * markup and CSS are documented in docs/html.md.
64
+ *
65
+ * Standalone (windowless) use mirrors MarkdownView:
66
+ * const view = new HtmlView(null, { fonts });
67
+ * view.setHtml(html); view.layout(width); view.draw(ctx, x, y);
68
+ */
69
+ export default class HtmlView {
70
+ constructor(window, opts = {}) {
71
+ this.window = window ?? null;
72
+ this._standaloneFonts = opts.fonts ?? null;
73
+ this.theme = { ...DEFAULT_THEME, ...(opts.theme || {}) };
74
+
75
+ /** called with (href, event, element) when a link is activated */
76
+ this.onLink = opts.onLink ?? null;
77
+ /** extra author stylesheet(s) applied after document <style>s */
78
+ this._userSheets = [].concat(opts.stylesheet ?? []);
79
+ /**
80
+ * resource loading policy:
81
+ * - a function `(url, { element }) => Promise<Buffer|Image|null>`
82
+ * - undefined: default loader — data: URIs always; file reads only
83
+ * when `baseUrl` is set, resolved relative to it
84
+ * - null/false: data: URIs only
85
+ */
86
+ this._loadResource = opts.loadResource;
87
+ this.baseUrl = opts.baseUrl ?? null;
88
+
89
+ this._dom = null;
90
+ this._styles = null;
91
+ this._root = null; // box tree root
92
+ this._images = new Map(); // <img> element -> { image, promise, failed }
93
+ this._links = [];
94
+ this._layoutWidth = -1;
95
+ this.contentHeight = 0;
96
+ this.scrollY = 0;
97
+
98
+ if (this.window) {
99
+ this._ctx = this.window.getContext('2d');
100
+ this.window.on('expose', () => this.render());
101
+ this.window.on('resize', () => {
102
+ this._layoutWidth = -1;
103
+ });
104
+ this.window.on('mousedown', (ev) => {
105
+ if (ev.keycode === 4 || ev.keycode === 5) {
106
+ // X11 wheel: buttons 4 (up) / 5 (down)
107
+ this.scrollBy(ev.keycode === 4 ? -48 : 48);
108
+ return;
109
+ }
110
+ if (ev.keycode !== 1) return;
111
+ const hit = this.linkAt(ev.x, ev.y);
112
+ if (hit && this.onLink) this.onLink(hit.href, ev, hit.element);
113
+ });
114
+ }
115
+ }
116
+
117
+ // -------------------------------------------------------------------------
118
+ // document lifecycle
119
+
120
+ /**
121
+ * Parse and adopt a new document. The widget never loads or replaces
122
+ * documents on its own — link clicks only fire `onLink`; navigation (e.g.
123
+ * fetching new content and calling `setHtml` again) belongs to the app.
124
+ *
125
+ * @param {string} html document or fragment markup
126
+ * @param {object} [opts] { baseUrl } — overrides the constructor baseUrl
127
+ * for this document (used to resolve relative image `src`)
128
+ */
129
+ setHtml(html, opts = {}) {
130
+ if (opts.baseUrl !== undefined) this.baseUrl = opts.baseUrl;
131
+ this._dom = parseDocument(String(html ?? ''));
132
+ const sheets = selectAll('style', this._dom).map((el) => textContent(el));
133
+ this._styles = computeStyles(this._dom, [...sheets, ...this._userSheets], {
134
+ rootFontSize: this.theme.size,
135
+ baseStyle: {
136
+ fontFamily: this.theme.family,
137
+ fontSize: this.theme.size,
138
+ color: cssColor(this.theme.color) ?? undefined
139
+ }
140
+ });
141
+ this._disposeTree();
142
+ for (const entry of this._images.values()) entry.image?.destroy();
143
+ this._images.clear();
144
+ const rootStyle = this._blockStyle();
145
+ this._root = this._buildBox(null, rootStyle, this._partition(this._dom.children, rootStyle));
146
+ this._collectImages(this._root);
147
+ this._layoutWidth = -1;
148
+ this.scrollY = 0;
149
+ if (this.window && this.window._mapped) this.render();
150
+ return this;
151
+ }
152
+
153
+ _blockStyle() {
154
+ return {
155
+ display: 'block',
156
+ color: cssColor(this.theme.color),
157
+ background: null,
158
+ fontFamily: this.theme.family,
159
+ fontSize: this.theme.size,
160
+ fontWeight: 400,
161
+ fontStyle: 'normal',
162
+ lineHeight: { mult: 1.25 },
163
+ textAlign: 'left',
164
+ textDecoration: 'none',
165
+ whiteSpace: 'normal',
166
+ listStyleType: 'disc',
167
+ margin: [{ px: 0 }, { px: 0 }, { px: 0 }, { px: 0 }],
168
+ padding: [{ px: 0 }, { px: 0 }, { px: 0 }, { px: 0 }],
169
+ borderWidth: [0, 0, 0, 0],
170
+ borderColor: [null, null, null, null],
171
+ borderStyle: ['none', 'none', 'none', 'none'],
172
+ width: 'auto',
173
+ height: 'auto',
174
+ minWidth: null,
175
+ maxWidth: null,
176
+ minHeight: null,
177
+ maxHeight: null,
178
+ flexDirection: 'column',
179
+ flexWrap: 'nowrap',
180
+ justifyContent: 'flex-start',
181
+ alignItems: 'stretch',
182
+ alignSelf: 'auto',
183
+ flexGrow: 0,
184
+ flexShrink: 1,
185
+ flexBasis: 'auto',
186
+ rowGap: 0,
187
+ columnGap: 0
188
+ };
189
+ }
190
+
191
+ _fonts() {
192
+ if (this.window) return this.window.app.fonts;
193
+ if (!this._standaloneFonts) {
194
+ throw new Error('HtmlView without a window needs { fonts: new FontManager() }');
195
+ }
196
+ return this._standaloneFonts;
197
+ }
198
+
199
+ // -------------------------------------------------------------------------
200
+ // box tree construction
201
+
202
+ /**
203
+ * Partition an element's children into block-level boxes and anonymous
204
+ * inline runs (which become text boxes). Returns child box descriptors.
205
+ */
206
+ _partition(children, containerStyle, inheritedHref = null) {
207
+ const out = [];
208
+ let run = []; // pending inline content: { el|text, style, href }
209
+
210
+ const flushRun = () => {
211
+ if (!run.length) return;
212
+ const spans = run;
213
+ run = [];
214
+ // drop runs that collapse to pure whitespace
215
+ const hasContent = spans.some((s) => s.text !== undefined ? /\S/.test(s.text) || s.pre : true);
216
+ if (!hasContent) return;
217
+ out.push({ kind: 'text', parts: spans });
218
+ };
219
+
220
+ const visitInline = (el, href) => {
221
+ const st = this._styles.get(el);
222
+ for (const child of el.children ?? []) {
223
+ if (child.type === 'text') {
224
+ run.push({ text: child.data, style: st, href, pre: st.whiteSpace.startsWith('pre') });
225
+ } else if (child.type === 'tag' || child.type === 'style' || child.type === 'script') {
226
+ dispatch(child, href);
227
+ }
228
+ }
229
+ };
230
+
231
+ const dispatch = (el, href) => {
232
+ if (VOID_SKIP.has(el.name)) return;
233
+ const st = this._styles.get(el);
234
+ if (!st || st.display === 'none') return;
235
+ const elHref = el.name === 'a' && el.attribs?.href ? el.attribs.href : href;
236
+ if (el.name === 'br') {
237
+ run.push({ text: '\n', style: st, href: elHref, pre: true });
238
+ return;
239
+ }
240
+ if (el.name === 'img' || el.name === 'svg') {
241
+ // replaced elements: inline <svg> renders through SvgView, like an
242
+ // image with an intrinsic size (its children never join text flow)
243
+ flushRun();
244
+ out.push({ kind: 'image', element: el, style: st, href: elHref });
245
+ return;
246
+ }
247
+ if (st.display === 'inline') {
248
+ visitInline(el, elHref);
249
+ return;
250
+ }
251
+ // block, flex, list-item, inline-block: a box of its own
252
+ flushRun();
253
+ out.push({ kind: 'box', element: el, style: st, href: elHref });
254
+ };
255
+
256
+ const containerPre = !!containerStyle?.whiteSpace?.startsWith('pre');
257
+ for (const node of children) {
258
+ if (node.type === 'text') {
259
+ run.push({ text: node.data, style: null, href: inheritedHref, pre: containerPre });
260
+ } else if (node.type === 'tag' || node.type === 'style' || node.type === 'script') {
261
+ dispatch(node, inheritedHref);
262
+ }
263
+ }
264
+ flushRun();
265
+ return out;
266
+ }
267
+
268
+ /** build a container box (and recursively its subtree) */
269
+ _buildBox(element, style, childDescs) {
270
+ const box = {
271
+ element,
272
+ style,
273
+ kind: 'container',
274
+ children: [],
275
+ href: null,
276
+ x: 0,
277
+ y: 0,
278
+ w: 0,
279
+ h: 0
280
+ };
281
+ let liIndex = 0;
282
+ for (const desc of childDescs) {
283
+ if (desc.kind === 'text') {
284
+ box.children.push(this._makeTextBox(desc.parts, style));
285
+ } else if (desc.kind === 'image') {
286
+ const child = {
287
+ element: desc.element,
288
+ style: desc.style,
289
+ kind: 'image',
290
+ children: [],
291
+ href: desc.href,
292
+ x: 0, y: 0, w: 0, h: 0
293
+ };
294
+ box.children.push(child);
295
+ } else {
296
+ const el = desc.element;
297
+ const st = desc.style;
298
+ if (st.display === 'list-item') st._liIndex = ++liIndex;
299
+ const child = this._buildBox(el, st, this._partition(el.children ?? [], st, desc.href));
300
+ child.href = desc.href;
301
+ box.children.push(child);
302
+ }
303
+ }
304
+ return box;
305
+ }
306
+
307
+ /** anonymous inline run -> text box with TextLayout spans */
308
+ _makeTextBox(parts, containerStyle) {
309
+ const fonts = this._fonts();
310
+ const spans = [];
311
+ let endedWithSpace = true; // strip leading whitespace at run start
312
+ for (const part of parts) {
313
+ const own = part.style != null; // inline element vs bare text node
314
+ const st = part.style ?? containerStyle;
315
+ let text = part.text;
316
+ if (!part.pre) {
317
+ text = text.replace(/[\t\n\r ]+/g, ' ');
318
+ if (endedWithSpace && text.startsWith(' ')) text = text.replace(/^ +/, '');
319
+ if (!text) continue;
320
+ endedWithSpace = text.endsWith(' ');
321
+ } else {
322
+ endedWithSpace = /[\s]$/.test(text);
323
+ }
324
+ // container background/decoration is drawn at box level; only inline
325
+ // elements' own decorations become span decorations
326
+ const deco =
327
+ own && (st.textDecoration !== 'none' || st.background)
328
+ ? {
329
+ underline: st.textDecoration === 'underline' ? st.color : null,
330
+ strike: st.textDecoration === 'line-through' ? st.color : null,
331
+ bg: st.background
332
+ }
333
+ : null;
334
+ spans.push({
335
+ text,
336
+ family: st.fontFamily,
337
+ size: st.fontSize,
338
+ weight: st.fontWeight,
339
+ style: st.fontStyle,
340
+ color: st.color,
341
+ _href: part.href,
342
+ _deco: deco
343
+ });
344
+ }
345
+ // strip trailing collapsible whitespace of the run
346
+ for (let i = spans.length - 1; i >= 0; i--) {
347
+ spans[i].text = spans[i].text.replace(/ +$/, '');
348
+ if (spans[i].text) break;
349
+ spans.splice(i, 1);
350
+ }
351
+
352
+ const style = containerStyle;
353
+ const m = fonts.match(style.fontFamily, { weight: style.fontWeight, style: style.fontStyle });
354
+ const natural = m.metrics(style.fontSize).lineHeight || style.fontSize * 1.2;
355
+ const desired = style.lineHeight.px !== undefined ? style.lineHeight.px : style.lineHeight.mult * style.fontSize;
356
+
357
+ return {
358
+ element: null,
359
+ style,
360
+ kind: 'text',
361
+ children: [],
362
+ spans,
363
+ align: style.textAlign,
364
+ nowrap: style.whiteSpace === 'nowrap' || style.whiteSpace === 'pre',
365
+ lineHeightMult: natural > 0 ? desired / natural : 1,
366
+ _layouts: new Map(),
367
+ x: 0, y: 0, w: 0, h: 0
368
+ };
369
+ }
370
+
371
+ _textLayout(box, width) {
372
+ const key = box.nowrap ? -1 : Math.max(0, Math.round(width));
373
+ let layout = box._layouts.get(key);
374
+ if (!layout) {
375
+ layout = new TextLayout(this._fonts(), box.spans.length ? box.spans : '', box.style, {
376
+ maxWidth: box.nowrap ? Infinity : key,
377
+ align: box.align,
378
+ lineHeight: box.lineHeightMult
379
+ });
380
+ box._layouts.set(key, layout);
381
+ }
382
+ return layout;
383
+ }
384
+
385
+ _collectImages(box) {
386
+ if (box.kind === 'image') {
387
+ const el = box.element;
388
+ if (el.name === 'svg') {
389
+ // inline svg: adopt the already-parsed element synchronously, so
390
+ // its intrinsic size is known on the first layout pass
391
+ if (!this._images.has(el)) {
392
+ const entry = { image: null, svg: null, failed: false };
393
+ this._images.set(el, entry);
394
+ try {
395
+ entry.svg = new SvgView(null).setSvgDom(el);
396
+ } catch {
397
+ entry.failed = true;
398
+ }
399
+ }
400
+ } else {
401
+ const src = el.attribs?.src;
402
+ if (src && !this._images.has(el)) {
403
+ const entry = { image: null, svg: null, failed: false };
404
+ this._images.set(el, entry);
405
+ this._fetchImage(src, el).then(
406
+ (res) => {
407
+ if (res instanceof SvgView) entry.svg = res;
408
+ else entry.image = res;
409
+ if (!res) entry.failed = true;
410
+ this._layoutWidth = -1;
411
+ if (this.window && this.window._mapped) this.render();
412
+ },
413
+ () => {
414
+ entry.failed = true;
415
+ this._layoutWidth = -1;
416
+ if (this.window && this.window._mapped) this.render();
417
+ }
418
+ );
419
+ }
420
+ }
421
+ }
422
+ for (const child of box.children) this._collectImages(child);
423
+ }
424
+
425
+ // decoded bytes -> Image (PNG/JPEG) or SvgView (sniffed SVG markup)
426
+ _decodeResource(buf) {
427
+ const head = buf.subarray(0, 1024).toString('utf8');
428
+ if (head.trimStart().startsWith('<') && /<svg[\s>]/i.test(head)) {
429
+ return new SvgView(null).setSvg(buf.toString('utf8'));
430
+ }
431
+ return decodeImage(buf);
432
+ }
433
+
434
+ /** resolve + decode an image source according to the resource policy */
435
+ async _fetchImage(src, element) {
436
+ if (src.startsWith('data:')) {
437
+ const svg = /^data:image\/svg\+xml(?:;charset=[\w-]+)?(;base64)?,(.*)$/s.exec(src);
438
+ if (svg) {
439
+ const text = svg[1] ? Buffer.from(svg[2], 'base64').toString('utf8') : decodeURIComponent(svg[2]);
440
+ return new SvgView(null).setSvg(text);
441
+ }
442
+ const m = /^data:image\/[\w+.-]+;base64,(.*)$/s.exec(src);
443
+ if (!m) return null;
444
+ return decodeImage(Buffer.from(m[1], 'base64'));
445
+ }
446
+ if (typeof this._loadResource === 'function') {
447
+ const res = await this._loadResource(src, { element });
448
+ if (res instanceof Image || res instanceof SvgView) return res;
449
+ if (res) return this._decodeResource(res);
450
+ return null;
451
+ }
452
+ if (this._loadResource === null || this._loadResource === false) return null;
453
+ // default policy: local files, only relative to an explicit baseUrl.
454
+ // node builtins are fetched lazily — in environments without them
455
+ // (browser bundles) pass a `loadResource` callback instead.
456
+ const getBuiltin = globalThis.process?.getBuiltinModule;
457
+ const fs = getBuiltin?.call(process, 'node:fs');
458
+ if (!fs || !this.baseUrl) return null;
459
+ let base = this.baseUrl;
460
+ if (typeof base === 'string' && !/^[a-z][a-z0-9+.-]*:/i.test(base)) {
461
+ // filesystem path; point at the directory itself when given one
462
+ let p = base;
463
+ if (!/[/\\]$/.test(p) && fs.existsSync(p) && fs.statSync(p).isDirectory()) p += '/';
464
+ base = getBuiltin.call(process, 'node:url').pathToFileURL(p);
465
+ }
466
+ const url = new URL(src, base);
467
+ if (url.protocol !== 'file:') return null;
468
+ return this._decodeResource(await fs.promises.readFile(url));
469
+ }
470
+
471
+ // -------------------------------------------------------------------------
472
+ // layout
473
+
474
+ /** lay the document out for a container width; returns content height */
475
+ layout(width) {
476
+ if (!this._root) return 0;
477
+ this._disposeYoga(this._root);
478
+ const yroot = this._yogaFor(this._root, true);
479
+ yroot.setWidth(width);
480
+ yroot.calculateLayout(width, undefined, Yoga.DIRECTION_LTR);
481
+ this._absolutize(this._root, 0, 0);
482
+ this.contentHeight = Math.ceil(this._root.h);
483
+ this._layoutWidth = width;
484
+ return this.contentHeight;
485
+ }
486
+
487
+ _yogaFor(box, isRoot = false) {
488
+ const node = Yoga.Node.create();
489
+ box.yoga = node;
490
+ const st = box.style;
491
+
492
+ if (box.kind === 'text') {
493
+ node.setMeasureFunc((w, wMode) => {
494
+ const maxW = wMode === Yoga.MEASURE_MODE_UNDEFINED ? Infinity : w;
495
+ const layout = this._textLayout(box, maxW === Infinity ? Infinity : maxW);
496
+ return { width: Math.ceil(layout.width), height: Math.ceil(layout.height) };
497
+ });
498
+ return node;
499
+ }
500
+
501
+ if (box.kind === 'image') {
502
+ this._applyBoxStyle(node, st);
503
+ const entry = this._images.get(box.element);
504
+ const img = entry?.image;
505
+ const cssW = st.width;
506
+ const cssH = st.height;
507
+ const natW = img?.width ?? entry?.svg?.naturalWidth ?? (entry?.failed ? 80 : 0);
508
+ const natH = img?.height ?? entry?.svg?.naturalHeight ?? (entry?.failed ? 40 : 0);
509
+ if (cssW === 'auto' && cssH === 'auto') {
510
+ // natural size, shrunk (ratio-preserving) to the available width; a
511
+ // measure function is used because yoga's min/max clamping does not
512
+ // recompute the aspect-ratio-derived dimension
513
+ if (st.alignSelf === 'auto') node.setAlignSelf(Yoga.ALIGN_FLEX_START);
514
+ node.setMeasureFunc((w, wMode) => {
515
+ let iw = natW;
516
+ if (wMode !== Yoga.MEASURE_MODE_UNDEFINED && w < iw) iw = w;
517
+ return { width: iw, height: natW > 0 ? (iw * natH) / natW : natH };
518
+ });
519
+ } else if (natW > 0 && natH > 0 && (cssW === 'auto' || cssH === 'auto')) {
520
+ // one dimension styled: the other follows the natural ratio
521
+ node.setAspectRatio(natW / natH);
522
+ }
523
+ return node;
524
+ }
525
+
526
+ // container
527
+ this._applyBoxStyle(node, st);
528
+ if (st.display === 'flex') {
529
+ node.setFlexDirection(FLEX_DIRECTION[st.flexDirection]);
530
+ node.setFlexWrap(WRAP[st.flexWrap]);
531
+ node.setJustifyContent(JUSTIFY[st.justifyContent]);
532
+ node.setAlignItems(ALIGN[st.alignItems] ?? Yoga.ALIGN_STRETCH);
533
+ node.setGap(Yoga.GUTTER_ROW, st.rowGap);
534
+ node.setGap(Yoga.GUTTER_COLUMN, st.columnGap);
535
+ } else {
536
+ node.setFlexDirection(Yoga.FLEX_DIRECTION_COLUMN);
537
+ node.setAlignItems(Yoga.ALIGN_STRETCH);
538
+ }
539
+
540
+ // children; approximate CSS adjacent-margin collapsing in block flow
541
+ const collapse = st.display !== 'flex';
542
+ let prevBottom = 0;
543
+ box.children.forEach((child, i) => {
544
+ const cnode = this._yogaFor(child);
545
+ if (collapse && child.style) {
546
+ const mt = child.style.margin?.[0];
547
+ const mb = child.style.margin?.[2];
548
+ const topPx = mt && mt.px !== undefined ? mt.px : 0;
549
+ if (i > 0 && mt && mt.px !== undefined) {
550
+ cnode.setMargin(Yoga.EDGE_TOP, Math.max(topPx, prevBottom) - prevBottom);
551
+ }
552
+ prevBottom = mb && mb.px !== undefined ? mb.px : 0;
553
+ }
554
+ node.insertChild(cnode, i);
555
+ });
556
+ return node;
557
+ }
558
+
559
+ _applyBoxStyle(node, st) {
560
+ const setLen = (val, pxFn, pctFn, autoFn) => {
561
+ if (val === 'auto') {
562
+ if (autoFn) autoFn();
563
+ } else if (val && val.px !== undefined) {
564
+ pxFn(val.px);
565
+ } else if (val && val.pct !== undefined && pctFn) {
566
+ pctFn(val.pct);
567
+ }
568
+ };
569
+
570
+ for (let i = 0; i < 4; i++) {
571
+ const edge = EDGES[i];
572
+ setLen(
573
+ st.margin[i],
574
+ (v) => node.setMargin(edge, v),
575
+ (v) => node.setMarginPercent(edge, v),
576
+ () => node.setMarginAuto(edge)
577
+ );
578
+ setLen(
579
+ st.padding[i],
580
+ (v) => node.setPadding(edge, v),
581
+ (v) => node.setPaddingPercent(edge, v)
582
+ );
583
+ if (st.borderWidth[i] > 0) node.setBorder(edge, st.borderWidth[i]);
584
+ }
585
+
586
+ setLen(st.width, (v) => node.setWidth(v), (v) => node.setWidthPercent(v), () => node.setWidthAuto());
587
+ setLen(st.height, (v) => node.setHeight(v), (v) => node.setHeightPercent(v), () => node.setHeightAuto());
588
+ setLen(st.minWidth, (v) => node.setMinWidth(v), (v) => node.setMinWidthPercent(v));
589
+ setLen(st.maxWidth, (v) => node.setMaxWidth(v), (v) => node.setMaxWidthPercent(v));
590
+ setLen(st.minHeight, (v) => node.setMinHeight(v), (v) => node.setMinHeightPercent(v));
591
+ setLen(st.maxHeight, (v) => node.setMaxHeight(v), (v) => node.setMaxHeightPercent(v));
592
+
593
+ node.setFlexGrow(st.flexGrow);
594
+ node.setFlexShrink(st.flexShrink);
595
+ setLen(st.flexBasis, (v) => node.setFlexBasis(v), (v) => node.setFlexBasisPercent(v), () => node.setFlexBasisAuto());
596
+ if (st.alignSelf !== 'auto') node.setAlignSelf(ALIGN[st.alignSelf] ?? Yoga.ALIGN_AUTO);
597
+ }
598
+
599
+ /** copy computed yoga geometry into absolute box coordinates */
600
+ _absolutize(box, ox, oy) {
601
+ const node = box.yoga;
602
+ box.x = ox + node.getComputedLeft();
603
+ box.y = oy + node.getComputedTop();
604
+ box.w = node.getComputedWidth();
605
+ box.h = node.getComputedHeight();
606
+ box.contentX = box.x + node.getComputedBorder(Yoga.EDGE_LEFT) + node.getComputedPadding(Yoga.EDGE_LEFT);
607
+ box.contentY = box.y + node.getComputedBorder(Yoga.EDGE_TOP) + node.getComputedPadding(Yoga.EDGE_TOP);
608
+ box.contentW =
609
+ box.w -
610
+ node.getComputedBorder(Yoga.EDGE_LEFT) - node.getComputedBorder(Yoga.EDGE_RIGHT) -
611
+ node.getComputedPadding(Yoga.EDGE_LEFT) - node.getComputedPadding(Yoga.EDGE_RIGHT);
612
+ box.contentH =
613
+ box.h -
614
+ node.getComputedBorder(Yoga.EDGE_TOP) - node.getComputedBorder(Yoga.EDGE_BOTTOM) -
615
+ node.getComputedPadding(Yoga.EDGE_TOP) - node.getComputedPadding(Yoga.EDGE_BOTTOM);
616
+ for (const child of box.children) this._absolutize(child, box.x, box.y);
617
+ }
618
+
619
+ _disposeYoga(box) {
620
+ if (box.yoga) {
621
+ if (!box.yoga.getParent()) box.yoga.freeRecursive();
622
+ box.yoga = null;
623
+ for (const child of box.children) child.yoga = null, this._disposeYoga(child);
624
+ }
625
+ if (box._layouts) box._layouts.clear();
626
+ }
627
+
628
+ _disposeTree() {
629
+ if (this._root) {
630
+ this._disposeYoga(this._root);
631
+ this._root = null;
632
+ }
633
+ }
634
+
635
+ // -------------------------------------------------------------------------
636
+ // drawing and hit testing
637
+
638
+ /** draw the last layout() onto a 2d context at (ox, oy) */
639
+ draw(ctx, ox = 0, oy = 0) {
640
+ this._links = [];
641
+ if (this._root) this._drawBox(ctx, this._root, ox, oy);
642
+ return this;
643
+ }
644
+
645
+ _drawBox(ctx, box, ox, oy) {
646
+ const st = box.style;
647
+ const x = ox + box.x;
648
+ const y = oy + box.y;
649
+
650
+ if (box.kind === 'text') {
651
+ // anonymous text boxes share the container's style object — the
652
+ // container already painted its background and borders
653
+ this._drawText(ctx, box, ox, oy);
654
+ return;
655
+ }
656
+
657
+ if (st?.background) {
658
+ ctx.fillStyle = st.background;
659
+ ctx.fillRect(Math.round(x), Math.round(y), Math.ceil(box.w), Math.ceil(box.h));
660
+ }
661
+ if (st) this._drawBorders(ctx, box, x, y);
662
+
663
+ if (box.kind === 'image') {
664
+ this._drawImage(ctx, box, x, y);
665
+ if (box.href) {
666
+ this._links.push({ x, y, w: box.w, h: box.h, href: box.href, element: box.element });
667
+ }
668
+ return;
669
+ }
670
+
671
+ if (st?.display === 'list-item') this._drawListMarker(ctx, box, ox, oy);
672
+ for (const child of box.children) this._drawBox(ctx, child, ox, oy);
673
+ }
674
+
675
+ _drawBorders(ctx, box, x, y) {
676
+ const st = box.style;
677
+ const bw = st.borderWidth;
678
+ if (!bw.some((w, i) => w > 0 && st.borderStyle[i] !== 'none')) return;
679
+ const color = (i) => st.borderColor[i] ?? st.color;
680
+ const w = Math.ceil(box.w);
681
+ const h = Math.ceil(box.h);
682
+ x = Math.round(x);
683
+ y = Math.round(y);
684
+ if (bw[0] > 0 && st.borderStyle[0] !== 'none') {
685
+ ctx.fillStyle = color(0);
686
+ ctx.fillRect(x, y, w, Math.round(bw[0]));
687
+ }
688
+ if (bw[2] > 0 && st.borderStyle[2] !== 'none') {
689
+ ctx.fillStyle = color(2);
690
+ ctx.fillRect(x, y + h - Math.round(bw[2]), w, Math.round(bw[2]));
691
+ }
692
+ if (bw[3] > 0 && st.borderStyle[3] !== 'none') {
693
+ ctx.fillStyle = color(3);
694
+ ctx.fillRect(x, y, Math.round(bw[3]), h);
695
+ }
696
+ if (bw[1] > 0 && st.borderStyle[1] !== 'none') {
697
+ ctx.fillStyle = color(1);
698
+ ctx.fillRect(x + w - Math.round(bw[1]), y, Math.round(bw[1]), h);
699
+ }
700
+ }
701
+
702
+ _drawText(ctx, box, ox, oy) {
703
+ const layout = this._textLayout(box, box.w);
704
+ const x = ox + box.x;
705
+ const y = oy + box.y;
706
+ for (const line of layout.lines) {
707
+ for (const r of line.runs) {
708
+ const m = r.run.font.metrics(r.run.size);
709
+ if (r.span._href) {
710
+ this._links.push({
711
+ x: x + line.x + r.x,
712
+ y: y + line.baseline - m.ascent,
713
+ w: r.width,
714
+ h: m.ascent + m.descent,
715
+ href: r.span._href,
716
+ element: null
717
+ });
718
+ }
719
+ const deco = r.span._deco;
720
+ if (!deco) continue;
721
+ if (deco.bg) {
722
+ ctx.fillStyle = deco.bg;
723
+ ctx.fillRect(
724
+ Math.round(x + line.x + r.x),
725
+ Math.round(y + line.baseline - m.ascent),
726
+ Math.ceil(r.width),
727
+ Math.ceil(m.ascent + m.descent)
728
+ );
729
+ }
730
+ if (deco.underline) {
731
+ ctx.fillStyle = deco.underline;
732
+ ctx.fillRect(Math.round(x + line.x + r.x), Math.round(y + line.baseline + 2), Math.ceil(r.width), 1);
733
+ }
734
+ if (deco.strike) {
735
+ ctx.fillStyle = deco.strike;
736
+ ctx.fillRect(
737
+ Math.round(x + line.x + r.x),
738
+ Math.round(y + line.baseline - m.ascent * 0.3),
739
+ Math.ceil(r.width),
740
+ 1
741
+ );
742
+ }
743
+ }
744
+ }
745
+ layout.draw(ctx, x, y);
746
+ }
747
+
748
+ _drawImage(ctx, box, x, y) {
749
+ const entry = this._images.get(box.element);
750
+ const img = entry?.image;
751
+ const w = box.contentW ?? box.w;
752
+ const h = box.contentH ?? box.h;
753
+ if (img) {
754
+ if (w > 0 && h > 0) ctx.drawImage(img, Math.round(box.contentX ?? x), Math.round(box.contentY ?? y), Math.round(w), Math.round(h));
755
+ return;
756
+ }
757
+ if (entry?.svg) {
758
+ if (w > 0 && h > 0) entry.svg.draw(ctx, Math.round(box.contentX ?? x), Math.round(box.contentY ?? y), Math.round(w), Math.round(h));
759
+ return;
760
+ }
761
+ // placeholder: light box with alt text
762
+ if (box.w > 2 && box.h > 2) {
763
+ ctx.fillStyle = '#eeeeee';
764
+ ctx.fillRect(Math.round(x), Math.round(y), Math.ceil(box.w), Math.ceil(box.h));
765
+ const alt = box.element.attribs?.alt;
766
+ if (alt) {
767
+ const layout = new TextLayout(this._fonts(), alt, { ...box.style, fontSize: 12 }, {
768
+ maxWidth: Math.max(10, box.w - 8)
769
+ });
770
+ ctx.fillStyle = '#666666';
771
+ layout.draw(ctx, x + 4, y + 4);
772
+ }
773
+ }
774
+ }
775
+
776
+ _drawListMarker(ctx, box, ox, oy) {
777
+ const st = box.style;
778
+ const n = st._liIndex ?? 1;
779
+ let marker;
780
+ switch (st.listStyleType) {
781
+ case 'none':
782
+ return;
783
+ case 'decimal':
784
+ marker = `${n}.`;
785
+ break;
786
+ case 'lower-alpha':
787
+ case 'lower-roman':
788
+ marker = `${String.fromCharCode(96 + ((n - 1) % 26) + 1)}.`;
789
+ break;
790
+ case 'upper-alpha':
791
+ case 'upper-roman':
792
+ marker = `${String.fromCharCode(64 + ((n - 1) % 26) + 1)}.`;
793
+ break;
794
+ case 'circle':
795
+ marker = '◦';
796
+ break;
797
+ case 'square':
798
+ marker = '▪';
799
+ break;
800
+ default:
801
+ marker = '•';
802
+ }
803
+ const layout = new TextLayout(this._fonts(), marker, st, {});
804
+ layout.draw(ctx, ox + box.x - layout.width - st.fontSize * 0.4, oy + box.y);
805
+ }
806
+
807
+ /**
808
+ * The link under a point (device coordinates of the last draw), or null.
809
+ * Returns `{ href, element }`.
810
+ */
811
+ linkAt(x, y) {
812
+ for (let i = this._links.length - 1; i >= 0; i--) {
813
+ const l = this._links[i];
814
+ if (x >= l.x && x < l.x + l.w && y >= l.y && y < l.y + l.h) return l;
815
+ }
816
+ return null;
817
+ }
818
+
819
+ /**
820
+ * The deepest DOM element whose box contains the point (coordinates as in
821
+ * the last draw), or null. Text runs report their nearest element ancestor.
822
+ */
823
+ elementAt(x, y) {
824
+ const scroll = this.window ? this.scrollY : 0;
825
+ const hit = (box, px, py, best) => {
826
+ if (px >= box.x && px < box.x + box.w && py >= box.y && py < box.y + box.h) {
827
+ if (box.element) best = box.element;
828
+ for (const child of box.children) best = hit(child, px, py, best);
829
+ }
830
+ return best;
831
+ };
832
+ return this._root ? hit(this._root, x, y + scroll, null) : null;
833
+ }
834
+
835
+ // -------------------------------------------------------------------------
836
+ // window mode
837
+
838
+ /** scroll the document vertically (window mode); clamps to content */
839
+ scrollBy(dy) {
840
+ return this.scrollTo(this.scrollY + dy);
841
+ }
842
+
843
+ scrollTo(yPos) {
844
+ if (!this.window) return this;
845
+ const max = Math.max(0, this.contentHeight - this.window.height);
846
+ const next = Math.max(0, Math.min(max, yPos));
847
+ if (next !== this.scrollY) {
848
+ this.scrollY = next;
849
+ this.render();
850
+ }
851
+ return this;
852
+ }
853
+
854
+ /** full window render: (re)layout on size change, clear, draw */
855
+ render() {
856
+ if (!this.window) throw new Error('HtmlView.render() needs a window');
857
+ const ctx = this._ctx;
858
+ if (this._layoutWidth !== this.window.width) {
859
+ this.layout(this.window.width);
860
+ const max = Math.max(0, this.contentHeight - this.window.height);
861
+ if (this.scrollY > max) this.scrollY = max;
862
+ }
863
+ ctx.fillStyle = this.theme.background;
864
+ ctx.fillRect(0, 0, this.window.width, this.window.height);
865
+ this.draw(ctx, 0, -this.scrollY);
866
+ return this;
867
+ }
868
+
869
+ /** free yoga nodes and server-side image uploads */
870
+ destroy() {
871
+ this._disposeTree();
872
+ for (const entry of this._images.values()) entry.image?.destroy();
873
+ this._images.clear();
874
+ }
875
+
876
+ [Symbol.dispose]() {
877
+ this.destroy();
878
+ }
879
+ }