1use crate::model::{BlockKind, Document};
4
5pub fn to_text(doc: &Document) -> String {
7 doc.pages
8 .iter()
9 .filter(|p| !p.text.trim().is_empty())
10 .map(|p| p.text.as_str())
11 .collect::<Vec<_>>()
12 .join("\n\n")
13}
14
15fn table_md(rows: &[Vec<String>]) -> String {
16 if rows.is_empty() {
17 return String::new();
18 }
19 let width = rows.iter().map(|r| r.len()).max().unwrap_or(0);
20 let norm: Vec<Vec<String>> = rows
21 .iter()
22 .map(|r| {
23 let mut v = r.clone();
24 while v.len() < width {
25 v.push(String::new());
26 }
27 v
28 })
29 .collect();
30 let cell = |r: &[String]| format!("| {} |", r.join(" | "));
31 let mut out = vec![cell(&norm[0])];
32 out.push(format!("| {} |", vec!["---"; width].join(" | ")));
33 for r in &norm[1..] {
34 out.push(cell(r));
35 }
36 out.join("\n")
37}
38
39fn block_md(b: &crate::model::Block) -> String {
40 match b.kind {
41 BlockKind::Heading => {
42 let lvl = (b.level.unwrap_or(1) as usize).clamp(1, 6);
43 format!("{} {}", "#".repeat(lvl), b.content)
44 }
45 BlockKind::List => {
46 let prefix = if b.ordered.unwrap_or(false) {
47 "1."
48 } else {
49 "-"
50 };
51 b.content
52 .lines()
53 .filter(|l| !l.trim().is_empty())
54 .map(|l| format!("{prefix} {l}"))
55 .collect::<Vec<_>>()
56 .join("\n")
57 }
58 BlockKind::Code => format!(
59 "```{}\n{}\n```",
60 b.lang.clone().unwrap_or_default(),
61 b.content
62 ),
63 BlockKind::Table => b
64 .table
65 .as_ref()
66 .map(|t| table_md(&t.rows))
67 .unwrap_or_default(),
68 BlockKind::Quote => b
69 .content
70 .lines()
71 .map(|l| format!("> {l}"))
72 .collect::<Vec<_>>()
73 .join("\n"),
74 _ => b.content.clone(),
75 }
76}
77
78pub fn to_markdown(doc: &Document) -> String {
83 let mut out: Vec<String> = Vec::new();
84 for page in &doc.pages {
85 let mut prev_list: Option<bool> = None;
86 for b in page
87 .blocks
88 .iter()
89 .filter(|b| !b.content.is_empty() || b.table.is_some())
90 {
91 let rendered = block_md(b);
92 if rendered.is_empty() {
93 continue;
94 }
95 if b.kind == BlockKind::List {
96 let ordered = b.ordered.unwrap_or(false);
97 if prev_list == Some(ordered) {
98 if let Some(last) = out.last_mut() {
99 last.push('\n');
100 last.push_str(&rendered);
101 }
102 continue;
103 }
104 prev_list = Some(ordered);
105 } else {
106 prev_list = None;
107 }
108 out.push(rendered);
109 }
110 }
111 out.join("\n\n")
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::model::{Block, BlockKind, Document, Page};
118
119 fn list(ordered: bool, content: &str) -> Block {
120 Block {
121 kind: BlockKind::List,
122 content: content.to_string(),
123 ordered: Some(ordered),
124 ..Default::default()
125 }
126 }
127
128 #[test]
129 fn adjacent_lists_merge_same_kind_only() {
130 let doc = Document {
131 pages: vec![Page {
132 blocks: vec![
133 Block {
134 kind: BlockKind::Heading,
135 content: "T".into(),
136 level: Some(2),
137 ..Default::default()
138 },
139 list(false, "a"),
140 list(false, "b"),
141 list(true, "x"),
142 ],
143 ..Default::default()
144 }],
145 ..Default::default()
146 };
147 let md = to_markdown(&doc);
148 assert_eq!(md, "## T\n\n- a\n- b\n\n1. x");
149 }
150
151 #[test]
152 fn table_rendered_as_markdown() {
153 let doc = Document {
154 pages: vec![Page {
155 blocks: vec![Block {
156 kind: BlockKind::Table,
157 table: Some(crate::model::TableData {
158 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
159 }),
160 ..Default::default()
161 }],
162 ..Default::default()
163 }],
164 ..Default::default()
165 };
166 assert_eq!(to_markdown(&doc), "| a | b |\n| --- | --- |\n| 1 | 2 |");
167 }
168}