diff --git a/moli-html2md/README.md b/moli-html2md/README.md index 60d4613281..408015cc6f 100644 --- a/moli-html2md/README.md +++ b/moli-html2md/README.md @@ -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. diff --git a/moli-renderer-v8/src/runtime/page_dump/visibility.rs b/moli-renderer-v8/src/runtime/page_dump/visibility.rs index 07edee214e..bedaab74d9 100644 --- a/moli-renderer-v8/src/runtime/page_dump/visibility.rs +++ b/moli-renderer-v8/src/runtime/page_dump/visibility.rs @@ -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, diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/markdown_dump.rs b/moli-renderer-v8/src/runtime/page_vm/tests/markdown_dump.rs index d0cb546c0d..c6bb4946d8 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/markdown_dump.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/markdown_dump.rs @@ -482,6 +482,12 @@ async fn markdown_live_structure_and_visibility_have_exact_outputs() { ("
NameStatusOwner
Aprivatealice
", "| Name | Status | Owner |\n| --- | --- | --- |\n| A | | alice |"), ("DocsA", "[Docs](https://example.test/docs)![A](https://example.test/a.png)"), ("
Cookie preferences
Hidden menu

Article

", "Article"), + ("", "- [A](https://example.test/a)\n- [B](https://example.test/b)"), + ("
PlanPrice
Loading

After

", "After"), + ("
AB
SecretHidden
XY
", "| A | B |\n| --- | --- |\n| X | Y |"), + (" ", "Volume"), + ("", "30 #ff0000"), + ("
H
ab
", "\n\n\n\n\n\n\n\n\n\n\n\n\n
H
ab
"), ] { 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 = `
Visible
Hidden
`; 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; +}