buninu-browser 0.2.1 → 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 (72) 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 +16 -11
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/headless-shell.js +66 -19
  11. package/lib/input/hit-test.js +25 -7
  12. package/lib/layout/text-layout.js +196 -49
  13. package/lib/log.js +5 -2
  14. package/lib/media/byte-pipe.js +82 -0
  15. package/lib/media/position-audio.js +42 -0
  16. package/lib/network/network-service.js +161 -14
  17. package/lib/paint/display-list.js +300 -14
  18. package/lib/paint/gradient.js +11 -5
  19. package/lib/render-tree/index.js +17 -2
  20. package/lib/render-tree/svg.js +2 -1
  21. package/lib/renderer/bindings.js +985 -68
  22. package/lib/renderer/canvas-2d.js +92 -0
  23. package/lib/renderer/host.js +158 -8
  24. package/lib/renderer/interfaces.js +215 -0
  25. package/lib/renderer/main.js +160 -12
  26. package/lib/renderer/media-source.js +819 -0
  27. package/lib/renderer/messaging.js +411 -0
  28. package/lib/renderer/native-functions.js +113 -0
  29. package/lib/renderer/page-realm.js +1405 -198
  30. package/lib/renderer/page-renderer.js +1736 -193
  31. package/lib/renderer/rejections.js +31 -0
  32. package/lib/renderer/rewrite-worker.js +22 -0
  33. package/lib/renderer/script-rewrite.js +805 -57
  34. package/lib/renderer/trusted-types.js +129 -0
  35. package/lib/renderer/workers.js +327 -0
  36. package/lib/screenshot-cli.js +2 -0
  37. package/lib/style/computed-style.js +162 -22
  38. package/lib/user-agent.js +14 -2
  39. package/package.json +4 -1
  40. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  41. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  42. package/patches/happy-dom/README.md +26 -0
  43. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  44. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  45. package/patches/ses/README.md +4 -0
  46. package/test/canvas-2d.test.js +55 -0
  47. package/test/compositor.test.js +3 -1
  48. package/test/computed-style.test.js +38 -1
  49. package/test/display-list.test.js +74 -1
  50. package/test/download.test.js +35 -0
  51. package/test/fixtures/long-video.mp4 +0 -0
  52. package/test/fixtures/media-sync.js +57 -0
  53. package/test/happy-dom-platform-adapters.test.js +25 -0
  54. package/test/headless-shell.test.js +1 -1
  55. package/test/hit-test.test.js +25 -1
  56. package/test/media-source.test.js +156 -0
  57. package/test/media-sync.test.js +12 -0
  58. package/test/mobile.test.js +2 -2
  59. package/test/navigation.test.js +61 -0
  60. package/test/network-service.test.js +14 -0
  61. package/test/page-scripts.test.js +1045 -7
  62. package/test/position-audio.test.js +67 -0
  63. package/test/positioning.test.js +95 -1
  64. package/test/pulse-client.test.js +69 -0
  65. package/test/renderer-host.test.js +5 -2
  66. package/test/screenshot-cli.test.js +25 -1
  67. package/test/script-rewrite.test.js +111 -6
  68. package/test/setup.js +5 -0
  69. package/test/user-agent.test.js +60 -4
  70. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  71. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  72. 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;
