buninu-browser 0.2.3 → 0.2.4

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 (105) hide show
  1. package/lib/cdp-server.js +47 -14
  2. package/lib/happy-dom/document-base.js +33 -0
  3. package/lib/happy-dom/parser.js +15 -6
  4. package/lib/happy-dom/selector-matcher.js +2 -2
  5. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  6. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  7. package/lib/headless-shell.js +9 -6
  8. package/lib/input/hit-test.js +67 -19
  9. package/lib/layout/text-layout.js +213 -91
  10. package/lib/media/byte-pipe.js +2 -2
  11. package/lib/media/position-audio.js +10 -1
  12. package/lib/media/source-snapshots.js +126 -0
  13. package/lib/media/video-keyframe.js +17 -4
  14. package/lib/media/webm-stream.js +106 -0
  15. package/lib/network/network-service.js +268 -120
  16. package/lib/paint/display-list.js +8 -28
  17. package/lib/paint/frame-cache.js +25 -0
  18. package/lib/paint/frame.js +42 -13
  19. package/lib/paint/paragraph-text.js +24 -0
  20. package/lib/paint/raster-readback.js +41 -0
  21. package/lib/paint/rounded-rect.js +82 -0
  22. package/lib/paint/shared-frame-pool.js +105 -0
  23. package/lib/render-tree/index.js +38 -6
  24. package/lib/render-tree/svg.js +15 -7
  25. package/lib/renderer/bindings.js +233 -80
  26. package/lib/renderer/broadcast-channel.js +76 -0
  27. package/lib/renderer/canvas-2d.js +257 -14
  28. package/lib/renderer/canvas-text.js +120 -0
  29. package/lib/renderer/compositor.js +13 -11
  30. package/lib/renderer/custom-element-reactions.js +25 -1
  31. package/lib/renderer/font-loading.js +227 -0
  32. package/lib/renderer/host.js +28 -4
  33. package/lib/renderer/image-resources.js +65 -0
  34. package/lib/renderer/interfaces.js +28 -0
  35. package/lib/renderer/layout-observers.js +236 -0
  36. package/lib/renderer/main.js +30 -4
  37. package/lib/renderer/media-source.js +126 -55
  38. package/lib/renderer/page-realm.js +270 -98
  39. package/lib/renderer/page-renderer.js +647 -235
  40. package/lib/renderer/prepared-script-cache.js +50 -0
  41. package/lib/renderer/rendered-text.js +108 -0
  42. package/lib/renderer/rewrite-worker.js +4 -3
  43. package/lib/renderer/script-rewrite.js +13 -2
  44. package/lib/renderer/scroll-into-view.js +28 -0
  45. package/lib/renderer/slot-assignment.js +60 -0
  46. package/lib/renderer/video-presentation.js +23 -4
  47. package/lib/renderer/workers.js +2 -2
  48. package/lib/style/computed-style.js +611 -104
  49. package/lib/style/selector-traits.js +49 -5
  50. package/package.json +1 -1
  51. package/test/broadcast-channel.test.js +26 -0
  52. package/test/canvas-2d.test.js +83 -2
  53. package/test/canvas-paint.test.js +9 -0
  54. package/test/canvas-text.test.js +41 -0
  55. package/test/cdp-stream.test.js +56 -0
  56. package/test/computed-style-binding.test.js +151 -0
  57. package/test/computed-style.test.js +258 -1
  58. package/test/display-list.test.js +12 -0
  59. package/test/document-base.test.js +87 -0
  60. package/test/dom-platform.test.js +461 -0
  61. package/test/fetch-lifecycle.test.js +161 -0
  62. package/test/fixtures/canvas-paint.js +117 -0
  63. package/test/fixtures/canvas-text.js +42 -0
  64. package/test/fixtures/image-lifetime.js +90 -0
  65. package/test/fixtures/media-seek-snapshot.js +77 -0
  66. package/test/fixtures/mse-audio-chirp.webm +0 -0
  67. package/test/fixtures/mse-switch-large.mp4 +0 -0
  68. package/test/fixtures/mse-switch-small.mp4 +0 -0
  69. package/test/fixtures/renderer-memory.js +110 -0
  70. package/test/fixtures/summary-style.js +28 -0
  71. package/test/font-loading.test.js +97 -0
  72. package/test/grid.test.js +58 -0
  73. package/test/hit-test.test.js +21 -1
  74. package/test/image-lifetime.test.js +12 -0
  75. package/test/image-resources.test.js +94 -0
  76. package/test/local-media.test.js +84 -0
  77. package/test/media-seek-snapshot.test.js +11 -0
  78. package/test/media-source-config.test.js +98 -0
  79. package/test/media-source-webm-accounting.test.js +98 -0
  80. package/test/media-source-webm.test.js +108 -0
  81. package/test/navigation.test.js +1 -0
  82. package/test/network-service.test.js +388 -0
  83. package/test/page-scripts.test.js +1 -1
  84. package/test/paint-frame.test.js +103 -0
  85. package/test/paint-paragraph-cache.test.js +87 -0
  86. package/test/position-audio.test.js +24 -0
  87. package/test/positioning.test.js +22 -0
  88. package/test/prepared-script-cache.test.js +45 -0
  89. package/test/pulse-client.test.js +10 -3
  90. package/test/raster-readback.test.js +40 -0
  91. package/test/render-tree.test.js +28 -0
  92. package/test/renderer-memory.test.js +11 -0
  93. package/test/selector-traits.test.js +24 -0
  94. package/test/shared-frame-pool.test.js +63 -0
  95. package/test/style-sheets.test.js +51 -0
  96. package/test/svg.test.js +29 -0
  97. package/test/text-layout.test.js +106 -0
  98. package/test/video-keyframe.test.js +61 -0
  99. package/test/video-presentation.test.js +69 -0
  100. package/tools/browser-lab.js +78 -0
  101. package/tools/browser-soak.js +75 -0
  102. package/tools/network-reference.js +49 -0
  103. package/tools/process-memory.js +38 -0
  104. package/tools/script-benchmark.js +25 -0
  105. package/ai-debug.log +0 -5537
@@ -23,22 +23,30 @@
23
23
  * after the load event.
24
24
  */
25
25
  import CanvasKitInit from "../../usr/lib/canvaskit/canvaskit.js";
26
- import { setCanvasKit } from "./canvas-2d.js";
26
+ import { setCanvasKit, canvasResource, observeDocumentCanvases } from "./canvas-2d.js";
27
+ import { createCanvasTextRenderer, parseCanvasFont } from "./canvas-text.js";
27
28
  import { DESKTOP_USER_AGENT } from "../user-agent.js";
28
29
  import { parseHTMLDocument } from "../happy-dom/parser.js";
29
30
  import { LayoutEngine } from "../layout/text-layout.js";
30
31
  import { ScrollViewport } from "../layout/scroll-viewport.js";
31
- import { elementAtPoint, elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
32
+ import { scrollAlignmentDelta } from "./scroll-into-view.js";
33
+ import { elementAtPoint, elementBounds, elementBox, hitTest, interactiveRegions, renderNodeAtPoint } from "../input/hit-test.js";
32
34
  import { beginImageLoad, finishImageLoad } from "./image-loading.js";
35
+ import { createImageResourceLoader, imageCrossOrigin, imageRequestKey } from "./image-resources.js";
36
+ import { DocumentFonts } from "./font-loading.js";
33
37
  import { dispatchPointerMove } from "./pointer-move.js";
34
38
  import { keyboardInput } from "./keyboard-input.js";
35
39
  import { playbackEvent } from "./media-playback-events.js";
36
40
  import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
37
- import { absolutizeStyleSheetUrls, matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
41
+ import { absolutizeStyleSheetUrls, extractFontFaces, matchSelectors, mediaQueryMatches, StyleEngine, topmostConnected } from "../style/computed-style.js";
38
42
  import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
39
43
  import { elementFeatures, sheetTraits } from "../style/selector-traits.js";
40
44
  import { buildDisplayList, visibleItems } from "../paint/display-list.js";
41
- import { backgroundBytes, cropViewport, encodeImage } from "../paint/frame.js";
45
+ import { backgroundBytes, cropViewport, encodeImageBase64 } from "../paint/frame.js";
46
+ import { FrameCaptureCache } from "../paint/frame-cache.js";
47
+ import { RasterReadback } from "../paint/raster-readback.js";
48
+ import { insetRadii, roundedRect } from "../paint/rounded-rect.js";
49
+ import { appendParagraphText } from "../paint/paragraph-text.js";
42
50
  import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
43
51
  import { originOf } from "./messaging.js";
44
52
  import { MOBILE_USER_AGENT } from "./flags.js";
@@ -143,6 +151,11 @@ export function loadGraphics() {
143
151
  const fontCollection = CanvasKit.FontCollection.Make();
144
152
  fontCollection.setDefaultFontManager(fontProvider);
145
153
  fontCollection.enableFontFallback();
154
+ const builtinTypefaces = [...loadedFamilies.values()].map(family => ({
155
+ family, typeface: fontProvider.matchFamilyStyle(family, {
156
+ weight: 400, width: 5, slant: CanvasKit.FontSlant.Upright,
157
+ }),
158
+ })).filter(entry => entry.typeface);
146
159
 
147
160
  const fontFamilies = uiFontNames.map((name) => (fontCatalogue.fonts[name].families ?? [name])[0]);
148
161
  const sansSerifFamily = fontFamilies[0];
@@ -158,7 +171,7 @@ export function loadGraphics() {
158
171
  ]);
159
172
  return loadedGraphics = {
160
173
  CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
161
- genericFamilies,
174
+ genericFamilies, builtinTypefaces,
162
175
  };
163
176
  })();
164
177
  }
@@ -231,38 +244,47 @@ function buildPageRenderer({
231
244
  // An iframe's document (see syncFrames): frames go to the embedding page,
232
245
  // which needs only the viewport, so nothing is painted ahead.
233
246
  embedded = false,
247
+ allocateFramePixels = null,
234
248
  // This document's window handle, made by the embedding page (see windowHandle).
235
249
  frameWindow = null,
236
250
  }, {
237
251
  CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
238
- genericFamilies,
252
+ genericFamilies, builtinTypefaces,
239
253
  }) {
254
+ let documentFonts = null;
255
+ let fontSheets = null;
256
+ let fontEnvironment = "";
257
+ let fontDefinitions = [];
258
+ let fontDefinitionKey = "[]";
240
259
  const resolvedFamilies = new Map();
241
260
  /** First available family from a computed font-family list, then the UI fallbacks. */
242
- function resolveFontFamilies(families = ["sans-serif"], weight = 400) {
261
+ function resolveFontFamilies(families = ["sans-serif"], weight = 400, fontStyle = "normal", point = null, stretch = 100) {
243
262
  const bold = weight >= 600;
244
- const key = `${bold}\n${families.join("\n")}`;
245
- let resolved = resolvedFamilies.get(key);
263
+ const key = `${weight}/${fontStyle}/${stretch}\n${families.join("\n")}`;
264
+ let resolved = point === null && resolvedFamilies.get(key);
246
265
  if (!resolved) {
247
- let primary = families
248
- .map((family) => loadedFamilies.get(family.toLowerCase()) ?? genericFamilies.get(family.toLowerCase()))
249
- .find(Boolean) ?? sansSerifFamily;
250
- if (bold && primary === monospaceFamily) primary = monospaceBoldFamily;
251
- resolved = [primary, ...fontFamilies.filter((family) => family !== primary)];
252
- resolvedFamilies.set(key, resolved);
266
+ let primary = [];
267
+ for (const family of families) {
268
+ const web = documentFonts?.resolve(family, weight, fontStyle, point, stretch);
269
+ if (web?.length) { primary.push(...web); continue; }
270
+ const builtin = loadedFamilies.get(family.toLowerCase()) ?? genericFamilies.get(family.toLowerCase());
271
+ if (builtin) { primary.push(bold && builtin === monospaceFamily ? monospaceBoldFamily : builtin); break; }
272
+ }
273
+ if (!primary.length) primary = [sansSerifFamily];
274
+ resolved = [...primary, ...fontFamilies.filter((family) => !primary.includes(family))];
275
+ if (point === null) resolvedFamilies.set(key, resolved);
253
276
  }
254
277
  return resolved;
255
278
  }
256
- const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
257
- const emojiPresentation = /^\p{Emoji_Presentation}/u;
258
279
  const renderTreeBuilder = new RenderTreeBuilder();
259
280
  const layoutEngine = new LayoutEngine({ measureText, fontMetrics });
260
- const styleEngine = new StyleEngine();
281
+ const styleEngine = new StyleEngine({ measureText });
261
282
  // Incremental restyle: what may have changed style since the last
262
283
  // computation. DOM mutations come from a MutationObserver; state that is
263
284
  // not in the DOM tree (checked, value, focus) is marked by the code that
264
285
  // changes it. `full` asks for a complete restyle.
265
- const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null, activeElement: null };
286
+ const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null,
287
+ activeElement: null, focusAncestors: new Set() };
266
288
  let selectorTraits = { sibling: false, has: null, sheets: null };
