diff --git a/moli-html2md/README.md b/moli-html2md/README.md
index 9767df8d83..67584234bb 100644
--- a/moli-html2md/README.md
+++ b/moli-html2md/README.md
@@ -34,8 +34,15 @@ their children as ordinary content. Comments and doctypes produce no output.
- An inline writer tracks desired and emitted styles, pending whitespace, and
block boundaries. It collapses HTML whitespace across node boundaries, keeps
Unicode spaces, and coalesces adjacent identical emphasis without changing nodes.
-- Lists, quotes, and headings have output buffers finalized by exit
+- Lists, quotes, headings, and table cells have output buffers finalized by exit
tasks. Raw code text uses the same iterative traversal with formatting disabled.
+- Tables inspect their direct sections, rows and cells before conversion. Nested
+ content uses the task stack; eligibility checks never rescan cell descendants.
+- Finished table cells form an owned fragment tree. Fallback joins and writes to
+ parent buffers move fragment roots without copying descendant text; the final
+ output is flattened in one iterative pass. Fragment destruction is iterative
+ too. Quotes, headings and list items still materialize their captured text
+ when rewriting its lines.
- List spacing follows block boundaries recorded during conversion. Nested
lists keep their own spacing; list items need no preliminary DOM scan.
- Ordered lists use `start` followed by sequential numbers, like Turndown core.
@@ -44,10 +51,26 @@ their children as ordinary content. Comments and doctypes produce no output.
punctuation and Unicode; a name containing backticks uses a tilde fence.
Fallback language hints end at the first line ending.
-Tables, sections, rows, and cells follow Turndown core's ordinary block behavior:
-their content is emitted in DOM order, separated by blank lines. Nested tables
-use the same rule. The converter does not classify layout tables or generate
-GFM table syntax; headers, roles, borders, and spans do not select another path.
+Simple tables produce GFM pipe tables by default. A first row inside `thead` or
+consisting entirely of `th` cells is used as the heading, following the heading
+recognition used by Turndown's GFM tables plugin. Without a heading, the converter
+adds an empty heading as wide as the widest data row and retains every data row.
+Whitespace, comments and empty spacer rows do not affect recognition. The first
+cell in each column supplies its `align` attribute; a preceding caption remains
+a separate Markdown block.
+
+Cells retain inline formatting, links, images and code. Literal pipes are escaped,
+and cell line breaks and paragraph boundaries use inline `
` tags. Attribute
+newlines are encoded or normalized so they cannot break a table row. GFM supplies
+missing trailing cells in short rows; the converter does not allocate a padded grid.
+
+Complex tables retain Turndown core's ordinary block expansion, in DOM order with
+blank lines between cells. This includes non-unit spans, multiple `thead` rows,
+rows wider than an explicit header, or cells containing nested tables, lists,
+headings, quotes or preformatted blocks. Inner simple tables can still produce
+GFM when their containing layout table is expanded. Roles, borders and CSS do
+not select another conversion path, so structurally simple layout tables also
+produce GFM. 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
@@ -77,6 +100,11 @@ checks DOM immutability, and explicitly asserts 12 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.
+An operation-count regression checks nested table fallback at 64, 128 and 256
+levels with 256 text bytes per level, both with and without headers. It bounds
+bytes copied during fragment materialization and writes to parent buffers by
+output size, so a repeated flatten/copy regression fails without timing thresholds.
+Dropping 20,000 nested fragments is also tested on a 64 KiB thread stack.
Emphasis tests cover adjacent links that can use Markdown delimiters and
intraword link boundaries that still require inline HTML.
diff --git a/moli-html2md/src/copy_tests.rs b/moli-html2md/src/copy_tests.rs
new file mode 100644
index 0000000000..e7fed121b5
--- /dev/null
+++ b/moli-html2md/src/copy_tests.rs
@@ -0,0 +1,72 @@
+#[path = "../tests/support/mod.rs"]
+mod support;
+
+use crate::{
+ Converter, Options,
+ output::{Output, measure_copies},
+};
+
+#[test]
+fn nested_table_fallback_copies_text_linearly() {
+ let before = "x".repeat(128);
+ let after = "y".repeat(128);
+ let converter = Converter::new(Options {
+ max_depth: usize::MAX,
+ ..Options::default()
+ });
+ for heading in ["", "
| Header |
"] {
+ let mut previous_copies = None;
+ for depth in [64, 128, 256] {
+ let html = format!(
+ "{}leaf{}",
+ format!("{heading}| {before}").repeat(depth),
+ format!("{after} |
").repeat(depth)
+ );
+ let dom = support::Tree::parse(&html);
+ let (actual, copied) = measure_copies(|| converter.convert(&dom, dom.root));
+ assert_eq!(actual.matches(&before).count(), depth);
+ assert_eq!(actual.matches(&after).count(), depth);
+ assert_eq!(
+ support::rendered_html(&actual).matches("").count(),
+ 1
+ );
+ // Count actual bytes copied when materializing fragments or writing
+ // strings back to a parent, not elapsed time or recursion depth.
+ assert!(
+ copied <= 2 * actual.len(),
+ "depth={depth}, output={}, copied={copied}",
+ actual.len()
+ );
+ if let Some(previous) = previous_copies {
+ assert!(
+ copied <= previous * 2 + 128,
+ "copy volume must grow linearly"
+ );
+ }
+ previous_copies = Some(copied);
+ println!(
+ "header={}, depth={depth}, output={}, copied={copied}",
+ !heading.is_empty(),
+ actual.len()
+ );
+ }
+ }
+}
+
+#[test]
+fn dropping_nested_fragments_does_not_recurse() {
+ std::thread::Builder::new()
+ .stack_size(64 * 1024)
+ .spawn(|| {
+ let mut output = Output::from("leaf".to_owned());
+ for _ in 0..20_000 {
+ let mut parent = Output::default();
+ parent.append(output);
+ output = parent;
+ }
+ drop(output);
+ })
+ .expect("spawn small-stack test")
+ .join()
+ .expect("dropping fragments should be iterative");
+}
diff --git a/moli-html2md/src/lib.rs b/moli-html2md/src/lib.rs
index dadb292b10..c14d26876e 100644
--- a/moli-html2md/src/lib.rs
+++ b/moli-html2md/src/lib.rs
@@ -2,8 +2,16 @@ mod converter;
mod dom;
mod machine;
mod options;
+mod output;
+mod table;
mod writer;
pub use converter::{Converter, convert};
pub use dom::{Dom, NodeKind};
pub use options::Options;
+
+#[cfg(test)]
+extern crate self as moli_html2md;
+
+#[cfg(test)]
+mod copy_tests;
diff --git a/moli-html2md/src/machine.rs b/moli-html2md/src/machine.rs
index af25d44886..903f042b54 100644
--- a/moli-html2md/src/machine.rs
+++ b/moli-html2md/src/machine.rs
@@ -1,3 +1,5 @@
+use crate::output::Output;
+use crate::table::Table;
use crate::writer::{Style, Writer, longest_run};
use crate::{Dom, NodeKind, Options};
@@ -14,6 +16,8 @@ enum Task<'a, Id> {
RawChildren(Option, usize, bool),
EndCode,
EndPre(Option<&'a str>),
+ TableCell,
+ EndTableCell,
}
struct List {
@@ -29,6 +33,7 @@ struct Machine<'a, D: Dom + ?Sized> {
tasks: Vec>,
writers: Vec>,
lists: Vec,
+ tables: Vec>,
raw: String,
serial: usize,
}
@@ -40,11 +45,12 @@ pub(crate) fn convert(dom: &D, root: D::NodeId, options: &Optio
tasks: vec![Task::Visit(root, 0)],
writers: vec![Writer::default()],
lists: Vec::new(),
+ tables: Vec::new(),
raw: String::new(),
serial: 0,
};
machine.run();
- machine.take_writer()
+ machine.take_writer().into_string()
}
impl<'a, D: Dom + ?Sized> Machine<'a, D> {
@@ -64,7 +70,7 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
Task::PopStyle => self.writer().pop_style(),
Task::EndLink(serial) => self.writer().end_link(serial),
Task::EndQuote => {
- let content = self.take_writer();
+ let content = self.take_writer().into_string();
let mut quote = String::new();
for line in content.lines() {
if !quote.is_empty() {
@@ -79,7 +85,7 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
self.writer().block("e, 2, 2);
}
Task::EndHeading(level) => {
- let content = self.take_writer();
+ let content = self.take_writer().into_string();
if !content.is_empty() {
let heading =
format!("{} {}", "#".repeat(level), content.replace('\n', " "));
@@ -92,7 +98,7 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
self.lists.pop();
let content = self.take_writer();
let has_blocks = self.writer().has_blocks;
- self.writer().block(&content, before, 2);
+ self.writer().block_output(content, before, 2);
if before == 1 {
// A nested list alone does not make its parent item loose.
self.writer().has_blocks = has_blocks;
@@ -100,7 +106,7 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
}
Task::EndItem(marker) => {
let loose = self.writer().has_blocks;
- let content = self.take_writer();
+ let content = self.take_writer().into_string();
if content.is_empty() {
continue;
}
@@ -130,6 +136,13 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
self.writer().code_with_edges(&text, preformatted);
}
Task::EndPre(language) => self.end_pre(language),
+ Task::TableCell => self.table_cell(),
+ Task::EndTableCell => {
+ let content = self.take_writer();
+ let table = self.tables.last_mut().expect("cell belongs to a table");
+ table.content.push(content);
+ table.in_cell = false;
+ }
}
}
}
@@ -143,7 +156,7 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
self.writers.push(writer);
}
- fn take_writer(&mut self) -> String {
+ fn take_writer(&mut self) -> Output {
let writer = self.writers.pop().expect("capture has an output");
if let Some(parent) = self.writers.last_mut() {
parent.last_link = parent.last_link.max(writer.last_link);
@@ -180,6 +193,27 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
NodeKind::Other => return,
NodeKind::Element(tag) => tag,
};
+ if let Some(table) = self.tables.last_mut()
+ && table.in_cell
+ && matches!(
+ tag,
+ "table"
+ | "pre"
+ | "blockquote"
+ | "ul"
+ | "ol"
+ | "li"
+ | "hr"
+ | "h1"
+ | "h2"
+ | "h3"
+ | "h4"
+ | "h5"
+ | "h6"
+ )
+ {
+ table.has_complex_content = true;
+ }
match tag {
"head" | "script" | "style" | "noscript" | "template" => return,
"br" => {
@@ -252,6 +286,21 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
}
"ul" | "ol" => self.start_list(node, tag == "ol"),
"li" => self.start_item(),
+ "table" => {
+ if let Some(mut table) =
+ Table::from_dom(self.dom, node, depth, self.options.max_depth)
+ {
+ let captions = std::mem::take(&mut table.captions);
+ self.tables.push(table);
+ self.tasks.push(Task::TableCell);
+ for caption in captions.into_iter().rev() {
+ self.tasks.push(Task::Visit(caption.node, caption.depth));
+ }
+ return;
+ }
+ self.writer().boundary(2);
+ self.tasks.push(Task::Boundary);
+ }
_ if is_block(tag) => {
self.writer().boundary(2);
self.tasks.push(Task::Boundary);
@@ -261,6 +310,21 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
self.children(node, depth + 1);
}
+ fn table_cell(&mut self) {
+ let table = self.tables.last_mut().expect("table conversion is active");
+ if let Some(cell) = table.cells.next() {
+ table.in_cell = true;
+ self.capture();
+ self.writer().table_cell();
+ self.tasks.push(Task::TableCell);
+ self.tasks.push(Task::EndTableCell);
+ self.children(cell.node, cell.depth + 1);
+ } else {
+ let table = self.tables.pop().expect("table conversion is active");
+ self.writer().block_output(table.finish(), 2, 2);
+ }
+ }
+
fn start_list(&mut self, node: D::NodeId, ordered: bool) {
let before = if self
.lists
diff --git a/moli-html2md/src/output.rs b/moli-html2md/src/output.rs
new file mode 100644
index 0000000000..4419a4b090
--- /dev/null
+++ b/moli-html2md/src/output.rs
@@ -0,0 +1,136 @@
+/// Finished Markdown fragments. Joining outputs moves their roots, not their
+/// text or descendant vectors. Both flattening and destruction are iterative.
+#[derive(Default)]
+pub(crate) struct Output {
+ parts: Vec,
+ len: usize,
+ last: Option,
+ trailing_newlines: usize,
+}
+
+enum Part {
+ Text(String),
+ Literal(&'static str),
+ Group(Output),
+}
+
+impl Output {
+ pub(crate) fn is_empty(&self) -> bool {
+ self.len == 0
+ }
+
+ pub(crate) fn last_char(&self) -> Option {
+ self.last
+ }
+
+ pub(crate) fn trailing_newlines(&self) -> usize {
+ self.trailing_newlines
+ }
+
+ fn extend_tail(&mut self, len: usize, last: char, trailing_newlines: usize) {
+ self.len += len;
+ self.last = Some(last);
+ self.trailing_newlines = if trailing_newlines == len {
+ self.trailing_newlines + trailing_newlines
+ } else {
+ trailing_newlines
+ };
+ }
+
+ pub(crate) fn push_text(&mut self, text: String) {
+ if let Some(last) = text.chars().next_back() {
+ self.extend_tail(
+ text.len(),
+ last,
+ text.len() - text.trim_end_matches('\n').len(),
+ );
+ self.parts.push(Part::Text(text));
+ }
+ }
+
+ pub(crate) fn push_literal(&mut self, text: &'static str) {
+ if let Some(last) = text.chars().next_back() {
+ self.extend_tail(
+ text.len(),
+ last,
+ text.len() - text.trim_end_matches('\n').len(),
+ );
+ self.parts.push(Part::Literal(text));
+ }
+ }
+
+ pub(crate) fn append(&mut self, output: Self) {
+ if let Some(last) = output.last {
+ self.extend_tail(output.len, last, output.trailing_newlines);
+ self.parts.push(Part::Group(output));
+ }
+ }
+
+ pub(crate) fn into_string(mut self) -> String {
+ // Ordinary inline output already has a contiguous buffer. Keep that
+ // allocation instead of adding a copy to the common, non-table path.
+ if self.parts.len() == 1 {
+ match self.parts.pop().expect("one output fragment") {
+ Part::Text(text) => return text,
+ part => self.parts.push(part),
+ }
+ }
+ let mut text = String::with_capacity(self.len);
+ let mut pending = std::mem::take(&mut self.parts);
+ pending.reverse();
+ while let Some(part) = pending.pop() {
+ match part {
+ Part::Text(fragment) => {
+ #[cfg(test)]
+ record_copy(fragment.len());
+ text.push_str(&fragment);
+ }
+ Part::Literal(fragment) => {
+ #[cfg(test)]
+ record_copy(fragment.len());
+ text.push_str(fragment);
+ }
+ Part::Group(mut output) => pending.extend(output.parts.drain(..).rev()),
+ }
+ }
+ text
+ }
+}
+
+impl From for Output {
+ fn from(text: String) -> Self {
+ let mut output = Self::default();
+ output.push_text(text);
+ output
+ }
+}
+
+impl Drop for Output {
+ fn drop(&mut self) {
+ // Dropping nested Vec