Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions moli-html2md/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ another conversion path. Complex layouts remain readable blocks instead of raw
HTML tables.

Supported output includes headings, paragraphs, emphasis, strikethrough, links,
images, lists, blockquotes, hard breaks, and fenced code. Inline HTML is used when
images, lists, blockquotes, hard breaks, media source links, readable form values,
separated form choices, and fenced code. Inline HTML is used when
Markdown delimiters cannot express an emphasis boundary.
Script/style/head/noscript/template subtrees are omitted.

Expand All @@ -99,7 +100,7 @@ strip/base/frame transformations remain the responsibility of the caller.
whitespace, escaping, code, tables, depth limits, and 20,000 nested nodes on a
64 KiB thread stack. The [Turndown differential suite](tests/turndown/README.md)
pins 147 upstream fixtures and 50 additional cases. It compares rendered meaning,
checks DOM immutability, and explicitly asserts 12 documented differences that
checks DOM immutability, and explicitly asserts 13 documented differences that
preserve Moli's handling of literal text, code, emphasis, and list boundaries.
Targeted regressions also cover empty elements, links, attributes in headings and
tables, code whitespace and language names, list numbering, and loose lists.
Expand All @@ -116,3 +117,19 @@ The HTML parser, reference JSON reader, and `pulldown-cmark` renderer are all
**test-only** dependencies. Tests use checked-in reference outputs and require
neither Node nor network access. NativeDom integration and real HTML fixtures
are tested in `moli-renderer-v8`.

Unavailable images retain their text alternatives. Media references use standard
source declarations before lazy-loading fallbacks. Password values and hidden or
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.

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.
102 changes: 102 additions & 0 deletions moli-html2md/src/content.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
use crate::{Dom, NodeKind};
use std::borrow::Cow;

pub(crate) fn fallback_text<D: Dom + ?Sized>(dom: &D, node: D::NodeId) -> Option<String> {
Comment thread
lanyue-llk marked this conversation as resolved.
if excluded_from_naming(dom, node) {
return None;
}
let named_tag = matches!(
dom.node_kind(node),
NodeKind::Element(
"a" | "button"
| "svg"
| "textarea"
| "select"
| "option"
| "optgroup"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
)
);
let named_role =
primary_role(dom, node).is_some_and(|role| role_matches(role, NAMED_WIDGET_ROLES));
if !named_tag && !named_role {
return None;
}
for name in ["aria-label", "title"] {
if let Some(value) = dom
.attribute(node, name)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(value.to_owned());
}
}
if matches!(dom.node_kind(node), NodeKind::Element("svg")) {
let mut child = dom.first_child(node);
while let Some(id) = child {
if dom.node_kind(id) == NodeKind::Element("title") {
let mut text = String::new();
let mut title_child = dom.first_child(id);
while let Some(part) = title_child {
if let NodeKind::Text(value) = dom.node_kind(part) {
text.push_str(value);
}
title_child = dom.next_sibling(part);
}
if !text.trim().is_empty() {
return Some(text);
}
}
child = dom.next_sibling(id);
}
}
None
}

const PRESENTATIONAL_ROLES: &[&str] = &["none", "presentation"];
const NAMED_WIDGET_ROLES: &[&str] = &[
"button", "link", "img", "heading", "checkbox", "radio", "textbox", "combobox", "switch", "tab",
];

// This structural converter uses the first authored token as a conservative
// naming hint; it does not implement an accessibility-tree role resolver.
fn primary_role<D: Dom + ?Sized>(dom: &D, node: D::NodeId) -> Option<&str> {
dom.attribute(node, "role")?.split_ascii_whitespace().next()
}
fn role_matches(role: &str, candidates: &[&str]) -> bool {
candidates
.iter()
.any(|candidate| role.eq_ignore_ascii_case(candidate))
}

fn excluded_from_naming<D: Dom + ?Sized>(dom: &D, node: D::NodeId) -> bool {
dom.attribute(node, "aria-hidden")
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
|| dom.attribute(node, "hidden").is_some()
|| primary_role(dom, node).is_some_and(|role| role_matches(role, PRESENTATIONAL_ROLES))
}

