buninu-browser 0.2.1 → 0.2.3

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 (90) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/ai-debug.log +5537 -0
  4. package/bunfig.toml +1 -0
  5. package/buninu-browser.js +4 -0
  6. package/index.js +5 -0
  7. package/lib/audio/piano.js +24 -1
  8. package/lib/audio/playback-buffer.js +27 -0
  9. package/lib/audio/pulse-client.js +137 -19
  10. package/lib/cdp-server.js +22 -11
  11. package/lib/happy-dom/parser.js +29 -4
  12. package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
  13. package/lib/headless-shell.js +69 -19
  14. package/lib/input/hit-test.js +25 -7
  15. package/lib/layout/text-layout.js +268 -85
  16. package/lib/log.js +5 -2
  17. package/lib/media/byte-pipe.js +82 -0
  18. package/lib/media/position-audio.js +42 -0
  19. package/lib/media/video-keyframe.js +29 -0
  20. package/lib/network/network-service.js +161 -14
  21. package/lib/paint/display-list.js +316 -15
  22. package/lib/paint/gradient.js +11 -5
  23. package/lib/render-tree/index.js +19 -2
  24. package/lib/render-tree/svg.js +2 -1
  25. package/lib/renderer/bindings.js +995 -69
  26. package/lib/renderer/canvas-2d.js +92 -0
  27. package/lib/renderer/compositor.js +16 -1
  28. package/lib/renderer/custom-element-reactions.js +18 -0
  29. package/lib/renderer/history.js +42 -0
  30. package/lib/renderer/host.js +166 -9
  31. package/lib/renderer/image-loading.js +27 -0
  32. package/lib/renderer/interfaces.js +215 -0
  33. package/lib/renderer/keyboard-input.js +12 -0
  34. package/lib/renderer/main.js +162 -12
  35. package/lib/renderer/media-playback-events.js +13 -0
  36. package/lib/renderer/media-source.js +819 -0
  37. package/lib/renderer/messaging.js +411 -0
  38. package/lib/renderer/native-functions.js +113 -0
  39. package/lib/renderer/page-realm.js +1408 -242
  40. package/lib/renderer/page-renderer.js +1893 -223
  41. package/lib/renderer/piano-input.js +22 -0
  42. package/lib/renderer/pointer-move.js +29 -0
  43. package/lib/renderer/rejections.js +31 -0
  44. package/lib/renderer/rewrite-worker.js +22 -0
  45. package/lib/renderer/script-rewrite.js +805 -57
  46. package/lib/renderer/trusted-types.js +129 -0
  47. package/lib/renderer/video-presentation.js +126 -0
  48. package/lib/renderer/workers.js +327 -0
  49. package/lib/screenshot-cli.js +2 -0
  50. package/lib/style/computed-style.js +186 -29
  51. package/lib/user-agent.js +14 -2
  52. package/package.json +4 -1
  53. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  54. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  55. package/patches/happy-dom/README.md +26 -0
  56. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  57. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  58. package/patches/ses/README.md +4 -0
  59. package/test/canvas-2d.test.js +55 -0
  60. package/test/compositor.test.js +41 -1
  61. package/test/computed-style.test.js +59 -1
  62. package/test/display-list.test.js +88 -1
  63. package/test/download.test.js +43 -0
  64. package/test/fixtures/long-video.mp4 +0 -0
  65. package/test/fixtures/media-sync.js +57 -0
  66. package/test/happy-dom-platform-adapters.test.js +25 -0
  67. package/test/headless-shell.test.js +23 -1
  68. package/test/hit-test.test.js +25 -1
  69. package/test/media-playback-events.test.js +22 -0
  70. package/test/media-source.test.js +156 -0
  71. package/test/media-sync.test.js +12 -0
  72. package/test/mobile.test.js +2 -2
  73. package/test/navigation.test.js +61 -0
  74. package/test/network-service.test.js +14 -0
  75. package/test/page-scripts.test.js +1203 -7
  76. package/test/piano-input.test.js +44 -0
  77. package/test/playback-buffer.test.js +31 -0
  78. package/test/position-audio.test.js +67 -0
  79. package/test/positioning.test.js +95 -1
  80. package/test/pulse-client.test.js +84 -0
  81. package/test/renderer-host.test.js +5 -2
  82. package/test/screenshot-cli.test.js +25 -1
  83. package/test/script-rewrite.test.js +111 -6
  84. package/test/setup.js +5 -0
  85. package/test/text-layout.test.js +62 -0
  86. package/test/user-agent.test.js +60 -4
  87. package/test/video-presentation.test.js +67 -0
  88. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  89. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  90. package/vendor/ses/dist/ses.mjs +52 -6
@@ -18,10 +18,19 @@
18
18
  * and `fragments` as positioned line boxes sorted by y.
19
19
  */
20
20
  const ZERO_BOX = Object.freeze([0, 0, 0, 0]);
21
+ const ZERO_OFFSET = Object.freeze({ x: 0, y: 0 });
21
22
  const ZERO_STRUT = Object.freeze({ positive: 0, negative: 0 });
22
23
  const DEFAULT_ASCENT = 0.93;
23
24
  const DEFAULT_CONTENT_HEIGHT = 1.17;
24
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
+
25
34
  const ROOT_LAYER = Object.freeze({ z: 0, order: 0, context: true, parent: null });
26
35
 
