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
@@ -15,19 +15,44 @@
15
15
  * Image resources are referenced by index into the non-enumerable `images`
16
16
  * array so `JSON.stringify(displayList)` stays a plain debug/reftest dump.
17
17
  */
18
- import { gradientLayers, resolveGradient } from "./gradient.js";
18
+ import { backgroundLayers, gradientLayers, isGradient, resolveGradient, splitTopLevel } from "./gradient.js";
19
19
 
20
20
  const TRANSPARENT = "rgba(0, 0, 0, 0)";
21
+ const CSS_URL = /^url\(\s*(["']?)(.*?)\1\s*\)$/is;
22
+ const LENGTH = /^(-?(?:\d+\.?\d*|\.\d+))(px|%|em|rem)?$/;
21
23
 
22
24
  /**
23
25
  * @param layout the layout result
24
26
  * @param tree the render tree it was laid out from; without it, visibility,
25
27
  * opacity and overflow clipping of ancestors are not applied.
26
28
  */
27
- export function buildDisplayList(layout, tree = null, resources = null) {
29
+ /**
30
+ * @param backgroundResource(url) the decoded image a background url() names,
31
+ * or null while it has not arrived (it paints nothing until then)
32
+ */
33
+ export function buildDisplayList(layout, tree = null, resources = null, backgroundResource = null, scrollOffsetOf = null) {
28
34
  const items = [];
29
35
  const images = [];
30
- const effects = paintEffects(layout, tree);
36
+ // Every box and run in paint order, painted or not: what a point hits (see hitTarget).
37
+ const hits = [];
38
+ // How far each scroll container's content reaches (its scrollable overflow),
39
+ // unscrolled: all of it, and the in-flow part, which alone gets the end
40
+ // padding (CSS Overflow 3 §2.2); absolutely positioned boxes count as they are.
41
+ const scrollers = new Map();
42
+ const reach = (effect, rect) => {
43
+ // Content of a nested scroll container counts for that one only.
44
+ const id = effect.scrollers?.[0];
45
+ if (id == null) return;
46
+ const extent = scrollers.get(id) ?? { right: -Infinity, bottom: -Infinity, flowRight: -Infinity, flowBottom: -Infinity };
47
+ extent.right = Math.max(extent.right, rect.x + rect.width);
48
+ extent.bottom = Math.max(extent.bottom, rect.y + rect.height);
49
+ if (!effect.outOfFlow) {
50
+ extent.flowRight = Math.max(extent.flowRight, rect.x + rect.width);
51
+ extent.flowBottom = Math.max(extent.flowBottom, rect.y + rect.height);
52
+ }
53
+ scrollers.set(id, extent);
54
+ };
55
+ const effects = paintEffects(layout, tree, scrollOffsetOf);
31
56
  // Text styled with background-clip:text takes its gradient from the owning box.
32
57
  const textGradients = new Map();
33
58
  for (const box of layout.boxes) {
@@ -84,6 +109,10 @@ export function buildDisplayList(layout, tree = null, resources = null) {
84
109
  if (!effect) return;
85
110
  const start = items.length;
86
111
  const rect = { x: box.x, y: box.y, width: box.width, height: box.height };
112
+ hits.push({
113
+ nodeId: box.nodeId, rect: effect.transform ? transformRect(effect.transform, rect) : rect, clipRect: effect.clipRect, fixed: effect.fixed,
114
+ });
115
+ reach(effect, rect);
87
116
  const radii = cornerRadii(style, rect);
88
117
  for (const shadow of style.boxShadow ?? []) {
89
118
  if (shadow.inset || shadow.color === TRANSPARENT) continue;
@@ -108,15 +137,26 @@ export function buildDisplayList(layout, tree = null, resources = null) {
108
137
  bounds: inflate(shadowRect, shadow.blur),
109
138
  });
110
139
  }
111
- const gradients = gradientLayers(style.backgroundImage)
112
- .map((source) => resolveGradient(source, rect, style.fontSize))
113
- .filter(Boolean);
114
140
  if (style.backgroundClip !== "text") {
115
141
  if (style.backgroundColor && style.backgroundColor !== TRANSPARENT) {
116
142
  items.push({ op: "fillRect", nodeId: box.nodeId, rect, radii, color: style.backgroundColor, bounds: rect });
117
143
  }
118
- for (const gradient of gradients.reverse()) {
119
- items.push({ op: "fillRect", nodeId: box.nodeId, rect, radii, gradient, bounds: rect });
144
+ // Background layers, bottom first.
145
+ const layers = backgroundLayers(style.backgroundImage);
146
+ for (let index = layers.length - 1; index >= 0; index--) {
147
+ const layer = layers[index];
148
+ if (isGradient(layer)) {
149
+ const gradient = resolveGradient(layer, rect, style.fontSize);
150
+ if (gradient) items.push({ op: "fillRect", nodeId: box.nodeId, rect, radii, gradient, bounds: rect });
151
+ continue;
152
+ }
153
+ const url = CSS_URL.exec(layer)?.[2];
154
+ const resource = url && backgroundResource?.(url);
155
+ if (!resource?.image) continue;
156
+ const placed = backgroundTile(style, rect, index, resource);
157
+ if (!placed) continue;
158
+ images.push(resource);
159
+ items.push({ op: "drawBackgroundImage", nodeId: box.nodeId, image: images.length - 1, rect, radii, ...placed });
120
160
  }
121
161
  }
122
162
  const widths = style.borderWidths ?? [0, 0, 0, 0];
@@ -142,6 +182,19 @@ export function buildDisplayList(layout, tree = null, resources = null) {
142
182
  const effect = effects(run.nodeId, run.type === "image" ? "box" : "text");
143
183
  if (!effect) continue;
144
184
  const start = items.length;
185
+ const hitRect = {
186
+ x: run.x,
187
+ y: run.type === "image" ? run.y ?? fragment.y : fragment.y,
188
+ width: run.width,
189
+ height: run.type === "image" ? run.height : fragment.height,
190
+ };
191
+ hits.push({
192
+ nodeId: run.nodeId,
193
+ rect: effect.transform ? transformRect(effect.transform, hitRect) : hitRect,
194
+ clipRect: effect.clipRect,
195
+ fixed: effect.fixed,
196
+ });
197
+ reach(effect, hitRect);
145
198
  paintRun(fragment, run);
146
199
  applyEffect(items, start, effect);
147
200
  }
@@ -162,7 +215,8 @@ export function buildDisplayList(layout, tree = null, resources = null) {
162
215
  if (run.video || run.audio) {
163
216
  const rect = { x: run.x, y, width: run.width, height: run.height };
164
217
  images.push(currentResource);
165
- items.push({ op: run.video ? "drawVideo" : "drawAudio", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
218
+ const controls = Boolean(tree?.nodesById.get(run.nodeId)?.domNode?.hasAttribute("controls"));
219
+ items.push({ op: run.video ? "drawVideo" : "drawAudio", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect, controls });
166
220
  return;
167
221
  }
168
222
  if (!currentResource?.image) return;
@@ -204,17 +258,51 @@ export function buildDisplayList(layout, tree = null, resources = null) {
204
258
  items: Object.freeze(kept.map(deepFreeze)),
205
259
  };
206
260
  Object.defineProperty(displayList, "images", { value: Object.freeze(images), enumerable: false });
261
+ Object.defineProperty(displayList, "hits", { value: Object.freeze(hits), enumerable: false });
262
+ Object.defineProperty(displayList, "scrollers", { value: scrollers, enumerable: false });
263
+ // How far the page's content reaches (its scrollable overflow, CSS Overflow 3 §2.2):
264
+ // every box and run, cut to the clips around it; fixed content does not count.
265
+ let right = 0;
266
+ let bottom = 0;
267
+ for (const { rect, clipRect, fixed } of hits) {
268
+ if (fixed) continue;
269
+ const reached = clipRect ? intersectRects(rect, clipRect) : rect;
270
+ if (!(reached.width > 0 && reached.height > 0)) continue;
271
+ right = Math.max(right, reached.x + reached.width);
272
+ bottom = Math.max(bottom, reached.y + reached.height);
273
+ }
274
+ Object.defineProperty(displayList, "extent", { value: Object.freeze({ width: right, height: bottom }), enumerable: false });
207
275
  return Object.freeze(displayList);
208
276
  }
209
277
 
278
+ /**
279
+ * The render node id of the topmost box or run at a viewport point, in paint
280
+ * order, or null. `skip(nodeId)` passes over nodes that take no pointer
281
+ * events. Fixed content stays put as the page scrolls.
282
+ */
283
+ export function hitTarget(displayList, x, y, scroll, skip = () => false) {
284
+ const hits = displayList?.hits ?? [];
285
+ for (let index = hits.length - 1; index >= 0; index--) {
286
+ const { nodeId, rect, clipRect, fixed } = hits[index];
287
+ const pointX = fixed ? x : x + scroll.x;
288
+ const pointY = fixed ? y : y + scroll.y;
289
+ if (pointX < rect.x || pointX >= rect.x + rect.width || pointY < rect.y || pointY >= rect.y + rect.height) continue;
290
+ if (clipRect && (pointX < clipRect.x || pointX >= clipRect.x + clipRect.width
291
+ || pointY < clipRect.y || pointY >= clipRect.y + clipRect.height)) continue;
292
+ if (skip(nodeId)) continue;
293
+ return nodeId;
294
+ }
295
+ return null;
296
+ }
297
+
210
298
  /**
211
299
  * Painting effects of ancestors (CSS Visual Effects / Overflow): visibility,
212
300
  * group opacity (approximated per item), and clipping by overflow, clip and
213
301
  * clip-path rectangles. Returns effects(nodeId, "box" | "text") → null when
214
302
  * the item is not painted, else { opacity, clipRect }.
215
303
  */
216
- function paintEffects(layout, tree) {
217
- const none = { opacity: 1, clipRect: null, fixed: false };
304
+ function paintEffects(layout, tree, scrollOffsetOf = null) {
305
+ const none = { opacity: 1, clipRect: null, fixed: false, transform: null, scrollers: null, outOfFlow: false };
218
306
  if (!tree?.root) return () => none;
219
307
  const parents = new Map();
220
308
  (function walk(node) {
@@ -267,6 +355,19 @@ function paintEffects(layout, tree) {
267
355
  }
268
356
  return rect;
269
357
  });
358
+ // A scale an element's transform applies to itself and its contents, about its transform-origin.
359
+ const ownTransform = memo((node) => {
360
+ const transform = node.style?.transform;
361
+ const box = boxes.get(node.id);
362
+ if (!box || !transform?.scaleX || (transform.scaleX === 1 && transform.scaleY === 1)) return null;
363
+ const origin = transformOrigin(node.style.transformOrigin, box);
364
+ return {
365
+ a: transform.scaleX,
366
+ d: transform.scaleY,
367
+ e: origin.x * (1 - transform.scaleX),
368
+ f: origin.y * (1 - transform.scaleY),
369
+ };
370
+ });
270
371
 
271
372
  return (nodeId, kind) => {
272
373
  const key = `${kind}:${nodeId}`;
@@ -282,24 +383,74 @@ function paintEffects(layout, tree) {
282
383
  // Overflow clipping skips ancestors that are not in the containing-block
283
384
  // chain of an absolutely positioned box; fixed boxes escape entirely.
284
385
  let escaping = null;
386
+ // Clips by the element that applies them: a scaled ancestor moves them too.
387
+ const clips = [];
388
+ const chain = [];
389
+ // Scroll containers this content scrolls with: those whose clip applies to it.
390
+ const scrolledBy = new Set();
391
+ // Whether an absolutely positioned box lies between the content and
392
+ // its innermost scroll container.
393
+ let positionedOut = false;
394
+ let outOfFlow = false;
285
395
  for (let node = owner; node; node = parents.get(node.id)) {
396
+ chain.push(node);
286
397
  const style = node.style;
287
398
  if (!style) continue;
288
399
  if (style.position === "fixed") fixed = true;
289
400
  opacity *= style.opacity ?? 1;
290
- clipRect = intersectRects(clipRect, shapeClip(node));
401
+ clips.push([node, shapeClip(node)]);
291
402
  const ownContent = kind === "text" || node !== owner;
292
403
  if (ownContent && escaping !== "fixed") {
293
404
  const positioned = style.position && style.position !== "static";
294
405
  if (!escaping || positioned) {
295
- clipRect = intersectRects(clipRect, overflowClip(node));
406
+ const clip = overflowClip(node);
407
+ const radii = clip ? cornerRadii(style, boxes.get(node.id)) : null;
408
+ if (radii) {
409
+ const [top, right, bottom, left] = style.borderWidths ?? [0, 0, 0, 0];
410
+ radii.x = Math.max(0, radii.x - Math.max(left, right));
411
+ radii.y = Math.max(0, radii.y - Math.max(top, bottom));
412
+ }
413
+ clips.push([node, clip, radii]);
414
+ if (isScroller(style)) {
415
+ if (!scrolledBy.size) outOfFlow = positionedOut;
416
+ scrolledBy.add(node);
417
+ }
296
418
  escaping = null;
297
419
  }
298
420
  }
421
+ if (style.position === "absolute" || style.position === "fixed") positionedOut = true;
299
422
  if (style.position === "fixed") escaping = "fixed";
300
423
  else if (style.position === "absolute" && escaping !== "fixed") escaping = "absolute";
301
424
  }
302
- result = opacity <= 0 ? null : { opacity, clipRect, fixed };
425
+ // The transforms from the root down to each element: scales, and the
426
+ // scroll offsets of the scroll containers it is the content of.
427
+ let transform = null;
428
+ const transforms = new Map();
429
+ for (let index = chain.length - 1; index >= 0; index--) {
430
+ const node = chain[index];
431
+ const own = ownTransform(node);
432
+ if (own) transform = transform ? composeTransforms(transform, own) : own;
433
+ transforms.set(node, transform);
434
+ const offset = scrolledBy.has(node) ? scrollOffsetOf?.(node.domNode) : null;
435
+ if (offset && (offset.x || offset.y)) {
436
+ const scrolled = { a: 1, d: 1, e: 0 - offset.x, f: 0 - offset.y };
437
+ transform = transform ? composeTransforms(transform, scrolled) : scrolled;
438
+ }
439
+ }
440
+ const roundedClips = [];
441
+ for (const [node, clip, radii] of clips) {
442
+ const matrix = transforms.get(node);
443
+ const rect = clip && matrix ? transformRect(matrix, clip) : clip;
444
+ clipRect = intersectRects(clipRect, rect);
445
+ if (rect && radii && (radii.x > 0 || radii.y > 0)) {
446
+ roundedClips.push({ rect, radii: matrix
447
+ ? { x: radii.x * Math.abs(matrix.a), y: radii.y * Math.abs(matrix.d) } : radii });
448
+ }
449
+ }
450
+ // The owner's own box moves with every scroll container above it, not with its own.
451
+ transform = kind === "text" ? transform : transforms.get(owner) ?? null;
452
+ const scrollers = scrolledBy.size ? [...scrolledBy].map((node) => node.id) : null;
453
+ result = opacity <= 0 ? null : { opacity, clipRect, roundedClips, fixed, transform, scrollers, outOfFlow };
303
454
  } else {
304
455
  result = null;
305
456
  }
@@ -308,6 +459,11 @@ function paintEffects(layout, tree) {
308
459
  };
309
460
  }
310
461
 
462
+ /** Whether a box is a scroll container (CSS Overflow 3 §3), by script or by the user. */
463
+ function isScroller(style) {
464
+ return Boolean(style?.overflowScrollX || style?.overflowScrollY);
465
+ }
466
+
311
467
  function intersectRects(a, b) {
312
468
  if (!a) return b;
313
469
  if (!b) return a;
@@ -318,16 +474,50 @@ function intersectRects(a, b) {
318
474
  return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
319
475
  }
320
476
 
477
+ /** The resolved transform-origin of a border box (CSS Transforms 1 §5); 50% 50% by default. */
478
+ function transformOrigin(value, box) {
479
+ const tokens = (value ?? "50% 50%").split(" ");
480
+ if (tokens.length === 1) tokens.push("center");
481
+ if (["top", "bottom"].includes(tokens[0]) || ["left", "right"].includes(tokens[1])) tokens.reverse();
482
+ const resolve = (token, start, extent) => {
483
+ const keyword = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 }[token];
484
+ if (keyword != null) return start + extent * keyword;
485
+ const match = /^(-?[\d.]+)(px|%)?$/.exec(token);
486
+ if (!match) return start + extent / 2;
487
+ return start + (match[2] === "%" ? extent * Number(match[1]) / 100 : Number(match[1]));
488
+ };
489
+ return { x: resolve(tokens[0], box.x, box.width), y: resolve(tokens[1], box.y, box.height) };
490
+ }
491
+
492
+ /** `outer` after `inner`: x -> outer(inner(x)), for scales with translation. */
493
+ function composeTransforms(outer, inner) {
494
+ return { a: outer.a * inner.a, d: outer.d * inner.d, e: outer.a * inner.e + outer.e, f: outer.d * inner.f + outer.f };
495
+ }
496
+
497
+ function transformRect(matrix, rect) {
498
+ const x0 = matrix.a * rect.x + matrix.e;
499
+ const y0 = matrix.d * rect.y + matrix.f;
500
+ const x1 = matrix.a * (rect.x + rect.width) + matrix.e;
501
+ const y1 = matrix.d * (rect.y + rect.height) + matrix.f;
502
+ return { x: Math.min(x0, x1), y: Math.min(y0, y1), width: Math.abs(x1 - x0), height: Math.abs(y1 - y0) };
503
+ }
504
+
321
505
  function applyEffect(items, start, effect) {
322
- if (effect.opacity === 1 && !effect.clipRect && !effect.fixed) return;
506
+ if (effect.opacity === 1 && !effect.clipRect && !effect.fixed && !effect.transform) return;
323
507
  for (let index = start; index < items.length; index++) {
324
508
  const item = items[index];
325
509
  if (effect.fixed) item.fixed = true;
326
510
  if (effect.opacity < 1) item.opacity = effect.opacity;
511
+ // Drawn through the transform; its bounds are where it lands.
512
+ if (effect.transform) {
513
+ item.transform = effect.transform;
514
+ item.bounds = transformRect(effect.transform, item.bounds);
515
+ }
327
516
  if (effect.clipRect) {
328
517
  item.clipRect = effect.clipRect;
329
518
  item.bounds = intersectRects(item.bounds, effect.clipRect);
330
519
  }
520
+ if (effect.roundedClips?.length) item.roundedClips = effect.roundedClips;
331
521
  }
332
522
  }
333
523
 
@@ -360,6 +550,117 @@ function cornerRadii(style, rect) {
360
550
  return { x: x * scale, y: y * scale };
361
551
  }
362
552
 
553
+ /**
554
+ * Where one background image layer's tiles go (CSS Backgrounds 3 §3): sized
555
+ * and positioned in the padding box, repeated per background-repeat, painted
556
+ * within the border box. Null when it has no area.
557
+ */
558
+ function backgroundTile(style, rect, index, resource) {
559
+ const border = style.borderWidths ?? [0, 0, 0, 0];
560
+ const area = {
561
+ x: rect.x + border[3],
562
+ y: rect.y + border[0],
563
+ width: Math.max(0, rect.width - border[1] - border[3]),
564
+ height: Math.max(0, rect.height - border[0] - border[2]),
565
+ };
566
+ const pick = (list, fallback) => {
567
+ if (!list) return fallback;
568
+ const entries = splitTopLevel(list, ",");
569
+ return entries.length ? entries[index % entries.length] : fallback;
570
+ };
571
+ const length = (token, reference) => {
572
+ const match = LENGTH.exec(token);
573
+ if (!match) return null;
574
+ const value = Number(match[1]);
575
+ if (match[2] === "%") return reference * value / 100;
576
+ if (match[2] === "em") return value * (style.fontSize ?? 16);
577
+ if (match[2] === "rem") return value * 16;
578
+ return match[2] || value === 0 ? value : null;
579
+ };
580
+
581
+ // background-size
582
+ const ratio = resource.width / resource.height;
583
+ let width;
584
+ let height;
585
+ const size = pick(style.backgroundSize, "auto").split(/\s+/);
586
+ if (size[0] === "cover" || size[0] === "contain") {
587
+ const fit = size[0] === "cover"
588
+ ? Math.max(area.width / resource.width, area.height / resource.height)
589
+ : Math.min(area.width / resource.width, area.height / resource.height);
590
+ width = resource.width * fit;
591
+ height = resource.height * fit;
592
+ } else {
593
+ width = size[0] === "auto" ? null : length(size[0], area.width);
594
+ height = (size[1] ?? "auto") === "auto" ? null : length(size[1], area.height);
595
+ if (width == null && height == null) [width, height] = [resource.width, resource.height];
596
+ else if (width == null) width = height * ratio;
597
+ else if (height == null) height = width / ratio;
598
+ }
599
+ if (!(width > 0 && height > 0)) return null;
600
+
601
+ // background-position: keywords, offsets from the top left, or from an edge.
602
+ const tokens = pick(style.backgroundPosition, "0% 0%").split(/\s+/);
603
+ const horizontalKeyword = (token) => token === "left" || token === "right";
604
+ const verticalKeyword = (token) => token === "top" || token === "bottom";
605
+ let horizontal = ["left", null];
606
+ let vertical = ["top", null];
607
+ if (tokens.length === 1) {
608
+ // One value: the other is center.
609
+ horizontal = verticalKeyword(tokens[0]) ? ["center", null] : [tokens[0], null];
610
+ vertical = verticalKeyword(tokens[0]) ? [tokens[0], null] : ["center", null];
611
+ } else if (tokens.length === 2) {
612
+ const [first, second] = verticalKeyword(tokens[0]) || horizontalKeyword(tokens[1]) ? [tokens[1], tokens[0]] : tokens;
613
+ horizontal = [first, null];
614
+ vertical = [second, null];
615
+ } else {
616
+ // Three or four values: edge keywords, each optionally followed by an
617
+ // offset; center takes an axis no edge keyword named.
618
+ const pairs = [];
619
+ for (let at = 0; at < tokens.length; at++) {
620
+ const keyword = tokens[at];
621
+ const offset = at + 1 < tokens.length && LENGTH.test(tokens[at + 1]) ? tokens[++at] : null;
622
+ pairs.push([keyword, offset]);
623
+ }
624
+ const named = { horizontal: false, vertical: false };
625
+ for (const pair of pairs) {
626
+ if (horizontalKeyword(pair[0])) [horizontal, named.horizontal] = [pair, true];
627
+ else if (verticalKeyword(pair[0])) [vertical, named.vertical] = [pair, true];
628
+ }
629
+ for (const pair of pairs) {
630
+ if (pair[0] !== "center") continue;
631
+ if (!named.horizontal) [horizontal, named.horizontal] = [pair, true];
632
+ else if (!named.vertical) [vertical, named.vertical] = [pair, true];
633
+ }
634
+ }
635
+ const place = ([keyword, offset], start, extent, tile) => {
636
+ const free = extent - tile;
637
+ const fraction = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 }[keyword];
638
+ if (fraction == null) return start + (length(keyword, free) ?? 0);
639
+ const distance = offset == null ? 0 : length(offset, free) ?? 0;
640
+ return start + free * fraction + (fraction === 1 ? -distance : distance);
641
+ };
642
+ const tile = {
643
+ x: place(horizontal, area.x, area.width, width),
644
+ y: place(vertical, area.y, area.height, height),
645
+ width,
646
+ height,
647
+ };
648
+
649
+ // background-repeat
650
+ const repeat = pick(style.backgroundRepeat, "repeat").split(/\s+/);
651
+ const repeats = (value) => value !== "no-repeat";
652
+ const [repeatX, repeatY] = repeat[0] === "repeat-x" ? [true, false]
653
+ : repeat[0] === "repeat-y" ? [false, true]
654
+ : [repeats(repeat[0]), repeats(repeat[1] ?? repeat[0])];
655
+ // Only the part of the border box the tiles reach paints.
656
+ const left = repeatX ? rect.x : Math.max(rect.x, tile.x);
657
+ const right = repeatX ? rect.x + rect.width : Math.min(rect.x + rect.width, tile.x + width);
658
+ const top = repeatY ? rect.y : Math.max(rect.y, tile.y);
659
+ const bottom = repeatY ? rect.y + rect.height : Math.min(rect.y + rect.height, tile.y + height);
660
+ if (right <= left || bottom <= top) return null;
661
+ return { tile, repeatX, repeatY, bounds: { x: left, y: top, width: right - left, height: bottom - top } };
662
+ }
663
+
363
664
  function inflate(rect, amount) {
364
665
  return { x: rect.x - amount, y: rect.y - amount, width: rect.width + amount * 2, height: rect.height + amount * 2 };
365
666
  }
@@ -9,12 +9,18 @@
9
9
  */
10
10
  const SIDES = Object.freeze({ top: [0, -1], bottom: [0, 1], left: [-1, 0], right: [1, 0] });
11
11
 
12
+ /** Splits a `background-image` value into its layers (images, gradients or none), topmost first. */
13
+ export function backgroundLayers(value) {
14
+ return value ? splitTopLevel(String(value), ",") : [];
15
+ }
16
+
17
+ export function isGradient(layer) {
18
+ return /^(?:repeating-)?(?:linear|radial)-gradient\(/i.test(layer);
19
+ }
20
+
12
21
  /** Splits a `background`/`background-image` value into gradient layers, topmost first. */
13
22
  export function gradientLayers(value) {
14
- if (!value) return [];
15
- return splitTopLevel(String(value), ",")
16
- .map((layer) => layer.trim())
17
- .filter((layer) => /^(?:repeating-)?(?:linear|radial)-gradient\(/i.test(layer));
23
+ return backgroundLayers(value).filter(isGradient);
18
24
  }
19
25
 
20
26
  export function resolveGradient(source, rect, fontSize = 16) {
@@ -189,7 +195,7 @@ function resolveLength(text, reference, fontSize) {
189
195
  return value;
190
196
  }
191
197
 
192
- function splitTopLevel(source, separator) {
198
+ export function splitTopLevel(source, separator) {
193
199
  const parts = [];
194
200
  let depth = 0;
195
201
  let start = 0;
@@ -187,8 +187,11 @@ export class RenderTreeBuilder {
187
187
  style,
188
188
  resource: null,
189
189
  svg,
190
- widthAttribute: svg.width ?? (svg.height && viewWidth ? svg.height * viewWidth / viewHeight : viewWidth ?? null),
191
- heightAttribute: svg.height ?? (svg.width && viewHeight ? svg.width * viewHeight / viewWidth : viewHeight ?? null),
190
+ // Sizes the svg element states; without them it fills its container
191
+ // (width 100%), keeping its viewBox's ratio (see imageDimensions).
192
+ widthAttribute: svg.width ?? null,
193
+ heightAttribute: svg.height ?? null,
194
+ svgRatio: viewWidth && viewHeight ? viewWidth / viewHeight : null,
192
195
  children: Object.freeze([]),
193
196
  });
194
197
  }
@@ -205,6 +208,18 @@ export class RenderTreeBuilder {
205
208
  children: Object.freeze([]),
206
209
  });
207
210
  }
211
+ // An iframe shows its document's latest frame (the resource), at the
212
+ // default object size unless sized (HTML §15.4.2: 300x150).
213
+ if (element.tagName === "IFRAME") {
214
+ return this.#node(element, "image", nodesById, {
215
+ tagName: "IFRAME",
216
+ style,
217
+ resource: resources?.get(element) ?? null,
218
+ widthAttribute: positiveDimension(element.getAttribute("width")),
219
+ heightAttribute: positiveDimension(element.getAttribute("height")),
220
+ children: Object.freeze([]),
221
+ });
222
+ }
208
223
  if (element.tagName === "IMG" || element.tagName === "VIDEO") {
209
224
  return this.#node(element, "image", nodesById, {
210
225
  tagName: element.tagName,
@@ -289,6 +304,8 @@ function sameSvg(left, right) {
289
304
 
290
305
  function boxType(style, tagName = null) {
291
306
  const display = style?.display;
307
+ if (display === "-webkit-box") return "block";
308
+ if (display === "-webkit-inline-box") return "inline-block";
292
309
  if (["flex", "inline-flex", "inline-block", "grid", "inline-grid"].includes(display)) return display;
293
310
  if (["table", "inline-table", "table-row-group", "table-header-group", "table-footer-group", "table-row",
294
311
  "table-cell", "table-caption", "table-column", "table-column-group"].includes(display)) return display;
@@ -20,7 +20,8 @@ const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
20
20
  * @returns {{ viewBox: number[] | null, width: number | null, height: number | null, shapes: object[] }}
21
21
  */
22
22
  export function extractSvg(element, currentColor) {
23
- const viewBox = parseNumbers(element.getAttribute("viewBox"));
23
+ // HTML-parsed markup may carry the attribute lowercased (viewbox).
24
+ const viewBox = parseNumbers(element.getAttribute("viewBox") ?? element.getAttribute("viewbox"));
24
25
  const shapes = [];
25
26
  const root = {
26
27
  fill: "rgba(0, 0, 0, 1)",