From 7985fcb7bca5f2db818eef2a07005d9bc0a59b35 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 17 Sep 2026 21:07:59 +0800 Subject: [PATCH] fix(fetch): preserve details and discard empty SVG placeholders --- moli-html2md/src/machine.rs | 22 +++++++++++++++++- moli-html2md/tests/regressions.rs | 15 +++++++++++++ moli-renderer-v8/src/runtime/page_dump.rs | 2 -- moli/src/fetch_dump/tests.rs | 27 +++++++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/moli-html2md/src/machine.rs b/moli-html2md/src/machine.rs index d6afabd4c..e79dd48c3 100644 --- a/moli-html2md/src/machine.rs +++ b/moli-html2md/src/machine.rs @@ -234,7 +234,20 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> { .attribute(node, "src") .filter(|src| !src.is_empty()) { - self.writer().image(alt, src, title); + // Lazy-image placeholders carry no visible image. Keep + // accessible alternate text without emitting a data URI. + if is_empty_svg_placeholder(src) { + if !alt.is_empty() + && !self + .dom + .attribute(node, "aria-hidden") + .is_some_and(|value| value.trim().eq_ignore_ascii_case("true")) + { + self.writer().text(alt); + } + } else { + self.writer().image(alt, src, title); + } } return; } @@ -428,6 +441,13 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> { } } +fn is_empty_svg_placeholder(src: &str) -> bool { + let lower = src.to_ascii_lowercase(); + lower.starts_with("data:image/svg+xml,%3csvg") + && lower.ends_with("%3c/svg%3e") + && lower.matches("%3c").count() == 2 +} + fn class_language(class: Option<&str>) -> Option<&str> { class? .split_ascii_whitespace() diff --git a/moli-html2md/tests/regressions.rs b/moli-html2md/tests/regressions.rs index 1eae5b299..1959ed734 100644 --- a/moli-html2md/tests/regressions.rs +++ b/moli-html2md/tests/regressions.rs @@ -225,6 +225,21 @@ fn images_without_sources_do_not_emit_placeholder_markdown() { } } +#[test] +fn empty_svg_lazy_image_placeholders_do_not_emit_data_uris() { + let placeholder = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20720%20960'%3E%3C/svg%3E"; + let html = format!("beforeRecipe photoafter"); + assert_eq!(markdown(&html, false), "beforeRecipe photoafter"); + + let html = + format!("beforeafter"); + assert_eq!(markdown(&html, false), "beforeafter"); + + let real_svg = "data:image/svg+xml,%3Csvg%3E%3Cpath%20d='M0%200'/%3E%3C/svg%3E"; + let html = format!("Logo"); + assert!(markdown(&html, false).contains(real_svg)); +} + #[test] fn list_paragraphs_and_code_blocks_require_blank_lines_between_items() { for (html, expected) in [ diff --git a/moli-renderer-v8/src/runtime/page_dump.rs b/moli-renderer-v8/src/runtime/page_dump.rs index 5cf4cf192..ee2f639de 100644 --- a/moli-renderer-v8/src/runtime/page_dump.rs +++ b/moli-renderer-v8/src/runtime/page_dump.rs @@ -157,8 +157,6 @@ fn apply_strip_options(dom: &mut NativeDom, strip: RendererPageDumpStripOptions) | "dialog" | "menu" | "menuitem" - | "details" - | "summary" )); if should_remove { remove.push(node_id); diff --git a/moli/src/fetch_dump/tests.rs b/moli/src/fetch_dump/tests.rs index 78f03f86f..84e3229a2 100644 --- a/moli/src/fetch_dump/tests.rs +++ b/moli/src/fetch_dump/tests.rs @@ -129,6 +129,33 @@ async fn render_page_dump_default_html_uses_renderer_live_serialize() -> Result< Ok(()) } +#[tokio::test] +async fn strip_ui_keeps_content_inside_details() -> Result<()> { + let (_browser, mut page, http_server) = load_page( + "
Product details

Critical product description

", + ) + .await?; + let rendered = render_page_dump_with_options_async( + &mut page, + DumpFormat::Markdown, + StripOptions { + js: false, + css: false, + ui: true, + }, + false, + false, + false, + None, + ) + .await?; + assert!(!rendered.contains("Menu")); + assert!(rendered.contains("Product details")); + assert!(rendered.contains("Critical product description")); + http_server.abort(); + Ok(()) +} + #[tokio::test] async fn render_page_dump_postprocessed_html_uses_renderer_live_dump() -> Result<()> { let (_browser, mut page, http_server) = load_page(