fix(renderer): omit empty hidden structures and synthetic control defaults

This commit is contained in:
lanyue-llk
2026-11-03 17:07:17 +08:00
committed by KEVIN LIANG
parent 0353303786
commit 164e8aaec9
3 changed files with 74 additions and 30 deletions
+24 -21
View File
@@ -19,7 +19,8 @@ let markdown = moli_html2md::Converter::new(moli_html2md::Options {
## DOM interface
Implement `Dom` with a copyable node ID and four required borrowed queries and four optional hooks:
Implement `Dom` with a copyable node ID, four required borrowed queries and four
optional hooks:
- `node_kind`: document/fragment, lowercase element tag, decoded text, or ignored node.
- `first_child` and `next_sibling`: ordered traversal of a stable, acyclic tree.
@@ -107,15 +108,25 @@ default is `false`, which collapses ordinary HTML whitespace and moves boundary
spaces outside code delimiters. Fenced blocks always preserve nonempty code's
spaces and blank lines. Empty blocks still separate surrounding text.
## Content policy
This is a structural content dump. It omits non-rendered serialized state blobs
but retains content in collapsed panels and inactive tabs. It does not evaluate
stylesheets, layout, or JavaScript. Callers may supply computed block boundaries
through the optional boundary hooks. Moli's browser exporter also uses current CSS
visibility, retaining disclosure panels and collapsed lines in visible source
blocks while excluding hidden UI templates. The converter cannot preserve all
HTML presentation (for example, arbitrary ordered-list numbering).
DOM snapshots used by Moli's optional
strip/base/frame transformations remain the responsibility of the caller.
through the optional boundary hooks.
Moli's browser export uses current CSS visibility. It retains explicit disclosure
targets, content tabpanels, searchable hidden content and collapsed lines inside
visible preformatted source. Hidden navigation, menus, dialogs and popovers do
not become disclosure content. An inherited root visibility veil does not discard
the document; explicitly hidden descendants remain excluded. Hidden table cells
retain their positions, while wholly invisible tables and collapsed rows are
omitted. Computed boxes supplement document structure without changing a tight
list into a loose one merely because each item contains a single block box.
The converter cannot preserve all HTML presentation (for example, arbitrary
ordered-list numbering). DOM snapshots used by Moli's optional strip/base/frame
transformations remain the responsibility of the caller.
## Verification
@@ -150,12 +161,12 @@ file input values are omitted.
Plain DOM attributes describe the supplied snapshot, including default form
values and checked state. Browser adapters can expose live attributes and
`control_value` instead. Placeholder hints are not field values.
Empty text-entry controls emit no value; button-like
controls may use accessible labels; generic containers do not acquire text
from tooltip titles or prohibited names. Child output takes precedence over
any fallback label. Links retain application protocols while executable
JavaScript and inline HTML document URLs are excluded.
`control_value` instead. Placeholder hints are not field values. Empty
text-entry controls emit no value; button-like controls may use accessible
labels. Generic containers do not acquire text from tooltip titles or prohibited
names. Child output takes precedence over any fallback label. Links retain
application protocols while executable JavaScript and inline HTML document URLs
are excluded.
SVG title children are tooltip metadata, not body text. They supply a name only
when an SVG has no emitted content; title elements outside SVG are omitted.
@@ -179,11 +190,3 @@ positions. Its language marker and physical newlines are preserved; a standalone
cell boundaries without blank lines. Plain heading wrappers remain Markdown
headings. Captured headings, list items and quotations contain their own fragment
targets rather than appending them to the preceding block.
Moli's browser export retains explicit disclosure targets, content tabpanels,
searchable hidden content and collapsed lines inside visible preformatted source.
Hidden navigation, menus, dialogs and popovers do not become disclosure content.
A root visibility veil can be inherited without discarding the document; explicit
hidden descendants remain excluded. Hidden table cells retain their positions.
Computed boundaries supplement ordinary document structure; they do not turn
native lists into loose lists or inline form controls into separate paragraphs.
@@ -116,6 +116,7 @@ impl<'a> MarkdownDom<'a> {
let mut unchecked_controls = HashSet::new();
let mut visibility_modes = HashMap::new();
let mut source_regions = HashSet::new();
let node_order: Vec<_> = styles.iter().map(|(node, _)| *node).collect();
for (node, values) in styles {
debug_assert!(
dom.parent_node(node).is_none_or(|parent| {
@@ -138,7 +139,10 @@ impl<'a> MarkdownDom<'a> {
// A clean textarea gets its default from child text, not the
// control-state slot (which is initially empty). Once dirty,
// even an empty live value is authoritative.
if element.is_html_input() || element.input_value_dirty() {
let synthetic_default = Dom::attribute(dom, node, "type").is_some_and(|kind| {
kind.eq_ignore_ascii_case("range") || kind.eq_ignore_ascii_case("color")
}) && Dom::attribute(dom, node, "value").is_none();
if (element.is_html_input() && !synthetic_default) || element.input_value_dirty() {
attribute_overrides
.entry(node)
.or_insert_with(HashMap::new)
@@ -192,7 +196,13 @@ impl<'a> MarkdownDom<'a> {
parent_visibility
};
visibility_modes.insert(node, visibility_mode);
if (values.display == "none" && !in_source && !disclosure_root && !disclosure_path)
let collapsed_table_part = values.visibility == "collapse"
&& matches!(
Dom::node_kind(dom, node),
NodeKind::Element("tr" | "thead" | "tbody" | "tfoot" | "col" | "colgroup")
);
if collapsed_table_part
|| (values.display == "none" && !in_source && !disclosure_root && !disclosure_path)
|| (parent_is_disclosure_path && !disclosure_root && !disclosure_path)
|| dom
.parent_node(node)
@@ -210,13 +220,7 @@ impl<'a> MarkdownDom<'a> {
}
let display = values.display.as_str();
if !matches!(display, "" | "none" | "contents" | "inline" | "inline flow") {
let native_control = matches!(
Dom::node_kind(dom, node),
NodeKind::Element("input" | "textarea" | "select" | "button")
);
if native_control && display == "inline-block" {
// Their inline control semantics already separate readable values.
} else if display.starts_with("inline") {
if display.starts_with("inline") {
inline_boundaries.insert(node);
} else {
blocks.insert(node);
@@ -259,6 +263,24 @@ impl<'a> MarkdownDom<'a> {
invisible.insert(node);
}
}
// Invisible cells still occupy columns. A wholly invisible table/list
// has no structure to export, unless a descendant restores visibility.
let mut visible_subtrees = HashSet::new();
for node in node_order.into_iter().rev() {
if suppressed.contains(&node) {
continue;
}
if !invisible.contains(&node) || visible_subtrees.contains(&node) {
if let Some(parent) = dom.parent_node(node) {
visible_subtrees.insert(parent);
}
} else if matches!(
Dom::node_kind(dom, node),
NodeKind::Element("table" | "ul" | "ol" | "dl")
) {
suppressed.insert(node);
}
}
Self {
dom,
suppressed,
@@ -482,6 +482,12 @@ async fn markdown_live_structure_and_visibility_have_exact_outputs() {
("<table><tr><th>Name</th><th>Status</th><th>Owner</th></tr><tr><td>A</td><td style='visibility:hidden'>private</td><td>alice</td></tr></table>", "| Name | Status | Owner |\n| --- | --- | --- |\n| A | | alice |"),
("<a href='../docs'>Docs</a><img src='../a.png' alt='A'>", "[Docs](https://example.test/docs)![A](https://example.test/a.png)"),
("<dialog><section role=tabpanel>Cookie preferences</section></dialog><div popover><section role=tabpanel>Hidden menu</section></div><p>Article</p>", "Article"),
("<ul><li><a href='/a' style='display:block'>A</a></li><li><a href='/b' style='display:block'>B</a></li></ul>", "- [A](https://example.test/a)\n- [B](https://example.test/b)"),
("<table style='visibility:hidden'><tr><th>Plan</th><th>Price</th></tr><tr><td colspan=2>Loading</td></tr></table><p>After</p>", "After"),
("<table><tr><th>A</th><th>B</th></tr><tr style='visibility:collapse'><td>Secret</td><td>Hidden</td></tr><tr><td>X</td><td>Y</td></tr></table>", "| A | B |\n| --- | --- |\n| X | Y |"),
("<label>Volume <input type=range></label> <input type=color>", "Volume"),
("<input type=range value=30><input type=color value='#ff0000'>", "30 #ff0000"),
("<table><colgroup><col><col></colgroup><tr><th colspan=2>H</th></tr><tr><td>a</td><td>b</td></tr></table>", "<table>\n<colgroup>\n<col>\n<col></colgroup>\n<tbody>\n<tr>\n<th colspan=\"2\">H</th>\n</tr>\n<tr>\n<td>a</td>\n<td>b</td>\n</tr>\n</tbody>\n</table>"),
] {
page.vm_mut().eval(&format!("document.body.innerHTML={}", serde_json::to_string(source).unwrap())).unwrap();
for strip_css in [false, true] {
@@ -490,3 +496,16 @@ async fn markdown_live_structure_and_visibility_have_exact_outputs() {
}
}).await;
}
#[tokio::test(flavor = "current_thread")]
async fn markdown_hidden_structure_keeps_restored_descendants_and_changed_controls() {
run_page_vm_async_test(async move {
let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let mut page = test_page_vm_with_loader_and_document_url(&loader, Vec::new(), Url::parse("https://example.test/").unwrap());
page.vm_mut().eval("document.body.innerHTML = `<table style='visibility:hidden'><tr><th style='visibility:visible'>Visible</th></tr><tr><td colspan=2>Hidden</td></tr></table><input id=r type=range><input id=c type=color>`; document.querySelector('#r').value='25'; document.querySelector('#c').value='#00ff00';").unwrap();
let result = page.render_page_dump(options(false));
assert!(result.contains("Visible"), "{result}");
assert!(!result.contains("Hidden"), "{result}");
assert!(result.contains("25 #00ff00"), "{result}");
}).await;
}