mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-14 15:03:27 -04:00
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:
3 files changed
+402
-245
No files matched your search
@@ -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
@@ -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
@@ -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/";
|
||||
|
||||
Reference in new issue
Block a user