27
36
  export function layoutText(tree, options = {}) {
@@ -51,6 +60,9 @@ export function layoutText(tree, options = {}) {
51
60
  stackingContext: ROOT_LAYER,
52
61
  layerCount: 1,
53
62
  initialContainingBlock: null,
63
+ // The content box of the block being laid out: the containing block of
64
+ // its in-flow children, for percentages in their relative offsets.
65
+ containing: null,
54
66
  // Static positions recorded for out-of-flow boxes, in order (see
55
67
  // cacheBlockResult), and those boxes by node for replays.
56
68
  staticPositions: [],
@@ -79,6 +91,7 @@ export function layoutText(tree, options = {}) {
79
91
  // establishes the BFC, so body margins collapse with its children's.
80
92
  const horizontal = resolveHorizontal(ctx, root, x, containingWidth, false);
81
93
  const rootY = y + strutValue(topStrut(root));
94
+ ctx.containing = { width: containingWidth, height: ctx.initialContainingBlock.height || null };
82
95
  const result = layoutBlock(ctx, root, horizontal.x, horizontal.width, rootY, collapsesThroughTop(root));
83
96
  const height = rootY + result.height + strutValue(bottomStrut(root));
84
97
 
@@ -317,9 +330,12 @@ function hasInlineContent(items) {
317
330
  * then applies relative offsets and translations to everything it produced.
318
331
  */
319
332
  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;
322
333
  const style = box.style;
334
+ // Its containing block, for percentages in relative offsets.
335
+ const containing = ctx.containing;
336
+ const key = blockCacheKey(width, absorbTop, forcedHeight, containing);
337
+ const cached = cachedBlockResult(ctx, box, key, y, x);
338
+ if (cached) return cached;
323
339
  const boxStart = ctx.boxes.length;
324
340
  const fragmentStart = ctx.fragments.length;
325
341
  const layerStart = ctx.layerCount;
@@ -343,10 +359,10 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
343
359
  if (box.absolutes?.length) layoutAbsolutes(ctx, box, record);
344
360
  let dx = 0;
345
361
  let dy = 0;
346
- if (style?.position === "relative") {
347
- const [top, right, bottom, left] = style.inset.map((value) => numberOrNull(value));
348
- dx = left ?? (right != null ? -right : 0);
349
- dy = top ?? (bottom != null ? -bottom : 0);
362
+ const offset = relativeOffset(style, containing ?? { width, height: null });
363
+ if (offset) {
364
+ dx = offset.x;
365
+ dy = offset.y;
350
366
  }
351
367
  if (style?.transform) {
352
368
  dx += numberOrZero(resolveLength(style.transform.x, record.width));
@@ -357,14 +373,29 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
357
373
  if (dx || dy) shiftOutput(ctx, boxStart, fragmentStart, dx, dy);
358
374
  ctx.layer = previousLayer;
359
375
  ctx.stackingContext = previousContext;
360
- cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result, {
376
+ cacheBlockResult(ctx, box, key, x, y, result, {
361
377
  boxStart, fragmentStart, layerStart, staticStart, layer: previousLayer, context: previousContext,
362
378
  });
363
379
  return result;
364
380
  }
365
381
 
366
- function blockCacheKey(width, absorbTop, forcedHeight) {
367
- return `${width}\n${absorbTop ? 1 : 0}\n${forcedHeight ?? "auto"}`;
382
+ function blockCacheKey(width, absorbTop, forcedHeight, containing) {
383
+ const key = `${width}\n${absorbTop ? 1 : 0}\n${forcedHeight ?? "auto"}`;
384
+ return containing ? `${key}\n${containing.width}\n${containing.height ?? "auto"}` : key;
385
+ }
386
+
387
+ /**
388
+ * The offset of a relatively positioned box (CSS 2 §9.4.3). Percentages are
389
+ * of its containing block; one of an indefinite height is auto.
390
+ */
391
+ function relativeOffset(style, containing) {
392
+ if (style?.position !== "relative" || !style.inset) return null;
393
+ const [top, right, bottom, left] = style.inset.map((value, index) => value === "auto"
394
+ ? null
395
+ : numberOrNull(resolveLength(value, (index % 2 ? containing?.width : containing?.height) ?? NaN)));
396
+ const x = left ?? (right != null ? -right : 0);
397
+ const y = top ?? (bottom != null ? -bottom : 0);
398
+ return x || y ? { x, y } : null;
368
399
  }
369
400
 
370
401
  /** Where an out-of-flow box would have been in flow; recorded so that a replayed block sets it again. */
@@ -374,9 +405,9 @@ function setStaticPosition(ctx, box, position) {
374
405
  }
375
406
 
376
407
  /** Replays a retained formatting-context result at its new flow position. */
377
- function cachedBlockResult(ctx, box, width, y, x, absorbTop, forcedHeight) {
408
+ function cachedBlockResult(ctx, box, key, y, x) {
378
409
  if (!ctx.blockResults || box.anonymous) return null;
379
- const cached = ctx.blockResults.get(box.node)?.get(blockCacheKey(width, absorbTop, forcedHeight));
410
+ const cached = ctx.blockResults.get(box.node)?.get(key);
380
411
  if (!cached) return null;
381
412
  const dx = x - cached.x;
382
413
  const dy = y - cached.y;
@@ -423,7 +454,7 @@ function cachedBlockResult(ctx, box, width, y, x, absorbTop, forcedHeight) {
423
454
  * static positions it recorded go along. Output in a layer made before the
424
455
  * block (which should not occur) is not retained.
425
456
  */
426
- function cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result, start) {
457
+ function cacheBlockResult(ctx, box, key, x, y, result, start) {
427
458
  if (!ctx.blockResults || box.anonymous) return;
428
459
  const boxes = ctx.boxes.slice(start.boxStart);
429
460
  const fragments = ctx.fragments.slice(start.fragmentStart);
@@ -451,10 +482,10 @@ function cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result
451
482
  if (!entries) ctx.blockResults.set(box.node, entries = new Map());
452
483
  // A node normally sees one containing width. Bound unusual cases such as a
453
484
  // reused table cell so the retained cache cannot grow without limit.
454
- if (!entries.has(blockCacheKey(width, absorbTop, forcedHeight)) && entries.size >= 4) {
485
+ if (!entries.has(key) && entries.size >= 4) {
455
486
  entries.delete(entries.keys().next().value);
456
487
  }
457
- entries.set(blockCacheKey(width, absorbTop, forcedHeight), {
488
+ entries.set(key, {
458
489
  x,
459
490
  y,
460
491
  result: { ...result },
@@ -488,31 +519,37 @@ function layoutAbsolutes(ctx, box, record) {
488
519
  for (const absolute of box.absolutes) {
489
520
  const style = absolute.style;
490
521
  const containing = style?.position === "fixed" ? { ...icb, height: icb.height || block.height } : block;
491
- const [top, right, bottom, left] = (style?.inset ?? ["auto", "auto", "auto", "auto"])
492
- .map((value, index) => value === "auto" ? null : resolveLength(value, index % 2 ? containing.width : containing.height));
493
- const margin = style?.margin ?? ZERO_BOX;
494
- const [marginTop, marginRight, marginBottom, marginLeft] = margin.map(numberOrZero);
495
- const specifiedWidth = numberOrNull(resolveLength(style?.width, containing.width));
496
- let width;
497
- if (specifiedWidth == null && left != null && right != null) {
498
- width = Math.max(0, containing.width - left - right - marginLeft - marginRight);
499
- } else {
500
- width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
522
+ const previousContaining = ctx.containing;
523
+ ctx.containing = containing;
524
+ try {
525
+ const [top, right, bottom, left] = (style?.inset ?? ["auto", "auto", "auto", "auto"])
526
+ .map((value, index) => value === "auto" ? null : resolveLength(value, index % 2 ? containing.width : containing.height));
527
+ const margin = style?.margin ?? ZERO_BOX;
528
+ const [marginTop, marginRight, marginBottom, marginLeft] = margin.map(numberOrZero);
529
+ const specifiedWidth = numberOrNull(resolveLength(style?.width, containing.width));
530
+ let width;
531
+ if (specifiedWidth == null && left != null && right != null) {
532
+ width = Math.max(0, containing.width - left - right - marginLeft - marginRight);
533
+ } else {
534
+ width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
535
+ }
536
+ const staticPosition = absolute.staticPosition ?? containing;
537
+ const x = left != null ? containing.x + left + marginLeft
538
+ : right != null ? containing.x + containing.width - right - marginRight - width
539
+ : staticPosition.x + marginLeft;
540
+ const specifiedHeight = numberOrNull(resolveLength(style?.height, containing.height));
541
+ const forcedHeight = specifiedHeight == null && top != null && bottom != null
542
+ ? Math.max(0, containing.height - top - bottom - marginTop - marginBottom)
543
+ : null;
544
+ let height = forcedHeight;
545
+ if (height == null && top == null && bottom != null) height = dryLayout(ctx, absolute, width).height;
546
+ const y = top != null ? containing.y + top + marginTop
547
+ : bottom != null ? containing.y + containing.height - bottom - marginBottom - height
548
+ : staticPosition.y + marginTop;
549
+ layoutBlock(ctx, absolute, x, width, y, false, forcedHeight);
550
+ } finally {
551
+ ctx.containing = previousContaining;
501
552
  }
502
- const staticPosition = absolute.staticPosition ?? containing;
503
- const x = left != null ? containing.x + left + marginLeft
504
- : right != null ? containing.x + containing.width - right - marginRight - width
505
- : staticPosition.x + marginLeft;
506
- const specifiedHeight = numberOrNull(resolveLength(style?.height, containing.height));
507
- const forcedHeight = specifiedHeight == null && top != null && bottom != null
508
- ? Math.max(0, containing.height - top - bottom - marginTop - marginBottom)
509
- : null;
510
- let height = forcedHeight;
511
- if (height == null && top == null && bottom != null) height = dryLayout(ctx, absolute, width).height;
512
- const y = top != null ? containing.y + top + marginTop
513
- : bottom != null ? containing.y + containing.height - bottom - marginBottom - height
514
- : staticPosition.y + marginTop;
515
- layoutBlock(ctx, absolute, x, width, y, false, forcedHeight);
516
553
  }
517
554
  }
518
555
 
@@ -570,13 +607,13 @@ function shiftedFragment(fragment, dx, dy) {
570
607
  }
571
608
 
572
609
  function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeight = null) {
573
- const style = box.style;
610
+ const padding = paddingBox(box.style, ctx.containing?.width ?? width);
611
+ const style = box.style && padding !== box.style.padding ? { ...box.style, padding } : box.style;
574
612
  const border = borderWidths(style);
575
- const padding = style?.padding ?? ZERO_BOX;
576
613
  const record = { nodeId: box.node.id, x, y, width, height: 0, style };
577
614
  ctx.boxes.push(record);
578
615
  if (box.replaced) {
579
- const dimensions = imageDimensions(box.node, width);
616
+ const dimensions = imageDimensions(box.node, width, ctx.containing?.height);
580
617
  const imageX = x + border[3] + padding[3];
581
618
  const imageY = y + border[0] + padding[0];
582
619
  ctx.fragments.push(lineFragment(box.style, "", [box.node.id], [{
@@ -608,6 +645,21 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
608
645
  let columnItems = null;
609
646
  let gridResult = null;
610
647
  let baseline = null;
648
+ const toContentHeight = (value) => style.boxSizing === "border-box"
649
+ ? Math.max(0, value - border[0] - border[2] - padding[0] - padding[2])
650
+ : value;
651
+ // A percentage height (and min/max) resolves against the containing block
652
+ // only when that height is definite (CSS 2 §10.5); otherwise it is auto.
653
+ const containingHeight = ctx.containing?.height;
654
+ const resolveHeight = (value) => numberOrNull(resolveLength(value, containingHeight ?? NaN));
655
+ const specifiedHeight = resolveHeight(style?.height);
656
+ const previousContaining = ctx.containing;
657
+ ctx.containing = {
658
+ width: contentWidth,
659
+ height: forcedHeight != null
660
+ ? Math.max(0, forcedHeight - border[0] - border[2] - padding[0] - padding[2])
661
+ : specifiedHeight != null ? toContentHeight(specifiedHeight) : null,
662
+ };
611
663
 
612
664
  if (box.table) {
613
665
  const result = layoutTable(ctx, box, contentX, contentWidth, contentTop);
@@ -639,7 +691,9 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
639
691
  : child.style.alignSelf;
640
692
  const shrink = !["normal", "stretch"].includes(alignment);
641
693
  const placed = resolveHorizontal(ctx, child, contentX, contentWidth, shrink);
642
- const autoMargins = (child.style?.margin ?? ZERO_BOX).some((value) => value === "auto");
694
+ // Auto margins on the cross axis (left, right) align the item instead (Flexbox §8.1).
695
+ const crossMargins = child.style?.margin ?? ZERO_BOX;
696
+ const autoMargins = crossMargins[1] === "auto" || crossMargins[3] === "auto";
643
697
  let childX = placed.x;
644
698
  if (shrink && !autoMargins) {
645
699
  const free = contentWidth - placed.width - placed.marginLeft - placed.marginRight;
@@ -678,23 +732,24 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
678
732
  // Tables with width: auto are sized to their contents (CSS 2 §17.5.2).
679
733
  const placed = resolveHorizontal(ctx, child, contentX, contentWidth, Boolean(child.table));
680
734
  const result = layoutBlock(ctx, child, placed.x, placed.width, childY, collapsesThroughTop(child));
681
- cursor = childY + result.height;
682
- pending = bottomStrut(child);
735
+ if (result.height === 0 && selfCollapsing(child)) {
736
+ // Empty blocks do not separate adjoining margins (CSS 2 §8.3.1).
737
+ pending = combineStruts(combined, bottomStrut(child));
738
+ } else {
739
+ cursor = childY + result.height;
740
+ pending = bottomStrut(child);
741
+ }
683
742
  baseline = result.baseline ?? baseline;
684
743
  });
685
744
  contentBottom = collapsesThroughBottom(box) ? cursor : cursor + strutValue(pending ?? ZERO_STRUT);
686
745
  }
687
746
 
747
+ ctx.containing = previousContaining;
688
748
  let contentHeight = Math.max(0, contentBottom - contentTop);
689
- const toContentHeight = (value) => style.boxSizing === "border-box"
690
- ? Math.max(0, value - border[0] - border[2] - padding[0] - padding[2])
691
- : value;
692
- const specifiedHeight = numberOrNull(style?.height);
693
749
  if (specifiedHeight != null) contentHeight = toContentHeight(specifiedHeight);
694
- // min-height and max-height (percentages need a definite containing height; not modelled).
695
- const maxHeight = numberOrNull(style?.maxHeight);
750
+ const maxHeight = style?.maxHeight === "none" ? null : resolveHeight(style?.maxHeight);
696
751
  if (maxHeight != null) contentHeight = Math.min(contentHeight, toContentHeight(maxHeight));
697
- const minHeight = numberOrNull(style?.minHeight);
752
+ const minHeight = resolveHeight(style?.minHeight);
698
753
  if (minHeight != null) contentHeight = Math.max(contentHeight, toContentHeight(minHeight));
699
754
  if (columnItems?.length) justifyColumn(ctx, columnItems, style?.justifyContent, contentHeight - (contentBottom - contentTop));
700
755
  if (gridResult) alignGridContent(ctx, gridResult, style?.alignContent, contentHeight - (contentBottom - contentTop));
@@ -817,13 +872,9 @@ function layoutFlexRow(ctx, box, x, width, y) {
817
872
  }
818
873
  let crossSize = Math.max(0, ...lineItems.map((item) => item.height + item.marginTop + item.marginBottom));
819
874
  // A single-line container with a definite height gives its line that cross size (§9.4.15).
820
- const definiteHeight = numberOrNull(style?.height);
821
- if (lines.length === 1 && definiteHeight != null) {
822
- const padding = style.padding ?? ZERO_BOX;
823
- crossSize = style.boxSizing === "border-box"
824
- ? Math.max(0, definiteHeight - padding[0] - padding[2] - borderWidths(style)[0] - borderWidths(style)[2])
825
- : definiteHeight;
826
- }
875
+ // ctx.containing.height is that content-box height, percentages already resolved.
876
+ const definiteHeight = ctx.containing?.height;
877
+ if (lines.length === 1 && definiteHeight != null) crossSize = definiteHeight;
827
878
 
828
879
  // Main axis: auto margins absorb free space first, then justify-content.
829
880
  let free = width - gaps - lineItems.reduce((sum, item) => sum + item.target + item.marginLeft + item.marginRight, 0);
@@ -880,7 +931,7 @@ function flexItem(ctx, box, containerWidth) {
880
931
  // Sizes below are content-box sizes, as in Blink's FlexItem
881
932
  // (base_content_size, hypothetical_content_size); `extra` is padding+border.
882
933
  const style = box.style;
883
- const padding = style?.padding ?? ZERO_BOX;
934
+ const padding = paddingBox(style, containerWidth);
884
935
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
885
936
  const contentBox = (value) => Math.max(0, style?.boxSizing === "border-box" ? value - extra : value);
886
937
  const margin = style?.margin ?? ZERO_BOX;
@@ -1471,7 +1522,7 @@ function minimumContribution(box, minContent, available) {
1471
1522
  const margins = numberOrZero(margin[1]) + numberOrZero(margin[3]);
1472
1523
  const specified = numberOrNull(resolveLength(style?.minWidth, available));
1473
1524
  if (specified != null) {
1474
- const padding = style?.padding ?? ZERO_BOX;
1525
+ const padding = paddingBox(style, available);
1475
1526
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
1476
1527
  return (style.boxSizing === "border-box" ? Math.max(specified, extra) : specified + extra) + margins;
1477
1528
  }
@@ -1661,7 +1712,7 @@ function dryLayout(ctx, box, width) {
1661
1712
  /** Resolves the used border-box width and horizontal margins of a block-level box. */
1662
1713
  function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, percentageBasis = containingWidth) {
1663
1714
  const style = box.style;
1664
- const padding = style?.padding ?? ZERO_BOX;
1715
+ const padding = paddingBox(style, percentageBasis);
1665
1716
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
1666
1717
  const borderBox = (value) => style?.boxSizing === "border-box" ? value : value + extra;
1667
1718
  const margin = style?.margin ?? ZERO_BOX;
@@ -1697,7 +1748,7 @@ function maxContentWidth(ctx, box) {
1697
1748
  const cached = ctx.maxContent.get(cacheKey);
1698
1749
  if (cached != null) return cached;
1699
1750
  const style = box.style;
1700
- const padding = style?.padding ?? ZERO_BOX;
1751
+ const padding = paddingBox(style);
1701
1752
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
1702
1753
  let width;
1703
1754
  const specified = numberOrNull(style?.width);
@@ -1730,6 +1781,7 @@ function maxContentWidth(ctx, box) {
1730
1781
  }
1731
1782
  const maxWidth = numberOrNull(style?.maxWidth);
1732
1783
  if (maxWidth != null) width = Math.min(width, style.boxSizing === "border-box" ? maxWidth : maxWidth + extra);
1784
+ width = atLeastMinWidth(style, width, extra);
1733
1785
  ctx.maxContent.set(cacheKey, width);
1734
1786
  return width;
1735
1787
  }
@@ -1739,7 +1791,7 @@ function minContentWidth(ctx, box) {
1739
1791
  const cached = ctx.minContent.get(cacheKey);
1740
1792
  if (cached != null) return cached;
1741
1793
  const style = box.style;
1742
- const padding = style?.padding ?? ZERO_BOX;
1794
+ const padding = paddingBox(style);
1743
1795
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
1744
1796
  const specified = numberOrNull(style?.width);
1745
1797
  const outer = (child) => {
@@ -1763,10 +1815,17 @@ function minContentWidth(ctx, box) {
1763
1815
  ? inlineMinContentWidth(ctx, child)
1764
1816
  : outer(child))) + extra;
1765
1817
  }
1818
+ width = atLeastMinWidth(style, width, extra);
1766
1819
  ctx.minContent.set(cacheKey, width);
1767
1820
  return width;
1768
1821
  }
1769
1822
 
1823
+ /** min-width wins over the intrinsic width (and max-width); a percentage is of an unknown width here. */
1824
+ function atLeastMinWidth(style, width, extra) {
1825
+ const minWidth = numberOrNull(style?.minWidth);
1826
+ return minWidth == null ? width : Math.max(width, style.boxSizing === "border-box" ? minWidth : minWidth + extra);
1827
+ }
1828
+
1770
1829
  function verticalMargins(style) {
1771
1830
  const margin = style?.margin ?? ZERO_BOX;
1772
1831
  return { top: numberOrZero(margin[0]), bottom: numberOrZero(margin[2]) };
@@ -1781,6 +1840,15 @@ function collapsesThroughTop(box) {
1781
1840
  && inFlowChildren(box)[0]?.kind === "block";
1782
1841
  }
1783
1842
 
1843
+ function selfCollapsing(box) {
1844
+ return !box.bfc && box.kind === "block" && !box.replaced
1845
+ && borderWidths(box.style).every(value => value === 0)
1846
+ && (box.style?.padding ?? ZERO_BOX).every(value => value === 0)
1847
+ && (box.style?.height === "auto" || box.style?.height === 0)
1848
+ && (numberOrNull(box.style?.minHeight) ?? 0) <= 0
1849
+ && inFlowChildren(box).every(selfCollapsing);
1850
+ }
1851
+
1784
1852
  function collapsesThroughBottom(box) {
1785
1853
  return !box.bfc && box.kind === "block" && !box.replaced
1786
1854
  && borderWidths(box.style)[2] === 0 && (box.style?.padding ?? ZERO_BOX)[2] === 0
@@ -1831,7 +1899,17 @@ function layoutInline(ctx, ifc, x, width, y) {
1831
1899
  }
1832
1900
  return atom;
1833
1901
  };
1902
+ // Percentage sizes of inline images.
1903
+ atomFor.containing = ctx.containing;
1834
1904
  const lines = breakLines(ctx, paragraph, width, atomFor);
1905
+ // Legacy line clamping applies to a vertical -webkit-box, not ordinary blocks.
1906
+ const clamp = ifc.style?.webkitBoxOrient === "vertical"
1907
+ && ["-webkit-box", "-webkit-inline-box"].includes(ifc.style?.display)
1908
+ ? ifc.style.webkitLineClamp : null;
1909
+ if (clamp && lines.length > clamp) {
1910
+ lines.length = clamp;
1911
+ lines[clamp - 1] = { ...lines[clamp - 1], clamped: true };
1912
+ }
1835
1913
  let cursor = y;
1836
1914
  let baseline = null;
1837
1915
  for (const line of lines) {
@@ -2006,7 +2084,7 @@ function mayBreakWithinWords(paragraph, piece) {
2006
2084
  * room for "…". Mutates `segments`; returns the ellipsis to append, or null.
2007
2085
  */
2008
2086
  function truncateForEllipsis(ctx, style, paragraph, segments, line, width) {
2009
- if (style?.textOverflow !== "ellipsis" || style.overflowX !== "clip" || line.width <= width + 0.5) return null;
2087
+ if (!line.clamped && (style?.textOverflow !== "ellipsis" || style.overflowX !== "clip" || line.width <= width + 0.5)) return null;
2010
2088
  const lastText = [...segments].reverse().find((segment) => segment.run.kind === "text");
2011
2089
  if (!lastText) return null;
2012
2090
  const text = "\u2026";
@@ -2042,9 +2120,26 @@ function truncateForEllipsis(ctx, style, paragraph, segments, line, width) {
2042
2120
  return { text, width: ellipsisWidth, run: lastText.run, metrics: textMetrics(ctx, lastText.run.style) };
2043
2121
  }
2044
2122
 
2123
+ /** Space beside an inline replaced element's content: its margins, borders and padding. */
2045
2124
  function inlineImageMargins(node) {
2046
2125
  const margin = node.style?.margin ?? ZERO_BOX;
2047
- return { left: numberOrZero(margin[3]), right: numberOrZero(margin[1]) };
2126
+ const border = borderWidths(node.style);
2127
+ const padding = paddingBox(node.style);
2128
+ return {
2129
+ left: numberOrZero(margin[3]) + border[3] + padding[3],
2130
+ right: numberOrZero(margin[1]) + border[1] + padding[1],
2131
+ };
2132
+ }
2133
+
2134
+ /** The same above and below its content (its margin box sits on the line). */
2135
+ function inlineImageVerticalInsets(node) {
2136
+ const margin = node.style?.margin ?? ZERO_BOX;
2137
+ const border = borderWidths(node.style);
2138
+ const padding = paddingBox(node.style);
2139
+ return {
2140
+ top: numberOrZero(margin[0]) + border[0] + padding[0],
2141
+ bottom: numberOrZero(margin[2]) + border[2] + padding[2],
2142
+ };
2048
2143
  }
2049
2144
 
2050
2145
  /**
@@ -2069,7 +2164,7 @@ function inlineImageTop(style, height, metrics) {
2069
2164
 
2070
2165
  function inlineInsets(style) {
2071
2166
  const margin = style?.margin ?? ZERO_BOX;
2072
- const padding = style?.padding ?? ZERO_BOX;
2167
+ const padding = paddingBox(style);
2073
2168
  const border = borderWidths(style);
2074
2169
  return {
2075
2170
  left: numberOrZero(margin[3]) + border[3] + padding[3],
@@ -2092,7 +2187,8 @@ function runSliceWidth(ctx, run, start, end, text, atomFor) {
2092
2187
  if (end <= start) return 0;
2093
2188
  if (run.kind === "image") {
2094
2189
  const margins = inlineImageMargins(run.node);
2095
- return imageDimensions(run.node, Infinity).width + margins.left + margins.right;
2190
+ const containing = atomFor.containing;
2191
+ return imageDimensions(run.node, containing?.width ?? Infinity, containing?.height).width + margins.left + margins.right;
2096
2192
  }
2097
2193
  if (run.kind === "atomic") {
2098
2194
  const atom = atomFor(run);
@@ -2172,13 +2268,20 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2172
2268
  const strut = textMetrics(ctx, style);
2173
2269
  let ascent = strut.ascent;
2174
2270
  let descent = strut.descent;
2271
+ // Floats (approximated as atomic inlines) sit at the top of the line instead of on its baseline.
2272
+ let floatHeight = 0;
2175
2273
  for (const segment of segments) {
2176
2274
  const { run } = segment;
2177
- if (run.kind === "image") {
2178
- segment.dimensions = imageDimensions(run.node, width);
2179
- segment.imageTop = inlineImageTop(run.node.style, segment.dimensions.height, strut);
2275
+ if (run.kind === "atomic" && isFloat(run.style)) {
2276
+ const atom = atomFor(run);
2277
+ floatHeight = Math.max(floatHeight, atom.marginTop + atom.height + atom.marginBottom);
2278
+ } else if (run.kind === "image") {
2279
+ segment.dimensions = imageDimensions(run.node, width, ctx.containing?.height);
2280
+ segment.insets = inlineImageVerticalInsets(run.node);
2281
+ const outerHeight = segment.dimensions.height + segment.insets.top + segment.insets.bottom;
2282
+ segment.imageTop = inlineImageTop(run.node.style, outerHeight, strut);
2180
2283
  ascent = Math.max(ascent, -segment.imageTop);
2181
- descent = Math.max(descent, segment.imageTop + segment.dimensions.height);
2284
+ descent = Math.max(descent, segment.imageTop + outerHeight);
2182
2285
  } else if (run.kind === "atomic") {
2183
2286
  const atom = atomFor(run);
2184
2287
  const atomAscent = atom.marginTop + (atom.baseline ?? atom.height) ;
@@ -2192,7 +2295,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2192
2295
  }
2193
2296
  }
2194
2297
  const baselineY = y + ascent;
2195
- const lineHeight = ascent + descent;
2298
+ const lineHeight = Math.max(ascent + descent, floatHeight);
2196
2299
 
2197
2300
  // Horizontal alignment.
2198
2301
  const alignment = style?.textAlign;
@@ -2205,6 +2308,22 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2205
2308
  const edges = new Map();
2206
2309
  const runs = [];
2207
2310
  const atomsToPlace = [];
2311
+ // Relatively positioned inline boxes move their contents (CSS 2 §9.4.3);
2312
+ // an offset adds to those of the inline boxes around it.
2313
+ const containing = ctx.containing;
2314
+ const offsets = new Map();
2315
+ const offsetOf = (node, style, outer) => {
2316
+ if (!offsets.has(node)) {
2317
+ const own = relativeOffset(style, containing);
2318
+ offsets.set(node, own ? { x: outer.x + own.x, y: outer.y + own.y } : outer);
2319
+ }
2320
+ return offsets.get(node);
2321
+ };
2322
+ const runOffset = (run) => {
2323
+ let offset = ZERO_OFFSET;
2324
+ for (const node of run.inlines) offset = offsetOf(node, paragraph.inlineState.get(node).style, offset);
2325
+ return run.kind === "image" ? offsetOf(run.node, run.node.style, offset) : offset;
2326
+ };
2208
2327
  // Right floats (approximated as atomic inlines) go to the end of the line.
2209
2328
  const rightFloats = segments.filter((segment) => segment.run.kind === "atomic" && segment.run.style?.float === "right");
2210
2329
  const rightFloatWidth = rightFloats.reduce((sum, segment) => sum + segment.width, 0);
@@ -2223,6 +2342,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2223
2342
  }
2224
2343
  }
2225
2344
  const segmentWidth = segment.width;
2345
+ const shift = runOffset(run);
2226
2346
  const common = {
2227
2347
  nodeId: run.nodeId,
2228
2348
  text: run.kind === "text" ? paragraph.text.slice(segment.start, segment.end) : "",
@@ -2231,11 +2351,27 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2231
2351
  style: run.style ?? style,
2232
2352
  };
2233
2353
  if (run.kind === "image") {
2354
+ const imageX = cursor + inlineImageMargins(run.node).left + shift.x;
2355
+ const imageY = baselineY + segment.imageTop + segment.insets.top + shift.y;
2356
+ // Its background and borders, around the content box.
2357
+ if (isDecorated(run.node.style)) {
2358
+ const border = borderWidths(run.node.style);
2359
+ const padding = paddingBox(run.node.style, ctx.containing?.width);
2360
+ ctx.boxes.push({
2361
+ nodeId: run.nodeId,
2362
+ x: imageX - padding[3] - border[3],
2363
+ y: imageY - padding[0] - border[0],
2364
+ width: segment.dimensions.width + padding[1] + padding[3] + border[1] + border[3],
2365
+ height: segment.dimensions.height + padding[0] + padding[2] + border[0] + border[2],
2366
+ style: run.node.style,
2367
+ inline: true,
2368
+ });
2369
+ }
2234
2370
  runs.push({
2235
2371
  ...common,
2236
2372
  type: "image",
2237
- x: cursor + inlineImageMargins(run.node).left,
2238
- y: baselineY + segment.imageTop,
2373
+ x: imageX,
2374
+ y: imageY,
2239
2375
  width: segment.dimensions.width,
2240
2376
  height: segment.dimensions.height,
2241
2377
  resource: run.node.resource,
@@ -2245,14 +2381,14 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2245
2381
  });
2246
2382
  } else if (run.kind === "atomic") {
2247
2383
  const atom = atomFor(run);
2248
- const atomY = baselineY - segment.atomAscent + atom.marginTop;
2249
- runs.push({ ...common, type: "atomic", x: cursor, y: atomY, width: segmentWidth, height: atom.height });
2250
- atomsToPlace.push({ box: run.box, x: cursor + atom.marginLeft, y: atomY, width: atom.width });
2384
+ const atomY = isFloat(run.style) ? y + atom.marginTop : baselineY - segment.atomAscent + atom.marginTop;
2385
+ runs.push({ ...common, type: "atomic", x: cursor + shift.x, y: atomY + shift.y, width: segmentWidth, height: atom.height });
2386
+ atomsToPlace.push({ box: run.box, x: cursor + atom.marginLeft + shift.x, y: atomY + shift.y, width: atom.width });
2251
2387
  } else {
2252
2388
  runs.push({
2253
2389
  ...common,
2254
- x: cursor,
2255
- y: baselineY - segment.metrics.contentAscent,
2390
+ x: cursor + shift.x,
2391
+ y: baselineY - segment.metrics.contentAscent + shift.y,
2256
2392
  width: Math.max(1, segmentWidth),
2257
2393
  height: null,
2258
2394
  });
@@ -2296,15 +2432,16 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2296
2432
  for (const [node, edge] of edges) {
2297
2433
  const nodeStyle = paragraph.inlineState.get(node)?.style;
2298
2434
  if (!isDecorated(nodeStyle) || edge.first == null) continue;
2299
- const padding = nodeStyle.padding ?? ZERO_BOX;
2435
+ const padding = paddingBox(nodeStyle, ctx.containing?.width);
2300
2436
  const border = borderWidths(nodeStyle);
2301
2437
  const metrics = textMetrics(ctx, nodeStyle);
2302
2438
  const left = edge.left ?? edge.first;
2303
2439
  const right = edge.right ?? edge.last;
2440
+ const shift = offsets.get(node) ?? ZERO_OFFSET;
2304
2441
  ctx.boxes.push({
2305
2442
  nodeId: node.id,
2306
- x: left,
2307
- y: baselineY - metrics.contentAscent - padding[0] - border[0],
2443
+ x: left + shift.x,
2444
+ y: baselineY - metrics.contentAscent - padding[0] - border[0] + shift.y,
2308
2445
  width: right - left,
2309
2446
  height: metrics.contentHeight + padding[0] + padding[2] + border[0] + border[2],
2310
2447
  style: nodeStyle,
@@ -2327,6 +2464,10 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2327
2464
  return { height: lineHeight, baseline: baselineY };
2328
2465
  }
2329
2466
 
2467
+ function isFloat(style) {
2468
+ return style?.float === "left" || style?.float === "right";
2469
+ }
2470
+
2330
2471
  function measureAtomic(ctx, box, availableWidth) {
2331
2472
  const placed = resolveHorizontal(ctx, box, 0, availableWidth, true);
2332
2473
  const result = dryLayout(ctx, box, placed.width);
@@ -2353,7 +2494,7 @@ function textMetrics(ctx, style) {
2353
2494
 
2354
2495
  function isDecorated(style) {
2355
2496
  return Boolean(style && ((style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)")
2356
- || borderWidths(style).some((width) => width > 0)));
2497
+ || style.backgroundImage || borderWidths(style).some((width) => width > 0)));
2357
2498
  }
2358
2499
 
2359
2500
  function borderWidths(style) {
@@ -2420,7 +2561,49 @@ function cachedMetrics(fontMetrics) {
2420
2561
  };
2421
2562
  }
2422
2563
 
2423
- function imageDimensions(node, referenceWidth) {
2564
+ /**
2565
+ * An iframe's content box: like other replaced elements, but with no
2566
+ * intrinsic size or ratio of its own (its document's frame is drawn into
2567
+ * whatever box it gets), so each dimension falls back to 300x150 alone.
2568
+ */
2569
+ function frameDimensions(node, referenceWidth, referenceHeight) {
2570
+ const resolve = (cssValue, attribute, fallback, reference) => {
2571
+ const value = cssValue !== "auto" && cssValue != null ? resolveLength(cssValue, reference ?? NaN) : attribute;
2572
+ return Number.isFinite(value) ? value : fallback;
2573
+ };
2574
+ let width = resolve(node.style?.width, node.widthAttribute, 300, referenceWidth);
2575
+ const height = resolve(node.style?.height, node.heightAttribute, 150, referenceHeight);
2576
+ const maximum = node.style?.maxWidth !== "none" && node.style?.maxWidth != null ? resolveLength(node.style.maxWidth, referenceWidth) : NaN;
2577
+ if (Number.isFinite(maximum) && width > maximum) width = maximum;
2578
+ return { width: Math.max(0, width), height: Math.max(0, height) };
2579
+ }
2580
+
2581
+ /**
2582
+ * An inline <svg>'s size (SVG 2 §8.2): its CSS size, else its width and
2583
+ * height attributes, else 100% of its container's width; a missing height
2584
+ * follows the viewBox's ratio, or is 150px without one. Where the container
2585
+ * is still being measured (an indefinite width) it counts as 300px wide.
2586
+ */
2587
+ function svgDimensions(node, referenceWidth, referenceHeight) {
2588
+ const css = (value, reference) => value !== "auto" && value != null ? numberOrNull(resolveLength(value, reference ?? NaN)) : null;
2589
+ const ratio = node.svgRatio ?? null;
2590
+ let width = css(node.style?.width, referenceWidth) ?? node.widthAttribute;
2591
+ let height = css(node.style?.height, referenceHeight) ?? node.heightAttribute;
2592
+ if (width == null && height != null && ratio) width = height * ratio;
2593
+ if (width == null) width = Number.isFinite(referenceWidth) ? referenceWidth : 300;
2594
+ if (height == null) height = ratio ? width / ratio : 150;
2595
+ const maximum = node.style?.maxWidth !== "none" && node.style?.maxWidth != null ? numberOrNull(resolveLength(node.style.maxWidth, referenceWidth)) : null;
2596
+ if (maximum != null && width > maximum) {
2597
+ height = ratio ? maximum / ratio : height;
2598
+ width = maximum;
2599
+ }
2600
+ return { width: Math.max(0, width), height: Math.max(0, height) };
2601
+ }
2602
+
2603
+ /** Percentages in width are of `referenceWidth`, in height of `referenceHeight` (auto if indefinite). */
2604
+ function imageDimensions(node, referenceWidth, referenceHeight = null) {
2605
+ if (node.tagName === "IFRAME") return frameDimensions(node, referenceWidth, referenceHeight);
2606
+ if (node.tagName === "SVG") return svgDimensions(node, referenceWidth, referenceHeight);
2424
2607
  const intrinsicWidth = node.resource?.width ?? node.widthAttribute ?? 300;
2425
2608
  const intrinsicHeight = node.resource?.height ?? node.heightAttribute ?? 150;
2426
2609
  const ratio = intrinsicWidth / intrinsicHeight;
@@ -2430,7 +2613,7 @@ function imageDimensions(node, referenceWidth) {
2430
2613
  ? resolveLength(cssWidth, referenceWidth)
2431
2614
  : node.widthAttribute;
2432
2615
  let height = cssHeight !== "auto" && cssHeight != null
2433
- ? resolveLength(cssHeight, referenceWidth)
2616
+ ? resolveLength(cssHeight, referenceHeight ?? NaN)
2434
2617
  : node.heightAttribute;
2435
2618
  if (!Number.isFinite(width)) width = null;
2436
2619
  if (!Number.isFinite(height)) height = null;