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