buninu-browser 0.1.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/AGENTS.md +17 -0
- package/LICENSES/brotli.txt +19 -0
- package/README.md +41 -7
- package/THIRD_PARTY_NOTICES.md +26 -4
- package/buninu-browser.js +10 -2
- package/lib/audio/pcm.js +16 -0
- package/lib/audio/piano.js +109 -0
- package/lib/audio/pulse-client.js +413 -0
- package/lib/cdp-server.js +28 -7
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +165 -26
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +290 -41
- package/lib/network/network-service.js +177 -40
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +25 -10
- package/lib/render-tree/index.js +58 -0
- package/lib/renderer/bindings.js +543 -55
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +103 -7
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +228 -24
- package/lib/renderer/page-renderer.js +1264 -221
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +558 -229
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/lib/user-agent.js +12 -0
- package/package.json +1 -1
- package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
- package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
- package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
- package/patches/happy-dom/README.md +12 -0
- package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
- package/patches/ses/README.md +16 -0
- package/test/computed-style.test.js +113 -1
- package/test/document-types.test.js +111 -0
- package/test/download.test.js +192 -0
- package/test/fixtures/tone.mp3 +0 -0
- package/test/flex.test.js +11 -0
- package/test/happy-dom-matches.test.js +25 -0
- package/test/headless-shell.test.js +56 -0
- package/test/hit-test.test.js +37 -0
- package/test/log.test.js +4 -1
- package/test/network-service.test.js +67 -0
- package/test/page-scripts.test.js +437 -29
- package/test/piano.test.js +65 -0
- package/test/positioning.test.js +11 -0
- package/test/pulse-client.test.js +130 -0
- package/test/render-tree.test.js +36 -1
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/text-layout.test.js +91 -1
- package/test/user-agent.test.js +42 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
- package/vendor/ses/dist/ses.mjs +2 -0
|
@@ -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
|
|
|
@@ -123,10 +162,13 @@ function buildBox(node, flags, blockified = false) {
|
|
|
123
162
|
if (node.type === "image") {
|
|
124
163
|
return { kind: "block", node, style, replaced: true, bfc: true, children: [], absolutes: [] };
|
|
125
164
|
}
|
|
126
|
-
|
|
165
|
+
// The root (<body>) keeps its own node type, but lays its children out
|
|
166
|
+
// by its display: body { display: grid } or flex is common.
|
|
167
|
+
const display = node.type === "root" ? style?.display : node.type;
|
|
168
|
+
const flex = ["flex", "inline-flex"].includes(display)
|
|
127
169
|
? style?.flexDirection === "column" ? "column" : "row"
|
|
128
170
|
: null;
|
|
129
|
-
const grid = ["grid", "inline-grid"].includes(
|
|
171
|
+
const grid = ["grid", "inline-grid"].includes(display);
|
|
130
172
|
const table = ["table", "inline-table"].includes(node.type);
|
|
131
173
|
const box = {
|
|
132
174
|
kind: "block",
|
|
@@ -275,9 +317,13 @@ function hasInlineContent(items) {
|
|
|
275
317
|
* then applies relative offsets and translations to everything it produced.
|
|
276
318
|
*/
|
|
277
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;
|
|
278
322
|
const style = box.style;
|
|
279
323
|
const boxStart = ctx.boxes.length;
|
|
280
324
|
const fragmentStart = ctx.fragments.length;
|
|
325
|
+
const layerStart = ctx.layerCount;
|
|
326
|
+
const staticStart = ctx.staticPositions.length;
|
|
281
327
|
const previousLayer = ctx.layer;
|
|
282
328
|
const previousContext = ctx.stackingContext;
|
|
283
329
|
// Anonymous boxes reuse their container's node but are never positioned themselves.
|
|
@@ -311,9 +357,122 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
|
|
|
311
357
|
if (dx || dy) shiftOutput(ctx, boxStart, fragmentStart, dx, dy);
|
|
312
358
|
ctx.layer = previousLayer;
|
|
313
359
|
ctx.stackingContext = previousContext;
|
|
360
|
+
cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result, {
|
|
361
|
+
boxStart, fragmentStart, layerStart, staticStart, layer: previousLayer, context: previousContext,
|
|
362
|
+
});
|
|
314
363
|
return result;
|
|
315
364
|
}
|
|
316
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
|
+
|
|
317
476
|
/** CSS 2 §10.3.7/§10.6.4 absolute placement against the padding box. */
|
|
318
477
|
function layoutAbsolutes(ctx, box, record) {
|
|
319
478
|
const border = borderWidths(box.style);
|
|
@@ -338,7 +497,7 @@ function layoutAbsolutes(ctx, box, record) {
|
|
|
338
497
|
if (specifiedWidth == null && left != null && right != null) {
|
|
339
498
|
width = Math.max(0, containing.width - left - right - marginLeft - marginRight);
|
|
340
499
|
} else {
|
|
341
|
-
width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true).width;
|
|
500
|
+
width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
|
|
342
501
|
}
|
|
343
502
|
const staticPosition = absolute.staticPosition ?? containing;
|
|
344
503
|
const x = left != null ? containing.x + left + marginLeft
|
|
@@ -357,27 +516,59 @@ function layoutAbsolutes(ctx, box, record) {
|
|
|
357
516
|
}
|
|
358
517
|
}
|
|
359
518
|
|
|
519
|
+
/**
|
|
520
|
+
* justify-content in a column flex container (Flexbox §8.2): `free` is the
|
|
521
|
+
* height left over once the items are stacked, as with a min-height or height
|
|
522
|
+
* taller than them. Each item's output moves by its share; shifts are applied
|
|
523
|
+
* item by item because shiftOutput moves everything after a start index.
|
|
524
|
+
*/
|
|
525
|
+
function justifyColumn(ctx, items, justify = "normal", free) {
|
|
526
|
+
if (!(free > 0) || ["normal", "start", "flex-start", "stretch"].includes(justify)) return;
|
|
527
|
+
const count = items.length;
|
|
528
|
+
const offset = (index) => {
|
|
529
|
+
switch (justify) {
|
|
530
|
+
case "center": return free / 2;
|
|
531
|
+
case "end": case "flex-end": return free;
|
|
532
|
+
case "space-between": return count > 1 ? free * index / (count - 1) : 0;
|
|
533
|
+
case "space-around": return free / count * (index + 0.5);
|
|
534
|
+
case "space-evenly": return free / (count + 1) * (index + 1);
|
|
535
|
+
default: return 0;
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
let applied = 0;
|
|
539
|
+
items.forEach((item, index) => {
|
|
540
|
+
const delta = offset(index) - applied;
|
|
541
|
+
if (delta) shiftOutput(ctx, item.boxStart, item.fragmentStart, 0, delta);
|
|
542
|
+
applied += delta;
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Moves output already produced; records are replaced, as retained ones are frozen and shared. */
|
|
360
547
|
function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
|
|
361
548
|
for (let index = boxStart; index < ctx.boxes.length; index++) {
|
|
362
|
-
ctx.boxes[index]
|
|
363
|
-
ctx.boxes[index].y
|
|
549
|
+
const record = ctx.boxes[index];
|
|
550
|
+
ctx.boxes[index] = { ...record, x: record.x + dx, y: record.y + dy };
|
|
364
551
|
}
|
|
365
552
|
for (let index = fragmentStart; index < ctx.fragments.length; index++) {
|
|
366
|
-
|
|
367
|
-
ctx.fragments[index] = {
|
|
368
|
-
...fragment,
|
|
369
|
-
x: fragment.x + dx,
|
|
370
|
-
y: fragment.y + dy,
|
|
371
|
-
baseline: fragment.baseline == null ? null : fragment.baseline + dy,
|
|
372
|
-
runs: Object.freeze(fragment.runs.map((run) => Object.freeze({
|
|
373
|
-
...run,
|
|
374
|
-
x: run.x + dx,
|
|
375
|
-
y: run.y == null ? run.y : run.y + dy,
|
|
376
|
-
}))),
|
|
377
|
-
};
|
|
553
|
+
ctx.fragments[index] = shiftedFragment(ctx.fragments[index], dx, dy);
|
|
378
554
|
}
|
|
379
555
|
}
|
|
380
556
|
|
|
557
|
+
function shiftedFragment(fragment, dx, dy) {
|
|
558
|
+
if (dx === 0 && dy === 0) return fragment;
|
|
559
|
+
return {
|
|
560
|
+
...fragment,
|
|
561
|
+
x: fragment.x + dx,
|
|
562
|
+
y: fragment.y + dy,
|
|
563
|
+
baseline: fragment.baseline == null ? null : fragment.baseline + dy,
|
|
564
|
+
runs: Object.freeze(fragment.runs.map((run) => Object.freeze({
|
|
565
|
+
...run,
|
|
566
|
+
x: run.x + dx,
|
|
567
|
+
y: run.y == null ? run.y : run.y + dy,
|
|
568
|
+
}))),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
381
572
|
function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeight = null) {
|
|
382
573
|
const style = box.style;
|
|
383
574
|
const border = borderWidths(style);
|
|
@@ -401,6 +592,7 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
401
592
|
height: dimensions.height,
|
|
402
593
|
resource: box.node.resource,
|
|
403
594
|
video: box.node.tagName === "VIDEO",
|
|
595
|
+
audio: box.node.tagName === "AUDIO",
|
|
404
596
|
svg: box.node.svg ?? null,
|
|
405
597
|
}], imageX, imageY, dimensions.width, dimensions.height));
|
|
406
598
|
record.height = dimensions.height + padding[0] + padding[2] + border[0] + border[2];
|
|
@@ -412,6 +604,9 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
412
604
|
const contentTop = y + border[0] + padding[0];
|
|
413
605
|
const firstFragment = ctx.fragments.length;
|
|
414
606
|
let contentBottom = contentTop;
|
|
607
|
+
// Column flex items and grid rows, aligned once the height is known.
|
|
608
|
+
let columnItems = null;
|
|
609
|
+
let gridResult = null;
|
|
415
610
|
let baseline = null;
|
|
416
611
|
|
|
417
612
|
if (box.table) {
|
|
@@ -419,9 +614,9 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
419
614
|
contentBottom = result.bottom;
|
|
420
615
|
baseline = result.baseline;
|
|
421
616
|
} else if (box.grid) {
|
|
422
|
-
|
|
423
|
-
contentBottom =
|
|
424
|
-
baseline =
|
|
617
|
+
gridResult = layoutGrid(ctx, box, contentX, contentWidth, contentTop);
|
|
618
|
+
contentBottom = gridResult.bottom;
|
|
619
|
+
baseline = gridResult.baseline;
|
|
425
620
|
} else if (box.flex === "row") {
|
|
426
621
|
const result = layoutFlexRow(ctx, box, contentX, contentWidth, contentTop);
|
|
427
622
|
contentBottom = result.bottom;
|
|
@@ -429,8 +624,10 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
429
624
|
} else if (box.flex === "column") {
|
|
430
625
|
const gap = style?.rowGap ?? style?.gap ?? 0;
|
|
431
626
|
let cursor = contentTop;
|
|
627
|
+
columnItems = [];
|
|
432
628
|
box.children.forEach((child, index) => {
|
|
433
629
|
if (index > 0) cursor += gap;
|
|
630
|
+
columnItems.push({ boxStart: ctx.boxes.length, fragmentStart: ctx.fragments.length });
|
|
434
631
|
if (child.kind === "ifc") {
|
|
435
632
|
const result = layoutInline(ctx, child, contentX, contentWidth, cursor);
|
|
436
633
|
cursor = result.bottom;
|
|
@@ -461,7 +658,7 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
461
658
|
let firstInFlow = true;
|
|
462
659
|
box.children.forEach((child) => {
|
|
463
660
|
if (child.kind === "placeholder") {
|
|
464
|
-
child.box
|
|
661
|
+
setStaticPosition(ctx, child.box, { x: contentX, y: cursor + strutValue(pending ?? ZERO_STRUT) });
|
|
465
662
|
return;
|
|
466
663
|
}
|
|
467
664
|
const first = firstInFlow;
|
|
@@ -499,6 +696,8 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
499
696
|
if (maxHeight != null) contentHeight = Math.min(contentHeight, toContentHeight(maxHeight));
|
|
500
697
|
const minHeight = numberOrNull(style?.minHeight);
|
|
501
698
|
if (minHeight != null) contentHeight = Math.max(contentHeight, toContentHeight(minHeight));
|
|
699
|
+
if (columnItems?.length) justifyColumn(ctx, columnItems, style?.justifyContent, contentHeight - (contentBottom - contentTop));
|
|
700
|
+
if (gridResult) alignGridContent(ctx, gridResult, style?.alignContent, contentHeight - (contentBottom - contentTop));
|
|
502
701
|
record.height = forcedHeight ?? contentHeight + padding[0] + padding[2] + border[0] + border[2];
|
|
503
702
|
if (style?.display === "list-item") placeListMarker(ctx, box, contentX, contentTop, firstFragment);
|
|
504
703
|
return { height: record.height, baseline };
|
|
@@ -812,7 +1011,9 @@ function layoutGrid(ctx, box, x, width, y) {
|
|
|
812
1011
|
const rowStarts = trackStarts(rows, [], rowGap, y);
|
|
813
1012
|
|
|
814
1013
|
let baseline = null;
|
|
1014
|
+
const placements = [];
|
|
815
1015
|
for (const item of grid.items) {
|
|
1016
|
+
const placement = { row: item.row, boxStart: ctx.boxes.length, fragmentStart: ctx.fragments.length };
|
|
816
1017
|
const areaY = rowStarts[item.row];
|
|
817
1018
|
const areaHeight = spanSize(rows, [], rowGap, item.row, item.rowSpan);
|
|
818
1019
|
const itemStyle = item.box.style;
|
|
@@ -832,9 +1033,41 @@ function layoutGrid(ctx, box, x, width, y) {
|
|
|
832
1033
|
}
|
|
833
1034
|
const result = layoutBlock(ctx, item.box, item.horizontal.x, item.horizontal.width, itemY, false, forcedHeight);
|
|
834
1035
|
baseline ??= result.baseline;
|
|
1036
|
+
placements.push(Object.assign(placement, { boxEnd: ctx.boxes.length, fragmentEnd: ctx.fragments.length }));
|
|
835
1037
|
}
|
|
836
1038
|
const height = rows.reduce((sum, size) => sum + size, 0) + rowGap * Math.max(0, rows.length - 1);
|
|
837
|
-
return { bottom: y + height, baseline };
|
|
1039
|
+
return { bottom: y + height, baseline, placements, rowCount: rows.length };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* align-content for a grid taller than its rows (CSS Box Alignment 3 §5.3):
|
|
1044
|
+
* the free space goes before, between or around the rows. Each item moves
|
|
1045
|
+
* with its (first) row.
|
|
1046
|
+
*/
|
|
1047
|
+
function alignGridContent(ctx, grid, alignment = "normal", free) {
|
|
1048
|
+
if (!(free > 0) || !grid.rowCount || ["normal", "start", "stretch"].includes(alignment)) return;
|
|
1049
|
+
const count = grid.rowCount;
|
|
1050
|
+
const offset = (row) => {
|
|
1051
|
+
switch (alignment) {
|
|
1052
|
+
case "center": return free / 2;
|
|
1053
|
+
case "end": return free;
|
|
1054
|
+
case "space-between": return count > 1 ? free * row / (count - 1) : 0;
|
|
1055
|
+
case "space-around": return free / count * (row + 0.5);
|
|
1056
|
+
case "space-evenly": return free / (count + 1) * (row + 1);
|
|
1057
|
+
default: return 0;
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
for (const placement of grid.placements) {
|
|
1061
|
+
const dy = offset(placement.row);
|
|
1062
|
+
if (!dy) continue;
|
|
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
|
+
}
|
|
1067
|
+
for (let index = placement.fragmentStart; index < placement.fragmentEnd; index++) {
|
|
1068
|
+
ctx.fragments[index] = shiftedFragment(ctx.fragments[index], 0, dy);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
838
1071
|
}
|
|
839
1072
|
|
|
840
1073
|
// ---------------------------------------------------------------------------
|
|
@@ -1426,7 +1659,7 @@ function dryLayout(ctx, box, width) {
|
|
|
1426
1659
|
}
|
|
1427
1660
|
|
|
1428
1661
|
/** Resolves the used border-box width and horizontal margins of a block-level box. */
|
|
1429
|
-
function resolveHorizontal(ctx, box, containingX, containingWidth, shrink) {
|
|
1662
|
+
function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, percentageBasis = containingWidth) {
|
|
1430
1663
|
const style = box.style;
|
|
1431
1664
|
const padding = style?.padding ?? ZERO_BOX;
|
|
1432
1665
|
const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
|
|
@@ -1435,15 +1668,15 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink) {
|
|
|
1435
1668
|
let marginLeft = numberOrZero(margin[3]);
|
|
1436
1669
|
let marginRight = numberOrZero(margin[1]);
|
|
1437
1670
|
const available = Math.max(0, containingWidth - marginLeft - marginRight);
|
|
1438
|
-
const specified = numberOrNull(resolveLength(style?.width,
|
|
1671
|
+
const specified = numberOrNull(resolveLength(style?.width, percentageBasis));
|
|
1439
1672
|
let width;
|
|
1440
1673
|
if (box.replaced) width = imageDimensions(box.node, containingWidth).width + extra;
|
|
1441
1674
|
else if (specified != null) width = borderBox(specified);
|
|
1442
1675
|
else if (shrink) width = Math.min(maxContentWidth(ctx, box), available);
|
|
1443
1676
|
else width = available;
|
|
1444
|
-
const maxWidth = numberOrNull(resolveLength(style?.maxWidth,
|
|
1677
|
+
const maxWidth = numberOrNull(resolveLength(style?.maxWidth, percentageBasis));
|
|
1445
1678
|
if (maxWidth != null) width = Math.min(width, borderBox(maxWidth));
|
|
1446
|
-
const minWidth = numberOrNull(resolveLength(style?.minWidth,
|
|
1679
|
+
const minWidth = numberOrNull(resolveLength(style?.minWidth, percentageBasis));
|
|
1447
1680
|
if (minWidth != null) width = Math.max(width, borderBox(minWidth));
|
|
1448
1681
|
width = Math.max(width, extra);
|
|
1449
1682
|
|
|
@@ -1460,7 +1693,8 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink) {
|
|
|
1460
1693
|
}
|
|
1461
1694
|
|
|
1462
1695
|
function maxContentWidth(ctx, box) {
|
|
1463
|
-
const
|
|
1696
|
+
const cacheKey = box.node ?? box;
|
|
1697
|
+
const cached = ctx.maxContent.get(cacheKey);
|
|
1464
1698
|
if (cached != null) return cached;
|
|
1465
1699
|
const style = box.style;
|
|
1466
1700
|
const padding = style?.padding ?? ZERO_BOX;
|
|
@@ -1496,12 +1730,13 @@ function maxContentWidth(ctx, box) {
|
|
|
1496
1730
|
}
|
|
1497
1731
|
const maxWidth = numberOrNull(style?.maxWidth);
|
|
1498
1732
|
if (maxWidth != null) width = Math.min(width, style.boxSizing === "border-box" ? maxWidth : maxWidth + extra);
|
|
1499
|
-
ctx.maxContent.set(
|
|
1733
|
+
ctx.maxContent.set(cacheKey, width);
|
|
1500
1734
|
return width;
|
|
1501
1735
|
}
|
|
1502
1736
|
|
|
1503
1737
|
function minContentWidth(ctx, box) {
|
|
1504
|
-
const
|
|
1738
|
+
const cacheKey = box.node ?? box;
|
|
1739
|
+
const cached = ctx.minContent.get(cacheKey);
|
|
1505
1740
|
if (cached != null) return cached;
|
|
1506
1741
|
const style = box.style;
|
|
1507
1742
|
const padding = style?.padding ?? ZERO_BOX;
|
|
@@ -1528,7 +1763,7 @@ function minContentWidth(ctx, box) {
|
|
|
1528
1763
|
? inlineMinContentWidth(ctx, child)
|
|
1529
1764
|
: outer(child))) + extra;
|
|
1530
1765
|
}
|
|
1531
|
-
ctx.minContent.set(
|
|
1766
|
+
ctx.minContent.set(cacheKey, width);
|
|
1532
1767
|
return width;
|
|
1533
1768
|
}
|
|
1534
1769
|
|
|
@@ -1724,7 +1959,7 @@ function buildParagraph(ctx, ifc) {
|
|
|
1724
1959
|
const runAt = new Array(text.length);
|
|
1725
1960
|
for (const run of visibleRuns) for (let index = run.start; index < run.end; index++) runAt[index] = run;
|
|
1726
1961
|
const opportunities = new Set();
|
|
1727
|
-
const graphemes = [...
|
|
1962
|
+
const graphemes = [...GRAPHEME_SEGMENTER.segment(text)];
|
|
1728
1963
|
for (const { segment, index } of graphemes) {
|
|
1729
1964
|
const run = runAt[index];
|
|
1730
1965
|
const end = index + segment.length;
|
|
@@ -2005,6 +2240,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
|
|
|
2005
2240
|
height: segment.dimensions.height,
|
|
2006
2241
|
resource: run.node.resource,
|
|
2007
2242
|
video: run.node.tagName === "VIDEO",
|
|
2243
|
+
audio: run.node.tagName === "AUDIO",
|
|
2008
2244
|
svg: run.node.svg ?? null,
|
|
2009
2245
|
});
|
|
2010
2246
|
} else if (run.kind === "atomic") {
|
|
@@ -2124,13 +2360,15 @@ function borderWidths(style) {
|
|
|
2124
2360
|
return style?.borderWidths ?? ZERO_BOX;
|
|
2125
2361
|
}
|
|
2126
2362
|
|
|
2363
|
+
// Not frozen: intrinsic sizing lays lines out many times over, and freezing
|
|
2364
|
+
// every run was the largest single cost of layout. Nothing mutates them.
|
|
2127
2365
|
function lineFragment(style, text, nodeIds, runs, x, y, width, height, baseline = null) {
|
|
2128
2366
|
return {
|
|
2129
2367
|
type: "line",
|
|
2130
2368
|
baseline,
|
|
2131
2369
|
text,
|
|
2132
|
-
nodeIds
|
|
2133
|
-
runs
|
|
2370
|
+
nodeIds,
|
|
2371
|
+
runs,
|
|
2134
2372
|
x,
|
|
2135
2373
|
y,
|
|
2136
2374
|
width,
|
|
@@ -2142,12 +2380,15 @@ function lineFragment(style, text, nodeIds, runs, x, y, width, height, baseline
|
|
|
2142
2380
|
// ---------------------------------------------------------------------------
|
|
2143
2381
|
// Shared helpers
|
|
2144
2382
|
|
|
2383
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
2384
|
+
|
|
2145
2385
|
function cachedMeasure(measureText) {
|
|
2146
|
-
const byStyle = new
|
|
2386
|
+
const byStyle = new WeakMap();
|
|
2387
|
+
const defaultStyle = new Map();
|
|
2147
2388
|
return (text, style) => {
|
|
2148
2389
|
if (!text) return 0;
|
|
2149
|
-
let cache = byStyle.get(style
|
|
2150
|
-
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());
|
|
2151
2392
|
let width = cache.get(text);
|
|
2152
2393
|
if (width === undefined) cache.set(text, width = measureText(text, style ?? null));
|
|
2153
2394
|
return width;
|
|
@@ -2156,7 +2397,9 @@ function cachedMeasure(measureText) {
|
|
|
2156
2397
|
|
|
2157
2398
|
function cachedMetrics(fontMetrics) {
|
|
2158
2399
|
const cache = new Map();
|
|
2159
|
-
|
|
2400
|
+
// Computed styles are frozen and shared, so most lookups hit by identity.
|
|
2401
|
+
const byStyle = new WeakMap();
|
|
2402
|
+
const metricsFor = (style) => {
|
|
2160
2403
|
const fontSize = style?.fontSize ?? 16;
|
|
2161
2404
|
const key = `${fontSize}/${style?.fontWeight ?? 400}/${style?.fontFamily?.join(",")}`;
|
|
2162
2405
|
let metrics = cache.get(key);
|
|
@@ -2169,6 +2412,12 @@ function cachedMetrics(fontMetrics) {
|
|
|
2169
2412
|
}
|
|
2170
2413
|
return metrics;
|
|
2171
2414
|
};
|
|
2415
|
+
return (style) => {
|
|
2416
|
+
if (style === null || typeof style !== "object") return metricsFor(style);
|
|
2417
|
+
let metrics = byStyle.get(style);
|
|
2418
|
+
if (!metrics) byStyle.set(style, metrics = metricsFor(style));
|
|
2419
|
+
return metrics;
|
|
2420
|
+
};
|
|
2172
2421
|
}
|
|
2173
2422
|
|
|
2174
2423
|
function imageDimensions(node, referenceWidth) {
|