buninu-browser 0.1.0 → 0.2.1

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 (64) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +28 -7
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/happy-dom/selector-matcher.js +83 -0
  14. package/lib/headless-shell.js +165 -26
  15. package/lib/input/hit-test.js +57 -8
  16. package/lib/layout/text-layout.js +290 -41
  17. package/lib/network/network-service.js +177 -40
  18. package/lib/network/resolver.js +16 -0
  19. package/lib/paint/display-list.js +25 -10
  20. package/lib/render-tree/index.js +58 -0
  21. package/lib/renderer/bindings.js +543 -55
  22. package/lib/renderer/flags.js +8 -5
  23. package/lib/renderer/host.js +103 -7
  24. package/lib/renderer/main.js +35 -3
  25. package/lib/renderer/module-transform.js +30 -23
  26. package/lib/renderer/page-realm.js +228 -24
  27. package/lib/renderer/page-renderer.js +1264 -221
  28. package/lib/screenshot-cli.js +61 -0
  29. package/lib/style/computed-style.js +558 -229
  30. package/lib/style/selector-keys.js +302 -0
  31. package/lib/style/selector-traits.js +240 -0
  32. package/lib/user-agent.js +12 -0
  33. package/package.json +1 -1
  34. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  35. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  36. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  37. package/patches/happy-dom/README.md +12 -0
  38. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  39. package/patches/ses/README.md +16 -0
  40. package/test/computed-style.test.js +113 -1
  41. package/test/document-types.test.js +111 -0
  42. package/test/download.test.js +192 -0
  43. package/test/fixtures/tone.mp3 +0 -0
  44. package/test/flex.test.js +11 -0
  45. package/test/happy-dom-matches.test.js +25 -0
  46. package/test/headless-shell.test.js +56 -0
  47. package/test/hit-test.test.js +37 -0
  48. package/test/log.test.js +4 -1
  49. package/test/network-service.test.js +67 -0
  50. package/test/page-scripts.test.js +437 -29
  51. package/test/piano.test.js +65 -0
  52. package/test/positioning.test.js +11 -0
  53. package/test/pulse-client.test.js +130 -0
  54. package/test/render-tree.test.js +36 -1
  55. package/test/resolver.test.js +12 -1
  56. package/test/screenshot-cli.test.js +19 -0
  57. package/test/selector-keys.test.js +39 -0
  58. package/test/selector-traits.test.js +19 -0
  59. package/test/text-layout.test.js +91 -1
  60. package/test/user-agent.test.js +42 -0
  61. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  62. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  63. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  64. package/vendor/ses/dist/ses.mjs +2 -0
@@ -39,10 +39,11 @@ export function layoutText(tree, options = {}) {
39
39
  boxes: [],
40
40
  lineHeight: options.lineHeight == null ? null : positiveNumber(options.lineHeight, 31),
41
41
  fallbackLineHeight: 31,
42
- measure: cachedMeasure(measureText),
43
- metrics: cachedMetrics(options.fontMetrics),
44
- maxContent: new WeakMap(),
45
- minContent: new WeakMap(),
42
+ measure: options.measureCache ?? cachedMeasure(measureText),
43
+ metrics: options.metricsCache ?? cachedMetrics(options.fontMetrics),
44
+ maxContent: options.maxContentCache ?? new WeakMap(),
45
+ minContent: options.minContentCache ?? new WeakMap(),
46
+ blockResults: options.blockResultCache ?? null,
46
47
  // Paint layers: the root layer holds in-flow content; positioned boxes add
47
48
  // more. A layer with an integer z-index is a stacking context; `parent` is
48
49
  // the stacking context a layer is painted in (CSS 2 Appendix E).
@@ -50,14 +51,20 @@ export function layoutText(tree, options = {}) {
50
51
  stackingContext: ROOT_LAYER,
51
52
  layerCount: 1,
52
53
  initialContainingBlock: null,
54
+ // Static positions recorded for out-of-flow boxes, in order (see
55
+ // cacheBlockResult), and those boxes by node for replays.
56
+ staticPositions: [],
57
+ outOfFlowBoxes: new Map(),
53
58
  };
54
59
 
55
60
  containingBlocks = [];
61
+ outOfFlowBoxes = ctx.outOfFlowBoxes;
56
62
  let root;
57
63
  try {
58
64
  root = buildBox(tree.root, inheritedFlags(tree.root, { preserve: false, wrap: true }));
59
65
  } finally {
60
66
  containingBlocks = null;
67
+ outOfFlowBoxes = null;
61
68
  }
62
69
  const containingWidth = options.measureText || viewportWidth
63
70
  ? Math.max(1, (viewportWidth || columns * columnWidth) - x * 2)
@@ -91,12 +98,43 @@ export function layoutText(tree, options = {}) {
91
98
  });
