mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-08 20:32:00 -04:00
Merge pull request #3617 from lightpanda-io/iterative-semantic-tree
mem: SemanticTree and AXNode recursive walk -> iterative
This commit is contained in:
2 files changed
+115
-56
No files matched your search
+92
-36
@@ -79,7 +79,7 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!
|
||||
.listener_targets = listener_targets,
|
||||
.label_index = &label_index,
|
||||
};
|
||||
self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| {
|
||||
self.walk(&ctx, &visitor) catch |err| {
|
||||
log.err(.app, "semantic tree json dump failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
@@ -98,7 +98,7 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v
|
||||
.listener_targets = listener_targets,
|
||||
.label_index = &label_index,
|
||||
};
|
||||
self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| {
|
||||
self.walk(&ctx, &visitor) catch |err| {
|
||||
log.err(.app, "semantic tree text dump failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
@@ -130,15 +130,60 @@ const WalkContext = struct {
|
||||
label_index: *Label.LabelByForIndex,
|
||||
};
|
||||
|
||||
fn walk(
|
||||
// A node whose children are still being walked
|
||||
const Open = struct {
|
||||
next_child: ?*Node,
|
||||
// for the children's xpath index
|
||||
tag_counts: std.StringArrayHashMapUnmanaged(usize) = .empty,
|
||||
name: ?[]const u8, // The children's parent_name
|
||||
xpath_len: usize,
|
||||
visited: bool,
|
||||
};
|
||||
|
||||
fn walk(self: @This(), ctx: *WalkContext, visitor: anytype) !void {
|
||||
var stack: std.ArrayList(Open) = .empty;
|
||||
defer stack.deinit(self.arena);
|
||||
|
||||
try self.visitNode(ctx, &stack, self.dom_node, null, visitor, 1);
|
||||
while (stack.items.len > 0) {
|
||||
// Everything read from `top` is read before visitNode, which can grow (move) the stack.
|
||||
const top = &stack.items[stack.items.len - 1];
|
||||
if (top.next_child) |child| {
|
||||
top.next_child = child._next;
|
||||
|
||||
var tag: []const u8 = "text()";
|
||||
if (child.is(Element)) |el| {
|
||||
tag = el.getTagNameLower();
|
||||
}
|
||||
const gop = try top.tag_counts.getOrPut(self.arena, tag);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = 0;
|
||||
}
|
||||
gop.value_ptr.* += 1;
|
||||
|
||||
try self.visitNode(ctx, &stack, child, top.name, visitor, gop.value_ptr.*);
|
||||
continue;
|
||||
}
|
||||
|
||||
const done = stack.pop().?;
|
||||
if (done.visited) {
|
||||
try visitor.leave();
|
||||
}
|
||||
ctx.xpath_buffer.shrinkRetainingCapacity(done.xpath_len);
|
||||
}
|
||||
}
|
||||
|
||||
// Every ancestor of `node` below the root is open, so the stack's length is its depth.
|
||||
fn visitNode(
|
||||
self: @This(),
|
||||
ctx: *WalkContext,
|
||||
stack: *std.ArrayList(Open),
|
||||
node: *Node,
|
||||
parent_name: ?[]const u8,
|
||||
visitor: anytype,
|
||||
index: usize,
|
||||
current_depth: u32,
|
||||
) !void {
|
||||
const current_depth = stack.items.len;
|
||||
if (current_depth > self.max_depth) return;
|
||||
|
||||
// 1. Skip non-content nodes
|
||||
@@ -216,8 +261,6 @@ fn walk(
|
||||
try appendXPathSegment(node, ctx.xpath_buffer, self.arena, index);
|
||||
const xpath = ctx.xpath_buffer.items;
|
||||
|
||||
var name = try axn.getName(self.frame, self.arena, ctx.label_index);
|
||||
|
||||
const has_explicit_label = if (node.is(Element)) |el|
|
||||
el.getAttributeInterned("aria-label") != null or el.getAttributeInterned("title") != null
|
||||
else
|
||||
@@ -225,12 +268,14 @@ fn walk(
|
||||
|
||||
const structural = isStructuralRole(role);
|
||||
|
||||
// Filter out computed concatenated names for generic containers without explicit labels.
|
||||
// No computed concatenated names for generic containers without explicit labels.
|
||||
// This prevents token bloat and ensures their StaticText children aren't incorrectly pruned.
|
||||
// We ignore interactivity because a generic wrapper with an event listener still shouldn't hoist all text.
|
||||
if (name != null and structural and !has_explicit_label) {
|
||||
name = null;
|
||||
}
|
||||
// Not computing it also keeps a deep chain of containers from being O(depth²).
|
||||
const name = if (structural and !has_explicit_label)
|
||||
null
|
||||
else
|
||||
try axn.getName(self.frame, self.arena, ctx.label_index);
|
||||
|
||||
var should_visit = true;
|
||||
if (self.interactive_only) {
|
||||
@@ -285,32 +330,12 @@ fn walk(
|
||||
did_visit = false;
|
||||
}
|
||||
|
||||
if (should_walk_children) {
|
||||
// If we are printing this node normally OR skipping it and unrolling its children,
|
||||
// we walk the children iterator.
|
||||
var it = node.childrenIterator();
|
||||
var tag_counts: std.StringArrayHashMapUnmanaged(usize) = .empty;
|
||||
while (it.next()) |child| {
|
||||
var tag: []const u8 = "text()";
|
||||
if (child.is(Element)) |el| {
|
||||
tag = el.getTagNameLower();
|
||||
}
|
||||
|
||||
const gop = try tag_counts.getOrPut(self.arena, tag);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = 0;
|
||||
}
|
||||
gop.value_ptr.* += 1;
|
||||
|
||||
try self.walk(ctx, child, name, visitor, gop.value_ptr.*, current_depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (did_visit) {
|
||||
try visitor.leave();
|
||||
}
|
||||
|
||||
ctx.xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
|
||||
try stack.append(self.arena, .{
|
||||
.next_child = if (should_walk_children) node._first_child else null,
|
||||
.name = name,
|
||||
.xpath_len = initial_xpath_len,
|
||||
.visited = did_visit,
|
||||
});
|
||||
}
|
||||
|
||||
fn extractSelectOptions(node: *Node, frame: *Frame, arena: std.mem.Allocator) ![]OptionData {
|
||||
@@ -787,3 +812,34 @@ test "SemanticTree max_depth" {
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, text_str, "other") == null);
|
||||
}
|
||||
|
||||
test "SemanticTree: deep nesting doesn't overflow the native stack" {
|
||||
var registry: NodeRegistry = .init(testing.allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
const frame = try testing.createFrame();
|
||||
defer testing.test_session.closeAllPages();
|
||||
|
||||
// The link's name comes from its content: the whole chain. The <g>s are
|
||||
// pruned, so the JSON only nests link > text. SVG, as an HTML element's
|
||||
// pointer-events lookup walks its ancestors: O(depth²).
|
||||
const depth = 50_000;
|
||||
const doc = frame.window._document;
|
||||
var top = try doc.createTextNode("deep");
|
||||
for (0..depth) |_| {
|
||||
const parent = (try doc.createElementNS("http://www.w3.org/2000/svg", "g", frame)).asNode();
|
||||
_ = try parent.appendChild(top, frame);
|
||||
top = parent;
|
||||
}
|
||||
const link = try doc.createElement("a", null, frame);
|
||||
try link.setAttribute(.wrap("href"), .wrap("#"), frame);
|
||||
_ = try link.asNode().appendChild(top, frame);
|
||||
|
||||
const st: Self = try .init(testing.arena_allocator, link.asNode(), ®istry, frame, .{});
|
||||
const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{});
|
||||
defer testing.allocator.free(json_str);
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, json_str, "\"role\":\"link\",\"name\":\"deep\"") != null);
|
||||
try testing.expectEqual(depth, std.mem.count(u8, json_str, "/g[1]"));
|
||||
try testing.expect(std.mem.endsWith(u8, json_str, "/text()[1]\",\"nodeType\":3,\"nodeValue\":\"deep\",\"children\":[]}]}"));
|
||||
}
|
||||
+23
-20
@@ -22,6 +22,7 @@ const lp = @import("lightpanda");
|
||||
const Frame = @import("../../browser/Frame.zig");
|
||||
const DOMNode = @import("../../browser/webapi/Node.zig");
|
||||
const Label = @import("../../browser/webapi/element/html/Label.zig");
|
||||
const TreeWalker = @import("../../browser/webapi/TreeWalker.zig");
|
||||
const interactive = @import("../../browser/interactive.zig");
|
||||
|
||||
const NodeRegistry = @import("../../NodeRegistry.zig");
|
||||
@@ -1058,7 +1059,7 @@ fn writeName(
|
||||
|
||||
if (use_name_for_content) {
|
||||
var buf: std.Io.Writer.Allocating = .init(scratchAllocator(temp_arena, frame));
|
||||
try writeAccessibleNameFallback(node, &buf.writer, frame);
|
||||
try writeAccessibleNameFallback(node, &buf.writer);
|
||||
if (buf.written().len > 0) {
|
||||
try writeString(buf.written(), w);
|
||||
return .contents;
|
||||
@@ -1085,9 +1086,22 @@ fn writeName(
|
||||
};
|
||||
}
|
||||
|
||||
fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer, frame: *Frame) !void {
|
||||
var it = node.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer) !void {
|
||||
var tw = TreeWalker.FullExcludeSelf.init(node, .{});
|
||||
while (tw.next()) |child| {
|
||||
const parent = child._parent.?;
|
||||
const in_svg = if (parent.is(DOMNode.Element)) |p| p.getTag() == .svg else false;
|
||||
if (in_svg and parent != node) {
|
||||
// Inside an SVG, only a <title> names it
|
||||
const is_title = if (child.is(DOMNode.Element)) |el| std.mem.eql(u8, el.getTagNameLower(), "title") else false;
|
||||
if (is_title) {
|
||||
try writer.writeByte(' ');
|
||||
} else {
|
||||
tw.skipChildren();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (child._type) {
|
||||
.cdata => {
|
||||
const cd = child.subtype(DOMNode.CData);
|
||||
@@ -1101,26 +1115,15 @@ fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer, frame: *F
|
||||
},
|
||||
.element => {
|
||||
const el = child.subtype(DOMNode.Element);
|
||||
if (el.getTag() == .img) {
|
||||
const tag = el.getTag();
|
||||
if (tag == .img) {
|
||||
if (el.getAttributeSafe(.wrap("alt"))) |alt| {
|
||||
try writer.writeAll(alt);
|
||||
try writer.writeByte(' ');
|
||||
}
|
||||
} else if (el.getTag() == .svg) {
|
||||
// Try to find a <title> inside SVG
|
||||
var sit = child.childrenIterator();
|
||||
while (sit.next()) |s_child| {
|
||||
if (s_child.is(DOMNode.Element)) |s_el| {
|
||||
if (std.mem.eql(u8, s_el.getTagNameLower(), "title")) {
|
||||
try writeAccessibleNameFallback(s_child, writer, frame);
|
||||
try writer.writeByte(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!el.getTag().isMetadata()) {
|
||||
try writeAccessibleNameFallback(child, writer, frame);
|
||||
}
|
||||
tw.skipChildren();
|
||||
} else if (tag != .svg and tag.isMetadata()) {
|
||||
tw.skipChildren();
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
|
||||
Reference in new issue
Block a user