ntk 2.1.1 → 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 (48) hide show
  1. package/README.md +115 -110
  2. package/lib/app.js +48 -15
  3. package/lib/cleanup.js +14 -0
  4. package/lib/drawable.js +10 -16
  5. package/lib/events_map.js +81 -87
  6. package/lib/fontconfig.js +122 -14
  7. package/lib/glyphset.js +36 -18
  8. package/lib/image.js +138 -0
  9. package/lib/index.js +110 -52
  10. package/lib/path.js +734 -0
  11. package/lib/picture.js +76 -38
  12. package/lib/pixmap.js +34 -25
  13. package/lib/rasterize.js +178 -0
  14. package/lib/renderingcontext_2d.js +994 -517
  15. package/lib/renderingcontext_opengl.js +38 -31
  16. package/lib/renderingcontext_x11.js +56 -64
  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 +568 -246
  33. package/package.json +35 -32
  34. package/.npmignore +0 -15
  35. package/examples/example-await.js +0 -23
  36. package/examples/example-co.js +0 -13
  37. package/examples/example.js +0 -14
  38. package/examples/fill-styles.js +0 -39
  39. package/examples/glclock.js +0 -100
  40. package/examples/glxpixmap.js +0 -12
  41. package/examples/gradient.js +0 -41
  42. package/examples/package.json +0 -17
  43. package/examples/simpletex.js +0 -72
  44. package/examples/teapot.js +0 -104
  45. package/examples/teapot.json +0 -6
  46. package/examples/text-rendering.js +0 -44
  47. package/lib/fontface.js +0 -109
  48. package/lib/widgets/webview.js +0 -33