pub(crate) fn image_label<D: Dom + ?Sized>(dom: &D, node: D::NodeId) -> Cow<'_, str> {
let alt = dom.attribute(node, "alt");
if let Some(alt) = alt.filter(|alt| !alt.is_empty()) {
return Cow::Borrowed(alt);
}
if excluded_from_naming(dom, node) {
return Cow::Borrowed("");
}
["aria-label", "title"]
.into_iter()
.filter(|name| *name != "title" || alt.is_none())
.find_map(|name| {
dom.attribute(node, name)
.map(str::trim)
.filter(|v| !v.is_empty())
})
.map(Cow::Borrowed)
.unwrap_or(Cow::Borrowed(""))
}
6 changes: 6 additions & 0 deletions moli-html2md/src/dom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,10 @@ pub trait Dom {
fn first_child(&self, node: Self::NodeId) -> Option<Self::NodeId>;
fn next_sibling(&self, node: Self::NodeId) -> Option<Self::NodeId>;
fn attribute(&self, node: Self::NodeId, name: &str) -> Option<&str>;
/// Current input/textarea value supplied by an adapter. None uses the
/// source default; Some("") suppresses that default after a clear. Empty
/// text-entry controls do not emit their name as a field value.
fn control_value(&self, _node: Self::NodeId) -> Option<&str> {
None
}
}
56 changes: 56 additions & 0 deletions moli-html2md/src/form.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
use std::borrow::Cow;

use crate::Dom;

pub(crate) fn input_text<'a, D: Dom + ?Sized>(dom: &'a D, node: D::NodeId) -> Option<Cow<'a, str>> {
let kind = dom.attribute(node, "type").unwrap_or("text");
if kind.eq_ignore_ascii_case("checkbox") || kind.eq_ignore_ascii_case("radio") {
return Some(Cow::Borrowed(if dom.attribute(node, "checked").is_some() {
Comment thread
lanyue-llk marked this conversation as resolved.
"☑"
} else {
"☐"
}));
}
if ["hidden", "file"]
.iter()
.any(|excluded| kind.eq_ignore_ascii_case(excluded))
{
return None;
}
if kind.eq_ignore_ascii_case("image") {
return dom
.attribute(node, "alt")
.filter(|value| !value.is_empty())
.map(Cow::Borrowed);
}
let button = ["submit", "button", "reset"]
.iter()
.any(|v| kind.eq_ignore_ascii_case(v));
let value = (!kind.eq_ignore_ascii_case("password"))
.then(|| {
dom.control_value(node)
.or_else(|| dom.attribute(node, "value"))
})
.flatten()
.filter(|value| !value.is_empty())
.or_else(|| {
button
.then(|| dom.attribute(node, "aria-label"))
.flatten()
.filter(|value| !value.is_empty())
})
.or_else(|| {
button
.then(|| dom.attribute(node, "title"))
.flatten()
.filter(|value| !value.is_empty())
});
value.map(Cow::Borrowed)
}

pub(crate) fn textarea_text<'a, D: Dom + ?Sized>(
dom: &'a D,
node: D::NodeId,
) -> Option<Cow<'a, str>> {
dom.control_value(node).map(Cow::Borrowed)
}
5 changes: 5 additions & 0 deletions moli-html2md/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
mod content;
mod converter;
mod dom;
mod form;
mod machine;
mod media;
mod options;
mod output;
mod table;
Expand All @@ -16,3 +19,5 @@ extern crate self as moli_html2md;

#[cfg(test)]
mod copy_tests;

