buninu-browser 0.2.3 → 0.3.0

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 (116) hide show
  1. package/README.md +86 -1
  2. package/lib/cdp-server.js +47 -14
  3. package/lib/clipboard.js +22 -0
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +15 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +40 -11
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +213 -91
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +17 -4
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +8 -28
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +77 -16
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/raw-frames.js +47 -0
  24. package/lib/paint/rounded-rect.js +82 -0
  25. package/lib/paint/shared-frame-pool.js +105 -0
  26. package/lib/render-tree/index.js +38 -6
  27. package/lib/render-tree/svg.js +15 -7
  28. package/lib/renderer/bindings.js +233 -80
  29. package/lib/renderer/broadcast-channel.js +76 -0
  30. package/lib/renderer/canvas-2d.js +257 -14
  31. package/lib/renderer/canvas-text.js +120 -0
  32. package/lib/renderer/compositor-channel.js +103 -0
  33. package/lib/renderer/compositor.js +34 -40
  34. package/lib/renderer/custom-element-reactions.js +25 -1
  35. package/lib/renderer/flags.js +1 -1
  36. package/lib/renderer/font-loading.js +227 -0
  37. package/lib/renderer/host.js +133 -98
  38. package/lib/renderer/image-resources.js +65 -0
  39. package/lib/renderer/interfaces.js +28 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +52 -5
  42. package/lib/renderer/media-source.js +126 -55
  43. package/lib/renderer/page-realm.js +287 -99
  44. package/lib/renderer/page-renderer.js +663 -239
  45. package/lib/renderer/prepared-script-cache.js +50 -0
  46. package/lib/renderer/rendered-text.js +108 -0
  47. package/lib/renderer/rewrite-worker.js +4 -3
  48. package/lib/renderer/script-rewrite.js +13 -2
  49. package/lib/renderer/scroll-into-view.js +28 -0
  50. package/lib/renderer/slot-assignment.js +60 -0
  51. package/lib/renderer/video-presentation.js +23 -4
  52. package/lib/renderer/workers.js +2 -2
  53. package/lib/style/computed-style.js +611 -104
  54. package/lib/style/selector-traits.js +49 -5
  55. package/package.json +1 -1
  56. package/test/broadcast-channel.test.js +26 -0
  57. package/test/canvas-2d.test.js +83 -2
  58. package/test/canvas-paint.test.js +9 -0
  59. package/test/canvas-text.test.js +41 -0
  60. package/test/cdp-stream.test.js +56 -0
  61. package/test/clipboard.test.js +35 -0
  62. package/test/computed-style-binding.test.js +151 -0
  63. package/test/computed-style.test.js +258 -1
  64. package/test/display-list.test.js +12 -0
  65. package/test/document-base.test.js +87 -0
  66. package/test/dom-platform.test.js +461 -0
  67. package/test/fetch-lifecycle.test.js +161 -0
  68. package/test/fixtures/browser-raw-demo.html +36 -0
  69. package/test/fixtures/canvas-paint.js +117 -0
  70. package/test/fixtures/canvas-text.js +42 -0
  71. package/test/fixtures/image-lifetime.js +90 -0
  72. package/test/fixtures/media-seek-snapshot.js +77 -0
  73. package/test/fixtures/mse-audio-chirp.webm +0 -0
  74. package/test/fixtures/mse-switch-large.mp4 +0 -0
  75. package/test/fixtures/mse-switch-small.mp4 +0 -0
  76. package/test/fixtures/raw-capture-benchmark.json +76 -0
  77. package/test/fixtures/renderer-memory.js +110 -0
  78. package/test/fixtures/summary-style.js +28 -0
  79. package/test/font-loading.test.js +97 -0
  80. package/test/grid.test.js +58 -0
  81. package/test/hit-test.test.js +21 -1
  82. package/test/image-lifetime.test.js +12 -0
  83. package/test/image-resources.test.js +94 -0
  84. package/test/local-media.test.js +84 -0
  85. package/test/media-seek-snapshot.test.js +11 -0
  86. package/test/media-source-config.test.js +98 -0
  87. package/test/media-source-webm-accounting.test.js +98 -0
  88. package/test/media-source-webm.test.js +108 -0
  89. package/test/navigation.test.js +1 -0
  90. package/test/network-service.test.js +388 -0
  91. package/test/page-scripts.test.js +1 -1
  92. package/test/paint-frame.test.js +103 -0
  93. package/test/paint-paragraph-cache.test.js +87 -0
  94. package/test/position-audio.test.js +24 -0
  95. package/test/positioning.test.js +22 -0
  96. package/test/prepared-script-cache.test.js +45 -0
  97. package/test/pulse-client.test.js +10 -3
  98. package/test/raster-readback.test.js +40 -0
  99. package/test/raw-capture.test.js +152 -0
  100. package/test/render-tree.test.js +28 -0
  101. package/test/renderer-memory.test.js +11 -0
  102. package/test/selector-traits.test.js +24 -0
  103. package/test/shared-frame-pool.test.js +63 -0
  104. package/test/style-sheets.test.js +51 -0
  105. package/test/svg.test.js +29 -0
  106. package/test/text-layout.test.js +106 -0
  107. package/test/video-keyframe.test.js +61 -0
  108. package/test/video-presentation.test.js +69 -0
  109. package/test/window-resize.test.js +36 -0
  110. package/tools/bench-raw-capture.js +32 -0
  111. package/tools/browser-lab.js +78 -0
  112. package/tools/browser-soak.js +75 -0
  113. package/tools/network-reference.js +49 -0
  114. package/tools/process-memory.js +38 -0
  115. package/tools/script-benchmark.js +25 -0
  116. 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, rawFrame, captureViewport, 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
  }
@@ -214,6 +227,7 @@ function buildPageRenderer({
214
227
  requestDownload = () => {},
215
228
  reportError = () => {},
216
229
  pageFetch = () => Promise.reject(new TypeError("network access is not available")),
230
+ clipboardWrite = () => Promise.reject(Error("Clipboard unavailable")),
217
231
  webSockets = null,
218
232
  audio = null,
219
233
  setCookie = () => {},
@@ -231,38 +245,47 @@ function buildPageRenderer({
231
245
  // An iframe's document (see syncFrames): frames go to the embedding page,
232
246
  // which needs only the viewport, so nothing is painted ahead.
233
247
  embedded = false,
248
+ allocateFramePixels = null,
234
249
  // This document's window handle, made by the embedding page (see windowHandle).
235
250
  frameWindow = null,
236
251
  }, {
237
252
  CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
238
- genericFamilies,
253
+ genericFamilies, builtinTypefaces,
239
254
  }) {
255
+ let documentFonts = null;
256
+ let fontSheets = null;
257
+ let fontEnvironment = "";
258
+ let fontDefinitions = [];
259
+ let fontDefinitionKey = "[]";
240
260
  const resolvedFamilies = new Map();
241
261
  /** First available family from a computed font-family list, then the UI fallbacks. */
242
- function resolveFontFamilies(families = ["sans-serif"], weight = 400) {
262
+ function resolveFontFamilies(families = ["sans-serif"], weight = 400, fontStyle = "normal", point = null, stretch = 100) {
243
263
  const bold = weight >= 600;
244
- const key = `${bold}\n${families.join("\n")}`;
245
- let resolved = resolvedFamilies.get(key);
264
+ const key = `${weight}/${fontStyle}/${stretch}\n${families.join("\n")}`;
265
+ let resolved = point === null && resolvedFamilies.get(key);
246
266
  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);
267
+ let primary = [];
268
+ for (const family of families) {
269
+ const web = documentFonts?.resolve(family, weight, fontStyle, point, stretch);
270
+ if (web?.length) { primary.push(...web); continue; }
271
+ const builtin = loadedFamilies.get(family.toLowerCase()) ?? genericFamilies.get(family.toLowerCase());
272
+ if (builtin) { primary.push(bold && builtin === monospaceFamily ? monospaceBoldFamily : builtin); break; }
273
+ }
274
+ if (!primary.length) primary = [sansSerifFamily];
275
+ resolved = [...primary, ...fontFamilies.filter((family) => !primary.includes(family))];
276
+ if (point === null) resolvedFamilies.set(key, resolved);
253
277
  }
254
278
  return resolved;
255
279
  }
256
- const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
257
- const emojiPresentation = /^\p{Emoji_Presentation}/u;
258
280
  const renderTreeBuilder = new RenderTreeBuilder();
259
281
  const layoutEngine = new LayoutEngine({ measureText, fontMetrics });
260
- const styleEngine = new StyleEngine();
282
+ const styleEngine = new StyleEngine({ measureText });
261
283
  // Incremental restyle: what may have changed style since the last
262
284
  // computation. DOM mutations come from a MutationObserver; state that is
263
285
  // not in the DOM tree (checked, value, focus) is marked by the code that
264
286
  // changes it. `full` asks for a complete restyle.
265
- const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null, activeElement: null };
287
+ const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null,
288
+ activeElement: null, focusAncestors: new Set() };
266
289
  let selectorTraits = { sibling: false, has: null, sheets: null };
267
290
  let lastStyleParts = "";
268
291
  let laidOutSheets = null;
