use std::borrow::Cow;
use std::collections::HashMap;
use super::document::Document;
use super::footnotes::{self, FootnoteIndex};
use super::math_text::math_source_from_other;
use super::node::{Block, CalloutKind, Fold, Inline};
use super::url::Url;
pub(crate) enum TextAtom<'a> {
Verbatim(&'a str),
Math(Cow<'a, str>),
Break,
}
pub(crate) fn push_atom(out: &mut String, atom: TextAtom<'_>) {
match atom {
TextAtom::Verbatim(t) => out.push_str(t),
TextAtom::Math(src) => out.push_str(&src),
TextAtom::Break => out.push(' '),
}
}
fn push_inlines(inlines: &[Inline], out: &mut String) {
for inline in inlines {
match inline {
Inline::Text(t) => push_atom(out, TextAtom::Verbatim(t)),
Inline::Code(c) => push_atom(out, TextAtom::Verbatim(c)),
Inline::Emphasis(children)
| Inline::Strong(children)
| Inline::Strikethrough(children) => push_inlines(children, out),
Inline::FootnoteRef(_) => {}
Inline::TaskMarker(_) => {}
Inline::Link { children, .. } => push_inlines(children, out),
Inline::Image { alt, .. } => push_atom(out, TextAtom::Verbatim(alt)),
Inline::LineBreak => push_atom(out, TextAtom::Break),
Inline::Other(html) => {
if let Some(src) = math_source_from_other(html) {
push_atom(out, TextAtom::Math(Cow::Owned(src)));
}
}
}
}
}
pub fn inlines_to_plain_text(inlines: &[Inline]) -> String {
let mut out = String::new();
push_inlines(inlines, &mut out);
out
}
pub fn render_plain_text(doc: &Document) -> String {
let index = FootnoteIndex::build(&doc.blocks);
let hoisted = footnotes::hoisted_definition_bodies(&doc.blocks);
let mut out = String::new();
render_blocks(&doc.blocks, &index, &hoisted, 0, &mut out);
render_footnote_section(&doc.blocks, &index, &hoisted, &mut out);
out
}
fn render_blocks(
blocks: &[Block],
index: &FootnoteIndex,
hoisted: &HashMap<String, usize>,
list_depth: usize,
out: &mut String,
) {
for block in blocks {
render_block(block, index, hoisted, list_depth, out);
}
}
fn render_blocks_to_string(
blocks: &[Block],
index: &FootnoteIndex,
hoisted: &HashMap<String, usize>,
list_depth: usize,
) -> String {
let mut buf = String::new();
render_blocks(blocks, index, hoisted, list_depth, &mut buf);
buf
}
fn hoist_emptied(children: &[Block], rendered: &str) -> bool {
!children.is_empty() && rendered.is_empty()
}
fn quote_prefix_lines(body: &str) -> String {
body.split('\n')
.map(|line| {
if line.is_empty() {
String::new()
} else {
format!("> {line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn url_href(url: &Url) -> &str {
match url {
Url::Resolved(r) => &r.href,
Url::Unresolved(s) => {
debug_assert!(false, "Url::Unresolved({s:?}) reached render_plain_text");
s
}
}
}
fn render_block(
block: &Block,
index: &FootnoteIndex,
hoisted: &HashMap<String, usize>,
list_depth: usize,
out: &mut String,
) {
match block {
Block::Heading { children, .. } => {
render_inlines_plain(children, index, out);
out.push_str("\n\n");
}
Block::Paragraph(children) => {
render_inlines_plain(children, index, out);
out.push_str("\n\n");
}
Block::Callout {
kind,
fold,
title,
children,
} => {
let body = render_blocks_to_string(children, index, hoisted, list_depth);
if hoist_emptied(children, &body) {
return;
}
let mut marker_line = callout_marker(*kind, *fold);
if let Some(t) = title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
marker_line.push(' ');
marker_line.push_str(t);
}
let mut full = marker_line;
if !body.is_empty() {
full.push('\n');
full.push_str(body.trim_end_matches('\n'));
}
out.push_str("e_prefix_lines(&full));
out.push_str("\n\n");
}
Block::List {
ordered,
start,
items,
..
} => {
let mut body = String::new();
let mut num = start.unwrap_or(1);
let indent = " ".repeat(list_depth);
for item_blocks in items {
let item = if let [Block::Paragraph(inlines)] = item_blocks.as_slice() {
let mut s = String::new();
render_inlines_plain(inlines, index, &mut s);
s
} else {
let mut s = String::new();
for (i, child) in item_blocks.iter().enumerate() {
if i > 0
&& matches!(child, Block::List { .. })
&& matches!(item_blocks[i - 1], Block::Paragraph(_))
{
while s.ends_with('\n') {
s.pop();
}
s.push('\n');
}
render_block(child, index, hoisted, list_depth + 1, &mut s);
}
s
};
if hoist_emptied(item_blocks, &item) {
continue;
}
body.push_str(&indent);
if *ordered {
body.push_str(&num.to_string());
body.push_str(". ");
num += 1;
} else {
body.push_str("- ");
}
body.push_str(item.trim_end_matches('\n'));
body.push('\n');
}
out.push_str(&body);
if !body.is_empty() && list_depth == 0 {
out.push('\n');
}
}
Block::CodeBlock { lang, value } => {
out.push_str("```");
out.push_str(lang.as_deref().unwrap_or(""));
out.push('\n');
out.push_str(value);
if !value.ends_with('\n') {
out.push('\n');
}
out.push_str("```\n\n");
}
Block::Table { header, rows, .. } => {
render_table_row(header, index, out);
for row in rows {
render_table_row(row, index, out);
}
out.push('\n');
}
Block::BlockQuote(children) => {
let body = render_blocks_to_string(children, index, hoisted, list_depth);
if hoist_emptied(children, &body) {
return;
}
out.push_str("e_prefix_lines(body.trim_end_matches('\n')));
out.push_str("\n\n");
}
Block::Shortcode(_) => {
}
Block::ThematicBreak => out.push_str("---\n\n"),
Block::Figure { image, .. } => {
render_inline_plain(image, index, out);
out.push_str("\n\n");
}
Block::LinkCard { url, children } => {
let body = render_blocks_to_string(children, index, hoisted, list_depth);
if hoist_emptied(children, &body) {
return;
}
out.push_str(body.trim());
let href = url_href(url);
if !href.is_empty() {
out.push_str(" (");
out.push_str(href);
out.push(')');
}
out.push_str("\n\n");
}
Block::FootnoteDefinition { label, children } => {
if footnotes::is_hoisted_in(label, children, hoisted) {
return;
}
render_blocks(children, index, hoisted, list_depth, out);
}
Block::Other(_) => {
}
}
}
fn callout_marker(kind: CalloutKind, fold: Option<Fold>) -> String {
let suffix = match fold {
Some(Fold::Open) => "+",
Some(Fold::Closed) => "-",
None => "",
};
format!("[!{}]{}", kind.as_slug(), suffix)
}
fn render_table_row(cells: &[Vec<Inline>], index: &FootnoteIndex, out: &mut String) {
for (i, cell) in cells.iter().enumerate() {
if i > 0 {
out.push_str(" | ");
}
render_inlines_plain(cell, index, out);
}
out.push('\n');
}
fn render_inlines_plain(inlines: &[Inline], index: &FootnoteIndex, out: &mut String) {
for inline in inlines {
render_inline_plain(inline, index, out);
}
}
fn render_inline_plain(inline: &Inline, index: &FootnoteIndex, out: &mut String) {
match inline {
Inline::Text(t) => out.push_str(t),
Inline::Code(c) => {
out.push('`');
out.push_str(c);
out.push('`');
}
Inline::Emphasis(children) | Inline::Strong(children) => {
render_inlines_plain(children, index, out);
}
Inline::Strikethrough(children) => {
out.push_str("~~");
render_inlines_plain(children, index, out);
out.push_str("~~");
}
Inline::FootnoteRef(label) => match index.number(label) {
Some(n) => {
out.push('[');
out.push_str(&n.to_string());
out.push(']');
}
None => {
out.push_str("[^");
out.push_str(label);
out.push(']');
}
},
Inline::TaskMarker(checked) => {
out.push_str(if *checked { "[x] " } else { "[ ] " });
}
Inline::Link { url, children, .. } => {
let mut text = String::new();
render_inlines_plain(children, index, &mut text);
out.push_str(&text);
let href = url_href(url);
if !href.is_empty() {
out.push_str(" (");
out.push_str(href);
out.push(')');
}
}
Inline::Image { src, alt, .. } => {
let href = url_href(src);
if alt.trim().is_empty() {
out.push_str("[image: ");
out.push_str(href);
out.push(']');
} else {
out.push_str("[image: ");
out.push_str(href);
out.push_str(" — ");
out.push_str(alt);
out.push(']');
}
}
Inline::LineBreak => out.push('\n'),
Inline::Other(html) => {
if let Some(src) = math_source_from_other(html) {
out.push('`');
out.push_str(&src);
out.push('`');
}
}
}
}
fn render_footnote_section(
blocks: &[Block],
index: &FootnoteIndex,
hoisted: &HashMap<String, usize>,
out: &mut String,
) {
if index.is_empty() {
return;
}
out.push_str("--\n");
for (n, label) in index.entries() {
let Some(children) = FootnoteIndex::definition(blocks, label) else {
continue;
};
let body = render_blocks_to_string(children, index, hoisted, 0);
out.push('[');
out.push_str(&n.to_string());
out.push_str("] ");
out.push_str(body.trim_end());
out.push('\n');
}
out.push('\n');
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::parse_with_config;
use crate::ast::parser::ParseConfig;
fn render(md: &str) -> String {
let mut doc = parse_with_config(md, &ParseConfig::default());
crate::ast::classify_remaining_urls(&mut doc);
render_plain_text(&doc)
}
#[test]
fn paragraph_renders_flat_text_with_blank_line() {
assert_eq!(render("Hello world.\n"), "Hello world.\n\n");
}
#[test]
fn heading_has_no_special_markup() {
assert_eq!(render("# Title\n"), "Title\n\n");
}
#[test]
fn unordered_list_uses_dash_bullets() {
assert_eq!(render("- one\n- two\n"), "- one\n- two\n\n");
}
#[test]
fn ordered_list_numbers_items() {
assert_eq!(render("1. one\n2. two\n"), "1. one\n2. two\n\n");
}
#[test]
fn nested_list_indents_two_spaces_per_level() {
let out = render("- outer\n - inner\n");
assert!(out.contains("- outer\n"));
assert!(out.contains(" - inner\n"), "got: {out:?}");
}
#[test]
fn blockquote_prefixes_every_line_with_gt() {
assert_eq!(
render("> line one\n> line two\n"),
"> line one\n> line two\n\n"
);
}
#[test]
fn blockquote_with_two_paragraphs_keeps_blank_separator_unprefixed() {
let out = render("> para one\n>\n> para two\n");
assert_eq!(out, "> para one\n\n> para two\n\n");
}
#[test]
fn nested_blockquote_doubles_the_prefix() {
let out = render("> outer\n> > inner\n");
assert!(out.contains("> > inner"), "got: {out:?}");
}
#[test]
fn code_block_keeps_language_tag() {
let out = render("```rust\nfn main() {}\n```\n");
assert_eq!(out, "```rust\nfn main() {}\n```\n\n");
}
#[test]
fn code_block_with_no_language_has_empty_info_string() {
let out = render("```\nplain\n```\n");
assert_eq!(out, "```\nplain\n```\n\n");
}
#[test]
fn table_cells_join_with_pipe() {
let out = render("| a | b |\n|---|---|\n| 1 | 2 |\n");
assert_eq!(out, "a | b\n1 | 2\n\n");
}
#[test]
fn task_markers_render_as_checkbox_text() {
let out = render("- [ ] todo\n- [x] done\n");
assert_eq!(out, "- [ ] todo\n- [x] done\n\n");
}
#[test]
fn link_renders_text_and_href() {
let out = render("[docs](https://example.com/docs)\n");
assert_eq!(out, "docs (https://example.com/docs)\n\n");
}
#[test]
fn image_with_alt_renders_bracketed_marker() {
let out = render("\n");
assert_eq!(out, "[image: cat.jpg — a cat]\n\n");
}
#[test]
fn image_without_alt_renders_bracketed_marker_no_dash() {
let out = render("\n");
assert_eq!(out, "[image: cat.jpg]\n\n");
}
#[test]
fn strikethrough_keeps_literal_markers() {
let out = render("~~gone~~\n");
assert_eq!(out, "~~gone~~\n\n");
}
#[test]
fn footnote_marker_and_hoisted_endnote_section() {
let out = render("See[^a].\n\n[^a]: The note.\n");
assert_eq!(out, "See[1].\n\n--\n[1] The note.\n\n");
}
#[test]
fn footnote_numbering_follows_first_reference_order() {
let out = render("First[^b], then[^a].\n\n[^a]: A.\n[^b]: B.\n");
assert!(out.starts_with("First[1], then[2].\n\n"), "got: {out:?}");
assert!(out.contains("[1] B."), "got: {out:?}");
assert!(out.contains("[2] A."), "got: {out:?}");
}
#[test]
fn unreferenced_footnote_is_not_silently_dropped() {
let out = render("No marker here.\n\n[^orphan]: Orphan note.\n");
assert!(out.contains("[1] Orphan note."), "got: {out:?}");
}
#[test]
fn list_item_that_is_only_a_hoisted_footnote_is_erased_without_a_bullet() {
let out = render("- one\n- [^a]: hoisted note\n- two\n\nref[^a]\n");
assert!(
out.contains("1. one\n2. two\n") || out.contains("- one\n- two\n"),
"got: {out:?}"
);
}
#[test]
fn callout_reconstructs_marker_line() {
let out = render("> [!note] Heads up\n> Body text.\n");
assert_eq!(out, "> [!note] Heads up\n> Body text.\n\n");
}
#[test]
fn callout_without_title_has_bare_marker() {
let out = render("> [!warning]\n> Body.\n");
assert_eq!(out, "> [!warning]\n> Body.\n\n");
}
#[test]
fn inlines_to_plain_text_drops_link_href_and_footnote_marker() {
let doc = parse_with_config(
"A [link](/x) and a note[^a].\n\n[^a]: n\n",
&ParseConfig::default(),
);
let Block::Paragraph(inlines) = &doc.blocks[0] else {
panic!("expected paragraph");
};
assert_eq!(inlines_to_plain_text(inlines), "A link and a note.");
}
}