buninu-browser 0.2.0 → 0.2.2

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 (80) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/bunfig.toml +1 -0
  4. package/buninu-browser.js +4 -0
  5. package/index.js +5 -0
  6. package/lib/audio/piano.js +24 -1
  7. package/lib/audio/pulse-client.js +120 -12
  8. package/lib/cdp-server.js +19 -12
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/happy-dom/selector-matcher.js +83 -0
  11. package/lib/headless-shell.js +120 -28
  12. package/lib/input/hit-test.js +25 -7
  13. package/lib/layout/text-layout.js +365 -54
  14. package/lib/log.js +5 -2
  15. package/lib/media/byte-pipe.js +82 -0
  16. package/lib/media/position-audio.js +42 -0
  17. package/lib/network/network-service.js +326 -46
  18. package/lib/paint/display-list.js +322 -21
  19. package/lib/paint/gradient.js +11 -5
  20. package/lib/render-tree/index.js +62 -2
  21. package/lib/render-tree/svg.js +2 -1
  22. package/lib/renderer/bindings.js +989 -72
  23. package/lib/renderer/canvas-2d.js +92 -0
  24. package/lib/renderer/host.js +159 -9
  25. package/lib/renderer/interfaces.js +215 -0
  26. package/lib/renderer/main.js +160 -12
  27. package/lib/renderer/media-source.js +819 -0
  28. package/lib/renderer/messaging.js +411 -0
  29. package/lib/renderer/native-functions.js +113 -0
  30. package/lib/renderer/page-realm.js +1407 -200
  31. package/lib/renderer/page-renderer.js +1899 -394
  32. package/lib/renderer/rejections.js +31 -0
  33. package/lib/renderer/rewrite-worker.js +22 -0
  34. package/lib/renderer/script-rewrite.js +805 -57
  35. package/lib/renderer/trusted-types.js +129 -0
  36. package/lib/renderer/workers.js +327 -0
  37. package/lib/screenshot-cli.js +2 -0
  38. package/lib/style/computed-style.js +463 -242
  39. package/lib/style/selector-keys.js +302 -0
  40. package/lib/style/selector-traits.js +240 -0
  41. package/lib/user-agent.js +14 -2
  42. package/package.json +4 -1
  43. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  44. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  45. package/patches/happy-dom/README.md +26 -0
  46. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  47. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  48. package/patches/ses/README.md +4 -0
  49. package/test/canvas-2d.test.js +55 -0
  50. package/test/compositor.test.js +3 -1
  51. package/test/computed-style.test.js +98 -1
  52. package/test/display-list.test.js +74 -1
  53. package/test/download.test.js +35 -0
  54. package/test/fixtures/long-video.mp4 +0 -0
  55. package/test/fixtures/media-sync.js +57 -0
  56. package/test/happy-dom-platform-adapters.test.js +25 -0
  57. package/test/headless-shell.test.js +29 -0
  58. package/test/hit-test.test.js +25 -1
  59. package/test/log.test.js +4 -1
  60. package/test/media-source.test.js +156 -0
  61. package/test/media-sync.test.js +12 -0
  62. package/test/mobile.test.js +2 -2
  63. package/test/navigation.test.js +61 -0
  64. package/test/network-service.test.js +81 -0
  65. package/test/page-scripts.test.js +1094 -7
  66. package/test/position-audio.test.js +67 -0
  67. package/test/positioning.test.js +95 -1
  68. package/test/pulse-client.test.js +69 -0
  69. package/test/render-tree.test.js +22 -1
  70. package/test/renderer-host.test.js +5 -2
  71. package/test/screenshot-cli.test.js +25 -1
  72. package/test/script-rewrite.test.js +111 -6
  73. package/test/selector-keys.test.js +39 -0
  74. package/test/selector-traits.test.js +19 -0
  75. package/test/setup.js +5 -0
  76. package/test/text-layout.test.js +69 -1
  77. package/test/user-agent.test.js +60 -4
  78. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  79. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  80. package/vendor/ses/dist/ses.mjs +52 -6
@@ -18,6 +18,7 @@
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;
@@ -39,10 +40,11 @@ export function layoutText(tree, options = {}) {
39
40
  boxes: [],
40
41
  lineHeight: options.lineHeight == null ? null : positiveNumber(options.lineHeight, 31),
41
42
  fallbackLineHeight: 31,
42
- measure: cachedMeasure(measureText),
43
- metrics: cachedMetrics(options.fontMetrics),
44
- maxContent: new WeakMap(),
45
- minContent: new WeakMap(),
43
+ measure: options.measureCache ?? cachedMeasure(measureText),
44
+ metrics: options.metricsCache ?? cachedMetrics(options.fontMetrics),
45
+ maxContent: options.maxContentCache ?? new WeakMap(),
46
+ minContent: options.minContentCache ?? new WeakMap(),
47
+ blockResults: options.blockResultCache ?? null,
46
48
  // Paint layers: the root layer holds in-flow content; positioned boxes add
47
49
  // more. A layer with an integer z-index is a stacking context; `parent` is
48
50
  // the stacking context a layer is painted in (CSS 2 Appendix E).
@@ -50,14 +52,23 @@ export function layoutText(tree, options = {}) {
50
52
  stackingContext: ROOT_LAYER,
51
53
  layerCount: 1,
52
54
  initialContainingBlock: null,
55
+ // The content box of the block being laid out: the containing block of
56
+ // its in-flow children, for percentages in their relative offsets.
57
+ containing: null,
58
+ // Static positions recorded for out-of-flow boxes, in order (see
59
+ // cacheBlockResult), and those boxes by node for replays.
60
+ staticPositions: [],
61
+ outOfFlowBoxes: new Map(),
53
62
  };
54
63
 
55
64
  containingBlocks = [];
65
+ outOfFlowBoxes = ctx.outOfFlowBoxes;
56
66
  let root;
57
67
  try {
58
68
  root = buildBox(tree.root, inheritedFlags(tree.root, { preserve: false, wrap: true }));
59
69
  } finally {
60
70
  containingBlocks = null;
71
+ outOfFlowBoxes = null;
61
72
  }
62
73
  const containingWidth = options.measureText || viewportWidth
63
74
  ? Math.max(1, (viewportWidth || columns * columnWidth) - x * 2)
@@ -72,6 +83,7 @@ export function layoutText(tree, options = {}) {
72
83
  // establishes the BFC, so body margins collapse with its children's.
73
84
  const horizontal = resolveHorizontal(ctx, root, x, containingWidth, false);
74
85
  const rootY = y + strutValue(topStrut(root));
86
+ ctx.containing = { width: containingWidth, height: ctx.initialContainingBlock.height || null };
75
87
  const result = layoutBlock(ctx, root, horizontal.x, horizontal.width, rootY, collapsesThroughTop(root));
76
88
  const height = rootY + result.height + strutValue(bottomStrut(root));
77
89
 
@@ -91,12 +103,43 @@ export function layoutText(tree, options = {}) {
91
103
  });
92
104
  }
