Skip to main content

formualizer_common/
numfmt.rs

1use std::sync::OnceLock;
2
3/// The calculation-relevant class of an Excel number-format code.
4#[non_exhaustive]
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub enum FormatClass {
7    General,
8    Number { decimals: u8, thousands: bool },
9    Date,
10    Time,
11    DateTime,
12    Duration,
13    Percent { decimals: u8 },
14    Currency { decimals: u8 },
15    Text,
16    Scientific,
17    Fraction,
18    Other,
19}
20
21/// A classified Excel number-format code.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct NumberFormat {
24    code: Box<str>,
25    class: FormatClass,
26}
27
28impl NumberFormat {
29    /// Classify a format code. Parsing is total; unsupported codes are `Other`.
30    pub fn parse(code: &str) -> Self {
31        let code = canonicalize(code);
32        let class = classify(&code);
33        Self { code, class }
34    }
35
36    pub fn class(&self) -> &FormatClass {
37        &self.class
38    }
39
40    pub fn code(&self) -> &str {
41        &self.code
42    }
43
44    /// Return an OOXML built-in number format (ids 0 through 49).
45    pub fn builtin(id: u16) -> Option<&'static NumberFormat> {
46        builtin_code(id).map(|_| {
47            static BUILTINS: OnceLock<Vec<Option<NumberFormat>>> = OnceLock::new();
48            BUILTINS
49                .get_or_init(|| {
50                    (0..=49)
51                        .map(|candidate| builtin_code(candidate).map(NumberFormat::parse))
52                        .collect()
53                })
54                .get(id as usize)
55                .and_then(Option::as_ref)
56                .expect("known builtin id")
57        })
58    }
59}
60
61fn canonicalize(code: &str) -> Box<str> {
62    code.trim().into()
63}
64
65fn decimal_count(section: &str) -> u8 {
66    let visible = visible_format(section);
67    let Some(dot) = visible.find('.') else {
68        return 0;
69    };
70    visible[dot + 1..]
71        .chars()
72        .take_while(|ch| matches!(ch, '0' | '#' | '?'))
73        .count()
74        .min(u8::MAX as usize) as u8
75}
76
77fn visible_format(code: &str) -> String {
78    let mut out = String::with_capacity(code.len());
79    let mut chars = code.chars().peekable();
80    let mut quoted = false;
81    while let Some(ch) = chars.next() {
82        if quoted {
83            if ch == '"' {
84                quoted = false;
85            }
86            continue;
87        }
88        match ch {
89            '"' => quoted = true,
90            '\\' | '_' | '*' => {
91                chars.next();
92            }
93            '[' => {
94                let mut bracket = String::new();
95                for next in chars.by_ref() {
96                    if next == ']' {
97                        break;
98                    }
99                    bracket.push(next);
100                }
101                let lower = bracket.to_ascii_lowercase();
102                if lower.chars().all(|c| matches!(c, 'h' | 'm' | 's' | ':'))
103                    && lower.chars().any(|c| matches!(c, 'h' | 'm' | 's'))
104                {
105                    out.push('[');
106                    out.push_str(&lower);
107                    out.push(']');
108                }
109            }
110            _ => out.push(ch.to_ascii_lowercase()),
111        }
112    }
113    out
114}
115
116fn classify(code: &str) -> FormatClass {
117    if code.eq_ignore_ascii_case("general") {
118        return FormatClass::General;
119    }
120    let first = code.split(';').next().unwrap_or(code);
121    let visible = visible_format(first);
122    if visible.trim() == "@" {
123        return FormatClass::Text;
124    }
125    if visible.contains("[h]") || visible.contains("[m]") || visible.contains("[s]") {
126        return FormatClass::Duration;
127    }
128
129    let am_pm = visible.contains("am/pm") || visible.contains("a/p");
130    let has_year = visible.contains('y');
131    let has_day = visible.contains('d');
132    let has_hour = visible.contains('h');
133    let has_second = visible.contains('s');
134    // `m` is a month beside date tokens and minutes beside time tokens.
135    let has_date = has_year || has_day;
136    let has_time = has_hour || has_second || am_pm;
137    if has_date && has_time {
138        return FormatClass::DateTime;
139    }
140    if has_date {
141        return FormatClass::Date;
142    }
143    if has_time {
144        return FormatClass::Time;
145    }
146    if visible.contains('%') {
147        return FormatClass::Percent {
148            decimals: decimal_count(first),
149        };
150    }
151    if visible.contains("e+") || visible.contains("e-") {
152        return FormatClass::Scientific;
153    }
154    if visible.contains('/') && visible.chars().any(|ch| ch == '?' || ch == '#') {
155        return FormatClass::Fraction;
156    }
157    let currency = visible.contains('$')
158        || visible.contains('€')
159        || visible.contains('£')
160        || visible.contains('¥');
161    if currency {
162        return FormatClass::Currency {
163            decimals: decimal_count(first),
164        };
165    }
166    if visible.chars().any(|ch| matches!(ch, '0' | '#' | '?')) {
167        return FormatClass::Number {
168            decimals: decimal_count(first),
169            thousands: visible.contains(','),
170        };
171    }
172    FormatClass::Other
173}
174
175/// OOXML built-in format codes. IDs 23-36 are locale-dependent/reserved.
176pub fn builtin_code(id: u16) -> Option<&'static str> {
177    match id {
178        0 => Some("General"),
179        1 => Some("0"),
180        2 => Some("0.00"),
181        3 => Some("#,##0"),
182        4 => Some("#,##0.00"),
183        5 => Some("$#,##0_);($#,##0)"),
184        6 => Some("$#,##0_);[Red]($#,##0)"),
185        7 => Some("$#,##0.00_);($#,##0.00)"),
186        8 => Some("$#,##0.00_);[Red]($#,##0.00)"),
187        9 => Some("0%"),
188        10 => Some("0.00%"),
189        11 => Some("0.00E+00"),
190        12 => Some("# ?/?"),
191        13 => Some("# ??/??"),
192        14 => Some("m/d/yy"),
193        15 => Some("d-mmm-yy"),
194        16 => Some("d-mmm"),
195        17 => Some("mmm-yy"),
196        18 => Some("h:mm AM/PM"),
197        19 => Some("h:mm:ss AM/PM"),
198        20 => Some("h:mm"),
199        21 => Some("h:mm:ss"),
200        22 => Some("m/d/yy h:mm"),
201        37 => Some("#,##0_);(#,##0)"),
202        38 => Some("#,##0_);[Red](#,##0)"),
203        39 => Some("#,##0.00_);(#,##0.00)"),
204        40 => Some("#,##0.00_);[Red](#,##0.00)"),
205        41 => Some("_(* #,##0_);_(* (#,##0);_(* \"-\"_);_(@_)"),
206        42 => Some("_($* #,##0_);_($* (#,##0);_($* \"-\"_);_(@_)"),
207        43 => Some("_(* #,##0.00_);_(* (#,##0.00);_(* \"-\"??_);_(@_)"),
208        44 => Some("_($* #,##0.00_);_($* (#,##0.00);_($* \"-\"??_);_(@_)"),
209        45 => Some("mm:ss"),
210        46 => Some("[h]:mm:ss"),
211        47 => Some("mmss.0"),
212        48 => Some("##0.0E+0"),
213        49 => Some("@"),
214        _ => None,
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn classifies_temporal_codes_without_being_fooled_by_literals() {
224        assert_eq!(
225            NumberFormat::parse("yyyy-mm-dd").class(),
226            &FormatClass::Date
227        );
228        assert_eq!(NumberFormat::parse("h:mm:ss").class(), &FormatClass::Time);
229        assert_eq!(
230            NumberFormat::parse("yyyy-mm-dd hh:mm").class(),
231            &FormatClass::DateTime
232        );
233        assert_eq!(
234            NumberFormat::parse("[h]:mm:ss").class(),
235            &FormatClass::Duration
236        );
237        assert_eq!(
238            NumberFormat::parse("0.00 \"days\"").class(),
239            &FormatClass::Number {
240                decimals: 2,
241                thousands: false
242            }
243        );
244    }
245
246    #[test]
247    fn classifies_non_temporal_codes() {
248        assert_eq!(
249            NumberFormat::parse("General").class(),
250            &FormatClass::General
251        );
252        assert_eq!(NumberFormat::parse("@").class(), &FormatClass::Text);
253        assert_eq!(
254            NumberFormat::parse("0.00%").class(),
255            &FormatClass::Percent { decimals: 2 }
256        );
257        assert_eq!(
258            NumberFormat::parse("0.00E+00").class(),
259            &FormatClass::Scientific
260        );
261        assert_eq!(
262            NumberFormat::parse("# ??/??").class(),
263            &FormatClass::Fraction
264        );
265        assert_eq!(
266            NumberFormat::parse("$#,##0.00").class(),
267            &FormatClass::Currency { decimals: 2 }
268        );
269    }
270
271    #[test]
272    fn builtin_table_has_expected_generic_classes() {
273        assert_eq!(
274            NumberFormat::builtin(0).unwrap().class(),
275            &FormatClass::General
276        );
277        assert_eq!(
278            NumberFormat::builtin(14).unwrap().class(),
279            &FormatClass::Date
280        );
281        assert_eq!(
282            NumberFormat::builtin(21).unwrap().class(),
283            &FormatClass::Time
284        );
285        assert_eq!(
286            NumberFormat::builtin(22).unwrap().class(),
287            &FormatClass::DateTime
288        );
289        assert_eq!(
290            NumberFormat::builtin(46).unwrap().class(),
291            &FormatClass::Duration
292        );
293        assert_eq!(
294            NumberFormat::builtin(49).unwrap().class(),
295            &FormatClass::Text
296        );
297        assert!(NumberFormat::builtin(23).is_none());
298        assert!(NumberFormat::builtin(50).is_none());
299    }
300}