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
@@ -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) {
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) {
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) {
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) {
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
  }
@@ -156,15 +209,18 @@ export function buildDisplayList(layout, tree = null) {
156
209
  return;
157
210
  }
158
211
  if (run.type === "image") {
212
+ const currentResource = resources && tree
213
+ ? resources.get(tree.nodesById.get(run.nodeId)?.domNode) ?? run.resource
214
+ : run.resource;
159
215
  if (run.video || run.audio) {
160
216
  const rect = { x: run.x, y, width: run.width, height: run.height };
161
- images.push(run.resource);
217
+ images.push(currentResource);
162
218
  items.push({ op: run.video ? "drawVideo" : "drawAudio", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
163
219
  return;
164
220
  }
165
- if (!run.resource?.image) return;
221
+ if (!currentResource?.image) return;
166
222
  const rect = { x: run.x, y, width: run.width, height: run.height };
167
- images.push(run.resource);
223
+ images.push(currentResource);
168
224
  items.push({
169
225
  op: "drawImage", nodeId: run.nodeId, image: images.length - 1, rect, radii: cornerRadii(run.style, rect), bounds: rect,
170
226
  });
@@ -201,17 +257,51 @@ export function buildDisplayList(layout, tree = null) {
201
257
  items: Object.freeze(kept.map(deepFreeze)),
202
258
  };
203
259
  Object.defineProperty(displayList, "images", { value: Object.freeze(images), enumerable: false });
260
+ Object.defineProperty(displayList, "hits", { value: Object.freeze(hits), enumerable: false });
261
+ Object.defineProperty(displayList, "scrollers", { value: scrollers, enumerable: false });
262
+ // How far the page's content reaches (its scrollable overflow, CSS Overflow 3 §2.2):
263
+ // every box and run, cut to the clips around it; fixed content does not count.
264
+ let right = 0;
265
+ let bottom = 0;
266
+ for (const { rect, clipRect, fixed } of hits) {
267
+ if (fixed) continue;
268
+ const reached = clipRect ? intersectRects(rect, clipRect) : rect;
269
+ if (!(reached.width > 0 && reached.height > 0)) continue;
270
+ right = Math.max(right, reached.x + reached.width);
271
+ bottom = Math.max(bottom, reached.y + reached.height);
272
+ }
273
+ Object.defineProperty(displayList, "extent", { value: Object.freeze({ width: right, height: bottom }), enumerable: false });
204
274
  return Object.freeze(displayList);
205
275
  }
206
276
 
277
+ /**
278
+ * The render node id of the topmost box or run at a viewport point, in paint
279
+ * order, or null. `skip(nodeId)` passes over nodes that take no pointer
280
+ * events. Fixed content stays put as the page scrolls.
281
+ */
282
+ export function hitTarget(displayList, x, y, scroll, skip = () => false) {
283
+ const hits = displayList?.hits ?? [];
284
+ for (let index = hits.length - 1; index >= 0; index--) {
285
+ const { nodeId, rect, clipRect, fixed } = hits[index];
286
+ const pointX = fixed ? x : x + scroll.x;
287
+ const pointY = fixed ? y : y + scroll.y;
288
+ if (pointX < rect.x || pointX >= rect.x + rect.width || pointY < rect.y || pointY >= rect.y + rect.height) continue;
289
+ if (clipRect && (pointX < clipRect.x || pointX >= clipRect.x + clipRect.width
290
+ || pointY < clipRect.y || pointY >= clipRect.y + clipRect.height)) continue;
291
+ if (skip(nodeId)) continue;
292
+ return nodeId;
293
+ }
294
+ return null;
295
+ }
296
+
207
297
  /**
208
298
  * Painting effects of ancestors (CSS Visual Effects / Overflow): visibility,
209
299
  * group opacity (approximated per item), and clipping by overflow, clip and
210
300
  * clip-path rectangles. Returns effects(nodeId, "box" | "text") → null when
211
301
  * the item is not painted, else { opacity, clipRect }.
212
302
  */
213
- function paintEffects(layout, tree) {
214
- const none = { opacity: 1, clipRect: null, fixed: false };
303
+ function paintEffects(layout, tree, scrollOffsetOf = null) {
304
+ const none = { opacity: 1, clipRect: null, fixed: false, transform: null, scrollers: null, outOfFlow: false };
215
305
  if (!tree?.root) return () => none;
216
306
  const parents = new Map();
217
307
  (function walk(node) {
@@ -223,17 +313,27 @@ function paintEffects(layout, tree) {
223
313
  const boxes = new Map();
224
314
  for (const box of layout.boxes) if (!box.inline && !boxes.has(box.nodeId)) boxes.set(box.nodeId, box);
225
315
  const cache = new Map();
316
+ // Each element's own clips, worked out once for all the items below it.
317
+ const memo = (compute) => {
318
+ const results = new Map();
319
+ return (node) => {
320
+ if (results.has(node)) return results.get(node);
321
+ const result = compute(node);
322
+ results.set(node, result === null ? null : Object.freeze(result));
323
+ return results.get(node);
324
+ };
325
+ };
226
326
 
227
327
  // The clip an element applies to its descendants (and, for clip/clip-path, to itself).
228
- const overflowClip = (node) => {
328
+ const overflowClip = memo((node) => {
229
329
  const style = node.style;
230
330
  const box = boxes.get(node.id);
231
331
  if (!style || !box || node === tree.root) return null;
232
332
  if (style.overflowX !== "clip" && style.overflowY !== "clip") return null;
233
333
  const [top, right, bottom, left] = style.borderWidths ?? [0, 0, 0, 0];
234
334
  return { x: box.x + left, y: box.y + top, width: box.width - left - right, height: box.height - top - bottom };
235
- };
236
- const shapeClip = (node) => {
335
+ });
336
+ const shapeClip = memo((node) => {
237
337
  const style = node.style;
238
338
  const box = boxes.get(node.id);
239
339
  if (!style || !box) return null;
@@ -253,7 +353,20 @@ function paintEffects(layout, tree) {
253
353
  rect = intersectRects(rect, { x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
254
354
  }
255
355
  return rect;
256
- };
356
+ });
357
+ // A scale an element's transform applies to itself and its contents, about its transform-origin.
358
+ const ownTransform = memo((node) => {
359
+ const transform = node.style?.transform;
360
+ const box = boxes.get(node.id);
361
+ if (!box || !transform?.scaleX || (transform.scaleX === 1 && transform.scaleY === 1)) return null;
362
+ const origin = transformOrigin(node.style.transformOrigin, box);
363
+ return {
364
+ a: transform.scaleX,
365
+ d: transform.scaleY,
366
+ e: origin.x * (1 - transform.scaleX),
367
+ f: origin.y * (1 - transform.scaleY),
368
+ };
369
+ });
257
370
 
258
371
  return (nodeId, kind) => {
259
372
  const key = `${kind}:${nodeId}`;
@@ -269,24 +382,61 @@ function paintEffects(layout, tree) {
269
382
  // Overflow clipping skips ancestors that are not in the containing-block
270
383
  // chain of an absolutely positioned box; fixed boxes escape entirely.
271
384
  let escaping = null;
385
+ // Clips by the element that applies them: a scaled ancestor moves them too.
386
+ const clips = [];
387
+ const chain = [];
388
+ // Scroll containers this content scrolls with: those whose clip applies to it.
389
+ const scrolledBy = new Set();
390
+ // Whether an absolutely positioned box lies between the content and
391
+ // its innermost scroll container.
392
+ let positionedOut = false;
393
+ let outOfFlow = false;
272
394
  for (let node = owner; node; node = parents.get(node.id)) {
395
+ chain.push(node);
273
396
  const style = node.style;
274
397
  if (!style) continue;
275
398
  if (style.position === "fixed") fixed = true;
276
399
  opacity *= style.opacity ?? 1;
277
- clipRect = intersectRects(clipRect, shapeClip(node));
400
+ clips.push([node, shapeClip(node)]);
278
401
  const ownContent = kind === "text" || node !== owner;
279
402
  if (ownContent && escaping !== "fixed") {
280
403
  const positioned = style.position && style.position !== "static";
281
404
  if (!escaping || positioned) {
282
- clipRect = intersectRects(clipRect, overflowClip(node));
405
+ clips.push([node, overflowClip(node)]);
406
+ if (isScroller(style)) {
407
+ if (!scrolledBy.size) outOfFlow = positionedOut;
408
+ scrolledBy.add(node);
409
+ }
283
410
  escaping = null;
284
411
  }
285
412
  }
413
+ if (style.position === "absolute" || style.position === "fixed") positionedOut = true;
286
414
  if (style.position === "fixed") escaping = "fixed";
287
415
  else if (style.position === "absolute" && escaping !== "fixed") escaping = "absolute";
288
416
  }
289
- result = opacity <= 0 ? null : { opacity, clipRect, fixed };
417
+ // The transforms from the root down to each element: scales, and the
418
+ // scroll offsets of the scroll containers it is the content of.
419
+ let transform = null;
420
+ const transforms = new Map();
421
+ for (let index = chain.length - 1; index >= 0; index--) {
422
+ const node = chain[index];
423
+ const own = ownTransform(node);
424
+ if (own) transform = transform ? composeTransforms(transform, own) : own;
425
+ transforms.set(node, transform);
426
+ const offset = scrolledBy.has(node) ? scrollOffsetOf?.(node.domNode) : null;
427
+ if (offset && (offset.x || offset.y)) {
428
+ const scrolled = { a: 1, d: 1, e: 0 - offset.x, f: 0 - offset.y };
429
+ transform = transform ? composeTransforms(transform, scrolled) : scrolled;
430
+ }
431
+ }
432
+ for (const [node, clip] of clips) {
433
+ const matrix = transforms.get(node);
434
+ clipRect = intersectRects(clipRect, clip && matrix ? transformRect(matrix, clip) : clip);
435
+ }
436
+ // The owner's own box moves with every scroll container above it, not with its own.
437
+ transform = kind === "text" ? transform : transforms.get(owner) ?? null;
438
+ const scrollers = scrolledBy.size ? [...scrolledBy].map((node) => node.id) : null;
439
+ result = opacity <= 0 ? null : { opacity, clipRect, fixed, transform, scrollers, outOfFlow };
290
440
  } else {
291
441
  result = null;
292
442
  }
@@ -295,6 +445,11 @@ function paintEffects(layout, tree) {
295
445
  };
296
446
  }
297
447
 
448
+ /** Whether a box is a scroll container (CSS Overflow 3 §3), by script or by the user. */
449
+ function isScroller(style) {
450
+ return Boolean(style?.overflowScrollX || style?.overflowScrollY);
451
+ }
452
+
298
453
  function intersectRects(a, b) {
299
454
  if (!a) return b;
300
455
  if (!b) return a;
@@ -305,12 +460,45 @@ function intersectRects(a, b) {
305
460
  return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
306
461
  }
307
462
 
463
+ /** The resolved transform-origin of a border box (CSS Transforms 1 §5); 50% 50% by default. */
464
+ function transformOrigin(value, box) {
465
+ const tokens = (value ?? "50% 50%").split(" ");
466
+ if (tokens.length === 1) tokens.push("center");
467
+ if (["top", "bottom"].includes(tokens[0]) || ["left", "right"].includes(tokens[1])) tokens.reverse();
468
+ const resolve = (token, start, extent) => {
469
+ const keyword = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 }[token];
470
+ if (keyword != null) return start + extent * keyword;
471
+ const match = /^(-?[\d.]+)(px|%)?$/.exec(token);
472
+ if (!match) return start + extent / 2;
473
+ return start + (match[2] === "%" ? extent * Number(match[1]) / 100 : Number(match[1]));
474
+ };
475
+ return { x: resolve(tokens[0], box.x, box.width), y: resolve(tokens[1], box.y, box.height) };
476
+ }
477
+
478
+ /** `outer` after `inner`: x -> outer(inner(x)), for scales with translation. */
479
+ function composeTransforms(outer, inner) {
480
+ 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 };
481
+ }
482
+
483
+ function transformRect(matrix, rect) {
484
+ const x0 = matrix.a * rect.x + matrix.e;
485
+ const y0 = matrix.d * rect.y + matrix.f;
486
+ const x1 = matrix.a * (rect.x + rect.width) + matrix.e;
487
+ const y1 = matrix.d * (rect.y + rect.height) + matrix.f;
488
+ return { x: Math.min(x0, x1), y: Math.min(y0, y1), width: Math.abs(x1 - x0), height: Math.abs(y1 - y0) };
489
+ }
490
+
308
491
  function applyEffect(items, start, effect) {
309
- if (effect.opacity === 1 && !effect.clipRect && !effect.fixed) return;
492
+ if (effect.opacity === 1 && !effect.clipRect && !effect.fixed && !effect.transform) return;
310
493
  for (let index = start; index < items.length; index++) {
311
494
  const item = items[index];
312
495
  if (effect.fixed) item.fixed = true;
313
496
  if (effect.opacity < 1) item.opacity = effect.opacity;
497
+ // Drawn through the transform; its bounds are where it lands.
498
+ if (effect.transform) {
499
+ item.transform = effect.transform;
500
+ item.bounds = transformRect(effect.transform, item.bounds);
501
+ }
314
502
  if (effect.clipRect) {
315
503
  item.clipRect = effect.clipRect;
316
504
  item.bounds = intersectRects(item.bounds, effect.clipRect);
@@ -347,11 +535,124 @@ function cornerRadii(style, rect) {
347
535
  return { x: x * scale, y: y * scale };
348
536
  }
349
537
 
538
+ /**
539
+ * Where one background image layer's tiles go (CSS Backgrounds 3 §3): sized
540
+ * and positioned in the padding box, repeated per background-repeat, painted
541
+ * within the border box. Null when it has no area.
542
+ */
543
+ function backgroundTile(style, rect, index, resource) {
544
+ const border = style.borderWidths ?? [0, 0, 0, 0];
545
+ const area = {
546
+ x: rect.x + border[3],
547
+ y: rect.y + border[0],
548
+ width: Math.max(0, rect.width - border[1] - border[3]),
549
+ height: Math.max(0, rect.height - border[0] - border[2]),
550
+ };
551
+ const pick = (list, fallback) => {
552
+ if (!list) return fallback;
553
+ const entries = splitTopLevel(list, ",");
554
+ return entries.length ? entries[index % entries.length] : fallback;
555
+ };
556
+ const length = (token, reference) => {
557
+ const match = LENGTH.exec(token);
558
+ if (!match) return null;
559
+ const value = Number(match[1]);
560
+ if (match[2] === "%") return reference * value / 100;
561
+ if (match[2] === "em") return value * (style.fontSize ?? 16);
562
+ if (match[2] === "rem") return value * 16;
563
+ return match[2] || value === 0 ? value : null;
564
+ };
565
+
566
+ // background-size
567
+ const ratio = resource.width / resource.height;
568
+ let width;
569
+ let height;
570
+ const size = pick(style.backgroundSize, "auto").split(/\s+/);
571
+ if (size[0] === "cover" || size[0] === "contain") {
572
+ const fit = size[0] === "cover"
573
+ ? Math.max(area.width / resource.width, area.height / resource.height)
574
+ : Math.min(area.width / resource.width, area.height / resource.height);
575
+ width = resource.width * fit;
576
+ height = resource.height * fit;
577
+ } else {
578
+ width = size[0] === "auto" ? null : length(size[0], area.width);
579
+ height = (size[1] ?? "auto") === "auto" ? null : length(size[1], area.height);
580
+ if (width == null && height == null) [width, height] = [resource.width, resource.height];
581
+ else if (width == null) width = height * ratio;
582
+ else if (height == null) height = width / ratio;
583
+ }
584
+ if (!(width > 0 && height > 0)) return null;
585
+
586
+ // background-position: keywords, offsets from the top left, or from an edge.
587
+ const tokens = pick(style.backgroundPosition, "0% 0%").split(/\s+/);
588
+ const horizontalKeyword = (token) => token === "left" || token === "right";
589
+ const verticalKeyword = (token) => token === "top" || token === "bottom";
590
+ let horizontal = ["left", null];
591
+ let vertical = ["top", null];
592
+ if (tokens.length === 1) {
593
+ // One value: the other is center.
594
+ horizontal = verticalKeyword(tokens[0]) ? ["center", null] : [tokens[0], null];
595
+ vertical = verticalKeyword(tokens[0]) ? [tokens[0], null] : ["center", null];
596
+ } else if (tokens.length === 2) {
597
+ const [first, second] = verticalKeyword(tokens[0]) || horizontalKeyword(tokens[1]) ? [tokens[1], tokens[0]] : tokens;
598
+ horizontal = [first, null];
599
+ vertical = [second, null];
600
+ } else {
601
+ // Three or four values: edge keywords, each optionally followed by an
602
+ // offset; center takes an axis no edge keyword named.
603
+ const pairs = [];
604
+ for (let at = 0; at < tokens.length; at++) {
605
+ const keyword = tokens[at];
606
+ const offset = at + 1 < tokens.length && LENGTH.test(tokens[at + 1]) ? tokens[++at] : null;
607
+ pairs.push([keyword, offset]);
608
+ }
609
+ const named = { horizontal: false, vertical: false };
610
+ for (const pair of pairs) {
611
+ if (horizontalKeyword(pair[0])) [horizontal, named.horizontal] = [pair, true];
612
+ else if (verticalKeyword(pair[0])) [vertical, named.vertical] = [pair, true];
613
+ }
614
+ for (const pair of pairs) {
615
+ if (pair[0] !== "center") continue;
616
+ if (!named.horizontal) [horizontal, named.horizontal] = [pair, true];
617
+ else if (!named.vertical) [vertical, named.vertical] = [pair, true];
618
+ }
619
+ }
620
+ const place = ([keyword, offset], start, extent, tile) => {
621
+ const free = extent - tile;
622
+ const fraction = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 }[keyword];
623
+ if (fraction == null) return start + (length(keyword, free) ?? 0);
624
+ const distance = offset == null ? 0 : length(offset, free) ?? 0;
625
+ return start + free * fraction + (fraction === 1 ? -distance : distance);
626
+ };
627
+ const tile = {
628
+ x: place(horizontal, area.x, area.width, width),
629
+ y: place(vertical, area.y, area.height, height),
630
+ width,
631
+ height,
632
+ };
633
+
634
+ // background-repeat
635
+ const repeat = pick(style.backgroundRepeat, "repeat").split(/\s+/);
636
+ const repeats = (value) => value !== "no-repeat";
637
+ const [repeatX, repeatY] = repeat[0] === "repeat-x" ? [true, false]
638
+ : repeat[0] === "repeat-y" ? [false, true]
639
+ : [repeats(repeat[0]), repeats(repeat[1] ?? repeat[0])];
640
+ // Only the part of the border box the tiles reach paints.
641
+ const left = repeatX ? rect.x : Math.max(rect.x, tile.x);
642
+ const right = repeatX ? rect.x + rect.width : Math.min(rect.x + rect.width, tile.x + width);
643
+ const top = repeatY ? rect.y : Math.max(rect.y, tile.y);
644
+ const bottom = repeatY ? rect.y + rect.height : Math.min(rect.y + rect.height, tile.y + height);
645
+ if (right <= left || bottom <= top) return null;
646
+ return { tile, repeatX, repeatY, bounds: { x: left, y: top, width: right - left, height: bottom - top } };
647
+ }
648
+
350
649
  function inflate(rect, amount) {
351
650
  return { x: rect.x - amount, y: rect.y - amount, width: rect.width + amount * 2, height: rect.height + amount * 2 };
352
651
  }
353
652
 
653
+ /** Freezes an item and what it holds; frozen values (shared clips, style data) are already done. */
354
654
  function deepFreeze(value) {
655
+ if (Object.isFrozen(value)) return value;
355
656
  for (const child of Object.values(value)) {
356
657
  if (child && typeof child === "object") deepFreeze(child);
357
658
  }
@@ -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;
@@ -30,6 +30,7 @@ const BLOCK_ELEMENTS = new Set([
30
30
  */
31
31
  export class RenderTreeBuilder {
32
32
  #ids = new WeakMap();
33
+ #previous = new WeakMap();
33
34
  #pseudoKeys = new WeakMap();
34
35
  #nextId = 1;
35
36
  #generation = 0;
@@ -71,12 +72,18 @@ export class RenderTreeBuilder {
71
72
  }
72
73
 
73
74
  #node(domNode, type, nodesById, properties, idKey = domNode) {
75
+ const previous = this.#previous.get(idKey);
76
+ if (previous && sameNodeProperties(previous, type, properties)) {
77
+ nodesById.set(previous.id, previous);
78
+ return previous;
79
+ }
74
80
  const node = Object.freeze({
75
81
  id: this.#id(idKey),
76
82
  type,
77
83
  domNode,
78
84
  ...properties,
79
85
  });
86
+ this.#previous.set(idKey, node);
80
87
  nodesById.set(node.id, node);
81
88
  return node;
82
89
  }
@@ -180,8 +187,11 @@ export class RenderTreeBuilder {
180
187
  style,
181
188
  resource: null,
182
189
  svg,
183
- widthAttribute: svg.width ?? (svg.height && viewWidth ? svg.height * viewWidth / viewHeight : viewWidth ?? null),
184
- 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,
185
195
  children: Object.freeze([]),
186
196
  });
187
197
  }
@@ -198,6 +208,18 @@ export class RenderTreeBuilder {
198
208
  children: Object.freeze([]),
199
209
  });
200
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
+ }
201
223
  if (element.tagName === "IMG" || element.tagName === "VIDEO") {
202
224
  return this.#node(element, "image", nodesById, {
203
225
  tagName: element.tagName,
@@ -242,6 +264,44 @@ export class RenderTreeBuilder {
242
264
  }
243
265
  }
244
266
 
267
+ function sameNodeProperties(previous, type, properties) {
268
+ if (previous.type !== type || Object.keys(previous).length !== Object.keys(properties).length + 3) return false;
269
+ for (const [key, value] of Object.entries(properties)) {
270
+ const old = previous[key];
271
+ if (old === value) continue;
272
+ if (key !== "children" || old.length !== value.length) return false;
273
+ for (let index = 0; index < old.length; index++) if (old[index] !== value[index]) return false;
274
+ }
275
+ return true;
276
+ }
277
+
278
+ /**
279
+ * Whether two generations provide identical input to layout. Node IDs are
280
+ * stable for retained DOM nodes, while computed styles and decoded resources
281
+ * are immutable values shared between generations. Callers which separately
282
+ * track whether resource dimensions changed may ignore resource identity.
283
+ */
284
+ export function sameLayoutInput(left, right, options = {}) {
285
+ if (left === right) return true;
286
+ if (!left || !right || left.id !== right.id || left.type !== right.type
287
+ || (!options.ignoreStyles && left.style !== right.style) || left.tagName !== right.tagName
288
+ || left.text !== right.text || left.pseudo !== right.pseudo
289
+ || (!options.ignoreResources && left.resource !== right.resource)
290
+ || !sameSvg(left.svg, right.svg)
291
+ || left.widthAttribute !== right.widthAttribute || left.heightAttribute !== right.heightAttribute
292
+ || left.children.length !== right.children.length) return false;
293
+ for (let index = 0; index < left.children.length; index++) {
294
+ if (!sameLayoutInput(left.children[index], right.children[index], options)) return false;
295
+ }
296
+ return true;
297
+ }
298
+
299
+ function sameSvg(left, right) {
300
+ if (left === right) return true;
301
+ if (!left || !right) return false;
302
+ return JSON.stringify(left) === JSON.stringify(right);
303
+ }
304
+
245
305
  function boxType(style, tagName = null) {
246
306
  const display = style?.display;
247
307
  if (["flex", "inline-flex", "inline-block", "grid", "inline-grid"].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)",