Skip to main content

ravenrs_grid_export/
lib.rs

1//! Turns what a [`DataGrid`](ravenrs_widgets::DataGrid) shows into a file. The rows go
2//! through the report engine and its exporters, so a grid exports like a report does.
3//!
4//! ```
5//! use ravenrs_grid_export::export;
6//! use ravenrs_widgets::grid::{GridQuery, to_table};
7//! use ravenrs_widgets::{GridColumn, GridExportFormat};
8//!
9//! let columns = [GridColumn::text("Name"), GridColumn::number("Total")];
10//! let rows = vec![vec!["Ana".into(), 12.5.into()]];
11//! let table = to_table("Sales", "en-US", &columns, &rows, &GridQuery::default(), &[0]);
12//! let file = export(&table, GridExportFormat::Xlsx, Some("2026-11-02"))?;
13//! assert_eq!(file.name, "Sales 2026-11-02.xlsx");
14//! # Ok::<(), String>(())
15//! ```
16
17use ravenrs_report::wizard::{ColumnWords, FieldInfo, FieldKind, WizardOptions, table_report_with};
18use ravenrs_report::{DataSource, DataSourceKind, Document, ReportEngine};
19use ravenrs_report_export::{ExportFormat, ExportedFile};
20use ravenrs_widgets::{GridExportFormat, GridTable, GridTableKind, Value};
21use serde_json::{Map, Value as Json};
22
23/// A field name that nothing in a title can break.
24fn field(column: usize) -> String {
25    format!("c{column}")
26}
27
28fn json_of(value: &Value) -> Json {
29    match value {
30        Value::Text(text) => Json::String(text.to_string()),
31        Value::Number(number) => serde_json::Number::from_f64(*number).map_or(Json::Null, Json::Number),
32        Value::Bool(on) => Json::Bool(*on),
33        Value::Date(date) => {
34            Json::String(format!("{:04}-{:02}-{:02}", date.year(), date.month(), date.day()))
35        }
36        _ => Json::Null,
37    }
38}
39
40/// The format of a number: its decimals, with its text around them.
41fn number_pattern(decimals: usize, prefix: &str, suffix: &str) -> String {
42    let digits = if decimals == 0 { "#,##0".to_owned() } else { format!("#,##0.{}", "0".repeat(decimals)) };
43    format!("{prefix}{digits}{suffix}")
44}
45
46/// `table` laid out as a document, the same one the files are made from.
47pub fn document_of(table: &GridTable) -> Document {
48    let rows: Vec<Json> = table
49        .rows
50        .iter()
51        .map(|row| {
52            let cells = row.iter().enumerate().map(|(c, value)| (field(c), json_of(value)));
53            Json::Object(cells.collect::<Map<_, _>>())
54        })
55        .collect();
56    let mut columns = Vec::with_capacity(table.columns.len());
57    let mut words = Vec::with_capacity(table.columns.len());
58    let mut totals = Vec::new();
59    for (c, column) in table.columns.iter().enumerate() {
60        let (kind, pattern) = match &column.kind {
61            GridTableKind::Number { decimals, prefix, suffix } => {
62                (FieldKind::Number, Some(number_pattern(*decimals, prefix, suffix)))
63            }
64            GridTableKind::Date => (FieldKind::Date, None),
65            GridTableKind::Boolean => (FieldKind::Boolean, None),
66            _ => (FieldKind::Text, None),
67        };
68        columns.push(FieldInfo { path: field(c), kind });
69        words.push(ColumnWords { caption: Some(column.title.clone()), format: pattern });
70        if column.total {
71            totals.push(field(c));
72        }
73    }
74    let options = WizardOptions {
75        title: table.title.clone(),
76        culture: table.culture.clone(),
77        columns,
78        group_by: table.group_by.map(field),
79        totals,
80        ..WizardOptions::default()
81    };
82    let data = Json::Array(rows);
83    let source = DataSource { name: "rows".into(), kind: DataSourceKind::Json { data: data.clone() } };
84    let report = table_report_with(source, &options, &words);
85    ReportEngine::new().render(&report, &data)
86}
87
88/// Writes `table` in `format`. `suffix`, such as a date, goes after the title in the name.
89pub fn export(
90    table: &GridTable,
91    format: GridExportFormat,
92    suffix: Option<&str>,
93) -> Result<ExportedFile, String> {
94    let document = document_of(table);
95    let format = match format {
96        GridExportFormat::Xlsx => ExportFormat::Xlsx,
97        GridExportFormat::Pdf => ExportFormat::Pdf,
98        _ => ExportFormat::Csv,
99    };
100    ravenrs_report_export::export(&document, format, suffix)
101}
102
103#[cfg(test)]
104#[allow(clippy::expect_used)]
105mod tests {
106    use ravenrs_widgets::grid::{GridQuery, to_table};
107    use ravenrs_widgets::{Date, GridColumn, Summary};
108
109    use super::*;
110
111    fn sample() -> GridTable {
112        let columns = [
113            GridColumn::text("Conta. Principal"),
114            GridColumn::currency("Valor", "R$ ", 2).summary(Summary::Sum),
115            GridColumn::date("Data"),
116            GridColumn::text("Oculta").hidden(),
117        ];
118        let date = Date::new(2026, 11, 2).expect("a date");
119        let rows = vec![
120            vec!["Ana".into(), 1234.5.into(), Value::Date(date), "x".into()],
121            vec!["Bia".into(), 10.0.into(), Value::Date(date), "y".into()],
122        ];
123        let query = GridQuery {
124            hidden: columns.iter().map(|c| c.title() == "Oculta").collect(),
125            ..GridQuery::default()
126        };
127        to_table("Contas", "pt-BR", &columns, &rows, &query, &[1, 0])
128    }
129
130    #[test]
131    fn the_table_is_what_the_grid_shows() {
132        let table = sample();
133        assert_eq!(table.columns.len(), 3, "the hidden column stays out");
134        assert_eq!(table.rows[0][0], Value::from("Bia"), "in the grid's order");
135        assert!(table.columns[1].total);
136        assert_eq!(table.columns[2].kind, GridTableKind::Date);
137    }
138
139    #[test]
140    fn numbers_keep_their_text_around_them() {
141        assert_eq!(number_pattern(2, "R$ ", ""), "R$ #,##0.00");
142        assert_eq!(number_pattern(0, "", " un"), "#,##0 un");
143    }
144
145    #[test]
146    fn every_format_makes_a_file() {
147        let table = sample();
148        let pdf = export(&table, GridExportFormat::Pdf, None).expect("a pdf");
149        assert_eq!(pdf.name, "Contas.pdf");
150        assert!(pdf.bytes.starts_with(b"%PDF"));
151        let xlsx = export(&table, GridExportFormat::Xlsx, None).expect("a workbook");
152        assert!(xlsx.bytes.starts_with(b"PK"), "a workbook is a zip");
153        let csv = export(&table, GridExportFormat::Csv, None).expect("a csv");
154        let text = String::from_utf8(csv.bytes).expect("text");
155        assert!(text.contains("CONTA. PRINCIPAL"), "a title with a dot is a caption: {text}");
156        assert!(text.contains("Bia"));
157        assert!(text.contains("R$ 1.234,50"), "the column's format, in pt-BR: {text}");
158    }
159}