Skip to main content

dre_protocol/
style.rs

1//! xlsx styling: the `style:` maps on an output, a query entry (tab) and a column. Shared by core
2//! (which checks a query entry's style and passes it on) and the xlsx plugin (which checks the
3//! output's and applies them all).
4//!
5//! A cell style: `bold`, `italic`, `underline` (true or false), `font` (`{name, size}`),
6//! `font_color` and `fill` (`"#RRGGBB"`), `align` (`left`, `center`, `right`) and `border`
7//! (`none`, `thin`, `medium`, around the cell). A sheet style (output or tab) takes the cell keys
8//! for every data cell, plus `header` and `totals` (cell styles for those rows), `banded_rows`
9//! (a fill for every other data row, or `false`) and `borders` (`none`, `thin`, `medium`: around
10//! every cell of the table). A tab's style merges over the output's, key by key; a column's over
11//! both, for that column's data cells.
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16/// A border's weight.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Border {
20    None,
21    Thin,
22    Medium,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum Align {
28    Left,
29    Center,
30    Right,
31}
32
33/// A font's name and size (points).
34#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Font {
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub name: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub size: Option<f64>,
41}
42
43/// How one cell looks; every key optional.
44#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
45#[serde(default)]
46pub struct CellStyle {
47    pub bold: Option<bool>,
48    pub italic: Option<bool>,
49    pub underline: Option<bool>,
50    pub font: Option<Font>,
51    /// `RRGGBB`.
52    pub font_color: Option<u32>,
53    pub fill: Option<u32>,
54    pub align: Option<Align>,
55    pub border: Option<Border>,
56}
57
58/// `banded_rows`: a fill, or off.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum Band {
62    Off,
63    Fill(u32),
64}
65
66/// How a sheet looks: its data cells, the header and totals rows, banding and borders.
67#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
68#[serde(default)]
69pub struct SheetStyle {
70    pub cells: CellStyle,
71    pub header: CellStyle,
72    pub totals: CellStyle,
73    /// The fill of every other data row, or `Off` (over an inherited banding).
74    pub banded_rows: Option<Band>,
75    pub borders: Option<Border>,
76}
77
78const CELL_KEYS: &[&str] = &[
79    "bold",
80    "italic",
81    "underline",
82    "font",
83    "font_color",
84    "fill",
85    "align",
86    "border",
87];
88const SHEET_KEYS: &[&str] = &["header", "totals", "banded_rows", "borders"];
89
90fn color(key: &str, v: &Value) -> Result<u32, String> {
91    let s = v.as_str().unwrap_or_default();
92    let hex = s.strip_prefix('#').unwrap_or(s);
93    if hex.len() == 6
94        && let Ok(n) = u32::from_str_radix(hex, 16)
95    {
96        return Ok(n);
97    }
98    Err(format!("`{key}` must be a colour like `\"#1F4E78\"`, got {v}"))
99}
100
101fn enum_of<T: for<'a> Deserialize<'a>>(key: &str, v: &Value, choices: &str) -> Result<T, String> {
102    serde_json::from_value(v.clone()).map_err(|_| format!("`{key}` must be one of {choices}, got {v}"))
103}
104
105fn flag(key: &str, v: &Value) -> Result<bool, String> {
106    v.as_bool()
107        .ok_or_else(|| format!("`{key}` must be true or false, got {v}"))
108}
109
110/// One cell key into `s`; `false` when `key` isn't a cell key.
111fn cell_key(s: &mut CellStyle, key: &str, v: &Value, errs: &mut Vec<String>) -> bool {
112    let r: Result<(), String> = match key {
113        "bold" => flag(key, v).map(|b| s.bold = Some(b)),
114        "italic" => flag(key, v).map(|b| s.italic = Some(b)),
115        "underline" => flag(key, v).map(|b| s.underline = Some(b)),
116        "font_color" => color(key, v).map(|c| s.font_color = Some(c)),
117        "fill" => color(key, v).map(|c| s.fill = Some(c)),
118        "align" => enum_of(key, v, "`left`, `center`, `right`").map(|a| s.align = Some(a)),
119        "border" => enum_of(key, v, "`none`, `thin`, `medium`").map(|b| s.border = Some(b)),
120        "font" => serde_json::from_value::<Font>(v.clone())
121            .map_err(|_| format!("`font` must be a map like `{{name: Calibri, size: 11}}`, got {v}"))
122            .and_then(|f| match f.size {
123                Some(sz) if !(1.0..=409.0).contains(&sz) => {
124                    Err("`font.size` must be from 1 to 409 points".into())
125                }
126                _ => Ok(f),
127            })
128            .map(|f| s.font = Some(f)),
129        _ => return false,
130    };
131    if let Err(e) = r {
132        errs.push(e);
133    }
134    true
135}
136
137fn unknown(key: &str, allowed: &[&[&str]]) -> String {
138    let all: Vec<String> = allowed
139        .iter()
140        .flat_map(|k| k.iter())
141        .map(|k| format!("`{k}`"))
142        .collect();
143    format!("unknown style key `{key}`; expected one of {}", all.join(", "))
144}
145
146/// A cell style (a column's `style`, a sheet style's `header` or `totals`), and every problem.
147pub fn parse_cell(v: &Value) -> (CellStyle, Vec<String>) {
148    let mut s = CellStyle::default();
149    let mut errs = Vec::new();
150    let Some(m) = v.as_object() else {
151        return (
152            s,
153            vec![format!(
154                "`style` must be a map like `{{bold: true, fill: \"#FFF2CC\"}}`, got {v}"
155            )],
156        );
157    };
158    for (k, val) in m {
159        if !cell_key(&mut s, k, val, &mut errs) {
160            errs.push(unknown(k, &[CELL_KEYS]));
161        }
162    }
163    (s, errs)
164}
165
166/// A sheet style (an output's or a tab's `style`), and every problem.
167pub fn parse_sheet(v: &Value) -> (SheetStyle, Vec<String>) {
168    let mut s = SheetStyle::default();
169    let mut errs = Vec::new();
170    let Some(m) = v.as_object() else {
171        return (
172            s,
173            vec![format!(
174                "`style` must be a map like `{{banded_rows: \"#F2F2F2\"}}`, got {v}"
175            )],
176        );
177    };
178    for (k, val) in m {
179        match k.as_str() {
180            "header" | "totals" => {
181                let (c, e) = parse_cell(val);
182                errs.extend(e.into_iter().map(|e| format!("`{k}`: {e}")));
183                if k == "header" {
184                    s.header = c;
185                } else {
186                    s.totals = c;
187                }
188            }
189            "banded_rows" => match val {
190                Value::Bool(false) => s.banded_rows = Some(Band::Off),
191                _ => match color(k, val) {
192                    Ok(c) => s.banded_rows = Some(Band::Fill(c)),
193                    Err(_) => errs.push(format!(
194                        "`banded_rows` must be a colour like `\"#F2F2F2\"`, or false, got {val}"
195                    )),
196                },
197            },
198            "borders" => match enum_of(k, val, "`none`, `thin`, `medium`") {
199                Ok(b) => s.borders = Some(b),
200                Err(e) => errs.push(e),
201            },
202            _ => {
203                if !cell_key(&mut s.cells, k, val, &mut errs) {
204                    errs.push(unknown(k, &[CELL_KEYS, SHEET_KEYS]));
205                }
206            }
207        }
208    }
209    (s, errs)
210}
211
212impl CellStyle {
213    /// `self` with every key `over` sets replacing it.
214    pub fn merged(&self, over: &CellStyle) -> CellStyle {
215        let font = match (&self.font, &over.font) {
216            (Some(a), Some(b)) => Some(Font {
217                name: b.name.clone().or_else(|| a.name.clone()),
218                size: b.size.or(a.size),
219            }),
220            (a, b) => b.clone().or_else(|| a.clone()),
221        };
222        CellStyle {
223            bold: over.bold.or(self.bold),
224            italic: over.italic.or(self.italic),
225            underline: over.underline.or(self.underline),
226            font,
227            font_color: over.font_color.or(self.font_color),
228            fill: over.fill.or(self.fill),
229            align: over.align.or(self.align),
230            border: over.border.or(self.border),
231        }
232    }
233
234    pub fn is_empty(&self) -> bool {
235        *self == CellStyle::default()
236    }
237}
238
239impl SheetStyle {
240    /// `self` (the output's) with the tab's style over it.
241    pub fn merged(&self, over: &SheetStyle) -> SheetStyle {
242        SheetStyle {
243            cells: self.cells.merged(&over.cells),
244            header: self.header.merged(&over.header),
245            totals: self.totals.merged(&over.totals),
246            banded_rows: over.banded_rows.or(self.banded_rows),
247            borders: over.borders.or(self.borders),
248        }
249    }
250
251    /// The band fill, if banding is on.
252    pub fn band(&self) -> Option<u32> {
253        match self.banded_rows {
254            Some(Band::Fill(c)) => Some(c),
255            _ => None,
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use serde_json::json;
264
265    #[test]
266    fn parses_checks_and_merges() {
267        let (s, e) = parse_sheet(&json!({
268            "font": {"name": "Arial", "size": 10},
269            "header": {"bold": true, "fill": "#1F4E78", "font_color": "FFFFFF"},
270            "banded_rows": "#F2F2F2",
271            "borders": "thin",
272            "totals": {"fill": "#DDEBF7"}
273        }));
274        assert!(e.is_empty(), "{e:?}");
275        assert_eq!(s.header.fill, Some(0x1F4E78));
276        assert_eq!(s.band(), Some(0xF2F2F2));
277        let (tab, _) = parse_sheet(&json!({"banded_rows": false, "font": {"size": 12}}));
278        let m = s.merged(&tab);
279        assert_eq!(m.band(), None);
280        assert_eq!(
281            m.cells.font,
282            Some(Font {
283                name: Some("Arial".into()),
284                size: Some(12.0)
285            })
286        );
287        let (_, e) =
288            parse_sheet(&json!({"fill": "blue", "borders": "thick", "colour": 1, "header": {"bold": "yes"}}));
289        assert_eq!(e.len(), 4, "{e:?}");
290        let (_, e) = parse_cell(&json!({"banded_rows": "#FFFFFF"}));
291        assert!(e[0].contains("unknown style key `banded_rows`"), "{e:?}");
292    }
293}