@@ -0,0 +1,676 @@
1
+ // CSS support for HtmlView: stylesheet parsing (postcss), selector matching
2
+ // (css-select), cascade (specificity + order + !important), inheritance and
3
+ // computed-value resolution for the property subset the widget renders.
4
+ import { selectAll } from 'css-select';
5
+ import postcss from 'postcss';
6
+
7
+ import parseColorRaw from 'parse-color';
8
+
9
+ /** parse a CSS color to premultipliable [r, g, b, a] floats, or null */
10
+ export function cssColor(value) {
11
+ if (!value) return null;
12
+ if (value === 'transparent') return [0, 0, 0, 0];
13
+ const c = parseColorRaw(String(value).trim());
14
+ if (!c || !c.rgba) return null;
15
+ return [c.rgba[0] / 255, c.rgba[1] / 255, c.rgba[2] / 255, c.rgba[3]];
16
+ }
17
+
18
+ const FONT_SIZE_KEYWORDS = {
19
+ 'xx-small': 9,
20
+ 'x-small': 10,
21
+ small: 13,
22
+ medium: 16,
23
+ large: 18,
24
+ 'x-large': 24,
25
+ 'xx-large': 32
26
+ };
27
+
28
+ /**
29
+ * Parse a CSS length into `{ px }`, `{ pct }` or `'auto'`; null if
30
+ * unsupported. `em` resolves against `emBase` (the element's font size).
31
+ */
32
+ export function cssLength(value, emBase = 16, rootSize = 16) {
33
+ if (value == null) return null;
34
+ value = String(value).trim();
35
+ if (value === 'auto') return 'auto';
36
+ if (value === '0') return { px: 0 };
37
+ const m = /^(-?\d*\.?\d+)(px|pt|em|rem|%)$/.exec(value);
38
+ if (!m) return null;
39
+ const n = parseFloat(m[1]);
40
+ switch (m[2]) {
41
+ case 'px':
42
+ return { px: n };
43
+ case 'pt':
44
+ return { px: (n * 4) / 3 };
45
+ case 'em':
46
+ return { px: n * emBase };
47
+ case 'rem':
48
+ return { px: n * rootSize };
49
+ case '%':
50
+ return { pct: n };
51
+ }
52
+ return null;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // specificity
57
+
58
+ // (ids, classes/attributes/pseudo-classes, types/pseudo-elements) packed into
59
+ // one comparable number. Computed textually from the selector — robust enough
60
+ // for the supported subset.
61
+ export function specificity(selector) {
62
+ let s = selector
63
+ .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, 'v') // string literals
64
+ .replace(/\\./g, 'c'); // escaped chars
65
+ let b = 0;
66
+ // attribute selectors (may contain anything except ])
67
+ s = s.replace(/\[[^\]]*\]/g, () => ((b += 1), ' '));
68
+ let a = 0;
69
+ s = s.replace(/#[\w-]+/g, () => ((a += 1), ' '));
70
+ s = s.replace(/\.[\w-]+/g, () => ((b += 1), ' '));
71
+ let c = 0;
72
+ s = s.replace(/::[\w-]+/g, () => ((c += 1), ' '));
73
+ // :not() adds the specificity of its argument but not itself
74
+ s = s.replace(/:not\(/g, '(');
75
+ s = s.replace(/:[\w-]+(\([^)]*\))?/g, () => ((b += 1), ' '));
76
+ s.replace(/(^|[\s>+~(,])[a-zA-Z][\w-]*/g, () => ((c += 1), ' '));
77
+ return a * 1_000_000 + b * 1_000 + c;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // stylesheet parsing
82
+
83
+ // media queries are not evaluated: rules under a plain `screen`/`all` @media
84
+ // apply, anything conditional is skipped
85
+ const APPLIED_MEDIA = /^\s*(all|screen)?\s*$/;
86
+
87
+ /**
88
+ * Parse a stylesheet into flat rule entries
89
+ * `{ selector, specificity, decls: [{ prop, value, important }] }`.
90
+ * Invalid CSS is dropped (postcss safe-ish: parse errors throw — callers
91
+ * treat a throwing sheet as empty).
92
+ */
93
+ export function parseStylesheet(cssText) {
94
+ let root;
95
+ try {
96
+ root = postcss.parse(cssText);
97
+ } catch {
98
+ return [];
99
+ }
100
+ const rules = [];
101
+ root.walkRules((rule) => {
102
+ if (rule.parent?.type === 'atrule') {
103
+ const at = rule.parent;
104
+ if (at.name !== 'media' || !APPLIED_MEDIA.test(at.params)) return;
105
+ }
106
+ const decls = [];
107
+ for (const node of rule.nodes ?? []) {
108
+ if (node.type !== 'decl') continue;
109
+ decls.push({ prop: node.prop.toLowerCase(), value: node.value.trim(), important: !!node.important });
110
+ }
111
+ if (!decls.length) return;
112
+ for (const selector of rule.selectors ?? [rule.selector]) {
113
+ rules.push({ selector: selector.trim(), specificity: specificity(selector), decls });
114
+ }
115
+ });
116
+ return rules;
117
+ }
118
+
119
+ /** parse an inline `style="..."` attribute into decl entries */
120
+ export function parseInlineStyle(text) {
121
+ const rules = parseStylesheet(`x{${text}}`);
122
+ return rules.length ? rules[0].decls : [];
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // the UA stylesheet
127
+
128
+ export const UA_STYLESHEET = `
129
+ html, body, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, dl, dt, dd,
130
+ blockquote, pre, hr, address, figure, figcaption, fieldset, form,
131
+ table, caption, section, article, header, footer, nav, aside, main,
132
+ details, summary { display: block }
133
+ head, style, script, title, meta, link, template, noscript { display: none }
134
+ body { margin: 8px }
135
+ p, blockquote, ul, ol, dl, pre, table, figure { margin-top: 1em; margin-bottom: 1em }
136
+ h1 { font-size: 2em; margin-top: 0.67em; margin-bottom: 0.67em; font-weight: bold }
137
+ h2 { font-size: 1.5em; margin-top: 0.83em; margin-bottom: 0.83em; font-weight: bold }
138
+ h3 { font-size: 1.17em; margin-top: 1em; margin-bottom: 1em; font-weight: bold }
139
+ h4 { margin-top: 1.33em; margin-bottom: 1.33em; font-weight: bold }
140
+ h5 { font-size: 0.83em; margin-top: 1.67em; margin-bottom: 1.67em; font-weight: bold }
141
+ h6 { font-size: 0.75em; margin-top: 2.33em; margin-bottom: 2.33em; font-weight: bold }
142
+ ul, ol { padding-left: 40px }
143
+ ul { list-style-type: disc }
144
+ ol { list-style-type: decimal }
145
+ ul ul, ol ul { list-style-type: circle }
146
+ li { display: list-item }
147
+ b, strong { font-weight: bold }
148
+ i, em, cite, var { font-style: italic }
149
+ u, ins { text-decoration: underline }
150
+ s, strike, del { text-decoration: line-through }
151
+ code, pre, kbd, samp, tt { font-family: monospace; font-size: 0.9em }
152
+ pre { white-space: pre }
153
+ a { color: #0b61c9; text-decoration: underline }
154
+ blockquote { margin-left: 40px; margin-right: 40px }
155
+ figure { margin-left: 40px; margin-right: 40px }
156
+ hr { border-bottom: 1px solid #999999; margin-top: 0.5em; margin-bottom: 0.5em }
157
+ img { display: inline-block }
158
+ table { display: flex; flex-direction: column }
159
+ thead, tbody, tfoot { display: flex; flex-direction: column }
160
+ tr { display: flex; flex-direction: row }
161
+ td, th { display: flex; flex-direction: column; flex: 1; padding: 3px }
162
+ th { font-weight: bold }
163
+ center { display: block; text-align: center }
164
+ big { font-size: 1.2em }
165
+ small, sub, sup { font-size: 0.83em }
166
+ dd { margin-left: 40px }
167
+ dt { font-weight: bold }
168
+ summary { font-weight: bold }
169
+ `;
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // computed styles
173
+
174
+ const INHERITED = new Set([
175
+ 'color',
176
+ 'font-family',
177
+ 'font-size',
178
+ 'font-weight',
179
+ 'font-style',
180
+ 'line-height',
181
+ 'text-align',
182
+ 'white-space',
183
+ 'list-style-type'
184
+ ]);
185
+
186
+ export const DEFAULT_STYLE = {
187
+ display: 'inline',
188
+ color: [0.133, 0.133, 0.133, 1],
189
+ background: null,
190
+ fontFamily: 'sans-serif',
191
+ fontSize: 16,
192
+ fontWeight: 400,
193
+ fontStyle: 'normal',
194
+ lineHeight: { mult: 1.25 },
195
+ textAlign: 'left',
196
+ textDecoration: 'none',
197
+ whiteSpace: 'normal',
198
+ listStyleType: 'disc',
199
+ margin: ['auto0', 'auto0', 'auto0', 'auto0'], // see note below
200
+ padding: [{ px: 0 }, { px: 0 }, { px: 0 }, { px: 0 }],
201
+ borderWidth: [0, 0, 0, 0],
202
+ borderColor: [null, null, null, null], // null -> currentColor
203
+ borderStyle: ['none', 'none', 'none', 'none'],
204
+ width: 'auto',
205
+ height: 'auto',
206
+ minWidth: null,
207
+ maxWidth: null,
208
+ minHeight: null,
209
+ maxHeight: null,
210
+ flexDirection: 'row',
211
+ flexWrap: 'nowrap',
212
+ justifyContent: 'flex-start',
213
+ alignItems: 'stretch',
214
+ alignSelf: 'auto',
215
+ flexGrow: 0,
216
+ flexShrink: 1,
217
+ flexBasis: 'auto',
218
+ rowGap: 0,
219
+ columnGap: 0
220
+ };
221
+
222
+ const SIDES = ['top', 'right', 'bottom', 'left'];
223
+
224
+ // expand 1-4 value shorthand into [top, right, bottom, left] value strings
225
+ function expandBox(value) {
226
+ const parts = value.split(/\s+/).filter(Boolean);
227
+ if (parts.length === 0) return null;
228
+ const [a, b = a, c = a, d = b] = parts;
229
+ return [a, b, c, d];
230
+ }
231
+
232
+ function splitOutsideParens(value) {
233
+ const out = [];
234
+ let depth = 0;
235
+ let cur = '';
236
+ for (const ch of value) {
237
+ if (ch === '(') depth++;
238
+ if (ch === ')') depth--;
239
+ if (/\s/.test(ch) && depth === 0) {
240
+ if (cur) out.push(cur);
241
+ cur = '';
242
+ } else {
243
+ cur += ch;
244
+ }
245
+ }
246
+ if (cur) out.push(cur);
247
+ return out;
248
+ }
249
+
250
+ // border shorthand: any of width / style / color in any order
251
+ function parseBorderShorthand(value) {
252
+ const out = { width: null, style: null, color: null };
253
+ for (const part of splitOutsideParens(value)) {
254
+ if (/^(none|hidden|solid|dashed|dotted|double|groove|ridge|inset|outset)$/.test(part)) {
255
+ out.style = part === 'hidden' ? 'none' : part;
256
+ } else if (/^(thin|medium|thick)$/.test(part)) {
257
+ out.width = { thin: 1, medium: 3, thick: 5 }[part];
258
+ } else if (cssLength(part) && cssLength(part) !== 'auto') {
259
+ out.width = cssLength(part).px ?? null;
260
+ } else if (cssColor(part)) {
261
+ out.color = cssColor(part);
262
+ }
263
+ }
264
+ return out;
265
+ }
266
+
267
+ const DISPLAY_VALUES = new Set([
268
+ 'block',
269
+ 'inline',
270
+ 'inline-block',
271
+ 'flex',
272
+ 'inline-flex',
273
+ 'list-item',
274
+ 'none'
275
+ ]);
276
+
277
+ /**
278
+ * Apply one declaration to a computed style object (mutates `st`).
279
+ * `emBase` is the element's own font size (already resolved), used for
280
+ * `em` lengths on non-font-size properties.
281
+ */
282
+ function applyDecl(st, prop, value, emBase, rootSize, parent) {
283
+ const len = (v) => cssLength(v, emBase, rootSize);
284
+ const px = (v) => {
285
+ const l = len(v);
286
+ return l && l !== 'auto' && l.px !== undefined ? l.px : null;
287
+ };
288
+ const sizeVal = (v) => {
289
+ const l = len(v);
290
+ if (l === 'auto') return 'auto';
291
+ if (!l) return null;
292
+ return l; // {px} or {pct}
293
+ };
294
+
295
+ switch (prop) {
296
+ case 'display': {
297
+ const v = value.toLowerCase();
298
+ if (DISPLAY_VALUES.has(v)) st.display = v === 'inline-flex' ? 'flex' : v;
299
+ break;
300
+ }
301
+ case 'color': {
302
+ const c = cssColor(value);
303
+ if (c) st.color = c;
304
+ break;
305
+ }
306
+ case 'background':
307
+ case 'background-color': {
308
+ if (value === 'none') {
309
+ st.background = null;
310
+ break;
311
+ }
312
+ // background shorthand: take the first color-looking token
313
+ for (const part of splitOutsideParens(value)) {
314
+ const c = cssColor(part);
315
+ if (c) {
316
+ st.background = c;
317
+ break;
318
+ }
319
+ }
320
+ break;
321
+ }
322
+ case 'font-family':
323
+ st.fontFamily = value.split(',')[0].trim().replace(/^["']|["']$/g, '') || st.fontFamily;
324
+ break;
325
+ case 'font-size': {
326
+ const v = value.toLowerCase();
327
+ if (FONT_SIZE_KEYWORDS[v] !== undefined) {
328
+ st.fontSize = FONT_SIZE_KEYWORDS[v];
329
+ } else {
330
+ // em/% for font-size resolve against the parent font size
331
+ const l = cssLength(v, parent.fontSize, rootSize);
332
+ if (l && l !== 'auto') {
333
+ st.fontSize = l.px !== undefined ? l.px : (l.pct / 100) * parent.fontSize;
334
+ }
335
+ }
336
+ break;
337
+ }
338
+ case 'font-weight': {
339
+ const v = value.toLowerCase();
340
+ if (v === 'bold' || v === 'bolder') st.fontWeight = 700;
341
+ else if (v === 'normal' || v === 'lighter') st.fontWeight = 400;
342
+ else if (/^\d+$/.test(v)) st.fontWeight = parseInt(v, 10);
343
+ break;
344
+ }
345
+ case 'font-style': {
346
+ const v = value.toLowerCase();
347
+ if (v === 'italic' || v === 'oblique') st.fontStyle = 'italic';
348
+ else if (v === 'normal') st.fontStyle = 'normal';
349
+ break;
350
+ }
351
+ case 'line-height': {
352
+ const v = value.toLowerCase();
353
+ if (v === 'normal') st.lineHeight = { mult: 1.25 };
354
+ else if (/^\d*\.?\d+$/.test(v)) st.lineHeight = { mult: parseFloat(v) };
355
+ else {
356
+ const l = len(v);
357
+ if (l && l.px !== undefined) st.lineHeight = { px: l.px };
358
+ else if (l && l.pct !== undefined) st.lineHeight = { mult: l.pct / 100 };
359
+ }
360
+ break;
361
+ }
362
+ case 'text-align': {
363
+ const v = value.toLowerCase();
364
+ if (['left', 'right', 'center', 'justify', 'start', 'end'].includes(v)) {
365
+ st.textAlign = v === 'justify' ? 'left' : v === 'start' ? 'left' : v === 'end' ? 'right' : v;
366
+ }
367
+ break;
368
+ }
369
+ case 'text-decoration':
370
+ case 'text-decoration-line': {
371
+ const v = value.toLowerCase();
372
+ if (v.includes('underline')) st.textDecoration = 'underline';
373
+ else if (v.includes('line-through')) st.textDecoration = 'line-through';
374
+ else if (v.includes('none')) st.textDecoration = 'none';
375
+ break;
376
+ }
377
+ case 'white-space': {
378
+ const v = value.toLowerCase();
379
+ if (['normal', 'pre', 'nowrap', 'pre-wrap', 'pre-line'].includes(v)) st.whiteSpace = v;
380
+ break;
381
+ }
382
+ case 'list-style':
383
+ case 'list-style-type': {
384
+ const v = value.toLowerCase();
385
+ for (const part of v.split(/\s+/)) {
386
+ if (['disc', 'circle', 'square', 'decimal', 'none', 'lower-alpha', 'upper-alpha', 'lower-roman', 'upper-roman'].includes(part)) {
387
+ st.listStyleType = part;
388
+ }
389
+ }
390
+ break;
391
+ }
392
+
393
+ case 'margin': {
394
+ const box = expandBox(value);
395
+ if (box) box.forEach((v, i) => {
396
+ const l = sizeVal(v);
397
+ if (l) st.margin[i] = l;
398
+ });
399
+ break;
400
+ }
401
+ case 'margin-top':
402
+ case 'margin-right':
403
+ case 'margin-bottom':
404
+ case 'margin-left': {
405
+ const l = sizeVal(value);
406
+ if (l) st.margin[SIDES.indexOf(prop.slice(7))] = l;
407
+ break;
408
+ }
409
+ case 'padding': {
410
+ const box = expandBox(value);
411
+ if (box) box.forEach((v, i) => {
412
+ const l = sizeVal(v);
413
+ if (l && l !== 'auto') st.padding[i] = l;
414
+ });
415
+ break;
416
+ }
417
+ case 'padding-top':
418
+ case 'padding-right':
419
+ case 'padding-bottom':
420
+ case 'padding-left': {
421
+ const l = sizeVal(value);
422
+ if (l && l !== 'auto') st.padding[SIDES.indexOf(prop.slice(8))] = l;
423
+ break;
424
+ }
425
+
426
+ case 'border': {
427
+ const b = parseBorderShorthand(value);
428
+ for (let i = 0; i < 4; i++) {
429
+ if (b.style) st.borderStyle[i] = b.style;
430
+ st.borderWidth[i] = b.style === 'none' ? 0 : (b.width ?? (b.style ? 3 : st.borderWidth[i]));
431
+ if (b.color) st.borderColor[i] = b.color;
432
+ }
433
+ break;
434
+ }
435
+ case 'border-top':
436
+ case 'border-right':
437
+ case 'border-bottom':
438
+ case 'border-left': {
439
+ const i = SIDES.indexOf(prop.slice(7));
440
+ const b = parseBorderShorthand(value);
441
+ if (b.style) st.borderStyle[i] = b.style;
442
+ st.borderWidth[i] = b.style === 'none' ? 0 : (b.width ?? (b.style ? 3 : st.borderWidth[i]));
443
+ if (b.color) st.borderColor[i] = b.color;
444
+ break;
445
+ }
446
+ case 'border-width': {
447
+ const box = expandBox(value);
448
+ if (box) box.forEach((v, i) => {
449
+ const w = /^(thin|medium|thick)$/.test(v) ? { thin: 1, medium: 3, thick: 5 }[v] : px(v);
450
+ if (w !== null) st.borderWidth[i] = w;
451
+ });
452
+ break;
453
+ }
454
+ case 'border-style': {
455
+ const box = expandBox(value.toLowerCase());
456
+ if (box) box.forEach((v, i) => {
457
+ st.borderStyle[i] = v === 'none' || v === 'hidden' ? 'none' : 'solid';
458
+ if (st.borderStyle[i] === 'none') st.borderWidth[i] = 0;
459
+ });
460
+ break;
461
+ }
462
+ case 'border-color': {
463
+ const box = expandBox(value);
464
+ if (box) box.forEach((v, i) => {
465
+ const c = cssColor(v);
466
+ if (c) st.borderColor[i] = c;
467
+ });
468
+ break;
469
+ }
470
+
471
+ case 'width':
472
+ case 'height': {
473
+ const l = sizeVal(value);
474
+ if (l) st[prop] = l;
475
+ break;
476
+ }
477
+ case 'min-width':
478
+ case 'max-width':
479
+ case 'min-height':
480
+ case 'max-height': {
481
+ const l = sizeVal(value);
482
+ if (l && l !== 'auto') {
483
+ st[prop.replace(/-(\w)/, (_, ch) => ch.toUpperCase())] = l;
484
+ }
485
+ break;
486
+ }
487
+
488
+ case 'flex-direction': {
489
+ const v = value.toLowerCase();
490
+ if (['row', 'column', 'row-reverse', 'column-reverse'].includes(v)) st.flexDirection = v;
491
+ break;
492
+ }
493
+ case 'flex-wrap': {
494
+ const v = value.toLowerCase();
495
+ if (['nowrap', 'wrap', 'wrap-reverse'].includes(v)) st.flexWrap = v;
496
+ break;
497
+ }
498
+ case 'justify-content': {
499
+ const v = value.toLowerCase();
500
+ if (['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly', 'start', 'end'].includes(v)) {
501
+ st.justifyContent = v === 'start' ? 'flex-start' : v === 'end' ? 'flex-end' : v;
502
+ }
503
+ break;
504
+ }
505
+ case 'align-items':
506
+ case 'align-self': {
507
+ const v = value.toLowerCase();
508
+ const key = prop === 'align-items' ? 'alignItems' : 'alignSelf';
509
+ if (['flex-start', 'flex-end', 'center', 'stretch', 'baseline', 'auto', 'start', 'end'].includes(v)) {
510
+ st[key] = v === 'start' ? 'flex-start' : v === 'end' ? 'flex-end' : v;
511
+ }
512
+ break;
513
+ }
514
+ case 'flex-grow':
515
+ if (/^\d*\.?\d+$/.test(value)) st.flexGrow = parseFloat(value);
516
+ break;
517
+ case 'flex-shrink':
518
+ if (/^\d*\.?\d+$/.test(value)) st.flexShrink = parseFloat(value);
519
+ break;
520
+ case 'flex-basis': {
521
+ const l = sizeVal(value);
522
+ if (l) st.flexBasis = l;
523
+ break;
524
+ }
525
+ case 'flex': {
526
+ const v = value.toLowerCase().trim();
527
+ if (v === 'none') {
528
+ st.flexGrow = 0;
529
+ st.flexShrink = 0;
530
+ st.flexBasis = 'auto';
531
+ break;
532
+ }
533
+ const parts = v.split(/\s+/);
534
+ if (/^\d*\.?\d+$/.test(parts[0])) {
535
+ st.flexGrow = parseFloat(parts[0]);
536
+ st.flexShrink = parts[1] && /^\d*\.?\d+$/.test(parts[1]) ? parseFloat(parts[1]) : 1;
537
+ const basis = parts[2] ?? (parts[1] && !/^\d*\.?\d+$/.test(parts[1]) ? parts[1] : '0px');
538
+ st.flexBasis = sizeVal(basis) ?? { px: 0 };
539
+ }
540
+ break;
541
+ }
542
+ case 'gap': {
543
+ const parts = value.split(/\s+/).filter(Boolean);
544
+ const row = px(parts[0]);
545
+ if (row !== null) st.rowGap = row;
546
+ const col = parts[1] ? px(parts[1]) : row;
547
+ if (col !== null) st.columnGap = col;
548
+ break;
549
+ }
550
+ case 'row-gap': {
551
+ const v = px(value);
552
+ if (v !== null) st.rowGap = v;
553
+ break;
554
+ }
555
+ case 'column-gap': {
556
+ const v = px(value);
557
+ if (v !== null) st.columnGap = v;
558
+ break;
559
+ }
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Compute styles for every element of a parsed document.
565
+ *
566
+ * @param {object} dom htmlparser2 document node
567
+ * @param {string[]} authorSheets stylesheet texts, in cascade order (the UA
568
+ * sheet is always applied first)
569
+ * @param {object} [opts] { rootFontSize = 16, baseStyle } — `baseStyle`
570
+ * overrides DEFAULT_STYLE fields for the root (theming: font, color...)
571
+ * @returns {Map<element, computedStyle>}
572
+ */
573
+ export function computeStyles(dom, authorSheets = [], opts = {}) {
574
+ const rootSize = opts.rootFontSize ?? 16;
575
+
576
+ // parse all sheets into one ordered rule list (UA first: lowest priority
577
+ // at equal specificity because of source order)
578
+ const rules = [];
579
+ for (const text of [UA_STYLESHEET, ...authorSheets]) {
580
+ for (const rule of parseStylesheet(text)) rules.push(rule);
581
+ }
582
+
583
+ // match each rule once over the whole document
584
+ const matches = new Map(); // element -> [{specificity, order, decls}]
585
+ rules.forEach((rule, order) => {
586
+ let matched;
587
+ try {
588
+ matched = selectAll(rule.selector, dom);
589
+ } catch {
590
+ return; // unsupported selector: skip the rule
591
+ }
592
+ for (const el of matched) {
593
+ let list = matches.get(el);
594
+ if (!list) matches.set(el, (list = []));
595
+ list.push({ specificity: rule.specificity, order, decls: rule.decls });
596
+ }
597
+ });
598
+
599
+ const computed = new Map();
600
+ const defaultStyle = { ...DEFAULT_STYLE, ...(opts.baseStyle || {}) };
601
+
602
+ const walk = (el, parentStyle) => {
603
+ // htmlparser2 marks <style>/<script> with their own node types
604
+ if (el.type !== 'tag' && el.type !== 'style' && el.type !== 'script') {
605
+ // only elements get computed styles; recurse for containers
606
+ for (const child of el.children ?? []) walk(child, parentStyle);
607
+ return;
608
+ }
609
+
610
+ // start from inherited/default values
611
+ const st = {
612
+ ...defaultStyle,
613
+ margin: ['auto0', 'auto0', 'auto0', 'auto0'],
614
+ padding: [{ px: 0 }, { px: 0 }, { px: 0 }, { px: 0 }],
615
+ borderWidth: [0, 0, 0, 0],
616
+ borderColor: [null, null, null, null],
617
+ borderStyle: ['none', 'none', 'none', 'none']
618
+ };
619
+ for (const prop of ['color', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'textAlign', 'whiteSpace', 'listStyleType']) {
620
+ st[prop] = parentStyle[prop];
621
+ }
622
+ // text-decoration propagates to inline descendants (approximation of
623
+ // CSS decoration propagation)
624
+ st.textDecoration = 'none';
625
+ st.display = 'inline';
626
+ st.background = null;
627
+ st.width = 'auto';
628
+ st.height = 'auto';
629
+ st.minWidth = st.maxWidth = st.minHeight = st.maxHeight = null;
630
+ st.flexDirection = 'row';
631
+ st.flexWrap = 'nowrap';
632
+ st.justifyContent = 'flex-start';
633
+ st.alignItems = 'stretch';
634
+ st.alignSelf = 'auto';
635
+ st.flexGrow = 0;
636
+ st.flexShrink = 1;
637
+ st.flexBasis = 'auto';
638
+ st.rowGap = 0;
639
+ st.columnGap = 0;
640
+
641
+ // presentational attributes (lowest priority)
642
+ const attribs = el.attribs || {};
643
+ if (el.name === 'img') {
644
+ if (attribs.width && /^\d+$/.test(attribs.width)) st.width = { px: parseInt(attribs.width, 10) };
645
+ if (attribs.height && /^\d+$/.test(attribs.height)) st.height = { px: parseInt(attribs.height, 10) };
646
+ }
647
+
648
+ // cascade: sort matched decl blocks; then apply — font-size first so em
649
+ // lengths in the same element resolve against the final font size
650
+ const declBlocks = (matches.get(el) ?? [])
651
+ .slice()
652
+ .sort((x, y) => x.specificity - y.specificity || x.order - y.order);
653
+ const decls = [];
654
+ for (const block of declBlocks) for (const d of block.decls) decls.push(d);
655
+ if (attribs.style) for (const d of parseInlineStyle(attribs.style)) decls.push(d);
656
+ // stable partition: non-important then important
657
+ const ordered = [...decls.filter((d) => !d.important), ...decls.filter((d) => d.important)];
658
+
659
+ for (const d of ordered) {
660
+ if (d.prop === 'font-size') applyDecl(st, d.prop, d.value, st.fontSize, rootSize, parentStyle);
661
+ }
662
+ for (const d of ordered) {
663
+ if (d.prop !== 'font-size') applyDecl(st, d.prop, d.value, st.fontSize, rootSize, parentStyle);
664
+ }
665
+
666
+ // unset margins default to 0 (the 'auto0' marker distinguishes "not
667
+ // set" from an explicit `margin: auto`)
668
+ st.margin = st.margin.map((m) => (m === 'auto0' ? { px: 0 } : m));
669
+
670
+ computed.set(el, st);
671
+ for (const child of el.children ?? []) walk(child, st);
672
+ };
673
+
674
+ for (const child of dom.children ?? []) walk(child, defaultStyle);
675
+ return computed;
676
+ }