Skip to main content

ravenrs_report_export/
lib.rs

1//! One way to turn a report [`Document`] into a file. The editor, the server and
2//! any other module call this, so a report exports the same from anywhere.
3//!
4//! ```
5//! use ravenrs_report::{Band, Control, Report, ReportEngine};
6//! use ravenrs_report_export::{ExportFormat, export};
7//! use serde_json::json;
8//!
9//! let report = Report::new("Vendas").detail(Band::new(18.0).control(Control::field("[N]").at(0.0, 0.0, 200.0, 18.0)));
10//! let document = ReportEngine::new().render(&report, &json!([{ "N": "Ana" }]));
11//! let file = export(&document, ExportFormat::Pdf, Some("2026-11-01"))?;
12//! assert_eq!(file.name, "Vendas 2026-11-01.pdf");
13//! assert_eq!(file.mime, "application/pdf");
14//! # Ok::<(), String>(())
15//! ```
16
17use ravenrs_report::Document;
18use serde::{Deserialize, Serialize};
19
20/// A format a document can be exported to.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23#[non_exhaustive]
24pub enum ExportFormat {
25    /// PDF, vector pages with embedded fonts.
26    Pdf,
27    /// Excel workbook, with real numbers and dates in the cells.
28    Xlsx,
29    /// Comma- or semicolon-separated text, as printed.
30    Csv,
31}
32
33impl ExportFormat {
34    /// The file extension.
35    pub fn extension(self) -> &'static str {
36        match self {
37            Self::Pdf => "pdf",
38            Self::Xlsx => "xlsx",
39            Self::Csv => "csv",
40        }
41    }
42
43    /// The media type.
44    pub fn mime(self) -> &'static str {
45        match self {
46            Self::Pdf => "application/pdf",
47            Self::Xlsx => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
48            Self::Csv => "text/csv; charset=utf-8",
49        }
50    }
51}
52
53/// A file ready to be saved or sent.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct ExportedFile {
56    /// The format.
57    pub format: ExportFormat,
58    /// A name that is safe on every file system.
59    pub name: String,
60    /// The media type.
61    pub mime: &'static str,
62    /// The content.
63    pub bytes: Vec<u8>,
64}
65
66/// Writes `document` in `format`. `suffix`, such as a date, goes after the title in the name.
67pub fn export(
68    document: &Document,
69    format: ExportFormat,
70    suffix: Option<&str>,
71) -> Result<ExportedFile, String> {
72    let bytes = match format {
73        ExportFormat::Pdf => ravenrs_report_pdf::to_pdf(document).map_err(|e| e.to_string())?,
74        ExportFormat::Xlsx => ravenrs_report_xlsx::to_xlsx(document).map_err(|e| e.to_string())?,
75        // A byte order mark, so Excel reads the accents as UTF-8.
76        ExportFormat::Csv => format!("\u{feff}{}", ravenrs_report_xlsx::to_csv(document)).into_bytes(),
77    };
78    Ok(ExportedFile { format, name: file_name(&document.title, suffix, format), mime: format.mime(), bytes })
79}
80
81/// A title as a file name, keeping accents and dropping what file systems refuse.
82pub fn file_name(title: &str, suffix: Option<&str>, format: ExportFormat) -> String {
83    let clean: String = title
84        .chars()
85        .map(|c| if c.is_control() || r#"<>:"/\|?*"#.contains(c) { ' ' } else { c })
86        .take(100)
87        .collect();
88    let clean = clean.trim().trim_matches('.').trim();
89    let stem = if clean.is_empty() { "report" } else { clean };
90    match suffix {
91        Some(suffix) => format!("{stem} {suffix}.{}", format.extension()),
92        None => format!("{stem}.{}", format.extension()),
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn file_names_drop_what_file_systems_refuse() {
102        assert_eq!(
103            file_name("Vendas: Sul/Norte", Some("2026-11-01"), ExportFormat::Pdf),
104            "Vendas  Sul Norte 2026-11-01.pdf"
105        );
106        assert_eq!(file_name("Relatório.", None, ExportFormat::Xlsx), "Relatório.xlsx");
107        assert_eq!(file_name("../..", None, ExportFormat::Csv), "report.csv");
108        assert_eq!(file_name("  ?? ", None, ExportFormat::Csv), "report.csv");
109    }
110
111    #[test]
112    fn each_format_gives_a_file_of_its_kind() -> Result<(), String> {
113        let document = Document { title: "T".to_owned(), ..Document::default() };
114        assert!(export(&document, ExportFormat::Pdf, None)?.bytes.starts_with(b"%PDF-"));
115        assert!(export(&document, ExportFormat::Xlsx, None)?.bytes.starts_with(b"PK"));
116        assert!(export(&document, ExportFormat::Csv, None)?.bytes.starts_with("\u{feff}".as_bytes()));
117        Ok(())
118    }
119
120    #[test]
121    fn formats_read_as_lowercase_names() {
122        assert_eq!(serde_json::from_str::<ExportFormat>("\"pdf\"").ok(), Some(ExportFormat::Pdf));
123        assert!(serde_json::from_str::<ExportFormat>("\"docx\"").is_err());
124    }
125}