1use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16#[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#[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#[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 pub font_color: Option<u32>,
53 pub fill: Option<u32>,
54 pub align: Option<Align>,
55 pub border: Option<Border>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum Band {
62 Off,
63 Fill(u32),
64}
65
66#[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 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
110fn 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
146pub 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
166pub 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 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 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 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}