92
99
  }
93
100
 
101
+ /**
102
+ * Long-lived layout entry point. Expensive shaping measurements are valid as
103
+ * long as their immutable computed-style identity is valid, so retain them
104
+ * across incremental updates instead of starting cold on every reflow.
105
+ * Formatting-context result caches are added to this same owner.
106
+ */
107
+ export class LayoutEngine {
108
+ #measure;
109
+ #metrics;
110
+ #maxContent = new WeakMap();
111
+ #minContent = new WeakMap();
112
+ #blockResults = new WeakMap();
113
+
114
+ constructor({ measureText = (text) => Bun.stringWidth(text) * 13, fontMetrics = null } = {}) {
115
+ this.#measure = cachedMeasure(measureText);
116
+ this.#metrics = cachedMetrics(fontMetrics);
117
+ }
118
+
119
+ layout(tree, options = {}) {
120
+ return layoutText(tree, {
121
+ ...options,
122
+ measureCache: this.#measure,
123
+ metricsCache: this.#metrics,
124
+ maxContentCache: this.#maxContent,
125
+ minContentCache: this.#minContent,
126
+ blockResultCache: this.#blockResults,
127
+ });
128
+ }
129
+ }
130
+
94
131
  // ---------------------------------------------------------------------------
95
132
  // Box tree construction
96
133
 
97
134
  // Absolutely positioned boxes register with their containing block while the
98
135
  // box tree is built. Building is synchronous and scoped to layoutText.
99
136
  let containingBlocks = null;
137
+ let outOfFlowBoxes = null;
100
138
 
101
139
  // Text nodes carry their parent's style, so only element boxes can be positioned.
102
140
  const UNPOSITIONABLE = new Set(["root", "text", "line-break"]);
@@ -115,6 +153,7 @@ function outOfFlowBox(node, flags) {
115
153
  const box = buildBox(node, inheritedFlags(node, flags), true);
116
154
  box.outOfFlow = true;
117
155
  holder.absolutes.push(box);
156
+ outOfFlowBoxes?.set(node, box);
118
157
  return box;
119
158
  }
120
159
 
