Skip to main content

datagrid_export/
html.rs

1//! An HTML table, for printing and for pasting into a page.
2//!
3//! Everything a cell says is escaped: a grid shows what its users typed, and
4//! this text is put into a page. Text appears only as element content; the one
5//! attribute that varies, a column's alignment, comes from a fixed list.
6
7use datagrid_core::{CellAlign, ExportAggregate, ExportRow, ExportTable};
8use std::fmt::Write as _;
9
10/// How [`to_html`] writes.
11#[derive(Clone, Debug, Default, PartialEq, Eq)]
12pub struct HtmlOptions {
13    /// A heading above the table, written as its `<caption>`.
14    pub caption: Option<String>,
15    /// Whether to write a whole document with a `<style>` for the page and for
16    /// print, rather than the `<table>` alone. A fragment is for putting into a
17    /// page that has its own styling.
18    pub document: bool,
19    /// The language of a whole document, such as `en` or `de`.
20    pub lang: Option<String>,
21    /// Whether a fragment brings the [`PRINT_CSS`] rules along in a `<style>`
22    /// of its own, for a page that has none for it. A whole document always
23    /// does.
24    pub style: bool,
25}
26
27/// The rules a document needs to print well: the header repeats on every page,
28/// a row stays whole, numbers line up.
29pub const PRINT_CSS: &str = "\
30@page { margin: 12mm; }\
31table.datagrid { width: 100%; border-collapse: collapse; font: 10pt/1.35 system-ui, sans-serif; }\
32table.datagrid caption { margin-bottom: 0.5em; font-weight: 600; text-align: start; }\
33table.datagrid th, table.datagrid td { padding: 3pt 6pt; border-bottom: 0.5pt solid #999; }\
34table.datagrid thead { display: table-header-group; }\
35table.datagrid thead th { border-bottom: 1pt solid #000; text-align: start; }\
36table.datagrid tr { break-inside: avoid; }\
37table.datagrid .end { text-align: end; }\
38table.datagrid .center { text-align: center; }\
39table.datagrid .group th { background: #eee; text-align: start; }\
40table.datagrid .aggregates td, table.datagrid .totals td { font-weight: 600; }\
41table.datagrid .totals td { border-top: 1pt solid #000; }";
42
43/// Writes the table as HTML.
44///
45/// Column groups become the header's upper rows, a group's heading a row of its
46/// own, and aggregates rows that say what they are (`Sum €7,300.50`). Values are
47/// the text the grid shows: a page is read, not parsed.
48///
49/// ```
50/// use datagrid_core::{ExportCell, ExportColumn, ExportRow, ExportTable};
51/// use datagrid_export::{HtmlOptions, to_html};
52///
53/// let table = ExportTable {
54///     columns: vec![ExportColumn::new("note", "Note")],
55///     rows: vec![ExportRow::Data(vec![ExportCell {
56///         value: None,
57///         text: "<b>&</b>".into(),
58///     }])],
59/// };
60/// let html = to_html(&table, &HtmlOptions::default());
61/// assert!(html.contains("&lt;b&gt;&amp;&lt;/b&gt;"));
62/// assert!(!html.contains("<b>"));
63/// ```
64#[must_use]
65pub fn to_html(table: &ExportTable, options: &HtmlOptions) -> String {
66    let mut out = String::new();
67    if options.document {
68        out.push_str("<!doctype html>\n<html");
69        if let Some(lang) = &options.lang {
70            let _ = write!(out, " lang=\"{}\"", escape(lang));
71        }
72        out.push_str(">\n<head><meta charset=\"utf-8\"><style>");
73        out.push_str(PRINT_CSS);
74        out.push_str("</style></head>\n<body>\n");
75    }
76
77    if options.style && !options.document {
78        let _ = writeln!(out, "<style>{PRINT_CSS}</style>");
79    }
80    out.push_str("<table class=\"datagrid\">\n");
81    if let Some(caption) = &options.caption {
82        let _ = writeln!(out, "<caption>{}</caption>", escape(caption));
83    }
84    header(&mut out, table);
85    out.push_str("<tbody>\n");
86    let width = table.columns.len().max(1);
87    for row in &table.rows {
88        match row {
89            ExportRow::Data(cells) => {
90                out.push_str("<tr>");
91                for (index, cell) in cells.iter().enumerate() {
92                    let _ = write!(
93                        out,
94                        "<td{}>{}</td>",
95                        align_attr(table, index),
96                        escape(&cell.text)
97                    );
98                }
99                out.push_str("</tr>\n");
100            }
101            ExportRow::GroupHeader {
102                level,
103                caption,
104                aggregates,
105                ..
106            } => {
107                // A level moves the heading in, as it does on screen.
108                let _ = writeln!(
109                    out,
110                    "<tr class=\"group\"><th colspan=\"{width}\" scope=\"colgroup\" style=\"padding-inline-start:{}em\">{}</th></tr>",
111                    1 + level * 2,
112                    escape(caption)
113                );
114                aggregate_rows(&mut out, table, aggregates, "aggregates");
115            }
116            ExportRow::Aggregates { label, cells, .. } => {
117                let class = if label.is_some() {
118                    "totals"
119                } else {
120                    "aggregates"
121                };
122                aggregate_rows(&mut out, table, cells, class);
123            }
124            _ => {}
125        }
126    }
127    out.push_str("</tbody>\n</table>\n");
128
129    if options.document {
130        out.push_str("</body>\n</html>\n");
131    }
132    out
133}
134
135/// The header: one row per level of column groups, then the labels.
136fn header(out: &mut String, table: &ExportTable) {
137    let levels = table
138        .columns
139        .iter()
140        .map(|column| column.group_path.len())
141        .max()
142        .unwrap_or(0);
143
144    out.push_str("<thead>\n");
145    for level in 0..levels {
146        out.push_str("<tr>");
147        let mut column = 0;
148        while column < table.columns.len() {
149            let here = table.columns.get(column);
150            let label = here.and_then(|column| column.group_path.get(level));
151            // Neighbours under the same label, on this level and every one above
152            // it, are one cell. `get(..=level)` is only there where the column
153            // has a group at this level.
154            let mut span = 1;
155            if label.is_some() {
156                let prefix = here.and_then(|column| column.group_path.get(..=level));
157                while let Some(next) = table.columns.get(column + span) {
158                    if next.group_path.get(..=level) != prefix {
159                        break;
160                    }
161                    span += 1;
162                }
163            }
164            let _ = write!(
165                out,
166                "<th colspan=\"{span}\" scope=\"colgroup\" class=\"center\">{}</th>",
167                label.map_or_else(String::new, |label| escape(label))
168            );
169            column += span;
170        }
171        out.push_str("</tr>\n");
172    }
173    out.push_str("<tr>");
174    for (index, column) in table.columns.iter().enumerate() {
175        let _ = write!(
176            out,
177            "<th scope=\"col\"{}>{}</th>",
178            align_attr(table, index),
179            escape(&column.label)
180        );
181    }
182    out.push_str("</tr>\n</thead>\n");
183}
184
185/// Rows of aggregates, one per aggregate a column has, the first row first.
186fn aggregate_rows(
187    out: &mut String,
188    table: &ExportTable,
189    columns: &[Vec<ExportAggregate>],
190    class: &str,
191) {
192    let rows = columns.iter().map(Vec::len).max().unwrap_or(0);
193    for index in 0..rows {
194        let _ = write!(out, "<tr class=\"{class}\">");
195        for column in 0..table.columns.len() {
196            let text = columns
197                .get(column)
198                .and_then(|aggregates| aggregates.get(index))
199                .filter(|aggregate| aggregate.value.is_some())
200                .map(|aggregate| format!("{} {}", aggregate.name, aggregate.text))
201                .unwrap_or_default();
202            let _ = write!(
203                out,
204                "<td{}>{}</td>",
205                align_attr(table, column),
206                escape(&text)
207            );
208        }
209        out.push_str("</tr>\n");
210    }
211}
212
213/// ` class="end"` for a column aligned at the end, and the like.
214fn align_attr(table: &ExportTable, column: usize) -> &'static str {
215    match table.columns.get(column).map(|column| column.align) {
216        Some(CellAlign::End) => " class=\"end\"",
217        Some(CellAlign::Center) => " class=\"center\"",
218        _ => "",
219    }
220}
221
222/// Escapes text for element content and for a quoted attribute.
223fn escape(text: &str) -> String {
224    let mut out = String::with_capacity(text.len());
225    for c in text.chars() {
226        match c {
227            '&' => out.push_str("&amp;"),
228            '<' => out.push_str("&lt;"),
229            '>' => out.push_str("&gt;"),
230            '"' => out.push_str("&quot;"),
231            '\'' => out.push_str("&#39;"),
232            c => out.push(c),
233        }
234    }
235    out
236}