diff --git a/src/browser/RenderTree.zig b/src/browser/RenderTree.zig index 1d1a859ba..b25d51465 100644 --- a/src/browser/RenderTree.zig +++ b/src/browser/RenderTree.zig @@ -17,6 +17,7 @@ // along with this program. If not, see . const std = @import("std"); +const lp = @import("lightpanda"); const Frame = @import("Frame.zig"); const StyleManager = @import("StyleManager.zig"); @@ -28,6 +29,8 @@ const Slot = @import("webapi/element/html/Slot.zig"); const dump_html = @import("dump.zig"); const isAllWhitespace = @import("../string.zig").isAllWhitespace; + +const log = lp.log; pub const Strip = dump_html.Opts.Strip; const RenderTree = @This(); @@ -216,6 +219,104 @@ pub fn isStandaloneAnchor(el: *Element, frame: *Frame) bool { return true; } +/// Shell stripping is undone when it would remove most of the content. Better +/// to leave too much in than to strip too muchout. Non-link text is +/// the measure (nav and footer text is mostly links); a page with none is +/// judged on all of its text. +pub fn resolveStrip(root: *Node, strip: Strip, frame: *Frame) Strip { + if (strip.shell == false) { + return strip; + } + + var render_with_shell = strip; + render_with_shell.shell = false; + + var m: Measure = .{}; + const tree: RenderTree = .{ .frame = frame, .root = root, .strip = render_with_shell }; + tree.measure(root, .{}, &m); + + const total, const shell = if (m.prose > 0) .{ m.prose, m.shell_prose } else .{ m.all, m.shell_all }; + const kept = total - shell; + if (kept * shell_undo_ratio < total) { + log.info(.browser, "strip shell undone", .{ .kept = kept, .total = total }); + return render_with_shell; + } + return strip; +} + +/// Undo when the shell holds more than this share of the text. +const shell_undo_ratio = 4; + +const Measure = struct { + all: usize = 0, + prose: usize = 0, + shell_all: usize = 0, + shell_prose: usize = 0, + + const Where = struct { + shell: bool = false, + link: bool = false, + }; + + fn count(self: *Measure, text: []const u8, where: Where) void { + var n: usize = 0; + for (text) |c| { + if (!std.ascii.isWhitespace(c)) { + n += 1; + } + } + self.all += n; + if (where.shell) { + self.shell_all += n; + } + if (!where.link) { + self.prose += n; + if (where.shell) { + self.shell_prose += n; + } + } + } +}; + +fn measure(self: *const RenderTree, node: *Node, where: Measure.Where, m: *Measure) void { + switch (node._type) { + .document, .document_fragment => { + var it = self.children(node, false); + while (it.next()) |child| { + self.measureChild(child, where, m); + } + }, + else => if (self.classify(node, .{})) |child| { + self.measureChild(child, where, m); + }, + } +} + +fn measureChild(self: *const RenderTree, child: Child, where: Measure.Where, m: *Measure) void { + switch (child.what) { + .text => |text| m.count(text, where), + .element => |d| { + const el = child.node.subtype(Element); + const inner: Measure.Where = .{ + .shell = where.shell or dump_html.isShellElement(el), + .link = where.link or el.getTag() == .anchor, + }; + if (el.is(Slot)) |slot| { + var it = self.slotted(slot); + while (it.next()) |c| { + self.measureChild(c, inner, m); + } + return; + } + const boxed = d == .flex or d == .grid; + var it = self.content(el, boxed); + while (it.next()) |c| { + self.measureChild(c, inner, m); + } + }, + } +} + const ContentInfo = struct { has_visible: bool, has_block: bool, @@ -252,3 +353,48 @@ pub fn analyzeContent(root: *Node, frame: *Frame) ContentInfo { } return result; } + +const testing = @import("../testing.zig"); + +test "RenderTree: resolveStrip keeps shell when the content holds the text" { + try testing.expectEqual(true, try resolveShell( + \\

Some article text.

+ )); +} + +test "RenderTree: resolveStrip undoes shell when the shell holds the text" { + try testing.expectEqual(false, try resolveShell( + \\
hi
+ )); +} + +test "RenderTree: resolveStrip judges an all-link page on its links" { + try testing.expectEqual(false, try resolveShell( + \\

x

+ )); + try testing.expectEqual(true, try resolveShell( + \\

a longer list of linksand another

+ )); +} + +test "RenderTree: resolveStrip ignores what other strip bits already drop" { + // The script text is not content; without strip.js it would tip the + // balance toward keeping the shell. + try testing.expectEqual(false, try resolveShell( + \\
hi
+ )); +} + +fn resolveShell(html: []const u8) !bool { + const frame = try testing.createFrame(); + defer testing.test_session.closeAllPages(); + + const doc = frame.window._document; + const div = try doc.createElement("div", null, frame); + try Frame.parse.htmlAsChildren(frame, div.asNode(), html); + + const strip = resolveStrip(div.asNode(), .{ .js = true, .shell = true }, frame); + // Only the shell bit is ever undone. + try testing.expectEqual(true, strip.js); + return strip.shell; +} diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 3f547f1ed..9db838894 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -33,11 +33,12 @@ pub const Opts = struct { /// appended. max_bytes: ?u32 = null, - pub const Strip = packed struct(u4) { + pub const Strip = packed struct(u5) { js: bool = false, ui: bool = false, css: bool = false, invisible: bool = false, + shell: bool = false, }; pub const Shadow = union(enum) { @@ -364,7 +365,7 @@ fn isVoidElement(el: *Node.Element) bool { pub fn shouldStripElement(el: *Node.Element, strip: Opts.Strip, frame: *Frame) bool { // Fast path: with no strip flags set (every innerHTML/outerHTML call) - if (@as(u4, @bitCast(strip)) == 0) { + if (@as(u5, @bitCast(strip)) == 0) { return false; } @@ -412,6 +413,51 @@ pub fn shouldStripElement(el: *Node.Element, strip: Opts.Strip, frame: *Frame) b return true; } + if (strip.shell and isShellElement(el)) { + return true; + } + + return false; +} + +/// Page chrome by markup alone.
and