buninu-browser 0.0.2 → 0.2.0
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 +30 -6
- 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 +25 -6
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/headless-shell.js +108 -14
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +112 -27
- package/lib/network/network-service.js +7 -3
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +2 -2
- package/lib/render-tree/index.js +13 -0
- package/lib/renderer/bindings.js +541 -53
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +110 -9
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +226 -22
- package/lib/renderer/page-renderer.js +1293 -212
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +268 -20
- 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/patches/wasmpeg/0001-windows-wasm-path.patch +14 -0
- package/patches/wasmpeg/README.md +28 -0
- package/test/computed-style.test.js +52 -0
- 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 +27 -0
- package/test/hit-test.test.js +37 -0
- package/test/page-scripts.test.js +388 -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 +14 -0
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/text-layout.test.js +22 -0
- 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
- package/vendor/wasmpeg/VENDORING.md +7 -3
- package/vendor/wasmpeg/src/js/gpu.js +2 -1
package/lib/input/hit-test.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
const INTERACTIVE_TAGS = new Set(["A", "BUTTON", "INPUT", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
|
|
1
|
+
const INTERACTIVE_TAGS = new Set(["A", "AUDIO", "BUTTON", "INPUT", "LABEL", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
|
|
2
|
+
// Form controls and labels are hit over their whole box, not only where text
|
|
3
|
+
// is drawn (an unchecked checkbox or an empty field has none; a block label
|
|
4
|
+
// is clickable beside its text).
|
|
5
|
+
const CONTROL_TAGS = new Set(["AUDIO", "BUTTON", "INPUT", "LABEL", "SELECT", "TEXTAREA"]);
|
|
2
6
|
|
|
3
7
|
export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewport = {}) {
|
|
4
8
|
const regions = [];
|
|
5
9
|
const byElement = new Map();
|
|
10
|
+
const hittable = hitTestable(tree);
|
|
6
11
|
|
|
7
12
|
for (const fragment of layout.fragments) {
|
|
8
13
|
for (const run of fragment.runs) {
|
|
9
14
|
const renderNode = tree.nodesById.get(run.nodeId);
|
|
10
15
|
const element = closestInteractive(renderNode?.domNode);
|
|
11
|
-
if (!element) continue;
|
|
16
|
+
if (!element || !hittable(renderNode)) continue;
|
|
12
17
|
const top = fragment.y - scroll.y;
|
|
13
18
|
const left = run.x - scroll.x;
|
|
14
19
|
const right = left + run.width;
|
|
@@ -27,19 +32,43 @@ export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewpo
|
|
|
27
32
|
}
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
for (const box of layout.boxes ?? []) {
|
|
36
|
+
const renderNode = tree.nodesById.get(box.nodeId);
|
|
37
|
+
const element = renderNode?.domNode;
|
|
38
|
+
if (!element || element.nodeType !== 1 || !CONTROL_TAGS.has(element.tagName) || !hittable(renderNode)) continue;
|
|
39
|
+
const top = box.y - scroll.y;
|
|
40
|
+
const left = box.x - scroll.x;
|
|
41
|
+
const right = left + box.width;
|
|
42
|
+
const bottom = top + box.height;
|
|
43
|
+
if (box.width <= 0 || box.height <= 0 || right <= 0 || bottom <= 0
|
|
44
|
+
|| left >= (viewport.width ?? Infinity) || top >= (viewport.height ?? Infinity)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const rect = Object.freeze({ left, top, right, bottom, width: box.width, height: box.height });
|
|
48
|
+
let region = byElement.get(element);
|
|
49
|
+
if (!region) {
|
|
50
|
+
region = { element, rects: [] };
|
|
51
|
+
byElement.set(element, region);
|
|
52
|
+
regions.push(region);
|
|
53
|
+
}
|
|
54
|
+
region.rects.push(rect);
|
|
55
|
+
}
|
|
56
|
+
|
|
30
57
|
return regions.map((region) => Object.freeze({
|
|
31
58
|
element: region.element,
|
|
32
59
|
rects: Object.freeze(region.rects),
|
|
33
60
|
}));
|
|
34
61
|
}
|
|
35
62
|
|
|
63
|
+
/** The innermost interactive element under a point (a checkbox before its label). */
|
|
36
64
|
export function hitTest(tree, layout, x, y, scroll, viewport) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
65
|
+
const hits = interactiveRegions(tree, layout, scroll, viewport).map((region) => ({
|
|
66
|
+
...region,
|
|
67
|
+
area: Math.min(...region.rects.filter((rect) => x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom)
|
|
68
|
+
.map((rect) => rect.width * rect.height)),
|
|
69
|
+
})).filter((region) => Number.isFinite(region.area));
|
|
70
|
+
const deepest = hits.filter((region) => !hits.some((other) => other !== region && region.element.contains(other.element)));
|
|
71
|
+
return deepest.sort((left, right) => left.area - right.area)[0] ?? null;
|
|
43
72
|
}
|
|
44
73
|
|
|
45
74
|
/** Returns the content-coordinate bounds contributed by an element's text descendants. */
|
|
@@ -68,6 +97,26 @@ export function elementBounds(tree, layout, element) {
|
|
|
68
97
|
return Object.freeze({ left, top, right, bottom, width: right - left, height: bottom - top });
|
|
69
98
|
}
|
|
70
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Whether a render node takes clicks: not when its element is
|
|
102
|
+
* visibility: hidden or pointer-events: none (both inherited, so a visible
|
|
103
|
+
* or pointer-events: auto descendant still does). Text uses its element's style.
|
|
104
|
+
*/
|
|
105
|
+
function hitTestable(tree) {
|
|
106
|
+
const styles = new Map();
|
|
107
|
+
for (const node of tree.nodesById.values()) {
|
|
108
|
+
if (node.domNode?.nodeType === 1 && node.style && !node.pseudo && !styles.has(node.domNode)) {
|
|
109
|
+
styles.set(node.domNode, node.style);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return (renderNode) => {
|
|
113
|
+
const domNode = renderNode?.domNode;
|
|
114
|
+
const element = domNode?.nodeType === 1 ? domNode : domNode?.parentElement;
|
|
115
|
+
const style = (renderNode?.pseudo ? renderNode.style : null) ?? styles.get(element);
|
|
116
|
+
return !style || (style.visibility !== "hidden" && style.pointerEvents !== "none");
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
71
120
|
function closestInteractive(node) {
|
|
72
121
|
for (let current = node?.nodeType === 1 ? node : node?.parentElement; current; current = current.parentElement) {
|
|
73
122
|
if (INTERACTIVE_TAGS.has(current.tagName) || current.hasAttribute("onclick") || current.hasAttribute("tabindex")) {
|
|
@@ -123,10 +123,13 @@ function buildBox(node, flags, blockified = false) {
|
|
|
123
123
|
if (node.type === "image") {
|
|
124
124
|
return { kind: "block", node, style, replaced: true, bfc: true, children: [], absolutes: [] };
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
// The root (<body>) keeps its own node type, but lays its children out
|
|
127
|
+
// by its display: body { display: grid } or flex is common.
|
|
128
|
+
const display = node.type === "root" ? style?.display : node.type;
|
|
129
|
+
const flex = ["flex", "inline-flex"].includes(display)
|
|
127
130
|
? style?.flexDirection === "column" ? "column" : "row"
|
|
128
131
|
: null;
|
|
129
|
-
const grid = ["grid", "inline-grid"].includes(
|
|
132
|
+
const grid = ["grid", "inline-grid"].includes(display);
|
|
130
133
|
const table = ["table", "inline-table"].includes(node.type);
|
|
131
134
|
const box = {
|
|
132
135
|
kind: "block",
|
|
@@ -338,7 +341,7 @@ function layoutAbsolutes(ctx, box, record) {
|
|
|
338
341
|
if (specifiedWidth == null && left != null && right != null) {
|
|
339
342
|
width = Math.max(0, containing.width - left - right - marginLeft - marginRight);
|
|
340
343
|
} else {
|
|
341
|
-
width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true).width;
|
|
344
|
+
width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
|
|
342
345
|
}
|
|
343
346
|
const staticPosition = absolute.staticPosition ?? containing;
|
|
344
347
|
const x = left != null ? containing.x + left + marginLeft
|
|
@@ -357,27 +360,57 @@ function layoutAbsolutes(ctx, box, record) {
|
|
|
357
360
|
}
|
|
358
361
|
}
|
|
359
362
|
|
|
363
|
+
/**
|
|
364
|
+
* justify-content in a column flex container (Flexbox §8.2): `free` is the
|
|
365
|
+
* height left over once the items are stacked, as with a min-height or height
|
|
366
|
+
* taller than them. Each item's output moves by its share; shifts are applied
|
|
367
|
+
* item by item because shiftOutput moves everything after a start index.
|
|
368
|
+
*/
|
|
369
|
+
function justifyColumn(ctx, items, justify = "normal", free) {
|
|
370
|
+
if (!(free > 0) || ["normal", "start", "flex-start", "stretch"].includes(justify)) return;
|
|
371
|
+
const count = items.length;
|
|
372
|
+
const offset = (index) => {
|
|
373
|
+
switch (justify) {
|
|
374
|
+
case "center": return free / 2;
|
|
375
|
+
case "end": case "flex-end": return free;
|
|
376
|
+
case "space-between": return count > 1 ? free * index / (count - 1) : 0;
|
|
377
|
+
case "space-around": return free / count * (index + 0.5);
|
|
378
|
+
case "space-evenly": return free / (count + 1) * (index + 1);
|
|
379
|
+
default: return 0;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
let applied = 0;
|
|
383
|
+
items.forEach((item, index) => {
|
|
384
|
+
const delta = offset(index) - applied;
|
|
385
|
+
if (delta) shiftOutput(ctx, item.boxStart, item.fragmentStart, 0, delta);
|
|
386
|
+
applied += delta;
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
360
390
|
function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
|
|
361
391
|
for (let index = boxStart; index < ctx.boxes.length; index++) {
|
|
362
392
|
ctx.boxes[index].x += dx;
|
|
363
393
|
ctx.boxes[index].y += dy;
|
|
364
394
|
}
|
|
365
395
|
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
|
-
};
|
|
396
|
+
ctx.fragments[index] = shiftedFragment(ctx.fragments[index], dx, dy);
|
|
378
397
|
}
|
|
379
398
|
}
|
|
380
399
|
|
|
400
|
+
function shiftedFragment(fragment, dx, dy) {
|
|
401
|
+
return {
|
|
402
|
+
...fragment,
|
|
403
|
+
x: fragment.x + dx,
|
|
404
|
+
y: fragment.y + dy,
|
|
405
|
+
baseline: fragment.baseline == null ? null : fragment.baseline + dy,
|
|
406
|
+
runs: Object.freeze(fragment.runs.map((run) => Object.freeze({
|
|
407
|
+
...run,
|
|
408
|
+
x: run.x + dx,
|
|
409
|
+
y: run.y == null ? run.y : run.y + dy,
|
|
410
|
+
}))),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
381
414
|
function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeight = null) {
|
|
382
415
|
const style = box.style;
|
|
383
416
|
const border = borderWidths(style);
|
|
@@ -401,6 +434,7 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
401
434
|
height: dimensions.height,
|
|
402
435
|
resource: box.node.resource,
|
|
403
436
|
video: box.node.tagName === "VIDEO",
|
|
437
|
+
audio: box.node.tagName === "AUDIO",
|
|
404
438
|
svg: box.node.svg ?? null,
|
|
405
439
|
}], imageX, imageY, dimensions.width, dimensions.height));
|
|
406
440
|
record.height = dimensions.height + padding[0] + padding[2] + border[0] + border[2];
|
|
@@ -412,6 +446,9 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
412
446
|
const contentTop = y + border[0] + padding[0];
|
|
413
447
|
const firstFragment = ctx.fragments.length;
|
|
414
448
|
let contentBottom = contentTop;
|
|
449
|
+
// Column flex items and grid rows, aligned once the height is known.
|
|
450
|
+
let columnItems = null;
|
|
451
|
+
let gridResult = null;
|
|
415
452
|
let baseline = null;
|
|
416
453
|
|
|
417
454
|
if (box.table) {
|
|
@@ -419,9 +456,9 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
419
456
|
contentBottom = result.bottom;
|
|
420
457
|
baseline = result.baseline;
|
|
421
458
|
} else if (box.grid) {
|
|
422
|
-
|
|
423
|
-
contentBottom =
|
|
424
|
-
baseline =
|
|
459
|
+
gridResult = layoutGrid(ctx, box, contentX, contentWidth, contentTop);
|
|
460
|
+
contentBottom = gridResult.bottom;
|
|
461
|
+
baseline = gridResult.baseline;
|
|
425
462
|
} else if (box.flex === "row") {
|
|
426
463
|
const result = layoutFlexRow(ctx, box, contentX, contentWidth, contentTop);
|
|
427
464
|
contentBottom = result.bottom;
|
|
@@ -429,8 +466,10 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
429
466
|
} else if (box.flex === "column") {
|
|
430
467
|
const gap = style?.rowGap ?? style?.gap ?? 0;
|
|
431
468
|
let cursor = contentTop;
|
|
469
|
+
columnItems = [];
|
|
432
470
|
box.children.forEach((child, index) => {
|
|
433
471
|
if (index > 0) cursor += gap;
|
|
472
|
+
columnItems.push({ boxStart: ctx.boxes.length, fragmentStart: ctx.fragments.length });
|
|
434
473
|
if (child.kind === "ifc") {
|
|
435
474
|
const result = layoutInline(ctx, child, contentX, contentWidth, cursor);
|
|
436
475
|
cursor = result.bottom;
|
|
@@ -499,6 +538,8 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
499
538
|
if (maxHeight != null) contentHeight = Math.min(contentHeight, toContentHeight(maxHeight));
|
|
500
539
|
const minHeight = numberOrNull(style?.minHeight);
|
|
501
540
|
if (minHeight != null) contentHeight = Math.max(contentHeight, toContentHeight(minHeight));
|
|
541
|
+
if (columnItems?.length) justifyColumn(ctx, columnItems, style?.justifyContent, contentHeight - (contentBottom - contentTop));
|
|
542
|
+
if (gridResult) alignGridContent(ctx, gridResult, style?.alignContent, contentHeight - (contentBottom - contentTop));
|
|
502
543
|
record.height = forcedHeight ?? contentHeight + padding[0] + padding[2] + border[0] + border[2];
|
|
503
544
|
if (style?.display === "list-item") placeListMarker(ctx, box, contentX, contentTop, firstFragment);
|
|
504
545
|
return { height: record.height, baseline };
|
|
@@ -812,7 +853,9 @@ function layoutGrid(ctx, box, x, width, y) {
|
|
|
812
853
|
const rowStarts = trackStarts(rows, [], rowGap, y);
|
|
813
854
|
|
|
814
855
|
let baseline = null;
|
|
856
|
+
const placements = [];
|
|
815
857
|
for (const item of grid.items) {
|
|
858
|
+
const placement = { row: item.row, boxStart: ctx.boxes.length, fragmentStart: ctx.fragments.length };
|
|
816
859
|
const areaY = rowStarts[item.row];
|
|
817
860
|
const areaHeight = spanSize(rows, [], rowGap, item.row, item.rowSpan);
|
|
818
861
|
const itemStyle = item.box.style;
|
|
@@ -832,9 +875,38 @@ function layoutGrid(ctx, box, x, width, y) {
|
|
|
832
875
|
}
|
|
833
876
|
const result = layoutBlock(ctx, item.box, item.horizontal.x, item.horizontal.width, itemY, false, forcedHeight);
|
|
834
877
|
baseline ??= result.baseline;
|
|
878
|
+
placements.push(Object.assign(placement, { boxEnd: ctx.boxes.length, fragmentEnd: ctx.fragments.length }));
|
|
835
879
|
}
|
|
836
880
|
const height = rows.reduce((sum, size) => sum + size, 0) + rowGap * Math.max(0, rows.length - 1);
|
|
837
|
-
return { bottom: y + height, baseline };
|
|
881
|
+
return { bottom: y + height, baseline, placements, rowCount: rows.length };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* align-content for a grid taller than its rows (CSS Box Alignment 3 §5.3):
|
|
886
|
+
* the free space goes before, between or around the rows. Each item moves
|
|
887
|
+
* with its (first) row.
|
|
888
|
+
*/
|
|
889
|
+
function alignGridContent(ctx, grid, alignment = "normal", free) {
|
|
890
|
+
if (!(free > 0) || !grid.rowCount || ["normal", "start", "stretch"].includes(alignment)) return;
|
|
891
|
+
const count = grid.rowCount;
|
|
892
|
+
const offset = (row) => {
|
|
893
|
+
switch (alignment) {
|
|
894
|
+
case "center": return free / 2;
|
|
895
|
+
case "end": return free;
|
|
896
|
+
case "space-between": return count > 1 ? free * row / (count - 1) : 0;
|
|
897
|
+
case "space-around": return free / count * (row + 0.5);
|
|
898
|
+
case "space-evenly": return free / (count + 1) * (row + 1);
|
|
899
|
+
default: return 0;
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
for (const placement of grid.placements) {
|
|
903
|
+
const dy = offset(placement.row);
|
|
904
|
+
if (!dy) continue;
|
|
905
|
+
for (let index = placement.boxStart; index < placement.boxEnd; index++) ctx.boxes[index].y += dy;
|
|
906
|
+
for (let index = placement.fragmentStart; index < placement.fragmentEnd; index++) {
|
|
907
|
+
ctx.fragments[index] = shiftedFragment(ctx.fragments[index], 0, dy);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
838
910
|
}
|
|
839
911
|
|
|
840
912
|
// ---------------------------------------------------------------------------
|
|
@@ -1426,7 +1498,7 @@ function dryLayout(ctx, box, width) {
|
|
|
1426
1498
|
}
|
|
1427
1499
|
|
|
1428
1500
|
/** Resolves the used border-box width and horizontal margins of a block-level box. */
|
|
1429
|
-
function resolveHorizontal(ctx, box, containingX, containingWidth, shrink) {
|
|
1501
|
+
function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, percentageBasis = containingWidth) {
|
|
1430
1502
|
const style = box.style;
|
|
1431
1503
|
const padding = style?.padding ?? ZERO_BOX;
|
|
1432
1504
|
const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
|
|
@@ -1435,15 +1507,15 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink) {
|
|
|
1435
1507
|
let marginLeft = numberOrZero(margin[3]);
|
|
1436
1508
|
let marginRight = numberOrZero(margin[1]);
|
|
1437
1509
|
const available = Math.max(0, containingWidth - marginLeft - marginRight);
|
|
1438
|
-
const specified = numberOrNull(resolveLength(style?.width,
|
|
1510
|
+
const specified = numberOrNull(resolveLength(style?.width, percentageBasis));
|
|
1439
1511
|
let width;
|
|
1440
1512
|
if (box.replaced) width = imageDimensions(box.node, containingWidth).width + extra;
|
|
1441
1513
|
else if (specified != null) width = borderBox(specified);
|
|
1442
1514
|
else if (shrink) width = Math.min(maxContentWidth(ctx, box), available);
|
|
1443
1515
|
else width = available;
|
|
1444
|
-
const maxWidth = numberOrNull(resolveLength(style?.maxWidth,
|
|
1516
|
+
const maxWidth = numberOrNull(resolveLength(style?.maxWidth, percentageBasis));
|
|
1445
1517
|
if (maxWidth != null) width = Math.min(width, borderBox(maxWidth));
|
|
1446
|
-
const minWidth = numberOrNull(resolveLength(style?.minWidth,
|
|
1518
|
+
const minWidth = numberOrNull(resolveLength(style?.minWidth, percentageBasis));
|
|
1447
1519
|
if (minWidth != null) width = Math.max(width, borderBox(minWidth));
|
|
1448
1520
|
width = Math.max(width, extra);
|
|
1449
1521
|
|
|
@@ -1724,7 +1796,7 @@ function buildParagraph(ctx, ifc) {
|
|
|
1724
1796
|
const runAt = new Array(text.length);
|
|
1725
1797
|
for (const run of visibleRuns) for (let index = run.start; index < run.end; index++) runAt[index] = run;
|
|
1726
1798
|
const opportunities = new Set();
|
|
1727
|
-
const graphemes = [...
|
|
1799
|
+
const graphemes = [...GRAPHEME_SEGMENTER.segment(text)];
|
|
1728
1800
|
for (const { segment, index } of graphemes) {
|
|
1729
1801
|
const run = runAt[index];
|
|
1730
1802
|
const end = index + segment.length;
|
|
@@ -2005,6 +2077,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
|
|
|
2005
2077
|
height: segment.dimensions.height,
|
|
2006
2078
|
resource: run.node.resource,
|
|
2007
2079
|
video: run.node.tagName === "VIDEO",
|
|
2080
|
+
audio: run.node.tagName === "AUDIO",
|
|
2008
2081
|
svg: run.node.svg ?? null,
|
|
2009
2082
|
});
|
|
2010
2083
|
} else if (run.kind === "atomic") {
|
|
@@ -2124,13 +2197,15 @@ function borderWidths(style) {
|
|
|
2124
2197
|
return style?.borderWidths ?? ZERO_BOX;
|
|
2125
2198
|
}
|
|
2126
2199
|
|
|
2200
|
+
// Not frozen: intrinsic sizing lays lines out many times over, and freezing
|
|
2201
|
+
// every run was the largest single cost of layout. Nothing mutates them.
|
|
2127
2202
|
function lineFragment(style, text, nodeIds, runs, x, y, width, height, baseline = null) {
|
|
2128
2203
|
return {
|
|
2129
2204
|
type: "line",
|
|
2130
2205
|
baseline,
|
|
2131
2206
|
text,
|
|
2132
|
-
nodeIds
|
|
2133
|
-
runs
|
|
2207
|
+
nodeIds,
|
|
2208
|
+
runs,
|
|
2134
2209
|
x,
|
|
2135
2210
|
y,
|
|
2136
2211
|
width,
|
|
@@ -2142,6 +2217,8 @@ function lineFragment(style, text, nodeIds, runs, x, y, width, height, baseline
|
|
|
2142
2217
|
// ---------------------------------------------------------------------------
|
|
2143
2218
|
// Shared helpers
|
|
2144
2219
|
|
|
2220
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
2221
|
+
|
|
2145
2222
|
function cachedMeasure(measureText) {
|
|
2146
2223
|
const byStyle = new Map();
|
|
2147
2224
|
return (text, style) => {
|
|
@@ -2156,7 +2233,9 @@ function cachedMeasure(measureText) {
|
|
|
2156
2233
|
|
|
2157
2234
|
function cachedMetrics(fontMetrics) {
|
|
2158
2235
|
const cache = new Map();
|
|
2159
|
-
|
|
2236
|
+
// Computed styles are frozen and shared, so most lookups hit by identity.
|
|
2237
|
+
const byStyle = new WeakMap();
|
|
2238
|
+
const metricsFor = (style) => {
|
|
2160
2239
|
const fontSize = style?.fontSize ?? 16;
|
|
2161
2240
|
const key = `${fontSize}/${style?.fontWeight ?? 400}/${style?.fontFamily?.join(",")}`;
|
|
2162
2241
|
let metrics = cache.get(key);
|
|
@@ -2169,6 +2248,12 @@ function cachedMetrics(fontMetrics) {
|
|
|
2169
2248
|
}
|
|
2170
2249
|
return metrics;
|
|
2171
2250
|
};
|
|
2251
|
+
return (style) => {
|
|
2252
|
+
if (style === null || typeof style !== "object") return metricsFor(style);
|
|
2253
|
+
let metrics = byStyle.get(style);
|
|
2254
|
+
if (!metrics) byStyle.set(style, metrics = metricsFor(style));
|
|
2255
|
+
return metrics;
|
|
2256
|
+
};
|
|
2172
2257
|
}
|
|
2173
2258
|
|
|
2174
2259
|
function imageDimensions(node, referenceWidth) {
|
|
@@ -59,10 +59,10 @@ export class NetworkService {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/** A top-level document load. */
|
|
62
|
-
async navigate(url) {
|
|
62
|
+
async navigate(url, { onResponse } = {}) {
|
|
63
63
|
return this.#send({ url, method: "GET", headers: [], body: null }, {
|
|
64
64
|
credentials: "include", site: null, topLevelNavigation: true,
|
|
65
|
-
});
|
|
65
|
+
}, { onResponse });
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** A subresource the renderer needs to render (no-cors, credentials included). */
|
|
@@ -192,7 +192,7 @@ export class NetworkService {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
/** Sends a request, following redirects by hand so cookies apply to every hop. */
|
|
195
|
-
async #send(request, context, { redirect = "follow", corsOrigin = null } = {}) {
|
|
195
|
+
async #send(request, context, { redirect = "follow", corsOrigin = null, onResponse = null } = {}) {
|
|
196
196
|
let { url, method, body } = request;
|
|
197
197
|
const baseHeaders = request.headers;
|
|
198
198
|
let redirected = false;
|
|
@@ -223,6 +223,10 @@ export class NetworkService {
|
|
|
223
223
|
redirected = true;
|
|
224
224
|
continue;
|
|
225
225
|
}
|
|
226
|
+
if (onResponse) {
|
|
227
|
+
const handled = await onResponse(response, url);
|
|
228
|
+
if (handled) return handled;
|
|
229
|
+
}
|
|
226
230
|
return this.#result(response, url, redirected, "basic");
|
|
227
231
|
}
|
|
228
232
|
throw new TypeError("Too many redirects");
|
package/lib/network/resolver.js
CHANGED
|
@@ -12,11 +12,20 @@
|
|
|
12
12
|
* each with its own socket), and requests connect to an address directly
|
|
13
13
|
* while keeping the host name for the Host header and for TLS (SNI and
|
|
14
14
|
* certificate verification). This also covers musl, which has no such option.
|
|
15
|
+
*
|
|
16
|
+
* `localhost` and `*.localhost` are loopback without asking anyone (RFC 6761
|
|
17
|
+
* §6.3, as Chrome and Bun's own socket resolver do): node:dns hands them to
|
|
18
|
+
* the system getaddrinfo(), which needs an /etc/hosts entry that minimal
|
|
19
|
+
* systems (an initramfs, containers) may not have.
|
|
15
20
|
*/
|
|
16
21
|
import { lookup as systemLookup } from "node:dns/promises";
|
|
17
22
|
import { isIP } from "node:net";
|
|
18
23
|
|
|
19
24
|
const DEFAULT_TTL_MS = 60_000;
|
|
25
|
+
const LOOPBACK = Object.freeze([
|
|
26
|
+
Object.freeze({ address: "127.0.0.1", family: 4 }),
|
|
27
|
+
Object.freeze({ address: "::1", family: 6 }),
|
|
28
|
+
]);
|
|
20
29
|
// Errors that mean "this address did not answer": try the next one.
|
|
21
30
|
const CONNECT_ERRORS = new Set([
|
|
22
31
|
"ECONNREFUSED", "ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH", "EADDRNOTAVAIL",
|
|
@@ -41,6 +50,7 @@ export class HostResolver {
|
|
|
41
50
|
/** Addresses for a host name, IPv4 first, then IPv6. */
|
|
42
51
|
addresses(hostname) {
|
|
43
52
|
const name = hostname.toLowerCase();
|
|
53
|
+
if (isLocalhostName(name)) return Promise.resolve(LOOPBACK);
|
|
44
54
|
const cached = this.#cache.get(name);
|
|
45
55
|
if (cached && cached.expires > this.#now()) return cached.addresses;
|
|
46
56
|
const addresses = this.#resolve(name);
|
|
@@ -70,6 +80,12 @@ export class HostResolver {
|
|
|
70
80
|
}
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
/** `localhost` or a name under `.localhost` (one trailing dot allowed), lowercase. */
|
|
84
|
+
export function isLocalhostName(name) {
|
|
85
|
+
const host = name.endsWith(".") ? name.slice(0, -1) : name;
|
|
86
|
+
return host === "localhost" || host.endsWith(".localhost");
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
/**
|
|
74
90
|
* fetch() that connects to a resolved address, keeping the host name for the
|
|
75
91
|
* Host header and TLS. IP literals and non-HTTP URLs go to `fetchImpl` as is.
|
|
@@ -156,10 +156,10 @@ export function buildDisplayList(layout, tree = null) {
|
|
|
156
156
|
return;
|
|
157
157
|
}
|
|
158
158
|
if (run.type === "image") {
|
|
159
|
-
if (run.video) {
|
|
159
|
+
if (run.video || run.audio) {
|
|
160
160
|
const rect = { x: run.x, y, width: run.width, height: run.height };
|
|
161
161
|
images.push(run.resource);
|
|
162
|
-
items.push({ op: "drawVideo", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
|
|
162
|
+
items.push({ op: run.video ? "drawVideo" : "drawAudio", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
|
|
163
163
|
return;
|
|
164
164
|
}
|
|
165
165
|
if (!run.resource?.image) return;
|
package/lib/render-tree/index.js
CHANGED
|
@@ -185,6 +185,19 @@ export class RenderTreeBuilder {
|
|
|
185
185
|
children: Object.freeze([]),
|
|
186
186
|
});
|
|
187
187
|
}
|
|
188
|
+
// Audio is a 300x32 player (painted like video's controls) with the
|
|
189
|
+
// controls attribute, and not rendered without it (HTML §4.8.11.13).
|
|
190
|
+
if (element.tagName === "AUDIO") {
|
|
191
|
+
if (!element.hasAttribute("controls")) return null;
|
|
192
|
+
return this.#node(element, "image", nodesById, {
|
|
193
|
+
tagName: "AUDIO",
|
|
194
|
+
style,
|
|
195
|
+
resource: resources?.get(element) ?? null,
|
|
196
|
+
widthAttribute: positiveDimension(element.getAttribute("width")) ?? 300,
|
|
197
|
+
heightAttribute: 32,
|
|
198
|
+
children: Object.freeze([]),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
188
201
|
if (element.tagName === "IMG" || element.tagName === "VIDEO") {
|
|
189
202
|
return this.#node(element, "image", nodesById, {
|
|
190
203
|
tagName: element.tagName,
|