use crate::ast::{Document, Node, Span};
fn escape_text(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+' | '-' | '.'
| '!' | '|' | '<' | '>' | '&' => {
out.push('\\');
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
pub(crate) fn render_span(span: &Span) -> String {
if !span.bold && !span.italic {
if span.text.is_empty() {
return String::new();
}
return escape_text(&span.text);
}
let core = span.text.trim();
if core.is_empty() {
return if span.text.is_empty() {
String::new()
} else {
" ".to_string()
};
}
let marker = match (span.bold, span.italic) {
(true, true) => "***",
(true, false) => "**",
(false, true) => "*",
(false, false) => unreachable!("plain spans return early above"),
};
format!("{marker}{}{marker}", escape_text(core))
}
fn render_spans(spans: &[Span]) -> String {
spans
.iter()
.map(render_span)
.filter(|s| !s.is_empty())
.collect::<String>()
}
fn trim_trailing_ws_per_line(input: &str) -> String {
input
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn render_node(node: &Node) -> String {
match node {
Node::Heading { level, spans } => {
let text = trim_trailing_ws_per_line(&render_spans(spans));
if text.is_empty() {
return String::new();
}
let hashes = "#".repeat((*level).clamp(1, 6) as usize);
format!("{hashes} {text}\n\n")
}
Node::Paragraph { spans } => {
let text = trim_trailing_ws_per_line(&render_spans(spans));
if text.is_empty() {
return String::new();
}
format!("{text}\n\n")
}
Node::RawText(text) => {
let cleaned = trim_trailing_ws_per_line(text);
if cleaned.is_empty() {
return String::new();
}
format!("{cleaned}\n\n")
}
}
}
pub fn render_document(document: &Document) -> String {
let body = document
.nodes
.iter()
.map(render_node)
.collect::<String>()
.trim_start_matches('\n')
.trim_end_matches('\n')
.to_string();
if body.is_empty() {
String::new()
} else {
format!("{body}\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::DocumentMetadata;
fn span(text: &str, bold: bool, italic: bool) -> Span {
Span {
text: text.to_string(),
bold,
italic,
font_size: 12.0,
font_name: None,
}
}
#[test]
fn escape_text_escapes_all_commonmark_special_chars() {
let raw = r"\`*_{}[]()#+-.!|";
let escaped = escape_text(raw);
assert_eq!(escaped, r"\\\`\*\_\{\}\[\]\(\)\#\+\-\.\!\|");
}
#[test]
fn escape_text_escapes_html_structural_chars() {
assert_eq!(escape_text("<"), r"\<");
assert_eq!(escape_text(">"), r"\>");
assert_eq!(escape_text("&"), r"\&");
assert_eq!(escape_text("A < B & C > D"), r"A \< B \& C \> D");
}
#[test]
fn escape_text_leaves_safe_text_unchanged() {
assert_eq!(escape_text("Papyrus Renderer 123"), "Papyrus Renderer 123");
}
#[test]
fn render_span_supports_plain_bold_italic_and_bold_italic() {
assert_eq!(render_span(&span("plain", false, false)), "plain");
assert_eq!(render_span(&span("bold", true, false)), "**bold**");
assert_eq!(render_span(&span("italic", false, true)), "*italic*");
assert_eq!(render_span(&span("both", true, true)), "***both***");
}
#[test]
fn render_span_plain_preserves_whitespace_for_inter_word_spacing() {
assert_eq!(render_span(&span(" ", false, false)), " ");
assert_eq!(render_span(&span(" hello ", false, false)), " hello ");
}
#[test]
fn render_span_drops_empty_formatted_output_but_preserves_spacing() {
assert_eq!(render_span(&span("", true, false)), "");
assert_eq!(render_span(&span("", true, true)), "");
assert_eq!(render_span(&span(" ", true, false)), " ");
assert_eq!(render_span(&span(" ", true, true)), " ");
assert_eq!(render_span(&span("\t", false, true)), " ");
}
#[test]
fn render_span_trims_surrounding_whitespace_before_applying_markers() {
assert_eq!(
render_span(&span(" bold me ", true, false)),
"**bold me**"
);
assert_eq!(render_span(&span("\tbold\t", false, true)), "*bold*");
}
#[test]
fn render_span_escapes_inner_text_without_escaping_markers() {
assert_eq!(render_span(&span("A*B", true, false)), "**A\\*B**");
}
#[test]
fn render_span_escapes_html_chars_in_plain_and_formatted() {
assert_eq!(render_span(&span("a < b", false, false)), r"a \< b");
assert_eq!(render_span(&span("a > b", false, false)), r"a \> b");
assert_eq!(render_span(&span("a & b", false, false)), r"a \& b");
assert_eq!(render_span(&span("x < y", true, false)), r"**x \< y**");
}
#[test]
fn render_spans_preserves_inter_word_space_from_formatted_whitespace_span() {
let spans = vec![
span("Click", false, false),
span(" ", true, false), span("here", true, false),
];
let result = render_spans(&spans);
assert_eq!(result, "Click **here**");
}
#[test]
fn render_node_heading_uses_hash_prefix_and_blank_line() {
let node = Node::Heading {
level: 3,
spans: vec![span("Heading", false, false)],
};
assert_eq!(render_node(&node), "### Heading\n\n");
}
#[test]
fn render_node_heading_level_clamping() {
let h0 = Node::Heading {
level: 0,
spans: vec![span("X", false, false)],
};
assert_eq!(render_node(&h0), "# X\n\n");
let h7 = Node::Heading {
level: 7,
spans: vec![span("X", false, false)],
};
assert_eq!(render_node(&h7), "###### X\n\n");
}
#[test]
fn render_node_empty_heading_produces_empty_string() {
let node = Node::Heading {
level: 3,
spans: vec![span("", true, false), span(" ", false, true)],
};
assert_eq!(render_node(&node), "");
}
#[test]
fn render_node_paragraph_joins_spans_without_extra_spaces() {
let node = Node::Paragraph {
spans: vec![
span("Hello", false, false),
span(" ", false, false),
span("world", true, false),
],
};
assert_eq!(render_node(&node), "Hello **world**\n\n");
}
#[test]
fn render_node_empty_paragraph_produces_empty_string() {
let node = Node::Paragraph {
spans: vec![span("", true, false)],
};
assert_eq!(render_node(&node), "");
}
#[test]
fn render_node_raw_text_passthrough_appends_blank_line() {
assert_eq!(render_node(&Node::RawText("raw".to_string())), "raw\n\n");
}
#[test]
fn render_node_empty_raw_text_produces_empty_string() {
assert_eq!(render_node(&Node::RawText(String::new())), "");
assert_eq!(render_node(&Node::RawText(" ".to_string())), "");
}
#[test]
fn render_document_has_single_trailing_newline_for_non_empty_docs() {
let doc = Document {
metadata: DocumentMetadata {
title: None,
author: None,
page_count: 1,
},
nodes: vec![
Node::Heading {
level: 1,
spans: vec![span("Title", false, false)],
},
Node::Paragraph {
spans: vec![span("Body", false, false)],
},
],
};
let markdown = render_document(&doc);
assert_eq!(markdown, "# Title\n\nBody\n");
assert!(markdown.ends_with('\n'));
assert!(!markdown.ends_with("\n\n"));
}
#[test]
fn render_document_empty_doc_is_empty_string() {
let doc = Document {
metadata: DocumentMetadata {
title: None,
author: None,
page_count: 0,
},
nodes: vec![],
};
assert_eq!(render_document(&doc), "");
}
#[test]
fn render_document_skips_empty_nodes_cleanly() {
let doc = Document {
metadata: DocumentMetadata {
title: None,
author: None,
page_count: 1,
},
nodes: vec![
Node::Paragraph {
spans: vec![span("Before", false, false)],
},
Node::Heading {
level: 2,
spans: vec![span(" ", false, false)],
},
Node::Paragraph {
spans: vec![span("After", false, false)],
},
],
};
let markdown = render_document(&doc);
assert_eq!(markdown, "Before\n\nAfter\n");
}
}