Skip to main content

ravenrs_report/
wizard.rs

1//! The report wizard: a finished table report from a data source and a choice
2//! of fields, like the wizards of commercial report designers. Also lists the
3//! fields a data source offers, for field lists.
4
5use serde_json::Value as Json;
6
7use crate::model::{
8    Band, Control, DataSource, Group, PageSettings, Report, Style, Table, TableRow, TextAlign,
9};
10
11/// A field of a data source, as a field list shows it.
12#[derive(Clone, Debug, PartialEq)]
13pub struct FieldInfo {
14    /// The path used in expressions, such as `Customer.Name`.
15    pub path: String,
16    /// What the field holds.
17    pub kind: FieldKind,
18}
19
20/// The kind of value a field holds, guessed from sample data.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum FieldKind {
24    /// Text.
25    Text,
26    /// A number.
27    Number,
28    /// A date (ISO 8601 text).
29    Date,
30    /// Yes or no.
31    Boolean,
32    /// A nested list of rows (master-detail).
33    Collection,
34}
35
36/// The rows of `data` at `data_member` (or the data itself).
37pub fn rows_of<'a>(data: &'a Json, data_member: Option<&str>) -> Vec<&'a Json> {
38    let base = match data_member.filter(|m| !m.trim().is_empty()) {
39        Some(path) => {
40            let segs: Vec<String> = path.split('.').map(|s| s.trim().to_owned()).collect();
41            crate::engine::lookup(Some(data), &segs)
42        }
43        None => Some(data),
44    };
45    match base {
46        Some(Json::Array(items)) => items.iter().collect(),
47        Some(Json::Null) | None => Vec::new(),
48        Some(other) => vec![other],
49    }
50}
51
52/// The fields of the rows of `data`, from the first few rows, nested objects
53/// flattened to `Parent.Child`.
54pub fn fields(data: &Json, data_member: Option<&str>) -> Vec<FieldInfo> {
55    let mut out: Vec<FieldInfo> = Vec::new();
56    for row in rows_of(data, data_member).into_iter().take(20) {
57        collect(row, "", &mut out);
58    }
59    out
60}
61
62fn collect(value: &Json, prefix: &str, out: &mut Vec<FieldInfo>) {
63    let Json::Object(map) = value else { return };
64    for (key, v) in map {
65        let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") };
66        if let Json::Object(_) = v {
67            collect(v, &path, out);
68            continue;
69        }
70        let kind = match v {
71            Json::Number(_) => FieldKind::Number,
72            Json::Bool(_) => FieldKind::Boolean,
73            Json::Array(_) => FieldKind::Collection,
74            Json::String(s) if crate::DateTime::parse(s).is_some() => FieldKind::Date,
75            Json::Null => continue,
76            _ => FieldKind::Text,
77        };
78        if !out.iter().any(|f| f.path == path) {
79            out.push(FieldInfo { path, kind });
80        }
81    }
82}
83
84/// A readable caption for a field path: `UnitPrice` → `Unit Price`, `Customer.Name` → `Name`.
85pub fn caption(path: &str) -> String {
86    let last = path.rsplit('.').next().unwrap_or(path);
87    let mut out = String::new();
88    let mut prev_lower = false;
89    for c in last.chars() {
90        if c == '_' {
91            out.push(' ');
92            prev_lower = false;
93            continue;
94        }
95        if c.is_uppercase() && prev_lower {
96            out.push(' ');
97        }
98        prev_lower = c.is_lowercase() || c.is_ascii_digit();
99        out.push(c);
100    }
101    out
102}
103
104/// The default format for a field kind in a culture-neutral way.
105pub fn default_format(kind: FieldKind) -> Option<&'static str> {
106    match kind {
107        FieldKind::Date => Some("d"),
108        _ => None,
109    }
110}
111
112/// Options of the wizard.
113#[derive(Clone, Debug)]
114pub struct WizardOptions {
115    /// The report title.
116    pub title: String,
117    /// Culture for formats (`pt-BR`, `en-US`).
118    pub culture: String,
119    /// The fields to show, as columns, in order.
120    pub columns: Vec<FieldInfo>,
121    /// Group rows by this field, with a header and subtotals.
122    pub group_by: Option<String>,
123    /// Number fields that get totals (group subtotals and a grand total).
124    pub totals: Vec<String>,
125    /// Paper; landscape when there are many columns.
126    pub page: PageSettings,
127    /// The accent color of headers.
128    pub accent: String,
129}
130
131impl Default for WizardOptions {
132    fn default() -> Self {
133        Self {
134            title: "Report".into(),
135            culture: "en-US".into(),
136            columns: Vec::new(),
137            group_by: None,
138            totals: Vec::new(),
139            page: PageSettings::a4(),
140            accent: "#3f57db".into(),
141        }
142    }
143}
144
145/// What a column of a table report says besides its field. Anything left out follows
146/// from the field.
147#[derive(Clone, Debug, Default, PartialEq, Eq)]
148pub struct ColumnWords {
149    /// The text of its header.
150    pub caption: Option<String>,
151    /// The format of its values, such as `R$ #,##0.00`.
152    pub format: Option<String>,
153}
154
155/// Builds a table report: title, column headers repeated on each page, zebra
156/// rows, optional grouping with subtotals, grand totals, page numbers.
157pub fn table_report(source: DataSource, options: &WizardOptions) -> Report {
158    table_report_with(source, options, &[])
159}
160
161/// Like [`table_report`], with what each column says given by the caller, in the order
162/// of [`WizardOptions::columns`].
163pub fn table_report_with(source: DataSource, options: &WizardOptions, words: &[ColumnWords]) -> Report {
164    let page = if options.columns.len() > 6 { options.page.landscape() } else { options.page };
165    let width = page.content_width();
166    // Text columns get more room than numbers and dates.
167    let weights: Vec<f32> =
168        options.columns.iter().map(|c| if matches!(c.kind, FieldKind::Text) { 2.0 } else { 1.0 }).collect();
169    let total_weight: f32 = weights.iter().sum::<f32>().max(1.0);
170    let widths: Vec<f32> = weights.iter().map(|w| (width * w / total_weight).floor()).collect();
171    let align = |kind: FieldKind| match kind {
172        FieldKind::Number => TextAlign::Right,
173        FieldKind::Boolean | FieldKind::Date => TextAlign::Center,
174        _ => TextAlign::Left,
175    };
176    let pt = options.culture.to_lowercase().starts_with("pt");
177    // Whole-number columns show no decimals.
178    let rows_data = match &source.kind {
179        crate::model::DataSourceKind::Json { data } => rows_of(data, None),
180        _ => Vec::new(),
181    };
182    let whole: Vec<String> = options
183        .columns
184        .iter()
185        .filter(|c| {
186            c.kind == FieldKind::Number
187                && rows_data.iter().take(200).all(|r| {
188                    let segs: Vec<String> = c.path.split('.').map(str::to_owned).collect();
189                    crate::engine::lookup(Some(r), &segs)
190                        .and_then(Json::as_f64)
191                        .is_none_or(|n| n.fract() == 0.0)
192                })
193        })
194        .map(|c| c.path.clone())
195        .collect();
196    let told_format = |i: usize| words.get(i).and_then(|w| w.format.as_deref());
197    let number_format = |path: &str| if whole.iter().any(|w| w == path) { "N0" } else { "N2" };
198    let mut header = TableRow::new(22.0);
199    let mut detail = TableRow::new(18.0);
200    for (i, (c, w)) in options.columns.iter().zip(&widths).enumerate() {
201        let title = words.get(i).and_then(|w| w.caption.clone()).unwrap_or_else(|| caption(&c.path));
202        header = header.text(*w, &title.to_uppercase()).cell_style(Style::new().align(align(c.kind)));
203        let expr = match c.kind {
204            FieldKind::Boolean if pt => format!("Iif([{}], 'Sim', 'Não')", c.path),
205            FieldKind::Boolean => format!("Iif([{}], 'Yes', 'No')", c.path),
206            _ => format!("[{}]", c.path),
207        };
208        let format = told_format(i).or(match c.kind {
209            FieldKind::Number => Some(number_format(&c.path)),
210            kind => default_format(kind),
211        });
212        detail = detail.field(*w, &expr, format).cell_style(Style::new().align(align(c.kind)));
213    }
214    let header = header.style_name("th");
215    let detail = detail.style_name("td");
216    let totals_row = |label: &str, style: &str| {
217        let mut row = TableRow::new(20.0);
218        for (i, (c, w)) in options.columns.iter().zip(&widths).enumerate() {
219            row = if options.totals.contains(&c.path) {
220                let format = told_format(i).unwrap_or_else(|| number_format(&c.path));
221                row.field(*w, &format!("Sum([{}])", c.path), Some(format))
222                    .cell_style(Style::new().align(TextAlign::Right))
223            } else if i == 0 {
224                row.text(*w, label)
225            } else {
226                row.text(*w, "")
227            };
228        }
229        row.style_name(style)
230    };
231    let mut report = Report::new(&options.title)
232        .culture(&options.culture)
233        .page(page)
234        .data_source(source)
235        .style(
236            "th",
237            Style::new().font_size(8.0).bold().color("#ffffff").background(&options.accent).padding(4.0),
238        )
239        .style("td", Style::new().font_size(9.0).color("#12151c").padding(4.0))
240        .style(
241            "total",
242            Style::new().font_size(9.0).bold().color("#12151c").padding(4.0).border("top", 1.0, "#12151c"),
243        )
244        .style("odd", Style::new().background("#ffffff"))
245        .style("even", Style::new().background("#f4f5f8"))
246        .report_header(
247            Band::new(48.0)
248                .control(
249                    Control::label(&options.title)
250                        .at(0.0, 0.0, width, 30.0)
251                        .style(Style::new().font_size(20.0).bold().color("#12151c")),
252                )
253                .control(
254                    Control::field(if pt {
255                        "FormatString('{0:N0} registros', Count())"
256                    } else {
257                        "FormatString('{0:N0} records', Count())"
258                    })
259                    .at(0.0, 30.0, width, 16.0)
260                    .style(Style::new().font_size(9.0).color("#5a6375")),
261                ),
262        )
263        .page_header(
264            Band::new(22.0).control(Control::table(Table::new().row(header)).at(0.0, 0.0, width, 22.0)),
265        )
266        .detail(
267            Band::new(18.0)
268                .control(Control::table(Table::new().row(detail)).at(0.0, 0.0, width, 18.0))
269                .zebra("odd", "even"),
270        )
271        .page_footer(
272            Band::new(20.0).control(
273                Control::field(if pt {
274                    "FormatString('Página {0} de {1}', PageNumber(), PageCount())"
275                } else {
276                    "FormatString('Page {0} of {1}', PageNumber(), PageCount())"
277                })
278                .at(0.0, 4.0, width, 14.0)
279                .style(Style::new().font_size(7.5).color("#5a6375").align(TextAlign::Right)),
280            ),
281        );
282    if let Some(field) = &options.group_by {
283        let mut group = Group::new(&format!("[{field}]")).header(
284            Band::new(26.0)
285                .control(
286                    Control::field(format!("[{field}]"))
287                        .at(0.0, 6.0, width, 18.0)
288                        .style(Style::new().font_size(11.0).bold().color(&options.accent)),
289                )
290                .keep_with_next(),
291        );
292        if !options.totals.is_empty() {
293            group = group.footer(Band::new(22.0).control(
294                Control::table(Table::new().row(totals_row("Subtotal", "total"))).at(0.0, 0.0, width, 20.0),
295            ));
296        }
297        report = report.group(group);
298    }
299    if !options.totals.is_empty() {
300        report = report.report_footer(
301            Band::new(28.0).control(
302                Control::table(
303                    Table::new().row(totals_row(if pt { "Total geral" } else { "Grand total" }, "total")),
304                )
305                .at(0.0, 6.0, width, 20.0),
306            ),
307        );
308    }
309    report
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::model::DataSourceKind;
316    use crate::{ReportEngine, samples};
317
318    #[test]
319    fn fields_flatten_objects_and_detect_kinds() {
320        let fields = fields(&samples::invoice_data(), None);
321        let get = |p: &str| fields.iter().find(|f| f.path == p).map(|f| f.kind);
322        assert_eq!(get("Customer.Name"), Some(FieldKind::Text));
323        assert_eq!(get("IssueDate"), Some(FieldKind::Date));
324        assert_eq!(get("Discount"), Some(FieldKind::Number));
325        assert_eq!(get("Items"), Some(FieldKind::Collection));
326        assert_eq!(caption("UnitPrice"), "Unit Price");
327        assert_eq!(caption("Customer.Name"), "Name");
328    }
329
330    #[test]
331    fn wizard_builds_a_grouped_report_with_totals() {
332        let data = samples::sales_data(120);
333        let all = fields(&data, None);
334        let pick = |p: &str| all.iter().find(|f| f.path == p).cloned().expect(p);
335        let options = WizardOptions {
336            title: "Vendas".into(),
337            culture: "pt-BR".into(),
338            columns: vec![pick("Customer"), pick("Product"), pick("Quantity"), pick("Paid")],
339            group_by: Some("Region".into()),
340            totals: vec!["Quantity".into()],
341            ..WizardOptions::default()
342        };
343        let report = table_report(
344            DataSource { name: "Vendas".into(), kind: DataSourceKind::Json { data: data.clone() } },
345            &options,
346        );
347        let doc = ReportEngine::new().render(&report, report.embedded_data().unwrap_or(&data));
348        assert!(doc.errors.is_empty(), "{:?}", doc.errors);
349        assert!(doc.page_contains(0, "CUSTOMER") && doc.page_contains(0, "Vendas"));
350        assert!(doc.page_contains(0, "120 registros"));
351        let last = doc.pages.len() - 1;
352        assert!(doc.page_contains(last, "Total geral"));
353        assert!(doc.page_contains(last, &format!("Página {} de {}", last + 1, doc.pages.len())));
354    }
355}