93
105
 
106
+ /**
107
+ * Long-lived layout entry point. Expensive shaping measurements are valid as
108
+ * long as their immutable computed-style identity is valid, so retain them
109
+ * across incremental updates instead of starting cold on every reflow.
110
+ * Formatting-context result caches are added to this same owner.
111
+ */
112
+ export class LayoutEngine {
113
+ #measure;
114
+ #metrics;
115
+ #maxContent = new WeakMap();
116
+ #minContent = new WeakMap();
117
+ #blockResults = new WeakMap();
118
+
119
+ constructor({ measureText = (text) => Bun.stringWidth(text) * 13, fontMetrics = null } = {}) {
120
+ this.#measure = cachedMeasure(measureText);
121
+ this.#metrics = cachedMetrics(fontMetrics);
122
+ }
123
+
124
+ layout(tree, options = {}) {
125
+ return layoutText(tree, {
126
+ ...options,
127
+ measureCache: this.#measure,
128
+ metricsCache: this.#metrics,
129
+ maxContentCache: this.#maxContent,
130
+ minContentCache: this.#minContent,
131
+ blockResultCache: this.#blockResults,
132
+ });
133
+ }
134
+ }
135
+
94
136
  // ---------------------------------------------------------------------------
95
137
  // Box tree construction
96
138
 
97
139
  // Absolutely positioned boxes register with their containing block while the
98
140
  // box tree is built. Building is synchronous and scoped to layoutText.
99
141
  let containingBlocks = null;
142
+ let outOfFlowBoxes = null;
100
143
 
101
144
  // Text nodes carry their parent's style, so only element boxes can be positioned.
102
145
  const UNPOSITIONABLE = new Set(["root", "text", "line-break"]);
@@ -115,6 +158,7 @@ function outOfFlowBox(node, flags) {
115
158
  const box = buildBox(node, inheritedFlags(node, flags), true);
116
159
  box.outOfFlow = true;
117
160
  holder.absolutes.push(box);
161
+ outOfFlowBoxes?.set(node, box);
118
162
  return box;
119
163
  }
120
164
 