@@ -51,6 +52,9 @@ export function layoutText(tree, options = {}) {
51
52
  stackingContext: ROOT_LAYER,
52
53
  layerCount: 1,
53
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,
54
58
  // Static positions recorded for out-of-flow boxes, in order (see
55
59
  // cacheBlockResult), and those boxes by node for replays.
56
60
  staticPositions: [],
@@ -79,6 +83,7 @@ export function layoutText(tree, options = {}) {
79
83
  // establishes the BFC, so body margins collapse with its children's.
80
84
  const horizontal = resolveHorizontal(ctx, root, x, containingWidth, false);
81
85
  const rootY = y + strutValue(topStrut(root));
86
+ ctx.containing = { width: containingWidth, height: ctx.initialContainingBlock.height || null };
82
87
  const result = layoutBlock(ctx, root, horizontal.x, horizontal.width, rootY, collapsesThroughTop(root));
83
88
  const height = rootY + result.height + strutValue(bottomStrut(root));
84
89
 
@@ -317,9 +322,12 @@ function hasInlineContent(items) {
317
322
  * then applies relative offsets and translations to everything it produced.
318
323
  */
319
324
  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
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;
323
331
  const boxStart = ctx.boxes.length;
324
332
  const fragmentStart = ctx.fragments.length;
325
333
  const layerStart = ctx.layerCount;
@@ -343,10 +351,10 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
343
351
  if (box.absolutes?.length) layoutAbsolutes(ctx, box, record);
344
352
  let dx = 0;
345
353
  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);
354
+ const offset = relativeOffset(style, containing ?? { width, height: null });
355
+ if (offset) {
356
+ dx = offset.x;
357
+ dy = offset.y;
350
358
  }
351
359
  if (style?.transform) {
352
360
  dx += numberOrZero(resolveLength(style.transform.x, record.width));
@@ -357,14 +365,29 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
357
365
  if (dx || dy) shiftOutput(ctx, boxStart, fragmentStart, dx, dy);
358
366
  ctx.layer = previousLayer;
359
367
  ctx.stackingContext = previousContext;
360
- cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result, {
368
+ cacheBlockResult(ctx, box, key, x, y, result, {
361
369
  boxStart, fragmentStart, layerStart, staticStart, layer: previousLayer, context: previousContext,
362
370
  });
363
371
  return result;
364
372
  }
365
373
 
366
- function blockCacheKey(width, absorbTop, forcedHeight) {
367
- return `${width}\n${absorbTop ? 1 : 0}\n${forcedHeight ?? "auto"}`;
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;
368
391
  }
369
392
 
370
393
  /** Where an out-of-flow box would have been in flow; recorded so that a replayed block sets it again. */
@@ -374,9 +397,9 @@ function setStaticPosition(ctx, box, position) {
374
397
  }
375
398
 
376
399
  /** Replays a retained formatting-context result at its new flow position. */
377
- function cachedBlockResult(ctx, box, width, y, x, absorbTop, forcedHeight) {
400
+ function cachedBlockResult(ctx, box, key, y, x) {
378
401
  if (!ctx.blockResults || box.anonymous) return null;
379
- const cached = ctx.blockResults.get(box.node)?.get(blockCacheKey(width, absorbTop, forcedHeight));
402
+ const cached = ctx.blockResults.get(box.node)?.get(key);
380
403
  if (!cached) return null;
381
404
  const dx = x - cached.x;
382
405
  const dy = y - cached.y;
@@ -423,7 +446,7 @@ function cachedBlockResult(ctx, box, width, y, x, absorbTop, forcedHeight) {
423
446
  * static positions it recorded go along. Output in a layer made before the
424
447
  * block (which should not occur) is not retained.
425
448
  */
426
- function cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result, start) {
449
+ function cacheBlockResult(ctx, box, key, x, y, result, start) {
427
450
  if (!ctx.blockResults || box.anonymous) return;
428
451
  const boxes = ctx.boxes.slice(start.boxStart);
429
452
  const fragments = ctx.fragments.slice(start.fragmentStart);
@@ -451,10 +474,10 @@ function cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result
451
474
  if (!entries) ctx.blockResults.set(box.node, entries = new Map());
452
475
  // A node normally sees one containing width. Bound unusual cases such as a
453
476
  // reused table cell so the retained cache cannot grow without limit.
454
- if (!entries.has(blockCacheKey(width, absorbTop, forcedHeight)) && entries.size >= 4) {
477
+ if (!entries.has(key) && entries.size >= 4) {
455
478
  entries.delete(entries.keys().next().value);
456
479
  }
457
- entries.set(blockCacheKey(width, absorbTop, forcedHeight), {
480
+ entries.set(key, {
458
481
  x,
459
482
  y,
460
483
  result: { ...result },
@@ -576,7 +599,7 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
576
599
  const record = { nodeId: box.node.id, x, y, width, height: 0, style };
577
600
  ctx.boxes.push(record);
578
601
  if (box.replaced) {
579
- const dimensions = imageDimensions(box.node, width);
602
+ const dimensions = imageDimensions(box.node, width, ctx.containing?.height);
580
603
  const imageX = x + border[3] + padding[3];
581
604
  const imageY = y + border[0] + padding[0];
582
605
  ctx.fragments.push(lineFragment(box.style, "", [box.node.id], [{
@@ -608,6 +631,21 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
608
631
  let columnItems = null;
609
632
  let gridResult = null;
610
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
+ };
611
649
 
612
650
  if (box.table) {
613
651
  const result = layoutTable(ctx, box, contentX, contentWidth, contentTop);
@@ -639,7 +677,9 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
639
677
  : child.style.alignSelf;
640
678
  const shrink = !["normal", "stretch"].includes(alignment);
641
679
  const placed = resolveHorizontal(ctx, child, contentX, contentWidth, shrink);
642
- 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";
643
683
  let childX = placed.x;
644
684
  if (shrink && !autoMargins) {
645
685
  const free = contentWidth - placed.width - placed.marginLeft - placed.marginRight;
@@ -685,16 +725,12 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
685
725
  contentBottom = collapsesThroughBottom(box) ? cursor : cursor + strutValue(pending ?? ZERO_STRUT);
686
726
  }
687
727
 
728
+ ctx.containing = previousContaining;
688
729
  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
730
  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);
731
+ const maxHeight = style?.maxHeight === "none" ? null : resolveHeight(style?.maxHeight);
696
732
  if (maxHeight != null) contentHeight = Math.min(contentHeight, toContentHeight(maxHeight));
697
- const minHeight = numberOrNull(style?.minHeight);
733
+ const minHeight = resolveHeight(style?.minHeight);
698
734
  if (minHeight != null) contentHeight = Math.max(contentHeight, toContentHeight(minHeight));
699
735
  if (columnItems?.length) justifyColumn(ctx, columnItems, style?.justifyContent, contentHeight - (contentBottom - contentTop));
700
736
  if (gridResult) alignGridContent(ctx, gridResult, style?.alignContent, contentHeight - (contentBottom - contentTop));
@@ -817,13 +853,9 @@ function layoutFlexRow(ctx, box, x, width, y) {
817
853
  }
818
854
  let crossSize = Math.max(0, ...lineItems.map((item) => item.height + item.marginTop + item.marginBottom));
819
855
  // 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
- }
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;
827
859
 
828
860
  // Main axis: auto margins absorb free space first, then justify-content.
829
861
  let free = width - gaps - lineItems.reduce((sum, item) => sum + item.target + item.marginLeft + item.marginRight, 0);
@@ -1730,6 +1762,7 @@ function maxContentWidth(ctx, box) {
1730
1762
  }
1731
1763
  const maxWidth = numberOrNull(style?.maxWidth);
1732
1764
  if (maxWidth != null) width = Math.min(width, style.boxSizing === "border-box" ? maxWidth : maxWidth + extra);
1765
+ width = atLeastMinWidth(style, width, extra);
1733
1766
  ctx.maxContent.set(cacheKey, width);
1734
1767
  return width;
1735
1768
  }
@@ -1763,10 +1796,17 @@ function minContentWidth(ctx, box) {
1763
1796
  ? inlineMinContentWidth(ctx, child)
1764
1797
  : outer(child))) + extra;
1765
1798
  }
1799
+ width = atLeastMinWidth(style, width, extra);
1766
1800
  ctx.minContent.set(cacheKey, width);
1767
1801
  return width;
1768
1802
  }
1769
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
+
1770
1810
  function verticalMargins(style) {
1771
1811
  const margin = style?.margin ?? ZERO_BOX;
1772
1812
  return { top: numberOrZero(margin[0]), bottom: numberOrZero(margin[2]) };
@@ -1831,6 +1871,8 @@ function layoutInline(ctx, ifc, x, width, y) {
1831
1871
  }
1832
1872
  return atom;
1833
1873
  };
1874
+ // Percentage sizes of inline images.
1875
+ atomFor.containing = ctx.containing;
1834
1876
  const lines = breakLines(ctx, paragraph, width, atomFor);
1835
1877
  let cursor = y;
1836
1878
  let baseline = null;
@@ -2042,9 +2084,26 @@ function truncateForEllipsis(ctx, style, paragraph, segments, line, width) {
2042
2084
  return { text, width: ellipsisWidth, run: lastText.run, metrics: textMetrics(ctx, lastText.run.style) };
2043
2085
  }
2044
2086
 
2087
+ /** Space beside an inline replaced element's content: its margins, borders and padding. */
2045
2088
  function inlineImageMargins(node) {
2046
2089
  const margin = node.style?.margin ?? ZERO_BOX;
2047
- 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
+ };
2048
2107
  }
2049
2108
 
2050
2109
  /**
@@ -2092,7 +2151,8 @@ function runSliceWidth(ctx, run, start, end, text, atomFor) {
2092
2151
  if (end <= start) return 0;
2093
2152
  if (run.kind === "image") {
2094
2153
  const margins = inlineImageMargins(run.node);
2095
- 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;
2096
2156
  }
2097
2157
  if (run.kind === "atomic") {
2098
2158
  const atom = atomFor(run);
@@ -2172,13 +2232,20 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2172
2232
  const strut = textMetrics(ctx, style);
2173
2233
  let ascent = strut.ascent;
2174
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;
2175
2237
  for (const segment of segments) {
2176
2238
  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);
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);
2180
2247
  ascent = Math.max(ascent, -segment.imageTop);
2181
- descent = Math.max(descent, segment.imageTop + segment.dimensions.height);
2248
+ descent = Math.max(descent, segment.imageTop + outerHeight);
2182
2249
  } else if (run.kind === "atomic") {
2183
2250
  const atom = atomFor(run);
2184
2251
  const atomAscent = atom.marginTop + (atom.baseline ?? atom.height) ;
@@ -2192,7 +2259,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2192
2259
  }
2193
2260
  }
2194
2261
  const baselineY = y + ascent;
2195
- const lineHeight = ascent + descent;
2262
+ const lineHeight = Math.max(ascent + descent, floatHeight);
2196
2263
 
2197
2264
  // Horizontal alignment.
2198
2265
  const alignment = style?.textAlign;
@@ -2205,6 +2272,22 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2205
2272
  const edges = new Map();
2206
2273
  const runs = [];
2207
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
+ };
2208
2291
  // Right floats (approximated as atomic inlines) go to the end of the line.
2209
2292
  const rightFloats = segments.filter((segment) => segment.run.kind === "atomic" && segment.run.style?.float === "right");
2210
2293
  const rightFloatWidth = rightFloats.reduce((sum, segment) => sum + segment.width, 0);
@@ -2223,6 +2306,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2223
2306
  }
2224
2307
  }
2225
2308
  const segmentWidth = segment.width;
2309
+ const shift = runOffset(run);
2226
2310
  const common = {
2227
2311
  nodeId: run.nodeId,
2228
2312
  text: run.kind === "text" ? paragraph.text.slice(segment.start, segment.end) : "",
@@ -2231,11 +2315,27 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2231
2315
  style: run.style ?? style,
2232
2316
  };
2233
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
+ }
2234
2334
  runs.push({
2235
2335
  ...common,
2236
2336
  type: "image",
2237
- x: cursor + inlineImageMargins(run.node).left,
2238
- y: baselineY + segment.imageTop,
2337
+ x: imageX,
2338
+ y: imageY,
2239
2339
  width: segment.dimensions.width,
2240
2340
  height: segment.dimensions.height,
2241
2341
  resource: run.node.resource,
@@ -2245,14 +2345,14 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2245
2345
  });
2246
2346
  } else if (run.kind === "atomic") {
2247
2347
  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 });
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 });
2251
2351
  } else {
2252
2352
  runs.push({
2253
2353
  ...common,
2254
- x: cursor,
2255
- y: baselineY - segment.metrics.contentAscent,
2354
+ x: cursor + shift.x,
2355
+ y: baselineY - segment.metrics.contentAscent + shift.y,
2256
2356
  width: Math.max(1, segmentWidth),
2257
2357
  height: null,
2258
2358
  });
@@ -2301,10 +2401,11 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2301
2401
  const metrics = textMetrics(ctx, nodeStyle);
2302
2402
  const left = edge.left ?? edge.first;
2303
2403
  const right = edge.right ?? edge.last;
2404
+ const shift = offsets.get(node) ?? ZERO_OFFSET;
2304
2405
  ctx.boxes.push({
2305
2406
  nodeId: node.id,
2306
- x: left,
2307
- y: baselineY - metrics.contentAscent - padding[0] - border[0],
2407
+ x: left + shift.x,
2408
+ y: baselineY - metrics.contentAscent - padding[0] - border[0] + shift.y,
2308
2409
  width: right - left,
2309
2410
  height: metrics.contentHeight + padding[0] + padding[2] + border[0] + border[2],
2310
2411
  style: nodeStyle,
@@ -2327,6 +2428,10 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2327
2428
  return { height: lineHeight, baseline: baselineY };
2328
2429
  }
2329
2430
 
2431
+ function isFloat(style) {
2432
+ return style?.float === "left" || style?.float === "right";
2433
+ }
2434
+
2330
2435
  function measureAtomic(ctx, box, availableWidth) {
2331
2436
  const placed = resolveHorizontal(ctx, box, 0, availableWidth, true);
2332
2437
  const result = dryLayout(ctx, box, placed.width);
@@ -2353,7 +2458,7 @@ function textMetrics(ctx, style) {
2353
2458
 
2354
2459
  function isDecorated(style) {
2355
2460
  return Boolean(style && ((style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)")
2356
- || borderWidths(style).some((width) => width > 0)));
2461
+ || style.backgroundImage || borderWidths(style).some((width) => width > 0)));
2357
2462
  }
2358
2463
 
2359
2464
  function borderWidths(style) {
@@ -2420,7 +2525,49 @@ function cachedMetrics(fontMetrics) {
2420
2525
  };
2421
2526
  }
2422
2527
 
2423
- 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);
2424
2571
  const intrinsicWidth = node.resource?.width ?? node.widthAttribute ?? 300;
2425
2572
  const intrinsicHeight = node.resource?.height ?? node.heightAttribute ?? 150;
2426
2573
  const ratio = intrinsicWidth / intrinsicHeight;
@@ -2430,7 +2577,7 @@ function imageDimensions(node, referenceWidth) {
2430
2577
  ? resolveLength(cssWidth, referenceWidth)
2431
2578
  : node.widthAttribute;
2432
2579
  let height = cssHeight !== "auto" && cssHeight != null
2433
- ? resolveLength(cssHeight, referenceWidth)
2580
+ ? resolveLength(cssHeight, referenceHeight ?? NaN)
2434
2581
  : node.heightAttribute;
2435
2582
  if (!Number.isFinite(width)) width = null;
2436
2583
  if (!Number.isFinite(height)) height = null;
package/lib/log.js CHANGED
@@ -34,9 +34,11 @@ export function installLog(path) {
34
34
  logPath = path;
35
35
  for (const level of ["log", "info", "warn", "error", "debug", "trace"]) {
36
36
  const original = console[level].bind(console);
37
+ // The file first: whoever sees a line on the console (and then stops
38
+ // this process) finds it in the log too.
37
39
  console[level] = (...args) => {
38
- original(...args);
39
40
  writeLog(level, formatWithOptions({ colors: false }, ...args));
41
+ original(...args);
40
42
  };
41
43
  }
42
44
  writeLog("log", `started pid ${process.pid}, bun ${Bun.version}, cwd ${process.cwd()}, args ${JSON.stringify(Bun.argv.slice(2))}`);
@@ -69,13 +71,14 @@ export async function pipeToLog(stream, source, echo) {
69
71
  let pending = "";
70
72
  try {
71
73
  for await (const chunk of stream) {
72
- echo(chunk);
73
74
  pending += decoder.decode(chunk, { stream: true });
74
75
  const end = pending.lastIndexOf("\n");
75
76
  if (end >= 0) {
76
77
  writeLog(source, pending.slice(0, end));
77
78
  pending = pending.slice(end + 1);
78
79
  }
80
+ // Echoed after the complete lines are in the log (see installLog).
81
+ echo(chunk);
79
82
  }
80
83
  } catch {
81
84
  // The child went away mid-read.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A growing media buffer shared by a progressive download and by MSE appends.
3
+ *
4
+ * wasmpeg can only open a decoder on a complete snapshot, so playback keeps
5
+ * this concatenation, reopens when the decoder runs off the end of what it
6
+ * was opened on, and seeks back. `generation` changes on every append and
7
+ * on finish/fail, which is how a decoder knows its snapshot is stale.
8
+ * `waitFor` resolves when `size` reaches the mark or the pipe is finished.
9
+ */
10
+ export function createBytePipe() {
11
+ const chunks = [];
12
+ let size = 0;
13
+ let generation = 0;
14
+ let done = false;
15
+ let error = null;
16
+ /** @type {Array<() => void>} */
17
+ const waiters = [];
18
+
19
+ const wake = () => {
20
+ for (const waiter of waiters.splice(0)) waiter();
21
+ };
22
+
23
+ return {
24
+ append(bytes) {
25
+ if (done || bytes == null) return;
26
+ const chunk = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
27
+ if (!chunk.byteLength) return;
28
+ chunks.push(chunk);
29
+ size += chunk.byteLength;
30
+ generation += 1;
31
+ wake();
32
+ },
33
+ finish() {
34
+ if (done) return;
35
+ done = true;
36
+ generation += 1;
37
+ wake();
38
+ },
39
+ fail(reason) {
40
+ if (done) return;
41
+ error = reason instanceof Error ? reason : new Error(String(reason ?? "media fetch failed"));
42
+ done = true;
43
+ generation += 1;
44
+ wake();
45
+ },
46
+ /** One contiguous snapshot. Collapses chunks so the next call is cheap. */
47
+ bytes() {
48
+ if (chunks.length === 1) return chunks[0];
49
+ const out = new Uint8Array(size);
50
+ let offset = 0;
51
+ for (const chunk of chunks) {
52
+ out.set(chunk, offset);
53
+ offset += chunk.byteLength;
54
+ }
55
+ chunks.length = 0;
56
+ if (out.byteLength) chunks.push(out);
57
+ return out;
58
+ },
59
+ get size() {
60
+ return size;
61
+ },
62
+ get generation() {
63
+ return generation;
64
+ },
65
+ get done() {
66
+ return done;
67
+ },
68
+ get error() {
69
+ return error;
70
+ },
71
+ waitFor(mark) {
72
+ if (size >= mark || done) return Promise.resolve();
73
+ return new Promise((resolve) => {
74
+ const check = () => {
75
+ if (size >= mark || done) resolve();
76
+ else waiters.push(check);
77
+ };
78
+ waiters.push(check);
79
+ });
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,42 @@
1
+ /** Advance a PCM decoder to an exact sample cursor, never playing discarded PCM.
2
+ * Reopened snapshots start at sample zero, including their pending first chunk.
3
+ * The caller holds output requests until this transaction has completed.
4
+ */
5
+ export async function advanceAudio(getSound, seconds, { reopen, needsMore, active = () => true }) {
6
+ while (active()) {
7
+ const sound = getSound();
8
+ const { sampleRate, channels } = sound.decoder;
9
+ const target = Math.max(0, Math.round(seconds * sampleRate));
10
+ if (sound.position === target) return true;
11
+ if (sound.position > target) {
12
+ if (!await reopen()) return false;
13
+ continue;
14
+ }
15
+ // This is also the first chunk of EVERY replacement decoder. Skipping it
16
+ // used to leave the opening sound queued at exact chunk boundaries.
17
+ let samples = sound.pending;
18
+ sound.pending = null;
19
+ if (!samples) {
20
+ try { samples = sound.decoder.nextSamples(); }
21
+ catch (error) {
22
+ if (!needsMore(sound)) throw error;
23
+ }
24
+ }
25
+ if (samples?.length) {
26
+ const take = Math.min(target - sound.position, samples.length / channels);
27
+ sound.position += take;
28
+ if (take * channels < samples.length) sound.pending = samples.subarray(take * channels);
29
+ continue;
30
+ }
31
+ if (!needsMore(sound)) return false;
32
+ const pipe = sound.pipe;
33
+ if (pipe.error) throw pipe.error;
34
+ // Data may already have arrived while this snapshot was being decoded.
35
+ if (pipe.generation === sound.openedGeneration) await pipe.waitFor(pipe.size + 1);
36
+ if (!active()) return false;
37
+ if (pipe.error) throw pipe.error;
38
+ if (pipe.generation === sound.openedGeneration) return false;
39
+ if (!await reopen()) return false;
40
+ }
41
+ return false;
42
+ }