@@ -123,10 +162,13 @@ function buildBox(node, flags, blockified = false) {
123
162
  if (node.type === "image") {
124
163
  return { kind: "block", node, style, replaced: true, bfc: true, children: [], absolutes: [] };
125
164
  }
126
- const flex = ["flex", "inline-flex"].includes(node.type)
165
+ // The root (<body>) keeps its own node type, but lays its children out
166
+ // by its display: body { display: grid } or flex is common.
167
+ const display = node.type === "root" ? style?.display : node.type;
168
+ const flex = ["flex", "inline-flex"].includes(display)
127
169
  ? style?.flexDirection === "column" ? "column" : "row"
128
170
  : null;
129
- const grid = ["grid", "inline-grid"].includes(node.type);
171
+ const grid = ["grid", "inline-grid"].includes(display);
130
172
  const table = ["table", "inline-table"].includes(node.type);
131
173
  const box = {
132
174
  kind: "block",
@@ -275,9 +317,13 @@ function hasInlineContent(items) {
275
317
  * then applies relative offsets and translations to everything it produced.
276
318
  */
277
319
  function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = null) {
320
+ const cached = cachedBlockResult(ctx, box, width, y, x, absorbTop, forcedHeight);
321
+ if (cached) return cached;
278
322
  const style = box.style;
279
323
  const boxStart = ctx.boxes.length;
280
324
  const fragmentStart = ctx.fragments.length;
325
+ const layerStart = ctx.layerCount;
326
+ const staticStart = ctx.staticPositions.length;
281
327
  const previousLayer = ctx.layer;
282
328
  const previousContext = ctx.stackingContext;
283
329
  // Anonymous boxes reuse their container's node but are never positioned themselves.
@@ -311,9 +357,122 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
311
357
  if (dx || dy) shiftOutput(ctx, boxStart, fragmentStart, dx, dy);
312
358
  ctx.layer = previousLayer;
313
359
  ctx.stackingContext = previousContext;
360
+ cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result, {
361
+ boxStart, fragmentStart, layerStart, staticStart, layer: previousLayer, context: previousContext,
362
+ });
314
363
  return result;
315
364
  }
316
365
 
366
+ function blockCacheKey(width, absorbTop, forcedHeight) {
367
+ return `${width}\n${absorbTop ? 1 : 0}\n${forcedHeight ?? "auto"}`;
368
+ }
369
+
370
+ /** Where an out-of-flow box would have been in flow; recorded so that a replayed block sets it again. */
371
+ function setStaticPosition(ctx, box, position) {
372
+ box.staticPosition = position;
373
+ ctx.staticPositions.push({ node: box.node, position });
374
+ }
375
+
376
+ /** Replays a retained formatting-context result at its new flow position. */
377
+ function cachedBlockResult(ctx, box, width, y, x, absorbTop, forcedHeight) {
378
+ if (!ctx.blockResults || box.anonymous) return null;
379
+ const cached = ctx.blockResults.get(box.node)?.get(blockCacheKey(width, absorbTop, forcedHeight));
380
+ if (!cached) return null;
381
+ const dx = x - cached.x;
382
+ const dy = y - cached.y;
383
+ // The block's own paint layers are made again, in their order, after the
384
+ // layers laid out before it; -1 is the layer and stacking context it is in.
385
+ const layers = [];
386
+ for (const layer of cached.layers) {
387
+ layers.push(Object.freeze({
388
+ z: layer.z,
389
+ order: ctx.layerCount++,
390
+ context: layer.context,
391
+ parent: layer.parent === -1 ? ctx.stackingContext : layers[layer.parent],
392
+ }));
393
+ }
394
+ const layerAt = (index) => index === -1 ? ctx.layer : layers[index];
395
+ // Retained output is frozen: where it neither moved nor changed layer, the
396
+ // same objects are output again.
397
+ const moved = dx !== 0 || dy !== 0;
398
+ cached.boxes.forEach((record, index) => {
399
+ const layer = layerAt(cached.boxLayers[index]);
400
+ ctx.boxes.push(!moved && record.layer === layer ? record : { ...record, x: record.x + dx, y: record.y + dy, layer });
401
+ });
402
+ cached.fragments.forEach((fragment, index) => {
403
+ const layer = layerAt(cached.fragmentLayers[index]);
404
+ ctx.fragments.push(!moved && fragment.layer === layer ? fragment : { ...shiftedFragment(fragment, dx, dy), layer });
405
+ });
406
+ // Out-of-flow boxes whose containing block is outside this one still need their static positions.
407
+ for (const { node, x: staticX, y: staticY } of cached.staticPositions) {
408
+ const outOfFlow = ctx.outOfFlowBoxes.get(node);
409
+ const position = { x: staticX + dx, y: staticY + dy };
410
+ if (outOfFlow) outOfFlow.staticPosition = position;
411
+ ctx.staticPositions.push({ node, position });
412
+ }
413
+ return {
414
+ height: cached.result.height,
415
+ baseline: cached.result.baseline == null ? null : cached.result.baseline + dy,
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Retains a block's output for replay (cachedBlockResult). Positioned boxes
421
+ * inside it make paint layers of their own; those are kept by creation
422
+ * order relative to the layer and stacking context the block is in, and the
423
+ * static positions it recorded go along. Output in a layer made before the
424
+ * block (which should not occur) is not retained.
425
+ */
426
+ function cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result, start) {
427
+ if (!ctx.blockResults || box.anonymous) return;
428
+ const boxes = ctx.boxes.slice(start.boxStart);
429
+ const fragments = ctx.fragments.slice(start.fragmentStart);
430
+ const local = new Set();
431
+ const collect = (layer) => {
432
+ if (layer === start.layer || layer === start.context || local.has(layer)) return true;
433
+ if (!layer || layer.order < start.layerStart) return false;
434
+ local.add(layer);
435
+ return collect(layer.parent);
436
+ };
437
+ if (!boxes.every((record) => collect(record.layer)) || !fragments.every((fragment) => collect(fragment.layer))) return;
438
+ const ordered = [...local].sort((left, right) => left.order - right.order);
439
+ const indices = new Map(ordered.map((layer, index) => [layer, index]));
440
+ const layers = [];
441
+ for (const layer of ordered) {
442
+ const parent = indices.get(layer.parent) ?? (layer.parent === start.context ? -1 : null);
443
+ if (parent === null) return;
444
+ layers.push({ z: layer.z, context: layer.context, parent });
445
+ }
446
+ const indexOf = (layer) => layer === start.layer ? -1 : indices.get(layer) ?? null;
447
+ const boxLayers = boxes.map((record) => indexOf(record.layer));
448
+ const fragmentLayers = fragments.map((fragment) => indexOf(fragment.layer));
449
+ if (boxLayers.includes(null) || fragmentLayers.includes(null)) return;
450
+ let entries = ctx.blockResults.get(box.node);
451
+ if (!entries) ctx.blockResults.set(box.node, entries = new Map());
452
+ // A node normally sees one containing width. Bound unusual cases such as a
453
+ // reused table cell so the retained cache cannot grow without limit.
454
+ if (!entries.has(blockCacheKey(width, absorbTop, forcedHeight)) && entries.size >= 4) {
455
+ entries.delete(entries.keys().next().value);
456
+ }
457
+ entries.set(blockCacheKey(width, absorbTop, forcedHeight), {
458
+ x,
459
+ y,
460
+ result: { ...result },
461
+ layers,
462
+ boxes: boxes.map(retained),
463
+ boxLayers,
464
+ fragments: fragments.map(retained),
465
+ fragmentLayers,
466
+ staticPositions: ctx.staticPositions.slice(start.staticStart)
467
+ .map(({ node, position }) => ({ node, x: position.x, y: position.y })),
468
+ });
469
+ }
470
+
471
+ /** An output record as retained: frozen (records replayed from a retained block already are). */
472
+ function retained(record) {
473
+ return Object.isFrozen(record) ? record : Object.freeze({ ...record });
474
+ }
475
+
317
476
  /** CSS 2 §10.3.7/§10.6.4 absolute placement against the padding box. */
318
477
  function layoutAbsolutes(ctx, box, record) {
319
478
  const border = borderWidths(box.style);
@@ -338,7 +497,7 @@ function layoutAbsolutes(ctx, box, record) {
338
497
  if (specifiedWidth == null && left != null && right != null) {
339
498
  width = Math.max(0, containing.width - left - right - marginLeft - marginRight);
340
499
  } else {
341
- width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true).width;
500
+ width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
342
501
  }
343
502
  const staticPosition = absolute.staticPosition ?? containing;
344
503
  const x = left != null ? containing.x + left + marginLeft
@@ -357,27 +516,59 @@ function layoutAbsolutes(ctx, box, record) {
357
516
  }
358
517
  }
359
518
 
519
+ /**
520
+ * justify-content in a column flex container (Flexbox §8.2): `free` is the
521
+ * height left over once the items are stacked, as with a min-height or height
522
+ * taller than them. Each item's output moves by its share; shifts are applied
523
+ * item by item because shiftOutput moves everything after a start index.
524
+ */
525
+ function justifyColumn(ctx, items, justify = "normal", free) {
526
+ if (!(free > 0) || ["normal", "start", "flex-start", "stretch"].includes(justify)) return;
527
+ const count = items.length;
528
+ const offset = (index) => {
529
+ switch (justify) {
530
+ case "center": return free / 2;
531
+ case "end": case "flex-end": return free;
532
+ case "space-between": return count > 1 ? free * index / (count - 1) : 0;
533
+ case "space-around": return free / count * (index + 0.5);
534
+ case "space-evenly": return free / (count + 1) * (index + 1);
535
+ default: return 0;
536
+ }
537
+ };
538
+ let applied = 0;
539
+ items.forEach((item, index) => {
540
+ const delta = offset(index) - applied;
541
+ if (delta) shiftOutput(ctx, item.boxStart, item.fragmentStart, 0, delta);
542
+ applied += delta;
543
+ });
544
+ }
545
+
546
+ /** Moves output already produced; records are replaced, as retained ones are frozen and shared. */
360
547
  function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
361
548
  for (let index = boxStart; index < ctx.boxes.length; index++) {
362
- ctx.boxes[index].x += dx;
363
- ctx.boxes[index].y += dy;
549
+ const record = ctx.boxes[index];
550
+ ctx.boxes[index] = { ...record, x: record.x + dx, y: record.y + dy };
364
551
  }
365
552
  for (let index = fragmentStart; index < ctx.fragments.length; index++) {
366
- const fragment = ctx.fragments[index];
367
- ctx.fragments[index] = {
368
- ...fragment,
369
- x: fragment.x + dx,
370
- y: fragment.y + dy,
371
- baseline: fragment.baseline == null ? null : fragment.baseline + dy,
372
- runs: Object.freeze(fragment.runs.map((run) => Object.freeze({
373
- ...run,
374
- x: run.x + dx,
375
- y: run.y == null ? run.y : run.y + dy,
376
- }))),
377
- };
553
+ ctx.fragments[index] = shiftedFragment(ctx.fragments[index], dx, dy);
378
554
  }
379
555
  }
380
556
 
557
+ function shiftedFragment(fragment, dx, dy) {
558
+ if (dx === 0 && dy === 0) return fragment;
559
+ return {
560
+ ...fragment,
561
+ x: fragment.x + dx,
562
+ y: fragment.y + dy,
563
+ baseline: fragment.baseline == null ? null : fragment.baseline + dy,
564
+ runs: Object.freeze(fragment.runs.map((run) => Object.freeze({
565
+ ...run,
566
+ x: run.x + dx,
567
+ y: run.y == null ? run.y : run.y + dy,
568
+ }))),
569
+ };
570
+ }
571
+
381
572
  function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeight = null) {
382
573
  const style = box.style;
383
574
  const border = borderWidths(style);
@@ -401,6 +592,7 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
401
592
  height: dimensions.height,
402
593
  resource: box.node.resource,
403
594
  video: box.node.tagName === "VIDEO",
595
+ audio: box.node.tagName === "AUDIO",
404
596
  svg: box.node.svg ?? null,
405
597
  }], imageX, imageY, dimensions.width, dimensions.height));