@@ -306,6 +329,7 @@ function buildPageRenderer({
306
329
  deviceScaleFactor: 1,
307
330
  pageScale: 1,
308
331
  needsPaint: true,
332
+ resourcePaintDirty: false,
309
333
  loading: false,
310
334
  hints: [],
311
335
  hintsVisible: false,
@@ -316,6 +340,7 @@ function buildPageRenderer({
316
340
  backgroundColor: "rgba(255, 255, 255, 1)",
317
341
  resources: new WeakMap(),
318
342
  decodedImages: [],
343
+ imageElements: new Set(),
319
344
  imagesLoaded: null,
320
345
  // CSS background images by absolute URL: { resource, loaded } (see queueBackgroundImages).
321
346
  backgrounds: new Map(),
@@ -341,15 +366,58 @@ function buildPageRenderer({
341
366
  if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
342
367
  return response;
343
368
  }
369
+ const loadImageResource = createImageResourceLoader(CanvasKit, fetchResource, pageFetch);
370
+ const renderCanvasText = createCanvasTextRenderer(CanvasKit, makeParagraph);
371
+
372
+ function resetDocumentFonts() {
373
+ documentFonts?.dispose();
374
+ fontSheets = null;
375
+ fontDefinitions = [];
376
+ if (fontDefinitionKey !== "[]") clearFontCaches();
377
+ fontEnvironment = "";
378
+ fontDefinitionKey = "[]";
379
+ documentFonts = new DocumentFonts({
380
+ CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection, fetch: pageFetch,
381
+ availableFamilies: new Set([...loadedFamilies.keys(), ...genericFamilies.keys()]),
382
+ changed() {
383
+ if (disposedRenderer) return;
384
+ clearFontCaches();
385
+ // Font metrics also affect CSS ch units and normal line-height.
386
+ styleChanges.full = true;
387
+ page.dirty = true;
388
+ page.resourceLayoutDirty = true;
389
+ page.needsPaint = true;
390
+ scheduleFrame();
391
+ },
392
+ error: error => trace?.(`font failed: ${error?.message ?? error}`),
393
+ });
394
+ }
344
395
 
345
- function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1) {
396
+ function clearFontCaches() {
397
+ resolvedFamilies.clear();
398
+ measuredWidths.clear();
399
+ measuredCount = 0;
400
+ fontKeys = new WeakMap();
401
+ fontMetricsCache.clear();
402
+ clearPaintParagraphs();
403
+ rasterized.list = null;
404
+ }
405
+
406
+ function queueFontLoads() {
407
+ if (page.renderTree && fontDefinitions.length) documentFonts.update(fontDefinitions,
408
+ [...page.renderTree.nodesById.values()].filter(node => node.type === "text"));
409
+ }
410
+
411
+ function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1, foregroundPaint = null) {
346
412
  const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
347
413
  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
- ];
414
+ const fontStyle = computedStyle?.fontStyle ?? "normal";
415
+ const stretch = computedStyle?.fontStretch ?? 100;
416
+ const widths = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200];
417
+ const width = widths.reduce((best, value, index) => Math.abs(value - stretch) < Math.abs(widths[best] - stretch) ? index : best, 4) + 1;
418
+ const slant = fontStyle === "italic" ? CanvasKit.FontSlant.Italic
419
+ : fontStyle === "oblique" ? CanvasKit.FontSlant.Oblique : CanvasKit.FontSlant.Upright;
420
+ const families = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch);
353
421
  // text-decoration lines, drawn by SkParagraph under/over/through the glyphs.
354
422
  const decoration = computedStyle?.decoration ? {
355
423
  decoration: computedStyle.decoration.lines.reduce((mask, line) => mask | ({
@@ -363,30 +431,60 @@ function buildPageRenderer({
363
431
  color,
364
432
  fontFamilies: families,
365
433
  fontSize: Math.max(1, Math.round(fontSize * scale)),
366
- fontStyle: { weight },
434
+ fontStyle: { weight, slant, width },
367
435
  // Roboto is a variable font; static fonts ignore the axis and match by weight.
368
- fontVariations: [{ axis: "wght", value: weight }],
436
+ fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }],
369
437
  ...decoration,
370
438
  },
371
439
  maxLines: 1,
440
+ ...(computedStyle?.direction ? { textDirection: computedStyle.direction === "rtl" ? CanvasKit.TextDirection.RTL : CanvasKit.TextDirection.LTR } : {}),
372
441
  });
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();
442
+ const builder = CanvasKit.ParagraphBuilder.MakeFromFontCollection(style, documentFonts?.collection ?? fontCollection);
443
+ const backgroundPaint = foregroundPaint ? new CanvasKit.Paint() : null;
444
+ if (backgroundPaint) backgroundPaint.setColor(CanvasKit.TRANSPARENT);
445
+ const pushTextStyle = foregroundPaint
446
+ ? value => builder.pushPaintStyle(value, foregroundPaint, backgroundPaint) : value => builder.pushStyle(value);
447
+ const textStyle = (fontFamilies) => CanvasKit.TextStyle({ color, ...decoration, fontFamilies,
448
+ fontSize: Math.max(1, Math.round(fontSize * scale)), fontStyle: { weight, slant, width },
449
+ fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }] });
450
+ function append(text, fonts) {
451
+ let emojiStyle;
452
+ const writer = foregroundPaint ? { addText: value => builder.addText(value), pushStyle: pushTextStyle, pop: () => builder.pop() } : builder;
453
+ appendParagraphText(writer, text, () => emojiStyle ??= textStyle([
454
+ ...fonts.filter(name => name.includes("Emoji")), ...fonts.filter(name => !name.includes("Emoji")),
455
+ ]));
387
456
  }
457
+ if (foregroundPaint) pushTextStyle(textStyle(families));
458
+ if (documentFonts?.hasUnicodeRanges && text) {
459
+ // A downloaded subset may physically contain more glyphs than its CSS
460
+ // unicode-range permits. Apply the range when choosing native families,
461
+ // preserving whole adjacent runs for shaping instead of per-letter calls.
462
+ const choices = new Map();
463
+ let run = "", previous = null, previousKey = "";
464
+ const flush = () => {
465
+ if (!run) return;
466
+ pushTextStyle(textStyle(previous));
467
+ append(run, previous);
468
+ builder.pop();
469
+ };
470
+ for (const character of text) {
471
+ const point = character.codePointAt(0);
472
+ let choice = choices.get(point);
473
+ if (!choice) {
474
+ const fonts = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, point, stretch);
475
+ choices.set(point, choice = { fonts, key: fonts.join("\n") });
476
+ }
477
+ if (run && choice.key !== previousKey) { flush(); run = ""; }
478
+ previous = choice.fonts;
479
+ previousKey = choice.key;
480
+ run += character;
481
+ }
482
+ flush();
483
+ } else append(text, families);
484
+ if (foregroundPaint) builder.pop();
388
485
  const paragraph = builder.build();
389
486
  builder.delete();
487
+ backgroundPaint?.delete();
390
488
  return paragraph;
391
489
  }
392
490
 
