buninu-browser 0.2.2 → 0.2.4

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 (118) hide show
  1. package/lib/audio/playback-buffer.js +27 -0
  2. package/lib/audio/pulse-client.js +18 -8
  3. package/lib/cdp-server.js +53 -14
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +20 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +12 -6
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +289 -131
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +42 -0
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +22 -27
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +42 -13
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/rounded-rect.js +82 -0
  24. package/lib/paint/shared-frame-pool.js +105 -0
  25. package/lib/render-tree/index.js +40 -6
  26. package/lib/render-tree/svg.js +15 -7
  27. package/lib/renderer/bindings.js +244 -82
  28. package/lib/renderer/broadcast-channel.js +76 -0
  29. package/lib/renderer/canvas-2d.js +257 -14
  30. package/lib/renderer/canvas-text.js +120 -0
  31. package/lib/renderer/compositor.js +27 -10
  32. package/lib/renderer/custom-element-reactions.js +42 -0
  33. package/lib/renderer/font-loading.js +227 -0
  34. package/lib/renderer/history.js +42 -0
  35. package/lib/renderer/host.js +36 -5
  36. package/lib/renderer/image-loading.js +27 -0
  37. package/lib/renderer/image-resources.js +65 -0
  38. package/lib/renderer/interfaces.js +28 -0
  39. package/lib/renderer/keyboard-input.js +12 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +33 -5
  42. package/lib/renderer/media-playback-events.js +13 -0
  43. package/lib/renderer/media-source.js +126 -55
  44. package/lib/renderer/page-realm.js +278 -147
  45. package/lib/renderer/page-renderer.js +840 -301
  46. package/lib/renderer/piano-input.js +22 -0
  47. package/lib/renderer/pointer-move.js +29 -0
  48. package/lib/renderer/prepared-script-cache.js +50 -0
  49. package/lib/renderer/rendered-text.js +108 -0
  50. package/lib/renderer/rewrite-worker.js +4 -3
  51. package/lib/renderer/script-rewrite.js +13 -2
  52. package/lib/renderer/scroll-into-view.js +28 -0
  53. package/lib/renderer/slot-assignment.js +60 -0
  54. package/lib/renderer/video-presentation.js +145 -0
  55. package/lib/renderer/workers.js +2 -2
  56. package/lib/style/computed-style.js +632 -108
  57. package/lib/style/selector-traits.js +49 -5
  58. package/package.json +1 -1
  59. package/test/broadcast-channel.test.js +26 -0
  60. package/test/canvas-2d.test.js +83 -2
  61. package/test/canvas-paint.test.js +9 -0
  62. package/test/canvas-text.test.js +41 -0
  63. package/test/cdp-stream.test.js +56 -0
  64. package/test/compositor.test.js +38 -0
  65. package/test/computed-style-binding.test.js +151 -0
  66. package/test/computed-style.test.js +279 -1
  67. package/test/display-list.test.js +26 -0
  68. package/test/document-base.test.js +87 -0
  69. package/test/dom-platform.test.js +461 -0
  70. package/test/download.test.js +8 -0
  71. package/test/fetch-lifecycle.test.js +161 -0
  72. package/test/fixtures/canvas-paint.js +117 -0
  73. package/test/fixtures/canvas-text.js +42 -0
  74. package/test/fixtures/image-lifetime.js +90 -0
  75. package/test/fixtures/media-seek-snapshot.js +77 -0
  76. package/test/fixtures/mse-audio-chirp.webm +0 -0
  77. package/test/fixtures/mse-switch-large.mp4 +0 -0
  78. package/test/fixtures/mse-switch-small.mp4 +0 -0
  79. package/test/fixtures/renderer-memory.js +110 -0
  80. package/test/fixtures/summary-style.js +28 -0
  81. package/test/font-loading.test.js +97 -0
  82. package/test/grid.test.js +58 -0
  83. package/test/headless-shell.test.js +22 -0
  84. package/test/hit-test.test.js +21 -1
  85. package/test/image-lifetime.test.js +12 -0
  86. package/test/image-resources.test.js +94 -0
  87. package/test/local-media.test.js +84 -0
  88. package/test/media-playback-events.test.js +22 -0
  89. package/test/media-seek-snapshot.test.js +11 -0
  90. package/test/media-source-config.test.js +98 -0
  91. package/test/media-source-webm-accounting.test.js +98 -0
  92. package/test/media-source-webm.test.js +108 -0
  93. package/test/navigation.test.js +1 -0
  94. package/test/network-service.test.js +388 -0
  95. package/test/page-scripts.test.js +160 -2
  96. package/test/paint-frame.test.js +103 -0
  97. package/test/paint-paragraph-cache.test.js +87 -0
  98. package/test/piano-input.test.js +44 -0
  99. package/test/playback-buffer.test.js +31 -0
  100. package/test/position-audio.test.js +24 -0
  101. package/test/positioning.test.js +22 -0
  102. package/test/prepared-script-cache.test.js +45 -0
  103. package/test/pulse-client.test.js +27 -5
  104. package/test/raster-readback.test.js +40 -0
  105. package/test/render-tree.test.js +28 -0
  106. package/test/renderer-memory.test.js +11 -0
  107. package/test/selector-traits.test.js +24 -0
  108. package/test/shared-frame-pool.test.js +63 -0
  109. package/test/style-sheets.test.js +51 -0
  110. package/test/svg.test.js +29 -0
  111. package/test/text-layout.test.js +168 -0
  112. package/test/video-keyframe.test.js +61 -0
  113. package/test/video-presentation.test.js +136 -0
  114. package/tools/browser-lab.js +78 -0
  115. package/tools/browser-soak.js +75 -0
  116. package/tools/network-reference.js +49 -0
  117. package/tools/process-memory.js +38 -0
  118. package/tools/script-benchmark.js +25 -0
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Box-tree layout: block formatting contexts with margin collapsing, inline
3
3
  * formatting contexts with line boxes, atomic inlines, and flex layout
4
- * (CSS Flexbox §9: flexible length resolution, wrapping, alignment; column
5
- * containers currently stack items without main-axis flexing), and grid
6
- * layout (CSS Grid 2 §8 placement and §12 track sizing, without named lines,
7
- * areas, or column auto-flow). Relative, absolute and fixed positioning
4
+ * (CSS Flexbox §9: row/column flexible length resolution, row wrapping,
5
+ * alignment), and grid
6
+ * layout (CSS Grid 2 §8 placement and §12 track sizing, including named areas
7
+ * and row/column auto-flow). Relative, absolute and fixed positioning
8
8
  * place boxes after flow layout; positioned boxes get their own paint layer.
9
9
  *
10
10
  * The block/inline structure follows concepts from Dropflow
@@ -23,6 +23,14 @@ const ZERO_STRUT = Object.freeze({ positive: 0, negative: 0 });
23
23
  const DEFAULT_ASCENT = 0.93;
