From c564e4e2dbe511804a247373ade4c92fa63d2249 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 1 Sep 2026 16:37:32 +0800 Subject: [PATCH] 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. --- src/browser/RenderTree.zig | 254 +++++++++++++++++++++++++++++++++++++ src/browser/markdown.zig | 221 ++++++-------------------------- src/browser/screenshot.zig | 172 +++++++++++++++---------- 3 files changed, 402 insertions(+), 245 deletions(-) create mode 100644 src/browser/RenderTree.zig diff --git a/src/browser/RenderTree.zig b/src/browser/RenderTree.zig new file mode 100644 index 000000000..451f318d8 --- /dev/null +++ b/src/browser/RenderTree.zig @@ -0,0 +1,254 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +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 '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 ). 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 '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 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; +} diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 2ec611962..017070b0f 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -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 '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 ). 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) { diff --git a/src/browser/screenshot.zig b/src/browser/screenshot.zig index af0777c55..8ec0fd523 100644 --- a/src/browser/screenshot.zig +++ b/src/browser/screenshot.zig @@ -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 ). - 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" { \\
); - 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" { \\

Log InSign Up! see this.

); - 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" { \\

inline link here

); - 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" { \\light , 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(), + \\

before

+ \\

inline

+ \\ + \\ + \\aria caps + \\

aria false

+ \\
Summary

collapsed

closed dialog

after

+ ); + 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(), + \\

dialog text

+ ); + 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(), + \\TitleAug 04 2026 + \\
ab xc
+ \\
lead x tail
+ \\
a
b
+ \\

Titledate

+ ); + 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/";