1use crate::pipeline::{Element, TableElementData};
2
3#[derive(Debug, Clone)]
5pub struct ExportConfig {
6 pub include_headers_footers: bool,
8}
9
10impl Default for ExportConfig {
11 fn default() -> Self {
12 Self {
13 include_headers_footers: false,
14 }
15 }
16}
17
18#[derive(Debug, Clone, Default)]
20pub struct ElementMarkdownExporter {
21 pub config: ExportConfig,
22}
23
24impl ElementMarkdownExporter {
25 pub fn new(config: ExportConfig) -> Self {
26 Self { config }
27 }
28
29 pub fn export(&self, elements: &[Element]) -> String {
31 if elements.is_empty() {
32 return String::new();
33 }
34 let mut parts: Vec<String> = Vec::new();
35 for element in elements {
36 if let Some(md) = self.element_to_markdown(element) {
37 parts.push(md);
38 }
39 }
40 parts.join("\n\n")
41 }
42
43 fn element_to_markdown(&self, element: &Element) -> Option<String> {
44 match element {
45 Element::Title(d) => Some(format!("# {}", d.text.trim())),
46 Element::Paragraph(d) => Some(d.text.trim().to_string()),
47 Element::ListItem(d) => Some(format!("- {}", d.text.trim())),
48 Element::KeyValue(kv) => Some(format!("**{}**: {}", kv.key.trim(), kv.value.trim())),
49 Element::CodeBlock(d) => Some(format!("```\n{}\n```", d.text.trim())),
50 Element::Image(img) => {
51 let alt = img.alt_text.as_deref().unwrap_or("");
52 Some(format!("![{}]()", alt))
53 }
54 Element::Table(t) => Some(table_to_markdown_data(t)),
55 Element::Header(_) | Element::Footer(_) => {
56 if self.config.include_headers_footers {
57 Some(element.display_text())
58 } else {
59 None
60 }
61 }
62 }
63 }
64}
65
66fn table_to_markdown(rows: &[Vec<String>]) -> String {
67 if rows.is_empty() {
68 return String::new();
69 }
70 let mut lines = Vec::new();
71 lines.push(format!("| {} |", rows[0].join(" | ")));
72 let sep: Vec<&str> = vec!["---"; rows[0].len()];
73 lines.push(format!("| {} |", sep.join(" | ")));
74 for row in &rows[1..] {
75 lines.push(format!("| {} |", row.join(" | ")));
76 }
77 lines.join("\n")
78}
79
80fn table_to_markdown_data(data: &TableElementData) -> String {
92 match &data.structure {
93 Some(st) if st.header_rows > 1 && !data.rows.is_empty() => {
94 let ncols = st.num_cols;
95 let header_rows = st.header_rows.min(data.rows.len());
96 let mut header = Vec::with_capacity(ncols);
97 for c in 0..ncols {
98 let mut parts: Vec<&str> = Vec::new();
99 for row in &data.rows[..header_rows] {
100 let cell = row.get(c).map(|s| s.as_str()).unwrap_or("");
101 if !cell.is_empty() && parts.last() != Some(&cell) {
102 parts.push(cell);
103 }
104 }
105 header.push(parts.join(" › "));
106 }
107 let mut lines = vec![
108 format!("| {} |", header.join(" | ")),
109 format!("| {} |", vec!["---"; ncols].join(" | ")),
110 ];
111 for row in &data.rows[header_rows..] {
112 lines.push(format!("| {} |", row.join(" | ")));
113 }
114 lines.join("\n")
115 }
116 _ => table_to_markdown(&data.rows),
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::pipeline::{ElementMetadata, RichCell, TableStructure};
124
125 #[test]
126 fn multi_level_header_flattens_with_separator() {
127 let structure = TableStructure {
130 num_rows: 3,
131 num_cols: 2,
132 header_rows: 2,
133 cells: vec![
134 RichCell {
135 row: 0,
136 col: 0,
137 row_span: 1,
138 col_span: 2,
139 text: "Region".into(),
140 is_header: true,
141 },
142 RichCell {
143 row: 1,
144 col: 0,
145 row_span: 1,
146 col_span: 1,
147 text: "Q1".into(),
148 is_header: true,
149 },
150 RichCell {
151 row: 1,
152 col: 1,
153 row_span: 1,
154 col_span: 1,
155 text: "Q2".into(),
156 is_header: true,
157 },
158 RichCell {
159 row: 2,
160 col: 0,
161 row_span: 1,
162 col_span: 1,
163 text: "10".into(),
164 is_header: false,
165 },
166 RichCell {
167 row: 2,
168 col: 1,
169 row_span: 1,
170 col_span: 1,
171 text: "20".into(),
172 is_header: false,
173 },
174 ],
175 };
176 let data = TableElementData::from_structure(structure, ElementMetadata::default());
177 let md = table_to_markdown_data(&data);
178 assert_eq!(
179 md,
180 "| Region › Q1 | Region › Q2 |\n| --- | --- |\n| 10 | 20 |"
181 );
182 }
183
184 #[test]
185 fn no_structure_table_unchanged() {
186 let metadata = ElementMetadata::default();
187 let data = TableElementData::new(
188 vec![
189 vec!["a".to_string(), "b".to_string()],
190 vec!["1".to_string(), "2".to_string()],
191 ],
192 metadata,
193 );
194 let expected = table_to_markdown(&data.rows);
195 assert_eq!(table_to_markdown_data(&data), expected);
196 assert_eq!(expected, "| a | b |\n| --- | --- |\n| 1 | 2 |");
197 }
198
199 #[test]
200 fn single_header_row_structure_unchanged() {
201 let structure = TableStructure {
203 num_rows: 2,
204 num_cols: 2,
205 header_rows: 1,
206 cells: vec![
207 RichCell {
208 row: 0,
209 col: 0,
210 row_span: 1,
211 col_span: 1,
212 text: "a".into(),
213 is_header: true,
214 },
215 RichCell {
216 row: 0,
217 col: 1,
218 row_span: 1,
219 col_span: 1,
220 text: "b".into(),
221 is_header: true,
222 },
223 RichCell {
224 row: 1,
225 col: 0,
226 row_span: 1,
227 col_span: 1,
228 text: "1".into(),
229 is_header: false,
230 },
231 RichCell {
232 row: 1,
233 col: 1,
234 row_span: 1,
235 col_span: 1,
236 text: "2".into(),
237 is_header: false,
238 },
239 ],
240 };
241 let data = TableElementData::from_structure(structure, ElementMetadata::default());
242 assert_eq!(
243 table_to_markdown_data(&data),
244 "| a | b |\n| --- | --- |\n| 1 | 2 |"
245 );
246 }
247}