@@ -420,22 +518,24 @@ function buildPageRenderer({
420
518
  }
421
519
 
422
520
  // Computed styles are immutable and shared, so each one's key is computed once.
423
- const fontKeys = new WeakMap();
521
+ let fontKeys = new WeakMap();
424
522
  function fontKeyOf(computedStyle) {
425
523
  if (computedStyle && typeof computedStyle === "object") {
426
524
  const cached = fontKeys.get(computedStyle);
427
525
  if (cached) return cached;
428
526
  }
429
527
  const weight = computedStyle?.fontWeight ?? 400;
430
- const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${
431
- resolveFontFamilies(computedStyle?.fontFamily, weight).join(",")}`;
528
+ const fontStyle = computedStyle?.fontStyle ?? "normal";
529
+ const stretch = computedStyle?.fontStretch ?? 100;
530
+ const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${fontStyle}/${stretch}/${
531
+ resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch).join(",")}`;
432
532
  if (computedStyle && typeof computedStyle === "object") fontKeys.set(computedStyle, key);
433
533
  return key;
434
534
  }
435
535
 
436
536
  const fontMetricsCache = new Map();
437
537
  function fontMetrics(computedStyle = null) {
438
- const key = `${computedStyle?.fontSize ?? 16}/${computedStyle?.fontWeight ?? 400}/${computedStyle?.fontFamily?.join(",")}`;
538
+ const key = fontKeyOf(computedStyle);
439
539
  let metrics = fontMetricsCache.get(key);
440
540
  if (!metrics) {
441
541
  const paragraph = makeParagraph("Hg", computedStyle);
@@ -452,9 +552,10 @@ function buildPageRenderer({
452
552
  // thread scrolls on its own (keys, fragments, script, a new document).
453
553
  let scrollEpoch = 0;
454
554
  let syncedScroll = { x: 0, y: 0 };
555
+ let syncedViewport = { width: 0, height: 0 };
455
556
  let tileSerial = 0;
456
557
  let lastTile = null;
457
- let encoded = { key: "", png: "" };
558
+ const captures = new FrameCaptureCache();
458
559
  let prepaintTimer = null;
459
560
  let frameTimer = null;
460
561
  let lastRebuildMs = 0;
@@ -469,6 +570,67 @@ function buildPageRenderer({
469
570
  let rasterSurface = null;
470
571
  let rasterSurfaceWidth = 0;
471
572
  let rasterSurfaceHeight = 0;
573
+ const rasterReadback = new RasterReadback(CanvasKit, { shared: Boolean(onFrame), allocate: allocateFramePixels });
574
+ // A scroll or unrelated DOM update does not change text shaping. Keep a
575
+ // bounded set of visible paragraphs keyed by their complete shaping input.
576
+ // Both limits matter: a page may contain many unique or very long strings.
577
+ const paintedParagraphs = new Map();
578
+ let paragraphKeys = new WeakMap();
579
+ let paintedCharacters = 0;
580
+ const MAX_PAINT_PARAGRAPHS = 256;
581
+ const MAX_PAINT_CHARACTERS = 64_000;
582
+
583
+ function releasePaintParagraph(key, entry) {
584
+ entry.paragraph.delete();
585
+ paintedParagraphs.delete(key);
586
+ paintedCharacters -= entry.characters;
587
+ }
588
+
589
+ function clearPaintParagraphs() {
590
+ renderCanvasText.clear();
591
+ for (const entry of paintedParagraphs.values()) entry.paragraph.delete();
592
+ paintedParagraphs.clear();
593
+ paragraphKeys = new WeakMap();
594
+ paintedCharacters = 0;
595
+ }
596
+
597
+ function paragraphForPaint(item, scale, width) {
598
+ const retained = item.text.length <= 4096;
599
+ let cachedKey = retained && paragraphKeys.get(item);
600
+ if (retained && (!cachedKey || cachedKey.scale !== scale || cachedKey.width !== width)) {
601
+ cachedKey = { scale, width, key: JSON.stringify([item.text, item.color, item.font,
602
+ item.decoration, scale, width]) };
603
+ paragraphKeys.set(item, cachedKey);
604
+ }
605
+ const key = cachedKey?.key;
606
+ let entry = retained && paintedParagraphs.get(key);
607
+ if (entry) {
608
+ paintedParagraphs.delete(key);
609
+ paintedParagraphs.set(key, entry);
610
+ return entry;
611
+ }
612
+ const paragraph = makeParagraph(item.text, {
613
+ color: item.color, fontSize: item.font.size, fontWeight: item.font.weight, fontFamily: item.font.family,
614
+ fontStyle: item.font.style, fontStretch: item.font.stretch,
615
+ decoration: item.decoration,
616
+ }, undefined, scale);
617
+ try {
618
+ paragraph.layout(width);
619
+ } catch (error) {
620
+ paragraph.delete();
621
+ throw error;
622
+ }
623
+ entry = { paragraph, retained, characters: item.text.length };
624
+ if (entry.retained) {
625
+ paintedParagraphs.set(key, entry);
626
+ paintedCharacters += item.text.length;
627
+ while (paintedParagraphs.size > MAX_PAINT_PARAGRAPHS || paintedCharacters > MAX_PAINT_CHARACTERS) {
628
+ const [oldKey, oldEntry] = paintedParagraphs.entries().next().value;
629
+ releasePaintParagraph(oldKey, oldEntry);
630
+ }
631
+ }
632
+ return entry;
633
+ }
472
634
 
473
635
  /** Hint overlay contents, as a comparable key. */
474
636
  function overlayKey() {
@@ -485,6 +647,8 @@ function buildPageRenderer({
485
647
  if (page.displayList?.items.some((item) => item.fixed)
486
648
  && (rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y)) return false;
487
649
  if (rasterized.list === page.displayList) return true;
650
+ if (rasterized.list?.images.length !== page.displayList?.images.length
651
+ || page.displayList?.images.some((resource, index) => resource !== rasterized.list.images[index])) return false;
488
652
  if (!rasterized.list || !page.displayList || !Bun.deepEquals(rasterized.list.items, page.displayList.items)) return false;
489
653
  rasterized.list = page.displayList;
490
654
  return true;
@@ -492,6 +656,12 @@ function buildPageRenderer({
492
656
 
493
657
  /** Rasterizes the viewport and hands it to the compositor. */
494
658
  function render() {
659
+ if (disposedRenderer) return;
660
+ if (page.resourcePaintDirty) {
661
+ page.displayList = paintList();
662
+ page.resourcePaintDirty = false;
663
+ }
664
+ page.realm?.layoutChanged(page.layoutVersion);
495
665
  if (onVideo) for (const state of page.media.values()) readPresentedFrame(state);
496
666
  const width = Math.max(1, Math.floor(page.width));
497
667
  const height = Math.max(1, Math.floor(page.height));
@@ -499,15 +669,27 @@ function buildPageRenderer({
499
669
  if (lastTile && tileCovers(lastTile, region) && sameRasterInput()) {
500
670
  page.needsPaint = false;
501
671
  // Only this thread's scroll position changed: tell the compositor.
502
- if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y) publish(lastTile);
672
+ if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y
673
+ || page.width !== syncedViewport.width || page.height !== syncedViewport.height) publish(lastTile);
503
674
  } else {
504
- publish(rasterTile(region));
675
+ // Keep a previously prepainted region during ordinary page updates.
676
+ // Shrinking to the viewport and expanding again 30ms later used to
677
+ // rasterize each animation twice and repeatedly resize native surfaces.
678
+ const retained = !page.loading && lastTile && tileCovers(lastTile, region)
679
+ && lastTile.height <= height * 2.5;
680
+ publish(rasterTile(retained ? { x: lastTile.x, y: lastTile.y, width: lastTile.width, height: lastTile.height } : region));
505
681
  }
506
682
  schedulePrepaint();
507
683
  }
508
684
 
509
685
  /** Rasterizes a page rectangle (CSS pixels) into RGBA pixels. */
510
686
  function rasterTile(region) {
687
+ // Prepaint can run before a queued canvas/image repaint. Refresh native
688
+ // snapshots here too: the old display list may hold a released image.
689
+ if (page.resourcePaintDirty) {
690
+ page.displayList = paintList();
691
+ page.resourcePaintDirty = false;
692
+ }
511
693
  const scale = page.deviceScaleFactor;
512
694
  const pixelWidth = Math.max(1, Math.round(region.width * scale));
513
695
  const pixelHeight = Math.max(1, Math.round(region.height * scale));
@@ -526,14 +708,9 @@ function buildPageRenderer({
526
708
  drawHints(canvas, region);
527
709
  surface.flush();
528
710
  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();
711
+ let pixels;
712
+ try { pixels = rasterReadback.read(image, pixelWidth, pixelHeight); }
713
+ finally { image.delete(); }
537
714
  const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
538
715
  page.needsPaint = false;
539
716
  rasterized = {
@@ -557,6 +734,7 @@ function buildPageRenderer({
557
734
  function publish(tile) {
558
735
  lastTile = tile;
559
736
  tileSerial++;
737
+ syncedViewport = { width: page.width, height: page.height };
560
738
  if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y) {
561
739
  scrollEpoch++;
562
740
  syncedScroll = { x: viewport.x, y: viewport.y };
@@ -587,7 +765,7 @@ function buildPageRenderer({
587
765
 
588
766
  /** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
589
767
  function schedulePrepaint() {
590
- if (!onFrame || embedded) return;
768
+ if (disposedRenderer || !onFrame || embedded) return;
591
769
  hostClearTimeout(prepaintTimer);
592
770
  prepaintTimer = hostSetTimeout(() => {
593
771
  prepaintTimer = null;
@@ -606,25 +784,28 @@ function buildPageRenderer({
606
784
 
607
785
  /** A rendering opportunity for DOM changes nobody has asked to see yet. */
608
786
  function scheduleFrame() {
609
- if (!onFrame || frameTimer || page.loading) return;
787
+ if (disposedRenderer || !onFrame || frameTimer || page.loading) return;
610
788
  frameTimer = hostSetTimeout(() => {
611
789
  frameTimer = null;
612
790
  refresh();
613
- // Leave one rebuild-sized idle window after an expensive frame. Without
614
- // it, a large document can spend all its time producing back-to-back
615
- // frames while scripts are still making related mutations.
616
- }, Math.max(50, lastRebuildMs * 2));
791
+ // Coalesce ordinary mutations for 16ms. Expensive style/layout rebuilds
792
+ // retain a longer idle window so related script mutations can settle
793
+ // instead of continuously rebuilding a large document.
794
+ }, Math.max(16, lastRebuildMs * 2));
617
795
  }
618
796
 
619
- /** The viewport as a PNG (base64), cut from the latest tile. */
620
- async function viewportPng() {
797
+ /** The viewport as an encoded image (base64), cut from the latest tile. */
798
+ async function viewportPng({ format = "png", quality = 80, optimizeForSpeed = false, clip } = {}) {
621
799
  if (!lastTile) return "";
622
- const key = `${tileSerial}:${viewport.x}:${viewport.y}`;
623
- if (encoded.key !== key) {
624
- 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") };
800
+ if (format === 'rgba' || format === 'bgra') {
801
+ const {pixels,width,height} = captureViewport(lastTile, viewport.x, viewport.y, page.width, page.height,clip);
802
+ return rawFrame(pixels,width,height,format);
626
803
  }
627
- return encoded.png;
804
+ const key = `${tileSerial}:${viewport.x}:${viewport.y}:${page.width}:${page.height}:${format}:${quality}:${optimizeForSpeed}:${JSON.stringify(clip)}`;
805
+ return captures.get(key, () => {
806
+ const { pixels, width, height } = captureViewport(lastTile, viewport.x, viewport.y, page.width, page.height,clip);
807
+ return encodeImageBase64(pixels, width, height, { format, quality, optimizeForSpeed });
808
+ });
628
809
  }
629
810
 
630
811
  /** Rasterizes display items intersecting the viewport onto a CanvasKit canvas. */
@@ -637,11 +818,12 @@ function buildPageRenderer({
637
818
  pixel(rect.x - view.x), pixel(rect.y - view.y), pixel(rect.width), pixel(rect.height),
638
819
  );
639
820
  const deviceRRect = (rect, radii) => radii && (radii.x > 0 || radii.y > 0)
640
- ? CanvasKit.RRectXY(deviceRect(rect), radii.x * scale, radii.y * scale)
821
+ ? roundedRect(deviceRect(rect), radii, scale)
641
822
  : null;
642
823
  // Returns a paint plus a release function that also frees its shader.
643
824
  const fillPaint = (item) => {
644
825
  const paint = new CanvasKit.Paint();
826
+ if (item.radii && (item.radii.x > 0 || item.radii.y > 0)) paint.setAntiAlias(true);
645
827
  const shader = item.gradient ? gradientShader(item.gradient, view, scale) : null;
646
828
  if (shader) paint.setShader(shader);
647
829
  else paint.setColor(canvasColor(CanvasKit, item.color));
@@ -831,25 +1013,23 @@ function buildPageRenderer({
831
1013
  } else if (item.op === "drawSvg") {
832
1014
  drawSvg(item);
833
1015
  } 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));
1016
+ const entry = paragraphForPaint(item, scale, pixel(Math.max(1, view.width + view.x - item.x)));
1017
+ try {
1018
+ if (item.gradient) {
1019
+ // background-clip:text: draw glyphs, then keep the gradient only where they are.
1020
+ canvas.saveLayer(null, deviceRect(item.bounds));
1021
+ canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
1022
+ const paint = fillPaint(item);
1023
+ paint.setBlendMode(CanvasKit.BlendMode.SrcIn);
1024
+ canvas.drawRect(deviceRect(item.bounds), paint);
1025
+ paint.release();
1026
+ canvas.restore();
1027
+ } else {
1028
+ canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
1029
+ }
1030
+ } finally {
1031
+ if (!entry.retained) entry.paragraph.delete();
851
1032
  }
852
- paragraph.delete();
853
1033
  }
854
1034
  }
855
1035
  }
@@ -866,13 +1046,9 @@ function buildPageRenderer({
866
1046
  const Y0 = (item.rect.y - view.y) * scale;
867
1047
  const X1 = X0 + item.rect.width * scale;
868
1048
  const Y1 = Y0 + item.rect.height * scale;
869
- const rx = item.radii.x * scale;
870
- const ry = item.radii.y * scale;
871
1049
  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));
1050
+ const outer = roundedRect([X0, Y0, X1, Y1], item.radii, scale);
1051
+ const inner = roundedRect([x0, y0, x1, y1], insetRadii(item.radii, item.widths), scale);
876
1052
  const sides = [
877
1053
  [X0, Y0, X1, Y0, x1, y0, x0, y0],
878
1054
  [X1, Y0, X1, Y1, x1, y1, x1, y0],
@@ -1029,6 +1205,29 @@ function buildPageRenderer({
1029
1205
  }
1030
1206
  }
1031
1207
 
1208
+ function nativeControlState(element) {
1209
+ return [element.value, element.checked, element.indeterminate, element.selected, element.selectedIndex];
1210
+ }
1211
+
1212
+ /** HTML activation can change control properties without attribute records
1213
+ * (including a different radio in the same group). Compare these few native
1214
+ * states instead of restyling the entire document for every pointer click.
1215
+ */
1216
+ function controlStatesBeforeActivation() {
1217
+ return new Map(Array.from(page.document.querySelectorAll("input,select,textarea,option"),
1218
+ element => [element,nativeControlState(element)]));
1219
+ }
1220
+
1221
+ function invalidateActivatedControls(previous) {
1222
+ for (const [element,before] of previous) {
1223
+ const after = nativeControlState(element);
1224
+ if (before.some((value,index) => value !== after[index])) {
1225
+ markStyleDirty(element);
1226
+ page.dirty = true;
1227
+ }
1228
+ }
1229
+ }
1230
+
1032
1231
  function observeStyleChanges(document) {
1033
1232
  styleChanges.observer?.disconnect();
1034
1233
  styleChanges.records = [];
@@ -1036,6 +1235,7 @@ function buildPageRenderer({
1036
1235
  styleChanges.rootFeatures = new Map();
1037
1236
  styleChanges.full = "new document";
1038
1237
  styleChanges.activeElement = null;
1238
+ styleChanges.focusAncestors = new Set();
1039
1239
  const observer = new page.window.MutationObserver((records) => { styleChanges.records.push(...records); });
1040
1240
  observer.observe(document, { subtree: true, childList: true, attributes: true, attributeOldValue: true, characterData: true });
1041
1241
  styleChanges.observer = observer;
@@ -1060,6 +1260,7 @@ function buildPageRenderer({
1060
1260
  descendantFeatures: union("descendantFeatures"), siblingFeatures: union("siblingFeatures"),
1061
1261
  descendantStateCompounds: parts.flatMap((part) => part.descendantStateCompounds),
1062
1262
  siblingTargetCompounds: [...union("siblingTargetCompounds")],
1263
+ observedAttributes: union("observedAttributes"),
1063
1264
  forward: any("forward"), firstChild: any("firstChild"), backward: any("backward"), lastChild: any("lastChild"),
1064
1265
  defined: any("defined"), target: any("target"),
1065
1266
  };
@@ -1189,11 +1390,12 @@ function buildPageRenderer({
1189
1390
  let treeDirty = false;
1190
1391
  let full = styleChanges.full;
1191
1392
  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
- }
1393
+ const previousActive = styleChanges.activeElement;
1394
+ const previousFocusAncestors = styleChanges.focusAncestors;
1395
+ const focusAncestors = new Set();
1396
+ for (let element = active; element; element = element.parentElement) focusAncestors.add(element);
1397
+ styleChanges.activeElement = active;
1398
+ styleChanges.focusAncestors = focusAncestors;
1197
1399
  // Elements whose subtree restyles (`roots`), and elements whose own
1198
1400
  // matches may change but whose subtree restyles only if their style does
1199
1401
  // (`self`, decided by the style engine).
@@ -1221,7 +1423,8 @@ function buildPageRenderer({
1221
1423
  if (!states.length) return false;
1222
1424
  const available = new Set(elementFeatures(element));
1223
1425
  return traits.descendantStateCompounds.some((entry) => (states.includes(":state") || entry.states.some((state) => states.includes(state)))
1224
- && entry.required.every((feature) => available.has(feature)));
1426
+ && entry.required.every((feature) => available.has(feature))
1427
+ && entry.alternatives.every(group => group.some(required => required.every(feature => available.has(feature)))));
1225
1428
  };
1226
1429
  /** An element whose features changed: itself, and wider where non-subject compounds use them. */
1227
1430
  const changed = (element, features) => {
@@ -1238,22 +1441,38 @@ function buildPageRenderer({
1238
1441
  if (matchesChanged(structuralState, element)) roots.add(element);
1239
1442
  };
1240
1443
  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);
1444
+ // Only the explicitly marked roots need converting to feature changes;
1445
+ // roots added by relational/focus invalidation below must remain roots.
1446
+ const markedRoots = [...roots];
1447
+ roots.clear();
1448
+ for (const element of markedRoots) {
1245
1449
  if (!full) changed(element, styleChanges.rootFeatures.get(element) ?? [":state"]);
1246
1450
  }
1451
+ if (!full) {
1452
+ // Focus affects the two subjects, their :focus-within ancestor paths,
1453
+ // and selectors related to those nodes. Keep the previous path because
1454
+ // the old focused element may have been removed or reparented already.
1455
+ if (active !== previousActive) {
1456
+ if (previousActive) changed(previousActive, [":focus", ":focus-visible"]);
1457
+ if (active) changed(active, [":focus", ":focus-visible"]);
1458
+ }
1459
+ for (const element of previousFocusAncestors) if (!focusAncestors.has(element)) changed(element, [":focus-within"]);
1460
+ for (const element of focusAncestors) if (!previousFocusAncestors.has(element)) changed(element, [":focus-within"]);
1461
+ }
1247
1462
  for (const record of records) {
1248
1463
  if (full) break;
1249
1464
  const target = record.target;
1250
1465
  if (record.type === "attributes") {
1251
1466
  if (target.nodeType !== 1) continue;
1252
1467
  const name = record.attributeName;
1468
+ // Happy DOM reports null for an empty attribute's previous value on
1469
+ // removal. Null therefore cannot prove that the attribute is unchanged.
1470
+ if (record.oldValue !== null && record.oldValue === target.getAttribute(name)) continue;
1253
1471
  // data/ARIA/title changes have no direct render-tree representation;
1254
1472
  // selectors that observe them are handled by the style diff below.
1255
1473
  // Other attributes may alter controls, replaced elements or visibility.
1256
1474
  if (!name.startsWith("data-") && !name.startsWith("aria-") && name !== "title") treeDirty = true;
1475
+ else if (!traits.observedAttributes.has(name.toLowerCase())) continue;
1257
1476
  const features = ["[" + name];
1258
1477
  if (name === "class") {
1259
1478
  const before = new Set(String(record.oldValue ?? "").split(/\s+/).filter(Boolean));
@@ -1266,6 +1485,12 @@ function buildPageRenderer({
1266
1485
  if (id) features.push("#" + id);
1267
1486
  }
1268
1487
  changed(target, features);
1488
+ // The UA disclosure marker depends on its parent's open state even
1489
+ // when no author selector observes [open].
1490
+ if (name === "open" && target.tagName === "DETAILS") {
1491
+ const summary = [...target.children].find(element => element.tagName === "SUMMARY");
1492
+ if (summary) self.add(summary);
1493
+ }
1269
1494
  } else if (record.type === "childList") {
1270
1495
  treeDirty = true;
1271
1496
  if (target.nodeType !== 1) { full = `children of ${target.nodeName}`; break; }
@@ -1280,6 +1505,11 @@ function buildPageRenderer({
1280
1505
  }
1281
1506
  }
1282
1507
  self.add(target);
1508
+ // Adding, removing or moving a summary can change which one receives
1509
+ // the UA list-item display and disclosure marker.
1510
+ if (target.tagName === "DETAILS") {
1511
+ for (const child of target.children) if (child.tagName === "SUMMARY") self.add(child);
1512
+ }
1283
1513
  hasChanges(traits, target, add, hasChecked);
1284
1514
  let next = record.nextSibling;
1285
1515
  while (next && next.nodeType !== 1) next = next.nextSibling;
@@ -1318,16 +1548,26 @@ function buildPageRenderer({
1318
1548
  }
1319
1549
 
1320
1550
  function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
1551
+ if (page.document) loadImageResource.cancelDocument(page.document);
1552
+ rasterReadback.dispose();
1553
+ clearPaintParagraphs();
1554
+ resetDocumentFonts();
1321
1555
  disposeFrames();
1322
1556
  closePiano();
1323
1557
  disposeVideos();
1324
1558
  page.realm?.dispose();
1325
1559
  page.realm = null;
1326
- page.window?.happyDOM?.abort?.();
1560
+ page.window?.happyDOM?.close?.();
1327
1561
  for (const image of page.decodedImages) image.delete();
1328
1562
  disposeBackgrounds();
1329
1563
  page.window = parsed.window;
1330
1564
  page.document = parsed.document;
1565
+ observeDocumentCanvases(parsed.document, (node) => {
1566
+ if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
1567
+ page.resourcePaintDirty = true;
1568
+ page.needsPaint = true;
1569
+ scheduleFrame();
1570
+ });
1331
1571
  observeStyleChanges(parsed.document);
1332
1572
  page.title = parsed.document.title;
1333
1573
  page.styleSheets = styleSheets;
@@ -1335,6 +1575,8 @@ function buildPageRenderer({
1335
1575
  page.resources = imageResources.map;
1336
1576
  initializeVideos(parsed.document, page.resources);
1337
1577
  page.decodedImages = imageResources.decoded;
1578
+ page.imageElements = new Set(imageResources.entries.map(entry => entry.element));
1579
+ page.resourcePaintDirty = false;
1338
1580
  page.imagesLoaded = null;
1339
1581
  imageRequests = new WeakMap();
1340
1582
  page.elementScroll = new Map();
@@ -1355,6 +1597,20 @@ function buildPageRenderer({
1355
1597
  const ms = (from, to) => Math.round(to - from);
1356
1598
  const sheets = currentStyleSheets();
1357
1599
  const environment = { width: page.width, height: page.height, mobile };
1600
+ const documentBaseURL = page.document.baseURI || page.url;
1601
+ const environmentKey = `${page.width}/${page.height}/${mobile}/${documentBaseURL}`;
1602
+ if (fontSheets !== sheets || fontEnvironment !== environmentKey) {
1603
+ fontSheets = sheets;
1604
+ fontEnvironment = environmentKey;
1605
+ fontDefinitions = sheets.flatMap(sheet => /@font-face\b/i.test(sheet) ? extractFontFaces(sheet, documentBaseURL, environment) : []);
1606
+ documentFonts.setFaces(fontDefinitions);
1607
+ const key = JSON.stringify(fontDefinitions);
1608
+ if (key !== fontDefinitionKey) {
1609
+ fontDefinitionKey = key;
1610
+ clearFontCaches();
1611
+ page.resourceLayoutDirty = true;
1612
+ }
1613
+ }
1358
1614
  const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
1359
1615
  const taken = hostPerformance.now();
1360
1616
  // A rendering opportunity can be scheduled by lifecycle work which did
@@ -1404,17 +1660,19 @@ function buildPageRenderer({
1404
1660
  measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
1405
1661
  });
1406
1662
  page.displayList = paintList();
1663
+ page.resourcePaintDirty = false;
1407
1664
  // The content may now reach less far than a container was scrolled.
1408
1665
  if (clampElementScroll()) page.displayList = paintList();
1409
1666
  page.resourceLayoutDirty = false;
1410
1667
  laidOutSheets = sheets;
1411
1668
  setContentSize();
1669
+ queueFontLoads();
1412
1670
  return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
1413
1671
  }
1414
1672
 
1415
1673
  /** BUNINU_VERIFY_STYLE: compare an incremental restyle with a full one, element by element. */
1416
1674
  function verifyIncrementalStyle(sheets, environment) {
1417
- const reference = new StyleEngine().compute(page.document, sheets, environment);
1675
+ const reference = new StyleEngine({ measureText }).compute(page.document, sheets, environment);
1418
1676
  const serialize = (style) => JSON.stringify(style, (key, value) => typeof value === "bigint" ? String(value) : value);
1419
1677
  let mismatches = 0;
1420
1678
  for (const element of page.document.querySelectorAll("*")) {
@@ -1450,13 +1708,14 @@ function buildPageRenderer({
1450
1708
  staleImages = queueImageResources();
1451
1709
  }
1452
1710
  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();
1711
+ if (staleImages.length) {
1712
+ const stale = new Set(staleImages);
1713
+ page.decodedImages = page.decodedImages.filter(image => !stale.has(image));
1714
+ for (const image of stale) image.delete();
1457
1715
  }
1458
1716
  if (!timing.skipped) page.needsPaint = true;
1459
1717
  syncFrames();
1718
+ page.realm?.layoutChanged(page.layoutVersion);
1460
1719
  if (!timing.skipped) queueBackgroundImages();
1461
1720
  lastRebuildMs = hostPerformance.now() - started;
1462
1721
  trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
@@ -1475,6 +1734,7 @@ function buildPageRenderer({
1475
1734
  * page); the rest of the load continues, see `whenLoaded`.
1476
1735
  */
1477
1736
  function loadDocument(document) {
1737
+ if (disposedRenderer) return Promise.reject(new Error("Renderer has been disposed"));
1478
1738
  const generation = ++loadGeneration;
1479
1739
  let commit;
1480
1740
  const committed = new Promise((resolve, reject) => {
@@ -1494,7 +1754,7 @@ function buildPageRenderer({
1494
1754
  }
1495
1755
 
1496
1756
  async function runLoad({
1497
- url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "",
1757
+ url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "", referrer = "",
1498
1758
  }, generation, commit) {
1499
1759
  const current = () => generation === loadGeneration;
1500
1760
  const finish = () => {
@@ -1538,7 +1798,7 @@ function buildPageRenderer({
1538
1798
  phaseStart = now;
1539
1799
  } : () => {};
1540
1800
  const isHtml = contentType.toLowerCase().includes("html");
1541
- const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url);
1801
+ const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url, referrer);
1542
1802
  phase("parse");
1543
1803
  // Stylesheets block rendering and scripts; images do not (only the
1544
1804
  // window load event waits for them, see whenImagesLoaded): one slow image
@@ -1605,6 +1865,9 @@ function buildPageRenderer({
1605
1865
  refresh();
1606
1866
  phase("style, layout and raster after scripts");
1607
1867
  } else {
1868
+ await documentFonts.ready();
1869
+ if (!current()) return;
1870
+ refresh();
1608
1871
  finish();
1609
1872
  }
1610
1873
  }
@@ -1620,7 +1883,7 @@ function buildPageRenderer({
1620
1883
  const preloaded = new Map();
1621
1884
  const preload = (href) => {
1622
1885
  let target;
1623
- try { target = new URL(href, documentUrl); } catch { return; }
1886
+ try { target = new URL(href, document.baseURI || documentUrl); } catch { return; }
1624
1887
  if (!["http:", "https:"].includes(target.protocol) || preloaded.has(target.href)) return;
1625
1888
  const response = fetchResource(target.href, "script");
1626
1889
  response.catch(() => {}); // reported when the script runs
@@ -1636,7 +1899,8 @@ function buildPageRenderer({
1636
1899
 
1637
1900
  /** Frees what a superseded load fetched. */
1638
1901
  async function discard(images, parsed = null) {
1639
- parsed?.window?.happyDOM?.abort?.();
1902
+ if (parsed?.document) loadImageResource.cancelDocument(parsed.document);
1903
+ parsed?.window?.happyDOM?.close?.();
1640
1904
  const loaded = await images.catch(() => emptyImageResources());
1641
1905
  for (const image of loaded.decoded) image.delete();
1642
1906
  }
@@ -1651,7 +1915,7 @@ function buildPageRenderer({
1651
1915
  // queueImageResources must not fetch these again.
1652
1916
  for (const element of document.querySelectorAll("img")) {
1653
1917
  const src = element.getAttribute("src");
1654
- if (src) imageRequests.set(element, src);
1918
+ if (src) imageRequests.set(element, imageRequestKey(src, imageCrossOrigin(element)));
1655
1919
  }
1656
1920
  page.imagesLoaded = images.then((loaded) => {
1657
1921
  if (generation !== loadGeneration || document !== page.document) {
@@ -1659,12 +1923,13 @@ function buildPageRenderer({
1659
1923
  return;
1660
1924
  }
1661
1925
  for (const { element, src, resource } of loaded.entries) {
1662
- if (element.getAttribute("src") !== src || page.resources.get(element)) {
1926
+ if (element.getAttribute("src") !== src || imageCrossOrigin(element) !== resource.crossOrigin || page.resources.get(element)) {
1663
1927
  resource.image.delete();
1664
1928
  continue;
1665
1929
  }
1666
1930
  page.resources.set(element, resource);
1667
1931
  page.decodedImages.push(resource.image);
1932
+ page.imageElements.add(element);
1668
1933
  finishImageLoad(element, src, resource, type => element.dispatchEvent(new page.window.Event(type)));
1669
1934
  }
1670
1935
  page.dirty = true;
@@ -1675,18 +1940,44 @@ function buildPageRenderer({
1675
1940
 
1676
1941
  function inlineStyleSheets(document) {
1677
1942
  return [...document.querySelectorAll("style")]
1678
- .filter((element) => !element.closest("noscript"))
1943
+ .filter((element) => isCssStyleSheet(element) && styleSheetMatches(element))
1679
1944
  .map((element) => element.textContent ?? "");
1680
1945
  }
1681
1946
 
1947
+ function isCssStyleSheet(element) {
1948
+ const type = element.getAttribute("type")?.trim().toLowerCase();
1949
+ return (!type || type === "text/css") && !element.closest("noscript");
1950
+ }
1951
+
1952
+ function styleSheetMatches(element) {
1953
+ return mediaQueryMatches(element.getAttribute("media") ?? "", { width: page.width, height: page.height, mobile })
1954
+ && !(element.tagName === "LINK" && element.hasAttribute("disabled"));
1955
+ }
1956
+
1682
1957
  /** Brings style, layout and raster up to date; returns whether it repainted. */
1683
1958
  function refresh() {
1959
+ if (disposedRenderer) return false;
1684
1960
  if (page.dirty) rebuildRenderTree();
1685
1961
  if (!page.needsPaint) return false;
1686
1962
  render();
1687
1963
  return true;
1688
1964
  }
1689
1965
 
1966
+ function isDisclosureControl(element) {
1967
+ if (element?.tagName === "DETAILS") return ![...element.children].some(child => child.tagName === "SUMMARY");
1968
+ return element?.tagName === "SUMMARY" && element.parentElement?.tagName === "DETAILS"
1969
+ && [...element.parentElement.children].find(child => child.tagName === "SUMMARY") === element;
1970
+ }
1971
+
1972
+ function activateDisclosure(element) {
1973
+ const prevented = page.realm.dispatch(element, new page.window.MouseEvent("click", {
1974
+ bubbles: true, cancelable: true, composed: true, detail: 0,
1975
+ }));
1976
+ // An explicit summary is activated by the DOM's click default action.
1977
+ // The generated label has no DOM summary, so its owner toggles here.
1978
+ if (!prevented && element.tagName === "DETAILS") element.open = !element.open;
1979
+ }
1980
+
1690
1981
  function editFocusedText(text, backspace = false) {
1691
1982
  const element = page.document.activeElement;
1692
1983
  if (!element || !["INPUT", "TEXTAREA"].includes(element.tagName) || element.disabled || element.readOnly) return;
@@ -1765,6 +2056,31 @@ function buildPageRenderer({
1765
2056
  page.dirty = true;
1766
2057
  scheduleFrame();
1767
2058
  },
2059
+ activationState: controlStatesBeforeActivation,
2060
+ activationChanged(previous) {
2061
+ invalidateActivatedControls(previous);
2062
+ if (page.dirty) scheduleFrame();
2063
+ },
2064
+ canvasImage(element) {
2065
+ if (!element) return null;
2066
+ if (element.tagName === "CANVAS") return canvasResource(element);
2067
+ const resource = page.resources.get(element);
2068
+ if (!resource?.image) return null;
2069
+ const url = resource.url ?? element.src ?? "";
2070
+ const originClean = resource.originClean ?? (url.startsWith("data:") || originOf(url) === originOf(page.url));
2071
+ return { ...resource, originClean };
2072
+ },
2073
+ canvasFont(element, value) {
2074
+ return parseCanvasFont(value, styleEngine.get(element)?.fontSize ?? 16,
2075
+ styleEngine.get(page.document.documentElement)?.fontSize ?? 16);
2076
+ },
2077
+ canvasText(element, request) {
2078
+ const style = { ...request.settings.parsedFont,
2079
+ direction: request.settings.direction === "inherit" ? styleEngine.get(element)?.direction ?? "ltr" : request.settings.direction };
2080
+ documentFonts.update(fontDefinitions, [{ text: String(request.args[0]), style }]);
2081
+ return renderCanvasText({ ...request, style,
2082
+ settings: { ...request.settings, direction: style.direction } });
2083
+ },
1768
2084
  mediaState,
1769
2085
  playMedia,
1770
2086
  pauseMedia,
@@ -1788,7 +2104,7 @@ function buildPageRenderer({
1788
2104
  scheduleFrame();
1789
2105
  }
1790
2106
  },
1791
- whenImagesLoaded: () => Promise.all([page.imagesLoaded, ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
2107
+ whenImagesLoaded: () => Promise.all([page.imagesLoaded, documentFonts.ready(), ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
1792
2108
  afterTask() {},
1793
2109
  boundsOf(element) {
1794
2110
  if (page.dirty) rebuildRenderTree("script read geometry");
@@ -1820,9 +2136,9 @@ function buildPageRenderer({
1820
2136
  if (page.dirty) rebuildRenderTree("script scrolled");
1821
2137
  setElementScroll(element, x, y);
1822
2138
  },
1823
- scrollIntoView(element) {
2139
+ scrollIntoView(element, options) {
1824
2140
  if (page.dirty) rebuildRenderTree("script scrolled");
1825
- revealElement(element);
2141
+ revealElement(element, options);
1826
2142
  },
1827
2143
  computedStyle(element) {
1828
2144
  if (!element) return null;
@@ -1845,7 +2161,7 @@ function buildPageRenderer({
1845
2161
  },
1846
2162
  navigate: (url) => navigateTo(String(url)),
1847
2163
  activateLink(anchor) {
1848
- const href = new URL(anchor.getAttribute("href"), page.url).href;
2164
+ const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
1849
2165
  const name = anchor.hasAttribute("download") ? anchor.getAttribute("download") ?? "" : null;
1850
2166
  if (href.startsWith("blob:") && name !== null) void downloadBlob(href, name);
1851
2167
  else navigateTo(href, name);
@@ -1880,6 +2196,7 @@ function buildPageRenderer({
1880
2196
  page.cookie = mergeDocumentCookie(page.cookie, String(value));
1881
2197
  setCookie(String(value));
1882
2198
  },
2199
+ clipboardWrite,
1883
2200
  webSockets,
1884
2201
  pageFetch(request) {
1885
2202
  const networkPending = pageFetch(request);
@@ -1887,7 +2204,8 @@ function buildPageRenderer({
1887
2204
  if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
1888
2205
  return response;
1889
2206
  });
1890
- result.abort = () => networkPending.abort?.();
2207
+ result.abort = (reason) => networkPending.abort?.(reason);
2208
+ result.finished = networkPending.finished;
1891
2209
  return result;
1892
2210
  },
1893
2211
  };
@@ -1901,7 +2219,7 @@ function buildPageRenderer({
1901
2219
  function submitForm(form, submitter = null) {
1902
2220
  const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
1903
2221
  const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
1904
- const url = new URL(action || page.url, page.url);
2222
+ const url = new URL(action || page.url, form.baseURI || page.url);
1905
2223
  const entries = [...new page.window.FormData(form, submitter)];
1906
2224
  if (method === "post") {
1907
2225
  const enctype = (submitter?.getAttribute("formenctype") ?? form.getAttribute("enctype") ?? "").toLowerCase();
@@ -1951,14 +2269,7 @@ function buildPageRenderer({
1951
2269
  const shift = scrollShift(element);
1952
2270
  const scrollX = (fixed ? 0 : viewport.x) + shift.x;
1953
2271
  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);
2272
+ const box = elementBox(page.renderTree, page.layout, element)?.box;
1962
2273
  if (box) return { left: box.x - scrollX, top: box.y - scrollY, width: box.width, height: box.height };
1963
2274
  const content = elementBounds(page.renderTree, page.layout, element);
1964
2275
  return content
@@ -2132,6 +2443,7 @@ function buildPageRenderer({
2132
2443
  * visualViewport.scale.
2133
2444
  */
2134
2445
  resize(width, height, deviceScaleFactor = 1, pageScale = 1) {
2446
+ const resized = width !== page.width || height !== page.height;
2135
2447
  page.pageScale = Number.isFinite(pageScale) && pageScale > 0 ? pageScale : 1;
2136
2448
  page.width = width;
2137
2449
  page.height = height;
@@ -2140,11 +2452,15 @@ function buildPageRenderer({
2140
2452
  : 1;
2141
2453
  viewport.resize(width, height);
2142
2454
  rebuildRenderTree();
2455
+ // Listeners must observe the new viewport and layout. A repeated metrics
2456
+ // request (or DPR-only change) does not resize the CSS viewport.
2457
+ if (resized && page.realm)
2458
+ page.realm.dispatch(page.window, new page.window.Event("resize"));
2143
2459
  render();
2144
2460
  },
2145
- screenshot() {
2461
+ screenshot(options) {
2146
2462
  refresh();
2147
- return viewportPng();
2463
+ return viewportPng(options);
2148
2464
  },
2149
2465
  /**
2150
2466
  * Diagnostics (not exposed over IPC): page-space border boxes and chosen
@@ -2212,6 +2528,7 @@ function buildPageRenderer({
2212
2528
  /** The compositor scrolled: follow it, and rasterize around the new position when idle. */
2213
2529
  applyCompositorScroll(x, y) {
2214
2530
  viewport.scrollTo(x, y);
2531
+ page.realm?.layoutChanged(page.layoutVersion);
2215
2532
  syncedScroll = { x: viewport.x, y: viewport.y };
2216
2533
  schedulePrepaint();
2217
2534
  },
@@ -2242,8 +2559,13 @@ function buildPageRenderer({
2242
2559
  bubbles: true, cancelable: true,
2243
2560
  };
2244
2561
  const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
2562
+ let activated = false;
2245
2563
  if (!prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
2246
- if (key === "Backspace") editFocusedText("", true);
2564
+ if ((key === "Enter" || key === " ") && isDisclosureControl(target)) {
2565
+ activated = true;
2566
+ if (key === "Enter") activateDisclosure(target);
2567
+ }
2568
+ else if (key === "Backspace") editFocusedText("", true);
2247
2569
  else if (key === "Enter" && target.tagName === "TEXTAREA") {
2248
2570
  // A search IME's action key submits a search form. Other textareas
2249
2571
  // keep their normal newline behavior.
@@ -2255,8 +2577,9 @@ function buildPageRenderer({
2255
2577
  }
2256
2578
  else if (String(key).length === 1) editFocusedText(String(key));
2257
2579
  }
2258
- page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
2259
- if (refresh() || prevented) return { x: viewport.x, y: viewport.y };
2580
+ const upPrevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
2581
+ if (activated && key === " " && !upPrevented) activateDisclosure(target);
2582
+ if (refresh() || prevented || activated) return { x: viewport.x, y: viewport.y };
2260
2583
  }
2261
2584
  const pageStep = Math.max(31, viewport.height - 31);
2262
2585
  const delta = {
@@ -2319,9 +2642,6 @@ function buildPageRenderer({
2319
2642
  */
2320
2643
  click(x, y) {
2321
2644
  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
2645
  let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
2326
2646
  let forwarded = false;
2327
2647
  if (!region) {
@@ -2344,12 +2664,17 @@ function buildPageRenderer({
2344
2664
  // it is now (scroll containers move their content); the interactive
2345
2665
  // regions (laid out, unscrolled) stand in only where nothing is painted,
2346
2666
  // 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;
2667
+ const hitNode = page.displayList ? renderNodeAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
2668
+ const precise = hitNode?.domNode?.nodeType === 1 ? hitNode.domNode : hitNode?.domNode?.parentElement;
2348
2669
  const target = forwarded ? region.element : precise ?? region?.element ?? null;
2670
+ const generatedDisclosure = hitNode?.pseudo === "summary" && target?.tagName === "DETAILS";
2349
2671
  if (!target) {
2350
2672
  focusedFrame = null;
2351
2673
  return null;
2352
2674
  }
2675
+ // Event dispatch determines the activation target before handlers run.
2676
+ // A handler may replace the clicked descendants while retaining the link.
2677
+ const activationAnchor = target.closest?.("a[href]");
2353
2678
  // A click inside an iframe belongs to its document alone.
2354
2679
  const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
2355
2680
  focusedFrame = inFrame?.frame ?? null;
@@ -2370,11 +2695,12 @@ function buildPageRenderer({
2370
2695
  }
2371
2696
  // The press focuses the focusable element it lands in, unless mousedown was canceled.
2372
2697
  const focus = () => {
2373
- const focusable = target.closest?.("input, textarea, select, button, [tabindex]");
2698
+ const focusable = generatedDisclosure ? target : target.closest?.("input, textarea, select, button, summary, [tabindex]");
2374
2699
  if (focusable && !focusable.disabled) focusable.focus();
2375
2700
  };
2376
2701
  if (!page.realm) focus();
2377
2702
  else {
2703
+ const controlStates = controlStatesBeforeActivation();
2378
2704
  // A press and release, then the click (UI Events §3.5).
2379
2705
  const init = { bubbles: true, cancelable: true, composed: true, clientX: Number(x), clientY: Number(y), button: 0 };
2380
2706
  const PointerEvent = page.window.PointerEvent ?? page.window.MouseEvent;
@@ -2384,16 +2710,22 @@ function buildPageRenderer({
2384
2710
  page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
2385
2711
  page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
2386
2712
  const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", { ...init, detail: 1 }));
2713
+ invalidateActivatedControls(controlStates);
2387
2714
  refresh();
2388
2715
  if (prevented) return null;
2389
2716
  }
2717
+ if (generatedDisclosure) {
2718
+ target.open = !target.open;
2719
+ refresh();
2720
+ return null;
2721
+ }
2390
2722
  if ((target.tagName === "AUDIO" || target.tagName === "VIDEO") && target.hasAttribute("controls")) {
2391
2723
  clickMedia(target, x, y);
2392
2724
  return null;
2393
2725
  }
2394
- const anchor = target.closest?.("a[href]");
2395
- if (!anchor) return null;
2396
- const href = new URL(anchor.getAttribute("href"), page.url).href;
2726
+ const anchor = activationAnchor;
2727
+ if (!anchor?.hasAttribute("href")) return null;
2728
+ const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
2397
2729
  if (href.startsWith("javascript:")) {
2398
2730
  this.runJavaScriptURL(href);
2399
2731
  return null;
@@ -2466,16 +2798,22 @@ function buildPageRenderer({
2466
2798
  if (!page.realm) return undefined;
2467
2799
  let value = page.realm.evaluate(utilityExpression);
2468
2800
  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));
2801
+ let timer;
2802
+ try {
2803
+ value = await Promise.race([
2804
+ Promise.resolve(value),
2805
+ new Promise((resolve) => { timer = hostSetTimeout(resolve, 5000, undefined); }),
2806
+ ]).catch((error) => String(error?.message ?? error));
2807
+ } finally { hostClearTimeout(timer); }
2473
2808
  }
2474
2809
  refresh();
2475
2810
  return cloneableResult(value);
2476
2811
  },
2477
2812
  dispose() {
2813
+ if (disposedRenderer) return;
2478
2814
  disposedRenderer = true;
2815
+ loadGeneration++;
2816
+ if (page.document) loadImageResource.cancelDocument(page.document);
2479
2817
  hostClearTimeout(prepaintTimer);
2480
2818
  hostClearTimeout(frameTimer);
2481
2819
  disposeFrames();
@@ -2484,8 +2822,13 @@ function buildPageRenderer({
2484
2822
  disposeVideos();
2485
2823
  rasterSurface?.dispose();
2486
2824
  rasterSurface = null;
2487
- page.window?.happyDOM?.abort?.();
2825
+ rasterReadback.dispose();
2826
+ clearPaintParagraphs();
2827
+ documentFonts.dispose();
2828
+ page.window?.happyDOM?.close?.();
2488
2829
  for (const image of page.decodedImages) image.delete();
2830
+ page.decodedImages = [];
2831
+ page.imageElements.clear();
2489
2832
  disposeBackgrounds();
2490
2833
  },
2491
2834
  };
@@ -2511,18 +2854,22 @@ function buildPageRenderer({
2511
2854
  function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
2512
2855
  const sheets = [];
2513
2856
  for (const element of document.querySelectorAll("style, link[rel~='stylesheet']")) {
2514
- if (element.closest("noscript")) continue;
2857
+ if (!isCssStyleSheet(element)) continue;
2858
+ const applies = styleSheetMatches(element);
2515
2859
  if (element.tagName === "STYLE") {
2516
- sheets.push(element.textContent ?? "");
2860
+ if (applies) sheets.push(element.textContent ?? "");
2517
2861
  continue;
2518
2862
  }
2863
+ // A disabled link is fetched when enabled; nonmatching media may be
2864
+ // fetched now, but must never contribute rules to the screen cascade.
2865
+ if (element.hasAttribute("disabled")) continue;
2519
2866
  const href = element.getAttribute("href");
2520
2867
  let url;
2521
- try { url = href ? new URL(href, documentUrl).href : null; } catch { url = null; }
2868
+ try { url = href ? new URL(href, element.baseURI || documentUrl).href : null; } catch { url = null; }
2522
2869
  if (!url) continue;
2523
2870
  const loaded = linkSheets.get(element);
2524
2871
  if (loaded?.url === url) {
2525
- if (loaded.text !== null) sheets.push(loaded.text);
2872
+ if (applies && loaded.text !== null) sheets.push(loaded.text);
2526
2873
  continue;
2527
2874
  }
2528
2875
  const entry = { url, text: null };
@@ -2561,17 +2908,10 @@ function buildPageRenderer({
2561
2908
  if (!src) return;
2562
2909
  beginImageLoad(element, src);
2563
2910
  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 });
2911
+ const resource = await loadImageResource(document, new URL(src, element.baseURI || documentUrl).href, imageCrossOrigin(element));
2572
2912
  finishImageLoad(element, src, resource);
2573
2913
  map.set(element, resource);
2574
- decoded.push(image);
2914
+ decoded.push(resource.image);
2575
2915
  entries.push({ element, src, resource });
2576
2916
  } catch {
2577
2917
  // Broken images keep their fallback replaced-element dimensions.
@@ -2640,6 +2980,7 @@ function buildPageRenderer({
2640
2980
  requestDownload,
2641
2981
  reportError,
2642
2982
  pageFetch,
2983
+ clipboardWrite,
2643
2984
  webSockets,
2644
2985
  setCookie,
2645
2986
  consoleMessage,
@@ -2691,7 +3032,7 @@ function buildPageRenderer({
2691
3032
  const src = element.getAttribute("src")?.trim();
2692
3033
  if (!src) return "about:blank";
2693
3034
  try {
2694
- return new URL(src, page.url).href;
3035
+ return new URL(src, element.baseURI || page.url).href;
2695
3036
  } catch {
2696
3037
  return "about:blank";
2697
3038
  }
@@ -2794,6 +3135,12 @@ function buildPageRenderer({
2794
3135
  }
2795
3136
 
2796
3137
  function paintList() {
3138
+ for (const node of page.renderTree?.nodesById.values() ?? []) {
3139
+ if (node.tagName !== "CANVAS") continue;
3140
+ const resource = canvasResource(node.domNode);
3141
+ if (resource) page.resources.set(node.domNode, resource);
3142
+ else page.resources.delete(node.domNode);
3143
+ }
2797
3144
  return buildDisplayList(page.layout, page.renderTree, page.resources, backgroundResource, scrollOffsetOf);
2798
3145
  }
2799
3146
 
@@ -2803,13 +3150,7 @@ function buildPageRenderer({
2803
3150
 
2804
3151
  /** The render node and layout box of an element, or null. */
2805
3152
  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;
3153
+ return elementBox(page.renderTree, page.layout, element);
2813
3154
  }
2814
3155
 
2815
3156
  /**
@@ -2863,6 +3204,7 @@ function buildPageRenderer({
2863
3204
  if (next.x || next.y) page.elementScroll.set(element, next);
2864
3205
  else page.elementScroll.delete(element);
2865
3206
  page.layoutVersion++;
3207
+ page.realm?.layoutChanged(page.layoutVersion);
2866
3208
  page.displayList = paintList();
2867
3209
  page.needsPaint = true;
2868
3210
  // A scroll event at the element, as a task (CSSOM View §13.1).
@@ -2960,20 +3302,31 @@ function buildPageRenderer({
2960
3302
  return left;
2961
3303
  }
2962
3304
 
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) {
3305
+ /** Aligns the element within each scrollport, from the innermost out. */
3306
+ function revealElement(element, { block = "start", inline = "nearest", container = "all" } = {}) {
3307
+ let moved = false;
3308
+ let fixed = styleEngine.get(element)?.position === "fixed";
3309
+ const direction = styleEngine.get(element)?.direction;
3310
+ if (direction === "rtl" && inline !== "nearest" && inline !== "center") inline = inline === "start" ? "end" : "start";
3311
+ for (let node = element.parentElement; !fixed && node && node.nodeType === 1; node = node.parentElement) {
3312
+ if (node === page.document.documentElement) break;
3313
+ fixed = styleEngine.get(node)?.position === "fixed";
2966
3314
  const geometry = scrollerGeometry(node);
2967
3315
  if (!geometry) continue;
2968
3316
  const target = viewportBounds(element);
2969
- const container = viewportBounds(node);
2970
- if (!target || !container) continue;
3317
+ const bounds = viewportBounds(node);
3318
+ if (!target || !bounds) continue;
2971
3319
  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));
3320
+ moved = setElementScroll(node,
3321
+ geometry.x + scrollAlignmentDelta(target.left - bounds.left - borderLeft, target.width, geometry.clientWidth, inline),
3322
+ geometry.y + scrollAlignmentDelta(target.top - bounds.top - borderTop, target.height, geometry.clientHeight, block)) || moved;
3323
+ if (container === "nearest") { if (moved) render(); return; }
2973
3324
  }
2974
3325
  const bounds = viewportBounds(element);
2975
- if (bounds) viewport.scrollTo(viewport.x, viewport.y + bounds.top);
2976
- render();
3326
+ if (bounds && !fixed) moved = viewport.scrollTo(
3327
+ viewport.x + scrollAlignmentDelta(bounds.left, bounds.width, viewport.width, inline),
3328
+ viewport.y + scrollAlignmentDelta(bounds.top, bounds.height, viewport.height, block)) || moved;
3329
+ if (moved) render();
2977
3330
  }
2978
3331
 
2979
3332
  /** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
@@ -2989,7 +3342,7 @@ function buildPageRenderer({
2989
3342
 
2990
3343
  function videoSource(element) {
2991
3344
  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; }
3345
+ try { return src ? new URL(src, element.baseURI || page.url).href : null; } catch { return null; }
2993
3346
  }
2994
3347
 
2995
3348
  // ---- media: <video> and <audio> share one pipeline. An <audio> is media
@@ -3095,6 +3448,22 @@ function buildPageRenderer({
3095
3448
  if (!state) return;
3096
3449
  state.mediaSource = source;
3097
3450
  bindSourcePipes(state);
3451
+ // A later initialization segment can replace already-buffered video.
3452
+ // Shorten the active presentation at its epoch boundary as soon as that
3453
+ // boundary is known, without discarding the frames before it.
3454
+ if (state.video?.pipe?.snapshotAt) {
3455
+ const current = state.video.pipe.snapshotAt(state.video.startTime);
3456
+ const endTime = current.id === state.video.snapshotId ? current.endTime : current.startTime;
3457
+ if (endTime !== state.video.endTime) {
3458
+ state.video.endTime = endTime;
3459
+ if (state.video.presentation) onVideo?.({ type: "video-bind", id: state.video.presentation.id,
3460
+ endTime });
3461
+ }
3462
+ }
3463
+ if (state.sound?.pipe?.snapshotAt) {
3464
+ const current = state.sound.pipe.snapshotAt(state.sound.startTime);
3465
+ state.sound.endTime = current.id === state.sound.snapshotId ? current.endTime : current.startTime;
3466
+ }
3098
3467
  if (state.audioPipe && state.audioPipe !== state.videoPipe) state.hasAudio = true;
3099
3468
  void refreshProbe(state);
3100
3469
  if (state.playing && state.hasAudio && !state.sound) ensureAudio(state);
@@ -3226,8 +3595,9 @@ function buildPageRenderer({
3226
3595
  const id = nextVideoPresentation++;
3227
3596
  const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
3228
3597
  video.presentation = { id, shared, serial: 0 };
3229
- onVideo({ type: "video-open", id, shared, bytes: snapshot(video.pipe, state.bytes),
3598
+ onVideo({ type: "video-open", id, shared, bytes: video.bytes,
3230
3599
  options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
3600
+ startTime: video.startTime ?? 0, endTime: video.endTime ?? Infinity,
3231
3601
  hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
3232
3602
  audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
3233
3603
  }
@@ -3244,10 +3614,10 @@ function buildPageRenderer({
3244
3614
  drawHints(canvas, region);
3245
3615
  surface.flush();
3246
3616
  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;
3617
+ try {
3618
+ return image.readPixels(0, 0, { width, height, colorType: CanvasKit.ColorType.RGBA_8888,
3619
+ alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB });
3620
+ } finally { image.delete(); }
3251
3621
  };
3252
3622
  const base = raster(false), mask = raster(true);
3253
3623
  for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
@@ -3338,7 +3708,7 @@ function buildPageRenderer({
3338
3708
  while (true) {
3339
3709
  if (pipe) await pipe.waitFor(pipe.done ? pipe.size : Math.max(1, Math.min(want, pipe.size + 1)));
3340
3710
  if (pipe?.error) throw pipe.error;
3341
- const bytes = pipe ? pipe.bytes() : state.bytes;
3711
+ const bytes = snapshot(pipe, state.bytes);
3342
3712
  if (!bytes?.byteLength) {
3343
3713
  if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
3344
3714
  continue;
@@ -3382,7 +3752,7 @@ function buildPageRenderer({
3382
3752
  const pipe = state.videoPipe ?? state.pipe;
3383
3753
  if (!pipe?.size) return;
3384
3754
  try {
3385
- const probe = await videoDecoder.probe(pipe.bytes());
3755
+ const probe = await videoDecoder.probe(snapshot(pipe, state.bytes, mediaTime(state)));
3386
3756
  if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
3387
3757
  state.duration = probe.duration;
3388
3758
  state.resource.duration = probe.duration;
@@ -3431,19 +3801,21 @@ function buildPageRenderer({
3431
3801
  return format ? { format } : {};
3432
3802
  }
3433
3803
 
3434
- function snapshot(pipe, bytes) {
3435
- return pipe ? pipe.bytes() : bytes;
3804
+ function snapshot(pipe, bytes, time = 0) {
3805
+ return pipe?.snapshotAt ? pipe.snapshotAt(time).bytes : pipe ? pipe.bytes() : bytes;
3436
3806
  }
3437
3807
 
3438
- async function openVideoDecoder(state) {
3808
+ async function openVideoDecoder(state, time = mediaTime(state)) {
3439
3809
  stopVideoPresentation(state);
3440
3810
  const pipe = state.videoPipe ?? state.pipe;
3441
3811
  const generation = pipe?.generation ?? 0;
3442
- const bytes = snapshot(pipe, state.bytes);
3812
+ const selected = pipe?.snapshotAt?.(time);
3813
+ const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
3443
3814
  if (!bytes?.byteLength) throw new Error(`media ${state.url}: no video bytes`);
3444
3815
  state.video?.decoder.close();
3445
3816
  const decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
3446
- state.video = { decoder, frameTime: 0, done: false, pipe, openedGeneration: generation };
3817
+ state.video = { decoder, bytes, frameTime: selected?.startTime ?? 0, done: false, pipe, openedGeneration: generation,
3818
+ snapshotId: selected?.id, startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
3447
3819
  trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
3448
3820
  if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
3449
3821
  state.resource.width = decoder.width;
@@ -3459,13 +3831,14 @@ function buildPageRenderer({
3459
3831
  * (no bytes yet, or audio_open hit EOF / invalid data on a short prefix).
3460
3832
  * The previous decoder stays until a new one succeeds.
3461
3833
  */
3462
- async function openAudioDecoder(state, { keepStream = false } = {}) {
3834
+ async function openAudioDecoder(state, { keepStream = false, time = mediaTime(state) } = {}) {
3463
3835
  const serial = state.seekSerial;
3464
3836
  const operation = (state.audioOpenSerial ?? 0) + 1;
3465
3837
  state.audioOpenSerial = operation;
3466
3838
  const pipe = state.audioPipe ?? state.pipe;
3467
3839
  const generation = pipe?.generation ?? 0;
3468
- const bytes = snapshot(pipe, state.bytes);
3840
+ const selected = pipe?.snapshotAt?.(time);
3841
+ const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
3469
3842
  const pending = pipe && !pipe.done && !pipe.error;
3470
3843
  if (!bytes?.byteLength) {
3471
3844
  if (pending) return false;
@@ -3502,8 +3875,10 @@ function buildPageRenderer({
3502
3875
  throw new Error("audio format changed while output stream is active");
3503
3876
  }
3504
3877
  previous?.decoder.close();
3505
- state.sound = { decoder, id: null, position: 0, pending: firstSamples, history: [], finishing: false, done: false,
3506
- pipe, openedGeneration: generation };
3878
+ state.sound = { decoder, id: null, position: Math.round((selected?.startTime ?? 0) * decoder.sampleRate),
3879
+ pending: firstSamples, history: [], finishing: false, done: false,
3880
+ pipe, openedGeneration: generation, snapshotId: selected?.id,
3881
+ startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
3507
3882
  if (keepStream && previousId != null) {
3508
3883
  // The output stream carries on with the new decoder's samples.
3509
3884
  Object.assign(state.sound, { id: previousId, streamStart: previous.streamStart,
@@ -3554,7 +3929,10 @@ function buildPageRenderer({
3554
3929
  function needsMore(track) {
3555
3930
  const pipe = track?.pipe;
3556
3931
  if (!pipe || pipe.error) return false;
3557
- return pipe.generation !== track.openedGeneration || !pipe.done;
3932
+ const atBoundary = Number.isFinite(track.endTime) && (track.frameTime !== undefined
3933
+ ? track.frameTime + 1e-6 >= track.endTime
3934
+ : track.position >= Math.round(track.endTime * track.decoder.sampleRate));
3935
+ return pipe.generation !== track.openedGeneration || !pipe.done || atBoundary;
3558
3936
  }
3559
3937
 
3560
3938
  /** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
@@ -3658,9 +4036,15 @@ function buildPageRenderer({
3658
4036
  /** Seek, reopening on a longer snapshot when the target is not in this prefix yet. */
3659
4037
  async function placeVideo(state, seconds) {
3660
4038
  for (let attempt = 0; attempt < 8; attempt++) {
3661
- if (!state.video || state.video.done || seconds === 0 || attempt > 0) await openVideoDecoder(state);
4039
+ const pipe = state.videoPipe ?? state.pipe;
4040
+ const selected = pipe?.snapshotAt?.(seconds);
4041
+ // A seek beyond an old snapshot can succeed at its last keyframe.
4042
+ // Reopen on newer bytes first; an exception does not detect that case.
4043
+ const stale = pipe && (pipe !== state.video?.pipe || pipe.generation !== state.video?.openedGeneration);
4044
+ if (!state.video || state.video.done || seconds === 0 || attempt > 0
4045
+ || stale || (selected && selected.id !== state.video.snapshotId)) await openVideoDecoder(state, seconds);
3662
4046
  try {
3663
- state.video.frameTime = seekToKeyframe(state.video.decoder, seconds);
4047
+ state.video.frameTime = seekToKeyframe(state.video.decoder, seconds, state.video.startTime);
3664
4048
  state.video.hideUntil = seconds;
3665
4049
  state.video.done = false;
3666
4050
  return;
@@ -3705,7 +4089,7 @@ function buildPageRenderer({
3705
4089
  const reached = await advanceAudio(() => state.sound, seconds, {
3706
4090
  needsMore,
3707
4091
  active: () => state.audioPositioning === transaction && state.seekSerial === serial && state.document === page.document,
3708
- reopen: () => openAudioDecoder(state, { keepStream: true }),
4092
+ reopen: () => openAudioDecoder(state, { keepStream: true, time: seconds }),
3709
4093
  });
3710
4094
  if (!reached && state.audioPositioning === transaction && state.seekSerial === serial) {
3711
4095
  throw new Error(`audio could not reach ${seconds.toFixed(3)}s; refusing to replay an earlier prefix`);
@@ -3804,7 +4188,7 @@ function buildPageRenderer({
3804
4188
  const video = state.video;
3805
4189
  if (video?.needsLocalPosition && !video.presentation && !video.done) {
3806
4190
  try {
3807
- video.frameTime = seekToKeyframe(video.decoder, now);
4191
+ video.frameTime = seekToKeyframe(video.decoder, now, video.startTime);
3808
4192
  video.hideUntil = now;
3809
4193
  video.needsLocalPosition = false;
3810
4194
  } catch (error) { failMedia(state, error, 3); return; }
@@ -3888,6 +4272,7 @@ function buildPageRenderer({
3888
4272
  function drawVideoFrame(state, show) {
3889
4273
  const video = state.video;
3890
4274
  const decoder = video.decoder;
4275
+ if (video.frameTime + 1e-6 >= video.endTime) return "stall";
3891
4276
  const width = Math.min(480, decoder.width);
3892
4277
  const height = Math.max(1, Math.round(width * decoder.height / Math.max(1, decoder.width)));
3893
4278
  let pixels;
@@ -3984,8 +4369,14 @@ function buildPageRenderer({
3984
4369
  }
3985
4370
  const { channels, sampleRate } = sound.decoder;
3986
4371
  const frameBytes = channels * 2;
4372
+ const endSample = Math.round(sound.endTime * sampleRate);
3987
4373
  sound.lastRequest = bytes;
3988
4374
  for (let sent = 0; sent + frameBytes <= bytes;) {
4375
+ if (sound.position >= endSample) {
4376
+ sound.owed = (sound.owed ?? 0) + bytes - sent;
4377
+ void refillMedia(state, sound);
4378
+ return;
4379
+ }
3989
4380
  let samples = sound.pending;
3990
4381
  sound.pending = null;
3991
4382
  if (!samples) {
@@ -4011,7 +4402,8 @@ function buildPageRenderer({
4011
4402
  audio.finish(sound.id);
4012
4403
  return;
4013
4404
  }
4014
- const [taken, remaining] = splitSamples(samples, channels, bytes - sent);
4405
+ const boundaryBytes = Math.max(0, endSample - sound.position) * frameBytes;
4406
+ const [taken, remaining] = splitSamples(samples, channels, Math.min(bytes - sent, boundaryBytes));
4015
4407
  if (remaining.length) sound.pending = remaining;
4016
4408
  sound.history.push({ start: sound.position, samples: taken });
4017
4409
  sound.position += taken.length / channels;
@@ -4057,8 +4449,8 @@ function buildPageRenderer({
4057
4449
  * binary search over seek targets finds where that keyframe starts (to a
4058
4450
  * frame). The frames from there to the clock are then decoded unshown.
4059
4451
  */
4060
- function seekToKeyframe(decoder, seconds) {
4061
- return seekVideoKeyframe(decoder, seconds);
4452
+ function seekToKeyframe(decoder, seconds, startTime = 0) {
4453
+ return seekVideoKeyframe(decoder, seconds, startTime);
4062
4454
  }
4063
4455
 
4064
4456
  /**
@@ -4146,19 +4538,24 @@ function buildPageRenderer({
4146
4538
  const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
4147
4539
  // The picture must not go back to the keyframe the decoder restarts at.
4148
4540
  const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
4149
- await openVideoDecoder(state);
4541
+ await openVideoDecoder(state, resumeAt);
4150
4542
  state.video.hideUntil = hideUntil;
4151
4543
  try {
4152
- state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt);
4544
+ state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt, state.video.startTime);
4153
4545
  } catch {
4154
- state.video.frameTime = 0;
4546
+ state.video.frameTime = state.video.startTime;
4155
4547
  }
4156
4548
  state.video.done = false;
4157
4549
  }
4158
4550
  if (refillAudio) {
4159
4551
  let opened = false;
4160
4552
  try {
4161
- opened = await openAudioDecoder(state, { keepStream: Boolean(held) });
4553
+ let audioTime = held ? writtenEnd : time;
4554
+ if (state.sound && Number.isFinite(state.sound.endTime)
4555
+ && state.sound.position >= Math.round(state.sound.endTime * state.sound.decoder.sampleRate)) {
4556
+ audioTime = Math.max(audioTime, state.sound.endTime);
4557
+ }
4558
+ opened = await openAudioDecoder(state, { keepStream: Boolean(held), time: audioTime });
4162
4559
  } catch (error) {
4163
4560
  if (!state.video) throw error;
4164
4561
  reportError(error);
@@ -4242,7 +4639,7 @@ function buildPageRenderer({
4242
4639
  /** The decoded CSS background image a url() names, if it has arrived. */
4243
4640
  function backgroundResource(url) {
4244
4641
  let absolute;
4245
- try { absolute = new URL(url, page.url).href; } catch { return null; }
4642
+ try { absolute = new URL(url, page.document.baseURI || page.url).href; } catch { return null; }
4246
4643
  return page.backgrounds.get(absolute)?.resource ?? null;
4247
4644
  }
4248
4645
 
@@ -4255,28 +4652,25 @@ function buildPageRenderer({
4255
4652
  if (!tree) return;
4256
4653
  const document = page.document;
4257
4654
  const seen = new Set();
4655
+ const used = new Set();
4258
4656
  for (const node of tree.nodesById.values()) {
4259
4657
  const value = node.style?.backgroundImage;
4260
4658
  if (!value || seen.has(value) || !value.includes("url(")) continue;
4261
4659
  seen.add(value);
4262
4660
  for (const match of value.matchAll(CSS_URL)) {
4263
4661
  let url;
4264
- try { url = new URL(match[2], page.url).href; } catch { continue; }
4662
+ try { url = new URL(match[2], document.baseURI || page.url).href; } catch { continue; }
4663
+ used.add(url);
4265
4664
  if (page.backgrounds.has(url)) continue;
4266
4665
  const entry = { resource: null, loaded: null };
4267
4666
  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;
4667
+ entry.loaded = loadImageResource(document, url).then((resource) => {
4274
4668
  if (page.backgrounds.get(url) !== entry || document !== page.document) {
4275
- image.delete();
4669
+ resource.image.delete();
4276
4670
  return;
4277
4671
  }
4278
- entry.resource = Object.freeze({ width, height, image, url });
4279
- if (page.layout) page.displayList = paintList();
4672
+ entry.resource = resource;
4673
+ page.resourcePaintDirty = true;
4280
4674
  page.needsPaint = true;
4281
4675
  scheduleFrame();
4282
4676
  }).catch(() => {
@@ -4284,6 +4678,11 @@ function buildPageRenderer({
4284
4678
  });
4285
4679
  }
4286
4680
  }
4681
+ for (const [url, entry] of page.backgrounds) {
4682
+ if (used.has(url)) continue;
4683
+ entry.resource?.image.delete();
4684
+ page.backgrounds.delete(url);
4685
+ }
4287
4686
  }
4288
4687
 
4289
4688
  function disposeBackgrounds() {
@@ -4295,36 +4694,58 @@ function buildPageRenderer({
4295
4694
  const document = page.document;
4296
4695
  const generation = loadGeneration;
4297
4696
  const staleImages = [];
4697
+ // Infinite feeds remove old DOM images without navigating. The resource
4698
+ // WeakMap alone cannot release their explicitly-owned Skia handles.
4699
+ for (const element of page.imageElements) {
4700
+ if (element.isConnected && element.ownerDocument === document && element.getAttribute("src")) continue;
4701
+ const image = page.resources.get(element)?.image;
4702
+ if (image) {
4703
+ staleImages.push(image);
4704
+ page.resourceLayoutDirty = true;
4705
+ }
4706
+ page.resources.delete(element);
4707
+ page.imageElements.delete(element);
4708
+ imageRequests.delete(element);
4709
+ }
4298
4710
  for (const element of document.querySelectorAll("img")) {
4299
4711
  const src = element.getAttribute("src");
4300
- if (!src || imageRequests.get(element) === src) continue;
4712
+ const credentials = imageCrossOrigin(element);
4713
+ const key = imageRequestKey(src, credentials);
4714
+ if (!src || imageRequests.get(element) === key) continue;
4301
4715
  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);
4716
+ try { url = new URL(src, element.baseURI || page.url).href; } catch { continue; }
4717
+ const existing = page.resources.get(element);
4718
+ if (existing?.requestedURL === url && existing.crossOrigin === credentials) {
4719
+ imageRequests.set(element, key);
4305
4720
  continue;
4306
4721
  }
4307
- imageRequests.set(element, src);
4722
+ imageRequests.set(element, key);
4308
4723
  const oldImage = page.resources.get(element)?.image;
4309
- if (oldImage) staleImages.push(oldImage);
4724
+ if (oldImage) {
4725
+ staleImages.push(oldImage);
4726
+ page.resourceLayoutDirty = true;
4727
+ }
4310
4728
  page.resources.delete(element);
4311
4729
  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) {
4730
+ loadImageResource(document, url, credentials).then((resource) => {
4731
+ const { width, height, image } = resource;
4732
+ if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src
4733
+ || imageCrossOrigin(element) !== credentials) {
4319
4734
  image.delete();
4320
4735
  return;
4321
4736
  }
4322
- page.resources.set(element, Object.freeze({ width, height, image, url: response.url }));
4737
+ page.resources.set(element, resource);
4323
4738
  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);
4739
+ page.imageElements.add(element);
4740
+ let node, run;
4741
+ for (const candidate of page.renderTree.nodesById.values()) {
4742
+ if (candidate.domNode === element) { node = candidate; break; }
4743
+ }
4744
+ if (node) outer: for (const fragment of page.layout.fragments) {
4745
+ for (const candidate of fragment.runs) {
4746
+ if (candidate.type === "image" && candidate.nodeId === node.id) { run = candidate; break outer; }
4747
+ }
4748
+ }
4328
4749
  const cssWidth = node?.style?.width;
4329
4750
  const cssHeight = node?.style?.height;
4330
4751
  const specifiedWidth = typeof cssWidth === "number" ? cssWidth : node?.widthAttribute;
@@ -4335,17 +4756,20 @@ function buildPageRenderer({
4335
4756
  const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
4336
4757
  && Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
4337
4758
  if (paintOnly) {
4338
- page.displayList = paintList();
4759
+ page.resourcePaintDirty = true;
4339
4760
  page.needsPaint = true;
4340
4761
  } else {
4341
4762
  page.dirty = true;
4342
4763
  page.resourceLayoutDirty = true;
4343
4764
  }
4344
- refresh();
4765
+ // All arrivals in this rendering opportunity share layout/raster.
4766
+ // An onload handler which reads geometry still flushes synchronously.
4767
+ finishImageLoad(element, src, { width, height }, type => element.dispatchEvent(new page.window.Event(type)));
4768
+ scheduleFrame();
4345
4769
  }).catch((error) => {
4346
4770
  // A failed image retains its fallback replaced-element dimensions.
4347
4771
  trace?.(`image failed ${url}: ${error?.message ?? error}`);
4348
- if (generation === loadGeneration && document === page.document) {
4772
+ if (generation === loadGeneration && document === page.document && imageCrossOrigin(element) === credentials) {
4349
4773
  finishImageLoad(element, src, null, type => element.dispatchEvent(new page.window.Event(type)));
4350
4774
  }
4351
4775
  });