Properly resolve inspector ObjectId back to a DOM Node

Tweak element boundingRect and "renderer" based on what puppeteer needs.
This commit is contained in:
Karl Seguin
2025-12-10 15:28:24 +08:00
parent a7e0110acb
commit 27e58181fb
5 changed files with 101 additions and 189 deletions

View File

@@ -1,109 +0,0 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("netsurf.zig");
const Allocator = std.mem.Allocator;
const Renderer = @This();
allocator: Allocator,
// key is a @ptrFromInt of the element
// value is the index position
positions: std.AutoHashMapUnmanaged(u64, u32),
// given an index, get the element
elements: std.ArrayListUnmanaged(u64),
const Element = @import("dom/element.zig").Element;
// we expect allocator to be an arena
pub fn init(allocator: Allocator) Renderer {
return .{
.elements = .{},
.positions = .{},
.allocator = allocator,
};
}
// The DOMRect is always relative to the viewport, not the document the element belongs to.
// Element that are not part of the main document, either detached or in a shadow DOM should not call this function.
pub fn getRect(self: *Renderer, e: *parser.Element) !Element.DOMRect {
var elements = &self.elements;
const gop = try self.positions.getOrPut(self.allocator, @intFromPtr(e));
var x: u32 = gop.value_ptr.*;
if (gop.found_existing == false) {
x = @intCast(elements.items.len);
try elements.append(self.allocator, @intFromPtr(e));
gop.value_ptr.* = x;
}
const _x: f64 = @floatFromInt(x);
const y: f64 = 0.0;
const w: f64 = 1.0;
const h: f64 = 1.0;
return .{
.x = _x,
.y = y,
.width = w,
.height = h,
.left = _x,
.top = y,
.right = _x + w,
.bottom = y + h,
};
}
pub fn boundingRect(self: *const Renderer) Element.DOMRect {
const x: f64 = 0.0;
const y: f64 = 0.0;
const w: f64 = @floatFromInt(self.width());
const h: f64 = @floatFromInt(self.width());
return .{
.x = x,
.y = y,
.width = w,
.height = h,
.left = x,
.top = y,
.right = x + w,
.bottom = y + h,
};
}
pub fn width(self: *const Renderer) u32 {
return @max(@as(u32, @intCast(self.elements.items.len)), 1); // At least 1 pixel even if empty
}
pub fn height(_: *const Renderer) u32 {
return 1;
}
pub fn getElementAtPosition(self: *const Renderer, x: i32, y: i32) ?*parser.Element {
if (y != 0 or x < 0) {
return null;
}
const elements = self.elements.items;
return if (x < elements.len) @ptrFromInt(elements[@intCast(x)]) else null;
}

View File

