buninu-browser 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/README.md +74 -9
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/pulse-client.js +120 -12
- package/lib/cdp-server.js +19 -12
- package/lib/happy-dom/parser.js +24 -4
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +120 -28
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +365 -54
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/network/network-service.js +326 -46
- package/lib/paint/display-list.js +322 -21
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +62 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +989 -72
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +159 -9
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/main.js +160 -12
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1407 -200
- package/lib/renderer/page-renderer.js +1899 -394
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +463 -242
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +3 -1
- package/test/computed-style.test.js +98 -1
- package/test/display-list.test.js +74 -1
- package/test/download.test.js +35 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +29 -0
- package/test/hit-test.test.js +25 -1
- package/test/log.test.js +4 -1
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +81 -0
- package/test/page-scripts.test.js +1094 -7
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +69 -0
- package/test/render-tree.test.js +22 -1
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +69 -1
- package/test/user-agent.test.js +60 -4
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- package/vendor/ses/dist/ses.mjs +52 -6
|
@@ -15,19 +15,44 @@
|
|
|
15
15
|
* Image resources are referenced by index into the non-enumerable `images`
|
|
16
16
|
* array so `JSON.stringify(displayList)` stays a plain debug/reftest dump.
|
|
17
17
|
*/
|
|
18
|
-
import { gradientLayers, resolveGradient } from "./gradient.js";
|
|
18
|
+
import { backgroundLayers, gradientLayers, isGradient, resolveGradient, splitTopLevel } from "./gradient.js";
|
|
19
19
|
|
|
20
20
|
const TRANSPARENT = "rgba(0, 0, 0, 0)";
|
|
21
|
+
const CSS_URL = /^url\(\s*(["']?)(.*?)\1\s*\)$/is;
|
|
22
|
+
const LENGTH = /^(-?(?:\d+\.?\d*|\.\d+))(px|%|em|rem)?$/;
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* @param layout the layout result
|
|
24
26
|
* @param tree the render tree it was laid out from; without it, visibility,
|
|
25
27
|
* opacity and overflow clipping of ancestors are not applied.
|
|
26
28
|
*/
|
|
27
|
-
|
|
29
|
+
/**
|
|
30
|
+
* @param backgroundResource(url) the decoded image a background url() names,
|
|
31
|
+
* or null while it has not arrived (it paints nothing until then)
|
|
32
|
+
*/
|
|
33
|
+
export function buildDisplayList(layout, tree = null, resources = null, backgroundResource = null, scrollOffsetOf = null) {
|
|
28
34
|
const items = [];
|
|
29
35
|
const images = [];
|
|
30
|
-
|
|
36
|
+
// Every box and run in paint order, painted or not: what a point hits (see hitTarget).
|
|
37
|
+
const hits = [];
|
|
38
|
+
// How far each scroll container's content reaches (its scrollable overflow),
|
|
39
|
+
// unscrolled: all of it, and the in-flow part, which alone gets the end
|
|
40
|
+
// padding (CSS Overflow 3 §2.2); absolutely positioned boxes count as they are.
|
|
41
|
+
const scrollers = new Map();
|
|
42
|
+
const reach = (effect, rect) => {
|
|
43
|
+
// Content of a nested scroll container counts for that one only.
|
|
44
|
+
const id = effect.scrollers?.[0];
|
|
45
|
+
if (id == null) return;
|
|
46
|
+
const extent = scrollers.get(id) ?? { right: -Infinity, bottom: -Infinity, flowRight: -Infinity, flowBottom: -Infinity };
|
|
47
|
+
extent.right = Math.max(extent.right, rect.x + rect.width);
|
|
48
|
+
extent.bottom = Math.max(extent.bottom, rect.y + rect.height);
|
|
49
|
+
if (!effect.outOfFlow) {
|
|
50
|
+
extent.flowRight = Math.max(extent.flowRight, rect.x + rect.width);
|
|
51
|
+
extent.flowBottom = Math.max(extent.flowBottom, rect.y + rect.height);
|
|
52
|
+
}
|
|
53
|
+
scrollers.set(id, extent);
|
|
54
|
+
};
|
|
55
|
+
const effects = paintEffects(layout, tree, scrollOffsetOf);
|
|
31
56
|
// Text styled with background-clip:text takes its gradient from the owning box.
|
|
32
57
|
const textGradients = new Map();
|
|
33
58
|
for (const box of layout.boxes) {
|
|
@@ -84,6 +109,10 @@ export function buildDisplayList(layout, tree = null) {
|
|
|
84
109
|
if (!effect) return;
|
|
85
110
|
const start = items.length;
|
|
86
111
|
const rect = { x: box.x, y: box.y, width: box.width, height: box.height };
|
|
112
|
+
hits.push({
|
|
113
|
+
nodeId: box.nodeId, rect: effect.transform ? transformRect(effect.transform, rect) : rect, clipRect: effect.clipRect, fixed: effect.fixed,
|
|
114
|
+
});
|
|
115
|
+
reach(effect, rect);
|
|
87
116
|
const radii = cornerRadii(style, rect);
|
|
88
117
|
for (const shadow of style.boxShadow ?? []) {
|
|
89
118
|
if (shadow.inset || shadow.color === TRANSPARENT) continue;
|
|
@@ -108,15 +137,26 @@ export function buildDisplayList(layout, tree = null) {
|
|
|
108
137
|
bounds: inflate(shadowRect, shadow.blur),
|
|
109
138
|
});
|
|
110
139
|
}
|
|
111
|
-
const gradients = gradientLayers(style.backgroundImage)
|
|
112
|
-
.map((source) => resolveGradient(source, rect, style.fontSize))
|
|
113
|
-
.filter(Boolean);
|
|
114
140
|
if (style.backgroundClip !== "text") {
|
|
115
141
|
if (style.backgroundColor && style.backgroundColor !== TRANSPARENT) {
|
|
116
142
|
items.push({ op: "fillRect", nodeId: box.nodeId, rect, radii, color: style.backgroundColor, bounds: rect });
|
|
117
143
|
}
|
|
118
|
-
|
|
119
|
-
|
|
144
|
+
// Background layers, bottom first.
|
|
145
|
+
const layers = backgroundLayers(style.backgroundImage);
|
|
146
|
+
for (let index = layers.length - 1; index >= 0; index--) {
|
|
147
|
+
const layer = layers[index];
|
|
148
|
+
if (isGradient(layer)) {
|
|
149
|
+
const gradient = resolveGradient(layer, rect, style.fontSize);
|
|
150
|
+
if (gradient) items.push({ op: "fillRect", nodeId: box.nodeId, rect, radii, gradient, bounds: rect });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const url = CSS_URL.exec(layer)?.[2];
|
|
154
|
+
const resource = url && backgroundResource?.(url);
|
|
155
|
+
if (!resource?.image) continue;
|
|
156
|
+
const placed = backgroundTile(style, rect, index, resource);
|
|
157
|
+
if (!placed) continue;
|
|
158
|
+
images.push(resource);
|
|
159
|
+
items.push({ op: "drawBackgroundImage", nodeId: box.nodeId, image: images.length - 1, rect, radii, ...placed });
|
|
120
160
|
}
|
|
121
161
|
}
|
|
122
162
|
const widths = style.borderWidths ?? [0, 0, 0, 0];
|
|
@@ -142,6 +182,19 @@ export function buildDisplayList(layout, tree = null) {
|
|
|
142
182
|
const effect = effects(run.nodeId, run.type === "image" ? "box" : "text");
|
|
143
183
|
if (!effect) continue;
|
|
144
184
|
const start = items.length;
|
|
185
|
+
const hitRect = {
|
|
186
|
+
x: run.x,
|
|
187
|
+
y: run.type === "image" ? run.y ?? fragment.y : fragment.y,
|
|
188
|
+
width: run.width,
|
|
189
|
+
height: run.type === "image" ? run.height : fragment.height,
|
|
190
|
+
};
|
|
191
|
+
hits.push({
|
|
192
|
+
nodeId: run.nodeId,
|
|
193
|
+
rect: effect.transform ? transformRect(effect.transform, hitRect) : hitRect,
|
|
194
|
+
clipRect: effect.clipRect,
|
|
195
|
+
fixed: effect.fixed,
|
|
196
|
+
});
|
|
197
|
+
reach(effect, hitRect);
|
|
145
198
|
paintRun(fragment, run);
|
|
146
199
|
applyEffect(items, start, effect);
|
|
147
200
|
}
|
|
@@ -156,15 +209,18 @@ export function buildDisplayList(layout, tree = null) {
|
|
|
156
209
|
return;
|
|
157
210
|
}
|
|
158
211
|
if (run.type === "image") {
|
|
212
|
+
const currentResource = resources && tree
|
|
213
|
+
? resources.get(tree.nodesById.get(run.nodeId)?.domNode) ?? run.resource
|
|
214
|
+
: run.resource;
|
|
159
215
|
if (run.video || run.audio) {
|
|
160
216
|
const rect = { x: run.x, y, width: run.width, height: run.height };
|
|
161
|
-
images.push(
|
|
217
|
+
images.push(currentResource);
|
|
162
218
|
items.push({ op: run.video ? "drawVideo" : "drawAudio", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
|
|
163
219
|
return;
|
|
164
220
|
}
|
|
165
|
-
if (!
|
|
221
|
+
if (!currentResource?.image) return;
|
|
166
222
|
const rect = { x: run.x, y, width: run.width, height: run.height };
|
|
167
|
-
images.push(
|
|
223
|
+
images.push(currentResource);
|
|
168
224
|
items.push({
|
|
169
225
|
op: "drawImage", nodeId: run.nodeId, image: images.length - 1, rect, radii: cornerRadii(run.style, rect), bounds: rect,
|
|
170
226
|
});
|
|
@@ -201,17 +257,51 @@ export function buildDisplayList(layout, tree = null) {
|
|
|
201
257
|
items: Object.freeze(kept.map(deepFreeze)),
|
|
202
258
|
};
|
|
203
259
|
Object.defineProperty(displayList, "images", { value: Object.freeze(images), enumerable: false });
|
|
260
|
+
Object.defineProperty(displayList, "hits", { value: Object.freeze(hits), enumerable: false });
|
|
261
|
+
Object.defineProperty(displayList, "scrollers", { value: scrollers, enumerable: false });
|
|
262
|
+
// How far the page's content reaches (its scrollable overflow, CSS Overflow 3 §2.2):
|
|
263
|
+
// every box and run, cut to the clips around it; fixed content does not count.
|
|
264
|
+
let right = 0;
|
|
265
|
+
let bottom = 0;
|
|
266
|
+
for (const { rect, clipRect, fixed } of hits) {
|
|
267
|
+
if (fixed) continue;
|
|
268
|
+
const reached = clipRect ? intersectRects(rect, clipRect) : rect;
|
|
269
|
+
if (!(reached.width > 0 && reached.height > 0)) continue;
|
|
270
|
+
right = Math.max(right, reached.x + reached.width);
|
|
271
|
+
bottom = Math.max(bottom, reached.y + reached.height);
|
|
272
|
+
}
|
|
273
|
+
Object.defineProperty(displayList, "extent", { value: Object.freeze({ width: right, height: bottom }), enumerable: false });
|
|
204
274
|
return Object.freeze(displayList);
|
|
205
275
|
}
|
|
206
276
|
|
|
277
|
+
/**
|
|
278
|
+
* The render node id of the topmost box or run at a viewport point, in paint
|
|
279
|
+
* order, or null. `skip(nodeId)` passes over nodes that take no pointer
|
|
280
|
+
* events. Fixed content stays put as the page scrolls.
|
|
281
|
+
*/
|
|
282
|
+
export function hitTarget(displayList, x, y, scroll, skip = () => false) {
|
|
283
|
+
const hits = displayList?.hits ?? [];
|
|
284
|
+
for (let index = hits.length - 1; index >= 0; index--) {
|
|
285
|
+
const { nodeId, rect, clipRect, fixed } = hits[index];
|
|
286
|
+
const pointX = fixed ? x : x + scroll.x;
|
|
287
|
+
const pointY = fixed ? y : y + scroll.y;
|
|
288
|
+
if (pointX < rect.x || pointX >= rect.x + rect.width || pointY < rect.y || pointY >= rect.y + rect.height) continue;
|
|
289
|
+
if (clipRect && (pointX < clipRect.x || pointX >= clipRect.x + clipRect.width
|
|
290
|
+
|| pointY < clipRect.y || pointY >= clipRect.y + clipRect.height)) continue;
|
|
291
|
+
if (skip(nodeId)) continue;
|
|
292
|
+
return nodeId;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
207
297
|
/**
|
|
208
298
|
* Painting effects of ancestors (CSS Visual Effects / Overflow): visibility,
|
|
209
299
|
* group opacity (approximated per item), and clipping by overflow, clip and
|
|
210
300
|
* clip-path rectangles. Returns effects(nodeId, "box" | "text") → null when
|
|
211
301
|
* the item is not painted, else { opacity, clipRect }.
|
|
212
302
|
*/
|
|
213
|
-
function paintEffects(layout, tree) {
|
|
214
|
-
const none = { opacity: 1, clipRect: null, fixed: false };
|
|
303
|
+
function paintEffects(layout, tree, scrollOffsetOf = null) {
|
|
304
|
+
const none = { opacity: 1, clipRect: null, fixed: false, transform: null, scrollers: null, outOfFlow: false };
|
|
215
305
|
if (!tree?.root) return () => none;
|
|
216
306
|
const parents = new Map();
|
|
217
307
|
(function walk(node) {
|
|
@@ -223,17 +313,27 @@ function paintEffects(layout, tree) {
|
|
|
223
313
|
const boxes = new Map();
|
|
224
314
|
for (const box of layout.boxes) if (!box.inline && !boxes.has(box.nodeId)) boxes.set(box.nodeId, box);
|
|
225
315
|
const cache = new Map();
|
|
316
|
+
// Each element's own clips, worked out once for all the items below it.
|
|
317
|
+
const memo = (compute) => {
|
|
318
|
+
const results = new Map();
|
|
319
|
+
return (node) => {
|
|
320
|
+
if (results.has(node)) return results.get(node);
|
|
321
|
+
const result = compute(node);
|
|
322
|
+
results.set(node, result === null ? null : Object.freeze(result));
|
|
323
|
+
return results.get(node);
|
|
324
|
+
};
|
|
325
|
+
};
|
|
226
326
|
|
|
227
327
|
// The clip an element applies to its descendants (and, for clip/clip-path, to itself).
|
|
228
|
-
const overflowClip = (node) => {
|
|
328
|
+
const overflowClip = memo((node) => {
|
|
229
329
|
const style = node.style;
|
|
230
330
|
const box = boxes.get(node.id);
|
|
231
331
|
if (!style || !box || node === tree.root) return null;
|
|
232
332
|
if (style.overflowX !== "clip" && style.overflowY !== "clip") return null;
|
|
233
333
|
const [top, right, bottom, left] = style.borderWidths ?? [0, 0, 0, 0];
|
|
234
334
|
return { x: box.x + left, y: box.y + top, width: box.width - left - right, height: box.height - top - bottom };
|
|
235
|
-
};
|
|
236
|
-
const shapeClip = (node) => {
|
|
335
|
+
});
|
|
336
|
+
const shapeClip = memo((node) => {
|
|
237
337
|
const style = node.style;
|
|
238
338
|
const box = boxes.get(node.id);
|
|
239
339
|
if (!style || !box) return null;
|
|
@@ -253,7 +353,20 @@ function paintEffects(layout, tree) {
|
|
|
253
353
|
rect = intersectRects(rect, { x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
|
|
254
354
|
}
|
|
255
355
|
return rect;
|
|
256
|
-
};
|
|
356
|
+
});
|
|
357
|
+
// A scale an element's transform applies to itself and its contents, about its transform-origin.
|
|
358
|
+
const ownTransform = memo((node) => {
|
|
359
|
+
const transform = node.style?.transform;
|
|
360
|
+
const box = boxes.get(node.id);
|
|
361
|
+
if (!box || !transform?.scaleX || (transform.scaleX === 1 && transform.scaleY === 1)) return null;
|
|
362
|
+
const origin = transformOrigin(node.style.transformOrigin, box);
|
|
363
|
+
return {
|
|
364
|
+
a: transform.scaleX,
|
|
365
|
+
d: transform.scaleY,
|
|
366
|
+
e: origin.x * (1 - transform.scaleX),
|
|
367
|
+
f: origin.y * (1 - transform.scaleY),
|
|
368
|
+
};
|
|
369
|
+
});
|
|
257
370
|
|
|
258
371
|
return (nodeId, kind) => {
|
|
259
372
|
const key = `${kind}:${nodeId}`;
|
|
@@ -269,24 +382,61 @@ function paintEffects(layout, tree) {
|
|
|
269
382
|
// Overflow clipping skips ancestors that are not in the containing-block
|
|
270
383
|
// chain of an absolutely positioned box; fixed boxes escape entirely.
|
|
271
384
|
let escaping = null;
|
|
385
|
+
// Clips by the element that applies them: a scaled ancestor moves them too.
|
|
386
|
+
const clips = [];
|
|
387
|
+
const chain = [];
|
|
388
|
+
// Scroll containers this content scrolls with: those whose clip applies to it.
|
|
389
|
+
const scrolledBy = new Set();
|
|
390
|
+
// Whether an absolutely positioned box lies between the content and
|
|
391
|
+
// its innermost scroll container.
|
|
392
|
+
let positionedOut = false;
|
|
393
|
+
let outOfFlow = false;
|
|
272
394
|
for (let node = owner; node; node = parents.get(node.id)) {
|
|
395
|
+
chain.push(node);
|
|
273
396
|
const style = node.style;
|
|
274
397
|
if (!style) continue;
|
|
275
398
|
if (style.position === "fixed") fixed = true;
|
|
276
399
|
opacity *= style.opacity ?? 1;
|
|
277
|
-
|
|
400
|
+
clips.push([node, shapeClip(node)]);
|
|
278
401
|
const ownContent = kind === "text" || node !== owner;
|
|
279
402
|
if (ownContent && escaping !== "fixed") {
|
|
280
403
|
const positioned = style.position && style.position !== "static";
|
|
281
404
|
if (!escaping || positioned) {
|
|
282
|
-
|
|
405
|
+
clips.push([node, overflowClip(node)]);
|
|
406
|
+
if (isScroller(style)) {
|
|
407
|
+
if (!scrolledBy.size) outOfFlow = positionedOut;
|
|
408
|
+
scrolledBy.add(node);
|
|
409
|
+
}
|
|
283
410
|
escaping = null;
|
|
284
411
|
}
|
|
285
412
|
}
|
|
413
|
+
if (style.position === "absolute" || style.position === "fixed") positionedOut = true;
|
|
286
414
|
if (style.position === "fixed") escaping = "fixed";
|
|
287
415
|
else if (style.position === "absolute" && escaping !== "fixed") escaping = "absolute";
|
|
288
416
|
}
|
|
289
|
-
|
|
417
|
+
// The transforms from the root down to each element: scales, and the
|
|
418
|
+
// scroll offsets of the scroll containers it is the content of.
|
|
419
|
+
let transform = null;
|
|
420
|
+
const transforms = new Map();
|
|
421
|
+
for (let index = chain.length - 1; index >= 0; index--) {
|
|
422
|
+
const node = chain[index];
|
|
423
|
+
const own = ownTransform(node);
|
|
424
|
+
if (own) transform = transform ? composeTransforms(transform, own) : own;
|
|
425
|
+
transforms.set(node, transform);
|
|
426
|
+
const offset = scrolledBy.has(node) ? scrollOffsetOf?.(node.domNode) : null;
|
|
427
|
+
if (offset && (offset.x || offset.y)) {
|
|
428
|
+
const scrolled = { a: 1, d: 1, e: 0 - offset.x, f: 0 - offset.y };
|
|
429
|
+
transform = transform ? composeTransforms(transform, scrolled) : scrolled;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
for (const [node, clip] of clips) {
|
|
433
|
+
const matrix = transforms.get(node);
|
|
434
|
+
clipRect = intersectRects(clipRect, clip && matrix ? transformRect(matrix, clip) : clip);
|
|
435
|
+
}
|
|
436
|
+
// The owner's own box moves with every scroll container above it, not with its own.
|
|
437
|
+
transform = kind === "text" ? transform : transforms.get(owner) ?? null;
|
|
438
|
+
const scrollers = scrolledBy.size ? [...scrolledBy].map((node) => node.id) : null;
|
|
439
|
+
result = opacity <= 0 ? null : { opacity, clipRect, fixed, transform, scrollers, outOfFlow };
|
|
290
440
|
} else {
|
|
291
441
|
result = null;
|
|
292
442
|
}
|
|
@@ -295,6 +445,11 @@ function paintEffects(layout, tree) {
|
|
|
295
445
|
};
|
|
296
446
|
}
|
|
297
447
|
|
|
448
|
+
/** Whether a box is a scroll container (CSS Overflow 3 §3), by script or by the user. */
|
|
449
|
+
function isScroller(style) {
|
|
450
|
+
return Boolean(style?.overflowScrollX || style?.overflowScrollY);
|
|
451
|
+
}
|
|
452
|
+
|
|
298
453
|
function intersectRects(a, b) {
|
|
299
454
|
if (!a) return b;
|
|
300
455
|
if (!b) return a;
|
|
@@ -305,12 +460,45 @@ function intersectRects(a, b) {
|
|
|
305
460
|
return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
|
|
306
461
|
}
|
|
307
462
|
|
|
463
|
+
/** The resolved transform-origin of a border box (CSS Transforms 1 §5); 50% 50% by default. */
|
|
464
|
+
function transformOrigin(value, box) {
|
|
465
|
+
const tokens = (value ?? "50% 50%").split(" ");
|
|
466
|
+
if (tokens.length === 1) tokens.push("center");
|
|
467
|
+
if (["top", "bottom"].includes(tokens[0]) || ["left", "right"].includes(tokens[1])) tokens.reverse();
|
|
468
|
+
const resolve = (token, start, extent) => {
|
|
469
|
+
const keyword = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 }[token];
|
|
470
|
+
if (keyword != null) return start + extent * keyword;
|
|
471
|
+
const match = /^(-?[\d.]+)(px|%)?$/.exec(token);
|
|
472
|
+
if (!match) return start + extent / 2;
|
|
473
|
+
return start + (match[2] === "%" ? extent * Number(match[1]) / 100 : Number(match[1]));
|
|
474
|
+
};
|
|
475
|
+
return { x: resolve(tokens[0], box.x, box.width), y: resolve(tokens[1], box.y, box.height) };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** `outer` after `inner`: x -> outer(inner(x)), for scales with translation. */
|
|
479
|
+
function composeTransforms(outer, inner) {
|
|
480
|
+
return { a: outer.a * inner.a, d: outer.d * inner.d, e: outer.a * inner.e + outer.e, f: outer.d * inner.f + outer.f };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function transformRect(matrix, rect) {
|
|
484
|
+
const x0 = matrix.a * rect.x + matrix.e;
|
|
485
|
+
const y0 = matrix.d * rect.y + matrix.f;
|
|
486
|
+
const x1 = matrix.a * (rect.x + rect.width) + matrix.e;
|
|
487
|
+
const y1 = matrix.d * (rect.y + rect.height) + matrix.f;
|
|
488
|
+
return { x: Math.min(x0, x1), y: Math.min(y0, y1), width: Math.abs(x1 - x0), height: Math.abs(y1 - y0) };
|
|
489
|
+
}
|
|
490
|
+
|
|
308
491
|
function applyEffect(items, start, effect) {
|
|
309
|
-
if (effect.opacity === 1 && !effect.clipRect && !effect.fixed) return;
|
|
492
|
+
if (effect.opacity === 1 && !effect.clipRect && !effect.fixed && !effect.transform) return;
|
|
310
493
|
for (let index = start; index < items.length; index++) {
|
|
311
494
|
const item = items[index];
|
|
312
495
|
if (effect.fixed) item.fixed = true;
|
|
313
496
|
if (effect.opacity < 1) item.opacity = effect.opacity;
|
|
497
|
+
// Drawn through the transform; its bounds are where it lands.
|
|
498
|
+
if (effect.transform) {
|
|
499
|
+
item.transform = effect.transform;
|
|
500
|
+
item.bounds = transformRect(effect.transform, item.bounds);
|
|
501
|
+
}
|
|
314
502
|
if (effect.clipRect) {
|
|
315
503
|
item.clipRect = effect.clipRect;
|
|
316
504
|
item.bounds = intersectRects(item.bounds, effect.clipRect);
|
|
@@ -347,11 +535,124 @@ function cornerRadii(style, rect) {
|
|
|
347
535
|
return { x: x * scale, y: y * scale };
|
|
348
536
|
}
|
|
349
537
|
|
|
538
|
+
/**
|
|
539
|
+
* Where one background image layer's tiles go (CSS Backgrounds 3 §3): sized
|
|
540
|
+
* and positioned in the padding box, repeated per background-repeat, painted
|
|
541
|
+
* within the border box. Null when it has no area.
|
|
542
|
+
*/
|
|
543
|
+
function backgroundTile(style, rect, index, resource) {
|
|
544
|
+
const border = style.borderWidths ?? [0, 0, 0, 0];
|
|
545
|
+
const area = {
|
|
546
|
+
x: rect.x + border[3],
|
|
547
|
+
y: rect.y + border[0],
|
|
548
|
+
width: Math.max(0, rect.width - border[1] - border[3]),
|
|
549
|
+
height: Math.max(0, rect.height - border[0] - border[2]),
|
|
550
|
+
};
|
|
551
|
+
const pick = (list, fallback) => {
|
|
552
|
+
if (!list) return fallback;
|
|
553
|
+
const entries = splitTopLevel(list, ",");
|
|
554
|
+
return entries.length ? entries[index % entries.length] : fallback;
|
|
555
|
+
};
|
|
556
|
+
const length = (token, reference) => {
|
|
557
|
+
const match = LENGTH.exec(token);
|
|
558
|
+
if (!match) return null;
|
|
559
|
+
const value = Number(match[1]);
|
|
560
|
+
if (match[2] === "%") return reference * value / 100;
|
|
561
|
+
if (match[2] === "em") return value * (style.fontSize ?? 16);
|
|
562
|
+
if (match[2] === "rem") return value * 16;
|
|
563
|
+
return match[2] || value === 0 ? value : null;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// background-size
|
|
567
|
+
const ratio = resource.width / resource.height;
|
|
568
|
+
let width;
|
|
569
|
+
let height;
|
|
570
|
+
const size = pick(style.backgroundSize, "auto").split(/\s+/);
|
|
571
|
+
if (size[0] === "cover" || size[0] === "contain") {
|
|
572
|
+
const fit = size[0] === "cover"
|
|
573
|
+
? Math.max(area.width / resource.width, area.height / resource.height)
|
|
574
|
+
: Math.min(area.width / resource.width, area.height / resource.height);
|
|
575
|
+
width = resource.width * fit;
|
|
576
|
+
height = resource.height * fit;
|
|
577
|
+
} else {
|
|
578
|
+
width = size[0] === "auto" ? null : length(size[0], area.width);
|
|
579
|
+
height = (size[1] ?? "auto") === "auto" ? null : length(size[1], area.height);
|
|
580
|
+
if (width == null && height == null) [width, height] = [resource.width, resource.height];
|
|
581
|
+
else if (width == null) width = height * ratio;
|
|
582
|
+
else if (height == null) height = width / ratio;
|
|
583
|
+
}
|
|
584
|
+
if (!(width > 0 && height > 0)) return null;
|
|
585
|
+
|
|
586
|
+
// background-position: keywords, offsets from the top left, or from an edge.
|
|
587
|
+
const tokens = pick(style.backgroundPosition, "0% 0%").split(/\s+/);
|
|
588
|
+
const horizontalKeyword = (token) => token === "left" || token === "right";
|
|
589
|
+
const verticalKeyword = (token) => token === "top" || token === "bottom";
|
|
590
|
+
let horizontal = ["left", null];
|
|
591
|
+
let vertical = ["top", null];
|
|
592
|
+
if (tokens.length === 1) {
|
|
593
|
+
// One value: the other is center.
|
|
594
|
+
horizontal = verticalKeyword(tokens[0]) ? ["center", null] : [tokens[0], null];
|
|
595
|
+
vertical = verticalKeyword(tokens[0]) ? [tokens[0], null] : ["center", null];
|
|
596
|
+
} else if (tokens.length === 2) {
|
|
597
|
+
const [first, second] = verticalKeyword(tokens[0]) || horizontalKeyword(tokens[1]) ? [tokens[1], tokens[0]] : tokens;
|
|
598
|
+
horizontal = [first, null];
|
|
599
|
+
vertical = [second, null];
|
|
600
|
+
} else {
|
|
601
|
+
// Three or four values: edge keywords, each optionally followed by an
|
|
602
|
+
// offset; center takes an axis no edge keyword named.
|
|
603
|
+
const pairs = [];
|
|
604
|
+
for (let at = 0; at < tokens.length; at++) {
|
|
605
|
+
const keyword = tokens[at];
|
|
606
|
+
const offset = at + 1 < tokens.length && LENGTH.test(tokens[at + 1]) ? tokens[++at] : null;
|
|
607
|
+
pairs.push([keyword, offset]);
|
|
608
|
+
}
|
|
609
|
+
const named = { horizontal: false, vertical: false };
|
|
610
|
+
for (const pair of pairs) {
|
|
611
|
+
if (horizontalKeyword(pair[0])) [horizontal, named.horizontal] = [pair, true];
|
|
612
|
+
else if (verticalKeyword(pair[0])) [vertical, named.vertical] = [pair, true];
|
|
613
|
+
}
|
|
614
|
+
for (const pair of pairs) {
|
|
615
|
+
if (pair[0] !== "center") continue;
|
|
616
|
+
if (!named.horizontal) [horizontal, named.horizontal] = [pair, true];
|
|
617
|
+
else if (!named.vertical) [vertical, named.vertical] = [pair, true];
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const place = ([keyword, offset], start, extent, tile) => {
|
|
621
|
+
const free = extent - tile;
|
|
622
|
+
const fraction = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 }[keyword];
|
|
623
|
+
if (fraction == null) return start + (length(keyword, free) ?? 0);
|
|
624
|
+
const distance = offset == null ? 0 : length(offset, free) ?? 0;
|
|
625
|
+
return start + free * fraction + (fraction === 1 ? -distance : distance);
|
|
626
|
+
};
|
|
627
|
+
const tile = {
|
|
628
|
+
x: place(horizontal, area.x, area.width, width),
|
|
629
|
+
y: place(vertical, area.y, area.height, height),
|
|
630
|
+
width,
|
|
631
|
+
height,
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// background-repeat
|
|
635
|
+
const repeat = pick(style.backgroundRepeat, "repeat").split(/\s+/);
|
|
636
|
+
const repeats = (value) => value !== "no-repeat";
|
|
637
|
+
const [repeatX, repeatY] = repeat[0] === "repeat-x" ? [true, false]
|
|
638
|
+
: repeat[0] === "repeat-y" ? [false, true]
|
|
639
|
+
: [repeats(repeat[0]), repeats(repeat[1] ?? repeat[0])];
|
|
640
|
+
// Only the part of the border box the tiles reach paints.
|
|
641
|
+
const left = repeatX ? rect.x : Math.max(rect.x, tile.x);
|
|
642
|
+
const right = repeatX ? rect.x + rect.width : Math.min(rect.x + rect.width, tile.x + width);
|
|
643
|
+
const top = repeatY ? rect.y : Math.max(rect.y, tile.y);
|
|
644
|
+
const bottom = repeatY ? rect.y + rect.height : Math.min(rect.y + rect.height, tile.y + height);
|
|
645
|
+
if (right <= left || bottom <= top) return null;
|
|
646
|
+
return { tile, repeatX, repeatY, bounds: { x: left, y: top, width: right - left, height: bottom - top } };
|
|
647
|
+
}
|
|
648
|
+
|
|
350
649
|
function inflate(rect, amount) {
|
|
351
650
|
return { x: rect.x - amount, y: rect.y - amount, width: rect.width + amount * 2, height: rect.height + amount * 2 };
|
|
352
651
|
}
|
|
353
652
|
|
|
653
|
+
/** Freezes an item and what it holds; frozen values (shared clips, style data) are already done. */
|
|
354
654
|
function deepFreeze(value) {
|
|
655
|
+
if (Object.isFrozen(value)) return value;
|
|
355
656
|
for (const child of Object.values(value)) {
|
|
356
657
|
if (child && typeof child === "object") deepFreeze(child);
|
|
357
658
|
}
|
package/lib/paint/gradient.js
CHANGED
|
@@ -9,12 +9,18 @@
|
|
|
9
9
|
*/
|
|
10
10
|
const SIDES = Object.freeze({ top: [0, -1], bottom: [0, 1], left: [-1, 0], right: [1, 0] });
|
|
11
11
|
|
|
12
|
+
/** Splits a `background-image` value into its layers (images, gradients or none), topmost first. */
|
|
13
|
+
export function backgroundLayers(value) {
|
|
14
|
+
return value ? splitTopLevel(String(value), ",") : [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isGradient(layer) {
|
|
18
|
+
return /^(?:repeating-)?(?:linear|radial)-gradient\(/i.test(layer);
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
/** Splits a `background`/`background-image` value into gradient layers, topmost first. */
|
|
13
22
|
export function gradientLayers(value) {
|
|
14
|
-
|
|
15
|
-
return splitTopLevel(String(value), ",")
|
|
16
|
-
.map((layer) => layer.trim())
|
|
17
|
-
.filter((layer) => /^(?:repeating-)?(?:linear|radial)-gradient\(/i.test(layer));
|
|
23
|
+
return backgroundLayers(value).filter(isGradient);
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
export function resolveGradient(source, rect, fontSize = 16) {
|
|
@@ -189,7 +195,7 @@ function resolveLength(text, reference, fontSize) {
|
|
|
189
195
|
return value;
|
|
190
196
|
}
|
|
191
197
|
|
|
192
|
-
function splitTopLevel(source, separator) {
|
|
198
|
+
export function splitTopLevel(source, separator) {
|
|
193
199
|
const parts = [];
|
|
194
200
|
let depth = 0;
|
|
195
201
|
let start = 0;
|
package/lib/render-tree/index.js
CHANGED
|
@@ -30,6 +30,7 @@ const BLOCK_ELEMENTS = new Set([
|
|
|
30
30
|
*/
|
|
31
31
|
export class RenderTreeBuilder {
|
|
32
32
|
#ids = new WeakMap();
|
|
33
|
+
#previous = new WeakMap();
|
|
33
34
|
#pseudoKeys = new WeakMap();
|
|
34
35
|
#nextId = 1;
|
|
35
36
|
#generation = 0;
|
|
@@ -71,12 +72,18 @@ export class RenderTreeBuilder {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
#node(domNode, type, nodesById, properties, idKey = domNode) {
|
|
75
|
+
const previous = this.#previous.get(idKey);
|
|
76
|
+
if (previous && sameNodeProperties(previous, type, properties)) {
|
|
77
|
+
nodesById.set(previous.id, previous);
|
|
78
|
+
return previous;
|
|
79
|
+
}
|
|
74
80
|
const node = Object.freeze({
|
|
75
81
|
id: this.#id(idKey),
|
|
76
82
|
type,
|
|
77
83
|
domNode,
|
|
78
84
|
...properties,
|
|
79
85
|
});
|
|
86
|
+
this.#previous.set(idKey, node);
|
|
80
87
|
nodesById.set(node.id, node);
|
|
81
88
|
return node;
|
|
82
89
|
}
|
|
@@ -180,8 +187,11 @@ export class RenderTreeBuilder {
|
|
|
180
187
|
style,
|
|
181
188
|
resource: null,
|
|
182
189
|
svg,
|
|
183
|
-
|
|
184
|
-
|
|
190
|
+
// Sizes the svg element states; without them it fills its container
|
|
191
|
+
// (width 100%), keeping its viewBox's ratio (see imageDimensions).
|
|
192
|
+
widthAttribute: svg.width ?? null,
|
|
193
|
+
heightAttribute: svg.height ?? null,
|
|
194
|
+
svgRatio: viewWidth && viewHeight ? viewWidth / viewHeight : null,
|
|
185
195
|
children: Object.freeze([]),
|
|
186
196
|
});
|
|
187
197
|
}
|
|
@@ -198,6 +208,18 @@ export class RenderTreeBuilder {
|
|
|
198
208
|
children: Object.freeze([]),
|
|
199
209
|
});
|
|
200
210
|
}
|
|
211
|
+
// An iframe shows its document's latest frame (the resource), at the
|
|
212
|
+
// default object size unless sized (HTML §15.4.2: 300x150).
|
|
213
|
+
if (element.tagName === "IFRAME") {
|
|
214
|
+
return this.#node(element, "image", nodesById, {
|
|
215
|
+
tagName: "IFRAME",
|
|
216
|
+
style,
|
|
217
|
+
resource: resources?.get(element) ?? null,
|
|
218
|
+
widthAttribute: positiveDimension(element.getAttribute("width")),
|
|
219
|
+
heightAttribute: positiveDimension(element.getAttribute("height")),
|
|
220
|
+
children: Object.freeze([]),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
201
223
|
if (element.tagName === "IMG" || element.tagName === "VIDEO") {
|
|
202
224
|
return this.#node(element, "image", nodesById, {
|
|
203
225
|
tagName: element.tagName,
|
|
@@ -242,6 +264,44 @@ export class RenderTreeBuilder {
|
|
|
242
264
|
}
|
|
243
265
|
}
|
|
244
266
|
|
|
267
|
+
function sameNodeProperties(previous, type, properties) {
|
|
268
|
+
if (previous.type !== type || Object.keys(previous).length !== Object.keys(properties).length + 3) return false;
|
|
269
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
270
|
+
const old = previous[key];
|
|
271
|
+
if (old === value) continue;
|
|
272
|
+
if (key !== "children" || old.length !== value.length) return false;
|
|
273
|
+
for (let index = 0; index < old.length; index++) if (old[index] !== value[index]) return false;
|
|
274
|
+
}
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Whether two generations provide identical input to layout. Node IDs are
|
|
280
|
+
* stable for retained DOM nodes, while computed styles and decoded resources
|
|
281
|
+
* are immutable values shared between generations. Callers which separately
|
|
282
|
+
* track whether resource dimensions changed may ignore resource identity.
|
|
283
|
+
*/
|
|
284
|
+
export function sameLayoutInput(left, right, options = {}) {
|
|
285
|
+
if (left === right) return true;
|
|
286
|
+
if (!left || !right || left.id !== right.id || left.type !== right.type
|
|
287
|
+
|| (!options.ignoreStyles && left.style !== right.style) || left.tagName !== right.tagName
|
|
288
|
+
|| left.text !== right.text || left.pseudo !== right.pseudo
|
|
289
|
+
|| (!options.ignoreResources && left.resource !== right.resource)
|
|
290
|
+
|| !sameSvg(left.svg, right.svg)
|
|
291
|
+
|| left.widthAttribute !== right.widthAttribute || left.heightAttribute !== right.heightAttribute
|
|
292
|
+
|| left.children.length !== right.children.length) return false;
|
|
293
|
+
for (let index = 0; index < left.children.length; index++) {
|
|
294
|
+
if (!sameLayoutInput(left.children[index], right.children[index], options)) return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sameSvg(left, right) {
|
|
300
|
+
if (left === right) return true;
|
|
301
|
+
if (!left || !right) return false;
|
|
302
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
303
|
+
}
|
|
304
|
+
|
|
245
305
|
function boxType(style, tagName = null) {
|
|
246
306
|
const display = style?.display;
|
|
247
307
|
if (["flex", "inline-flex", "inline-block", "grid", "inline-grid"].includes(display)) return display;
|
package/lib/render-tree/svg.js
CHANGED
|
@@ -20,7 +20,8 @@ const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
|
|
|
20
20
|
* @returns {{ viewBox: number[] | null, width: number | null, height: number | null, shapes: object[] }}
|
|
21
21
|
*/
|
|
22
22
|
export function extractSvg(element, currentColor) {
|
|
23
|
-
|
|
23
|
+
// HTML-parsed markup may carry the attribute lowercased (viewbox).
|
|
24
|
+
const viewBox = parseNumbers(element.getAttribute("viewBox") ?? element.getAttribute("viewbox"));
|
|
24
25
|
const shapes = [];
|
|
25
26
|
const root = {
|
|
26
27
|
fill: "rgba(0, 0, 0, 1)",
|