pub use media::{SrcsetCandidate, parse_srcset};
Comment thread
lanyue-llk marked this conversation as resolved.
114 changes: 106 additions & 8 deletions moli-html2md/src/machine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ enum Task<'a, Id> {
Visit(Id, usize),
Children(Option<Id>, usize),
Boundary,
ChoiceBoundary,
Fallback(String, usize),
PopStyle,
EndLink(usize),
EndQuote,
Expand Down Expand Up @@ -69,6 +71,14 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
}
Task::Children(None, _) | Task::RawChildren(None, _, _) => {}
Task::Boundary => self.writer().boundary(2),
Task::ChoiceBoundary => self.writer().boundary(1),
Task::Fallback(label, before) => {
if self.writer().content_mark() == before {
self.writer().text(" ");
self.writer().text(&label);
self.writer().text(" ");
}
}
Task::PopStyle => self.writer().pop_style(),
Task::EndLink(serial) => self.writer().end_link(serial),
Task::EndQuote => {
Expand Down Expand Up @@ -207,6 +217,7 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
}
match tag {
"head" | "script" | "style" | "noscript" | "template" => return,
"title" => return,
"br" => {
self.writer().hard_break();
return;
Expand All @@ -215,14 +226,27 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
self.writer().block("---".into(), 2, 2);
return;
}
"select" | "optgroup" | "option" => {
self.writer().boundary(1);
if matches!(tag, "optgroup" | "option")
&& let Some(label) = self.dom.attribute(node, "label")
{
self.writer().text(label);
self.writer().boundary(1);
if tag == "option" {
return;
}
}
self.tasks.push(Task::ChoiceBoundary);
}
"strong" | "b" => self.style(Style::Strong),
"em" | "i" => self.style(Style::Emphasis),
"del" | "s" | "strike" => self.style(Style::Strike),
"a" => {
if let Some(href) = self
.dom
.attribute(node, "href")
.filter(|href| !href.is_empty())
.filter(|href| !href.is_empty() && crate::media::safe_link_url(href))
{
self.serial += 1;
let serial = self.serial;
Expand All @@ -237,17 +261,38 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
}
}
"img" => {
let alt = self.dom.attribute(node, "alt").unwrap_or_default();
let alt = crate::content::image_label(self.dom, node);
let title = self.dom.attribute(node, "title");
if let Some(src) = self
.dom
.attribute(node, "src")
.filter(|src| !src.is_empty())
{
self.writer().image(alt, src, title);
if let Some(src) = crate::media::source(self.dom, node) {
self.writer().image(&alt, &src, title);
} else {
// A failed or absent resource must not erase its textual
// alternative, especially when it is a link's only label.
self.writer().text(&alt);
}
return;
}
"input" => {
if let Some(value) = crate::form::input_text(self.dom, node) {
self.writer().text(&value);
}
return;
}
"textarea" => {
if let Some(value) = crate::form::textarea_text(self.dom, node) {
self.writer().text(&value);
return;
}
}
"iframe" | "video" | "audio" => {
if self.media(node, tag, depth) {
return;
}
if is_block(tag) {
self.writer().boundary(2);
self.tasks.push(Task::Boundary);
}
}
"code" | "pre" => {
self.raw.clear();
let end = if tag == "pre" {
Expand Down Expand Up @@ -298,9 +343,62 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
}
_ => {}
}
if let Some(label) = crate::content::fallback_text(self.dom, node) {
let before = self.writer().content_mark();
self.tasks.push(Task::Fallback(label, before));
}
self.children(node, depth + 1);
}

fn media(&mut self, node: D::NodeId, tag: &str, depth: usize) -> bool {
let urls = crate::media::sources(self.dom, node, tag, depth, self.options.max_depth);
let poster = (tag == "video")
.then(|| crate::media::direct_attribute(self.dom, node, "poster", true))
.flatten();
if urls.is_empty() && poster.is_none() {
Comment thread
lanyue-llk marked this conversation as resolved.
return false;
}
let block = is_block(tag);
if block {
self.writer().boundary(2);
} else {
self.writer().text(" ");
}
if let Some(poster) = poster {
let poster_label = crate::media::poster_label(self.dom, node);
self.writer().image(&poster_label, poster, None);
self.writer().text(" ");
}
let label = crate::media::label(self.dom, node, tag);
let has_source = !urls.is_empty();
for href in urls {
self.serial += 1;
let serial = self.serial;
let opened = self.writer().push_style(Style::Link {
serial,
href,
title: None,
});
self.writer().text(label);
if opened {
self.writer().end_link(serial);
} else {
// Markdown cannot nest a media link inside a surrounding link.
// Retain its destination as text rather than silently losing it.
self.writer().text(" (");
self.writer().text(href);
self.writer().text(")");
}
self.writer().text(" ");
}
if block {
self.writer().boundary(2);
} else {
self.writer().text(" ");
}
has_source
}

fn table_cell(&mut self) {
let table = self.tables.last_mut().expect("table conversion is active");
if let Some(cell) = table.next_cell() {
Expand Down
Loading
Loading