mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-19 10:59:28 -04:00
markdown: separate flex and grid items in the dump
Children of a `display: flex`/`grid` container are separate boxes however inline their tags are, so `Title<b>Aug 04 2026</b>` inside a flex `<a>` ran together as `Title**Aug 04 2026**`. StyleManager now keeps the display kind (none/flex/grid/other) instead of a display:none bool, and the dumper puts a space between the rendered items of such a container, skipping the whitespace between them like layout does.
This commit is contained in:
1 parent
fa1b9ab03d
commit
81530d8a22
3 files changed
+138
-54
No files matched your search
@@ -459,7 +459,7 @@ fn addRawRule(self: *StyleManager, build_arena: Allocator, selector_text: []cons
|
||||
const name = decl.name;
|
||||
const val = decl.value;
|
||||
if (std.ascii.eqlIgnoreCase(name, "display")) {
|
||||
props.display_none = std.ascii.eqlIgnoreCase(val, "none");
|
||||
props.display = Display.parse(val);
|
||||
} else if (std.ascii.eqlIgnoreCase(name, "visibility")) {
|
||||
props.visibility_hidden = std.ascii.eqlIgnoreCase(val, "hidden") or std.ascii.eqlIgnoreCase(val, "collapse");
|
||||
} else if (std.ascii.eqlIgnoreCase(name, "opacity")) {
|
||||
@@ -611,8 +611,13 @@ pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, opt
|
||||
/// Honors the UA stylesheet rules per HTML Rendering §15.3.1 "Hidden elements"
|
||||
/// via `isElementHidden`.
|
||||
pub fn hasDisplayNone(self: *StyleManager, el: *Element, comptime access: InlineAccess) bool {
|
||||
self.rebuildIfDirty() catch return false;
|
||||
return self.isElementHidden(el, .{}, access);
|
||||
return self.display(el, access) == .none;
|
||||
}
|
||||
|
||||
/// Own property, no ancestor walk; honors the UA hidden-element rules.
|
||||
pub fn display(self: *StyleManager, el: *Element, comptime access: InlineAccess) Display {
|
||||
self.rebuildIfDirty() catch return .other;
|
||||
return self.resolve(el, .{}, access).display orelse .other;
|
||||
}
|
||||
|
||||
/// Computed display:none coming only from inline style or an author stylesheet
|
||||
@@ -676,25 +681,34 @@ pub fn hasVisibilityHiddenInherited(self: *StyleManager, el: *Element) bool {
|
||||
|
||||
/// Check if a single element (not ancestors) is hidden.
|
||||
fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOptions, comptime access: InlineAccess) bool {
|
||||
return self.resolve(el, options, access).hidden();
|
||||
}
|
||||
|
||||
const Resolved = struct {
|
||||
display: ?Display = null,
|
||||
visibility_hidden: ?bool = null,
|
||||
opacity_zero: ?bool = null,
|
||||
|
||||
fn hidden(self: Resolved) bool {
|
||||
return self.display == .none or (self.visibility_hidden orelse false) or (self.opacity_zero orelse false);
|
||||
}
|
||||
};
|
||||
|
||||
/// A hiding inline value returns early, so only `hidden()` is exact then.
|
||||
fn resolve(self: *StyleManager, el: *Element, options: CheckVisibilityOptions, comptime access: InlineAccess) Resolved {
|
||||
// Track best match per property (value + priority)
|
||||
// Initialize priority to INLINE_PRIORITY for properties we don't care about - this makes
|
||||
// the loop naturally skip them since no stylesheet rule can have priority >= INLINE_PRIORITY
|
||||
var display_none: ?bool = null;
|
||||
var r: Resolved = .{};
|
||||
var display_priority: u64 = 0;
|
||||
|
||||
var visibility_hidden: ?bool = null;
|
||||
var visibility_priority: u64 = 0;
|
||||
|
||||
var opacity_zero: ?bool = null;
|
||||
var opacity_priority: u64 = 0;
|
||||
|
||||
// Check inline styles FIRST - they use INLINE_PRIORITY so no stylesheet can beat them
|
||||
if (options.check_display) {
|
||||
if (inlineValue(el, comptime .wrap("display"), access, self.frame)) |value| {
|
||||
if (std.ascii.eqlIgnoreCase(value, "none")) {
|
||||
return true; // Early exit for hiding value
|
||||
}
|
||||
display_none = false;
|
||||
r.display = Display.parse(value);
|
||||
if (r.display == .none) return r;
|
||||
display_priority = INLINE_PRIORITY;
|
||||
}
|
||||
} else {
|
||||
@@ -705,9 +719,10 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
|
||||
if (options.check_visibility) {
|
||||
if (inlineValue(el, comptime .wrap("visibility"), access, self.frame)) |value| {
|
||||
if (std.ascii.eqlIgnoreCase(value, "hidden") or std.ascii.eqlIgnoreCase(value, "collapse")) {
|
||||
return true;
|
||||
r.visibility_hidden = true;
|
||||
return r;
|
||||
}
|
||||
visibility_hidden = false;
|
||||
r.visibility_hidden = false;
|
||||
visibility_priority = INLINE_PRIORITY;
|
||||
}
|
||||
} else {
|
||||
@@ -720,9 +735,10 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
|
||||
if (options.check_opacity) {
|
||||
if (inlineValue(el, comptime .wrap("opacity"), access, self.frame)) |value| {
|
||||
if (std.ascii.eqlIgnoreCase(value, "0")) {
|
||||
return true;
|
||||
r.opacity_zero = true;
|
||||
return r;
|
||||
}
|
||||
opacity_zero = false;
|
||||
r.opacity_zero = false;
|
||||
opacity_priority = INLINE_PRIORITY;
|
||||
}
|
||||
} else {
|
||||
@@ -730,16 +746,14 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
|
||||
}
|
||||
|
||||
if (display_priority == INLINE_PRIORITY and visibility_priority == INLINE_PRIORITY and opacity_priority == INLINE_PRIORITY) {
|
||||
return false;
|
||||
return r;
|
||||
}
|
||||
|
||||
// Helper to check a single rule
|
||||
const Ctx = struct {
|
||||
display_none: *?bool,
|
||||
r: *Resolved,
|
||||
display_priority: *u64,
|
||||
visibility_hidden: *?bool,
|
||||
visibility_priority: *u64,
|
||||
opacity_zero: *?bool,
|
||||
opacity_priority: *u64,
|
||||
el: *Element,
|
||||
frame: *Frame,
|
||||
@@ -763,7 +777,7 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
|
||||
}
|
||||
|
||||
// Logic for property dominance
|
||||
const dominated = (props.display_none == null or p <= ctx.display_priority.*) and
|
||||
const dominated = (props.display == null or p <= ctx.display_priority.*) and
|
||||
(props.visibility_hidden == null or p <= ctx.visibility_priority.*) and
|
||||
(props.opacity_zero == null or p <= ctx.opacity_priority.*);
|
||||
|
||||
@@ -771,16 +785,16 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
|
||||
|
||||
if (matchesSelector(ctx.el, selector, ctx.frame)) {
|
||||
// Update best priorities
|
||||
if (props.display_none != null and p > ctx.display_priority.*) {
|
||||
ctx.display_none.* = props.display_none;
|
||||
if (props.display != null and p > ctx.display_priority.*) {
|
||||
ctx.r.display = props.display;
|
||||
ctx.display_priority.* = p;
|
||||
}
|
||||
if (props.visibility_hidden != null and p > ctx.visibility_priority.*) {
|
||||
ctx.visibility_hidden.* = props.visibility_hidden;
|
||||
ctx.r.visibility_hidden = props.visibility_hidden;
|
||||
ctx.visibility_priority.* = p;
|
||||
}
|
||||
if (props.opacity_zero != null and p > ctx.opacity_priority.*) {
|
||||
ctx.opacity_zero.* = props.opacity_zero;
|
||||
ctx.r.opacity_zero = props.opacity_zero;
|
||||
ctx.opacity_priority.* = p;
|
||||
}
|
||||
}
|
||||
@@ -788,11 +802,9 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
|
||||
}
|
||||
};
|
||||
const ctx = Ctx{
|
||||
.display_none = &display_none,
|
||||
.r = &r,
|
||||
.display_priority = &display_priority,
|
||||
.visibility_hidden = &visibility_hidden,
|
||||
.visibility_priority = &visibility_priority,
|
||||
.opacity_zero = &opacity_zero,
|
||||
.opacity_priority = &opacity_priority,
|
||||
.el = el,
|
||||
.frame = self.frame,
|
||||
@@ -826,11 +838,11 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
|
||||
// `<div class="x" hidden>` must report visible.
|
||||
if (options.check_display and options.ua_display_none and display_priority == 0) {
|
||||
if (matchesUaDisplayNoneRule(el)) {
|
||||
display_none = true;
|
||||
r.display = .none;
|
||||
}
|
||||
}
|
||||
|
||||
return (display_none orelse false) or (visibility_hidden orelse false) or (opacity_zero orelse false);
|
||||
return r;
|
||||
}
|
||||
|
||||
/// Check if an element has pointer-events:none.
|
||||
@@ -1048,7 +1060,7 @@ fn extractVisibilityProperties(style: *CSSStyleProperties) VisibilityProperties
|
||||
const decl = style.asCSSStyleDeclaration();
|
||||
|
||||
if (decl.findProperty(comptime .wrap("display"))) |property| {
|
||||
props.display_none = property._value.eqlSliceIgnoreCase("none");
|
||||
props.display = Display.parse(property._value.str());
|
||||
}
|
||||
|
||||
if (decl.findProperty(comptime .wrap("visibility"))) |property| {
|
||||
@@ -1123,15 +1135,30 @@ fn matchesSelector(el: *Element, selector: Selector.Selector, frame: *Frame) boo
|
||||
return SelectorList.matches(node, selector, node, frame);
|
||||
}
|
||||
|
||||
/// Only the values the dumpers act on; everything else is `other`.
|
||||
pub const Display = enum {
|
||||
none,
|
||||
flex,
|
||||
grid,
|
||||
other,
|
||||
|
||||
fn parse(value: []const u8) Display {
|
||||
if (std.ascii.eqlIgnoreCase(value, "none")) return .none;
|
||||
if (std.ascii.eqlIgnoreCase(value, "flex") or std.ascii.eqlIgnoreCase(value, "inline-flex")) return .flex;
|
||||
if (std.ascii.eqlIgnoreCase(value, "grid") or std.ascii.eqlIgnoreCase(value, "inline-grid")) return .grid;
|
||||
return .other;
|
||||
}
|
||||
};
|
||||
|
||||
const VisibilityProperties = struct {
|
||||
display_none: ?bool = null,
|
||||
display: ?Display = null,
|
||||
visibility_hidden: ?bool = null,
|
||||
opacity_zero: ?bool = null,
|
||||
pointer_events_none: ?bool = null,
|
||||
|
||||
// return true if any field in VisibilityProperties is not null
|
||||
fn isRelevant(self: VisibilityProperties) bool {
|
||||
return self.display_none != null or
|
||||
return self.display != null or
|
||||
self.visibility_hidden != null or
|
||||
self.opacity_zero != null or
|
||||
self.pointer_events_none != null;
|
||||
|
||||
+77
-21
@@ -19,6 +19,7 @@
|
||||
const std = @import("std");
|
||||
|
||||
const Frame = @import("Frame.zig");
|
||||
const StyleManager = @import("StyleManager.zig");
|
||||
const URL = @import("URL.zig");
|
||||
|
||||
const Node = @import("webapi/Node.zig");
|
||||
@@ -102,13 +103,19 @@ fn isSignificantText(node: *Node) bool {
|
||||
// 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 false;
|
||||
if (frame._style_manager.hasDisplayNone(el, .scan)) return false;
|
||||
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 false;
|
||||
if (std.ascii.eqlIgnoreCase(v, "true")) return null;
|
||||
}
|
||||
return true;
|
||||
return display;
|
||||
}
|
||||
|
||||
fn getAnchorLabel(el: *Element) ?[]const u8 {
|
||||
@@ -167,7 +174,7 @@ const Context = struct {
|
||||
fn render(self: *Context, node: *Node) error{WriteFailed}!void {
|
||||
switch (node._type) {
|
||||
.document, .document_fragment => {
|
||||
try self.renderChildren(node);
|
||||
try self.renderChildren(node, false);
|
||||
},
|
||||
.element => {
|
||||
try self.renderElement(node.subtype(Node.Element));
|
||||
@@ -187,19 +194,44 @@ const Context = struct {
|
||||
}
|
||||
}
|
||||
|
||||
fn renderChildren(self: *Context, parent: *Node) !void {
|
||||
/// `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| {
|
||||
try self.render(child);
|
||||
if (!boxed) {
|
||||
try self.render(child);
|
||||
continue;
|
||||
}
|
||||
if (child.is(Element)) |el| {
|
||||
if (self.skips(el, self.force_slot)) continue;
|
||||
if (separate and !el.getTag().isBlock() and !self.state.last_char_was_newline) {
|
||||
try self.writer.writeByte(' ');
|
||||
}
|
||||
try self.renderElement(el);
|
||||
} 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(' ');
|
||||
try self.renderText(text);
|
||||
} else continue;
|
||||
separate = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn skips(self: *Context, el: *Element, force_slot: bool) bool {
|
||||
if (el.asNode() != self.root and !isVisibleElement(el, self.frame)) return true;
|
||||
if (dump_html.shouldStripElement(el, self.strip, self.frame)) return true;
|
||||
return !force_slot and el.getAttributeSafe(comptime .wrap("slot")) != null;
|
||||
}
|
||||
|
||||
// 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());
|
||||
return self.renderChildren(slot.asNode(), false);
|
||||
}
|
||||
for (assigned) |node| {
|
||||
// ensures that we don't skip this element when rending it.
|
||||
@@ -215,16 +247,10 @@ const Context = struct {
|
||||
|
||||
const tag = el.getTag();
|
||||
|
||||
if (el.asNode() != self.root and !isVisibleElement(el, self.frame)) return;
|
||||
const display = visibleDisplay(el, self.frame) orelse if (el.asNode() == self.root) StyleManager.Display.other else return;
|
||||
if (dump_html.shouldStripElement(el, self.strip, self.frame)) return;
|
||||
|
||||
if (!force_slot) {
|
||||
if (el.getAttributeSafe(comptime .wrap("slot")) != null) {
|
||||
// This element has a slot attribute, and we aren't forcing slot
|
||||
// rendering (i.e. this is the light-DOM), skip it.
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!force_slot and el.getAttributeSafe(comptime .wrap("slot")) != null) return;
|
||||
const boxed = display == .flex or display == .grid;
|
||||
|
||||
// Ensure block elements start on a new line
|
||||
if (tag.isBlock() and !self.state.in_table) {
|
||||
@@ -348,7 +374,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());
|
||||
try self.renderChildren(el.asNode(), boxed);
|
||||
if (href) |h| {
|
||||
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
|
||||
try self.writer.writeByte('[');
|
||||
@@ -367,7 +393,7 @@ const Context = struct {
|
||||
}
|
||||
try self.writer.writeByte('[');
|
||||
if (info.has_visible) {
|
||||
try self.renderChildren(el.asNode());
|
||||
try self.renderChildren(el.asNode(), boxed);
|
||||
} else {
|
||||
try self.writer.writeAll(label orelse "");
|
||||
}
|
||||
@@ -404,9 +430,9 @@ const Context = struct {
|
||||
// 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());
|
||||
try self.renderChildren(shadow.asNode(), boxed);
|
||||
} else {
|
||||
try self.renderChildren(el.asNode());
|
||||
try self.renderChildren(el.asNode(), boxed);
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
@@ -617,6 +643,36 @@ test "browser.markdown: table" {
|
||||
);
|
||||
}
|
||||
|
||||
test "browser.markdown: flex and grid items are separated" {
|
||||
try testMarkdownHTML(
|
||||
\\<a href="/p" style="display:flex">Title<b>Aug 04 2026</b></a>
|
||||
, "[Title **Aug 04 2026**](http://localhost/p)\n");
|
||||
try testMarkdownHTML(
|
||||
\\<div style="display:grid"><span>a</span><span>b</span> <span style="display:none">x</span><span>c</span></div>
|
||||
, "a b c\n");
|
||||
try testMarkdownHTML(
|
||||
\\<div style="display:inline-flex"> lead <b>x</b> tail </div>
|
||||
, "lead **x** tail\n");
|
||||
try testMarkdownHTML(
|
||||
\\<div style="display:flex"><div>a</div><div>b</div></div>
|
||||
, "a\nb\n");
|
||||
}
|
||||
|
||||
test "browser.markdown: flex from a stylesheet" {
|
||||
var page = try testing.pageTest("markdown_flex.html", .{});
|
||||
defer page.close();
|
||||
const frame = page.frame().?;
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator);
|
||||
try dump(frame.window._document.asNode(), .{}, &aw.writer, frame);
|
||||
try testing.expectString(
|
||||
\\[Title **Aug 04 2026**](http://127.0.0.1:9582/p)
|
||||
\\
|
||||
\\Title**date**
|
||||
\\
|
||||
, aw.written());
|
||||
}
|
||||
|
||||
test "browser.markdown: nested lists" {
|
||||
try testMarkdownHTML(
|
||||
\\<ul><li>Parent<ul><li>Child</li></ul></li></ul>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><html><head><style>.row{display:flex}</style></head><body><a href="/p" class="row">Title<b>Aug 04 2026</b></a><p>Title<b>date</b></p></body></html>
|
||||
Reference in new issue
Block a user