1use joaju_csv::Dataset;
22use regex::Regex;
23use std::collections::HashMap;
24use thiserror::Error;
25
26#[derive(Error, Debug)]
27pub enum TableError {
28 #[error("CSV error: {0}")]
29 Csv(#[from] joaju_csv::CsvError),
30
31 #[error("Empty table or no data")]
32 EmptyTable,
33
34 #[error("Format error: {0}")]
35 FormatError(String),
36}
37
38#[derive(Debug, Clone, Default)]
39pub struct TableConfig {
40 pub caption: Option<String>,
41 pub source: Option<String>,
42 pub note: Option<String>,
43 pub columns: Option<String>,
44 pub align: Option<String>,
45 pub layout_width: Option<String>,
46}
47
48impl TableConfig {
49 pub fn from_directives(directives: &HashMap<String, String>) -> Self {
50 let caption = directives
51 .get("caption")
52 .or_else(|| directives.get("title"))
53 .or_else(|| directives.get("titulo"))
54 .cloned();
55
56 let source = directives
57 .get("source")
58 .or_else(|| directives.get("fonte"))
59 .cloned();
60
61 let note = directives
62 .get("note")
63 .or_else(|| directives.get("nota"))
64 .cloned();
65
66 let columns = directives.get("columns").cloned();
67 let align = directives.get("align").cloned();
68 let layout_width = directives
69 .get("layout.width")
70 .or_else(|| directives.get("width"))
71 .cloned();
72
73 Self {
74 caption,
75 source,
76 note,
77 columns,
78 align,
79 layout_width,
80 }
81 }
82}
83
84pub fn render_dataset_to_typst(
85 dataset: &Dataset,
86 config: &TableConfig,
87) -> Result<String, TableError> {
88 if dataset.row_count() == 0 && dataset.column_count() == 0 {
89 return Err(TableError::EmptyTable);
90 }
91
92 let num_cols = dataset.column_count();
93 let headers = dataset.headers();
94 let rows = dataset.rows();
95
96 let mut out = String::new();
97
98 if let Some(ref cap) = config.caption {
99 out.push_str(&format!(
100 "#figure(\n caption: [{}],\n supplement: [Tabela],\n)[\n",
101 cap
102 ));
103 }
104
105 out.push_str(" #align(center)[\n");
106
107 let cols_str = if let Some(ref c) = config.columns {
109 if c.contains(',') {
110 format!("({})", c)
111 } else {
112 c.clone()
113 }
114 } else {
115 num_cols.to_string()
116 };
117
118 let align_str = if let Some(ref a) = config.align {
120 if a.contains(',') {
121 format!(" align: ({}),\n", a)
122 } else {
123 format!(" align: {},\n", a)
124 }
125 } else {
126 String::new()
127 };
128
129 out.push_str(&format!(
130 " #table(\n columns: {},\n{} stroke: none,\n table.hline(stroke: 1.2pt),\n table.header(\n",
131 cols_str, align_str
132 ));
133
134 out.push_str(" ");
136 for (i, cell) in headers.iter().enumerate() {
137 if i > 0 {
138 out.push_str(", ");
139 }
140 out.push_str(&format!("[{}]", cell));
141 }
142 out.push_str("\n ),\n table.hline(stroke: 0.6pt),\n");
143
144 for row in rows {
146 out.push_str(" ");
147 for (i, cell) in row.iter().enumerate() {
148 if i > 0 {
149 out.push_str(", ");
150 }
151 out.push_str(&format!("[{}]", cell));
152 }
153 out.push_str(",\n");
154 }
155
156 out.push_str(" table.hline(stroke: 1.2pt),\n )\n ]\n");
157
158 if config.source.is_some() || config.note.is_some() {
160 out.push_str(" #v(-4pt)\n #align(left)[\n #set text(size: 8.5pt)\n");
161 if let Some(ref s) = config.source {
162 out.push_str(&format!(" Fonte: {}\n", s));
163 }
164 if let Some(ref n) = config.note {
165 out.push_str(&format!(" Nota: {}\n", n));
166 }
167 out.push_str(" ]\n");
168 }
169
170 if config.caption.is_some() {
171 out.push_str("]\n");
172 }
173
174 Ok(out)
175}
176
177pub fn render_table_block(raw_content: &str) -> Result<String, TableError> {
178 let (directives, body) = joaju_csv::split_block_content(raw_content);
179 let config = TableConfig::from_directives(&directives);
180
181 let select_expr = directives.get("select").map(|s| s.as_str());
182 let where_expr = directives.get("where").map(|s| s.as_str());
183
184 let dataset = if let Some(src_path) = directives
185 .get("src")
186 .or_else(|| directives.get("file"))
187 .or_else(|| directives.get("arquivo"))
188 {
189 let ds = Dataset::from_file(src_path)?;
190 ds.apply_query(select_expr, where_expr)?
191 } else {
192 let clean_lines: Vec<&str> = body
193 .lines()
194 .map(|l| l.trim())
195 .filter(|l| !l.is_empty())
196 .filter(|l| !(l.contains("---") && (l.contains('|') || l.contains('+'))))
197 .collect();
198
199 if clean_lines.is_empty() {
200 return Err(TableError::EmptyTable);
201 }
202
203 let first = clean_lines[0];
204 let ds = if first.contains('|') {
205 let mut all_rows = Vec::new();
207 for line in clean_lines {
208 let cells: Vec<String> = line
209 .split('|')
210 .map(|s| s.trim().to_string())
211 .filter(|s| !s.is_empty())
212 .collect();
213 if !cells.is_empty() {
214 all_rows.push(cells);
215 }
216 }
217 if all_rows.is_empty() {
218 return Err(TableError::EmptyTable);
219 }
220 let headers = all_rows[0].clone();
221 let rows = all_rows[1..].to_vec();
222 Dataset::new(headers, rows)
223 } else {
224 let joined = clean_lines.join("\n");
225 Dataset::from_csv(&joined)?
226 };
227
228 ds.apply_query(select_expr, where_expr)?
229 };
230
231 render_dataset_to_typst(&dataset, &config)
232}
233
234pub fn preprocess_tables(markdown: &str) -> String {
235 let re = Regex::new(r"(?s)```table\s*\n(.*?)```").unwrap();
236 re.replace_all(markdown, |caps: ®ex::Captures| {
237 let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
238 match render_table_block(content) {
239 Ok(typst) => format!("\n{}\n", typst.trim()),
240 Err(_) => caps.get(0).unwrap().as_str().to_string(),
241 }
242 })
243 .to_string()
244}
245
246pub use render_table_block as render_single_table;