@@ -279,8 +323,15 @@ function hasInlineContent(items) {
279
323
  */
280
324
  function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = null) {
281
325
  const style = box.style;
326
+ // Its containing block, for percentages in relative offsets.
327
+ const containing = ctx.containing;
328
+ const key = blockCacheKey(width, absorbTop, forcedHeight, style?.position === "relative" ? containing : null);
329
+ const cached = cachedBlockResult(ctx, box, key, y, x);
330
+ if (cached) return cached;
282
331
  const boxStart = ctx.boxes.length;
283
332
  const fragmentStart = ctx.fragments.length;
333
+ const layerStart = ctx.layerCount;
334
+ const staticStart = ctx.staticPositions.length;
284
335
  const previousLayer = ctx.layer;
285
336
  const previousContext = ctx.stackingContext;
286
337
  // Anonymous boxes reuse their container's node but are never positioned themselves.
@@ -300,10 +351,10 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
300
351
  if (box.absolutes?.length) layoutAbsolutes(ctx, box, record);
301
352
  let dx = 0;
302
353
  let dy = 0;
303
- if (style?.position === "relative") {
304
- const [top, right, bottom, left] = style.inset.map((value) => numberOrNull(value));
305
- dx = left ?? (right != null ? -right : 0);
306
- dy = top ?? (bottom != null ? -bottom : 0);
354
+ const offset = relativeOffset(style, containing ?? { width, height: null });
355
+ if (offset) {
356
+ dx = offset.x;
357
+ dy = offset.y;
307
358
  }
308
359
  if (style?.transform) {
309
360
  dx += numberOrZero(resolveLength(style.transform.x, record.width));
@@ -314,9 +365,137 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
314
365
  if (dx || dy) shiftOutput(ctx, boxStart, fragmentStart, dx, dy);
315
366
  ctx.layer = previousLayer;
316
367
  ctx.stackingContext = previousContext;
368
+ cacheBlockResult(ctx, box, key, x, y, result, {
369
+ boxStart, fragmentStart, layerStart, staticStart, layer: previousLayer, context: previousContext,
370
+ });
317
371
  return result;
318
372
  }
319
373
 
374
+ function blockCacheKey(width, absorbTop, forcedHeight, containing) {
375
+ const key = `${width}\n${absorbTop ? 1 : 0}\n${forcedHeight ?? "auto"}`;
376
+ return containing ? `${key}\n${containing.width}\n${containing.height ?? "auto"}` : key;
377
+ }
378
+
379
+ /**
380
+ * The offset of a relatively positioned box (CSS 2 §9.4.3). Percentages are
381
+ * of its containing block; one of an indefinite height is auto.
382
+ */
383
+ function relativeOffset(style, containing) {
384
+ if (style?.position !== "relative" || !style.inset) return null;
385
+ const [top, right, bottom, left] = style.inset.map((value, index) => value === "auto"
386
+ ? null
387
+ : numberOrNull(resolveLength(value, (index % 2 ? containing?.width : containing?.height) ?? NaN)));
388
+ const x = left ?? (right != null ? -right : 0);
389
+ const y = top ?? (bottom != null ? -bottom : 0);
390
+ return x || y ? { x, y } : null;
391
+ }
392
+
393
+ /** Where an out-of-flow box would have been in flow; recorded so that a replayed block sets it again. */
394
+ function setStaticPosition(ctx, box, position) {
395
+ box.staticPosition = position;
396
+ ctx.staticPositions.push({ node: box.node, position });
397
+ }
398
+
399
+ /** Replays a retained formatting-context result at its new flow position. */
400
+ function cachedBlockResult(ctx, box, key, y, x) {
401
+ if (!ctx.blockResults || box.anonymous) return null;
402
+ const cached = ctx.blockResults.get(box.node)?.get(key);
403
+ if (!cached) return null;
404
+ const dx = x - cached.x;
405
+ const dy = y - cached.y;
406
+ // The block's own paint layers are made again, in their order, after the
407
+ // layers laid out before it; -1 is the layer and stacking context it is in.
408
+ const layers = [];
409
+ for (const layer of cached.layers) {
410
+ layers.push(Object.freeze({
411
+ z: layer.z,
412
+ order: ctx.layerCount++,
413
+ context: layer.context,
414
+ parent: layer.parent === -1 ? ctx.stackingContext : layers[layer.parent],
415
+ }));
416
+ }
417
+ const layerAt = (index) => index === -1 ? ctx.layer : layers[index];
418
+ // Retained output is frozen: where it neither moved nor changed layer, the
419
+ // same objects are output again.
420
+ const moved = dx !== 0 || dy !== 0;
421
+ cached.boxes.forEach((record, index) => {
422
+ const layer = layerAt(cached.boxLayers[index]);
423
+ ctx.boxes.push(!moved && record.layer === layer ? record : { ...record, x: record.x + dx, y: record.y + dy, layer });
424
+ });
425
+ cached.fragments.forEach((fragment, index) => {
426
+ const layer = layerAt(cached.fragmentLayers[index]);
427
+ ctx.fragments.push(!moved && fragment.layer === layer ? fragment : { ...shiftedFragment(fragment, dx, dy), layer });
428
+ });
429
+ // Out-of-flow boxes whose containing block is outside this one still need their static positions.
430
+ for (const { node, x: staticX, y: staticY } of cached.staticPositions) {
431
+ const outOfFlow = ctx.outOfFlowBoxes.get(node);
432
+ const position = { x: staticX + dx, y: staticY + dy };
433
+ if (outOfFlow) outOfFlow.staticPosition = position;
434
+ ctx.staticPositions.push({ node, position });
435
+ }
436
+ return {
437
+ height: cached.result.height,
438
+ baseline: cached.result.baseline == null ? null : cached.result.baseline + dy,
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Retains a block's output for replay (cachedBlockResult). Positioned boxes
444
+ * inside it make paint layers of their own; those are kept by creation
445
+ * order relative to the layer and stacking context the block is in, and the
446
+ * static positions it recorded go along. Output in a layer made before the
447
+ * block (which should not occur) is not retained.
448
+ */
449
+ function cacheBlockResult(ctx, box, key, x, y, result, start) {
450
+ if (!ctx.blockResults || box.anonymous) return;
451
+ const boxes = ctx.boxes.slice(start.boxStart);
452
+ const fragments = ctx.fragments.slice(start.fragmentStart);
453
+ const local = new Set();
454
+ const collect = (layer) => {
455
+ if (layer === start.layer || layer === start.context || local.has(layer)) return true;
456
+ if (!layer || layer.order < start.layerStart) return false;
457
+ local.add(layer);
458
+ return collect(layer.parent);
459
+ };
460
+ if (!boxes.every((record) => collect(record.layer)) || !fragments.every((fragment) => collect(fragment.layer))) return;
461
+ const ordered = [...local].sort((left, right) => left.order - right.order);
462
+ const indices = new Map(ordered.map((layer, index) => [layer, index]));
463
+ const layers = [];
464
+ for (const layer of ordered) {
465
+ const parent = indices.get(layer.parent) ?? (layer.parent === start.context ? -1 : null);
466
+ if (parent === null) return;
467
+ layers.push({ z: layer.z, context: layer.context, parent });
468
+ }
469
+ const indexOf = (layer) => layer === start.layer ? -1 : indices.get(layer) ?? null;
470
+ const boxLayers = boxes.map((record) => indexOf(record.layer));
471
+ const fragmentLayers = fragments.map((fragment) => indexOf(fragment.layer));
472
+ if (boxLayers.includes(null) || fragmentLayers.includes(null)) return;
473
+ let entries = ctx.blockResults.get(box.node);
474
+ if (!entries) ctx.blockResults.set(box.node, entries = new Map());
475
+ // A node normally sees one containing width. Bound unusual cases such as a
476
+ // reused table cell so the retained cache cannot grow without limit.
477
+ if (!entries.has(key) && entries.size >= 4) {
478
+ entries.delete(entries.keys().next().value);
479
+ }
480
+ entries.set(key, {
481
+ x,
482
+ y,
483
+ result: { ...result },
484
+ layers,
485
+ boxes: boxes.map(retained),
486
+ boxLayers,
487
+ fragments: fragments.map(retained),
488
+ fragmentLayers,
489
+ staticPositions: ctx.staticPositions.slice(start.staticStart)
490
+ .map(({ node, position }) => ({ node, x: position.x, y: position.y })),
491
+ });
492
+ }
493
+
494
+ /** An output record as retained: frozen (records replayed from a retained block already are). */
495
+ function retained(record) {
496
+ return Object.isFrozen(record) ? record : Object.freeze({ ...record });
497
+ }
498
+
320
499
  /** CSS 2 §10.3.7/§10.6.4 absolute placement against the padding box. */
321
500
  function layoutAbsolutes(ctx, box, record) {
322
501
  const border = borderWidths(box.style);
@@ -387,10 +566,11 @@ function justifyColumn(ctx, items, justify = "normal", free) {
387
566
  });
388
567
  }
389
568
 
569
+ /** Moves output already produced; records are replaced, as retained ones are frozen and shared. */
390
570
  function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
391
571
  for (let index = boxStart; index < ctx.boxes.length; index++) {
392
- ctx.boxes[index].x += dx;
393
- ctx.boxes[index].y += dy;
572
+ const record = ctx.boxes[index];
573
+ ctx.boxes[index] = { ...record, x: record.x + dx, y: record.y + dy };
394
574
  }
395
575
  for (let index = fragmentStart; index < ctx.fragments.length; index++) {
396
576
  ctx.fragments[index] = shiftedFragment(ctx.fragments[index], dx, dy);
@@ -398,6 +578,7 @@ function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
398
578
  }
399
579
 
400
580
  function shiftedFragment(fragment, dx, dy) {
581
+ if (dx === 0 && dy === 0) return fragment;
401
582
  return {
402
583
  ...fragment,
403
584
  x: fragment.x + dx,
@@ -418,7 +599,7 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
418
599
  const record = { nodeId: box.node.id, x, y, width, height: 0, style };
419
600
  ctx.boxes.push(record);
420
601
  if (box.replaced) {
421
- const dimensions = imageDimensions(box.node, width);
602
+ const dimensions = imageDimensions(box.node, width, ctx.containing?.height);
422
603
  const imageX = x + border[3] + padding[3];
423
604
  const imageY = y + border[0] + padding[0];
424
605
  ctx.fragments.push(lineFragment(box.style, "", [box.node.id], [{
@@ -450,6 +631,21 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
450
631
  let columnItems = null;
451
632
  let gridResult = null;
452
633
  let baseline = null;
634
+ const toContentHeight = (value) => style.boxSizing === "border-box"
635
+ ? Math.max(0, value - border[0] - border[2] - padding[0] - padding[2])
636
+ : value;
637
+ // A percentage height (and min/max) resolves against the containing block
638
+ // only when that height is definite (CSS 2 §10.5); otherwise it is auto.
639
+ const containingHeight = ctx.containing?.height;
640
+ const resolveHeight = (value) => numberOrNull(resolveLength(value, containingHeight ?? NaN));
641
+ const specifiedHeight = resolveHeight(style?.height);
642
+ const previousContaining = ctx.containing;
643
+ ctx.containing = {
644
+ width: contentWidth,
645
+ height: forcedHeight != null
646
+ ? Math.max(0, forcedHeight - border[0] - border[2] - padding[0] - padding[2])
647
+ : specifiedHeight != null ? toContentHeight(specifiedHeight) : null,
648
+ };
453
649
 
454
650
  if (box.table) {
455
651
  const result = layoutTable(ctx, box, contentX, contentWidth, contentTop);
@@ -481,7 +677,9 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
481
677
  : child.style.alignSelf;
482
678
  const shrink = !["normal", "stretch"].includes(alignment);
483
679
  const placed = resolveHorizontal(ctx, child, contentX, contentWidth, shrink);
484
- const autoMargins = (child.style?.margin ?? ZERO_BOX).some((value) => value === "auto");
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";
485
683
  let childX = placed.x;
486
684
  if (shrink && !autoMargins) {
487
685
  const free = contentWidth - placed.width - placed.marginLeft - placed.marginRight;
@@ -500,7 +698,7 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
500
698
  let firstInFlow = true;
501
699
  box.children.forEach((child) => {
502
700
  if (child.kind === "placeholder") {
503
- child.box.staticPosition = { x: contentX, y: cursor + strutValue(pending ?? ZERO_STRUT) };
701
+ setStaticPosition(ctx, child.box, { x: contentX, y: cursor + strutValue(pending ?? ZERO_STRUT) });
504
702
  return;
505
703
  }
506
704
  const first = firstInFlow;
@@ -527,16 +725,12 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
527
725
  contentBottom = collapsesThroughBottom(box) ? cursor : cursor + strutValue(pending ?? ZERO_STRUT);
528
726
  }
529
727
 
728
+ ctx.containing = previousContaining;
530
729
  let contentHeight = Math.max(0, contentBottom - contentTop);
531
- const toContentHeight = (value) => style.boxSizing === "border-box"
532
- ? Math.max(0, value - border[0] - border[2] - padding[0] - padding[2])
533
- : value;
534
- const specifiedHeight = numberOrNull(style?.height);
535
730
  if (specifiedHeight != null) contentHeight = toContentHeight(specifiedHeight);
536
- // min-height and max-height (percentages need a definite containing height; not modelled).
537
- const maxHeight = numberOrNull(style?.maxHeight);
731
+ const maxHeight = style?.maxHeight === "none" ? null : resolveHeight(style?.maxHeight);
538
732
  if (maxHeight != null) contentHeight = Math.min(contentHeight, toContentHeight(maxHeight));
539
- const minHeight = numberOrNull(style?.minHeight);
733
+ const minHeight = resolveHeight(style?.minHeight);
540
734
  if (minHeight != null) contentHeight = Math.max(contentHeight, toContentHeight(minHeight));
541
735
  if (columnItems?.length) justifyColumn(ctx, columnItems, style?.justifyContent, contentHeight - (contentBottom - contentTop));
542
736
  if (gridResult) alignGridContent(ctx, gridResult, style?.alignContent, contentHeight - (contentBottom - contentTop));
@@ -659,13 +853,9 @@ function layoutFlexRow(ctx, box, x, width, y) {
659
853
  }
660
854
  let crossSize = Math.max(0, ...lineItems.map((item) => item.height + item.marginTop + item.marginBottom));
661
855
  // A single-line container with a definite height gives its line that cross size (§9.4.15).
662
- const definiteHeight = numberOrNull(style?.height);
663
- if (lines.length === 1 && definiteHeight != null) {
664
- const padding = style.padding ?? ZERO_BOX;
665
- crossSize = style.boxSizing === "border-box"
666
- ? Math.max(0, definiteHeight - padding[0] - padding[2] - borderWidths(style)[0] - borderWidths(style)[2])
667
- : definiteHeight;
668
- }
856
+ // ctx.containing.height is that content-box height, percentages already resolved.
857
+ const definiteHeight = ctx.containing?.height;
858
+ if (lines.length === 1 && definiteHeight != null) crossSize = definiteHeight;
669
859
 
670
860
  // Main axis: auto margins absorb free space first, then justify-content.
671
861
  let free = width - gaps - lineItems.reduce((sum, item) => sum + item.target + item.marginLeft + item.marginRight, 0);
@@ -902,7 +1092,10 @@ function alignGridContent(ctx, grid, alignment = "normal", free) {
902
1092
  for (const placement of grid.placements) {
903
1093
  const dy = offset(placement.row);
904
1094
  if (!dy) continue;
905
- for (let index = placement.boxStart; index < placement.boxEnd; index++) ctx.boxes[index].y += dy;
1095
+ for (let index = placement.boxStart; index < placement.boxEnd; index++) {
1096
+ const record = ctx.boxes[index];
1097
+ ctx.boxes[index] = { ...record, y: record.y + dy };
1098
+ }
906
1099
  for (let index = placement.fragmentStart; index < placement.fragmentEnd; index++) {
907
1100
  ctx.fragments[index] = shiftedFragment(ctx.fragments[index], 0, dy);
908
1101
  }
@@ -1532,7 +1725,8 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, perce
1532
1725
  }
1533
1726
 
1534
1727
  function maxContentWidth(ctx, box) {
1535
- const cached = ctx.maxContent.get(box);
1728
+ const cacheKey = box.node ?? box;
1729
+ const cached = ctx.maxContent.get(cacheKey);
1536
1730
  if (cached != null) return cached;
1537
1731
  const style = box.style;
1538
1732
  const padding = style?.padding ?? ZERO_BOX;
@@ -1568,12 +1762,14 @@ function maxContentWidth(ctx, box) {
1568
1762
  }
1569
1763
  const maxWidth = numberOrNull(style?.maxWidth);
1570
1764
  if (maxWidth != null) width = Math.min(width, style.boxSizing === "border-box" ? maxWidth : maxWidth + extra);
1571
- ctx.maxContent.set(box, width);
1765
+ width = atLeastMinWidth(style, width, extra);
1766
+ ctx.maxContent.set(cacheKey, width);
1572
1767
  return width;
1573
1768
  }
1574
1769
 
1575
1770
  function minContentWidth(ctx, box) {
1576
- const cached = ctx.minContent.get(box);
1771
+ const cacheKey = box.node ?? box;
1772
+ const cached = ctx.minContent.get(cacheKey);
1577
1773
  if (cached != null) return cached;
1578
1774
  const style = box.style;
1579
1775
  const padding = style?.padding ?? ZERO_BOX;
@@ -1600,10 +1796,17 @@ function minContentWidth(ctx, box) {
1600
1796
  ? inlineMinContentWidth(ctx, child)
1601
1797
  : outer(child))) + extra;
1602
1798
  }
1603
- ctx.minContent.set(box, width);
1799
+ width = atLeastMinWidth(style, width, extra);
1800
+ ctx.minContent.set(cacheKey, width);
1604
1801
  return width;
1605
1802
  }
1606
1803
 
1804
+ /** min-width wins over the intrinsic width (and max-width); a percentage is of an unknown width here. */
1805
+ function atLeastMinWidth(style, width, extra) {
1806
+ const minWidth = numberOrNull(style?.minWidth);
1807
+ return minWidth == null ? width : Math.max(width, style.boxSizing === "border-box" ? minWidth : minWidth + extra);
1808
+ }
1809
+
1607
1810
  function verticalMargins(style) {
1608
1811
  const margin = style?.margin ?? ZERO_BOX;
1609
1812
  return { top: numberOrZero(margin[0]), bottom: numberOrZero(margin[2]) };
@@ -1668,6 +1871,8 @@ function layoutInline(ctx, ifc, x, width, y) {
1668
1871
  }
1669
1872
  return atom;
1670
1873
  };
1874
+ // Percentage sizes of inline images.
1875
+ atomFor.containing = ctx.containing;
1671
1876
  const lines = breakLines(ctx, paragraph, width, atomFor);
1672
1877
  let cursor = y;
1673
1878
  let baseline = null;
@@ -1879,9 +2084,26 @@ function truncateForEllipsis(ctx, style, paragraph, segments, line, width) {
1879
2084
  return { text, width: ellipsisWidth, run: lastText.run, metrics: textMetrics(ctx, lastText.run.style) };
1880
2085
  }
1881
2086
 
2087
+ /** Space beside an inline replaced element's content: its margins, borders and padding. */
1882
2088
  function inlineImageMargins(node) {
1883
2089
  const margin = node.style?.margin ?? ZERO_BOX;
1884
- return { left: numberOrZero(margin[3]), right: numberOrZero(margin[1]) };
2090
+ const border = borderWidths(node.style);
2091
+ const padding = node.style?.padding ?? ZERO_BOX;
2092
+ return {
2093
+ left: numberOrZero(margin[3]) + border[3] + padding[3],
2094
+ right: numberOrZero(margin[1]) + border[1] + padding[1],
2095
+ };
2096
+ }
2097
+
2098
+ /** The same above and below its content (its margin box sits on the line). */
2099
+ function inlineImageVerticalInsets(node) {
2100
+ const margin = node.style?.margin ?? ZERO_BOX;
2101
+ const border = borderWidths(node.style);
2102
+ const padding = node.style?.padding ?? ZERO_BOX;
2103
+ return {
2104
+ top: numberOrZero(margin[0]) + border[0] + padding[0],
2105
+ bottom: numberOrZero(margin[2]) + border[2] + padding[2],
2106
+ };
1885
2107
  }
1886
2108
 
1887
2109
  /**
@@ -1929,7 +2151,8 @@ function runSliceWidth(ctx, run, start, end, text, atomFor) {
1929
2151
  if (end <= start) return 0;
1930
2152
  if (run.kind === "image") {
1931
2153
  const margins = inlineImageMargins(run.node);
1932
- return imageDimensions(run.node, Infinity).width + margins.left + margins.right;
2154
+ const containing = atomFor.containing;
2155
+ return imageDimensions(run.node, containing?.width ?? Infinity, containing?.height).width + margins.left + margins.right;
1933
2156
  }
1934
2157
  if (run.kind === "atomic") {
1935
2158
  const atom = atomFor(run);
@@ -2009,13 +2232,20 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2009
2232
  const strut = textMetrics(ctx, style);
2010
2233
  let ascent = strut.ascent;
2011
2234
  let descent = strut.descent;
2235
+ // Floats (approximated as atomic inlines) sit at the top of the line instead of on its baseline.
2236
+ let floatHeight = 0;
2012
2237
  for (const segment of segments) {
2013
2238
  const { run } = segment;
2014
- if (run.kind === "image") {
2015
- segment.dimensions = imageDimensions(run.node, width);
2016
- segment.imageTop = inlineImageTop(run.node.style, segment.dimensions.height, strut);
2239
+ if (run.kind === "atomic" && isFloat(run.style)) {
2240
+ const atom = atomFor(run);
2241
+ floatHeight = Math.max(floatHeight, atom.marginTop + atom.height + atom.marginBottom);
2242
+ } else if (run.kind === "image") {
2243
+ segment.dimensions = imageDimensions(run.node, width, ctx.containing?.height);
2244
+ segment.insets = inlineImageVerticalInsets(run.node);
2245
+ const outerHeight = segment.dimensions.height + segment.insets.top + segment.insets.bottom;
2246
+ segment.imageTop = inlineImageTop(run.node.style, outerHeight, strut);
2017
2247
  ascent = Math.max(ascent, -segment.imageTop);
2018
- descent = Math.max(descent, segment.imageTop + segment.dimensions.height);
2248
+ descent = Math.max(descent, segment.imageTop + outerHeight);
2019
2249
  } else if (run.kind === "atomic") {
2020
2250
  const atom = atomFor(run);
2021
2251
  const atomAscent = atom.marginTop + (atom.baseline ?? atom.height) ;
@@ -2029,7 +2259,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2029
2259
  }
2030
2260
  }
2031
2261
  const baselineY = y + ascent;
2032
- const lineHeight = ascent + descent;
2262
+ const lineHeight = Math.max(ascent + descent, floatHeight);
2033
2263
 
2034
2264
  // Horizontal alignment.
2035
2265
  const alignment = style?.textAlign;
@@ -2042,6 +2272,22 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2042
2272
  const edges = new Map();
2043
2273
  const runs = [];
2044
2274
  const atomsToPlace = [];
2275
+ // Relatively positioned inline boxes move their contents (CSS 2 §9.4.3);
2276
+ // an offset adds to those of the inline boxes around it.
2277
+ const containing = ctx.containing;
2278
+ const offsets = new Map();
2279
+ const offsetOf = (node, style, outer) => {
2280
+ if (!offsets.has(node)) {
2281
+ const own = relativeOffset(style, containing);
2282
+ offsets.set(node, own ? { x: outer.x + own.x, y: outer.y + own.y } : outer);
2283
+ }
2284
+ return offsets.get(node);
2285
+ };
2286
+ const runOffset = (run) => {
2287
+ let offset = ZERO_OFFSET;
2288
+ for (const node of run.inlines) offset = offsetOf(node, paragraph.inlineState.get(node).style, offset);
2289
+ return run.kind === "image" ? offsetOf(run.node, run.node.style, offset) : offset;
2290
+ };
2045
2291
  // Right floats (approximated as atomic inlines) go to the end of the line.
2046
2292
  const rightFloats = segments.filter((segment) => segment.run.kind === "atomic" && segment.run.style?.float === "right");
2047
2293
  const rightFloatWidth = rightFloats.reduce((sum, segment) => sum + segment.width, 0);
@@ -2060,6 +2306,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2060
2306
  }
2061
2307
  }
2062
2308
  const segmentWidth = segment.width;
2309
+ const shift = runOffset(run);
2063
2310
  const common = {
2064
2311
  nodeId: run.nodeId,
2065
2312
  text: run.kind === "text" ? paragraph.text.slice(segment.start, segment.end) : "",
@@ -2068,11 +2315,27 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2068
2315
  style: run.style ?? style,
2069
2316
  };
2070
2317
  if (run.kind === "image") {
2318
+ const imageX = cursor + inlineImageMargins(run.node).left + shift.x;
2319
+ const imageY = baselineY + segment.imageTop + segment.insets.top + shift.y;
2320
+ // Its background and borders, around the content box.
2321
+ if (isDecorated(run.node.style)) {
2322
+ const border = borderWidths(run.node.style);
2323
+ const padding = run.node.style.padding ?? ZERO_BOX;
2324
+ ctx.boxes.push({
2325
+ nodeId: run.nodeId,
2326
+ x: imageX - padding[3] - border[3],
2327
+ y: imageY - padding[0] - border[0],
2328
+ width: segment.dimensions.width + padding[1] + padding[3] + border[1] + border[3],
2329
+ height: segment.dimensions.height + padding[0] + padding[2] + border[0] + border[2],
2330
+ style: run.node.style,
2331
+ inline: true,
2332
+ });
2333
+ }
2071
2334
  runs.push({
2072
2335
  ...common,
2073
2336
  type: "image",
2074
- x: cursor + inlineImageMargins(run.node).left,
2075
- y: baselineY + segment.imageTop,
2337
+ x: imageX,
2338
+ y: imageY,
2076
2339
  width: segment.dimensions.width,
2077
2340
  height: segment.dimensions.height,
2078
2341
  resource: run.node.resource,
@@ -2082,14 +2345,14 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2082
2345
  });
2083
2346
  } else if (run.kind === "atomic") {
2084
2347
  const atom = atomFor(run);
2085
- const atomY = baselineY - segment.atomAscent + atom.marginTop;
2086
- runs.push({ ...common, type: "atomic", x: cursor, y: atomY, width: segmentWidth, height: atom.height });
2087
- atomsToPlace.push({ box: run.box, x: cursor + atom.marginLeft, y: atomY, width: atom.width });
2348
+ const atomY = isFloat(run.style) ? y + atom.marginTop : baselineY - segment.atomAscent + atom.marginTop;
2349
+ runs.push({ ...common, type: "atomic", x: cursor + shift.x, y: atomY + shift.y, width: segmentWidth, height: atom.height });
2350
+ atomsToPlace.push({ box: run.box, x: cursor + atom.marginLeft + shift.x, y: atomY + shift.y, width: atom.width });
2088
2351
  } else {
2089
2352
  runs.push({
2090
2353
  ...common,
2091
- x: cursor,
2092
- y: baselineY - segment.metrics.contentAscent,
2354
+ x: cursor + shift.x,
2355
+ y: baselineY - segment.metrics.contentAscent + shift.y,
2093
2356
  width: Math.max(1, segmentWidth),
2094
2357
  height: null,
2095
2358
  });
@@ -2138,10 +2401,11 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2138
2401
  const metrics = textMetrics(ctx, nodeStyle);
2139
2402
  const left = edge.left ?? edge.first;
2140
2403
  const right = edge.right ?? edge.last;
2404
+ const shift = offsets.get(node) ?? ZERO_OFFSET;
2141
2405
  ctx.boxes.push({
2142
2406
  nodeId: node.id,
2143
- x: left,
2144
- y: baselineY - metrics.contentAscent - padding[0] - border[0],
2407
+ x: left + shift.x,
2408
+ y: baselineY - metrics.contentAscent - padding[0] - border[0] + shift.y,
2145
2409
  width: right - left,
2146
2410
  height: metrics.contentHeight + padding[0] + padding[2] + border[0] + border[2],
2147
2411
  style: nodeStyle,
@@ -2164,6 +2428,10 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2164
2428
  return { height: lineHeight, baseline: baselineY };
2165
2429
  }
2166
2430
 
2431
+ function isFloat(style) {
2432
+ return style?.float === "left" || style?.float === "right";
2433
+ }
2434
+
2167
2435
  function measureAtomic(ctx, box, availableWidth) {
2168
2436
  const placed = resolveHorizontal(ctx, box, 0, availableWidth, true);
2169
2437
  const result = dryLayout(ctx, box, placed.width);
@@ -2190,7 +2458,7 @@ function textMetrics(ctx, style) {
2190
2458
 
2191
2459
  function isDecorated(style) {
2192
2460
  return Boolean(style && ((style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)")
2193
- || borderWidths(style).some((width) => width > 0)));
2461
+ || style.backgroundImage || borderWidths(style).some((width) => width > 0)));
2194
2462
  }
2195
2463
 
2196
2464
  function borderWidths(style) {
@@ -2220,11 +2488,12 @@ function lineFragment(style, text, nodeIds, runs, x, y, width, height, baseline
2220
2488
  const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
2221
2489
 
2222
2490
  function cachedMeasure(measureText) {
2223
- const byStyle = new Map();
2491
+ const byStyle = new WeakMap();
2492
+ const defaultStyle = new Map();
2224
2493
  return (text, style) => {
2225
2494
  if (!text) return 0;
2226
- let cache = byStyle.get(style ?? null);
2227
- if (!cache) byStyle.set(style ?? null, cache = new Map());
2495
+ let cache = style && typeof style === "object" ? byStyle.get(style) : defaultStyle;
2496
+ if (!cache) byStyle.set(style, cache = new Map());
2228
2497
  let width = cache.get(text);
2229
2498
  if (width === undefined) cache.set(text, width = measureText(text, style ?? null));
2230
2499
  return width;
@@ -2256,7 +2525,49 @@ function cachedMetrics(fontMetrics) {
2256
2525
  };
2257
2526
  }
2258
2527
 
2259
- function imageDimensions(node, referenceWidth) {
2528
+ /**
2529
+ * An iframe's content box: like other replaced elements, but with no
2530
+ * intrinsic size or ratio of its own (its document's frame is drawn into
2531
+ * whatever box it gets), so each dimension falls back to 300x150 alone.
2532
+ */
2533
+ function frameDimensions(node, referenceWidth, referenceHeight) {
2534
+ const resolve = (cssValue, attribute, fallback, reference) => {
2535
+ const value = cssValue !== "auto" && cssValue != null ? resolveLength(cssValue, reference ?? NaN) : attribute;
2536
+ return Number.isFinite(value) ? value : fallback;
2537
+ };
2538
+ let width = resolve(node.style?.width, node.widthAttribute, 300, referenceWidth);
2539
+ const height = resolve(node.style?.height, node.heightAttribute, 150, referenceHeight);
2540
+ const maximum = node.style?.maxWidth !== "none" && node.style?.maxWidth != null ? resolveLength(node.style.maxWidth, referenceWidth) : NaN;
2541
+ if (Number.isFinite(maximum) && width > maximum) width = maximum;
2542
+ return { width: Math.max(0, width), height: Math.max(0, height) };
2543
+ }
2544
+
2545
+ /**
2546
+ * An inline <svg>'s size (SVG 2 §8.2): its CSS size, else its width and
2547
+ * height attributes, else 100% of its container's width; a missing height
2548
+ * follows the viewBox's ratio, or is 150px without one. Where the container
2549
+ * is still being measured (an indefinite width) it counts as 300px wide.
2550
+ */
2551
+ function svgDimensions(node, referenceWidth, referenceHeight) {
2552
+ const css = (value, reference) => value !== "auto" && value != null ? numberOrNull(resolveLength(value, reference ?? NaN)) : null;
2553
+ const ratio = node.svgRatio ?? null;
2554
+ let width = css(node.style?.width, referenceWidth) ?? node.widthAttribute;
2555
+ let height = css(node.style?.height, referenceHeight) ?? node.heightAttribute;
2556
+ if (width == null && height != null && ratio) width = height * ratio;
2557
+ if (width == null) width = Number.isFinite(referenceWidth) ? referenceWidth : 300;
2558
+ if (height == null) height = ratio ? width / ratio : 150;
2559
+ const maximum = node.style?.maxWidth !== "none" && node.style?.maxWidth != null ? numberOrNull(resolveLength(node.style.maxWidth, referenceWidth)) : null;
2560
+ if (maximum != null && width > maximum) {
2561
+ height = ratio ? maximum / ratio : height;
2562
+ width = maximum;
2563
+ }
2564
+ return { width: Math.max(0, width), height: Math.max(0, height) };
2565
+ }
2566
+
2567
+ /** Percentages in width are of `referenceWidth`, in height of `referenceHeight` (auto if indefinite). */
2568
+ function imageDimensions(node, referenceWidth, referenceHeight = null) {
2569
+ if (node.tagName === "IFRAME") return frameDimensions(node, referenceWidth, referenceHeight);
2570
+ if (node.tagName === "SVG") return svgDimensions(node, referenceWidth, referenceHeight);
2260
2571
  const intrinsicWidth = node.resource?.width ?? node.widthAttribute ?? 300;
2261
2572
  const intrinsicHeight = node.resource?.height ?? node.heightAttribute ?? 150;
2262
2573
  const ratio = intrinsicWidth / intrinsicHeight;
@@ -2266,7 +2577,7 @@ function imageDimensions(node, referenceWidth) {
2266
2577
  ? resolveLength(cssWidth, referenceWidth)
2267
2578
  : node.widthAttribute;
2268
2579
  let height = cssHeight !== "auto" && cssHeight != null
2269
- ? resolveLength(cssHeight, referenceWidth)
2580
+ ? resolveLength(cssHeight, referenceHeight ?? NaN)
2270
2581
  : node.heightAttribute;
2271
2582
  if (!Number.isFinite(width)) width = null;
2272
2583
  if (!Number.isFinite(height)) height = null;