buninu-browser 0.2.1 → 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 +16 -11
- package/lib/happy-dom/parser.js +24 -4
- package/lib/headless-shell.js +66 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +196 -49
- 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 +161 -14
- package/lib/paint/display-list.js +300 -14
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +17 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +985 -68
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +158 -8
- 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 +1405 -198
- package/lib/renderer/page-renderer.js +1736 -193
- 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 +162 -22
- 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 +38 -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 +1 -1
- package/test/hit-test.test.js +25 -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 +14 -0
- package/test/page-scripts.test.js +1045 -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/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/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, 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
|
}
|
|
@@ -204,17 +257,51 @@ export function buildDisplayList(layout, tree = null, resources = null) {
|
|
|
204
257
|
items: Object.freeze(kept.map(deepFreeze)),
|
|
205
258
|
};
|
|
206
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 });
|
|
207
274
|
return Object.freeze(displayList);
|
|
208
275
|
}
|
|
209
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
|
+
|
|
210
297
|
/**
|
|
211
298
|
* Painting effects of ancestors (CSS Visual Effects / Overflow): visibility,
|
|
212
299
|
* group opacity (approximated per item), and clipping by overflow, clip and
|
|
213
300
|
* clip-path rectangles. Returns effects(nodeId, "box" | "text") → null when
|
|
214
301
|
* the item is not painted, else { opacity, clipRect }.
|
|
215
302
|
*/
|
|
216
|
-
function paintEffects(layout, tree) {
|
|
217
|
-
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 };
|
|
218
305
|
if (!tree?.root) return () => none;
|
|
219
306
|
const parents = new Map();
|
|
220
307
|
(function walk(node) {
|
|
@@ -267,6 +354,19 @@ function paintEffects(layout, tree) {
|
|
|
267
354
|
}
|
|
268
355
|
return rect;
|
|
269
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
|
+
});
|
|
270
370
|
|
|
271
371
|
return (nodeId, kind) => {
|
|
272
372
|
const key = `${kind}:${nodeId}`;
|
|
@@ -282,24 +382,61 @@ function paintEffects(layout, tree) {
|
|
|
282
382
|
// Overflow clipping skips ancestors that are not in the containing-block
|
|
283
383
|
// chain of an absolutely positioned box; fixed boxes escape entirely.
|
|
284
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;
|
|
285
394
|
for (let node = owner; node; node = parents.get(node.id)) {
|
|
395
|
+
chain.push(node);
|
|
286
396
|
const style = node.style;
|
|
287
397
|
if (!style) continue;
|
|
288
398
|
if (style.position === "fixed") fixed = true;
|
|
289
399
|
opacity *= style.opacity ?? 1;
|
|
290
|
-
|
|
400
|
+
clips.push([node, shapeClip(node)]);
|
|
291
401
|
const ownContent = kind === "text" || node !== owner;
|
|
292
402
|
if (ownContent && escaping !== "fixed") {
|
|
293
403
|
const positioned = style.position && style.position !== "static";
|
|
294
404
|
if (!escaping || positioned) {
|
|
295
|
-
|
|
405
|
+
clips.push([node, overflowClip(node)]);
|
|
406
|
+
if (isScroller(style)) {
|
|
407
|
+
if (!scrolledBy.size) outOfFlow = positionedOut;
|
|
408
|
+
scrolledBy.add(node);
|
|
409
|
+
}
|
|
296
410
|
escaping = null;
|
|
297
411
|
}
|
|
298
412
|
}
|
|
413
|
+
if (style.position === "absolute" || style.position === "fixed") positionedOut = true;
|
|
299
414
|
if (style.position === "fixed") escaping = "fixed";
|
|
300
415
|
else if (style.position === "absolute" && escaping !== "fixed") escaping = "absolute";
|
|
301
416
|
}
|
|
302
|
-
|
|
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 };
|
|
303
440
|
} else {
|
|
304
441
|
result = null;
|
|
305
442
|
}
|
|
@@ -308,6 +445,11 @@ function paintEffects(layout, tree) {
|
|
|
308
445
|
};
|
|
309
446
|
}
|
|
310
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
|
+
|
|
311
453
|
function intersectRects(a, b) {
|
|
312
454
|
if (!a) return b;
|
|
313
455
|
if (!b) return a;
|
|
@@ -318,12 +460,45 @@ function intersectRects(a, b) {
|
|
|
318
460
|
return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
|
|
319
461
|
}
|
|
320
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
|
+
|
|
321
491
|
function applyEffect(items, start, effect) {
|
|
322
|
-
if (effect.opacity === 1 && !effect.clipRect && !effect.fixed) return;
|
|
492
|
+
if (effect.opacity === 1 && !effect.clipRect && !effect.fixed && !effect.transform) return;
|
|
323
493
|
for (let index = start; index < items.length; index++) {
|
|
324
494
|
const item = items[index];
|
|
325
495
|
if (effect.fixed) item.fixed = true;
|
|
326
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
|
+
}
|
|
327
502
|
if (effect.clipRect) {
|
|
328
503
|
item.clipRect = effect.clipRect;
|
|
329
504
|
item.bounds = intersectRects(item.bounds, effect.clipRect);
|
|
@@ -360,6 +535,117 @@ function cornerRadii(style, rect) {
|
|
|
360
535
|
return { x: x * scale, y: y * scale };
|
|
361
536
|
}
|
|
362
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
|
+
|
|
363
649
|
function inflate(rect, amount) {
|
|
364
650
|
return { x: rect.x - amount, y: rect.y - amount, width: rect.width + amount * 2, height: rect.height + amount * 2 };
|
|
365
651
|
}
|
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,
|
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)",
|