Skip to main content

joaju_table/
lib.rs

1//! # joaju-table
2//!
3//! A fast and deterministic Rust library for rendering 3-line academic tables
4//! (ABNT NBR 14724 / IBGE / APA) natively in Typst with tabular data queries.
5//!
6//! > ⚠️ **Early Development Notice (pre-1.0 / alpha)**: This library is currently in early
7//! > active development (`0.1.0`). It is NOT recommended for production use beyond experimental
8//! > testing and research. APIs are subject to breaking changes prior to a stable 1.0.0 release.
9//!
10//! ## Quick Example
11//!
12//! ```rust
13//! use joaju_table::render_table_block;
14//!
15//! let block = "%% caption = Census\nid,name\n1,Ana\n";
16//! let typst = render_table_block(block).unwrap();
17//! assert!(typst.contains("#figure("));
18//! assert!(typst.contains("table.hline"));
19//! ```
20
21use 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    // Configuração de colunas
108    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    // Configuração de alinhamento
119    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    // Cabeçalho
135    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    // Linhas de dados
145    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    // Fonte e Nota de rodapé padrão ABNT
159    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            // Tabela Markdown (| col1 | col2 |)
206            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: &regex::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;