use crate::ir::{Document, ListItem, Node};
pub struct MarkdownRenderer {
buf: String,
}
impl MarkdownRenderer {
pub fn new() -> Self {
Self {
buf: String::with_capacity(4096),
}
}
pub fn render(&mut self, doc: &Document) -> String {
self.buf.clear();
self.render_nodes(&doc.nodes, 0);
normalize_output(&self.buf)
}
fn render_nodes(&mut self, nodes: &[Node], depth: usize) {
for (i, node) in nodes.iter().enumerate() {
if i > 0 && is_block(node) && is_block(&nodes[i - 1]) {
self.buf.push('\n');
}
self.render_node(node, depth);
}
}
fn render_node(&mut self, node: &Node, depth: usize) {
match node {
Node::Heading { level, children } => {
let prefix = "#".repeat(*level as usize);
self.buf.push_str(&prefix);
self.buf.push(' ');
let start = self.buf.len();
self.render_inline(children);
trim_trailing_inline(&mut self.buf, start);
self.buf.push('\n');
}
Node::Paragraph { children } => {
let start = self.buf.len();
self.render_inline(children);
trim_trailing_inline(&mut self.buf, start);
self.buf.push('\n');
}
Node::List {
ordered,
start,
items,
} => {
self.render_list(*ordered, *start, items, depth);
}
Node::BlockQuote { children } => {
let inner = {
let mut sub = MarkdownRenderer::new();
sub.render_nodes(children, depth);
normalize_output(&sub.buf)
};
for line in inner.lines() {
if line.is_empty() {
self.buf.push_str(">\n");
} else {
self.buf.push_str("> ");
self.buf.push_str(line);
self.buf.push('\n');
}
}
}
Node::CodeBlock { language, code } => {
self.buf.push_str("```");
if let Some(lang) = language {
self.buf.push_str(lang);
}
self.buf.push('\n');
self.buf.push_str(code);
if !code.ends_with('\n') {
self.buf.push('\n');
}
self.buf.push_str("```\n");
}
Node::Table { headers, rows } => {
self.render_table(headers, rows);
}
Node::ThematicBreak => {
self.buf.push_str("---\n");
}
Node::Text(_)
| Node::Bold { .. }
| Node::Italic { .. }
| Node::Strikethrough { .. }
| Node::InlineCode(_)
| Node::Link { .. }
| Node::Image { .. }
| Node::LineBreak => {
self.render_inline_node(node);
}
}
}
fn render_list(&mut self, ordered: bool, start: Option<u64>, items: &[ListItem], depth: usize) {
let indent = " ".repeat(depth);
let start_num = start.unwrap_or(1);
for (i, item) in items.iter().enumerate() {
let marker = if ordered {
format!("{}{}. ", indent, start_num + i as u64)
} else {
format!("{}- ", indent)
};
self.buf.push_str(&marker);
let has_block = item.children.iter().any(|n| !is_inline_node(n));
if has_block {
let mut first = true;
for child in &item.children {
if first {
first = false;
match child {
Node::Paragraph { children } => {
self.render_inline(children);
self.buf.push('\n');
}
Node::List {
ordered: sub_ord,
start: sub_start,
items: sub_items,
} => {
self.buf.push('\n');
self.render_list(*sub_ord, *sub_start, sub_items, depth + 1);
}
_ => {
let mut sub = MarkdownRenderer::new();
sub.render_node(child, depth + 1);
let text = sub.buf.trim_end().to_string();
self.buf.push_str(&text);
self.buf.push('\n');
}
}
} else {
match child {
Node::List {
ordered: sub_ord,
start: sub_start,
items: sub_items,
} => {
self.render_list(*sub_ord, *sub_start, sub_items, depth + 1);
}
_ => {
let mut sub = MarkdownRenderer::new();
sub.render_node(child, depth + 1);
let inner = sub.buf.trim_end().to_string();
let sub_indent = " ".repeat(depth + 1);
for line in inner.lines() {
self.buf.push_str(&sub_indent);
self.buf.push_str(line);
self.buf.push('\n');
}
}
}
}
}
} else {
self.render_inline(&item.children);
self.buf.push('\n');
}
}
}
fn render_inline(&mut self, nodes: &[Node]) {
for node in nodes {
self.render_inline_node(node);
}
}
fn render_inline_node(&mut self, node: &Node) {
match node {
Node::Text(t) => self.buf.push_str(t),
Node::Bold { children } => {
self.buf.push_str("**");
self.render_inline(children);
self.buf.push_str("**");
}
Node::Italic { children } => {
self.buf.push('*');
self.render_inline(children);
self.buf.push('*');
}
Node::Strikethrough { children } => {
self.buf.push_str("~~");
self.render_inline(children);
self.buf.push_str("~~");
}
Node::InlineCode(code) => {
self.buf.push('`');
self.buf.push_str(code);
self.buf.push('`');
}
Node::Link {
url,
title,
children,
} => {
self.buf.push('[');
self.render_inline(children);
self.buf.push_str("](");
self.buf.push_str(url);
if let Some(t) = title {
self.buf.push_str(" \"");
self.buf.push_str(t);
self.buf.push('"');
}
self.buf.push(')');
}
Node::Image { src, alt } => {
self.buf.push_str(";
self.buf.push_str(src);
self.buf.push(')');
}
Node::LineBreak => {
self.buf.push_str(" \n");
}
other => self.render_node(other, 0),
}
}
fn render_table(&mut self, headers: &[Vec<Node>], rows: &[Vec<Vec<Node>>]) {
let col_count = headers
.len()
.max(rows.iter().map(|r| r.len()).max().unwrap_or(0));
if col_count == 0 {
return;
}
self.buf.push('|');
for i in 0..col_count {
self.buf.push(' ');
if let Some(cells) = headers.get(i) {
self.render_inline(cells);
}
self.buf.push_str(" |");
}
self.buf.push('\n');
self.buf.push('|');
for _ in 0..col_count {
self.buf.push_str(" --- |");
}
self.buf.push('\n');
for row in rows {
self.buf.push('|');
for i in 0..col_count {
self.buf.push(' ');
if let Some(cells) = row.get(i) {
self.render_inline(cells);
}
self.buf.push_str(" |");
}
self.buf.push('\n');
}
}
}
impl Default for MarkdownRenderer {
fn default() -> Self {
Self::new()
}
}
fn trim_trailing_inline(buf: &mut String, start: usize) {
while buf.len() > start && buf.ends_with(' ') {
buf.pop();
}
let content = &buf[start..];
let trimmed_start = content.len() - content.trim_start().len();
if trimmed_start > 0 {
buf.drain(start..start + trimmed_start);
}
}
#[cfg(any(feature = "docx", feature = "spreadsheet"))]
pub(crate) fn escape_table_cell(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'|' => out.push_str("\\|"),
'\r' | '\n' => out.push(' '),
c => out.push(c),
}
}
out
}
fn is_block(node: &Node) -> bool {
matches!(
node,
Node::Heading { .. }
| Node::Paragraph { .. }
| Node::List { .. }
| Node::BlockQuote { .. }
| Node::CodeBlock { .. }
| Node::Table { .. }
| Node::ThematicBreak
)
}
fn is_inline_node(node: &Node) -> bool {
!is_block(node)
}
fn normalize_output(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut blank_count = 0;
for line in s.lines() {
let core = line.trim_end();
let is_hard_break = line.ends_with(" ") && !core.is_empty();
let trimmed = if is_hard_break {
core.to_string() + " "
} else {
core.to_string()
};
if trimmed.is_empty() || trimmed == " " {
blank_count += 1;
if blank_count <= 1 {
result.push('\n');
}
} else {
blank_count = 0;
result.push_str(&trimmed);
result.push('\n');
}
}
let trimmed = result.trim_matches('\n');
if trimmed.is_empty() {
String::new()
} else {
let mut out = trimmed.to_string();
out.push('\n');
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_output() {
assert_eq!(normalize_output("a\n\n\n\nb\n"), "a\n\nb\n");
assert_eq!(normalize_output("\n\na\n\n"), "a\n");
assert_eq!(normalize_output(""), "");
assert_eq!(normalize_output(" \n \n"), "");
assert_eq!(normalize_output("a \nb\n"), "a \nb\n");
}
#[cfg(any(feature = "docx", feature = "spreadsheet"))]
#[test]
fn test_escape_table_cell() {
assert_eq!(escape_table_cell("a|b\r\nc"), "a\\|b c");
}
}