buninu-browser 0.2.0 → 0.2.1
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/lib/cdp-server.js +3 -1
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +57 -12
- package/lib/layout/text-layout.js +179 -15
- package/lib/network/network-service.js +176 -43
- package/lib/paint/display-list.js +23 -8
- package/lib/render-tree/index.js +45 -0
- package/lib/renderer/bindings.js +4 -4
- package/lib/renderer/host.js +1 -1
- package/lib/renderer/page-realm.js +2 -2
- package/lib/renderer/page-renderer.js +166 -204
- package/lib/style/computed-style.js +301 -220
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/package.json +1 -1
- package/test/computed-style.test.js +61 -1
- package/test/headless-shell.test.js +29 -0
- package/test/log.test.js +4 -1
- package/test/network-service.test.js +67 -0
- package/test/page-scripts.test.js +49 -0
- package/test/render-tree.test.js +22 -1
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/text-layout.test.js +69 -1
package/lib/cdp-server.js
CHANGED
|
@@ -509,7 +509,9 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
509
509
|
const target = getSessionTarget(state, sessionId);
|
|
510
510
|
target.screencast = { ...params };
|
|
511
511
|
target.screencastEmit = emit;
|
|
512
|
-
|
|
512
|
+
// As in Chrome, the reply does not wait for the first frame (nor the
|
|
513
|
+
// renderer, which may still be starting); frames follow as events.
|
|
514
|
+
void pushScreencastFrame(ctx, state, target);
|
|
513
515
|
return {};
|
|
514
516
|
}
|
|
515
517
|
case "Page.screencastFrameAck":
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
|
|
2
|
+
import QuerySelector from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts";
|
|
3
|
+
import SelectorCombinatorEnum from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorCombinatorEnum.ts";
|
|
4
|
+
import SelectorParser from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Selector matching for the style engine, which matches tens of thousands of
|
|
8
|
+
* stylesheet selectors against every element. It gives the answers of Happy
|
|
9
|
+
* DOM's Element.matches() (same parser, same per-compound matching), without
|
|
10
|
+
* what that pays on every call: re-validating and looking up the selector
|
|
11
|
+
* string, and recording a cached result on the element and the elements it
|
|
12
|
+
* visited. The style engine keeps its own results.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Element.matches() rejects these before parsing (Happy DOM's QuerySelector).
|
|
16
|
+
const INVALID_SELECTOR = /^[.#\[]?\d|[.#]$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A selector compiled for matchesCompiled(), or null when it is invalid.
|
|
20
|
+
* `element` is any element of the document (it provides the window). The
|
|
21
|
+
* result does not depend on the document and may be kept with the selector.
|
|
22
|
+
* Returns undefined for objects that are not Happy DOM elements.
|
|
23
|
+
*/
|
|
24
|
+
export function compileSelector(element, selector) {
|
|
25
|
+
const window = element?.[PropertySymbol.window];
|
|
26
|
+
if (!window) return undefined;
|
|
27
|
+
selector = String(selector);
|
|
28
|
+
if (INVALID_SELECTOR.test(selector)) return null;
|
|
29
|
+
try {
|
|
30
|
+
const groups = new SelectorParser({ window, scope: element, globalMatchFunction: QuerySelector.globalMatchFunction })
|
|
31
|
+
.getSelectorGroups(selector);
|
|
32
|
+
// Matching walks from the subject (the last compound) leftwards.
|
|
33
|
+
return groups.map((items) => items.slice().reverse());
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether `element` matches a compiled selector, as element.matches(selector) would. */
|
|
40
|
+
export function matchesCompiled(element, compiled) {
|
|
41
|
+
for (const items of compiled) {
|
|
42
|
+
if (matchItems(element, element, items, 0, null)) return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Happy DOM's QuerySelector.matchSelector, reduced to a yes or no. */
|
|
48
|
+
function matchItems(scope, element, items, index, previous) {
|
|
49
|
+
const item = items[index];
|
|
50
|
+
if (!item) return false;
|
|
51
|
+
if (item.match(scope, element)) {
|
|
52
|
+
if (index === items.length - 1) return true;
|
|
53
|
+
switch (item.combinator) {
|
|
54
|
+
case SelectorCombinatorEnum.adjacentSibling: {
|
|
55
|
+
const sibling = element.previousElementSibling;
|
|
56
|
+
if (sibling && matchItems(scope, sibling, items, index + 1, item)) return true;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case SelectorCombinatorEnum.none:
|
|
60
|
+
case SelectorCombinatorEnum.child:
|
|
61
|
+
case SelectorCombinatorEnum.descendant: {
|
|
62
|
+
const parent = element.parentNode;
|
|
63
|
+
if (parent && parent !== element[PropertySymbol.ownerDocument] && matchItems(scope, parent, items, index + 1, item)) return true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case SelectorCombinatorEnum.subsequentSibling: {
|
|
67
|
+
const parent = element.parentNode;
|
|
68
|
+
if (parent && parent !== element[PropertySymbol.ownerDocument]) {
|
|
69
|
+
const siblings = parent[PropertySymbol.elementArray];
|
|
70
|
+
for (let i = siblings.indexOf(element) - 1; i >= 0; i--) {
|
|
71
|
+
if (matchItems(scope, siblings[i], items, index + 1, item)) return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (previous?.combinator === SelectorCombinatorEnum.none || previous?.combinator === SelectorCombinatorEnum.descendant) {
|
|
79
|
+
const parent = element.parentNode;
|
|
80
|
+
if (parent && parent !== element[PropertySymbol.ownerDocument]) return matchItems(scope, parent, items, index, previous);
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
package/lib/headless-shell.js
CHANGED
|
@@ -37,15 +37,23 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
|
37
37
|
const mobile = args.includes(MOBILE);
|
|
38
38
|
if (mobile) console.error(`buninu-browser: ${MOBILE}: mobile user agent and touch media features; user agent overrides are ignored`);
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
+ `ses ${layerStatus(browser.sandbox?.ses)}`);
|
|
44
|
-
|
|
40
|
+
// The endpoint opens while the first renderer starts: a client's first
|
|
41
|
+
// navigation fetches its document meanwhile.
|
|
42
|
+
const browser = await createBrowser({ allowHostJs, mobile, waitForRenderer: false });
|
|
45
43
|
const portArgument = args.find((argument) => argument.startsWith("--remote-debugging-port="));
|
|
46
44
|
const requestedPort = Number(portArgument?.slice("--remote-debugging-port=".length) ?? 9222);
|
|
47
45
|
const server = CdpServer.create(browser.context).listen(requestedPort, "127.0.0.1");
|
|
48
46
|
console.error(`DevTools listening on ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
|
|
47
|
+
try {
|
|
48
|
+
await browser.ready;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
server.stop(true);
|
|
51
|
+
browser.close();
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
const layerStatus = (layer) => layer?.installed ? "on" : `off (${layer?.reason})`;
|
|
55
|
+
console.error(`buninu-browser: renderer sandbox: seccomp ${layerStatus(browser.sandbox?.seccomp)}, `
|
|
56
|
+
+ `ses ${layerStatus(browser.sandbox?.ses)}`);
|
|
49
57
|
|
|
50
58
|
const shutdown = () => {
|
|
51
59
|
server.stop(true);
|
|
@@ -67,10 +75,16 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
|
67
75
|
* not wait for CanvasKit and fonts to load. Site isolation is by origin
|
|
68
76
|
* because no Public Suffix List is bundled to compute sites.
|
|
69
77
|
*
|
|
70
|
-
*
|
|
78
|
+
* With `waitForRenderer: false` it returns before the first renderer is up
|
|
79
|
+
* (`ready` settles then): navigations fetch their documents meanwhile, and
|
|
80
|
+
* anything that needs a renderer waits for it.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number,
|
|
83
|
+
* waitForRenderer?: boolean }} options
|
|
71
84
|
*/
|
|
72
85
|
export async function createBrowser({
|
|
73
86
|
allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
|
|
87
|
+
waitForRenderer = true,
|
|
74
88
|
} = {}) {
|
|
75
89
|
// --mobile fixes the user agent for page requests and navigator.userAgent.
|
|
76
90
|
network.userAgent = mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT;
|
|
@@ -83,6 +97,8 @@ export async function createBrowser({
|
|
|
83
97
|
let currentKey = null;
|
|
84
98
|
let spare = null;
|
|
85
99
|
let closed = false;
|
|
100
|
+
// Settles when the first renderer shows about:blank (assigned below).
|
|
101
|
+
let rendererReady = null;
|
|
86
102
|
// Renderer-independent state replayed into every new renderer.
|
|
87
103
|
const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
|
|
88
104
|
let userAgent = null;
|
|
@@ -216,7 +232,21 @@ export async function createBrowser({
|
|
|
216
232
|
return current;
|
|
217
233
|
}
|
|
218
234
|
|
|
219
|
-
const call = (method, ...params) =>
|
|
235
|
+
const call = async (method, ...params) => {
|
|
236
|
+
await rendererReady;
|
|
237
|
+
return current.call(method, ...params);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Applies viewport or user agent state the renderer is told about. Before
|
|
242
|
+
* the first renderer is up there is nothing to wait for: it starts with the
|
|
243
|
+
* state (replayState), and the latest state is applied again once it is.
|
|
244
|
+
*/
|
|
245
|
+
function applyState(method, args) {
|
|
246
|
+
if (current) return current.call(method, ...args());
|
|
247
|
+
rendererReady.then(() => current?.call(method, ...args())).catch(() => {});
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
220
250
|
|
|
221
251
|
/**
|
|
222
252
|
* Fetches a top-level document, then commits it in the renderer for its
|
|
@@ -266,6 +296,7 @@ export async function createBrowser({
|
|
|
266
296
|
const fetched = performance.now();
|
|
267
297
|
writeLog("navigation", `${url}: ${document.error ?? `HTTP ${document.status ?? 200}`}`
|
|
268
298
|
+ `${document.url === url ? "" : ` -> ${document.url}`} in ${Math.round(fetched - started)}ms`);
|
|
299
|
+
if (!invoke) await rendererReady;
|
|
269
300
|
const host = invoke ? current : await rendererFor(document.url);
|
|
270
301
|
const generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
|
|
271
302
|
committed = { host, generation };
|
|
@@ -275,9 +306,14 @@ export async function createBrowser({
|
|
|
275
306
|
|
|
276
307
|
// Screenshots and scrolling go to the compositor thread, so they are
|
|
277
308
|
// answered even while the renderer's main thread is busy.
|
|
278
|
-
const composite = (method, ...params) =>
|
|
309
|
+
const composite = async (method, ...params) => {
|
|
310
|
+
await rendererReady;
|
|
311
|
+
return current.composite(method, ...params);
|
|
312
|
+
};
|
|
279
313
|
|
|
280
314
|
async function rememberScroll() {
|
|
315
|
+
// Before the first renderer is up nothing has been shown or scrolled.
|
|
316
|
+
if (!current) return;
|
|
281
317
|
const offset = await composite("scrollOffset");
|
|
282
318
|
Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
|
|
283
319
|
}
|
|
@@ -336,14 +372,14 @@ export async function createBrowser({
|
|
|
336
372
|
goForward: () => traverseHistory(historyIndex + 1),
|
|
337
373
|
resize(width, height, deviceScaleFactor = 1) {
|
|
338
374
|
Object.assign(viewport, { width, height, deviceScaleFactor });
|
|
339
|
-
return
|
|
375
|
+
return applyState("resize", resizeArguments);
|
|
340
376
|
},
|
|
341
377
|
setUserAgent(value) {
|
|
342
378
|
// With --mobile the phone user agent stays (casty, for one, overrides it with a desktop one).
|
|
343
379
|
if (mobile) return undefined;
|
|
344
380
|
userAgent = typeof value === "string" && value ? value : null;
|
|
345
381
|
network.userAgent = userAgent;
|
|
346
|
-
return userAgent ?
|
|
382
|
+
return userAgent ? applyState("setUserAgent", () => [userAgent]) : undefined;
|
|
347
383
|
},
|
|
348
384
|
screenshot: (options = {}) => composite("screenshot", {
|
|
349
385
|
format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
|
|
@@ -401,12 +437,21 @@ export async function createBrowser({
|
|
|
401
437
|
},
|
|
402
438
|
};
|
|
403
439
|
|
|
404
|
-
|
|
405
|
-
|
|
440
|
+
// The spare starts with the first renderer rather than after it, ready
|
|
441
|
+
// for the first navigation to an origin.
|
|
442
|
+
rendererReady = (async () => {
|
|
443
|
+
const first = rendererFor("about:blank");
|
|
444
|
+
prepareSpare();
|
|
445
|
+
await first;
|
|
446
|
+
await current.call("loadDocument", { url: "about:blank" });
|
|
447
|
+
})();
|
|
448
|
+
if (waitForRenderer) await rendererReady;
|
|
449
|
+
else rendererReady.catch(() => {}); // the caller awaits `ready`
|
|
406
450
|
|
|
407
451
|
return {
|
|
408
452
|
context,
|
|
409
453
|
network,
|
|
454
|
+
ready: rendererReady,
|
|
410
455
|
get sandbox() {
|
|
411
456
|
return current?.sandbox ?? null;
|
|
412
457
|
},
|
|
@@ -39,10 +39,11 @@ export function layoutText(tree, options = {}) {
|
|
|
39
39
|
boxes: [],
|
|
40
40
|
lineHeight: options.lineHeight == null ? null : positiveNumber(options.lineHeight, 31),
|
|
41
41
|
fallbackLineHeight: 31,
|
|
42
|
-
measure: cachedMeasure(measureText),
|
|
43
|
-
metrics: cachedMetrics(options.fontMetrics),
|
|
44
|
-
maxContent: new WeakMap(),
|
|
45
|
-
minContent: new WeakMap(),
|
|
42
|
+
measure: options.measureCache ?? cachedMeasure(measureText),
|
|
43
|
+
metrics: options.metricsCache ?? cachedMetrics(options.fontMetrics),
|
|
44
|
+
maxContent: options.maxContentCache ?? new WeakMap(),
|
|
45
|
+
minContent: options.minContentCache ?? new WeakMap(),
|
|
46
|
+
blockResults: options.blockResultCache ?? null,
|
|
46
47
|
// Paint layers: the root layer holds in-flow content; positioned boxes add
|
|
47
48
|
// more. A layer with an integer z-index is a stacking context; `parent` is
|
|
48
49
|
// the stacking context a layer is painted in (CSS 2 Appendix E).
|
|
@@ -50,14 +51,20 @@ export function layoutText(tree, options = {}) {
|
|
|
50
51
|
stackingContext: ROOT_LAYER,
|
|
51
52
|
layerCount: 1,
|
|
52
53
|
initialContainingBlock: null,
|
|
54
|
+
// Static positions recorded for out-of-flow boxes, in order (see
|
|
55
|
+
// cacheBlockResult), and those boxes by node for replays.
|
|
56
|
+
staticPositions: [],
|
|
57
|
+
outOfFlowBoxes: new Map(),
|
|
53
58
|
};
|
|
54
59
|
|
|
55
60
|
containingBlocks = [];
|
|
61
|
+
outOfFlowBoxes = ctx.outOfFlowBoxes;
|
|
56
62
|
let root;
|
|
57
63
|
try {
|
|
58
64
|
root = buildBox(tree.root, inheritedFlags(tree.root, { preserve: false, wrap: true }));
|
|
59
65
|
} finally {
|
|
60
66
|
containingBlocks = null;
|
|
67
|
+
outOfFlowBoxes = null;
|
|
61
68
|
}
|
|
62
69
|
const containingWidth = options.measureText || viewportWidth
|
|
63
70
|
? Math.max(1, (viewportWidth || columns * columnWidth) - x * 2)
|
|
@@ -91,12 +98,43 @@ export function layoutText(tree, options = {}) {
|
|
|
91
98
|
});
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Long-lived layout entry point. Expensive shaping measurements are valid as
|
|
103
|
+
* long as their immutable computed-style identity is valid, so retain them
|
|
104
|
+
* across incremental updates instead of starting cold on every reflow.
|
|
105
|
+
* Formatting-context result caches are added to this same owner.
|
|
106
|
+
*/
|
|
107
|
+
export class LayoutEngine {
|
|
108
|
+
#measure;
|
|
109
|
+
#metrics;
|
|
110
|
+
#maxContent = new WeakMap();
|
|
111
|
+
#minContent = new WeakMap();
|
|
112
|
+
#blockResults = new WeakMap();
|
|
113
|
+
|
|
114
|
+
constructor({ measureText = (text) => Bun.stringWidth(text) * 13, fontMetrics = null } = {}) {
|
|
115
|
+
this.#measure = cachedMeasure(measureText);
|
|
116
|
+
this.#metrics = cachedMetrics(fontMetrics);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
layout(tree, options = {}) {
|
|
120
|
+
return layoutText(tree, {
|
|
121
|
+
...options,
|
|
122
|
+
measureCache: this.#measure,
|
|
123
|
+
metricsCache: this.#metrics,
|
|
124
|
+
maxContentCache: this.#maxContent,
|
|
125
|
+
minContentCache: this.#minContent,
|
|
126
|
+
blockResultCache: this.#blockResults,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
94
131
|
// ---------------------------------------------------------------------------
|
|
95
132
|
// Box tree construction
|
|
96
133
|
|
|
97
134
|
// Absolutely positioned boxes register with their containing block while the
|
|
98
135
|
// box tree is built. Building is synchronous and scoped to layoutText.
|
|
99
136
|
let containingBlocks = null;
|
|
137
|
+
let outOfFlowBoxes = null;
|
|
100
138
|
|
|
101
139
|
// Text nodes carry their parent's style, so only element boxes can be positioned.
|
|
102
140
|
const UNPOSITIONABLE = new Set(["root", "text", "line-break"]);
|
|
@@ -115,6 +153,7 @@ function outOfFlowBox(node, flags) {
|
|
|
115
153
|
const box = buildBox(node, inheritedFlags(node, flags), true);
|
|
116
154
|
box.outOfFlow = true;
|
|
117
155
|
holder.absolutes.push(box);
|
|
156
|
+
outOfFlowBoxes?.set(node, box);
|
|
118
157
|
return box;
|
|
119
158
|
}
|
|
120
159
|
|
|
@@ -278,9 +317,13 @@ function hasInlineContent(items) {
|
|
|
278
317
|
* then applies relative offsets and translations to everything it produced.
|
|
279
318
|
*/
|
|
280
319
|
function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = null) {
|
|
320
|
+
const cached = cachedBlockResult(ctx, box, width, y, x, absorbTop, forcedHeight);
|
|
321
|
+
if (cached) return cached;
|
|
281
322
|
const style = box.style;
|
|
282
323
|
const boxStart = ctx.boxes.length;
|
|
283
324
|
const fragmentStart = ctx.fragments.length;
|
|
325
|
+
const layerStart = ctx.layerCount;
|
|
326
|
+
const staticStart = ctx.staticPositions.length;
|
|
284
327
|
const previousLayer = ctx.layer;
|
|
285
328
|
const previousContext = ctx.stackingContext;
|
|
286
329
|
// Anonymous boxes reuse their container's node but are never positioned themselves.
|
|
@@ -314,9 +357,122 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
|
|
|
314
357
|
if (dx || dy) shiftOutput(ctx, boxStart, fragmentStart, dx, dy);
|
|
315
358
|
ctx.layer = previousLayer;
|
|
316
359
|
ctx.stackingContext = previousContext;
|
|
360
|
+
cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result, {
|
|
361
|
+
boxStart, fragmentStart, layerStart, staticStart, layer: previousLayer, context: previousContext,
|
|
362
|
+
});
|
|
317
363
|
return result;
|
|
318
364
|
}
|
|
319
365
|
|
|
366
|
+
function blockCacheKey(width, absorbTop, forcedHeight) {
|
|
367
|
+
return `${width}\n${absorbTop ? 1 : 0}\n${forcedHeight ?? "auto"}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Where an out-of-flow box would have been in flow; recorded so that a replayed block sets it again. */
|
|
371
|
+
function setStaticPosition(ctx, box, position) {
|
|
372
|
+
box.staticPosition = position;
|
|
373
|
+
ctx.staticPositions.push({ node: box.node, position });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Replays a retained formatting-context result at its new flow position. */
|
|
377
|
+
function cachedBlockResult(ctx, box, width, y, x, absorbTop, forcedHeight) {
|
|
378
|
+
if (!ctx.blockResults || box.anonymous) return null;
|
|
379
|
+
const cached = ctx.blockResults.get(box.node)?.get(blockCacheKey(width, absorbTop, forcedHeight));
|
|
380
|
+
if (!cached) return null;
|
|
381
|
+
const dx = x - cached.x;
|
|
382
|
+
const dy = y - cached.y;
|
|
383
|
+
// The block's own paint layers are made again, in their order, after the
|
|
384
|
+
// layers laid out before it; -1 is the layer and stacking context it is in.
|
|
385
|
+
const layers = [];
|
|
386
|
+
for (const layer of cached.layers) {
|
|
387
|
+
layers.push(Object.freeze({
|
|
388
|
+
z: layer.z,
|
|
389
|
+
order: ctx.layerCount++,
|
|
390
|
+
context: layer.context,
|
|
391
|
+
parent: layer.parent === -1 ? ctx.stackingContext : layers[layer.parent],
|
|
392
|
+
}));
|
|
393
|
+
}
|
|
394
|
+
const layerAt = (index) => index === -1 ? ctx.layer : layers[index];
|
|
395
|
+
// Retained output is frozen: where it neither moved nor changed layer, the
|
|
396
|
+
// same objects are output again.
|
|
397
|
+
const moved = dx !== 0 || dy !== 0;
|
|
398
|
+
cached.boxes.forEach((record, index) => {
|
|
399
|
+
const layer = layerAt(cached.boxLayers[index]);
|
|
400
|
+
ctx.boxes.push(!moved && record.layer === layer ? record : { ...record, x: record.x + dx, y: record.y + dy, layer });
|
|
401
|
+
});
|
|
402
|
+
cached.fragments.forEach((fragment, index) => {
|
|
403
|
+
const layer = layerAt(cached.fragmentLayers[index]);
|
|
404
|
+
ctx.fragments.push(!moved && fragment.layer === layer ? fragment : { ...shiftedFragment(fragment, dx, dy), layer });
|
|
405
|
+
});
|
|
406
|
+
// Out-of-flow boxes whose containing block is outside this one still need their static positions.
|
|
407
|
+
for (const { node, x: staticX, y: staticY } of cached.staticPositions) {
|
|
408
|
+
const outOfFlow = ctx.outOfFlowBoxes.get(node);
|
|
409
|
+
const position = { x: staticX + dx, y: staticY + dy };
|
|
410
|
+
if (outOfFlow) outOfFlow.staticPosition = position;
|
|
411
|
+
ctx.staticPositions.push({ node, position });
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
height: cached.result.height,
|
|
415
|
+
baseline: cached.result.baseline == null ? null : cached.result.baseline + dy,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Retains a block's output for replay (cachedBlockResult). Positioned boxes
|
|
421
|
+
* inside it make paint layers of their own; those are kept by creation
|
|
422
|
+
* order relative to the layer and stacking context the block is in, and the
|
|
423
|
+
* static positions it recorded go along. Output in a layer made before the
|
|
424
|
+
* block (which should not occur) is not retained.
|
|
425
|
+
*/
|
|
426
|
+
function cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result, start) {
|
|
427
|
+
if (!ctx.blockResults || box.anonymous) return;
|
|
428
|
+
const boxes = ctx.boxes.slice(start.boxStart);
|
|
429
|
+
const fragments = ctx.fragments.slice(start.fragmentStart);
|
|
430
|
+
const local = new Set();
|
|
431
|
+
const collect = (layer) => {
|
|
432
|
+
if (layer === start.layer || layer === start.context || local.has(layer)) return true;
|
|
433
|
+
if (!layer || layer.order < start.layerStart) return false;
|
|
434
|
+
local.add(layer);
|
|
435
|
+
return collect(layer.parent);
|
|
436
|
+
};
|
|
437
|
+
if (!boxes.every((record) => collect(record.layer)) || !fragments.every((fragment) => collect(fragment.layer))) return;
|
|
438
|
+
const ordered = [...local].sort((left, right) => left.order - right.order);
|
|
439
|
+
const indices = new Map(ordered.map((layer, index) => [layer, index]));
|
|
440
|
+
const layers = [];
|
|
441
|
+
for (const layer of ordered) {
|
|
442
|
+
const parent = indices.get(layer.parent) ?? (layer.parent === start.context ? -1 : null);
|
|
443
|
+
if (parent === null) return;
|
|
444
|
+
layers.push({ z: layer.z, context: layer.context, parent });
|
|
445
|
+
}
|
|
446
|
+
const indexOf = (layer) => layer === start.layer ? -1 : indices.get(layer) ?? null;
|
|
447
|
+
const boxLayers = boxes.map((record) => indexOf(record.layer));
|
|
448
|
+
const fragmentLayers = fragments.map((fragment) => indexOf(fragment.layer));
|
|
449
|
+
if (boxLayers.includes(null) || fragmentLayers.includes(null)) return;
|
|
450
|
+
let entries = ctx.blockResults.get(box.node);
|
|
451
|
+
if (!entries) ctx.blockResults.set(box.node, entries = new Map());
|
|
452
|
+
// A node normally sees one containing width. Bound unusual cases such as a
|
|
453
|
+
// reused table cell so the retained cache cannot grow without limit.
|
|
454
|
+
if (!entries.has(blockCacheKey(width, absorbTop, forcedHeight)) && entries.size >= 4) {
|
|
455
|
+
entries.delete(entries.keys().next().value);
|
|
456
|
+
}
|
|
457
|
+
entries.set(blockCacheKey(width, absorbTop, forcedHeight), {
|
|
458
|
+
x,
|
|
459
|
+
y,
|
|
460
|
+
result: { ...result },
|
|
461
|
+
layers,
|
|
462
|
+
boxes: boxes.map(retained),
|
|
463
|
+
boxLayers,
|
|
464
|
+
fragments: fragments.map(retained),
|
|
465
|
+
fragmentLayers,
|
|
466
|
+
staticPositions: ctx.staticPositions.slice(start.staticStart)
|
|
467
|
+
.map(({ node, position }) => ({ node, x: position.x, y: position.y })),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** An output record as retained: frozen (records replayed from a retained block already are). */
|
|
472
|
+
function retained(record) {
|
|
473
|
+
return Object.isFrozen(record) ? record : Object.freeze({ ...record });
|
|
474
|
+
}
|
|
475
|
+
|
|
320
476
|
/** CSS 2 §10.3.7/§10.6.4 absolute placement against the padding box. */
|
|
321
477
|
function layoutAbsolutes(ctx, box, record) {
|
|
322
478
|
const border = borderWidths(box.style);
|
|
@@ -387,10 +543,11 @@ function justifyColumn(ctx, items, justify = "normal", free) {
|
|
|
387
543
|
});
|
|
388
544
|
}
|
|
389
545
|
|
|
546
|
+
/** Moves output already produced; records are replaced, as retained ones are frozen and shared. */
|
|
390
547
|
function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
|
|
391
548
|
for (let index = boxStart; index < ctx.boxes.length; index++) {
|
|
392
|
-
ctx.boxes[index]
|
|
393
|
-
ctx.boxes[index].y
|
|
549
|
+
const record = ctx.boxes[index];
|
|
550
|
+
ctx.boxes[index] = { ...record, x: record.x + dx, y: record.y + dy };
|
|
394
551
|
}
|
|
395
552
|
for (let index = fragmentStart; index < ctx.fragments.length; index++) {
|
|
396
553
|
ctx.fragments[index] = shiftedFragment(ctx.fragments[index], dx, dy);
|
|
@@ -398,6 +555,7 @@ function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
|
|
|
398
555
|
}
|
|
399
556
|
|
|
400
557
|
function shiftedFragment(fragment, dx, dy) {
|
|
558
|
+
if (dx === 0 && dy === 0) return fragment;
|
|
401
559
|
return {
|
|
402
560
|
...fragment,
|
|
403
561
|
x: fragment.x + dx,
|
|
@@ -500,7 +658,7 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
500
658
|
let firstInFlow = true;
|
|
501
659
|
box.children.forEach((child) => {
|
|
502
660
|
if (child.kind === "placeholder") {
|
|
503
|
-
child.box
|
|
661
|
+
setStaticPosition(ctx, child.box, { x: contentX, y: cursor + strutValue(pending ?? ZERO_STRUT) });
|
|
504
662
|
return;
|
|
505
663
|
}
|
|
506
664
|
const first = firstInFlow;
|
|
@@ -902,7 +1060,10 @@ function alignGridContent(ctx, grid, alignment = "normal", free) {
|
|
|
902
1060
|
for (const placement of grid.placements) {
|
|
903
1061
|
const dy = offset(placement.row);
|
|
904
1062
|
if (!dy) continue;
|
|
905
|
-
for (let index = placement.boxStart; index < placement.boxEnd; index++)
|
|
1063
|
+
for (let index = placement.boxStart; index < placement.boxEnd; index++) {
|
|
1064
|
+
const record = ctx.boxes[index];
|
|
1065
|
+
ctx.boxes[index] = { ...record, y: record.y + dy };
|
|
1066
|
+
}
|
|
906
1067
|
for (let index = placement.fragmentStart; index < placement.fragmentEnd; index++) {
|
|
907
1068
|
ctx.fragments[index] = shiftedFragment(ctx.fragments[index], 0, dy);
|
|
908
1069
|
}
|
|
@@ -1532,7 +1693,8 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, perce
|
|
|
1532
1693
|
}
|
|
1533
1694
|
|
|
1534
1695
|
function maxContentWidth(ctx, box) {
|
|
1535
|
-
const
|
|
1696
|
+
const cacheKey = box.node ?? box;
|
|
1697
|
+
const cached = ctx.maxContent.get(cacheKey);
|
|
1536
1698
|
if (cached != null) return cached;
|
|
1537
1699
|
const style = box.style;
|
|
1538
1700
|
const padding = style?.padding ?? ZERO_BOX;
|
|
@@ -1568,12 +1730,13 @@ function maxContentWidth(ctx, box) {
|
|
|
1568
1730
|
}
|
|
1569
1731
|
const maxWidth = numberOrNull(style?.maxWidth);
|
|
1570
1732
|
if (maxWidth != null) width = Math.min(width, style.boxSizing === "border-box" ? maxWidth : maxWidth + extra);
|
|
1571
|
-
ctx.maxContent.set(
|
|
1733
|
+
ctx.maxContent.set(cacheKey, width);
|
|
1572
1734
|
return width;
|
|
1573
1735
|
}
|
|
1574
1736
|
|
|
1575
1737
|
function minContentWidth(ctx, box) {
|
|
1576
|
-
const
|
|
1738
|
+
const cacheKey = box.node ?? box;
|
|
1739
|
+
const cached = ctx.minContent.get(cacheKey);
|
|
1577
1740
|
if (cached != null) return cached;
|
|
1578
1741
|
const style = box.style;
|
|
1579
1742
|
const padding = style?.padding ?? ZERO_BOX;
|
|
@@ -1600,7 +1763,7 @@ function minContentWidth(ctx, box) {
|
|
|
1600
1763
|
? inlineMinContentWidth(ctx, child)
|
|
1601
1764
|
: outer(child))) + extra;
|
|
1602
1765
|
}
|
|
1603
|
-
ctx.minContent.set(
|
|
1766
|
+
ctx.minContent.set(cacheKey, width);
|
|
1604
1767
|
return width;
|
|
1605
1768
|
}
|
|
1606
1769
|
|
|
@@ -2220,11 +2383,12 @@ function lineFragment(style, text, nodeIds, runs, x, y, width, height, baseline
|
|
|
2220
2383
|
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
2221
2384
|
|
|
2222
2385
|
function cachedMeasure(measureText) {
|
|
2223
|
-
const byStyle = new
|
|
2386
|
+
const byStyle = new WeakMap();
|
|
2387
|
+
const defaultStyle = new Map();
|
|
2224
2388
|
return (text, style) => {
|
|
2225
2389
|
if (!text) return 0;
|
|
2226
|
-
let cache = byStyle.get(style
|
|
2227
|
-
if (!cache) byStyle.set(style
|
|
2390
|
+
let cache = style && typeof style === "object" ? byStyle.get(style) : defaultStyle;
|
|
2391
|
+
if (!cache) byStyle.set(style, cache = new Map());
|
|
2228
2392
|
let width = cache.get(text);
|
|
2229
2393
|
if (width === undefined) cache.set(text, width = measureText(text, style ?? null));
|
|
2230
2394
|
return width;
|