1use datagrid_core::{CellAlign, ExportAggregate, ExportRow, ExportTable};
8use std::fmt::Write as _;
9
10#[derive(Clone, Debug, Default, PartialEq, Eq)]
12pub struct HtmlOptions {
13 pub caption: Option<String>,
15 pub document: bool,
19 pub lang: Option<String>,
21 pub style: bool,
25}
26
27pub 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#[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 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
135fn 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 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
185fn 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
213fn 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
222fn 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("&"),
228 '<' => out.push_str("<"),
229 '>' => out.push_str(">"),
230 '"' => out.push_str("""),
231 '\'' => out.push_str("'"),
232 c => out.push(c),
233 }
234 }
235 out
236}