@@ -123,14 +123,24 @@ pub fn getRemoteObject(
}
// Gets a value by object ID regardless of which context it is in.
pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []const u8) !?*anyopaque {
// Our TaggedAnyOpaque stores the "resolved" ptr value (the most specific _type,
// e.g. we store the ptr to the Div not the EventTarget). But, this is asking for
// the pointer to the Node, so we need to use the same resolution mechanism which
// is used when we're calling a function to turn the Div into a Node, which is
// what Context.typeTaggedAnyOpaque does.
pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []const u8) !*anyopaque {
const unwrapped = try self.session.unwrapObject(allocator, object_id);
// The values context and groupId are not used here
const toa = getTaggedAnyOpaque(unwrapped.value) orelse return null;
if (toa.subtype == null or toa.subtype != .node) {
const js_val = unwrapped.value;
if (js_val.isObject() == false) {
std.debug.print("XX-0\n", .{});
return error.ObjectIdIsNotANode;
}
return toa.value;
const Node = @import("../webapi/Node.zig");
return Context.typeTaggedAnyOpaque(*Node, js_val.castTo(v8.Object)) catch {
std.debug.print("XX-1\n", .{});
return error.ObjectIdIsNotANode;
};
}
const NoopInspector = struct {

View File

@@ -755,9 +755,53 @@ pub fn checkVisibility(self: *Element, page: *Page) !bool {
return true;
}
fn getElementDimensions(self: *Element, page: *Page) !struct { width: f64, height: f64 } {
const style = try self.getStyle(page);
const decl = style.asCSSStyleDeclaration();
var width = CSS.parseDimension(decl.getPropertyValue("width", page)) orelse 5.0;
var height = CSS.parseDimension(decl.getPropertyValue("height", page)) orelse 5.0;
if (width == 5.0 or height == 5.0) {
const tag = self.getTag();
// Root containers get large default size to contain descendant positions.
// With calculateDocumentPosition using 10x multipliers per level, deep trees
// can position elements at y=millions, so we need a large container height.
// 100M pixels is plausible for very long documents.
if (tag == .html or tag == .body) {
if (width == 5.0) width = 1920.0;
if (height == 5.0) height = 100_000_000.0;
} else if (tag == .img or tag == .iframe) {
if (self.getAttributeSafe("width")) |w| {
width = std.fmt.parseFloat(f64, w) catch width;
}
if (self.getAttributeSafe("height")) |h| {
height = std.fmt.parseFloat(f64, h) catch height;
}
}
}
return .{ .width = width, .height = height };
}
pub fn getClientWidth(self: *Element, page: *Page) !f64 {
if (!try self.checkVisibility(page)) {
return 0.0;
}
const dims = try self.getElementDimensions(page);
return dims.width;
}
pub fn getClientHeight(self: *Element, page: *Page) !f64 {
if (!try self.checkVisibility(page)) {
return 0.0;
}
const dims = try self.getElementDimensions(page);
return dims.height;
}
pub fn getBoundingClientRect(self: *Element, page: *Page) !*DOMRect {
const is_visible = try self.checkVisibility(page);
if (!is_visible) {
if (!try self.checkVisibility(page)) {
return page._factory.create(DOMRect{
._x = 0.0,
._y = 0.0,
@@ -771,38 +815,19 @@ pub fn getBoundingClientRect(self: *Element, page: *Page) !*DOMRect {
}
const y = calculateDocumentPosition(self.asNode());
var width: f64 = 1.0;
var height: f64 = 1.0;
const style = try self.getStyle(page);
const decl = style.asCSSStyleDeclaration();
width = CSS.parseDimension(decl.getPropertyValue("width", page)) orelse 1.0;
height = CSS.parseDimension(decl.getPropertyValue("height", page)) orelse 1.0;
if (width == 1.0 or height == 1.0) {
const tag = self.getTag();
if (tag == .img or tag == .iframe) {
if (self.getAttributeSafe("width")) |w| {
width = std.fmt.parseFloat(f64, w) catch width;
}
if (self.getAttributeSafe("height")) |h| {
height = std.fmt.parseFloat(f64, h) catch height;
}
}
}
const dims = try self.getElementDimensions(page);
const x: f64 = 0.0;
const top = y;
const left = x;
const right = x + width;
const bottom = y + height;
const right = x + dims.width;
const bottom = y + dims.height;
return page._factory.create(DOMRect{
._x = x,
._y = y,
._width = width,
._height = height,
._width = dims.width,
._height = dims.height,
._top = top,
._right = right,
._bottom = bottom,
@@ -810,11 +835,19 @@ pub fn getBoundingClientRect(self: *Element, page: *Page) !*DOMRect {
});
}
pub fn getClientRects(self: *Element, page: *Page) ![]DOMRect {
if (!try self.checkVisibility(page)) {
return &.{};
}
const ptr = try self.getBoundingClientRect(page);
return ptr[0..1];
}
// Calculates a pseudo-position in the document using an efficient heuristic.
//
// Instead of walking the entire DOM tree (which would be O(total_nodes)), this
// function walks UP the tree counting previous siblings at each level. Each level
// uses exponential weighting (1000x per depth level) to preserve document order.
// uses exponential weighting (10x per depth level) to preserve document order.
//
// This gives O(depth * avg_siblings) complexity while maintaining relative positioning
// that's useful for scraping and understanding element flow in the document.
@@ -825,15 +858,16 @@ pub fn getBoundingClientRect(self: *Element, page: *Page) !*DOMRect {
// <span></span> → position 0 (0 siblings at level 2)
// <span></span> → position 1 (1 sibling at level 2)
// </div>
// <div> → position 1000 (1 sibling at level 1, weighted by 1000)
// <p></p> → position 1000 (0 siblings at level 2, parent has 1000)
// <div> → position 10 (1 sibling at level 1, weighted by 10)
// <p></p> → position 10 (0 siblings at level 2, parent has 10)
// </div>
// </body>
//
// Trade-offs:
// - Much faster than full tree-walking for deep/large DOMs
// - Positions reflect document order and parent-child relationships
// - Not pixel-accurate, but sufficient for 1x1 layout heuristics
// - Keeps positions within reasonable bounds (10-level deep tree → ~10M pixels)
// - Not pixel-accurate, but sufficient for layout heuristics
fn calculateDocumentPosition(node: *Node) f64 {
var position: f64 = 0.0;
var multiplier: f64 = 1.0;
@@ -849,7 +883,7 @@ fn calculateDocumentPosition(node: *Node) f64 {
}
position += count * multiplier;
multiplier *= 1000.0;
multiplier *= 10.0;
current = parent;
}
@@ -1145,6 +1179,9 @@ pub const JsApi = struct {
pub const getAnimations = bridge.function(Element.getAnimations, .{});
pub const animate = bridge.function(Element.animate, .{});
pub const checkVisibility = bridge.function(Element.checkVisibility, .{});
pub const clientWidth = bridge.accessor(Element.getClientWidth, null, .{});
pub const clientHeight = bridge.accessor(Element.getClientHeight, null, .{});
pub const getClientRects = bridge.function(Element.getClientRects, .{});
pub const getBoundingClientRect = bridge.function(Element.getBoundingClientRect, .{});
pub const getElementsByTagName = bridge.function(Element.getElementsByTagName, .{});
pub const getElementsByClassName = bridge.function(Element.getElementsByClassName, .{});

View File

@@ -142,7 +142,7 @@ fn calculateIntersection(
) !IntersectionData {
const target_rect = try target.getBoundingClientRect(page);
// Use root element's rect or viewport (simplified: assume infinite viewport)
// Use root element's rect or viewport (simplified: assume 1920x1080)
const root_rect = if (self._root) |root|
try root.getBoundingClientRect(page)
else
@@ -158,46 +158,19 @@ fn calculateIntersection(
._left = 0.0,
});
// Calculate intersection rectangle
const left = @max(target_rect._left, root_rect._left);
const top = @max(target_rect._top, root_rect._top);
const right = @min(target_rect._right, root_rect._right);
const bottom = @min(target_rect._bottom, root_rect._bottom);
// For a headless browser without real layout, we treat all elements as fully visible.
// This avoids fingerprinting issues (massive viewports) and matches the behavior
// scripts expect when querying element visibility.
const is_intersecting = true;
const intersection_ratio: f64 = 1.0;
const is_intersecting = left < right and top < bottom;
var intersection_rect: ?*DOMRect = null;
var intersection_ratio: f64 = 0.0;
if (is_intersecting) {
const width = right - left;
const height = bottom - top;
const intersection_area = width * height;
const target_area = target_rect._width * target_rect._height;
if (target_area > 0) {
intersection_ratio = intersection_area / target_area;
}
intersection_rect = try page._factory.create(DOMRect{
._x = left,
._y = top,
._width = width,
._height = height,
._top = top,
._right = right,
._bottom = bottom,
._left = left,
});
} else {
// No intersection - reuse shared zero rect to avoid allocation
intersection_rect = &zero_rect;
}
// Intersection rect is the same as the target rect (fully visible)
const intersection_rect = target_rect;
return .{
.is_intersecting = is_intersecting,
.intersection_ratio = intersection_ratio,
.intersection_rect = intersection_rect.?,
.intersection_rect = intersection_rect,
.bounding_client_rect = target_rect,
.root_bounds = root_rect,
};

View File

@@ -429,12 +429,13 @@ fn getBoxModel(cmd: anytype) !void {
const rect = try element.getBoundingClientRect(page);
const quad = rectToQuad(rect);
const zero = [_]f64{0.0} ** 8;
return cmd.sendResult(.{ .model = BoxModel{
.content = quad,
.padding = quad,
.border = quad,
.margin = quad,
.padding = zero,
.border = zero,
.margin = zero,
.width = @intFromFloat(rect._width),
.height = @intFromFloat(rect._height),
} }, .{});
@@ -649,11 +650,11 @@ test "cdp.dom: getBoxModel" {
.params = .{ .nodeId = 6 },
});
try ctx.expectSentResult(.{ .model = BoxModel{
.content = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 },
.padding = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 },
.border = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 },
.margin = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 },
.width = 1,
.height = 1,
.content = Quad{ 0.0, 0.0, 5.0, 0.0, 5.0, 5.0, 0.0, 5.0 },
.padding = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
.border = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
.margin = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
.width = 5,
.height = 5,
} }, .{ .id = 5 });
}