Skip to main content

oxidize_pdf/pipeline/
export.rs

1use crate::pipeline::{Element, TableElementData};
2
3/// Configuration for element-aware markdown export.
4#[derive(Debug, Clone)]
5pub struct ExportConfig {
6    /// Include Header and Footer elements in output (default: false).
7    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/// Exports a slice of [`Element`]s to Markdown format.
19#[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    /// Export elements to a Markdown string.
30    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
80/// Structure-aware table export: when `data.structure` reveals a multi-level
81/// header (`header_rows > 1`), collapse the first `header_rows` rows into a
82/// single GFM header row, joining each column's header texts top-to-bottom
83/// with " › " (skipping empties and consecutive duplicates so a vertically-
84/// or horizontally-merged header cell that is already repeated across the
85/// flat `rows` view doesn't render as "X › X"). Body rows start right after
86/// the header rows; merged body cells are already repeated in `rows`, so
87/// their rendering is unchanged.
88///
89/// When `structure` is absent or `header_rows <= 1`, delegates to
90/// [`table_to_markdown`] — behavior for single/no-header tables is unchanged.
91fn 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        // "Region" spans both columns over row 0; row 1 sub-headers "Q1"/"Q2".
128        // Dedup must prevent "Region › Region" leaking through.
129        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        // header_rows == 1: same behavior as the plain no-structure path.
202        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}