267
289
  let lastStyleParts = "";
268
290
  let laidOutSheets = null;
@@ -306,6 +328,7 @@ function buildPageRenderer({
306
328
  deviceScaleFactor: 1,
307
329
  pageScale: 1,
308
330
  needsPaint: true,
331
+ resourcePaintDirty: false,
309
332
  loading: false,
310
333
  hints: [],
311
334
  hintsVisible: false,
@@ -316,6 +339,7 @@ function buildPageRenderer({
316
339
  backgroundColor: "rgba(255, 255, 255, 1)",
317
340
  resources: new WeakMap(),
318
341
  decodedImages: [],
342
+ imageElements: new Set(),
319
343
  imagesLoaded: null,
320
344
  // CSS background images by absolute URL: { resource, loaded } (see queueBackgroundImages).
321
345
  backgrounds: new Map(),
@@ -341,15 +365,58 @@ function buildPageRenderer({
341
365
  if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
342
366
  return response;
343
367
  }
368
+ const loadImageResource = createImageResourceLoader(CanvasKit, fetchResource, pageFetch);
369
+ const renderCanvasText = createCanvasTextRenderer(CanvasKit, makeParagraph);
370
+
371
+ function resetDocumentFonts() {
372
+ documentFonts?.dispose();
373
+ fontSheets = null;
374
+ fontDefinitions = [];
375
+ if (fontDefinitionKey !== "[]") clearFontCaches();
376
+ fontEnvironment = "";
377
+ fontDefinitionKey = "[]";
378
+ documentFonts = new DocumentFonts({
379
+ CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection, fetch: pageFetch,
380
+ availableFamilies: new Set([...loadedFamilies.keys(), ...genericFamilies.keys()]),
381
+ changed() {
382
+ if (disposedRenderer) return;
383
+ clearFontCaches();
384
+ // Font metrics also affect CSS ch units and normal line-height.
385
+ styleChanges.full = true;
386
+ page.dirty = true;
387
+ page.resourceLayoutDirty = true;
388
+ page.needsPaint = true;
389
+ scheduleFrame();
390
+ },
391
+ error: error => trace?.(`font failed: ${error?.message ?? error}`),
392
+ });
393
+ }
344
394
 
345
- function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1) {
395
+ function clearFontCaches() {
396
+ resolvedFamilies.clear();
397
+ measuredWidths.clear();
398
+ measuredCount = 0;
399
+ fontKeys = new WeakMap();
400
+ fontMetricsCache.clear();
401
+ clearPaintParagraphs();
402
+ rasterized.list = null;
403
+ }
404
+
405
+ function queueFontLoads() {
406
+ if (page.renderTree && fontDefinitions.length) documentFonts.update(fontDefinitions,
407
+ [...page.renderTree.nodesById.values()].filter(node => node.type === "text"));
408
+ }
409
+
410
+ function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1, foregroundPaint = null) {
346
411
  const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
347
412
  const weight = computedStyle?.fontWeight ?? 400;
348
- const families = resolveFontFamilies(computedStyle?.fontFamily, weight);
349
- const emojiFontFamilies = [
350
- ...families.filter((name) => name.includes("Emoji")),
351
- ...families.filter((name) => !name.includes("Emoji")),
352
- ];
413
+ const fontStyle = computedStyle?.fontStyle ?? "normal";
414
+ const stretch = computedStyle?.fontStretch ?? 100;
415
+ const widths = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200];
416
+ const width = widths.reduce((best, value, index) => Math.abs(value - stretch) < Math.abs(widths[best] - stretch) ? index : best, 4) + 1;
417
+ const slant = fontStyle === "italic" ? CanvasKit.FontSlant.Italic
418
+ : fontStyle === "oblique" ? CanvasKit.FontSlant.Oblique : CanvasKit.FontSlant.Upright;
419
+ const families = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch);
353
420
  // text-decoration lines, drawn by SkParagraph under/over/through the glyphs.
354
421
  const decoration = computedStyle?.decoration ? {
355
422
  decoration: computedStyle.decoration.lines.reduce((mask, line) => mask | ({
@@ -363,30 +430,60 @@ function buildPageRenderer({
363
430
  color,
364
431
  fontFamilies: families,
365
432
  fontSize: Math.max(1, Math.round(fontSize * scale)),
366
- fontStyle: { weight },
433
+ fontStyle: { weight, slant, width },
367
434
  // Roboto is a variable font; static fonts ignore the axis and match by weight.
368
- fontVariations: [{ axis: "wght", value: weight }],
435
+ fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }],
369
436
  ...decoration,
370
437
  },
371
438
  maxLines: 1,
439
+ ...(computedStyle?.direction ? { textDirection: computedStyle.direction === "rtl" ? CanvasKit.TextDirection.RTL : CanvasKit.TextDirection.LTR } : {}),
372
440
  });
373
- const builder = CanvasKit.ParagraphBuilder.MakeFromFontCollection(style, fontCollection);
374
- for (const { segment } of graphemes.segment(text || " ")) {
375
- const emoji = emojiPresentation.test(segment) || segment.includes("\uFE0F");
376
- if (emoji) {
377
- builder.pushStyle(CanvasKit.TextStyle({
378
- color,
379
- ...decoration,
380
- fontFamilies: emojiFontFamilies,
381
- fontSize: Math.max(1, Math.round(fontSize * scale)),
382
- fontStyle: { weight: computedStyle?.fontWeight ?? 400 },
383
- }));
384
- }
385
- builder.addText(segment);
386
- if (emoji) builder.pop();
441
+ const builder = CanvasKit.ParagraphBuilder.MakeFromFontCollection(style, documentFonts?.collection ?? fontCollection);
442
+ const backgroundPaint = foregroundPaint ? new CanvasKit.Paint() : null;
443
+ if (backgroundPaint) backgroundPaint.setColor(CanvasKit.TRANSPARENT);
444
+ const pushTextStyle = foregroundPaint
445
+ ? value => builder.pushPaintStyle(value, foregroundPaint, backgroundPaint) : value => builder.pushStyle(value);
446
+ const textStyle = (fontFamilies) => CanvasKit.TextStyle({ color, ...decoration, fontFamilies,
447
+ fontSize: Math.max(1, Math.round(fontSize * scale)), fontStyle: { weight, slant, width },
448
+ fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }] });
449
+ function append(text, fonts) {
450
+ let emojiStyle;
451
+ const writer = foregroundPaint ? { addText: value => builder.addText(value), pushStyle: pushTextStyle, pop: () => builder.pop() } : builder;
452
+ appendParagraphText(writer, text, () => emojiStyle ??= textStyle([
453
+ ...fonts.filter(name => name.includes("Emoji")), ...fonts.filter(name => !name.includes("Emoji")),
454
+ ]));
387
455
  }
456
+ if (foregroundPaint) pushTextStyle(textStyle(families));
457
+ if (documentFonts?.hasUnicodeRanges && text) {
458
+ // A downloaded subset may physically contain more glyphs than its CSS
459
+ // unicode-range permits. Apply the range when choosing native families,
460
+ // preserving whole adjacent runs for shaping instead of per-letter calls.
461
+ const choices = new Map();
462
+ let run = "", previous = null, previousKey = "";
463
+ const flush = () => {
464
+ if (!run) return;
465
+ pushTextStyle(textStyle(previous));
466
+ append(run, previous);
467
+ builder.pop();
468
+ };
469
+ for (const character of text) {
470
+ const point = character.codePointAt(0);
471
+ let choice = choices.get(point);
472
+ if (!choice) {
473
+ const fonts = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, point, stretch);
474
+ choices.set(point, choice = { fonts, key: fonts.join("\n") });
475
+ }
476
+ if (run && choice.key !== previousKey) { flush(); run = ""; }
477
+ previous = choice.fonts;
478
+ previousKey = choice.key;
479
+ run += character;
480
+ }
481
+ flush();
482
+ } else append(text, families);
483
+ if (foregroundPaint) builder.pop();
388
484
  const paragraph = builder.build();
389
485
  builder.delete();
486
+ backgroundPaint?.delete();
390
487
  return paragraph;
391
488
  }
392
489
 
@@ -420,22 +517,24 @@ function buildPageRenderer({
420
517
  }
421
518
 
422
519
  // Computed styles are immutable and shared, so each one's key is computed once.
423
- const fontKeys = new WeakMap();
520
+ let fontKeys = new WeakMap();
424
521
  function fontKeyOf(computedStyle) {
425
522
  if (computedStyle && typeof computedStyle === "object") {
426
523
  const cached = fontKeys.get(computedStyle);
427
524
  if (cached) return cached;
428
525
  }
429
526
  const weight = computedStyle?.fontWeight ?? 400;
430
- const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${
431
- resolveFontFamilies(computedStyle?.fontFamily, weight).join(",")}`;
527
+ const fontStyle = computedStyle?.fontStyle ?? "normal";
528
+ const stretch = computedStyle?.fontStretch ?? 100;
529
+ const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${fontStyle}/${stretch}/${
530
+ resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch).join(",")}`;
432
531
  if (computedStyle && typeof computedStyle === "object") fontKeys.set(computedStyle, key);
433
532
  return key;
434
533
  }
435
534
 
436
535
  const fontMetricsCache = new Map();
437
536
  function fontMetrics(computedStyle = null) {
438
- const key = `${computedStyle?.fontSize ?? 16}/${computedStyle?.fontWeight ?? 400}/${computedStyle?.fontFamily?.join(",")}`;
537
+ const key = fontKeyOf(computedStyle);
439
538
  let metrics = fontMetricsCache.get(key);
440
539
  if (!metrics) {
441
540
  const paragraph = makeParagraph("Hg", computedStyle);
@@ -452,9 +551,10 @@ function buildPageRenderer({
452
551
  // thread scrolls on its own (keys, fragments, script, a new document).
453
552
  let scrollEpoch = 0;
454
553
  let syncedScroll = { x: 0, y: 0 };
554
+ let syncedViewport = { width: 0, height: 0 };
455
555
  let tileSerial = 0;
456
556
  let lastTile = null;
457
- let encoded = { key: "", png: "" };
557
+ const captures = new FrameCaptureCache();
458
558
  let prepaintTimer = null;
459
559
  let frameTimer = null;
460
560
  let lastRebuildMs = 0;
@@ -469,6 +569,67 @@ function buildPageRenderer({
469
569
  let rasterSurface = null;
470
570
  let rasterSurfaceWidth = 0;
471
571
  let rasterSurfaceHeight = 0;
572
+ const rasterReadback = new RasterReadback(CanvasKit, { shared: Boolean(onFrame), allocate: allocateFramePixels });
573
+ // A scroll or unrelated DOM update does not change text shaping. Keep a
574
+ // bounded set of visible paragraphs keyed by their complete shaping input.
575
+ // Both limits matter: a page may contain many unique or very long strings.
576
+ const paintedParagraphs = new Map();
577
+ let paragraphKeys = new WeakMap();
578
+ let paintedCharacters = 0;
579
+ const MAX_PAINT_PARAGRAPHS = 256;
580
+ const MAX_PAINT_CHARACTERS = 64_000;
581
+
582
+ function releasePaintParagraph(key, entry) {
583
+ entry.paragraph.delete();
584
+ paintedParagraphs.delete(key);
585
+ paintedCharacters -= entry.characters;
586
+ }
587
+
588
+ function clearPaintParagraphs() {
589
+ renderCanvasText.clear();
590
+ for (const entry of paintedParagraphs.values()) entry.paragraph.delete();
591
+ paintedParagraphs.clear();
592
+ paragraphKeys = new WeakMap();
593
+ paintedCharacters = 0;
594
+ }
595
+
596
+ function paragraphForPaint(item, scale, width) {
597
+ const retained = item.text.length <= 4096;
598
+ let cachedKey = retained && paragraphKeys.get(item);
599
+ if (retained && (!cachedKey || cachedKey.scale !== scale || cachedKey.width !== width)) {
600
+ cachedKey = { scale, width, key: JSON.stringify([item.text, item.color, item.font,
601
+ item.decoration, scale, width]) };
602
+ paragraphKeys.set(item, cachedKey);
603
+ }
604
+ const key = cachedKey?.key;
605
+ let entry = retained && paintedParagraphs.get(key);
606
+ if (entry) {
607
+ paintedParagraphs.delete(key);
608
+ paintedParagraphs.set(key, entry);
609
+ return entry;
610
+ }
611
+ const paragraph = makeParagraph(item.text, {
612
+ color: item.color, fontSize: item.font.size, fontWeight: item.font.weight, fontFamily: item.font.family,
613
+ fontStyle: item.font.style, fontStretch: item.font.stretch,
614
+ decoration: item.decoration,
615
+ }, undefined, scale);
616
+ try {
617
+ paragraph.layout(width);
618
+ } catch (error) {
619
+ paragraph.delete();
620
+ throw error;
621
+ }
622
+ entry = { paragraph, retained, characters: item.text.length };
623
+ if (entry.retained) {
624
+ paintedParagraphs.set(key, entry);
625
+ paintedCharacters += item.text.length;
626
+ while (paintedParagraphs.size > MAX_PAINT_PARAGRAPHS || paintedCharacters > MAX_PAINT_CHARACTERS) {
627
+ const [oldKey, oldEntry] = paintedParagraphs.entries().next().value;
628
+ releasePaintParagraph(oldKey, oldEntry);
629
+ }
630
+ }
631
+ return entry;
632
+ }
472
633
 
473
634
  /** Hint overlay contents, as a comparable key. */
474
635
  function overlayKey() {
@@ -485,6 +646,8 @@ function buildPageRenderer({
485
646
  if (page.displayList?.items.some((item) => item.fixed)
486
647
  && (rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y)) return false;
487
648
  if (rasterized.list === page.displayList) return true;
649
+ if (rasterized.list?.images.length !== page.displayList?.images.length
650
+ || page.displayList?.images.some((resource, index) => resource !== rasterized.list.images[index])) return false;
488
651
  if (!rasterized.list || !page.displayList || !Bun.deepEquals(rasterized.list.items, page.displayList.items)) return false;
489
652
  rasterized.list = page.displayList;
490
653
  return true;
@@ -492,6 +655,12 @@ function buildPageRenderer({
492
655
 
493
656
  /** Rasterizes the viewport and hands it to the compositor. */
494
657
  function render() {
658
+ if (disposedRenderer) return;
659
+ if (page.resourcePaintDirty) {
660
+ page.displayList = paintList();
661
+ page.resourcePaintDirty = false;
662
+ }
663
+ page.realm?.layoutChanged(page.layoutVersion);
495
664
  if (onVideo) for (const state of page.media.values()) readPresentedFrame(state);
496
665
  const width = Math.max(1, Math.floor(page.width));
497
666
  const height = Math.max(1, Math.floor(page.height));
@@ -499,15 +668,27 @@ function buildPageRenderer({
499
668
  if (lastTile && tileCovers(lastTile, region) && sameRasterInput()) {
500
669
  page.needsPaint = false;
501
670
  // Only this thread's scroll position changed: tell the compositor.
502
- if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y) publish(lastTile);
671
+ if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y
672
+ || page.width !== syncedViewport.width || page.height !== syncedViewport.height) publish(lastTile);
503
673
  } else {
504
- publish(rasterTile(region));
674
+ // Keep a previously prepainted region during ordinary page updates.
675
+ // Shrinking to the viewport and expanding again 30ms later used to
676
+ // rasterize each animation twice and repeatedly resize native surfaces.
677
+ const retained = !page.loading && lastTile && tileCovers(lastTile, region)
678
+ && lastTile.height <= height * 2.5;
679
+ publish(rasterTile(retained ? { x: lastTile.x, y: lastTile.y, width: lastTile.width, height: lastTile.height } : region));
505
680
  }
506
681
  schedulePrepaint();
507
682
  }
508
683
 
509
684
  /** Rasterizes a page rectangle (CSS pixels) into RGBA pixels. */
510
685
  function rasterTile(region) {
686
+ // Prepaint can run before a queued canvas/image repaint. Refresh native
687
+ // snapshots here too: the old display list may hold a released image.
688
+ if (page.resourcePaintDirty) {
689
+ page.displayList = paintList();
690
+ page.resourcePaintDirty = false;
691
+ }
511
692
  const scale = page.deviceScaleFactor;
512
693
  const pixelWidth = Math.max(1, Math.round(region.width * scale));
513
694
  const pixelHeight = Math.max(1, Math.round(region.height * scale));
@@ -526,14 +707,9 @@ function buildPageRenderer({
526
707
  drawHints(canvas, region);
527
708
  surface.flush();
528
709
  const image = surface.makeImageSnapshot();
529
- const pixels = image.readPixels(0, 0, {
530
- width: pixelWidth,
531
- height: pixelHeight,
532
- colorType: CanvasKit.ColorType.RGBA_8888,
533
- alphaType: CanvasKit.AlphaType.Unpremul,
534
- colorSpace: CanvasKit.ColorSpace.SRGB,
535
- });
536
- image.delete();
710
+ let pixels;
711
+ try { pixels = rasterReadback.read(image, pixelWidth, pixelHeight); }
712
+ finally { image.delete(); }
537
713
  const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
538
714
  page.needsPaint = false;
539
715
  rasterized = {
@@ -557,6 +733,7 @@ function buildPageRenderer({
557
733
  function publish(tile) {
558
734
  lastTile = tile;
559
735
  tileSerial++;
736
+ syncedViewport = { width: page.width, height: page.height };
560
737
  if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y) {
561
738
  scrollEpoch++;
562
739
  syncedScroll = { x: viewport.x, y: viewport.y };
@@ -587,7 +764,7 @@ function buildPageRenderer({
587
764
 
588
765
  /** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
589
766
  function schedulePrepaint() {
590
- if (!onFrame || embedded) return;
767
+ if (disposedRenderer || !onFrame || embedded) return;
591
768
  hostClearTimeout(prepaintTimer);
592
769
  prepaintTimer = hostSetTimeout(() => {
593
770
  prepaintTimer = null;
@@ -606,7 +783,7 @@ function buildPageRenderer({
606
783
 
607
784
  /** A rendering opportunity for DOM changes nobody has asked to see yet. */
608
785
  function scheduleFrame() {
609
- if (!onFrame || frameTimer || page.loading) return;
786
+ if (disposedRenderer || !onFrame || frameTimer || page.loading) return;
610
787
  frameTimer = hostSetTimeout(() => {
611
788
  frameTimer = null;
612
789
  refresh();
@@ -616,15 +793,14 @@ function buildPageRenderer({
616
793
  }, Math.max(50, lastRebuildMs * 2));
617
794
  }
618
795
 
619
- /** The viewport as a PNG (base64), cut from the latest tile. */
620
- async function viewportPng() {
796
+ /** The viewport as an encoded image (base64), cut from the latest tile. */
797
+ async function viewportPng({ format = "png", quality = 80, optimizeForSpeed = false } = {}) {
621
798
  if (!lastTile) return "";
622
- const key = `${tileSerial}:${viewport.x}:${viewport.y}`;
623
- if (encoded.key !== key) {
799
+ const key = `${tileSerial}:${viewport.x}:${viewport.y}:${page.width}:${page.height}:${format}:${quality}:${optimizeForSpeed}`;
800
+ return captures.get(key, () => {
624
801
  const { pixels, width, height } = cropViewport(lastTile, viewport.x, viewport.y, page.width, page.height);
625
- encoded = { key, png: Buffer.from(await encodeImage(pixels, width, height)).toString("base64") };
626
- }
627
- return encoded.png;
802
+ return encodeImageBase64(pixels, width, height, { format, quality, optimizeForSpeed });
803
+ });
628
804
  }
629
805
 
630
806
  /** Rasterizes display items intersecting the viewport onto a CanvasKit canvas. */
@@ -637,11 +813,12 @@ function buildPageRenderer({
637
813
  pixel(rect.x - view.x), pixel(rect.y - view.y), pixel(rect.width), pixel(rect.height),
638
814
  );
639
815
  const deviceRRect = (rect, radii) => radii && (radii.x > 0 || radii.y > 0)
640
- ? CanvasKit.RRectXY(deviceRect(rect), radii.x * scale, radii.y * scale)
816
+ ? roundedRect(deviceRect(rect), radii, scale)
641
817
  : null;
642
818
  // Returns a paint plus a release function that also frees its shader.
643
819
  const fillPaint = (item) => {
644
820
  const paint = new CanvasKit.Paint();
821
+ if (item.radii && (item.radii.x > 0 || item.radii.y > 0)) paint.setAntiAlias(true);
645
822
  const shader = item.gradient ? gradientShader(item.gradient, view, scale) : null;
646
823
  if (shader) paint.setShader(shader);
647
824
  else paint.setColor(canvasColor(CanvasKit, item.color));
@@ -831,25 +1008,23 @@ function buildPageRenderer({
831
1008
  } else if (item.op === "drawSvg") {
832
1009
  drawSvg(item);
833
1010
  } else if (item.op === "drawText") {
834
- const textStyle = {
835
- color: item.color, fontSize: item.font.size, fontWeight: item.font.weight, fontFamily: item.font.family,
836
- decoration: item.decoration,
837
- };
838
- const paragraph = makeParagraph(item.text, textStyle, undefined, scale);
839
- paragraph.layout(pixel(Math.max(1, view.width + view.x - item.x)));
840
- if (item.gradient) {
841
- // background-clip:text: draw glyphs, then keep the gradient only where they are.
842
- canvas.saveLayer(null, deviceRect(item.bounds));
843
- canvas.drawParagraph(paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
844
- const paint = fillPaint(item);
845
- paint.setBlendMode(CanvasKit.BlendMode.SrcIn);
846
- canvas.drawRect(deviceRect(item.bounds), paint);
847
- paint.release();
848
- canvas.restore();
849
- } else {
850
- canvas.drawParagraph(paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
1011
+ const entry = paragraphForPaint(item, scale, pixel(Math.max(1, view.width + view.x - item.x)));
1012
+ try {
1013
+ if (item.gradient) {
1014
+ // background-clip:text: draw glyphs, then keep the gradient only where they are.
1015
+ canvas.saveLayer(null, deviceRect(item.bounds));
1016
+ canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
1017
+ const paint = fillPaint(item);
1018
+ paint.setBlendMode(CanvasKit.BlendMode.SrcIn);
1019
+ canvas.drawRect(deviceRect(item.bounds), paint);
1020
+ paint.release();
1021
+ canvas.restore();
1022
+ } else {
1023
+ canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
1024
+ }
1025
+ } finally {
1026
+ if (!entry.retained) entry.paragraph.delete();
851
1027
  }
852
- paragraph.delete();
853
1028
  }
854
1029
  }
855
1030
  }
@@ -866,13 +1041,9 @@ function buildPageRenderer({
866
1041
  const Y0 = (item.rect.y - view.y) * scale;
867
1042
  const X1 = X0 + item.rect.width * scale;
868
1043
  const Y1 = Y0 + item.rect.height * scale;
869
- const rx = item.radii.x * scale;
870
- const ry = item.radii.y * scale;
871
1044
  const [x0, y0, x1, y1] = [X0 + left, Y0 + top, Math.max(X0 + left, X1 - right), Math.max(Y0 + top, Y1 - bottom)];
872
- const outer = Float32Array.of(X0, Y0, X1, Y1, rx, ry, rx, ry, rx, ry, rx, ry);
873
- const inner = Float32Array.of(x0, y0, x1, y1,
874
- Math.max(0, rx - left), Math.max(0, ry - top), Math.max(0, rx - right), Math.max(0, ry - top),
875
- Math.max(0, rx - right), Math.max(0, ry - bottom), Math.max(0, rx - left), Math.max(0, ry - bottom));
1045
+ const outer = roundedRect([X0, Y0, X1, Y1], item.radii, scale);
1046
+ const inner = roundedRect([x0, y0, x1, y1], insetRadii(item.radii, item.widths), scale);
876
1047
  const sides = [
877
1048
  [X0, Y0, X1, Y0, x1, y0, x0, y0],
878
1049
  [X1, Y0, X1, Y1, x1, y1, x1, y0],
@@ -1029,6 +1200,29 @@ function buildPageRenderer({
1029
1200
  }
1030
1201
  }
1031
1202
 
1203
+ function nativeControlState(element) {
1204
+ return [element.value, element.checked, element.indeterminate, element.selected, element.selectedIndex];
1205
+ }
1206
+
1207
+ /** HTML activation can change control properties without attribute records
1208
+ * (including a different radio in the same group). Compare these few native
1209
+ * states instead of restyling the entire document for every pointer click.
1210
+ */
1211
+ function controlStatesBeforeActivation() {
1212
+ return new Map(Array.from(page.document.querySelectorAll("input,select,textarea,option"),
1213
+ element => [element,nativeControlState(element)]));
1214
+ }
1215
+
1216
+ function invalidateActivatedControls(previous) {
1217
+ for (const [element,before] of previous) {
1218
+ const after = nativeControlState(element);
1219
+ if (before.some((value,index) => value !== after[index])) {
1220
+ markStyleDirty(element);
1221
+ page.dirty = true;
1222
+ }
1223
+ }
1224
+ }
1225
+
1032
1226
  function observeStyleChanges(document) {
1033
1227
  styleChanges.observer?.disconnect();
1034
1228
  styleChanges.records = [];
@@ -1036,6 +1230,7 @@ function buildPageRenderer({
1036
1230
  styleChanges.rootFeatures = new Map();
1037
1231
  styleChanges.full = "new document";
1038
1232
  styleChanges.activeElement = null;
1233
+ styleChanges.focusAncestors = new Set();
1039
1234
  const observer = new page.window.MutationObserver((records) => { styleChanges.records.push(...records); });
1040
1235
  observer.observe(document, { subtree: true, childList: true, attributes: true, attributeOldValue: true, characterData: true });
1041
1236
  styleChanges.observer = observer;
@@ -1060,6 +1255,7 @@ function buildPageRenderer({
1060
1255
  descendantFeatures: union("descendantFeatures"), siblingFeatures: union("siblingFeatures"),
1061
1256
  descendantStateCompounds: parts.flatMap((part) => part.descendantStateCompounds),
1062
1257
  siblingTargetCompounds: [...union("siblingTargetCompounds")],
1258
+ observedAttributes: union("observedAttributes"),
1063
1259
  forward: any("forward"), firstChild: any("firstChild"), backward: any("backward"), lastChild: any("lastChild"),
1064
1260
  defined: any("defined"), target: any("target"),
1065
1261
  };
@@ -1189,11 +1385,12 @@ function buildPageRenderer({
1189
1385
  let treeDirty = false;
1190
1386
  let full = styleChanges.full;
1191
1387
  const active = page.document.activeElement;
1192
- if (active !== styleChanges.activeElement) {
1193
- // :focus, :focus-within and :focus-visible follow the focused element.
1194
- if (styleChanges.activeElement !== null) full ||= "focus moved";
1195
- styleChanges.activeElement = active;
1196
- }
1388
+ const previousActive = styleChanges.activeElement;
1389
+ const previousFocusAncestors = styleChanges.focusAncestors;
1390
+ const focusAncestors = new Set();
1391
+ for (let element = active; element; element = element.parentElement) focusAncestors.add(element);
1392
+ styleChanges.activeElement = active;
1393
+ styleChanges.focusAncestors = focusAncestors;
1197
1394
  // Elements whose subtree restyles (`roots`), and elements whose own
1198
1395
  // matches may change but whose subtree restyles only if their style does
1199
1396
  // (`self`, decided by the style engine).
@@ -1221,7 +1418,8 @@ function buildPageRenderer({
1221
1418
  if (!states.length) return false;
1222
1419
  const available = new Set(elementFeatures(element));
1223
1420
  return traits.descendantStateCompounds.some((entry) => (states.includes(":state") || entry.states.some((state) => states.includes(state)))
1224
- && entry.required.every((feature) => available.has(feature)));
1421
+ && entry.required.every((feature) => available.has(feature))
1422
+ && entry.alternatives.every(group => group.some(required => required.every(feature => available.has(feature)))));
1225
1423
  };
1226
1424
  /** An element whose features changed: itself, and wider where non-subject compounds use them. */
1227
1425
  const changed = (element, features) => {
@@ -1238,22 +1436,38 @@ function buildPageRenderer({
1238
1436
  if (matchesChanged(structuralState, element)) roots.add(element);
1239
1437
  };
1240
1438
  if (!full && !hasState) full = "first restyle";
1241
- // Marked elements: state outside the DOM (checked, value) or a DOM change
1242
- // the records also describe; both start as a change of the element's state.
1243
- for (const element of [...roots]) {
1244
- roots.delete(element);
1439
+ // Only the explicitly marked roots need converting to feature changes;
1440
+ // roots added by relational/focus invalidation below must remain roots.
1441
+ const markedRoots = [...roots];
1442
+ roots.clear();
1443
+ for (const element of markedRoots) {
1245
1444
  if (!full) changed(element, styleChanges.rootFeatures.get(element) ?? [":state"]);
1246
1445
  }
1446
+ if (!full) {
1447
+ // Focus affects the two subjects, their :focus-within ancestor paths,
1448
+ // and selectors related to those nodes. Keep the previous path because
1449
+ // the old focused element may have been removed or reparented already.
1450
+ if (active !== previousActive) {
1451
+ if (previousActive) changed(previousActive, [":focus", ":focus-visible"]);
1452
+ if (active) changed(active, [":focus", ":focus-visible"]);
1453
+ }
1454
+ for (const element of previousFocusAncestors) if (!focusAncestors.has(element)) changed(element, [":focus-within"]);
1455
+ for (const element of focusAncestors) if (!previousFocusAncestors.has(element)) changed(element, [":focus-within"]);
1456
+ }
1247
1457
  for (const record of records) {
1248
1458
  if (full) break;
1249
1459
  const target = record.target;
1250
1460
  if (record.type === "attributes") {
1251
1461
  if (target.nodeType !== 1) continue;
1252
1462
  const name = record.attributeName;
1463
+ // Happy DOM reports null for an empty attribute's previous value on
1464
+ // removal. Null therefore cannot prove that the attribute is unchanged.
1465
+ if (record.oldValue !== null && record.oldValue === target.getAttribute(name)) continue;
1253
1466
  // data/ARIA/title changes have no direct render-tree representation;
1254
1467
  // selectors that observe them are handled by the style diff below.
1255
1468
  // Other attributes may alter controls, replaced elements or visibility.
1256
1469
  if (!name.startsWith("data-") && !name.startsWith("aria-") && name !== "title") treeDirty = true;
1470
+ else if (!traits.observedAttributes.has(name.toLowerCase())) continue;
1257
1471
  const features = ["[" + name];
1258
1472
  if (name === "class") {
1259
1473
  const before = new Set(String(record.oldValue ?? "").split(/\s+/).filter(Boolean));
@@ -1266,6 +1480,12 @@ function buildPageRenderer({
1266
1480
  if (id) features.push("#" + id);
1267
1481
  }
1268
1482
  changed(target, features);
1483
+ // The UA disclosure marker depends on its parent's open state even
1484
+ // when no author selector observes [open].
1485
+ if (name === "open" && target.tagName === "DETAILS") {
1486
+ const summary = [...target.children].find(element => element.tagName === "SUMMARY");
1487
+ if (summary) self.add(summary);
1488
+ }
1269
1489
  } else if (record.type === "childList") {
1270
1490
  treeDirty = true;
1271
1491
  if (target.nodeType !== 1) { full = `children of ${target.nodeName}`; break; }
@@ -1280,6 +1500,11 @@ function buildPageRenderer({
1280
1500
  }
1281
1501
  }
1282
1502
  self.add(target);
1503
+ // Adding, removing or moving a summary can change which one receives
1504
+ // the UA list-item display and disclosure marker.
1505
+ if (target.tagName === "DETAILS") {
1506
+ for (const child of target.children) if (child.tagName === "SUMMARY") self.add(child);
1507
+ }
1283
1508
  hasChanges(traits, target, add, hasChecked);
1284
1509
  let next = record.nextSibling;
1285
1510
  while (next && next.nodeType !== 1) next = next.nextSibling;
@@ -1318,16 +1543,26 @@ function buildPageRenderer({
1318
1543
  }
1319
1544
 
1320
1545
  function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
1546
+ if (page.document) loadImageResource.cancelDocument(page.document);
1547
+ rasterReadback.dispose();
1548
+ clearPaintParagraphs();
1549
+ resetDocumentFonts();
1321
1550
  disposeFrames();
1322
1551
  closePiano();
1323
1552
  disposeVideos();
1324
1553
  page.realm?.dispose();
1325
1554
  page.realm = null;
1326
- page.window?.happyDOM?.abort?.();
1555
+ page.window?.happyDOM?.close?.();
1327
1556
  for (const image of page.decodedImages) image.delete();
1328
1557
  disposeBackgrounds();
1329
1558
  page.window = parsed.window;
1330
1559
  page.document = parsed.document;
1560
+ observeDocumentCanvases(parsed.document, (node) => {
1561
+ if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
1562
+ page.resourcePaintDirty = true;
1563
+ page.needsPaint = true;
1564
+ scheduleFrame();
1565
+ });
1331
1566
  observeStyleChanges(parsed.document);
1332
1567
  page.title = parsed.document.title;
1333
1568
  page.styleSheets = styleSheets;
@@ -1335,6 +1570,8 @@ function buildPageRenderer({
1335
1570
  page.resources = imageResources.map;
1336
1571
  initializeVideos(parsed.document, page.resources);
1337
1572
  page.decodedImages = imageResources.decoded;
1573
+ page.imageElements = new Set(imageResources.entries.map(entry => entry.element));
1574
+ page.resourcePaintDirty = false;
1338
1575
  page.imagesLoaded = null;
1339
1576
  imageRequests = new WeakMap();
1340
1577
  page.elementScroll = new Map();
@@ -1355,6 +1592,20 @@ function buildPageRenderer({
1355
1592
  const ms = (from, to) => Math.round(to - from);
1356
1593
  const sheets = currentStyleSheets();
1357
1594
  const environment = { width: page.width, height: page.height, mobile };
1595
+ const documentBaseURL = page.document.baseURI || page.url;
1596
+ const environmentKey = `${page.width}/${page.height}/${mobile}/${documentBaseURL}`;
1597
+ if (fontSheets !== sheets || fontEnvironment !== environmentKey) {
1598
+ fontSheets = sheets;
1599
+ fontEnvironment = environmentKey;
1600
+ fontDefinitions = sheets.flatMap(sheet => /@font-face\b/i.test(sheet) ? extractFontFaces(sheet, documentBaseURL, environment) : []);
1601
+ documentFonts.setFaces(fontDefinitions);
1602
+ const key = JSON.stringify(fontDefinitions);
1603
+ if (key !== fontDefinitionKey) {
1604
+ fontDefinitionKey = key;
1605
+ clearFontCaches();
1606
+ page.resourceLayoutDirty = true;
1607
+ }
1608
+ }
1358
1609
  const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
1359
1610
  const taken = hostPerformance.now();
1360
1611
  // A rendering opportunity can be scheduled by lifecycle work which did
@@ -1404,17 +1655,19 @@ function buildPageRenderer({
1404
1655
  measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
1405
1656
  });
1406
1657
  page.displayList = paintList();
1658
+ page.resourcePaintDirty = false;
1407
1659
  // The content may now reach less far than a container was scrolled.
1408
1660
  if (clampElementScroll()) page.displayList = paintList();
1409
1661
  page.resourceLayoutDirty = false;
1410
1662
  laidOutSheets = sheets;
1411
1663
  setContentSize();
1664
+ queueFontLoads();
1412
1665
  return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
1413
1666
  }
1414
1667
 
1415
1668
  /** BUNINU_VERIFY_STYLE: compare an incremental restyle with a full one, element by element. */
1416
1669
  function verifyIncrementalStyle(sheets, environment) {
1417
- const reference = new StyleEngine().compute(page.document, sheets, environment);
1670
+ const reference = new StyleEngine({ measureText }).compute(page.document, sheets, environment);
1418
1671
  const serialize = (style) => JSON.stringify(style, (key, value) => typeof value === "bigint" ? String(value) : value);
1419
1672
  let mismatches = 0;
1420
1673
  for (const element of page.document.querySelectorAll("*")) {
@@ -1450,13 +1703,14 @@ function buildPageRenderer({
1450
1703
  staleImages = queueImageResources();
1451
1704
  }
1452
1705
  const timing = styleAndLayout();
1453
- for (const image of staleImages) {
1454
- const index = page.decodedImages.indexOf(image);
1455
- if (index >= 0) page.decodedImages.splice(index, 1);
1456
- image.delete();
1706
+ if (staleImages.length) {
1707
+ const stale = new Set(staleImages);
1708
+ page.decodedImages = page.decodedImages.filter(image => !stale.has(image));
1709
+ for (const image of stale) image.delete();
1457
1710
  }
1458
1711
  if (!timing.skipped) page.needsPaint = true;
1459
1712
  syncFrames();
1713
+ page.realm?.layoutChanged(page.layoutVersion);
1460
1714
  if (!timing.skipped) queueBackgroundImages();
1461
1715
  lastRebuildMs = hostPerformance.now() - started;
1462
1716
  trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
@@ -1475,6 +1729,7 @@ function buildPageRenderer({
1475
1729
  * page); the rest of the load continues, see `whenLoaded`.
1476
1730
  */
1477
1731
  function loadDocument(document) {
1732
+ if (disposedRenderer) return Promise.reject(new Error("Renderer has been disposed"));
1478
1733
  const generation = ++loadGeneration;
1479
1734
  let commit;
1480
1735
  const committed = new Promise((resolve, reject) => {
@@ -1494,7 +1749,7 @@ function buildPageRenderer({
1494
1749
  }
1495
1750
 
1496
1751
  async function runLoad({
1497
- url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "",
1752
+ url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "", referrer = "",
1498
1753
  }, generation, commit) {
1499
1754
  const current = () => generation === loadGeneration;
1500
1755
  const finish = () => {
@@ -1538,7 +1793,7 @@ function buildPageRenderer({
1538
1793
  phaseStart = now;
1539
1794
  } : () => {};
1540
1795
  const isHtml = contentType.toLowerCase().includes("html");
1541
- const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url);
1796
+ const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url, referrer);
1542
1797
  phase("parse");
1543
1798
  // Stylesheets block rendering and scripts; images do not (only the
1544
1799
  // window load event waits for them, see whenImagesLoaded): one slow image
@@ -1605,6 +1860,9 @@ function buildPageRenderer({
1605
1860
  refresh();
1606
1861
  phase("style, layout and raster after scripts");
1607
1862
  } else {
1863
+ await documentFonts.ready();
1864
+ if (!current()) return;
1865
+ refresh();
1608
1866
  finish();
1609
1867
  }
1610
1868
  }
@@ -1620,7 +1878,7 @@ function buildPageRenderer({
1620
1878
  const preloaded = new Map();
1621
1879
  const preload = (href) => {
1622
1880
  let target;
1623
- try { target = new URL(href, documentUrl); } catch { return; }
1881
+ try { target = new URL(href, document.baseURI || documentUrl); } catch { return; }
1624
1882
  if (!["http:", "https:"].includes(target.protocol) || preloaded.has(target.href)) return;
1625
1883
  const response = fetchResource(target.href, "script");
1626
1884
  response.catch(() => {}); // reported when the script runs
@@ -1636,7 +1894,8 @@ function buildPageRenderer({
1636
1894
 
1637
1895
  /** Frees what a superseded load fetched. */
1638
1896
  async function discard(images, parsed = null) {
1639
- parsed?.window?.happyDOM?.abort?.();
1897
+ if (parsed?.document) loadImageResource.cancelDocument(parsed.document);
1898
+ parsed?.window?.happyDOM?.close?.();
1640
1899
  const loaded = await images.catch(() => emptyImageResources());
1641
1900
  for (const image of loaded.decoded) image.delete();
1642
1901
  }
@@ -1651,7 +1910,7 @@ function buildPageRenderer({
1651
1910
  // queueImageResources must not fetch these again.
1652
1911
  for (const element of document.querySelectorAll("img")) {
1653
1912
  const src = element.getAttribute("src");
1654
- if (src) imageRequests.set(element, src);
1913
+ if (src) imageRequests.set(element, imageRequestKey(src, imageCrossOrigin(element)));
1655
1914
  }
1656
1915
  page.imagesLoaded = images.then((loaded) => {
1657
1916
  if (generation !== loadGeneration || document !== page.document) {
@@ -1659,12 +1918,13 @@ function buildPageRenderer({
1659
1918
  return;
1660
1919
  }
1661
1920
  for (const { element, src, resource } of loaded.entries) {
1662
- if (element.getAttribute("src") !== src || page.resources.get(element)) {
1921
+ if (element.getAttribute("src") !== src || imageCrossOrigin(element) !== resource.crossOrigin || page.resources.get(element)) {
1663
1922
  resource.image.delete();
1664
1923
  continue;
1665
1924
  }
1666
1925
  page.resources.set(element, resource);
1667
1926
  page.decodedImages.push(resource.image);
1927
+ page.imageElements.add(element);
1668
1928
  finishImageLoad(element, src, resource, type => element.dispatchEvent(new page.window.Event(type)));
1669
1929
  }
1670
1930
  page.dirty = true;
@@ -1675,18 +1935,44 @@ function buildPageRenderer({
1675
1935
 
1676
1936
  function inlineStyleSheets(document) {
1677
1937
  return [...document.querySelectorAll("style")]
1678
- .filter((element) => !element.closest("noscript"))
1938
+ .filter((element) => isCssStyleSheet(element) && styleSheetMatches(element))
1679
1939
  .map((element) => element.textContent ?? "");
1680
1940
  }
1681
1941
 
1942
+ function isCssStyleSheet(element) {
1943
+ const type = element.getAttribute("type")?.trim().toLowerCase();
1944
+ return (!type || type === "text/css") && !element.closest("noscript");
1945
+ }
1946
+
1947
+ function styleSheetMatches(element) {
1948
+ return mediaQueryMatches(element.getAttribute("media") ?? "", { width: page.width, height: page.height, mobile })
1949
+ && !(element.tagName === "LINK" && element.hasAttribute("disabled"));
1950
+ }
1951
+
1682
1952
  /** Brings style, layout and raster up to date; returns whether it repainted. */
1683
1953
  function refresh() {
1954
+ if (disposedRenderer) return false;
1684
1955
  if (page.dirty) rebuildRenderTree();
1685
1956
  if (!page.needsPaint) return false;
1686
1957
  render();
1687
1958
  return true;
1688
1959
  }
1689
1960
 
1961
+ function isDisclosureControl(element) {
1962
+ if (element?.tagName === "DETAILS") return ![...element.children].some(child => child.tagName === "SUMMARY");
1963
+ return element?.tagName === "SUMMARY" && element.parentElement?.tagName === "DETAILS"
1964
+ && [...element.parentElement.children].find(child => child.tagName === "SUMMARY") === element;
1965
+ }
1966
+
1967
+ function activateDisclosure(element) {
1968
+ const prevented = page.realm.dispatch(element, new page.window.MouseEvent("click", {
1969
+ bubbles: true, cancelable: true, composed: true, detail: 0,
1970
+ }));
1971
+ // An explicit summary is activated by the DOM's click default action.
1972
+ // The generated label has no DOM summary, so its owner toggles here.
1973
+ if (!prevented && element.tagName === "DETAILS") element.open = !element.open;
1974
+ }
1975
+
1690
1976
  function editFocusedText(text, backspace = false) {
1691
1977
  const element = page.document.activeElement;
1692
1978
  if (!element || !["INPUT", "TEXTAREA"].includes(element.tagName) || element.disabled || element.readOnly) return;
@@ -1765,6 +2051,31 @@ function buildPageRenderer({
1765
2051
  page.dirty = true;
1766
2052
  scheduleFrame();
1767
2053
  },
2054
+ activationState: controlStatesBeforeActivation,
2055
+ activationChanged(previous) {
2056
+ invalidateActivatedControls(previous);
2057
+ if (page.dirty) scheduleFrame();
2058
+ },
2059
+ canvasImage(element) {
2060
+ if (!element) return null;
2061
+ if (element.tagName === "CANVAS") return canvasResource(element);
2062
+ const resource = page.resources.get(element);
2063
+ if (!resource?.image) return null;
2064
+ const url = resource.url ?? element.src ?? "";
2065
+ const originClean = resource.originClean ?? (url.startsWith("data:") || originOf(url) === originOf(page.url));
2066
+ return { ...resource, originClean };
2067
+ },
2068
+ canvasFont(element, value) {
2069
+ return parseCanvasFont(value, styleEngine.get(element)?.fontSize ?? 16,
2070
+ styleEngine.get(page.document.documentElement)?.fontSize ?? 16);
2071
+ },
2072
+ canvasText(element, request) {
2073
+ const style = { ...request.settings.parsedFont,
2074
+ direction: request.settings.direction === "inherit" ? styleEngine.get(element)?.direction ?? "ltr" : request.settings.direction };
2075
+ documentFonts.update(fontDefinitions, [{ text: String(request.args[0]), style }]);
2076
+ return renderCanvasText({ ...request, style,
2077
+ settings: { ...request.settings, direction: style.direction } });
2078
+ },
1768
2079
  mediaState,
1769
2080
  playMedia,
1770
2081
  pauseMedia,
@@ -1788,7 +2099,7 @@ function buildPageRenderer({
1788
2099
  scheduleFrame();
1789
2100
  }
1790
2101
  },
1791
- whenImagesLoaded: () => Promise.all([page.imagesLoaded, ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
2102
+ whenImagesLoaded: () => Promise.all([page.imagesLoaded, documentFonts.ready(), ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
1792
2103
  afterTask() {},
1793
2104
  boundsOf(element) {
1794
2105
  if (page.dirty) rebuildRenderTree("script read geometry");
@@ -1820,9 +2131,9 @@ function buildPageRenderer({
1820
2131
  if (page.dirty) rebuildRenderTree("script scrolled");
1821
2132
  setElementScroll(element, x, y);
1822
2133
  },
1823
- scrollIntoView(element) {
2134
+ scrollIntoView(element, options) {
1824
2135
  if (page.dirty) rebuildRenderTree("script scrolled");
1825
- revealElement(element);
2136
+ revealElement(element, options);
1826
2137
  },
1827
2138
  computedStyle(element) {
1828
2139
  if (!element) return null;
@@ -1845,7 +2156,7 @@ function buildPageRenderer({
1845
2156
  },
1846
2157
  navigate: (url) => navigateTo(String(url)),
1847
2158
  activateLink(anchor) {
1848
- const href = new URL(anchor.getAttribute("href"), page.url).href;
2159
+ const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
1849
2160
  const name = anchor.hasAttribute("download") ? anchor.getAttribute("download") ?? "" : null;
1850
2161
  if (href.startsWith("blob:") && name !== null) void downloadBlob(href, name);
1851
2162
  else navigateTo(href, name);
@@ -1887,7 +2198,8 @@ function buildPageRenderer({
1887
2198
  if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
1888
2199
  return response;
1889
2200
  });
1890
- result.abort = () => networkPending.abort?.();
2201
+ result.abort = (reason) => networkPending.abort?.(reason);
2202
+ result.finished = networkPending.finished;
1891
2203
  return result;
1892
2204
  },
1893
2205
  };
@@ -1901,7 +2213,7 @@ function buildPageRenderer({
1901
2213
  function submitForm(form, submitter = null) {
1902
2214
  const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
1903
2215
  const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
1904
- const url = new URL(action || page.url, page.url);
2216
+ const url = new URL(action || page.url, form.baseURI || page.url);
1905
2217
  const entries = [...new page.window.FormData(form, submitter)];
1906
2218
  if (method === "post") {
1907
2219
  const enctype = (submitter?.getAttribute("formenctype") ?? form.getAttribute("enctype") ?? "").toLowerCase();
@@ -1951,14 +2263,7 @@ function buildPageRenderer({
1951
2263
  const shift = scrollShift(element);
1952
2264
  const scrollX = (fixed ? 0 : viewport.x) + shift.x;
1953
2265
  const scrollY = (fixed ? 0 : viewport.y) + shift.y;
1954
- let nodeId = null;
1955
- for (const node of page.renderTree.nodesById.values()) {
1956
- if (node.domNode === element && !node.pseudo) {
1957
- nodeId = node.id;
1958
- break;
1959
- }
1960
- }
1961
- const box = nodeId == null ? null : page.layout.boxes.find((candidate) => candidate.nodeId === nodeId && !candidate.inline);
2266
+ const box = elementBox(page.renderTree, page.layout, element)?.box;
1962
2267
  if (box) return { left: box.x - scrollX, top: box.y - scrollY, width: box.width, height: box.height };
1963
2268
  const content = elementBounds(page.renderTree, page.layout, element);
1964
2269
  return content
@@ -2142,9 +2447,9 @@ function buildPageRenderer({
2142
2447
  rebuildRenderTree();
2143
2448
  render();
2144
2449
  },
2145
- screenshot() {
2450
+ screenshot(options) {
2146
2451
  refresh();
2147
- return viewportPng();
2452
+ return viewportPng(options);
2148
2453
  },
2149
2454
  /**
2150
2455
  * Diagnostics (not exposed over IPC): page-space border boxes and chosen
@@ -2212,6 +2517,7 @@ function buildPageRenderer({
2212
2517
  /** The compositor scrolled: follow it, and rasterize around the new position when idle. */
2213
2518
  applyCompositorScroll(x, y) {
2214
2519
  viewport.scrollTo(x, y);
2520
+ page.realm?.layoutChanged(page.layoutVersion);
2215
2521
  syncedScroll = { x: viewport.x, y: viewport.y };
2216
2522
  schedulePrepaint();
2217
2523
  },
@@ -2242,8 +2548,13 @@ function buildPageRenderer({
2242
2548
  bubbles: true, cancelable: true,
2243
2549
  };
2244
2550
  const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
2551
+ let activated = false;
2245
2552
  if (!prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
2246
- if (key === "Backspace") editFocusedText("", true);
2553
+ if ((key === "Enter" || key === " ") && isDisclosureControl(target)) {
2554
+ activated = true;
2555
+ if (key === "Enter") activateDisclosure(target);
2556
+ }
2557
+ else if (key === "Backspace") editFocusedText("", true);
2247
2558
  else if (key === "Enter" && target.tagName === "TEXTAREA") {
2248
2559
  // A search IME's action key submits a search form. Other textareas
2249
2560
  // keep their normal newline behavior.
@@ -2255,8 +2566,9 @@ function buildPageRenderer({
2255
2566
  }
2256
2567
  else if (String(key).length === 1) editFocusedText(String(key));
2257
2568
  }
2258
- page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
2259
- if (refresh() || prevented) return { x: viewport.x, y: viewport.y };
2569
+ const upPrevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
2570
+ if (activated && key === " " && !upPrevented) activateDisclosure(target);
2571
+ if (refresh() || prevented || activated) return { x: viewport.x, y: viewport.y };
2260
2572
  }
2261
2573
  const pageStep = Math.max(31, viewport.height - 31);
2262
2574
  const delta = {
@@ -2319,9 +2631,6 @@ function buildPageRenderer({
2319
2631
  */
2320
2632
  click(x, y) {
2321
2633
  refresh();
2322
- // Activation can toggle checked state (radios in a group, through a
2323
- // label) without a DOM mutation, so the next restyle is a full one.
2324
- markStyleDirty(null); // full: click
2325
2634
  let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
2326
2635
  let forwarded = false;
2327
2636
  if (!region) {
@@ -2344,12 +2653,17 @@ function buildPageRenderer({
2344
2653
  // it is now (scroll containers move their content); the interactive
2345
2654
  // regions (laid out, unscrolled) stand in only where nothing is painted,
2346
2655
  // and a search form's forwarded field takes the click on the form.
2347
- const precise = page.displayList ? elementAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
2656
+ const hitNode = page.displayList ? renderNodeAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
2657
+ const precise = hitNode?.domNode?.nodeType === 1 ? hitNode.domNode : hitNode?.domNode?.parentElement;
2348
2658
  const target = forwarded ? region.element : precise ?? region?.element ?? null;
2659
+ const generatedDisclosure = hitNode?.pseudo === "summary" && target?.tagName === "DETAILS";
2349
2660
  if (!target) {
2350
2661
  focusedFrame = null;
2351
2662
  return null;
2352
2663
  }
2664
+ // Event dispatch determines the activation target before handlers run.
2665
+ // A handler may replace the clicked descendants while retaining the link.
2666
+ const activationAnchor = target.closest?.("a[href]");
2353
2667
  // A click inside an iframe belongs to its document alone.
2354
2668
  const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
2355
2669
  focusedFrame = inFrame?.frame ?? null;
@@ -2370,11 +2684,12 @@ function buildPageRenderer({
2370
2684
  }
2371
2685
  // The press focuses the focusable element it lands in, unless mousedown was canceled.
2372
2686
  const focus = () => {
2373
- const focusable = target.closest?.("input, textarea, select, button, [tabindex]");
2687
+ const focusable = generatedDisclosure ? target : target.closest?.("input, textarea, select, button, summary, [tabindex]");
2374
2688
  if (focusable && !focusable.disabled) focusable.focus();
2375
2689
  };
2376
2690
  if (!page.realm) focus();
2377
2691
  else {
2692
+ const controlStates = controlStatesBeforeActivation();
2378
2693
  // A press and release, then the click (UI Events §3.5).
2379
2694
  const init = { bubbles: true, cancelable: true, composed: true, clientX: Number(x), clientY: Number(y), button: 0 };
2380
2695
  const PointerEvent = page.window.PointerEvent ?? page.window.MouseEvent;
@@ -2384,16 +2699,22 @@ function buildPageRenderer({
2384
2699
  page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
2385
2700
  page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
2386
2701
  const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", { ...init, detail: 1 }));
2702
+ invalidateActivatedControls(controlStates);
2387
2703
  refresh();
2388
2704
  if (prevented) return null;
2389
2705
  }
2706
+ if (generatedDisclosure) {
2707
+ target.open = !target.open;
2708
+ refresh();
2709
+ return null;
2710
+ }
2390
2711
  if ((target.tagName === "AUDIO" || target.tagName === "VIDEO") && target.hasAttribute("controls")) {
2391
2712
  clickMedia(target, x, y);
2392
2713
  return null;
2393
2714
  }
2394
- const anchor = target.closest?.("a[href]");
2395
- if (!anchor) return null;
2396
- const href = new URL(anchor.getAttribute("href"), page.url).href;
2715
+ const anchor = activationAnchor;
2716
+ if (!anchor?.hasAttribute("href")) return null;
2717
+ const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
2397
2718
  if (href.startsWith("javascript:")) {
2398
2719
  this.runJavaScriptURL(href);
2399
2720
  return null;
@@ -2466,16 +2787,22 @@ function buildPageRenderer({
2466
2787
  if (!page.realm) return undefined;
2467
2788
  let value = page.realm.evaluate(utilityExpression);
2468
2789
  if (value !== null && typeof value === "object" && typeof value.then === "function") {
2469
- value = await Promise.race([
2470
- Promise.resolve(value),
2471
- new Promise((resolve) => hostSetTimeout(resolve, 5000, undefined)),
2472
- ]).catch((error) => String(error?.message ?? error));
2790
+ let timer;
2791
+ try {
2792
+ value = await Promise.race([
2793
+ Promise.resolve(value),
2794
+ new Promise((resolve) => { timer = hostSetTimeout(resolve, 5000, undefined); }),
2795
+ ]).catch((error) => String(error?.message ?? error));
2796
+ } finally { hostClearTimeout(timer); }
2473
2797
  }
2474
2798
  refresh();
2475
2799
  return cloneableResult(value);
2476
2800
  },
2477
2801
  dispose() {
2802
+ if (disposedRenderer) return;
2478
2803
  disposedRenderer = true;
2804
+ loadGeneration++;
2805
+ if (page.document) loadImageResource.cancelDocument(page.document);
2479
2806
  hostClearTimeout(prepaintTimer);
2480
2807
  hostClearTimeout(frameTimer);
2481
2808
  disposeFrames();
@@ -2484,8 +2811,13 @@ function buildPageRenderer({
2484
2811
  disposeVideos();
2485
2812
  rasterSurface?.dispose();
2486
2813
  rasterSurface = null;
2487
- page.window?.happyDOM?.abort?.();
2814
+ rasterReadback.dispose();
2815
+ clearPaintParagraphs();
2816
+ documentFonts.dispose();
2817
+ page.window?.happyDOM?.close?.();
2488
2818
  for (const image of page.decodedImages) image.delete();
2819
+ page.decodedImages = [];
2820
+ page.imageElements.clear();
2489
2821
  disposeBackgrounds();
2490
2822
  },
2491
2823
  };
@@ -2511,18 +2843,22 @@ function buildPageRenderer({
2511
2843
  function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
2512
2844
  const sheets = [];
2513
2845
  for (const element of document.querySelectorAll("style, link[rel~='stylesheet']")) {
2514
- if (element.closest("noscript")) continue;
2846
+ if (!isCssStyleSheet(element)) continue;
2847
+ const applies = styleSheetMatches(element);
2515
2848
  if (element.tagName === "STYLE") {
2516
- sheets.push(element.textContent ?? "");
2849
+ if (applies) sheets.push(element.textContent ?? "");
2517
2850
  continue;
2518
2851
  }
2852
+ // A disabled link is fetched when enabled; nonmatching media may be
2853
+ // fetched now, but must never contribute rules to the screen cascade.
2854
+ if (element.hasAttribute("disabled")) continue;
2519
2855
  const href = element.getAttribute("href");
2520
2856
  let url;
2521
- try { url = href ? new URL(href, documentUrl).href : null; } catch { url = null; }
2857
+ try { url = href ? new URL(href, element.baseURI || documentUrl).href : null; } catch { url = null; }
2522
2858
  if (!url) continue;
2523
2859
  const loaded = linkSheets.get(element);
2524
2860
  if (loaded?.url === url) {
2525
- if (loaded.text !== null) sheets.push(loaded.text);
2861
+ if (applies && loaded.text !== null) sheets.push(loaded.text);
2526
2862
  continue;
2527
2863
  }
2528
2864
  const entry = { url, text: null };
@@ -2561,17 +2897,10 @@ function buildPageRenderer({
2561
2897
  if (!src) return;
2562
2898
  beginImageLoad(element, src);
2563
2899
  try {
2564
- const response = await fetchResource(new URL(src, documentUrl).href, "image");
2565
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
2566
- // Untrusted image bytes are decoded here, inside the renderer.
2567
- const bytes = response.body;
2568
- const { width, height } = await new Bun.Image(bytes).metadata();
2569
- const image = CanvasKit.MakeImageFromEncoded(bytes);
2570
- if (!image) throw new Error("Image decoding failed");
2571
- const resource = Object.freeze({ width, height, image, url: response.url });
2900
+ const resource = await loadImageResource(document, new URL(src, element.baseURI || documentUrl).href, imageCrossOrigin(element));
2572
2901
  finishImageLoad(element, src, resource);
2573
2902
  map.set(element, resource);
2574
- decoded.push(image);
2903
+ decoded.push(resource.image);
2575
2904
  entries.push({ element, src, resource });
2576
2905
  } catch {
2577
2906
  // Broken images keep their fallback replaced-element dimensions.
@@ -2691,7 +3020,7 @@ function buildPageRenderer({
2691
3020
  const src = element.getAttribute("src")?.trim();
2692
3021
  if (!src) return "about:blank";
2693
3022
  try {
2694
- return new URL(src, page.url).href;
3023
+ return new URL(src, element.baseURI || page.url).href;
2695
3024
  } catch {
2696
3025
  return "about:blank";
2697
3026
  }
@@ -2794,6 +3123,12 @@ function buildPageRenderer({
2794
3123
  }
2795
3124
 
2796
3125
  function paintList() {
3126
+ for (const node of page.renderTree?.nodesById.values() ?? []) {
3127
+ if (node.tagName !== "CANVAS") continue;
3128
+ const resource = canvasResource(node.domNode);
3129
+ if (resource) page.resources.set(node.domNode, resource);
3130
+ else page.resources.delete(node.domNode);
3131
+ }
2797
3132
  return buildDisplayList(page.layout, page.renderTree, page.resources, backgroundResource, scrollOffsetOf);
2798
3133
  }
2799
3134
 
@@ -2803,13 +3138,7 @@ function buildPageRenderer({
2803
3138
 
2804
3139
  /** The render node and layout box of an element, or null. */
2805
3140
  function layoutBoxOf(element) {
2806
- if (!page.renderTree || !page.layout) return null;
2807
- for (const node of page.renderTree.nodesById.values()) {
2808
- if (node.domNode !== element || node.pseudo) continue;
2809
- const box = page.layout.boxes.find((candidate) => candidate.nodeId === node.id && !candidate.inline);
2810
- return box ? { node, box } : null;
2811
- }
2812
- return null;
3141
+ return elementBox(page.renderTree, page.layout, element);
2813
3142
  }
2814
3143
 
2815
3144
  /**
@@ -2863,6 +3192,7 @@ function buildPageRenderer({
2863
3192
  if (next.x || next.y) page.elementScroll.set(element, next);
2864
3193
  else page.elementScroll.delete(element);
2865
3194
  page.layoutVersion++;
3195
+ page.realm?.layoutChanged(page.layoutVersion);
2866
3196
  page.displayList = paintList();
2867
3197
  page.needsPaint = true;
2868
3198
  // A scroll event at the element, as a task (CSSOM View §13.1).
@@ -2960,20 +3290,31 @@ function buildPageRenderer({
2960
3290
  return left;
2961
3291
  }
2962
3292
 
2963
- /** Scrolls the scroll containers around an element, then the page, so that its top shows. */
2964
- function revealElement(element) {
2965
- for (let node = element.parentElement; node && node.nodeType === 1; node = node.parentElement) {
3293
+ /** Aligns the element within each scrollport, from the innermost out. */
3294
+ function revealElement(element, { block = "start", inline = "nearest", container = "all" } = {}) {
3295
+ let moved = false;
3296
+ let fixed = styleEngine.get(element)?.position === "fixed";
3297
+ const direction = styleEngine.get(element)?.direction;
3298
+ if (direction === "rtl" && inline !== "nearest" && inline !== "center") inline = inline === "start" ? "end" : "start";
3299
+ for (let node = element.parentElement; !fixed && node && node.nodeType === 1; node = node.parentElement) {
3300
+ if (node === page.document.documentElement) break;
3301
+ fixed = styleEngine.get(node)?.position === "fixed";
2966
3302
  const geometry = scrollerGeometry(node);
2967
3303
  if (!geometry) continue;
2968
3304
  const target = viewportBounds(element);
2969
- const container = viewportBounds(node);
2970
- if (!target || !container) continue;
3305
+ const bounds = viewportBounds(node);
3306
+ if (!target || !bounds) continue;
2971
3307
  const [borderTop, , , borderLeft] = styleEngine.get(node)?.borderWidths ?? [0, 0, 0, 0];
2972
- setElementScroll(node, geometry.x + target.left - (container.left + borderLeft), geometry.y + target.top - (container.top + borderTop));
3308
+ moved = setElementScroll(node,
3309
+ geometry.x + scrollAlignmentDelta(target.left - bounds.left - borderLeft, target.width, geometry.clientWidth, inline),
3310
+ geometry.y + scrollAlignmentDelta(target.top - bounds.top - borderTop, target.height, geometry.clientHeight, block)) || moved;
3311
+ if (container === "nearest") { if (moved) render(); return; }
2973
3312
  }
2974
3313
  const bounds = viewportBounds(element);
2975
- if (bounds) viewport.scrollTo(viewport.x, viewport.y + bounds.top);
2976
- render();
3314
+ if (bounds && !fixed) moved = viewport.scrollTo(
3315
+ viewport.x + scrollAlignmentDelta(bounds.left, bounds.width, viewport.width, inline),
3316
+ viewport.y + scrollAlignmentDelta(bounds.top, bounds.height, viewport.height, block)) || moved;
3317
+ if (moved) render();
2977
3318
  }
2978
3319
 
2979
3320
  /** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
@@ -2989,7 +3330,7 @@ function buildPageRenderer({
2989
3330
 
2990
3331
  function videoSource(element) {
2991
3332
  const src = element.getAttribute("src") ?? element.querySelector("source[src]")?.getAttribute("src");
2992
- try { return src ? new URL(src, page.url).href : null; } catch { return null; }
3333
+ try { return src ? new URL(src, element.baseURI || page.url).href : null; } catch { return null; }
2993
3334
  }
2994
3335
 
2995
3336
  // ---- media: <video> and <audio> share one pipeline. An <audio> is media
@@ -3095,6 +3436,22 @@ function buildPageRenderer({
3095
3436
  if (!state) return;
3096
3437
  state.mediaSource = source;
3097
3438
  bindSourcePipes(state);
3439
+ // A later initialization segment can replace already-buffered video.
3440
+ // Shorten the active presentation at its epoch boundary as soon as that
3441
+ // boundary is known, without discarding the frames before it.
3442
+ if (state.video?.pipe?.snapshotAt) {
3443
+ const current = state.video.pipe.snapshotAt(state.video.startTime);
3444
+ const endTime = current.id === state.video.snapshotId ? current.endTime : current.startTime;
3445
+ if (endTime !== state.video.endTime) {
3446
+ state.video.endTime = endTime;
3447
+ if (state.video.presentation) onVideo?.({ type: "video-bind", id: state.video.presentation.id,
3448
+ endTime });
3449
+ }
3450
+ }
3451
+ if (state.sound?.pipe?.snapshotAt) {
3452
+ const current = state.sound.pipe.snapshotAt(state.sound.startTime);
3453
+ state.sound.endTime = current.id === state.sound.snapshotId ? current.endTime : current.startTime;
3454
+ }
3098
3455
  if (state.audioPipe && state.audioPipe !== state.videoPipe) state.hasAudio = true;
3099
3456
  void refreshProbe(state);
3100
3457
  if (state.playing && state.hasAudio && !state.sound) ensureAudio(state);
@@ -3226,8 +3583,9 @@ function buildPageRenderer({
3226
3583
  const id = nextVideoPresentation++;
3227
3584
  const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
3228
3585
  video.presentation = { id, shared, serial: 0 };
3229
- onVideo({ type: "video-open", id, shared, bytes: snapshot(video.pipe, state.bytes),
3586
+ onVideo({ type: "video-open", id, shared, bytes: video.bytes,
3230
3587
  options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
3588
+ startTime: video.startTime ?? 0, endTime: video.endTime ?? Infinity,
3231
3589
  hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
3232
3590
  audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
3233
3591
  }
@@ -3244,10 +3602,10 @@ function buildPageRenderer({
3244
3602
  drawHints(canvas, region);
3245
3603
  surface.flush();
3246
3604
  const image = surface.makeImageSnapshot();
3247
- const pixels = image.readPixels(0, 0, { width, height, colorType: CanvasKit.ColorType.RGBA_8888,
3248
- alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB });
3249
- image.delete();
3250
- return pixels;
3605
+ try {
3606
+ return image.readPixels(0, 0, { width, height, colorType: CanvasKit.ColorType.RGBA_8888,
3607
+ alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB });
3608
+ } finally { image.delete(); }
3251
3609
  };
3252
3610
  const base = raster(false), mask = raster(true);
3253
3611
  for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
@@ -3338,7 +3696,7 @@ function buildPageRenderer({
3338
3696
  while (true) {
3339
3697
  if (pipe) await pipe.waitFor(pipe.done ? pipe.size : Math.max(1, Math.min(want, pipe.size + 1)));
3340
3698
  if (pipe?.error) throw pipe.error;
3341
- const bytes = pipe ? pipe.bytes() : state.bytes;
3699
+ const bytes = snapshot(pipe, state.bytes);
3342
3700
  if (!bytes?.byteLength) {
3343
3701
  if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
3344
3702
  continue;
@@ -3382,7 +3740,7 @@ function buildPageRenderer({
3382
3740
  const pipe = state.videoPipe ?? state.pipe;
3383
3741
  if (!pipe?.size) return;
3384
3742
  try {
3385
- const probe = await videoDecoder.probe(pipe.bytes());
3743
+ const probe = await videoDecoder.probe(snapshot(pipe, state.bytes, mediaTime(state)));
3386
3744
  if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
3387
3745
  state.duration = probe.duration;
3388
3746
  state.resource.duration = probe.duration;
@@ -3431,19 +3789,21 @@ function buildPageRenderer({
3431
3789
  return format ? { format } : {};
3432
3790
  }
3433
3791
 
3434
- function snapshot(pipe, bytes) {
3435
- return pipe ? pipe.bytes() : bytes;
3792
+ function snapshot(pipe, bytes, time = 0) {
3793
+ return pipe?.snapshotAt ? pipe.snapshotAt(time).bytes : pipe ? pipe.bytes() : bytes;
3436
3794
  }
3437
3795
 
3438
- async function openVideoDecoder(state) {
3796
+ async function openVideoDecoder(state, time = mediaTime(state)) {
3439
3797
  stopVideoPresentation(state);
3440
3798
  const pipe = state.videoPipe ?? state.pipe;
3441
3799
  const generation = pipe?.generation ?? 0;
3442
- const bytes = snapshot(pipe, state.bytes);
3800
+ const selected = pipe?.snapshotAt?.(time);
3801
+ const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
3443
3802
  if (!bytes?.byteLength) throw new Error(`media ${state.url}: no video bytes`);
3444
3803
  state.video?.decoder.close();
3445
3804
  const decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
3446
- state.video = { decoder, frameTime: 0, done: false, pipe, openedGeneration: generation };
3805
+ state.video = { decoder, bytes, frameTime: selected?.startTime ?? 0, done: false, pipe, openedGeneration: generation,
3806
+ snapshotId: selected?.id, startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
3447
3807
  trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
3448
3808
  if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
3449
3809
  state.resource.width = decoder.width;
@@ -3459,13 +3819,14 @@ function buildPageRenderer({
3459
3819
  * (no bytes yet, or audio_open hit EOF / invalid data on a short prefix).
3460
3820
  * The previous decoder stays until a new one succeeds.
3461
3821
  */
3462
- async function openAudioDecoder(state, { keepStream = false } = {}) {
3822
+ async function openAudioDecoder(state, { keepStream = false, time = mediaTime(state) } = {}) {
3463
3823
  const serial = state.seekSerial;
3464
3824
  const operation = (state.audioOpenSerial ?? 0) + 1;
3465
3825
  state.audioOpenSerial = operation;
3466
3826
  const pipe = state.audioPipe ?? state.pipe;
3467
3827
  const generation = pipe?.generation ?? 0;
3468
- const bytes = snapshot(pipe, state.bytes);
3828
+ const selected = pipe?.snapshotAt?.(time);
3829
+ const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
3469
3830
  const pending = pipe && !pipe.done && !pipe.error;
3470
3831
  if (!bytes?.byteLength) {
3471
3832
  if (pending) return false;
@@ -3502,8 +3863,10 @@ function buildPageRenderer({
3502
3863
  throw new Error("audio format changed while output stream is active");
3503
3864
  }
3504
3865
  previous?.decoder.close();
3505
- state.sound = { decoder, id: null, position: 0, pending: firstSamples, history: [], finishing: false, done: false,
3506
- pipe, openedGeneration: generation };
3866
+ state.sound = { decoder, id: null, position: Math.round((selected?.startTime ?? 0) * decoder.sampleRate),
3867
+ pending: firstSamples, history: [], finishing: false, done: false,
3868
+ pipe, openedGeneration: generation, snapshotId: selected?.id,
3869
+ startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
3507
3870
  if (keepStream && previousId != null) {
3508
3871
  // The output stream carries on with the new decoder's samples.
3509
3872
  Object.assign(state.sound, { id: previousId, streamStart: previous.streamStart,
@@ -3554,7 +3917,10 @@ function buildPageRenderer({
3554
3917
  function needsMore(track) {
3555
3918
  const pipe = track?.pipe;
3556
3919
  if (!pipe || pipe.error) return false;
3557
- return pipe.generation !== track.openedGeneration || !pipe.done;
3920
+ const atBoundary = Number.isFinite(track.endTime) && (track.frameTime !== undefined
3921
+ ? track.frameTime + 1e-6 >= track.endTime
3922
+ : track.position >= Math.round(track.endTime * track.decoder.sampleRate));
3923
+ return pipe.generation !== track.openedGeneration || !pipe.done || atBoundary;
3558
3924
  }
3559
3925
 
3560
3926
  /** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
@@ -3658,9 +4024,15 @@ function buildPageRenderer({
3658
4024
  /** Seek, reopening on a longer snapshot when the target is not in this prefix yet. */
3659
4025
  async function placeVideo(state, seconds) {
3660
4026
  for (let attempt = 0; attempt < 8; attempt++) {
3661
- if (!state.video || state.video.done || seconds === 0 || attempt > 0) await openVideoDecoder(state);
4027
+ const pipe = state.videoPipe ?? state.pipe;
4028
+ const selected = pipe?.snapshotAt?.(seconds);
4029
+ // A seek beyond an old snapshot can succeed at its last keyframe.
4030
+ // Reopen on newer bytes first; an exception does not detect that case.
4031
+ const stale = pipe && (pipe !== state.video?.pipe || pipe.generation !== state.video?.openedGeneration);
4032
+ if (!state.video || state.video.done || seconds === 0 || attempt > 0
4033
+ || stale || (selected && selected.id !== state.video.snapshotId)) await openVideoDecoder(state, seconds);
3662
4034
  try {
3663
- state.video.frameTime = seekToKeyframe(state.video.decoder, seconds);
4035
+ state.video.frameTime = seekToKeyframe(state.video.decoder, seconds, state.video.startTime);
3664
4036
  state.video.hideUntil = seconds;
3665
4037
  state.video.done = false;
3666
4038
  return;
@@ -3705,7 +4077,7 @@ function buildPageRenderer({
3705
4077
  const reached = await advanceAudio(() => state.sound, seconds, {
3706
4078
  needsMore,
3707
4079
  active: () => state.audioPositioning === transaction && state.seekSerial === serial && state.document === page.document,
3708
- reopen: () => openAudioDecoder(state, { keepStream: true }),
4080
+ reopen: () => openAudioDecoder(state, { keepStream: true, time: seconds }),
3709
4081
  });
3710
4082
  if (!reached && state.audioPositioning === transaction && state.seekSerial === serial) {
3711
4083
  throw new Error(`audio could not reach ${seconds.toFixed(3)}s; refusing to replay an earlier prefix`);
@@ -3804,7 +4176,7 @@ function buildPageRenderer({
3804
4176
  const video = state.video;
3805
4177
  if (video?.needsLocalPosition && !video.presentation && !video.done) {
3806
4178
  try {
3807
- video.frameTime = seekToKeyframe(video.decoder, now);
4179
+ video.frameTime = seekToKeyframe(video.decoder, now, video.startTime);
3808
4180
  video.hideUntil = now;
3809
4181
  video.needsLocalPosition = false;
3810
4182
  } catch (error) { failMedia(state, error, 3); return; }
@@ -3888,6 +4260,7 @@ function buildPageRenderer({
3888
4260
  function drawVideoFrame(state, show) {
3889
4261
  const video = state.video;
3890
4262
  const decoder = video.decoder;
4263
+ if (video.frameTime + 1e-6 >= video.endTime) return "stall";
3891
4264
  const width = Math.min(480, decoder.width);
3892
4265
  const height = Math.max(1, Math.round(width * decoder.height / Math.max(1, decoder.width)));
3893
4266
  let pixels;
@@ -3984,8 +4357,14 @@ function buildPageRenderer({
3984
4357
  }
3985
4358
  const { channels, sampleRate } = sound.decoder;
3986
4359
  const frameBytes = channels * 2;
4360
+ const endSample = Math.round(sound.endTime * sampleRate);
3987
4361
  sound.lastRequest = bytes;
3988
4362
  for (let sent = 0; sent + frameBytes <= bytes;) {
4363
+ if (sound.position >= endSample) {
4364
+ sound.owed = (sound.owed ?? 0) + bytes - sent;
4365
+ void refillMedia(state, sound);
4366
+ return;
4367
+ }
3989
4368
  let samples = sound.pending;
3990
4369
  sound.pending = null;
3991
4370
  if (!samples) {
@@ -4011,7 +4390,8 @@ function buildPageRenderer({
4011
4390
  audio.finish(sound.id);
4012
4391
  return;
4013
4392
  }
4014
- const [taken, remaining] = splitSamples(samples, channels, bytes - sent);
4393
+ const boundaryBytes = Math.max(0, endSample - sound.position) * frameBytes;
4394
+ const [taken, remaining] = splitSamples(samples, channels, Math.min(bytes - sent, boundaryBytes));
4015
4395
  if (remaining.length) sound.pending = remaining;
4016
4396
  sound.history.push({ start: sound.position, samples: taken });
4017
4397
  sound.position += taken.length / channels;
@@ -4057,8 +4437,8 @@ function buildPageRenderer({
4057
4437
  * binary search over seek targets finds where that keyframe starts (to a
4058
4438
  * frame). The frames from there to the clock are then decoded unshown.
4059
4439
  */
4060
- function seekToKeyframe(decoder, seconds) {
4061
- return seekVideoKeyframe(decoder, seconds);
4440
+ function seekToKeyframe(decoder, seconds, startTime = 0) {
4441
+ return seekVideoKeyframe(decoder, seconds, startTime);
4062
4442
  }
4063
4443
 
4064
4444
  /**
@@ -4146,19 +4526,24 @@ function buildPageRenderer({
4146
4526
  const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
4147
4527
  // The picture must not go back to the keyframe the decoder restarts at.
4148
4528
  const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
4149
- await openVideoDecoder(state);
4529
+ await openVideoDecoder(state, resumeAt);
4150
4530
  state.video.hideUntil = hideUntil;
4151
4531
  try {
4152
- state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt);
4532
+ state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt, state.video.startTime);
4153
4533
  } catch {
4154
- state.video.frameTime = 0;
4534
+ state.video.frameTime = state.video.startTime;
4155
4535
  }
4156
4536
  state.video.done = false;
4157
4537
  }
4158
4538
  if (refillAudio) {
4159
4539
  let opened = false;
4160
4540
  try {
4161
- opened = await openAudioDecoder(state, { keepStream: Boolean(held) });
4541
+ let audioTime = held ? writtenEnd : time;
4542
+ if (state.sound && Number.isFinite(state.sound.endTime)
4543
+ && state.sound.position >= Math.round(state.sound.endTime * state.sound.decoder.sampleRate)) {
4544
+ audioTime = Math.max(audioTime, state.sound.endTime);
4545
+ }
4546
+ opened = await openAudioDecoder(state, { keepStream: Boolean(held), time: audioTime });
4162
4547
  } catch (error) {
4163
4548
  if (!state.video) throw error;
4164
4549
  reportError(error);
@@ -4242,7 +4627,7 @@ function buildPageRenderer({
4242
4627
  /** The decoded CSS background image a url() names, if it has arrived. */
4243
4628
  function backgroundResource(url) {
4244
4629
  let absolute;
4245
- try { absolute = new URL(url, page.url).href; } catch { return null; }
4630
+ try { absolute = new URL(url, page.document.baseURI || page.url).href; } catch { return null; }
4246
4631
  return page.backgrounds.get(absolute)?.resource ?? null;
4247
4632
  }
4248
4633
 
@@ -4255,28 +4640,25 @@ function buildPageRenderer({
4255
4640
  if (!tree) return;
4256
4641
  const document = page.document;
4257
4642
  const seen = new Set();
4643
+ const used = new Set();
4258
4644
  for (const node of tree.nodesById.values()) {
4259
4645
  const value = node.style?.backgroundImage;
4260
4646
  if (!value || seen.has(value) || !value.includes("url(")) continue;
4261
4647
  seen.add(value);
4262
4648
  for (const match of value.matchAll(CSS_URL)) {
4263
4649
  let url;
4264
- try { url = new URL(match[2], page.url).href; } catch { continue; }
4650
+ try { url = new URL(match[2], document.baseURI || page.url).href; } catch { continue; }
4651
+ used.add(url);
4265
4652
  if (page.backgrounds.has(url)) continue;
4266
4653
  const entry = { resource: null, loaded: null };
4267
4654
  page.backgrounds.set(url, entry);
4268
- entry.loaded = fetchResource(url, "image").then(async (response) => {
4269
- if (!response.ok || page.backgrounds.get(url) !== entry) return;
4270
- const bytes = response.body;
4271
- const { width, height } = await new Bun.Image(bytes).metadata();
4272
- const image = CanvasKit.MakeImageFromEncoded(bytes);
4273
- if (!image) return;
4655
+ entry.loaded = loadImageResource(document, url).then((resource) => {
4274
4656
  if (page.backgrounds.get(url) !== entry || document !== page.document) {
4275
- image.delete();
4657
+ resource.image.delete();
4276
4658
  return;
4277
4659
  }
4278
- entry.resource = Object.freeze({ width, height, image, url });
4279
- if (page.layout) page.displayList = paintList();
4660
+ entry.resource = resource;
4661
+ page.resourcePaintDirty = true;
4280
4662
  page.needsPaint = true;
4281
4663
  scheduleFrame();
4282
4664
  }).catch(() => {
@@ -4284,6 +4666,11 @@ function buildPageRenderer({
4284
4666
  });
4285
4667
  }
4286
4668
  }
4669
+ for (const [url, entry] of page.backgrounds) {
4670
+ if (used.has(url)) continue;
4671
+ entry.resource?.image.delete();
4672
+ page.backgrounds.delete(url);
4673
+ }
4287
4674
  }
4288
4675
 
4289
4676
  function disposeBackgrounds() {
@@ -4295,36 +4682,58 @@ function buildPageRenderer({
4295
4682
  const document = page.document;
4296
4683
  const generation = loadGeneration;
4297
4684
  const staleImages = [];
4685
+ // Infinite feeds remove old DOM images without navigating. The resource
4686
+ // WeakMap alone cannot release their explicitly-owned Skia handles.
4687
+ for (const element of page.imageElements) {
4688
+ if (element.isConnected && element.ownerDocument === document && element.getAttribute("src")) continue;
4689
+ const image = page.resources.get(element)?.image;
4690
+ if (image) {
4691
+ staleImages.push(image);
4692
+ page.resourceLayoutDirty = true;
4693
+ }
4694
+ page.resources.delete(element);
4695
+ page.imageElements.delete(element);
4696
+ imageRequests.delete(element);
4697
+ }
4298
4698
  for (const element of document.querySelectorAll("img")) {
4299
4699
  const src = element.getAttribute("src");
4300
- if (!src || imageRequests.get(element) === src) continue;
4700
+ const credentials = imageCrossOrigin(element);
4701
+ const key = imageRequestKey(src, credentials);
4702
+ if (!src || imageRequests.get(element) === key) continue;
4301
4703
  let url;
4302
- try { url = new URL(src, page.url).href; } catch { continue; }
4303
- if (page.resources.get(element)?.url === url) {
4304
- imageRequests.set(element, src);
4704
+ try { url = new URL(src, element.baseURI || page.url).href; } catch { continue; }
4705
+ const existing = page.resources.get(element);
4706
+ if (existing?.requestedURL === url && existing.crossOrigin === credentials) {
4707
+ imageRequests.set(element, key);
4305
4708
  continue;
4306
4709
  }
4307
- imageRequests.set(element, src);
4710
+ imageRequests.set(element, key);
4308
4711
  const oldImage = page.resources.get(element)?.image;
4309
- if (oldImage) staleImages.push(oldImage);
4712
+ if (oldImage) {
4713
+ staleImages.push(oldImage);
4714
+ page.resourceLayoutDirty = true;
4715
+ }
4310
4716
  page.resources.delete(element);
4311
4717
  beginImageLoad(element, src);
4312
- fetchResource(url, "image").then(async (response) => {
4313
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
4314
- const bytes = response.body;
4315
- const { width, height } = await new Bun.Image(bytes).metadata();
4316
- const image = CanvasKit.MakeImageFromEncoded(bytes);
4317
- if (!image) throw new Error("Image decoding failed");
4318
- if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src) {
4718
+ loadImageResource(document, url, credentials).then((resource) => {
4719
+ const { width, height, image } = resource;
4720
+ if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src
4721
+ || imageCrossOrigin(element) !== credentials) {
4319
4722
  image.delete();
4320
4723
  return;
4321
4724
  }
4322
- page.resources.set(element, Object.freeze({ width, height, image, url: response.url }));
4725
+ page.resources.set(element, resource);
4323
4726
  page.decodedImages.push(image);
4324
- finishImageLoad(element, src, { width, height }, type => element.dispatchEvent(new page.window.Event(type)));
4325
- const node = [...page.renderTree.nodesById.values()].find((candidate) => candidate.domNode === element);
4326
- const run = page.layout.fragments.flatMap((fragment) => fragment.runs)
4327
- .find((candidate) => candidate.type === "image" && candidate.nodeId === node?.id);
4727
+ page.imageElements.add(element);
4728
+ let node, run;
4729
+ for (const candidate of page.renderTree.nodesById.values()) {
4730
+ if (candidate.domNode === element) { node = candidate; break; }
4731
+ }
4732
+ if (node) outer: for (const fragment of page.layout.fragments) {
4733
+ for (const candidate of fragment.runs) {
4734
+ if (candidate.type === "image" && candidate.nodeId === node.id) { run = candidate; break outer; }
4735
+ }
4736
+ }
4328
4737
  const cssWidth = node?.style?.width;
4329
4738
  const cssHeight = node?.style?.height;
4330
4739
  const specifiedWidth = typeof cssWidth === "number" ? cssWidth : node?.widthAttribute;
@@ -4335,17 +4744,20 @@ function buildPageRenderer({
4335
4744
  const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
4336
4745
  && Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
4337
4746
  if (paintOnly) {
4338
- page.displayList = paintList();
4747
+ page.resourcePaintDirty = true;
4339
4748
  page.needsPaint = true;
4340
4749
  } else {
4341
4750
  page.dirty = true;
4342
4751
  page.resourceLayoutDirty = true;
4343
4752
  }
4344
- refresh();
4753
+ // All arrivals in this rendering opportunity share layout/raster.
4754
+ // An onload handler which reads geometry still flushes synchronously.
4755
+ finishImageLoad(element, src, { width, height }, type => element.dispatchEvent(new page.window.Event(type)));
4756
+ scheduleFrame();
4345
4757
  }).catch((error) => {
4346
4758
  // A failed image retains its fallback replaced-element dimensions.
4347
4759
  trace?.(`image failed ${url}: ${error?.message ?? error}`);
4348
- if (generation === loadGeneration && document === page.document) {
4760
+ if (generation === loadGeneration && document === page.document && imageCrossOrigin(element) === credentials) {
4349
4761
  finishImageLoad(element, src, null, type => element.dispatchEvent(new page.window.Event(type)));
4350
4762
  }
4351
4763
  });