Skip to main content

ravenrs_sheet/model/
style.rs

1//! Cell styles (font, fill, borders, alignment, number format, protection), interned per
2//! workbook.
3
4use std::collections::HashMap;
5use std::hash::{Hash, Hasher};
6use std::sync::Arc;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
11pub struct StyleId(pub u32);
12
13impl StyleId {
14    pub const DEFAULT: StyleId = StyleId(0);
15}
16
17/// A colour: explicit RGB, a theme slot with tint, or automatic (black text / no fill).
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
19pub enum Color {
20    #[default]
21    Auto,
22    /// 0xRRGGBB
23    Rgb(u32),
24    /// Theme index (0 = Background 1 / lt1, 1 = Text 1 / dk1, 2 = lt2, 3 = dk2, 4–9 = Accent 1–6,
25    /// 10 hyperlink, 11 followed) and tint in thousandths (-1000..=1000).
26    Theme(u8, i16),
27}
28
29impl Color {
30    pub fn rgb(r: u8, g: u8, b: u8) -> Color {
31        Color::Rgb(((r as u32) << 16) | ((g as u32) << 8) | b as u32)
32    }
33    pub fn from_hex(s: &str) -> Option<Color> {
34        let h = s.trim().trim_start_matches('#');
35        let h = if h.len() == 8 { h.get(2..)? } else { h };
36        if h.len() != 6 {
37            return None;
38        }
39        u32::from_str_radix(h, 16).ok().map(Color::Rgb)
40    }
41    /// Resolved RGB, `None` for automatic.
42    pub fn resolve(&self, theme: &Theme) -> Option<[u8; 3]> {
43        match *self {
44            Color::Auto => None,
45            Color::Rgb(v) => Some([(v >> 16) as u8, (v >> 8) as u8, v as u8]),
46            Color::Theme(i, tint) => {
47                let base = theme.colors.get(i as usize).copied().unwrap_or(0);
48                Some(apply_tint([(base >> 16) as u8, (base >> 8) as u8, base as u8], tint as f64 / 1000.0))
49            }
50        }
51    }
52    pub fn hex(&self, theme: &Theme) -> Option<String> {
53        self.resolve(theme).map(|[r, g, b]| format!("#{r:02X}{g:02X}{b:02X}"))
54    }
55}
56
57/// Tint as in ECMA-376: lighten towards white (positive) or darken (negative) in HSL luminance.
58pub fn apply_tint(rgb: [u8; 3], tint: f64) -> [u8; 3] {
59    if tint == 0.0 {
60        return rgb;
61    }
62    let (h, s, l) = rgb_to_hsl(rgb);
63    let l = if tint < 0.0 { l * (1.0 + tint) } else { l * (1.0 - tint) + tint };
64    hsl_to_rgb(h, s, l.clamp(0.0, 1.0))
65}
66
67fn rgb_to_hsl([r, g, b]: [u8; 3]) -> (f64, f64, f64) {
68    let (r, g, b) = (r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0);
69    let max = r.max(g).max(b);
70    let min = r.min(g).min(b);
71    let l = (max + min) / 2.0;
72    if max == min {
73        return (0.0, 0.0, l);
74    }
75    let d = max - min;
76    let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
77    let h = if max == r {
78        (g - b) / d + if g < b { 6.0 } else { 0.0 }
79    } else if max == g {
80        (b - r) / d + 2.0
81    } else {
82        (r - g) / d + 4.0
83    };
84    (h / 6.0, s, l)
85}
86
87fn hsl_to_rgb(h: f64, s: f64, l: f64) -> [u8; 3] {
88    if s == 0.0 {
89        let v = (l * 255.0).round() as u8;
90        return [v, v, v];
91    }
92    let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
93    let p = 2.0 * l - q;
94    let hue = |mut t: f64| {
95        if t < 0.0 {
96            t += 1.0;
97        }
98        if t > 1.0 {
99            t -= 1.0;
100        }
101        if t < 1.0 / 6.0 {
102            p + (q - p) * 6.0 * t
103        } else if t < 0.5 {
104            q
105        } else if t < 2.0 / 3.0 {
106            p + (q - p) * (2.0 / 3.0 - t) * 6.0
107        } else {
108            p
109        }
110    };
111    [(hue(h + 1.0 / 3.0) * 255.0).round() as u8, (hue(h) * 255.0).round() as u8, (hue(h - 1.0 / 3.0) * 255.0).round() as u8]
112}
113
114/// Workbook theme: 12 colours and the heading/body fonts.
115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
116pub struct Theme {
117    pub name: String,
118    /// lt1, dk1, lt2, dk2, accent1..6, hlink, folHlink (0xRRGGBB).
119    pub colors: [u32; 12],
120    pub major_font: String,
121    pub minor_font: String,
122    /// The heading font's East Asian typeface (`<a:ea>`); empty when the file did not set one.
123    #[serde(default)]
124    pub major_font_ea: String,
125    /// The body font's East Asian typeface (`<a:ea>`); empty when the file did not set one.
126    #[serde(default)]
127    pub minor_font_ea: String,
128    /// The heading font's complex-script typeface (`<a:cs>`); empty when the file did not set one.
129    #[serde(default)]
130    pub major_font_cs: String,
131    /// The body font's complex-script typeface (`<a:cs>`); empty when the file did not set one.
132    #[serde(default)]
133    pub minor_font_cs: String,
134    /// The heading font's per-script typefaces (`<a:font script="Arab" typeface="…"/>`), as
135    /// (script, typeface) in file order.
136    #[serde(default)]
137    pub major_font_scripts: Vec<(String, String)>,
138    /// The body font's per-script typefaces, as (script, typeface) in file order.
139    #[serde(default)]
140    pub minor_font_scripts: Vec<(String, String)>,
141}
142
143impl Default for Theme {
144    /// GridCraft's own default theme ("Craft"): original palette. The East Asian and
145    /// complex-script typefaces stay empty: the file did not set one.
146    fn default() -> Self {
147        Theme {
148            name: "Craft".into(),
149            colors: [0xFFFFFF, 0x000000, 0xE8E8E8, 0x0E2841, 0x156082, 0xE97132, 0x196B24, 0x0F9ED5, 0xA02B93, 0x4EA72E, 0x467886, 0x96607D],
150            major_font: crate::model::DEFAULT_FONT.into(),
151            minor_font: crate::model::DEFAULT_FONT.into(),
152            major_font_ea: String::new(),
153            minor_font_ea: String::new(),
154            major_font_cs: String::new(),
155            minor_font_cs: String::new(),
156            major_font_scripts: Vec::new(),
157            minor_font_scripts: Vec::new(),
158        }
159    }
160}
161
162#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
163pub enum Underline {
164    #[default]
165    None,
166    Single,
167    Double,
168    SingleAccounting,
169    DoubleAccounting,
170}
171
172#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
173pub enum VertAlign {
174    #[default]
175    Baseline,
176    Superscript,
177    Subscript,
178}
179
180#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
181pub struct Font {
182    pub name: String,
183    pub size: f32,
184    pub bold: bool,
185    pub italic: bool,
186    pub underline: Underline,
187    pub strike: bool,
188    pub color: Color,
189    pub vert: VertAlign,
190    /// Windows character set the font was chosen for (178 = Arabic); kept so Excel substitutes a font for the same script.
191    #[serde(default)]
192    pub charset: Option<u8>,
193    /// Font family class (1 roman, 2 swiss, 3 modern…); `None` is swiss, the default.
194    #[serde(default)]
195    pub family: Option<u8>,
196    /// The font follows the theme's body font (`<scheme val="minor"/>`) while its face is the
197    /// theme's minor font. Fonts derived from it (bold, a colour, a size) keep the link.
198    #[serde(default = "default_minor")]
199    pub minor: bool,
200}
201
202fn default_minor() -> bool {
203    true
204}
205
206impl Eq for Font {}
207impl Hash for Font {
208    fn hash<H: Hasher>(&self, h: &mut H) {
209        self.name.hash(h);
210        self.size.to_bits().hash(h);
211        (self.bold, self.italic, self.underline, self.strike, self.color, self.vert, self.charset, self.family, self.minor).hash(h);
212    }
213}
214
215impl Default for Font {
216    fn default() -> Self {
217        Font {
218            name: crate::model::DEFAULT_FONT.into(),
219            size: crate::model::DEFAULT_FONT_SIZE,
220            bold: false,
221            italic: false,
222            underline: Underline::None,
223            strike: false,
224            color: Color::Auto,
225            vert: VertAlign::Baseline,
226            charset: None,
227            family: None,
228            minor: true,
229        }
230    }
231}
232
233#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
234pub enum PatternType {
235    #[default]
236    None,
237    Solid,
238    Gray50,
239    Gray75,
240    Gray25,
241    Gray125,
242    Gray0625,
243    DarkHorizontal,
244    DarkVertical,
245    DarkDown,
246    DarkUp,
247    DarkGrid,
248    DarkTrellis,
249    LightHorizontal,
250    LightVertical,
251    LightDown,
252    LightUp,
253    LightGrid,
254    LightTrellis,
255}
256
257#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
258pub struct Fill {
259    pub pattern: PatternType,
260    /// Pattern foreground (the solid colour for `Solid`).
261    pub fg: Color,
262    pub bg: Color,
263}
264
265impl Fill {
266    pub fn solid(c: Color) -> Fill {
267        Fill { pattern: PatternType::Solid, fg: c, bg: Color::Auto }
268    }
269}
270
271#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
272pub enum BorderStyle {
273    #[default]
274    None,
275    Thin,
276    Medium,
277    Thick,
278    Dashed,
279    Dotted,
280    Double,
281    Hair,
282    MediumDashed,
283    DashDot,
284    MediumDashDot,
285    DashDotDot,
286    MediumDashDotDot,
287    SlantDashDot,
288}
289
290impl BorderStyle {
291    pub fn width(&self) -> f32 {
292        match self {
293            BorderStyle::None => 0.0,
294            BorderStyle::Hair | BorderStyle::Thin | BorderStyle::Dashed | BorderStyle::Dotted | BorderStyle::DashDot | BorderStyle::DashDotDot => 1.0,
295            BorderStyle::Medium
296            | BorderStyle::MediumDashed
297            | BorderStyle::MediumDashDot
298            | BorderStyle::MediumDashDotDot
299            | BorderStyle::SlantDashDot => 2.0,
300            BorderStyle::Thick | BorderStyle::Double => 3.0,
301        }
302    }
303}
304
305#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
306pub struct BorderLine {
307    pub style: BorderStyle,
308    pub color: Color,
309}
310
311impl BorderLine {
312    pub fn thin() -> BorderLine {
313        BorderLine { style: BorderStyle::Thin, color: Color::Auto }
314    }
315    pub fn is_none(&self) -> bool {
316        self.style == BorderStyle::None
317    }
318}
319
320#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
321pub struct Borders {
322    pub left: BorderLine,
323    pub right: BorderLine,
324    pub top: BorderLine,
325    pub bottom: BorderLine,
326    pub diag_down: BorderLine,
327    pub diag_up: BorderLine,
328}
329
330#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
331pub enum HAlign {
332    #[default]
333    General,
334    Left,
335    Center,
336    Right,
337    Fill,
338    Justify,
339    CenterAcross,
340    Distributed,
341}
342
343#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
344pub enum VAlign {
345    Top,
346    Center,
347    #[default]
348    Bottom,
349    Justify,
350    Distributed,
351}
352
353#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
354pub struct Alignment {
355    pub h: HAlign,
356    pub v: VAlign,
357    pub wrap: bool,
358    pub shrink: bool,
359    pub indent: u8,
360    /// Degrees -90..=90, or 255 for vertical stacked text.
361    pub rotation: i16,
362}
363
364#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
365pub struct Protection {
366    pub locked: bool,
367    pub hidden: bool,
368}
369
370impl Default for Protection {
371    fn default() -> Self {
372        Protection { locked: true, hidden: false }
373    }
374}
375
376#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
377pub struct Style {
378    pub font: Font,
379    pub fill: Fill,
380    pub borders: Borders,
381    pub align: Alignment,
382    /// Number format code; "General" by default.
383    pub num_fmt: NumFmt,
384    pub protection: Protection,
385    /// Differential formats only.
386    #[serde(default)]
387    pub dxf_stated: DxfStated,
388}
389
390/// The parts a differential format states at a value its `Style` can't tell from unset: a dxf's
391/// default parts are unset, so a bold turned off, a face, a size or a protection that matches the
392/// defaults would otherwise read as nothing. The values are the style's own; this records that
393/// the format states them, and only where they match the defaults, so a format built in the app
394/// and the same format read back from a file stay equal.
395#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
396pub struct DxfStated {
397    pub bold: bool,
398    pub italic: bool,
399    pub strike: bool,
400    pub underline: bool,
401    pub size: bool,
402    pub name: bool,
403    /// `<scheme val>`: the theme font the face follows (`major`, `minor` or `none`).
404    pub scheme: Option<String>,
405    pub protection: bool,
406}
407
408#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
409pub struct NumFmt(pub Arc<str>);
410
411impl Default for NumFmt {
412    fn default() -> Self {
413        NumFmt(Arc::from("General"))
414    }
415}
416
417impl NumFmt {
418    pub fn new(s: &str) -> NumFmt {
419        NumFmt(Arc::from(s))
420    }
421    pub fn as_str(&self) -> &str {
422        &self.0
423    }
424}
425
426/// Interned styles. Index 0 is the default (Normal) style.
427#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
428pub struct StyleTable {
429    styles: Vec<Style>,
430    #[serde(skip)]
431    index: HashMap<Style, StyleId>,
432}
433
434impl Default for StyleTable {
435    fn default() -> Self {
436        let d = Style::default();
437        let mut index = HashMap::new();
438        index.insert(d.clone(), StyleId::DEFAULT);
439        StyleTable { styles: vec![d], index }
440    }
441}
442
443impl StyleTable {
444    /// A table whose default (Normal) style is `normal`, such as a file's own Normal font.
445    pub fn with_default(normal: Style) -> Self {
446        let mut index = HashMap::new();
447        index.insert(normal.clone(), StyleId::DEFAULT);
448        StyleTable { styles: vec![normal], index }
449    }
450    pub fn get(&self, id: StyleId) -> &Style {
451        self.styles.get(id.0 as usize).or_else(|| self.styles.first()).unwrap_or(&DEFAULT_STYLE)
452    }
453    pub fn intern(&mut self, s: Style) -> StyleId {
454        if self.index.is_empty() && !self.styles.is_empty() {
455            self.rebuild_index();
456        }
457        if let Some(id) = self.index.get(&s) {
458            return *id;
459        }
460        let id = StyleId(self.styles.len() as u32);
461        self.index.insert(s.clone(), id);
462        self.styles.push(s);
463        id
464    }
465    /// The style `id` changed by `f`, interned.
466    pub fn derive(&mut self, id: StyleId, f: impl FnOnce(&mut Style)) -> StyleId {
467        let mut s = self.get(id).clone();
468        f(&mut s);
469        self.intern(s)
470    }
471    pub fn len(&self) -> usize {
472        self.styles.len()
473    }
474    pub fn is_empty(&self) -> bool {
475        self.styles.is_empty()
476    }
477    pub fn iter(&self) -> impl Iterator<Item = (StyleId, &Style)> {
478        self.styles.iter().enumerate().map(|(i, s)| (StyleId(i as u32), s))
479    }
480    pub fn rebuild_index(&mut self) {
481        self.index = self.styles.iter().enumerate().map(|(i, s)| (s.clone(), StyleId(i as u32))).collect();
482    }
483}
484
485static DEFAULT_STYLE: std::sync::LazyLock<Style> = std::sync::LazyLock::new(Style::default);
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn interning() {
493        let mut t = StyleTable::default();
494        let bold = t.derive(StyleId::DEFAULT, |s| s.font.bold = true);
495        assert_ne!(bold, StyleId::DEFAULT);
496        assert_eq!(t.derive(StyleId::DEFAULT, |s| s.font.bold = true), bold);
497        assert_eq!(t.derive(bold, |s| s.font.bold = false), StyleId::DEFAULT);
498        assert_eq!(t.len(), 2);
499        assert!(!t.get(StyleId(999)).font.bold);
500    }
501
502    #[test]
503    fn a_theme_saved_before_per_script_fonts_still_opens() -> Result<(), Box<dyn std::error::Error>> {
504        let mut json = serde_json::to_value(Theme::default())?;
505        let fields = json.as_object_mut().ok_or("none")?;
506        assert!(fields.remove("major_font_scripts").is_some() && fields.remove("minor_font_scripts").is_some());
507        assert_eq!(serde_json::from_value::<Theme>(json)?, Theme::default());
508        Ok(())
509    }
510
511    #[test]
512    fn colors() -> Result<(), Box<dyn std::error::Error>> {
513        let th = Theme::default();
514        assert_eq!(Color::from_hex("#FF0000").ok_or("none")?.resolve(&th), Some([255, 0, 0]));
515        assert_eq!(Color::from_hex("FF00FF00").ok_or("none")?.resolve(&th), Some([0, 255, 0]));
516        assert_eq!(Color::Theme(0, -500).resolve(&th), Some([128, 128, 128]));
517        assert_eq!(Color::Theme(1, 500).resolve(&th), Some([128, 128, 128]));
518        assert_eq!(Color::Auto.resolve(&th), None);
519        Ok(())
520    }
521
522    #[test]
523    fn theme_json_keeps_ea_and_cs_and_reads_old_json() -> Result<(), Box<dyn std::error::Error>> {
524        // JSON written before the ea/cs fields existed still deserializes (`serde(default)`).
525        let mut old = serde_json::to_value(Theme::default())?;
526        let obj = old.as_object_mut().ok_or("none")?;
527        for key in ["major_font_ea", "minor_font_ea", "major_font_cs", "minor_font_cs"] {
528            assert!(obj.remove(key).is_some(), "the default theme serializes {key}");
529        }
530        assert_eq!(serde_json::from_value::<Theme>(old)?, Theme::default());
531
532        // Set fields serialize and come back.
533        let t = Theme {
534            major_font_ea: "游ゴシック".into(),
535            minor_font_ea: "MS Pゴシック".into(),
536            major_font_cs: "Arial".into(),
537            minor_font_cs: "Times New Roman".into(),
538            ..Theme::default()
539        };
540        let json = serde_json::to_string(&t)?;
541        assert!(json.contains("major_font_ea") && json.contains("游ゴシック"), "{json}");
542        assert_eq!(serde_json::from_str::<Theme>(&json)?, t);
543        Ok(())
544    }
545}