406
598
  record.height = dimensions.height + padding[0] + padding[2] + border[0] + border[2];
@@ -412,6 +604,9 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
412
604
  const contentTop = y + border[0] + padding[0];
413
605
  const firstFragment = ctx.fragments.length;
414
606
  let contentBottom = contentTop;
607
+ // Column flex items and grid rows, aligned once the height is known.
608
+ let columnItems = null;
609
+ let gridResult = null;
415
610
  let baseline = null;
416
611
 
417
612
  if (box.table) {
@@ -419,9 +614,9 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
419
614
  contentBottom = result.bottom;
420
615
  baseline = result.baseline;
421
616
  } else if (box.grid) {
422
- const result = layoutGrid(ctx, box, contentX, contentWidth, contentTop);
423
- contentBottom = result.bottom;
424
- baseline = result.baseline;
617
+ gridResult = layoutGrid(ctx, box, contentX, contentWidth, contentTop);
618
+ contentBottom = gridResult.bottom;
619
+ baseline = gridResult.baseline;
425
620
  } else if (box.flex === "row") {
426
621
  const result = layoutFlexRow(ctx, box, contentX, contentWidth, contentTop);
427
622
  contentBottom = result.bottom;
@@ -429,8 +624,10 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
429
624
  } else if (box.flex === "column") {
430
625
  const gap = style?.rowGap ?? style?.gap ?? 0;
431
626
  let cursor = contentTop;
627
+ columnItems = [];
432
628
  box.children.forEach((child, index) => {
433
629
  if (index > 0) cursor += gap;
630
+ columnItems.push({ boxStart: ctx.boxes.length, fragmentStart: ctx.fragments.length });
434
631
  if (child.kind === "ifc") {
435
632
  const result = layoutInline(ctx, child, contentX, contentWidth, cursor);
436
633
  cursor = result.bottom;
@@ -461,7 +658,7 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
461
658
  let firstInFlow = true;
462
659
  box.children.forEach((child) => {
463
660
  if (child.kind === "placeholder") {
464
- child.box.staticPosition = { x: contentX, y: cursor + strutValue(pending ?? ZERO_STRUT) };
661
+ setStaticPosition(ctx, child.box, { x: contentX, y: cursor + strutValue(pending ?? ZERO_STRUT) });
465
662
  return;
466
663
  }
467
664
  const first = firstInFlow;
@@ -499,6 +696,8 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
499
696
  if (maxHeight != null) contentHeight = Math.min(contentHeight, toContentHeight(maxHeight));
500
697
  const minHeight = numberOrNull(style?.minHeight);
501
698
  if (minHeight != null) contentHeight = Math.max(contentHeight, toContentHeight(minHeight));
699
+ if (columnItems?.length) justifyColumn(ctx, columnItems, style?.justifyContent, contentHeight - (contentBottom - contentTop));
700
+ if (gridResult) alignGridContent(ctx, gridResult, style?.alignContent, contentHeight - (contentBottom - contentTop));
502
701
  record.height = forcedHeight ?? contentHeight + padding[0] + padding[2] + border[0] + border[2];
503
702
  if (style?.display === "list-item") placeListMarker(ctx, box, contentX, contentTop, firstFragment);
504
703
  return { height: record.height, baseline };
@@ -812,7 +1011,9 @@ function layoutGrid(ctx, box, x, width, y) {
812
1011
  const rowStarts = trackStarts(rows, [], rowGap, y);
813
1012
 
814
1013
  let baseline = null;
1014
+ const placements = [];
815
1015
  for (const item of grid.items) {
1016
+ const placement = { row: item.row, boxStart: ctx.boxes.length, fragmentStart: ctx.fragments.length };
816
1017
  const areaY = rowStarts[item.row];
817
1018
  const areaHeight = spanSize(rows, [], rowGap, item.row, item.rowSpan);
818
1019
  const itemStyle = item.box.style;
@@ -832,9 +1033,41 @@ function layoutGrid(ctx, box, x, width, y) {
832
1033
  }
833
1034
  const result = layoutBlock(ctx, item.box, item.horizontal.x, item.horizontal.width, itemY, false, forcedHeight);
834
1035
  baseline ??= result.baseline;
1036
+ placements.push(Object.assign(placement, { boxEnd: ctx.boxes.length, fragmentEnd: ctx.fragments.length }));
835
1037
  }
836
1038
  const height = rows.reduce((sum, size) => sum + size, 0) + rowGap * Math.max(0, rows.length - 1);
837
- return { bottom: y + height, baseline };
1039
+ return { bottom: y + height, baseline, placements, rowCount: rows.length };
1040
+ }
1041
+
1042
+ /**
1043
+ * align-content for a grid taller than its rows (CSS Box Alignment 3 §5.3):
1044
+ * the free space goes before, between or around the rows. Each item moves
1045
+ * with its (first) row.
1046
+ */
1047
+ function alignGridContent(ctx, grid, alignment = "normal", free) {
1048
+ if (!(free > 0) || !grid.rowCount || ["normal", "start", "stretch"].includes(alignment)) return;
1049
+ const count = grid.rowCount;
1050
+ const offset = (row) => {
1051
+ switch (alignment) {
1052
+ case "center": return free / 2;
1053
+ case "end": return free;
1054
+ case "space-between": return count > 1 ? free * row / (count - 1) : 0;
1055
+ case "space-around": return free / count * (row + 0.5);
1056
+ case "space-evenly": return free / (count + 1) * (row + 1);
1057
+ default: return 0;
1058
+ }
1059
+ };
1060
+ for (const placement of grid.placements) {
1061
+ const dy = offset(placement.row);
1062
+ if (!dy) continue;
1063
+ for (let index = placement.boxStart; index < placement.boxEnd; index++) {
1064
+ const record = ctx.boxes[index];
1065
+ ctx.boxes[index] = { ...record, y: record.y + dy };
1066
+ }
1067
+ for (let index = placement.fragmentStart; index < placement.fragmentEnd; index++) {
1068
+ ctx.fragments[index] = shiftedFragment(ctx.fragments[index], 0, dy);
1069
+ }
1070
+ }
838
1071
  }
839
1072
 
840
1073
  // ---------------------------------------------------------------------------
@@ -1426,7 +1659,7 @@ function dryLayout(ctx, box, width) {
1426
1659
  }
1427
1660
 
1428
1661
  /** Resolves the used border-box width and horizontal margins of a block-level box. */
1429
- function resolveHorizontal(ctx, box, containingX, containingWidth, shrink) {
1662
+ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, percentageBasis = containingWidth) {
1430
1663
  const style = box.style;
1431
1664
  const padding = style?.padding ?? ZERO_BOX;
1432
1665
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
@@ -1435,15 +1668,15 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink) {
1435
1668
  let marginLeft = numberOrZero(margin[3]);
1436
1669
  let marginRight = numberOrZero(margin[1]);
1437
1670
  const available = Math.max(0, containingWidth - marginLeft - marginRight);
1438
- const specified = numberOrNull(resolveLength(style?.width, containingWidth));
1671
+ const specified = numberOrNull(resolveLength(style?.width, percentageBasis));
1439
1672
  let width;
1440
1673
  if (box.replaced) width = imageDimensions(box.node, containingWidth).width + extra;
1441
1674
  else if (specified != null) width = borderBox(specified);
1442
1675
  else if (shrink) width = Math.min(maxContentWidth(ctx, box), available);
1443
1676
  else width = available;
1444
- const maxWidth = numberOrNull(resolveLength(style?.maxWidth, containingWidth));
1677
+ const maxWidth = numberOrNull(resolveLength(style?.maxWidth, percentageBasis));
1445
1678
  if (maxWidth != null) width = Math.min(width, borderBox(maxWidth));
1446
- const minWidth = numberOrNull(resolveLength(style?.minWidth, containingWidth));
1679
+ const minWidth = numberOrNull(resolveLength(style?.minWidth, percentageBasis));
1447
1680
  if (minWidth != null) width = Math.max(width, borderBox(minWidth));
1448
1681
  width = Math.max(width, extra);
1449
1682
 
@@ -1460,7 +1693,8 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink) {
1460
1693
  }
1461
1694
 
1462
1695
  function maxContentWidth(ctx, box) {
1463
- const cached = ctx.maxContent.get(box);
1696
+ const cacheKey = box.node ?? box;
1697
+ const cached = ctx.maxContent.get(cacheKey);
1464
1698
  if (cached != null) return cached;
1465
1699
  const style = box.style;
1466
1700
  const padding = style?.padding ?? ZERO_BOX;
@@ -1496,12 +1730,13 @@ function maxContentWidth(ctx, box) {
1496
1730
  }
1497
1731
  const maxWidth = numberOrNull(style?.maxWidth);
1498
1732
  if (maxWidth != null) width = Math.min(width, style.boxSizing === "border-box" ? maxWidth : maxWidth + extra);
1499
- ctx.maxContent.set(box, width);
1733
+ ctx.maxContent.set(cacheKey, width);
1500
1734
  return width;
1501
1735
  }
1502
1736
 
1503
1737
  function minContentWidth(ctx, box) {
1504
- const cached = ctx.minContent.get(box);
1738
+ const cacheKey = box.node ?? box;
1739
+ const cached = ctx.minContent.get(cacheKey);
1505
1740
  if (cached != null) return cached;
1506
1741
  const style = box.style;
1507
1742
  const padding = style?.padding ?? ZERO_BOX;
@@ -1528,7 +1763,7 @@ function minContentWidth(ctx, box) {
1528
1763
  ? inlineMinContentWidth(ctx, child)
1529
1764
  : outer(child))) + extra;
1530
1765
  }
1531
- ctx.minContent.set(box, width);
1766
+ ctx.minContent.set(cacheKey, width);
1532
1767
  return width;
1533
1768
  }
1534
1769
 
@@ -1724,7 +1959,7 @@ function buildParagraph(ctx, ifc) {
1724
1959
  const runAt = new Array(text.length);
1725
1960
  for (const run of visibleRuns) for (let index = run.start; index < run.end; index++) runAt[index] = run;
1726
1961
  const opportunities = new Set();
1727
- const graphemes = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)];
1962
+ const graphemes = [...GRAPHEME_SEGMENTER.segment(text)];
1728
1963
  for (const { segment, index } of graphemes) {
1729
1964
  const run = runAt[index];
1730
1965
  const end = index + segment.length;
@@ -2005,6 +2240,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2005
2240
  height: segment.dimensions.height,
2006
2241
  resource: run.node.resource,
2007
2242
  video: run.node.tagName === "VIDEO",
2243
+ audio: run.node.tagName === "AUDIO",
2008
2244
  svg: run.node.svg ?? null,
2009
2245
  });
2010
2246
  } else if (run.kind === "atomic") {
@@ -2124,13 +2360,15 @@ function borderWidths(style) {
2124
2360
  return style?.borderWidths ?? ZERO_BOX;
2125
2361
  }
2126
2362
 
2363
+ // Not frozen: intrinsic sizing lays lines out many times over, and freezing
2364
+ // every run was the largest single cost of layout. Nothing mutates them.
2127
2365
  function lineFragment(style, text, nodeIds, runs, x, y, width, height, baseline = null) {
2128
2366
  return {
2129
2367
  type: "line",
2130
2368
  baseline,
2131
2369
  text,
2132
- nodeIds: Object.freeze(nodeIds),
2133
- runs: Object.freeze(runs.map((run) => Object.freeze(run))),
2370
+ nodeIds,
2371
+ runs,
2134
2372
  x,
2135
2373
  y,
2136
2374
  width,
@@ -2142,12 +2380,15 @@ function lineFragment(style, text, nodeIds, runs, x, y, width, height, baseline
2142
2380
  // ---------------------------------------------------------------------------
2143
2381
  // Shared helpers
2144
2382
 
2383
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
2384
+
2145
2385
  function cachedMeasure(measureText) {
2146
- const byStyle = new Map();
2386
+ const byStyle = new WeakMap();
2387
+ const defaultStyle = new Map();
2147
2388
  return (text, style) => {
2148
2389
  if (!text) return 0;
2149
- let cache = byStyle.get(style ?? null);
2150
- if (!cache) byStyle.set(style ?? null, cache = new Map());
2390
+ let cache = style && typeof style === "object" ? byStyle.get(style) : defaultStyle;
2391
+ if (!cache) byStyle.set(style, cache = new Map());
2151
2392
  let width = cache.get(text);
2152
2393
  if (width === undefined) cache.set(text, width = measureText(text, style ?? null));
2153
2394
  return width;
@@ -2156,7 +2397,9 @@ function cachedMeasure(measureText) {
2156
2397
 
2157
2398
  function cachedMetrics(fontMetrics) {
2158
2399
  const cache = new Map();
2159
- return (style) => {
2400
+ // Computed styles are frozen and shared, so most lookups hit by identity.
2401
+ const byStyle = new WeakMap();
2402
+ const metricsFor = (style) => {
2160
2403
  const fontSize = style?.fontSize ?? 16;
2161
2404
  const key = `${fontSize}/${style?.fontWeight ?? 400}/${style?.fontFamily?.join(",")}`;
2162
2405
  let metrics = cache.get(key);
@@ -2169,6 +2412,12 @@ function cachedMetrics(fontMetrics) {
2169
2412
  }
2170
2413
  return metrics;
2171
2414
  };
2415
+ return (style) => {
2416
+ if (style === null || typeof style !== "object") return metricsFor(style);
2417
+ let metrics = byStyle.get(style);
2418
+ if (!metrics) byStyle.set(style, metrics = metricsFor(style));
2419
+ return metrics;
2420
+ };
2172
2421
  }
2173
2422
 
2174
2423
  function imageDimensions(node, referenceWidth) {