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