chore: Extract common Markdown/Screenshot logic

https://github.com/lightpanda-io/browser/pull/3333 improved markdown rendering
for flexbox items. But screenshot generation didn't benefit from that change.
This commit introduces a RenderTree that extracts and shares functionality
between markdown and screenshots.
This commit is contained in:
Karl Seguin committed 2026-09-01 16:37:32 +08:00
1 parent 1c12eb73a8
commit c564e4e2db
3 files changed
+402 -245

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
// Copyright (C) 2023-2026 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 Frame = @import("Frame.zig");
const StyleManager = @import("StyleManager.zig");
const Node = @import("webapi/Node.zig");
const Element = @import("webapi/Element.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const Slot = @import("webapi/element/html/Slot.zig");
const dump_html = @import("dump.zig");
const isAllWhitespace = @import("../string.zig").isAllWhitespace;
pub const Strip = dump_html.Opts.Strip;
const RenderTree = @This();
frame: *Frame,
root: *Node,
strip: Strip = .{},
pub const Child = struct {
node: *Node,
what: union(enum) {
element: StyleManager.Display,
text: []const u8,
},
// flex/grid items are separate boxes however inline their tags
// are, and the whitespace between them doesn't render.
separated: bool,
};
/// The rendering children of one parent, in tree order.
pub const Children = struct {
boxed: bool,
yielded: bool = false,
next_node: ?*Node,
tree: *const RenderTree,
pub fn next(self: *Children) ?Child {
while (self.next_node) |node| {
self.next_node = node.nextSibling();
var child = self.tree.classify(node, .{ .boxed = self.boxed }) orelse continue;
child.separated = self.boxed and self.yielded;
self.yielded = true;
return child;
}
return null;
}
};
/// A <slot>'s assigned light-DOM nodes, or its own children as fallback.
pub const Slotted = struct {
tree: *const RenderTree,
assigned: []const *Node,
fallback: Children,
pub fn next(self: *Slotted) ?Child {
while (self.assigned.len > 0) {
const node = self.assigned[0];
self.assigned = self.assigned[1..];
return self.tree.classify(node, .{ .slotted = true }) orelse continue;
}
return self.fallback.next();
}
};
pub fn children(self: *const RenderTree, parent: *Node, boxed: bool) Children {
return .{ .tree = self, .next_node = parent.firstChild(), .boxed = boxed };
}
/// An element's content in the composed tree: a shadow host renders its
/// shadow tree in place of its light-DOM children (those are visible only
/// through <slot>). Applies to open and closed roots alike.
pub fn content(self: *const RenderTree, el: *Element, boxed: bool) Children {
const parent = if (el.hostedShadowRoot(self.frame)) |shadow| shadow.asNode() else el.asNode();
return self.children(parent, boxed);
}
pub fn slotted(self: *const RenderTree, slot: *Slot) Slotted {
const assigned = slot.assignedNodes(null, self.frame) catch &.{};
return .{
.tree = self,
.assigned = assigned,
// Only consulted when nothing is assigned.
.fallback = self.children(slot.asNode(), false),
};
}
pub const ClassifyOpts = struct {
boxed: bool = false,
// Reached through a <slot>'s assignment: the element's own `slot`
// attribute then no longer excludes it.
slotted: bool = false,
};
/// How `node` renders, or null when it doesn't.
pub fn classify(self: *const RenderTree, node: *Node, opts: ClassifyOpts) ?Child {
if (node.is(Element)) |el| {
const d = self.display(el, opts.slotted) orelse return null;
return .{ .node = node, .what = .{ .element = d }, .separated = false };
}
const text_node = node.is(Node.CData.Text) orelse return null;
var text = text_node.ownData();
if (opts.boxed) {
text = std.mem.trim(u8, text, &std.ascii.whitespace);
if (text.len == 0) return null;
} else if (node.nextSibling() == null) {
// The newline before </pre> isn't content.
if (node.parentNode()) |parent| {
if (parent.is(Element)) |parent_el| {
if (parent_el.getTag() == .pre) {
text = std.mem.trimEnd(u8, text, " \t\r\n");
}
}
}
}
return .{ .node = node, .what = .{ .text = text }, .separated = false };
}
fn display(self: *const RenderTree, el: *Element, is_slotted: bool) ?StyleManager.Display {
const d = visibleDisplay(el, self.frame) orelse {
if (el.asNode() != self.root) return null;
return .other;
};
if (dump_html.shouldStripElement(el, self.strip, self.frame)) return null;
if (!is_slotted and el.getAttributeSafe(comptime .wrap("slot")) != null) return null;
return d;
}
/// The element's own display when it renders; null when it doesn't. Own
/// state only: ancestors are handled by not descending into them.
fn visibleDisplay(el: *Element, frame: *Frame) ?StyleManager.Display {
const tag = el.getTag();
if (tag.isMetadata() or tag == .svg) {
return null;
}
const d = frame._style_manager.display(el, .scan);
if (d == .none) {
return null;
}
if (el.getAttributeSafe(comptime .wrap("aria-hidden"))) |v| {
if (std.ascii.eqlIgnoreCase(v, "true")) return null;
}
return d;
}
fn isVisibleElement(el: *Element, frame: *Frame) bool {
return visibleDisplay(el, frame) != null;
}
fn isSignificantText(node: *Node) bool {
const text = node.is(Node.CData.Text) orelse return false;
return !isAllWhitespace(text.ownData());
}
fn isLayoutBlock(tag: Element.Tag) bool {
return switch (tag) {
.main, .section, .article, .nav, .aside, .header, .footer, .div, .ul, .ol => true,
else => false,
};
}
/// An anchor sitting among element-only siblings of a layout block (nav
/// bars, post lists) reads as its own line rather than inline text.
pub fn isStandaloneAnchor(el: *Element, frame: *Frame) bool {
const node = el.asNode();
const parent = node.parentNode() orelse return false;
const parent_el = parent.is(Element) orelse return false;
if (!isLayoutBlock(parent_el.getTag())) {
return false;
}
var prev = node.previousSibling();
while (prev) |p| : (prev = p.previousSibling()) {
if (isSignificantText(p)) {
return false;
}
if (p.is(Element)) |pe| {
if (isVisibleElement(pe, frame)) {
break;
}
}
}
var next = node.nextSibling();
while (next) |n| : (next = n.nextSibling()) {
if (isSignificantText(n)) {
return false;
}
if (n.is(Element)) |ne| {
if (isVisibleElement(ne, frame)) {
break;
}
}
}
return true;
}
pub const ContentInfo = struct {
has_visible: bool,
has_block: bool,
};
pub fn analyzeContent(root: *Node, frame: *Frame) ContentInfo {
var result: ContentInfo = .{ .has_visible = false, .has_block = false };
var tw = TreeWalker.FullExcludeSelf.init(root, .{});
while (tw.next()) |node| {
if (isSignificantText(node)) {
result.has_visible = true;
if (result.has_block) {
return result;
}
} else if (node.is(Element)) |el| {
if (!isVisibleElement(el, frame)) {
tw.skipChildren();
} else {
const tag = el.getTag();
if (tag == .img) {
result.has_visible = true;
if (result.has_block) {
return result;
}
}
if (tag.isBlock()) {
result.has_block = true;
if (result.has_visible) {
return result;
}
}
}
}
}
return result;
}
+42 -179
View File
@@ -19,18 +19,17 @@
const std = @import("std");
const Frame = @import("Frame.zig");
const RenderTree = @import("RenderTree.zig");
const StyleManager = @import("StyleManager.zig");
const URL = @import("URL.zig");
const Node = @import("webapi/Node.zig");
const Element = @import("webapi/Element.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const Slot = @import("webapi/element/html/Slot.zig");
const isAllWhitespace = @import("../string.zig").isAllWhitespace;
const LimitedWriter = @import("../LimitedWriter.zig");
const dump_html = @import("dump.zig");
const Strip = dump_html.Opts.Strip;
const Strip = RenderTree.Strip;
pub const Opts = struct {
max_bytes: ?u32 = null,
@@ -62,107 +61,15 @@ fn shouldAddSpacing(tag: Element.Tag) bool {
};
}
fn isLayoutBlock(tag: Element.Tag) bool {
return switch (tag) {
.main, .section, .article, .nav, .aside, .header, .footer, .div, .ul, .ol => true,
else => false,
};
}
pub fn isStandaloneAnchor(el: *Element, frame: *Frame) bool {
const node = el.asNode();
const parent = node.parentNode() orelse return false;
const parent_el = parent.is(Element) orelse return false;
if (!isLayoutBlock(parent_el.getTag())) return false;
var prev = node.previousSibling();
while (prev) |p| : (prev = p.previousSibling()) {
if (isSignificantText(p)) return false;
if (p.is(Element)) |pe| {
if (isVisibleElement(pe, frame)) break;
}
}
var next = node.nextSibling();
while (next) |n| : (next = n.nextSibling()) {
if (isSignificantText(n)) return false;
if (n.is(Element)) |ne| {
if (isVisibleElement(ne, frame)) break;
}
}
return true;
}
fn isSignificantText(node: *Node) bool {
const text = node.is(Node.CData.Text) orelse return false;
return !isAllWhitespace(text.ownData());
}
// Own state only; the dump root is exempt so a scoped dump of a hidden
// subtree still renders it.
fn isVisibleElement(el: *Element, frame: *Frame) bool {
return visibleDisplay(el, frame) != null;
}
/// Null when the element doesn't render.
fn visibleDisplay(el: *Element, frame: *Frame) ?StyleManager.Display {
const tag = el.getTag();
if (tag.isMetadata() or tag == .svg) return null;
const display = frame._style_manager.display(el, .scan);
if (display == .none) return null;
if (el.getAttributeSafe(comptime .wrap("aria-hidden"))) |v| {
if (std.ascii.eqlIgnoreCase(v, "true")) return null;
}
return display;
}
fn getAnchorLabel(el: *Element) ?[]const u8 {
return el.getAttributeSafe(comptime .wrap("aria-label")) orelse el.getAttributeSafe(comptime .wrap("title"));
}
pub const ContentInfo = struct {
has_visible: bool,
has_block: bool,
};
pub fn analyzeContent(root: *Node, frame: *Frame) ContentInfo {
var result: ContentInfo = .{ .has_visible = false, .has_block = false };
var tw = TreeWalker.FullExcludeSelf.init(root, .{});
while (tw.next()) |node| {
if (isSignificantText(node)) {
result.has_visible = true;
if (result.has_block) return result;
} else if (node.is(Element)) |el| {
if (!isVisibleElement(el, frame)) {
tw.skipChildren();
} else {
const tag = el.getTag();
if (tag == .img) {
result.has_visible = true;
if (result.has_block) return result;
}
if (tag.isBlock()) {
result.has_block = true;
if (result.has_visible) return result;
}
}
}
}
return result;
}
const Context = struct {
state: State,
writer: *std.Io.Writer,
frame: *Frame,
root: *Node,
strip: Strip,
// When there's a slot-attribute, we skip rendering, unless this flag has
// bet set to true.
force_slot: bool = false,
tree: RenderTree,
fn ensureNewline(self: *Context) !void {
if (!self.state.last_char_was_newline) {
@@ -171,89 +78,57 @@ const Context = struct {
}
}
fn getRenderDisplay(self: *Context, el: *Element, force_slot: bool) ?StyleManager.Display {
const display = visibleDisplay(el, self.frame) orelse {
if (el.asNode() == self.root) {
return StyleManager.Display.other;
} else {
return null;
}
};
if (dump_html.shouldStripElement(el, self.strip, self.frame)) return null;
if (!force_slot and el.getAttributeSafe(comptime .wrap("slot")) != null) return null;
return display;
}
fn render(self: *Context, node: *Node) error{WriteFailed}!void {
switch (node._type) {
.document, .document_fragment => {
try self.renderChildren(node, false);
},
.element => {
const el = node.subtype(Node.Element);
if (self.getRenderDisplay(el, self.force_slot)) |display| {
try self.renderElement(el, display);
.document, .document_fragment => try self.renderChildren(node, false),
else => {
if (self.tree.classify(node, .{})) |child| {
try self.renderChild(child);
}
},
.cdata => {
if (node.is(Node.CData.Text)) |_| {
var text = node.subtype(Node.CData).getData().str();
if (self.state.pre_node) |pre| {
if (node.parentNode() == pre and node.nextSibling() == null) {
text = std.mem.trimEnd(u8, text, " \t\r\n");
}
}
try self.renderText(text);
}
},
else => {},
}
}
/// `boxed`: flex/grid items are separate boxes however inline their tags
/// are, and the whitespace between them doesn't render.
fn renderChildren(self: *Context, parent: *Node, boxed: bool) error{WriteFailed}!void {
var it = parent.childrenIterator();
var separate = false;
while (it.next()) |child| {
if (!boxed) {
try self.render(child);
continue;
}
if (child.is(Element)) |el| {
const display = self.getRenderDisplay(el, self.force_slot) orelse continue;
if (separate and !el.getTag().isBlock() and !self.state.last_char_was_newline) {
fn renderChild(self: *Context, child: RenderTree.Child) error{WriteFailed}!void {
switch (child.what) {
.element => |display| {
const el = child.node.subtype(Node.Element);
if (child.separated and !el.getTag().isBlock() and !self.state.last_char_was_newline) {
try self.writer.writeByte(' ');
}
try self.renderElement(el, display);
} else if (child.is(Node.CData.Text)) |_| {
const text = std.mem.trim(u8, child.subtype(Node.CData).getData().str(), &std.ascii.whitespace);
if (text.len == 0) continue;
if (separate and !self.state.last_char_was_newline) try self.writer.writeByte(' ');
},
.text => |text| {
if (child.separated and !self.state.last_char_was_newline) {
try self.writer.writeByte(' ');
}
try self.renderText(text);
} else continue;
separate = true;
},
}
}
// Render a <slot>'s assigned light-DOM nodes, or its own children as
// fallback. Same as dump's dumpSlotContent.
fn renderSlotContent(self: *Context, slot: *Slot) !void {
const assigned = slot.assignedNodes(null, self.frame) catch return;
if (assigned.len == 0) {
return self.renderChildren(slot.asNode(), false);
fn renderChildren(self: *Context, parent: *Node, boxed: bool) error{WriteFailed}!void {
var it = self.tree.children(parent, boxed);
while (it.next()) |child| {
try self.renderChild(child);
}
for (assigned) |node| {
// ensures that we don't skip this element when rending it.
self.force_slot = true;
try self.render(node);
}
fn renderContent(self: *Context, el: *Element, boxed: bool) error{WriteFailed}!void {
var it = self.tree.content(el, boxed);
while (it.next()) |child| {
try self.renderChild(child);
}
}
fn renderSlotContent(self: *Context, slot: *Slot) error{WriteFailed}!void {
var it = self.tree.slotted(slot);
while (it.next()) |child| {
try self.renderChild(child);
}
self.force_slot = false;
}
fn renderElement(self: *Context, el: *Element, display: StyleManager.Display) !void {
self.force_slot = false;
const tag = el.getTag();
const boxed = display == .flex or display == .grid;
@@ -370,7 +245,7 @@ const Context = struct {
},
.anchor => {
const frame = self.frame;
const info = analyzeContent(el.asNode(), frame);
const info = RenderTree.analyzeContent(el.asNode(), frame);
const label = getAnchorLabel(el);
const href_raw = el.getAttributeSafe(comptime .wrap("href"));
@@ -379,7 +254,7 @@ const Context = struct {
const href = if (href_raw) |h| URL.resolve(frame.local_arena, frame.base(), h, .{ .encoding = frame.charset }) catch h else null;
if (info.has_block) {
try self.renderChildren(el.asNode(), boxed);
try self.renderContent(el, boxed);
if (href) |h| {
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
try self.writer.writeByte('[');
@@ -392,13 +267,13 @@ const Context = struct {
return;
}
const standalone = isStandaloneAnchor(el, frame);
const standalone = RenderTree.isStandaloneAnchor(el, frame);
if (standalone) {
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
}
try self.writer.writeByte('[');
if (info.has_visible) {
try self.renderChildren(el.asNode(), boxed);
try self.renderContent(el, boxed);
} else {
try self.writer.writeAll(label orelse "");
}
@@ -428,17 +303,7 @@ const Context = struct {
else => {},
}
// Composed tree: a shadow host renders its shadow tree in place of its
// light-DOM children (light DOM is visible only through <slot>). Applies
// to open and closed roots alike. markdown is always a rendered-content
// path (cf. dump.zig's default .rendered mode), so we always pierce; the
// early-return tags above can never be valid shadow hosts, so only this
// generic path needs the check.
if (el.hostedShadowRoot(self.frame)) |shadow| {
try self.renderChildren(shadow.asNode(), boxed);
} else {
try self.renderChildren(el.asNode(), boxed);
}
try self.renderContent(el, boxed);
switch (tag) {
.pre => {
@@ -559,8 +424,7 @@ pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !voi
.state = .{},
.writer = &lw.writer,
.frame = frame,
.root = node,
.strip = opts.strip,
.tree = .{ .frame = frame, .root = node, .strip = opts.strip },
};
ctx.render(node) catch |err| switch (err) {
error.WriteFailed => {
@@ -579,8 +443,7 @@ pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !voi
.state = .{},
.writer = writer,
.frame = frame,
.root = node,
.strip = opts.strip,
.tree = .{ .frame = frame, .root = node, .strip = opts.strip },
};
try ctx.render(node);
if (!ctx.state.last_char_was_newline) {
+106 -66
View File
@@ -25,7 +25,8 @@ const isAllWhitespace = @import("../string.zig").isAllWhitespace;
const URL = @import("URL.zig");
const Frame = @import("Frame.zig");
const Viewport = @import("Viewport.zig");
const markdown = @import("markdown.zig");
const RenderTree = @import("RenderTree.zig");
const StyleManager = @import("StyleManager.zig");
const Node = @import("webapi/Node.zig");
const Element = @import("webapi/Element.zig");
@@ -144,6 +145,7 @@ pub fn collect(arena: Allocator, node: *Node, frame: *Frame) ![]const LpBlock {
var builder: Builder = .{
.frame = frame,
.arena = arena,
.tree = .{ .frame = frame, .root = node },
};
try builder.render(node);
try builder.closeBlock();
@@ -568,6 +570,7 @@ extern "c" fn lp_render_abi(out: *LpAbi) void;
const Builder = struct {
frame: *Frame,
arena: Allocator,
tree: RenderTree,
blocks: std.ArrayList(LpBlock) = .empty,
@@ -610,10 +613,6 @@ const Builder = struct {
// Blocks closed while > 0 get list-like vertical spacing.
tight: u8 = 0,
// When there's a slot-attribute, we skip rendering, unless this flag has
// been set to true.
force_slot: bool = false,
const ListState = struct {
ordered: bool,
index: u32,
@@ -759,40 +758,32 @@ const Builder = struct {
fn render(self: *Builder, node: *Node) Error!void {
switch (node._type) {
.document, .document_fragment => try self.renderChildren(node),
.element => try self.renderElement(node.subtype(Node.Element)),
.cdata => {
if (node.is(Node.CData.Text)) |_| {
var text = node.subtype(Node.CData).getData().str();
if (self.pre_node) |pre| {
if (node.parentNode() == pre and node.nextSibling() == null) {
text = std.mem.trimEnd(u8, text, " \t\r\n");
}
}
try self.renderText(text);
}
},
else => {},
.document, .document_fragment => try self.renderChildren(node, false),
else => if (self.tree.classify(node, .{})) |child| try self.renderChild(child),
}
}
fn renderChildren(self: *Builder, parent: *Node) Error!void {
var it = parent.childrenIterator();
while (it.next()) |child| {
try self.render(child);
fn renderChild(self: *Builder, child: RenderTree.Child) Error!void {
if (child.separated) self.pending_space = true;
switch (child.what) {
.element => |display| try self.renderElement(child.node.subtype(Node.Element), display),
.text => |text| try self.renderText(text),
}
}
fn renderChildren(self: *Builder, parent: *Node, boxed: bool) Error!void {
var it = self.tree.children(parent, boxed);
while (it.next()) |child| try self.renderChild(child);
}
fn renderContent(self: *Builder, el: *Element, boxed: bool) Error!void {
var it = self.tree.content(el, boxed);
while (it.next()) |child| try self.renderChild(child);
}
fn renderSlotContent(self: *Builder, slot: *Slot) Error!void {
const assigned = slot.assignedNodes(null, self.frame) catch return;
if (assigned.len == 0) {
return self.renderChildren(slot.asNode());
}
for (assigned) |node| {
self.force_slot = true;
try self.render(node);
}
self.force_slot = false;
var it = self.tree.slotted(slot);
while (it.next()) |child| try self.renderChild(child);
}
fn renderText(self: *Builder, text: []const u8) Error!void {
@@ -818,18 +809,9 @@ const Builder = struct {
self.pending_space = std.ascii.isWhitespace(text[text.len - 1]);
}
fn renderElement(self: *Builder, el: *Element) Error!void {
const force_slot = self.force_slot;
self.force_slot = false;
fn renderElement(self: *Builder, el: *Element, display: StyleManager.Display) Error!void {
const tag = el.getTag();
if (tag.isMetadata() or tag == .svg) {
return;
}
if (!force_slot and el.getAttributeSafe(comptime .wrap("slot")) != null) {
return;
}
const boxed = display == .flex or display == .grid;
switch (tag) {
.h1, .h2, .h3, .h4, .h5, .h6 => {
@@ -842,14 +824,14 @@ const Builder = struct {
else => 6,
};
try self.openBlock(.heading, level);
try self.renderContent(el);
try self.renderContent(el, boxed);
return self.closeBlock();
},
.pre => {
try self.openBlock(.pre, 0);
const prev = self.pre_node;
self.pre_node = el.asNode();
try self.renderContent(el);
try self.renderContent(el, boxed);
self.pre_node = prev;
return self.closeBlock();
},
@@ -872,7 +854,7 @@ const Builder = struct {
self.list_stack[self.list_depth] = .{ .ordered = tag == .ol, .index = 1 };
self.list_depth += 1;
}
try self.renderContent(el);
try self.renderContent(el, boxed);
try self.closeBlock();
if (pushed) self.list_depth -= 1;
return;
@@ -889,7 +871,7 @@ const Builder = struct {
} else {
self.pending_marker = "";
}
try self.renderContent(el);
try self.renderContent(el, boxed);
try self.closeBlock();
self.pending_marker = "";
if (stray) self.list_depth = 0;
@@ -898,7 +880,7 @@ const Builder = struct {
.blockquote => {
try self.closeBlock();
self.quote_depth +|= 1;
try self.renderContent(el);
try self.renderContent(el, boxed);
try self.closeBlock();
self.quote_depth -= 1;
return;
@@ -925,13 +907,13 @@ const Builder = struct {
.anchor => {
const href = el.getAttributeSafe(comptime .wrap("href"));
const label = el.getAttributeSafe(comptime .wrap("aria-label")) orelse el.getAttributeSafe(comptime .wrap("title"));
const info = markdown.analyzeContent(el.asNode(), self.frame);
const info = RenderTree.analyzeContent(el.asNode(), self.frame);
if (!info.has_visible and label == null) return;
// Same split as markdown: an anchor wrapping blocks, or one
// sitting among element-only siblings (nav bars, post lists),
// gets its own tight block instead of flowing inline.
const standalone = info.has_block or markdown.isStandaloneAnchor(el, self.frame);
const standalone = info.has_block or RenderTree.isStandaloneAnchor(el, self.frame);
if (standalone) {
try self.closeBlock();
self.tight += 1;
@@ -945,7 +927,7 @@ const Builder = struct {
self.href = URL.resolve(self.arena, self.frame.base(), h, .{ .encoding = self.frame.charset }) catch h;
}
if (info.has_visible) {
try self.renderContent(el);
try self.renderContent(el, boxed);
} else {
try self.renderText(label.?);
}
@@ -971,7 +953,7 @@ const Builder = struct {
self.pending_space = true;
}
if (tag == .th) self.bold += 1;
try self.renderContent(el);
try self.renderContent(el, boxed);
if (tag == .th) self.bold -= 1;
self.pending_space = true;
return;
@@ -993,7 +975,7 @@ const Builder = struct {
.code => self.mono += 1,
else => {},
}
try self.renderContent(el);
try self.renderContent(el, boxed);
switch (tag) {
.b, .strong => self.bold -= 1,
.i, .em, .dfn => self.italic -= 1,
@@ -1007,15 +989,6 @@ const Builder = struct {
try self.closeBlock();
}
}
// Composed tree: a shadow host renders its shadow tree in place of its
// light-DOM children (visible only through <slot>).
fn renderContent(self: *Builder, el: *Element) Error!void {
if (el.hostedShadowRoot(self.frame)) |shadow| {
return self.renderChildren(shadow.asNode());
}
return self.renderChildren(el.asNode());
}
};
const testing = @import("../testing.zig");
@@ -1326,7 +1299,7 @@ test "browser.screenshot: block extraction" {
\\<div> </div>
);
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame };
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame, .tree = .{ .frame = frame, .root = div.asNode() } };
try builder.render(div.asNode());
try builder.closeBlock();
@@ -1399,7 +1372,7 @@ test "browser.screenshot: adjacent anchors" {
\\<p><a href="/a">Log In</a><a href="/b">Sign Up</a><b>!</b> see <a href="/c">this</a>.</p>
);
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame };
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame, .tree = .{ .frame = frame, .root = div.asNode() } };
try builder.render(div.asNode());
try builder.closeBlock();
const blocks = builder.blocks.items;
@@ -1421,7 +1394,7 @@ test "browser.screenshot: standalone anchors get their own block" {
\\<p>inline <a href="/x">link</a> here</p>
);
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame };
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame, .tree = .{ .frame = frame, .root = div.asNode() } };
try builder.render(div.asNode());
try builder.closeBlock();
const blocks = builder.blocks.items;
@@ -1447,7 +1420,7 @@ test "browser.screenshot: shadow dom and slots" {
\\<x-host><template shadowrootmode="open"><p>shadow <slot></slot></p></template>light</x-host>
, frame);
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame };
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame, .tree = .{ .frame = frame, .root = div.asNode() } };
try builder.render(div.asNode());
try builder.closeBlock();
const blocks = builder.blocks.items;
@@ -1456,6 +1429,73 @@ test "browser.screenshot: shadow dom and slots" {
try testing.expectEqual("shadow light", blocks[0].spans[0].text[0..blocks[0].spans[0].len]);
}
test "browser.screenshot: hidden elements are skipped" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(),
\\<p>before</p>
\\<p style="display:none">inline</p>
\\<div hidden><p>attribute</p></div>
\\<p aria-hidden="true">aria</p>
\\<span aria-hidden="TRUE">aria caps</span>
\\<p aria-hidden="false">aria false</p>
\\<details><summary>Summary</summary><p>collapsed</p></details><dialog><p>closed dialog</p></dialog><p>after</p>
);
try testing.expectString("before\naria false\nSummary\nafter", try collectLines(div.asNode(), frame));
// The root is exempt, so a shot scoped to a hidden subtree still renders it.
const modal = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, modal.asNode(),
\\<div style="display:none"><p>dialog text</p><p hidden>nested hidden</p></div>
);
try testing.expectString("dialog text", try collectLines(modal.asNode().firstChild().?, frame));
}
test "browser.screenshot: flex and grid items are separated" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(),
\\<a href="/p" style="display:flex">Title<b>Aug 04 2026</b></a>
\\<div style="display:grid"><span>a</span><span>b</span> <span style="display:none">x</span><span>c</span></div>
\\<div style="display:inline-flex"> lead <b>x</b> tail </div>
\\<div style="display:flex"><div>a</div><div>b</div></div>
\\<p>Title<b>date</b></p>
);
try testing.expectString(
\\Title Aug 04 2026
\\a b c
\\lead x tail
\\a
\\b
\\Titledate
, try collectLines(div.asNode(), frame));
}
test "browser.screenshot: flex from a stylesheet" {
var page = try testing.pageTest("markdown_flex.html", .{});
defer page.close();
const frame = page.frame().?;
try testing.expectString("Title Aug 04 2026\nTitledate", try collectLines(frame.window._document.asNode(), frame));
}
// The text of each block on its own line: what renders, not how.
fn collectLines(node: *Node, frame: *Frame) ![]const u8 {
const arena = testing.arena_allocator;
const blocks = try collect(arena, node, frame);
var out: std.ArrayList(u8) = .empty;
for (blocks, 0..) |b, i| {
if (i > 0) try out.append(arena, '\n');
for (b.spans[0..b.spans_len]) |sp| try out.appendSlice(arena, sp.text[0..sp.len]);
}
return out.items;
}
fn testPng(html: []const u8, width: u32) ![]const u8 {
const frame = try testing.createFrame();
frame.url = "http://localhost/";