24
24
  const DEFAULT_CONTENT_HEIGHT = 1.17;
25
25
 
26
+ // CSS 2 §8.4: all four percentage paddings use the containing block's width.
27
+ // Cyclic percentages contribute zero during intrinsic sizing.
28
+ function paddingBox(style, width = 0) {
29
+ const padding = style?.padding ?? ZERO_BOX;
30
+ return padding.every(Number.isFinite) ? padding
31
+ : padding.map(value => Math.max(0, numberOrZero(resolveLength(value, width))));
32
+ }
33
+
26
34
  const ROOT_LAYER = Object.freeze({ z: 0, order: 0, context: true, parent: null });
27
35
 
28
36
  export function layoutText(tree, options = {}) {
@@ -142,7 +150,7 @@ let containingBlocks = null;
142
150
  let outOfFlowBoxes = null;
143
151
 
144
152
  // Text nodes carry their parent's style, so only element boxes can be positioned.
145
- const UNPOSITIONABLE = new Set(["root", "text", "line-break"]);
153
+ const UNPOSITIONABLE = new Set(["root", "text", "line-break", "contents"]);
146
154
 
147
155
  function isPositioned(node) {
148
156
  return !UNPOSITIONABLE.has(node.type) && ["relative", "absolute", "fixed", "sticky"].includes(node.style?.position);
@@ -209,7 +217,10 @@ function blockChildren(node, flags) {
209
217
  };
210
218
 
211
219
  function walk(child, childFlags) {
212
- if (isOutOfFlow(child)) {
220
+ if (child.type === "contents") {
221
+ const flags = inheritedFlags(child, childFlags);
222
+ for (const grandchild of child.children) walk(grandchild, flags);
223
+ } else if (isOutOfFlow(child)) {
213
224
  const box = outOfFlowBox(child, childFlags);
214
225
  // Between blocks the placeholder records the static position; inline
215
226
  // placeholders fall back to the containing block's origin.
@@ -263,10 +274,10 @@ function flexItems(node, flags) {
263
274
  }
264
275
  text = [];
265
276
  };
266
- for (const child of node.children) {
277
+ for (const child of boxChildren(node)) {
267
278
  if (child.type === "line-break") continue;
268
279
  if (child.type === "text") {
269
- if (child.text.trim()) text.push(textItem(child, flags));
280
+ if (child.text.trim()) text.push(textItem(child, inheritedFlags(child, flags)));
270
281
  continue;
271
282
  }
272
283
  if (isOutOfFlow(child)) {
@@ -280,6 +291,14 @@ function flexItems(node, flags) {
280
291
  return items;
281
292
  }
282
293
 
294
+ /** display:contents contributes its descendants directly to the box tree. */
295
+ function* boxChildren(node) {
296
+ for (const child of node.children) {
297
+ if (child.type === "contents") yield* boxChildren(child);
298
+ else yield child;
299
+ }
300
+ }
301
+
283
302
  function textItem(node, flags) {
284
303
  return { kind: "text", node, style: node.style ?? null, text: node.text, preserve: flags.preserve, wrap: flags.wrap };
285
304
  }
@@ -325,7 +344,7 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
325
344
  const style = box.style;
326
345
  // Its containing block, for percentages in relative offsets.
327
346
  const containing = ctx.containing;
328
- const key = blockCacheKey(width, absorbTop, forcedHeight, style?.position === "relative" ? containing : null);
347
+ const key = blockCacheKey(width, absorbTop, forcedHeight, containing);
329
348
  const cached = cachedBlockResult(ctx, box, key, y, x);
330
349
  if (cached) return cached;
331
350
  const boxStart = ctx.boxes.length;
@@ -356,9 +375,11 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
356
375
  dx = offset.x;
357
376
  dy = offset.y;
358
377
  }
359
- if (style?.transform) {
360
- dx += numberOrZero(resolveLength(style.transform.x, record.width));
361
- dy += numberOrZero(resolveLength(style.transform.y, record.height));
378
+ // Individual translate precedes the transform list; its offset is in the
379
+ // parent's coordinates and is not multiplied by the list's own scale.
380
+ for (const translation of [style?.translate, style?.transform]) if (translation) {
381
+ dx += numberOrZero(resolveLength(translation.x, record.width));
382
+ dy += numberOrZero(resolveLength(translation.y, record.height));
362
383
  }
363
384
  for (let index = boxStart; index < ctx.boxes.length; index++) ctx.boxes[index].layer ??= ctx.layer;
364
385
  for (let index = fragmentStart; index < ctx.fragments.length; index++) ctx.fragments[index].layer ??= ctx.layer;
@@ -511,31 +532,37 @@ function layoutAbsolutes(ctx, box, record) {
511
532
  for (const absolute of box.absolutes) {
512
533
  const style = absolute.style;
513
534
  const containing = style?.position === "fixed" ? { ...icb, height: icb.height || block.height } : block;
514
- const [top, right, bottom, left] = (style?.inset ?? ["auto", "auto", "auto", "auto"])
515
- .map((value, index) => value === "auto" ? null : resolveLength(value, index % 2 ? containing.width : containing.height));
516
- const margin = style?.margin ?? ZERO_BOX;
517
- const [marginTop, marginRight, marginBottom, marginLeft] = margin.map(numberOrZero);
518
- const specifiedWidth = numberOrNull(resolveLength(style?.width, containing.width));
519
- let width;
520
- if (specifiedWidth == null && left != null && right != null) {
521
- width = Math.max(0, containing.width - left - right - marginLeft - marginRight);
522
- } else {
523
- width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
535
+ const previousContaining = ctx.containing;
536
+ ctx.containing = containing;
537
+ try {
538
+ const [top, right, bottom, left] = (style?.inset ?? ["auto", "auto", "auto", "auto"])
539
+ .map((value, index) => value === "auto" ? null : resolveLength(value, index % 2 ? containing.width : containing.height));
540
+ const margin = style?.margin ?? ZERO_BOX;
541
+ const [marginTop, marginRight, marginBottom, marginLeft] = margin.map(numberOrZero);
542
+ const specifiedWidth = numberOrNull(resolveLength(style?.width, containing.width));
543
+ let width;
544
+ if (specifiedWidth == null && left != null && right != null) {
545
+ width = Math.max(0, containing.width - left - right - marginLeft - marginRight);
546
+ } else {
547
+ width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
548
+ }
549
+ const staticPosition = absolute.staticPosition ?? containing;
550
+ const x = left != null ? containing.x + left + marginLeft
551
+ : right != null ? containing.x + containing.width - right - marginRight - width
552
+ : staticPosition.x + marginLeft;
553
+ const specifiedHeight = numberOrNull(resolveLength(style?.height, containing.height));
554
+ const forcedHeight = specifiedHeight == null && top != null && bottom != null
555
+ ? Math.max(0, containing.height - top - bottom - marginTop - marginBottom)
556
+ : null;
557
+ let height = forcedHeight;
558
+ if (height == null && top == null && bottom != null) height = dryLayout(ctx, absolute, width).height;
559
+ const y = top != null ? containing.y + top + marginTop
560
+ : bottom != null ? containing.y + containing.height - bottom - marginBottom - height
561
+ : staticPosition.y + marginTop;
562
+ layoutBlock(ctx, absolute, x, width, y, false, forcedHeight);
563
+ } finally {
564
+ ctx.containing = previousContaining;
524
565
  }
525
- const staticPosition = absolute.staticPosition ?? containing;
526
- const x = left != null ? containing.x + left + marginLeft
527
- : right != null ? containing.x + containing.width - right - marginRight - width
528
- : staticPosition.x + marginLeft;
529
- const specifiedHeight = numberOrNull(resolveLength(style?.height, containing.height));
530
- const forcedHeight = specifiedHeight == null && top != null && bottom != null
531
- ? Math.max(0, containing.height - top - bottom - marginTop - marginBottom)
532
- : null;
533
- let height = forcedHeight;
534
- if (height == null && top == null && bottom != null) height = dryLayout(ctx, absolute, width).height;
535
- const y = top != null ? containing.y + top + marginTop
536
- : bottom != null ? containing.y + containing.height - bottom - marginBottom - height
537
- : staticPosition.y + marginTop;
538
- layoutBlock(ctx, absolute, x, width, y, false, forcedHeight);
539
566
  }
540
567
  }
541
568
 
@@ -546,15 +573,23 @@ function layoutAbsolutes(ctx, box, record) {
546
573
  * item by item because shiftOutput moves everything after a start index.
547
574
  */
548
575
  function justifyColumn(ctx, items, justify = "normal", free) {
549
- if (!(free > 0) || ["normal", "start", "flex-start", "stretch"].includes(justify)) return;
576
+ const autoMargins = items.reduce((sum, item) => sum + Number(item.autoTop) + Number(item.autoBottom), 0);
577
+ if (!free || !autoMargins && ["normal", "start", "flex-start", "stretch"].includes(justify)) return;
550
578
  const count = items.length;
579
+ let autoOffset = 0;
551
580
  const offset = (index) => {
581
+ if (autoMargins && free > 0) {
582
+ autoOffset += Number(items[index].autoTop) * free / autoMargins;
583
+ const result = autoOffset;
584
+ autoOffset += Number(items[index].autoBottom) * free / autoMargins;
585
+ return result;
586
+ }
552
587
  switch (justify) {
553
588
  case "center": return free / 2;
554
589
  case "end": case "flex-end": return free;
555
- case "space-between": return count > 1 ? free * index / (count - 1) : 0;
556
- case "space-around": return free / count * (index + 0.5);
557
- case "space-evenly": return free / (count + 1) * (index + 1);
590
+ case "space-between": return free > 0 && count > 1 ? free * index / (count - 1) : 0;
591
+ case "space-around": return free > 0 ? free / count * (index + 0.5) : free / 2;
592
+ case "space-evenly": return free > 0 ? free / (count + 1) * (index + 1) : free / 2;
558
593
  default: return 0;
559
594
  }
560
595
  };
@@ -593,9 +628,9 @@ function shiftedFragment(fragment, dx, dy) {
593
628
  }
594
629
 
595
630
  function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeight = null) {
596
- const style = box.style;
631
+ const padding = paddingBox(box.style, ctx.containing?.width ?? width);
632
+ const style = box.style && padding !== box.style.padding ? { ...box.style, padding } : box.style;
597
633
  const border = borderWidths(style);
598
- const padding = style?.padding ?? ZERO_BOX;
599
634
  const record = { nodeId: box.node.id, x, y, width, height: 0, style };
600
635
  ctx.boxes.push(record);
601
636
  if (box.replaced) {
@@ -639,6 +674,8 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
639
674
  const containingHeight = ctx.containing?.height;
640
675
  const resolveHeight = (value) => numberOrNull(resolveLength(value, containingHeight ?? NaN));
641
676
  const specifiedHeight = resolveHeight(style?.height);
677
+ const minHeight = resolveHeight(style?.minHeight);
678
+ const maxHeight = style?.maxHeight === "none" ? null : resolveHeight(style?.maxHeight);
642
679
  const previousContaining = ctx.containing;
643
680
  ctx.containing = {
644
681
  width: contentWidth,
@@ -660,38 +697,11 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
660
697
  contentBottom = result.bottom;
661
698
  baseline = result.baseline;
662
699
  } else if (box.flex === "column") {
663
- const gap = style?.rowGap ?? style?.gap ?? 0;
664
- let cursor = contentTop;
665
- columnItems = [];
666
- box.children.forEach((child, index) => {
667
- if (index > 0) cursor += gap;
668
- columnItems.push({ boxStart: ctx.boxes.length, fragmentStart: ctx.fragments.length });
669
- if (child.kind === "ifc") {
670
- const result = layoutInline(ctx, child, contentX, contentWidth, cursor);
671
- cursor = result.bottom;
672
- baseline = result.baseline ?? baseline;
673
- return;
674
- }
675
- const alignment = ["auto", "normal", undefined].includes(child.style?.alignSelf)
676
- ? style?.alignItems ?? "normal"
677
- : child.style.alignSelf;
678
- const shrink = !["normal", "stretch"].includes(alignment);
679
- const placed = resolveHorizontal(ctx, child, contentX, contentWidth, shrink);
680
- // Auto margins on the cross axis (left, right) align the item instead (Flexbox §8.1).
681
- const crossMargins = child.style?.margin ?? ZERO_BOX;
682
- const autoMargins = crossMargins[1] === "auto" || crossMargins[3] === "auto";
683
- let childX = placed.x;
684
- if (shrink && !autoMargins) {
685
- const free = contentWidth - placed.width - placed.marginLeft - placed.marginRight;
686
- if (alignment === "center") childX = contentX + placed.marginLeft + free / 2;
687
- else if (alignment === "end") childX = contentX + placed.marginLeft + free;
688
- }
689
- const margin = verticalMargins(child.style);
690
- const result = layoutBlock(ctx, child, childX, placed.width, cursor + margin.top);
691
- cursor += margin.top + result.height + margin.bottom;
692
- baseline = result.baseline ?? baseline;
693
- });
694
- contentBottom = cursor;
700
+ const result = layoutFlexColumn(ctx, box, contentX, contentWidth, contentTop,
701
+ minHeight == null ? 0 : toContentHeight(minHeight), maxHeight == null ? Infinity : toContentHeight(maxHeight));
702
+ contentBottom = result.bottom;
703
+ baseline = result.baseline;
704
+ columnItems = result.items;
695
705
  } else {
696
706
  let cursor = contentTop;
697
707
  let pending = null;
@@ -718,8 +728,13 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
718
728
  // Tables with width: auto are sized to their contents (CSS 2 §17.5.2).
719
729
  const placed = resolveHorizontal(ctx, child, contentX, contentWidth, Boolean(child.table));
720
730
  const result = layoutBlock(ctx, child, placed.x, placed.width, childY, collapsesThroughTop(child));
721
- cursor = childY + result.height;
722
- pending = bottomStrut(child);
731
+ if (result.height === 0 && selfCollapsing(child)) {
732
+ // Empty blocks do not separate adjoining margins (CSS 2 §8.3.1).
733
+ pending = combineStruts(combined, bottomStrut(child));
734
+ } else {
735
+ cursor = childY + result.height;
736
+ pending = bottomStrut(child);
737
+ }
723
738
  baseline = result.baseline ?? baseline;
724
739
  });
725
740
  contentBottom = collapsesThroughBottom(box) ? cursor : cursor + strutValue(pending ?? ZERO_STRUT);
@@ -728,14 +743,15 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
728
743
  ctx.containing = previousContaining;
729
744
  let contentHeight = Math.max(0, contentBottom - contentTop);
730
745
  if (specifiedHeight != null) contentHeight = toContentHeight(specifiedHeight);
731
- const maxHeight = style?.maxHeight === "none" ? null : resolveHeight(style?.maxHeight);
732
746
  if (maxHeight != null) contentHeight = Math.min(contentHeight, toContentHeight(maxHeight));
733
- const minHeight = resolveHeight(style?.minHeight);
734
747
  if (minHeight != null) contentHeight = Math.max(contentHeight, toContentHeight(minHeight));
748
+ if (forcedHeight != null) contentHeight = Math.max(0, forcedHeight - padding[0] - padding[2] - border[0] - border[2]);
735
749
  if (columnItems?.length) justifyColumn(ctx, columnItems, style?.justifyContent, contentHeight - (contentBottom - contentTop));
736
750
  if (gridResult) alignGridContent(ctx, gridResult, style?.alignContent, contentHeight - (contentBottom - contentTop));
737
751
  record.height = forcedHeight ?? contentHeight + padding[0] + padding[2] + border[0] + border[2];
738
- if (style?.display === "list-item") placeListMarker(ctx, box, contentX, contentTop, firstFragment);
752
+ // Summary's disclosure marker participates in its inline content.
753
+ const summaryMarker = box.node.tagName === "SUMMARY" && ["disclosure-open", "disclosure-closed"].includes(style?.listStyleType);
754
+ if (style?.display === "list-item" && !summaryMarker) placeListMarker(ctx, box, contentX, contentTop, firstFragment);
739
755
  return { height: record.height, baseline };
740
756
  }
741
757
 
@@ -783,6 +799,8 @@ function markerText(type, ordinal) {
783
799
  case "disc": return "\u2022 ";
784
800
  case "circle": return "\u25E6 ";
785
801
  case "square": return "\u25AA ";
802
+ case "disclosure-closed": return "\u25B6 ";
803
+ case "disclosure-open": return "\u25BC ";
786
804
  case "decimal": return `${ordinal}. `;
787
805
  case "decimal-leading-zero": return `${String(ordinal).padStart(2, "0")}. `;
788
806
  case "lower-alpha":
@@ -818,7 +836,97 @@ function roman(value) {
818
836
  }
819
837
 
820
838
  // ---------------------------------------------------------------------------
821
- // Flex row layout (CSS Flexbox §9)
839
+ // Flex layout (CSS Flexbox §9)
840
+
841
+ function layoutFlexColumn(ctx, box, x, width, y, minHeight, maxHeight) {
842
+ const style = box.style;
843
+ const gap = style?.rowGap ?? style?.gap ?? 0;
844
+ const definiteHeight = ctx.containing?.height;
845
+ // Most column containers have auto height. Without a main-axis constraint
846
+ // or definite basis there is no flexible free space to distribute, so a
847
+ // single layout pass suffices even for deeply nested application shells.
848
+ const unconstrained = definiteHeight == null && minHeight === 0 && maxHeight === Infinity
849
+ && box.children.every(child => numberOrNull(resolveLength(child.style?.flexBasis, NaN)) == null);
850
+ if (unconstrained) {
851
+ let cursor = y;
852
+ let baseline = null;
853
+ const items = [];
854
+ for (const [index, child] of box.children.entries()) {
855
+ if (index) cursor += gap;
856
+ const horizontal = columnHorizontal(ctx, child, style, x, width);
857
+ const margin = child.style?.margin ?? ZERO_BOX;
858
+ items.push({boxStart:ctx.boxes.length,fragmentStart:ctx.fragments.length,
859
+ autoTop:margin[0] === "auto",autoBottom:margin[2] === "auto"});
860
+ const result = layoutBlock(ctx, child, horizontal.x, horizontal.width, cursor + numberOrZero(margin[0]));
861
+ cursor += numberOrZero(margin[0]) + result.height + numberOrZero(margin[2]);
862
+ baseline ??= result.baseline;
863
+ }
864
+ return {bottom:cursor,baseline,items};
865
+ }
866
+ const items = box.children.map(child => {
867
+ const childStyle = child.style;
868
+ const horizontal = columnHorizontal(ctx, child, style, x, width);
869
+ const margin = childStyle?.margin ?? ZERO_BOX;
870
+ const padding = paddingBox(childStyle, width);
871
+ const border = borderWidths(childStyle);
872
+ const extra = padding[0] + padding[2] + border[0] + border[2];
873
+ const contentBox = value => Math.max(0, childStyle?.boxSizing === "border-box" ? value - extra : value);
874
+ const resolve = value => numberOrNull(resolveLength(value, definiteHeight ?? NaN));
875
+ const measured = dryLayout(ctx, child, horizontal.width);
876
+ const content = Math.max(0, measured.height - extra);
877
+ const specified = resolve(childStyle?.height);
878
+ const basis = childStyle?.flexBasis ?? "auto";
879
+ const definiteBasis = resolve(basis);
880
+ const base = definiteBasis != null ? contentBox(definiteBasis)
881
+ : basis === "auto" && specified != null ? contentBox(specified) : content;
882
+ const minSize = resolve(childStyle?.minHeight);
883
+ // The automatic minimum is content-based except for scrollable items.
884
+ const scrollable = ["auto", "scroll", "hidden"].includes(childStyle?.overflowScrollY);
885
+ let min = minSize != null ? contentBox(minSize) : scrollable ? 0
886
+ : Math.min(content, specified == null ? Infinity : contentBox(specified));
887
+ const maxSize = resolve(childStyle?.maxHeight);
888
+ const max = maxSize == null ? Infinity : contentBox(maxSize);
889
+ min = Math.min(min, max);
890
+ return { box:child, horizontal, extra, base, min, max, naturalHeight:measured.height,
891
+ definite:definiteHeight != null || definiteBasis != null,
892
+ hypothetical:Math.min(max, Math.max(min, base)), grow:childStyle?.flexGrow ?? 0, shrink:childStyle?.flexShrink ?? 1,
893
+ // The shared main-axis solver names these left/right; columns use top/bottom.
894
+ marginLeft:numberOrZero(margin[0]), marginRight:numberOrZero(margin[2]),
895
+ autoTop:margin[0] === "auto", autoBottom:margin[2] === "auto" };
896
+ });
897
+ const gaps = gap * Math.max(0, items.length - 1);
898
+ const natural = gaps + items.reduce((sum, item) => sum + item.hypothetical + item.extra + item.marginLeft + item.marginRight, 0);
899
+ const available = Math.max(minHeight, Math.min(maxHeight, definiteHeight ?? natural));
900
+ resolveFlexibleLengths(items, available - gaps);
901
+ let cursor = y;
902
+ let baseline = null;
903
+ for (const [index, item] of items.entries()) {
904
+ if (index) cursor += gap;
905
+ item.boxStart = ctx.boxes.length;
906
+ item.fragmentStart = ctx.fragments.length;
907
+ const targetHeight = item.target + item.extra;
908
+ const forcedHeight = item.definite || Math.abs(targetHeight - item.naturalHeight) > 1e-9 ? targetHeight : null;
909
+ const result = layoutBlock(ctx, item.box, item.horizontal.x, item.horizontal.width,
910
+ cursor + item.marginLeft, false, forcedHeight);
911
+ cursor += item.marginLeft + result.height + item.marginRight;
912
+ baseline ??= result.baseline;
913
+ }
914
+ return { bottom:cursor, baseline, items };
915
+ }
916
+
917
+ function columnHorizontal(ctx, child, style, x, width) {
918
+ const alignment = ["auto", "normal", undefined].includes(child.style?.alignSelf)
919
+ ? style?.alignItems ?? "normal" : child.style.alignSelf;
920
+ const shrink = !["normal", "stretch"].includes(alignment);
921
+ const horizontal = resolveHorizontal(ctx, child, x, width, shrink);
922
+ const margin = child.style?.margin ?? ZERO_BOX;
923
+ if (shrink && margin[1] !== "auto" && margin[3] !== "auto") {
924
+ const free = width - horizontal.width - horizontal.marginLeft - horizontal.marginRight;
925
+ if (alignment === "center") horizontal.x += free / 2;
926
+ else if (alignment === "end") horizontal.x += free;
927
+ }
928
+ return horizontal;
929
+ }
822
930
 
823
931
  function layoutFlexRow(ctx, box, x, width, y) {
824
932
  const style = box.style;
@@ -912,7 +1020,7 @@ function flexItem(ctx, box, containerWidth) {
912
1020
  // Sizes below are content-box sizes, as in Blink's FlexItem
913
1021
  // (base_content_size, hypothetical_content_size); `extra` is padding+border.
914
1022
  const style = box.style;
915
- const padding = style?.padding ?? ZERO_BOX;
1023
+ const padding = paddingBox(style, containerWidth);
916
1024
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
917
1025
  const contentBox = (value) => Math.max(0, style?.boxSizing === "border-box" ? value - extra : value);
918
1026
  const margin = style?.margin ?? ZERO_BOX;
@@ -1124,7 +1232,7 @@ function tableStructure(node, flags) {
1124
1232
  target.push({
1125
1233
  node: child,
1126
1234
  style: child.style ?? null,
1127
- cells: child.children.filter((cell) => cell.type === "table-cell").map((cell) => tableCell(cell, rowFlags)),
1235
+ cells: [...boxChildren(child)].filter((cell) => cell.type === "table-cell").map((cell) => tableCell(cell, rowFlags)),
1128
1236
  });
1129
1237
  } else if (child.type === "table-cell") {
1130
1238
  if (!anonymous) target.push(anonymous = { node: null, style: null, cells: [] });
@@ -1137,16 +1245,16 @@ function tableStructure(node, flags) {
1137
1245
  addRows(body, loose, flags);
1138
1246
  loose = [];
1139
1247
  };
1140
- for (const child of node.children) {
1248
+ for (const child of boxChildren(node)) {
1141
1249
  if (child.type === "table-row" || child.type === "table-cell") {
1142
1250
  loose.push(child);
1143
1251
  continue;
1144
1252
  }
1145
1253
  flushLoose();
1146
1254
  if (child.type === "table-caption") captions.push(buildBox(child, inheritedFlags(child, flags), true));
1147
- else if (child.type === "table-header-group") addRows(header, child.children, inheritedFlags(child, flags));
1148
- else if (child.type === "table-footer-group") addRows(footer, child.children, inheritedFlags(child, flags));
1149
- else if (child.type === "table-row-group") addRows(body, child.children, inheritedFlags(child, flags));
1255
+ else if (child.type === "table-header-group") addRows(header, boxChildren(child), inheritedFlags(child, flags));
1256
+ else if (child.type === "table-footer-group") addRows(footer, boxChildren(child), inheritedFlags(child, flags));
1257
+ else if (child.type === "table-row-group") addRows(body, boxChildren(child), inheritedFlags(child, flags));
1150
1258
  }
1151
1259
  flushLoose();
1152
1260
  return { rows: [...header, ...body, ...footer], captions, grid: null };
@@ -1338,15 +1446,44 @@ function gridHorizontal(ctx, containerStyle, item, areaX, areaWidth) {
1338
1446
  return placed;
1339
1447
  }
1340
1448
 
1341
- /** Expands track lists and places items (§7.2, §8.5 row auto-flow). */
1449
+ /** Expands track lists and places items (§7.2, §8.5). */
1342
1450
  function gridStructure(ctx, box, availableWidth, columnGap, definite) {
1343
1451
  const style = box.style;
1344
1452
  const explicitColumns = expandTrackList(style?.gridTemplateColumns, definite ? availableWidth : null, columnGap);
1345
1453
  const explicitRows = expandTrackList(style?.gridTemplateRows, null, style?.rowGap ?? 0);
1346
- const items = box.children.map((child) => ({ box: child, ...gridLines(child.style, explicitColumns.tracks.length, "Column"),
1347
- ...rowLines(child.style, explicitRows.tracks.length) }));
1454
+ const areas = style?.gridTemplateAreas;
1455
+ const explicitColumnCount = Math.max(explicitColumns.tracks.length, areas?.columns ?? 0);
1456
+ const explicitRowCount = Math.max(explicitRows.tracks.length, areas?.rows ?? 0);
1457
+ const items = box.children.map((child) => ({ box: child,
1458
+ ...gridLines(child.style, explicitColumnCount, "Column", areas),
1459
+ ...gridLines(child.style, explicitRowCount, "Row", areas) }));
1460
+ // The column-flow algorithm is the row-flow algorithm with its axes exchanged.
1461
+ const columnFlow = style?.gridAutoFlow?.column === true;
1462
+ if (columnFlow) for (const item of items) transposeGridItem(item);
1463
+ let { columnCount, rowCount } = placeGridItems(items,
1464
+ columnFlow ? explicitRowCount : explicitColumnCount,
1465
+ columnFlow ? explicitColumnCount : explicitRowCount, style?.gridAutoFlow?.dense === true);
1466
+ if (columnFlow) {
1467
+ for (const item of items) transposeGridItem(item);
1468
+ [columnCount, rowCount] = [rowCount, columnCount];
1469
+ }
1470
+ const columnTracks = Array.from({ length: columnCount }, (_, index) =>
1471
+ explicitColumns.tracks[index] ?? cycle(style?.gridAutoColumns, index - explicitColumns.tracks.length));
1472
+ const rowTracks = Array.from({ length: rowCount }, (_, index) =>
1473
+ explicitRows.tracks[index] ?? cycle(style?.gridAutoRows, index - explicitRows.tracks.length));
1474
+ // auto-fit: repeated tracks nobody occupies collapse (§7.2.3.2).
1475
+ const columnCollapsed = columnTracks.map((_, index) => explicitColumns.autoFit.has(index)
1476
+ && !items.some((item) => index >= item.column && index < item.column + item.columnSpan));
1477
+ return { items, columnTracks, rowTracks, columnCollapsed };
1478
+ }
1348
1479
 
1349
- let columnCount = Math.max(1, explicitColumns.tracks.length);
1480
+ function transposeGridItem(item) {
1481
+ [item.row, item.column] = [item.column, item.row];
1482
+ [item.rowSpan, item.columnSpan] = [item.columnSpan, item.rowSpan];
1483
+ }
1484
+
1485
+ function placeGridItems(items, explicitColumnCount, explicitRowCount, dense) {
1486
+ let columnCount = Math.max(1, explicitColumnCount);
1350
1487
  for (const item of items) {
1351
1488
  if (item.column != null) columnCount = Math.max(columnCount, item.column + item.columnSpan);
1352
1489
  else columnCount = Math.max(columnCount, item.columnSpan);
@@ -1376,7 +1513,6 @@ function gridStructure(ctx, box, availableWidth, columnGap, definite) {
1376
1513
  occupy(item);
1377
1514
  }
1378
1515
  // 3. Auto-placed items walk a cursor forward (sparse) or restart (dense).
1379
- const dense = style?.gridAutoFlow?.dense === true;
1380
1516
  let cursorRow = 0;
1381
1517
  let cursorColumn = 0;
1382
1518
  for (const item of items) {
@@ -1406,15 +1542,8 @@ function gridStructure(ctx, box, availableWidth, columnGap, definite) {
1406
1542
  occupy(item);
1407
1543
  }
1408
1544
 
1409
- const rowCount = Math.max(explicitRows.tracks.length, ...items.map((item) => item.row + item.rowSpan), 0);
1410
- const columnTracks = Array.from({ length: columnCount }, (_, index) =>
1411
- explicitColumns.tracks[index] ?? cycle(style?.gridAutoColumns, index - explicitColumns.tracks.length));
1412
- const rowTracks = Array.from({ length: rowCount }, (_, index) =>
1413
- explicitRows.tracks[index] ?? cycle(style?.gridAutoRows, index - explicitRows.tracks.length));
1414
- // auto-fit: repeated tracks nobody occupies collapse (§7.2.3.2).
1415
- const columnCollapsed = columnTracks.map((_, index) => explicitColumns.autoFit.has(index)
1416
- && !items.some((item) => index >= item.column && index < item.column + item.columnSpan));
1417
- return { items, columnTracks, rowTracks, columnCollapsed };
1545
+ const rowCount = Math.max(explicitRowCount, ...items.map((item) => item.row + item.rowSpan), 0);
1546
+ return { columnCount, rowCount };
1418
1547
  }
1419
1548
 
1420
1549
  function cycle(tracks, index) {
@@ -1422,13 +1551,22 @@ function cycle(tracks, index) {
1422
1551
  return list[((index % list.length) + list.length) % list.length];
1423
1552
  }
1424
1553
 
1425
- function gridLines(style, explicitCount, axis) {
1554
+ function gridLines(style, explicitCount, axis, templateAreas) {
1426
1555
  const start = style?.[`grid${axis}Start`] ?? "auto";
1427
1556
  const end = style?.[`grid${axis}End`] ?? "auto";
1428
1557
  // Line numbers are 1-based; negatives count back from the explicit end line.
1429
1558
  const index = (line) => line > 0 ? line - 1 : Math.max(0, explicitCount + 1 + line);
1430
- let from = start.line != null ? index(start.line) : null;
1431
- let to = end.line != null ? index(end.line) : null;
1559
+ const areaLine = (name, end) => {
1560
+ if (!name || !templateAreas) return null;
1561
+ const key = axis.toLowerCase();
1562
+ const direct = templateAreas.areas[name];
1563
+ if (direct) return direct[end ? `${key}End` : key];
1564
+ const suffix = /^(.*)-(start|end)$/.exec(name);
1565
+ const area = suffix && templateAreas.areas[suffix[1]];
1566
+ return area ? area[suffix[2] === "end" ? `${key}End` : key] : null;
1567
+ };
1568
+ let from = start.line != null ? index(start.line) : areaLine(start.name, false);
1569
+ let to = end.line != null ? index(end.line) : areaLine(end.name, true);
1432
1570
  let span = 1;
1433
1571
  if (from != null && to != null) {
1434
1572
  if (to < from) [from, to] = [to, from];
@@ -1445,10 +1583,6 @@ function gridLines(style, explicitCount, axis) {
1445
1583
  return axis === "Column" ? { column: from, columnSpan: span } : { row: from, rowSpan: span };
1446
1584
  }
1447
1585
 
1448
- function rowLines(style, explicitCount) {
1449
- return gridLines(style, explicitCount, "Row");
1450
- }
1451
-
1452
1586
  /** Expands repeat() and resolves auto-fill/auto-fit repetitions (§7.2.3.2). */
1453
1587
  function expandTrackList(list, available, gap) {
1454
1588
  const tracks = [];
@@ -1503,7 +1637,7 @@ function minimumContribution(box, minContent, available) {
1503
1637
  const margins = numberOrZero(margin[1]) + numberOrZero(margin[3]);
1504
1638
  const specified = numberOrNull(resolveLength(style?.minWidth, available));
1505
1639
  if (specified != null) {
1506
- const padding = style?.padding ?? ZERO_BOX;
1640
+ const padding = paddingBox(style, available);
1507
1641
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
1508
1642
  return (style.boxSizing === "border-box" ? Math.max(specified, extra) : specified + extra) + margins;
1509
1643
  }
@@ -1693,7 +1827,7 @@ function dryLayout(ctx, box, width) {
1693
1827
  /** Resolves the used border-box width and horizontal margins of a block-level box. */
1694
1828
  function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, percentageBasis = containingWidth) {
1695
1829
  const style = box.style;
1696
- const padding = style?.padding ?? ZERO_BOX;
1830
+ const padding = paddingBox(style, percentageBasis);
1697
1831
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
1698
1832
  const borderBox = (value) => style?.boxSizing === "border-box" ? value : value + extra;
1699
1833
  const margin = style?.margin ?? ZERO_BOX;
@@ -1729,7 +1863,7 @@ function maxContentWidth(ctx, box) {
1729
1863
  const cached = ctx.maxContent.get(cacheKey);
1730
1864
  if (cached != null) return cached;
1731
1865
  const style = box.style;
1732
- const padding = style?.padding ?? ZERO_BOX;
1866
+ const padding = paddingBox(style);
1733
1867
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
1734
1868
  let width;
1735
1869
  const specified = numberOrNull(style?.width);
@@ -1772,7 +1906,7 @@ function minContentWidth(ctx, box) {
1772
1906
  const cached = ctx.minContent.get(cacheKey);
1773
1907
  if (cached != null) return cached;
1774
1908
  const style = box.style;
1775
- const padding = style?.padding ?? ZERO_BOX;
1909
+ const padding = paddingBox(style);
1776
1910
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
1777
1911
  const specified = numberOrNull(style?.width);
1778
1912
  const outer = (child) => {
@@ -1821,6 +1955,15 @@ function collapsesThroughTop(box) {
1821
1955
  && inFlowChildren(box)[0]?.kind === "block";
1822
1956
  }
1823
1957
 
1958
+ function selfCollapsing(box) {
1959
+ return !box.bfc && box.kind === "block" && !box.replaced
1960
+ && borderWidths(box.style).every(value => value === 0)
1961
+ && (box.style?.padding ?? ZERO_BOX).every(value => value === 0)
1962
+ && (box.style?.height === "auto" || box.style?.height === 0)
1963
+ && (numberOrNull(box.style?.minHeight) ?? 0) <= 0
1964
+ && inFlowChildren(box).every(selfCollapsing);
1965
+ }
1966
+
1824
1967
  function collapsesThroughBottom(box) {
1825
1968
  return !box.bfc && box.kind === "block" && !box.replaced
1826
1969
  && borderWidths(box.style)[2] === 0 && (box.style?.padding ?? ZERO_BOX)[2] === 0
@@ -1874,6 +2017,14 @@ function layoutInline(ctx, ifc, x, width, y) {
1874
2017
  // Percentage sizes of inline images.
1875
2018
  atomFor.containing = ctx.containing;
1876
2019
  const lines = breakLines(ctx, paragraph, width, atomFor);
2020
+ // Legacy line clamping applies to a vertical -webkit-box, not ordinary blocks.
2021
+ const clamp = ifc.style?.webkitBoxOrient === "vertical"
2022
+ && ["-webkit-box", "-webkit-inline-box"].includes(ifc.style?.display)
2023
+ ? ifc.style.webkitLineClamp : null;
2024
+ if (clamp && lines.length > clamp) {
2025
+ lines.length = clamp;
2026
+ lines[clamp - 1] = { ...lines[clamp - 1], clamped: true };
2027
+ }
1877
2028
  let cursor = y;
1878
2029
  let baseline = null;
1879
2030
  for (const line of lines) {
@@ -2048,7 +2199,7 @@ function mayBreakWithinWords(paragraph, piece) {
2048
2199
  * room for "…". Mutates `segments`; returns the ellipsis to append, or null.
2049
2200
  */
2050
2201
  function truncateForEllipsis(ctx, style, paragraph, segments, line, width) {
2051
- if (style?.textOverflow !== "ellipsis" || style.overflowX !== "clip" || line.width <= width + 0.5) return null;
2202
+ if (!line.clamped && (style?.textOverflow !== "ellipsis" || style.overflowX !== "clip" || line.width <= width + 0.5)) return null;
2052
2203
  const lastText = [...segments].reverse().find((segment) => segment.run.kind === "text");
2053
2204
  if (!lastText) return null;
2054
2205
  const text = "\u2026";
@@ -2088,7 +2239,7 @@ function truncateForEllipsis(ctx, style, paragraph, segments, line, width) {
2088
2239
  function inlineImageMargins(node) {
2089
2240
  const margin = node.style?.margin ?? ZERO_BOX;
2090
2241
  const border = borderWidths(node.style);
2091
- const padding = node.style?.padding ?? ZERO_BOX;
2242
+ const padding = paddingBox(node.style);
2092
2243
  return {
2093
2244
  left: numberOrZero(margin[3]) + border[3] + padding[3],
2094
2245
  right: numberOrZero(margin[1]) + border[1] + padding[1],
@@ -2099,7 +2250,7 @@ function inlineImageMargins(node) {
2099
2250
  function inlineImageVerticalInsets(node) {
2100
2251
  const margin = node.style?.margin ?? ZERO_BOX;
2101
2252
  const border = borderWidths(node.style);
2102
- const padding = node.style?.padding ?? ZERO_BOX;
2253
+ const padding = paddingBox(node.style);
2103
2254
  return {
2104
2255
  top: numberOrZero(margin[0]) + border[0] + padding[0],
2105
2256
  bottom: numberOrZero(margin[2]) + border[2] + padding[2],
@@ -2128,7 +2279,7 @@ function inlineImageTop(style, height, metrics) {
2128
2279
 
2129
2280
  function inlineInsets(style) {
2130
2281
  const margin = style?.margin ?? ZERO_BOX;
2131
- const padding = style?.padding ?? ZERO_BOX;
2282
+ const padding = paddingBox(style);
2132
2283
  const border = borderWidths(style);
2133
2284
  return {
2134
2285
  left: numberOrZero(margin[3]) + border[3] + padding[3],
@@ -2320,7 +2471,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2320
2471
  // Its background and borders, around the content box.
2321
2472
  if (isDecorated(run.node.style)) {
2322
2473
  const border = borderWidths(run.node.style);
2323
- const padding = run.node.style.padding ?? ZERO_BOX;
2474
+ const padding = paddingBox(run.node.style, ctx.containing?.width);
2324
2475
  ctx.boxes.push({
2325
2476
  nodeId: run.nodeId,
2326
2477
  x: imageX - padding[3] - border[3],
@@ -2396,7 +2547,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2396
2547
  for (const [node, edge] of edges) {
2397
2548
  const nodeStyle = paragraph.inlineState.get(node)?.style;
2398
2549
  if (!isDecorated(nodeStyle) || edge.first == null) continue;
2399
- const padding = nodeStyle.padding ?? ZERO_BOX;
2550
+ const padding = paddingBox(nodeStyle, ctx.containing?.width);
2400
2551
  const border = borderWidths(nodeStyle);
2401
2552
  const metrics = textMetrics(ctx, nodeStyle);
2402
2553
  const left = edge.left ?? edge.first;
@@ -2506,7 +2657,7 @@ function cachedMetrics(fontMetrics) {
2506
2657
  const byStyle = new WeakMap();
2507
2658
  const metricsFor = (style) => {
2508
2659
  const fontSize = style?.fontSize ?? 16;
2509
- const key = `${fontSize}/${style?.fontWeight ?? 400}/${style?.fontFamily?.join(",")}`;
2660
+ const key = `${fontSize}/${style?.fontWeight ?? 400}/${style?.fontStyle ?? "normal"}/${style?.fontStretch ?? 100}/${style?.fontFamily?.join(",")}`;
2510
2661
  let metrics = cache.get(key);
2511
2662
  if (!metrics) {
2512
2663
  metrics = typeof fontMetrics === "function" ? fontMetrics(style ?? null) : null;
@@ -2531,12 +2682,12 @@ function cachedMetrics(fontMetrics) {
2531
2682
  * whatever box it gets), so each dimension falls back to 300x150 alone.
2532
2683
  */
2533
2684
  function frameDimensions(node, referenceWidth, referenceHeight) {
2534
- const resolve = (cssValue, attribute, fallback, reference) => {
2535
- const value = cssValue !== "auto" && cssValue != null ? resolveLength(cssValue, reference ?? NaN) : attribute;
2685
+ const resolve = (cssValue, fallback, reference) => {
2686
+ const value = cssValue !== "auto" && cssValue != null ? resolveLength(cssValue, reference ?? NaN) : null;
2536
2687
  return Number.isFinite(value) ? value : fallback;
2537
2688
  };
2538
- let width = resolve(node.style?.width, node.widthAttribute, 300, referenceWidth);
2539
- const height = resolve(node.style?.height, node.heightAttribute, 150, referenceHeight);
2689
+ let width = resolve(node.style?.width, 300, referenceWidth);
2690
+ const height = resolve(node.style?.height, 150, referenceHeight);
2540
2691
  const maximum = node.style?.maxWidth !== "none" && node.style?.maxWidth != null ? resolveLength(node.style.maxWidth, referenceWidth) : NaN;
2541
2692
  if (Number.isFinite(maximum) && width > maximum) width = maximum;
2542
2693
  return { width: Math.max(0, width), height: Math.max(0, height) };
@@ -2568,25 +2719,29 @@ function svgDimensions(node, referenceWidth, referenceHeight) {
2568
2719
  function imageDimensions(node, referenceWidth, referenceHeight = null) {
2569
2720
  if (node.tagName === "IFRAME") return frameDimensions(node, referenceWidth, referenceHeight);
2570
2721
  if (node.tagName === "SVG") return svgDimensions(node, referenceWidth, referenceHeight);
2571
- const intrinsicWidth = node.resource?.width ?? node.widthAttribute ?? 300;
2572
- const intrinsicHeight = node.resource?.height ?? node.heightAttribute ?? 150;
2573
- const ratio = intrinsicWidth / intrinsicHeight;
2722
+ // A canvas's attributes are its intrinsic bitmap dimensions. Its cached
2723
+ // paint snapshot can still describe the previous bitmap during layout.
2724
+ const canvas = node.tagName === "CANVAS";
2725
+ const intrinsicWidth = (canvas ? node.widthAttribute : node.resource?.width) ?? node.widthAttribute ?? 300;
2726
+ const intrinsicHeight = (canvas ? node.heightAttribute : node.resource?.height) ?? node.heightAttribute ?? 150;
2727
+ const emptyCanvas = node.tagName === "CANVAS" && !(intrinsicWidth > 0 && intrinsicHeight > 0);
2728
+ const ratio = emptyCanvas ? null : intrinsicWidth / intrinsicHeight;
2574
2729
  const cssWidth = node.style?.width;
2575
2730
  const cssHeight = node.style?.height;
2576
2731
  let width = cssWidth !== "auto" && cssWidth != null
2577
2732
  ? resolveLength(cssWidth, referenceWidth)
2578
- : node.widthAttribute;
2733
+ : null;
2579
2734
  let height = cssHeight !== "auto" && cssHeight != null
2580
2735
  ? resolveLength(cssHeight, referenceHeight ?? NaN)
2581
- : node.heightAttribute;
2736
+ : null;
2582
2737
  if (!Number.isFinite(width)) width = null;
2583
2738
  if (!Number.isFinite(height)) height = null;
2584
2739
  if (width == null && height == null) [width, height] = [intrinsicWidth, intrinsicHeight];
2585
- else if (width == null) width = height * ratio;
2586
- else if (height == null) height = width / ratio;
2740
+ else if (width == null) width = ratio == null ? intrinsicWidth : height * ratio;
2741
+ else if (height == null) height = ratio == null ? intrinsicHeight : width / ratio;
2587
2742
  if (node.style?.maxWidth !== "none" && node.style?.maxWidth != null) {
2588
2743
  const maximum = resolveLength(node.style.maxWidth, referenceWidth);
2589
- if (Number.isFinite(maximum) && width > maximum) [width, height] = [maximum, maximum / ratio];
2744
+ if (Number.isFinite(maximum) && width > maximum) [width, height] = [maximum, ratio == null ? height : maximum / ratio];
2590
2745
  }
2591
2746
  if (node.tagName === "VIDEO") {
2592
2747
  if (Number.isFinite(referenceWidth) && width > referenceWidth) [width, height] = [referenceWidth, referenceWidth / ratio];
@@ -2595,7 +2750,8 @@ function imageDimensions(node, referenceWidth, referenceHeight = null) {
2595
2750
  if (Number.isFinite(minHeight) && height < minHeight) height = minHeight;
2596
2751
  if (Number.isFinite(maxHeight) && height > maxHeight) [width, height] = [maxHeight * ratio, maxHeight];
2597
2752
  }
2598
- return { width: Math.max(1, width), height: Math.max(1, height) };
2753
+ const minimum = node.tagName === "CANVAS" ? 0 : 1;
2754
+ return { width: Math.max(minimum, width), height: Math.max(minimum, height) };
2599
2755
  }
2600
2756
 
2601
2757
  function positiveInteger(value, fallback) {
@@ -2622,8 +2778,10 @@ function resolveLength(value, reference) {
2622
2778
  if (!value || typeof value !== "object") return value;
2623
2779
  if (value.unit === "%") return reference * value.value / 100;
2624
2780
  if (value.unit === "math" && value.kind === "calc") return value.px + reference * value.percent / 100;
2781
+ if (value.unit === "math" && value.kind === "scale") return value.factor * resolveLength(value.value, reference);
2625
2782
  if (value.unit === "math") {
2626
2783
  const values = value.values.map((part) => resolveLength(part, reference));
2784
+ if (value.kind === "sum") return values.reduce((sum, part) => sum + part, 0);
2627
2785
  if (value.kind === "min") return Math.min(...values);
2628
2786
  if (value.kind === "max") return Math.max(...values);
2629
2787
  return Math.max(values[0], Math.min(values[1], values[2]));