Skip to main content

excelize_rs/
numfmt.rs

1//! Number format parser and applier.
2//!
3//! Ports the most commonly used parts of Go `numfmt.go`. It maps built-in
4//! number-format IDs to their format strings and can apply many custom format
5//! codes for numbers, percentages, scientific notation, fractions, currency,
6//! accounting, dates and times.
7
8use std::collections::HashMap;
9use std::sync::LazyLock;
10
11use crate::date::excel_serial_to_datetime;
12
13/// Built-in number format map (English localization).
14///
15/// These IDs are reserved by Excel and do not need to be stored in
16/// `xl/styles.xml`.
17pub static BUILT_IN_NUM_FMT: LazyLock<HashMap<i32, &'static str>> = LazyLock::new(|| {
18    let mut m = HashMap::new();
19    m.insert(0, "General");
20    m.insert(1, "0");
21    m.insert(2, "0.00");
22    m.insert(3, "#,##0");
23    m.insert(4, "#,##0.00");
24    m.insert(9, "0%");
25    m.insert(10, "0.00%");
26    m.insert(11, "0.00E+00");
27    m.insert(12, "# ?/?");
28    m.insert(13, "# ??/??");
29    m.insert(14, "mm-dd-yy");
30    m.insert(15, "d-mmm-yy");
31    m.insert(16, "d-mmm");
32    m.insert(17, "mmm-yy");
33    m.insert(18, "h:mm AM/PM");
34    m.insert(19, "h:mm:ss AM/PM");
35    m.insert(20, "hh:mm");
36    m.insert(21, "hh:mm:ss");
37    m.insert(22, "m/d/yy hh:mm");
38    m.insert(37, "#,##0 ;(#,##0)");
39    m.insert(38, "#,##0 ;[Red](#,##0)");
40    m.insert(39, "#,##0.00 ;(#,##0.00)");
41    m.insert(40, "#,##0.00 ;[Red](#,##0.00)");
42    m.insert(45, "mm:ss");
43    m.insert(46, "[h]:mm:ss");
44    m.insert(47, "mm:ss.0");
45    m.insert(48, "##0.0E+0");
46    m.insert(49, "@");
47    m
48});
49
50/// Return the built-in format code for an ID, if one exists.
51pub fn built_in_num_fmt_code(num_fmt_id: i32) -> Option<&'static str> {
52    BUILT_IN_NUM_FMT.get(&num_fmt_id).copied()
53}
54
55/// Apply a number format to a numeric value.
56///
57/// * `value` - the raw numeric cell value (Excel serial for dates/times).
58/// * `num_fmt_id` - Excel number format ID.
59/// * `format_code` - Optional explicit format code (used for custom formats
60///   with IDs >= 164 or when overriding a built-in ID).
61/// * `date1904` - Whether the workbook uses the 1904 date system.
62///
63/// Falls back to the value formatted with default precision when the format
64/// code is not supported.
65pub fn apply_number_format(
66    value: f64,
67    num_fmt_id: i32,
68    format_code: Option<&str>,
69    date1904: bool,
70) -> String {
71    let code = format_code
72        .filter(|c| !c.is_empty())
73        .or_else(|| built_in_num_fmt_code(num_fmt_id))
74        .unwrap_or("General");
75
76    apply_format_code(value, code, date1904)
77}
78
79/// Apply a format code directly to a numeric value without an Excel numFmt ID.
80///
81/// This is the entry point used by formula functions such as `TEXT`, where the
82/// format is supplied as a string rather than a style ID.
83pub fn format_number(value: f64, code: &str, date1904: bool) -> String {
84    apply_format_code(value, code, date1904)
85}
86
87fn apply_format_code(value: f64, code: &str, date1904: bool) -> String {
88    let code = code.trim();
89    if code.eq_ignore_ascii_case("General") || code.is_empty() {
90        return format_general(value);
91    }
92
93    // Excel number formats can contain up to four semicolon-separated sections.
94    // Select the appropriate section for the value, strip color/condition
95    // metadata, and remember whether the selected section is the dedicated
96    // negative section so we avoid adding a duplicate minus sign.
97    let (section, is_negative_section, switch_argument, locale_code) =
98        choose_format_section(code, value);
99    let abs_value = value.abs();
100
101    let mut result = if is_date_format(&section) {
102        if let Ok(dt) = excel_serial_to_datetime(abs_value, date1904) {
103            format_date_time(&section, abs_value, dt, locale_code.as_deref())
104        } else {
105            abs_value.to_string()
106        }
107    } else {
108        format_numeric(&section, abs_value)
109    };
110
111    // If the selected section is not the dedicated negative section but the
112    // value is negative, prepend a minus sign (matching Go's usePositive
113    // behavior when no negative section exists).
114    if !is_negative_section && value < 0.0 && result != "0" {
115        result = format!("-{}", result);
116    }
117
118    // Apply East Asian DBNum switch arguments.
119    if let Some(sw) = switch_argument {
120        result = apply_switch_argument(&result, &sw);
121    }
122
123    result
124}
125
126fn apply_switch_argument(result: &str, sw: &str) -> String {
127    match sw.to_ascii_uppercase().as_str() {
128        "DBNUM1" => result
129            .chars()
130            .map(|c| match c {
131                '0' => '\u{25CB}',
132                '1' => '\u{4E00}',
133                '2' => '\u{4E8C}',
134                '3' => '\u{4E09}',
135                '4' => '\u{56DB}',
136                '5' => '\u{4E94}',
137                '6' => '\u{516D}',
138                '7' => '\u{4E03}',
139                '8' => '\u{516B}',
140                '9' => '\u{4E5D}',
141                _ => c,
142            })
143            .collect(),
144        "DBNUM2" => result
145            .chars()
146            .map(|c| match c {
147                '0' => '\u{96F6}',
148                '1' => '\u{58F9}',
149                '2' => '\u{8D30}',
150                '3' => '\u{53C1}',
151                '4' => '\u{8086}',
152                '5' => '\u{4F0D}',
153                '6' => '\u{9646}',
154                '7' => '\u{67D2}',
155                '8' => '\u{634C}',
156                '9' => '\u{7396}',
157                _ => c,
158            })
159            .collect(),
160        "DBNUM3" => result
161            .chars()
162            .map(|c| match c {
163                '0' => '\u{FF10}',
164                '1' => '\u{FF11}',
165                '2' => '\u{FF12}',
166                '3' => '\u{FF13}',
167                '4' => '\u{FF14}',
168                '5' => '\u{FF15}',
169                '6' => '\u{FF16}',
170                '7' => '\u{FF17}',
171                '8' => '\u{FF18}',
172                '9' => '\u{FF19}',
173                _ => c,
174            })
175            .collect(),
176        _ => result.to_string(),
177    }
178}
179
180fn format_general(value: f64) -> String {
181    if value.fract() == 0.0 && value.abs() < 1e15 {
182        format!("{:.0}", value)
183    } else {
184        format!("{}", value)
185    }
186}
187
188/// Check whether a format code contains date/time tokens.
189fn is_date_format(code: &str) -> bool {
190    // Scientific notation stays numeric even though it contains `E`.
191    let upper = code.to_ascii_uppercase();
192    if upper.contains("E+0") || upper.contains("E-0") {
193        return false;
194    }
195    // A section is a date/time format when it contains date/time token
196    // letters (y, d, h, s, m, e, g) outside quoted literals and escapes, or
197    // elapsed-time tokens ([h], [m], [s]) in brackets. Mirrors nfp's section
198    // typing in Go, which treats standalone month/weekday tokens such as
199    // `mmm` or `mmmmm` as date formats too.
200    let mut chars = code.chars().peekable();
201    while let Some(c) = chars.next() {
202        match c {
203            '"' => {
204                for c in chars.by_ref() {
205                    if c == '"' {
206                        break;
207                    }
208                }
209            }
210            '\\' => {
211                chars.next();
212            }
213            '[' => {
214                let mut content = String::new();
215                for c in chars.by_ref() {
216                    if c == ']' {
217                        break;
218                    }
219                    content.push(c);
220                }
221                if matches!(content.to_ascii_lowercase().as_str(), "h" | "m" | "s") {
222                    return true;
223                }
224            }
225            'y' | 'Y' | 'd' | 'D' | 'h' | 'H' | 's' | 'S' | 'm' | 'M' | 'e' | 'E' | 'g' | 'G' => {
226                return true;
227            }
228            _ => {}
229        }
230    }
231    false
232}
233
234// ------------------------------------------------------------------
235// Format-section splitting and selection
236// ------------------------------------------------------------------
237
238/// Split a number format code into its semicolon-separated sections, respecting
239/// quoted literals and escaped characters.
240fn split_format_sections(code: &str) -> Vec<String> {
241    let mut sections: Vec<String> = vec![String::new()];
242    let mut in_quote = false;
243    let mut chars = code.chars().peekable();
244
245    while let Some(ch) = chars.next() {
246        if ch == '"' {
247            in_quote = !in_quote;
248            sections.last_mut().unwrap().push(ch);
249        } else if ch == '\\' && !in_quote {
250            sections.last_mut().unwrap().push(ch);
251            if let Some(next) = chars.next() {
252                sections.last_mut().unwrap().push(next);
253            }
254        } else if ch == ';' && !in_quote {
255            sections.push(String::new());
256        } else {
257            sections.last_mut().unwrap().push(ch);
258        }
259    }
260
261    sections
262}
263
264/// A condition that may prefix a number-format section, e.g. `[>100]`.
265#[derive(Debug, Clone, PartialEq)]
266enum FormatCondition {
267    Greater(f64),
268    GreaterOrEqual(f64),
269    Less(f64),
270    LessOrEqual(f64),
271    Equal(f64),
272    NotEqual(f64),
273}
274
275impl FormatCondition {
276    /// Return `true` if the value satisfies this condition.
277    fn matches(&self, value: f64) -> bool {
278        match self {
279            FormatCondition::Greater(threshold) => value > *threshold,
280            FormatCondition::GreaterOrEqual(threshold) => value >= *threshold,
281            FormatCondition::Less(threshold) => value < *threshold,
282            FormatCondition::LessOrEqual(threshold) => value <= *threshold,
283            FormatCondition::Equal(threshold) => value == *threshold,
284            FormatCondition::NotEqual(threshold) => value != *threshold,
285        }
286    }
287}
288
289/// Parse a leading bracketed condition from a format-section body.
290fn parse_condition(section: &str) -> (Option<FormatCondition>, &str) {
291    let Some(rest) = section.strip_prefix('[') else {
292        return (None, section);
293    };
294    let Some(end) = rest.find(']') else {
295        return (None, section);
296    };
297    let token = &rest[..end];
298    let after = &rest[end + 1..];
299
300    let op_and_value = if token.starts_with(">=") {
301        Some((">=", &token[2..]))
302    } else if token.starts_with("<>") {
303        // `<>` is the not-equal operator and must be checked before `<=`/`<`.
304        Some(("<>", &token[2..]))
305    } else if token.starts_with("<=") {
306        Some(("<=", &token[2..]))
307    } else if token.starts_with('<') {
308        Some(("<", &token[1..]))
309    } else if token.starts_with('>') {
310        Some((">", &token[1..]))
311    } else if token.starts_with('=') {
312        Some(("=", &token[1..]))
313    } else {
314        None
315    };
316
317    if let Some((op, val_str)) = op_and_value {
318        let val_str = val_str.trim();
319        if let Ok(value) = val_str.parse::<f64>() {
320            let condition = match op {
321                ">" => FormatCondition::Greater(value),
322                ">=" => FormatCondition::GreaterOrEqual(value),
323                "<" => FormatCondition::Less(value),
324                "<=" => FormatCondition::LessOrEqual(value),
325                "=" => FormatCondition::Equal(value),
326                "<>" => FormatCondition::NotEqual(value),
327                _ => return (None, section),
328            };
329            return (Some(condition), after);
330        }
331    }
332
333    (None, section)
334}
335
336/// Return `true` if `token` is an Excel color name such as `Red` or `Color5`.
337fn is_color_token(token: &str) -> bool {
338    const NAMED_COLORS: &[&str] = &[
339        "Black", "Blue", "Cyan", "Green", "Magenta", "Red", "White", "Yellow",
340    ];
341    if NAMED_COLORS
342        .iter()
343        .any(|c| c.eq_ignore_ascii_case(token))
344    {
345        return true;
346    }
347    // `[Color1]` .. `[Color56]` are also valid color specifiers.
348    if token.len() >= 6 && token[..5].eq_ignore_ascii_case("Color") {
349        token[5..].chars().all(|c| c.is_ascii_digit())
350    } else {
351        false
352    }
353}
354
355/// Return `true` if `token` is a currency/language bracket such as `$-409`
356/// or `€-40C`. These brackets carry locale/currency metadata and should not
357/// be printed as literal text.
358fn is_currency_language_token(token: &str) -> bool {
359    // Common currency symbols.
360    if token.contains('$')
361        || token.contains('€')
362        || token.contains('£')
363        || token.contains('¥')
364    {
365        return true;
366    }
367    // Locale ID like `-409` or `-804` (hex digits).
368    if token.starts_with('-') && token[1..].chars().all(|c| c.is_ascii_hexdigit()) {
369        return true;
370    }
371    // Special locale aliases used by Excel.
372    matches!(token.to_ascii_lowercase().as_str(), "f800" | "f400" | "x-sysdate" | "x-systime" | "1010000")
373}
374
375/// Extract a currency symbol from a currency/language bracket token such as
376/// `$$` (`$`) or `$€-40C` (`€`). Returns `None` for locale-only brackets like
377/// `$-409`.
378fn extract_currency_symbol(token: &str) -> Option<&str> {
379    // Special case: `$$` means a literal dollar sign.
380    if token == "$$" {
381        return Some("$");
382    }
383    // Non-dollar currency symbols inside the bracket are explicit currency
384    // strings (e.g. `$€-40C` carries the Euro symbol).
385    for sym in &["€", "£", "¥"] {
386        if let Some(pos) = token.find(sym) {
387            let len = sym.len();
388            return Some(&token[pos..pos + len]);
389        }
390    }
391    // `$-409` and similar are locale-only brackets; do not emit the dollar sign.
392    None
393}
394
395/// Extract a DBNum switch argument such as `[DBNum1]`.
396fn extract_switch_argument(token: &str) -> Option<String> {
397    if token.len() >= 6 && token[..5].eq_ignore_ascii_case("DBNum") {
398        if token[5..].chars().all(|c| c.is_ascii_digit()) {
399            return Some(token.to_string());
400        }
401    }
402    None
403}
404
405/// Extract the locale ID from a currency/language bracket token such as
406/// `$-409` or `€-40C`.
407fn extract_locale_code(token: &str) -> Option<String> {
408    if token.starts_with('-') {
409        return Some(token[1..].to_string());
410    }
411    if let Some(pos) = token.find('-') {
412        return Some(token[pos + 1..].to_string());
413    }
414    None
415}
416
417/// Strip leading color, condition, currency/language and DBNum bracket tokens
418/// from a format section.
419fn strip_section_metadata(section: &str) -> (Option<FormatCondition>, String, Option<String>, Option<String>) {
420    let mut section = section;
421    let mut condition: Option<FormatCondition> = None;
422    let mut currency_prefix = String::new();
423    let mut switch_argument: Option<String> = None;
424    let mut locale_code: Option<String> = None;
425
426    while let Some(rest) = section.strip_prefix('[') {
427        let Some(end) = rest.find(']') else { break };
428        let token = &rest[..end];
429
430        // Conditions take precedence: only the first condition in a section is
431        // meaningful, but additional color tokens may appear before or after it.
432        if condition.is_none() {
433            let (cond, after) = parse_condition(section);
434            if let Some(c) = cond {
435                condition = Some(c);
436                section = after;
437                continue;
438            }
439        }
440
441        if is_color_token(token) {
442            section = &rest[end + 1..];
443            continue;
444        }
445
446        if is_currency_language_token(token) {
447            if let Some(sym) = extract_currency_symbol(token) {
448                currency_prefix.push_str(sym);
449            }
450            if let Some(code) = extract_locale_code(token) {
451                locale_code = Some(code);
452            }
453            section = &rest[end + 1..];
454            continue;
455        }
456
457        if let Some(sw) = extract_switch_argument(token) {
458            switch_argument = Some(sw);
459            section = &rest[end + 1..];
460            continue;
461        }
462
463        // Anything else (elapsed-time codes, etc.) is part of the format body
464        // and stops metadata stripping.
465        break;
466    }
467
468    let mut body = currency_prefix;
469    body.push_str(section);
470    (condition, body, switch_argument, locale_code)
471}
472
473/// Select the format section that applies to a numeric value.
474///
475/// Mirrors Go's positional section selection: conditions such as `[>100]`
476/// are stripped as metadata but never evaluated. Non-negative values always
477/// use the first (positive) section; negative values use the second section
478/// when present (rendered with the absolute value), otherwise the positive
479/// section with a minus sign prepended by the caller.
480fn choose_format_section(code: &str, value: f64) -> (String, bool, Option<String>, Option<String>) {
481    let sections = split_format_sections(code);
482    let parsed: Vec<(Option<FormatCondition>, String, Option<String>, Option<String>)> =
483        sections.iter().map(|s| strip_section_metadata(s)).collect();
484
485    let selected_idx = if value >= 0.0 {
486        0
487    } else if parsed.len() >= 2 {
488        1
489    } else {
490        0
491    };
492    let is_negative = value < 0.0 && selected_idx == 1;
493    (parsed[selected_idx].1.clone(), is_negative, parsed[selected_idx].2.clone(), parsed[selected_idx].3.clone())
494}
495
496/// Check whether a number-format pattern is suitable for use as a date/time
497/// pattern.
498///
499/// This is a minimal token scanner (not a full `nfp` parser). It rejects
500/// patterns that contain numeric-only tokens such as `0`, `#`, `?`, `%`, the
501/// text placeholder `@`, scientific notation (`E+0`, `E-0`) or fractions
502/// (`?/?`). Quoted literals, escaped characters, bracketed expressions
503/// (colors, locales, elapsed time) and common date/time separators are
504/// allowed. An empty pattern is allowed.
505pub fn is_date_time_pattern(pattern: &str) -> bool {
506    if pattern.is_empty() {
507        return true;
508    }
509    for section in pattern.split(';') {
510        if !is_date_time_section(section) {
511            return false;
512        }
513    }
514    true
515}
516
517fn is_date_time_section(section: &str) -> bool {
518    let mut chars = section.chars().peekable();
519    let mut prev: Option<char> = None;
520
521    while let Some(ch) = chars.next() {
522        match ch {
523            '"' => {
524                if !consume_quoted_literal(&mut chars) {
525                    return false;
526                }
527                prev = Some('"');
528            }
529            '\\' => {
530                if chars.next().is_none() {
531                    return false;
532                }
533                prev = Some('\\');
534            }
535            '[' => {
536                if !consume_bracketed_expr(&mut chars) {
537                    return false;
538                }
539                prev = Some(']');
540            }
541            '@' | '0' | '#' | '?' | '%' => {
542                return false;
543            }
544            '/' => {
545                // Reject slashes that look like fraction operators (adjacent
546                // to numeric placeholders). Slashes used as date separators
547                // (e.g. `m/d/yyyy`) are surrounded by date/time letters and
548                // are allowed.
549                if prev.map_or(false, is_numeric_placeholder) {
550                    return false;
551                }
552                if chars.peek().copied().map_or(false, is_numeric_placeholder) {
553                    return false;
554                }
555                prev = Some('/');
556            }
557            'e' | 'E' => {
558                // Reject scientific notation E+0 / E-0.
559                if let Some(&next) = chars.peek() {
560                    if next == '+' || next == '-' {
561                        let mut tmp = chars.clone();
562                        tmp.next();
563                        if tmp.peek().copied().map_or(false, is_numeric_placeholder) {
564                            return false;
565                        }
566                    }
567                }
568                prev = Some(ch);
569            }
570            _ => {
571                prev = Some(ch);
572            }
573        }
574    }
575    true
576}
577
578fn consume_quoted_literal(chars: &mut std::iter::Peekable<impl Iterator<Item = char>>) -> bool {
579    for c in chars {
580        if c == '"' {
581            return true;
582        }
583    }
584    false
585}
586
587fn consume_bracketed_expr(chars: &mut std::iter::Peekable<impl Iterator<Item = char>>) -> bool {
588    for c in chars {
589        if c == ']' {
590            return true;
591        }
592    }
593    false
594}
595
596fn is_numeric_placeholder(c: char) -> bool {
597    matches!(c, '0' | '#' | '?')
598}
599
600// ------------------------------------------------------------------
601// Numeric formatting
602// ------------------------------------------------------------------
603
604/// Remove commas that appear after the last digit placeholder; each such
605/// comma scales the value down by a factor of 1000. Mirrors Go's
606/// `scalingFactor` handling in `applyThousandsSeparatorToken`.
607fn strip_scaling_commas(code: &str) -> (usize, String) {
608    let mut last_placeholder: Option<usize> = None;
609    let mut chars = code.char_indices().peekable();
610    while let Some((i, c)) = chars.next() {
611        match c {
612            '"' => {
613                for (_, c) in chars.by_ref() {
614                    if c == '"' {
615                        break;
616                    }
617                }
618            }
619            '\\' => {
620                chars.next();
621            }
622            '0' | '#' | '?' => last_placeholder = Some(i),
623            _ => {}
624        }
625    }
626    let Some(last) = last_placeholder else {
627        return (0, code.to_string());
628    };
629    let mut factor = 0;
630    let mut result = String::with_capacity(code.len());
631    let mut in_quote = false;
632    let mut escaped = false;
633    for (i, c) in code.chars().enumerate() {
634        if escaped {
635            result.push(c);
636            escaped = false;
637            continue;
638        }
639        match c {
640            '\\' => {
641                result.push(c);
642                escaped = true;
643            }
644            '"' => {
645                result.push(c);
646                in_quote = !in_quote;
647            }
648            ',' if !in_quote && i > last => factor += 1,
649            _ => result.push(c),
650        }
651    }
652    (factor, result)
653}
654
655/// Build a placeholder-only view of a format body: quoted literals, escaped
656/// characters, `_x` space literals and `*x` fill literals are removed so
657/// digit placeholders can be counted and located reliably.
658fn analysis_pattern(code: &str) -> String {
659    let mut out = String::with_capacity(code.len());
660    let mut chars = code.chars().peekable();
661    while let Some(c) = chars.next() {
662        match c {
663            '"' => {
664                for c in chars.by_ref() {
665                    if c == '"' {
666                        break;
667                    }
668                }
669            }
670            '\\' | '_' | '*' => {
671                chars.next();
672            }
673            _ => out.push(c),
674        }
675    }
676    out
677}
678
679fn format_numeric(code: &str, value: f64) -> String {
680    let (scaling, code) = strip_scaling_commas(code);
681    let code = code.as_str();
682    let value = value / 1000f64.powi(scaling as i32);
683    let analysis = analysis_pattern(code);
684
685    // Percentage.
686    let percent_count = analysis.chars().filter(|&c| c == '%').count();
687    let base_value = value * (100.0_f64.powi(percent_count as i32));
688
689    // Scientific notation.
690    if analysis.to_ascii_uppercase().contains("E+0")
691        || analysis.to_ascii_uppercase().contains("E-0")
692    {
693        return format_scientific(&analysis, base_value);
694    }
695
696    // Fraction.
697    if analysis.contains('/') {
698        return format_fraction(code, base_value);
699    }
700
701    // Sections that contain no numeric placeholders are rendered as literal text.
702    let has_placeholders = analysis.chars().any(|c| matches!(c, '0' | '#' | '?'));
703    if !has_placeholders {
704        return merge_literals(code, "", false);
705    }
706
707    // Extract integer and decimal patterns.
708    let parts: Vec<&str> = analysis.split('.').collect();
709    let int_pattern = parts.first().copied().unwrap_or("");
710    let frac_pattern = parts.get(1).copied().unwrap_or("");
711
712    let use_thousands = int_pattern.contains(',');
713    let frac_digits = count_digit_placeholders(frac_pattern);
714    let force_decimal = frac_pattern.contains('0');
715
716    let rounded = round_to_digits(base_value, frac_digits);
717
718    let mut int_part = rounded.trunc().abs() as i64;
719    // Handle the case where rounding pushes us to the next integer.
720    if (rounded.fract().abs() - 1.0).abs() < 1e-12 {
721        int_part += 1;
722    }
723
724    let mut result = String::new();
725    result.push_str(&format_integer(int_part, int_pattern));
726
727    if force_decimal || (!frac_pattern.is_empty() && frac_digits > 0) {
728        let frac = rounded.fract().abs();
729        let frac_str = format_fractional(frac, frac_digits, frac_pattern.contains('0'));
730        if !frac_str.is_empty() {
731            result.push('.');
732            result.push_str(&frac_str);
733        } else if force_decimal {
734            result.push('.');
735            result.push_str(&"0".repeat(frac_digits.max(1)));
736        }
737    }
738
739    if use_thousands {
740        result = add_thousands(result);
741    }
742
743    // Append literals and currency symbols that were stripped during parsing.
744    result = merge_literals(code, &result, true);
745
746    if percent_count > 0 {
747        result.push('%');
748    }
749
750    result
751}
752
753fn count_digit_placeholders(pattern: &str) -> usize {
754    pattern
755        .chars()
756        .filter(|&c| c == '0' || c == '#' || c == '?')
757        .count()
758}
759
760fn format_integer(value: i64, pattern: &str) -> String {
761    let min_digits = pattern.chars().filter(|&c| c == '0').count();
762    let has_question = pattern.contains('?');
763    let digits_needed = pattern
764        .chars()
765        .filter(|&c| c == '#' || c == '0' || c == '?')
766        .count();
767
768    let mut s = value.to_string();
769    if s.len() < min_digits {
770        s = format!("{}{}", "0".repeat(min_digits - s.len()), s);
771    }
772    // Question-mark placeholders right-align with spaces; hash placeholders do
773    // not pad. Only enforce the total width when the pattern explicitly uses '?'.
774    if has_question && s.len() < digits_needed {
775        s = format!("{}{}", " ".repeat(digits_needed - s.len()), s);
776    }
777    s
778}
779
780fn format_fractional(frac: f64, digits: usize, force_zero: bool) -> String {
781    if digits == 0 {
782        return String::new();
783    }
784    let scaled = (frac * 10.0_f64.powi(digits as i32)).round() as i64;
785    if scaled == 0 && !force_zero {
786        return String::new();
787    }
788    let mut s = format!("{:0digits$}", scaled, digits = digits);
789    if !force_zero {
790        s = s.trim_end_matches('0').to_string();
791    }
792    s
793}
794
795fn add_thousands(text: String) -> String {
796    let mut result = String::new();
797    let (int_part, frac_part) = if let Some(pos) = text.find('.') {
798        (&text[..pos], Some(&text[pos..]))
799    } else {
800        (text.as_str(), None)
801    };
802
803    let (sign, digits) = if int_part.starts_with('-') {
804        ("-", &int_part[1..])
805    } else {
806        ("", int_part)
807    };
808
809    result.push_str(sign);
810    for (i, ch) in digits.chars().enumerate() {
811        if i > 0 && (digits.len() - i) % 3 == 0 {
812            result.push(',');
813        }
814        result.push(ch);
815    }
816
817    if let Some(frac) = frac_part {
818        result.push_str(frac);
819    }
820    result
821}
822
823fn format_scientific(code: &str, value: f64) -> String {
824    let upper = code.to_ascii_uppercase();
825    let mantissa_code = upper.split('E').next().unwrap_or("");
826    let frac_digits = mantissa_code
827        .split('.')
828        .nth(1)
829        .map(|s| s.chars().filter(|&c| c == '0' || c == '#').count())
830        .unwrap_or(0);
831
832    if value == 0.0 {
833        let mantissa = format!("{:.*}", frac_digits, 0.0);
834        return format!("{}E+00", mantissa);
835    }
836
837    let sign = if value < 0.0 { "-" } else { "" };
838    let abs = value.abs();
839    let exponent = abs.log10().floor() as i32;
840    let mantissa = abs / 10.0_f64.powi(exponent);
841    format!("{}{:.*}E{:+03}", sign, frac_digits, mantissa, exponent)
842}
843
844fn format_fraction(code: &str, value: f64) -> String {
845    let analysis = analysis_pattern(code);
846    let Some(slash) = analysis.find('/') else {
847        return merge_literals(code, &value.to_string(), true);
848    };
849    let before = &analysis[..slash];
850    let after = &analysis[slash + 1..];
851
852    // The numerator is the run of `?` placeholders right before the slash.
853    let num_width = before.chars().rev().take_while(|&c| c == '?').count();
854    // The denominator is either a run of `?` placeholders or explicit digits.
855    let den_token: String = after
856        .chars()
857        .take_while(|&c| c == '?' || c.is_ascii_digit())
858        .collect();
859
860    let abs = value.abs();
861    let whole = abs.trunc() as i64;
862    let frac = abs.fract();
863
864    // Integer part: the placeholder run preceding the numerator (Go always
865    // renders it, even when it is zero), plus any literal between it and the
866    // numerator (e.g. the space in `# ?/?`).
867    let int_region = &before[..before.len() - num_width];
868    let int_width = int_region
869        .chars()
870        .rev()
871        .take_while(|&c| matches!(c, '0' | '#' | '?'))
872        .count();
873    let int_pattern = &int_region[int_region.len() - int_width..];
874    let between = &int_region[..int_region.len() - int_width];
875
876    let mut result = String::new();
877    if int_width > 0 {
878        result.push_str(&format_integer(whole, int_pattern));
879    }
880    result.push_str(between);
881
882    if den_token.chars().all(|c| c == '?') && !den_token.is_empty() {
883        result.push_str(&float_to_fraction(frac, num_width, den_token.len()));
884    } else if let Ok(denom) = den_token.parse::<f64>() {
885        // Explicit denominator: rounded numerator over the literal value.
886        let num = (frac * denom).round() as i64;
887        result.push_str(&format!("{}/{}", num, denom as i64));
888    }
889    result
890}
891
892/// Convert the fractional part to a fraction string with the numerator
893/// left-padded and the denominator right-padded to the placeholder widths.
894/// Mirrors Go's `floatToFraction` (continued-fraction approximation).
895fn float_to_fraction(x: f64, numerator_place_holder: usize, denominator_place_holder: usize) -> String {
896    if denominator_place_holder == 0 {
897        return String::new();
898    }
899    let limit = 10i64.pow(denominator_place_holder as u32);
900    let (num, den) = float_to_frac_continued(x, limit);
901    if num == 0 {
902        return " ".repeat(numerator_place_holder + denominator_place_holder + 1);
903    }
904    let num_str = num.to_string();
905    let den_str = den.to_string();
906    let num_pad = numerator_place_holder.saturating_sub(num_str.len());
907    let den_pad = denominator_place_holder.saturating_sub(den_str.len());
908    format!(
909        "{}{}/{}{}",
910        " ".repeat(num_pad),
911        num_str,
912        den_str,
913        " ".repeat(den_pad)
914    )
915}
916
917/// Convert a floating-point decimal to a fraction using continued fractions
918/// and recurrence relations. Mirrors Go's `floatToFracUseContinuedFraction`.
919fn float_to_frac_continued(mut r: f64, denominator_limit: i64) -> (i64, i64) {
920    let mut p1: i64 = 1;
921    let mut q1: i64 = 0;
922    let mut p2: i64 = 0;
923    let mut q2: i64 = 1;
924    let mut lasta: i64 = 0;
925    let mut lastb: i64 = 0;
926    loop {
927        let a = r.floor() as i64;
928        let curra = a * p1 + p2;
929        let currb = a * q1 + q2;
930        p2 = p1;
931        q2 = q1;
932        p1 = curra;
933        q1 = currb;
934        let frac = r - a as f64;
935        if q1 >= denominator_limit {
936            return (lasta, lastb);
937        }
938        if frac.abs() < 1e-12 {
939            return (curra, currb);
940        }
941        lasta = curra;
942        lastb = currb;
943        r = 1.0 / frac;
944    }
945}
946
947fn merge_literals(code: &str, formatted_number: &str, append_number: bool) -> String {
948    // This is a best-effort merge: replace the first contiguous run of numeric
949    // placeholders in the code with the formatted integer/decimal digits.
950    let mut result = String::new();
951    let mut digit_run_seen = false;
952    let mut chars = code.chars().peekable();
953
954    while let Some(ch) = chars.next() {
955        if ch == '"' {
956            // Quoted literal.
957            while let Some(c) = chars.next() {
958                if c == '"' {
959                    break;
960                }
961                result.push(c);
962            }
963            continue;
964        }
965        if ch == '\\' {
966            if let Some(c) = chars.next() {
967                result.push(c);
968            }
969            continue;
970        }
971        if ch == '_' {
972            // Underscore alignment: leave a space equal to the width of the
973            // next character and consume that character.
974            if chars.next().is_some() {
975                result.push(' ');
976            }
977            continue;
978        }
979        if ch == '*' {
980            // Repeat alignment: consume the next character and do not emit it.
981            chars.next();
982            continue;
983        }
984        if ch == '(' || ch == ')' || ch == '[' || ch == ']' {
985            result.push(ch);
986            continue;
987        }
988        if ch == '%' {
989            // Percent symbol is appended once after formatting.
990            continue;
991        }
992        if matches!(ch, '$' | '€' | '£' | '¥') {
993            result.push(ch);
994            continue;
995        }
996        if ch == '-' {
997            // A literal minus sign in the format code is always emitted (the
998            // caller has already selected the appropriate signed section).
999            result.push('-');
1000            continue;
1001        }
1002        if ch.is_ascii_digit() || ch == '#' || ch == '?' || ch == ',' || ch == '.' {
1003            if !digit_run_seen {
1004                result.push_str(formatted_number);
1005                digit_run_seen = true;
1006            }
1007            continue;
1008        }
1009        result.push(ch);
1010    }
1011
1012    if !digit_run_seen && append_number {
1013        result.push_str(formatted_number);
1014    }
1015
1016    result
1017}
1018
1019fn round_to_digits(value: f64, digits: usize) -> f64 {
1020    if digits == 0 {
1021        return value.round();
1022    }
1023    let factor = 10.0_f64.powi(digits as i32);
1024    (value * factor).round() / factor
1025}
1026
1027// ------------------------------------------------------------------
1028// Date/time formatting
1029// ------------------------------------------------------------------
1030
1031fn fraction_to_time(fraction: f64) -> chrono::NaiveTime {
1032    let total_seconds = (fraction.abs().fract() * 24.0 * 60.0 * 60.0).round() as u32;
1033    let hours = (total_seconds / 3600).min(23);
1034    let minutes = ((total_seconds % 3600) / 60).min(59);
1035    let seconds = (total_seconds % 60).min(59);
1036    chrono::NaiveTime::from_hms_opt(hours, minutes, seconds).unwrap_or(chrono::NaiveTime::MIN)
1037}
1038
1039fn format_date_time(
1040    code: &str,
1041    value: f64,
1042    dt: chrono::NaiveDateTime,
1043    locale_code: Option<&str>,
1044) -> String {
1045    use chrono::{Datelike, Timelike};
1046
1047    let mut result = String::new();
1048    let mut chars = code.chars().peekable();
1049    let mut prev_token_was_hour = false;
1050    let mut use_elapsed_hour = false;
1051    let mut current_dt = dt;
1052
1053    while let Some(ch) = chars.next() {
1054        if ch == '[' {
1055            let mut bracket = String::new();
1056            while let Some(c) = chars.next() {
1057                if c == ']' {
1058                    break;
1059                }
1060                bracket.push(c);
1061            }
1062            match bracket.to_ascii_uppercase().as_str() {
1063                "H" => {
1064                    let total_hours = (value * 24.0).floor();
1065                    result.push_str(&format!("{:.0}", total_hours));
1066                    let remaining = value - total_hours / 24.0;
1067                    current_dt = dt.date().and_time(fraction_to_time(remaining));
1068                    use_elapsed_hour = true;
1069                }
1070                "M" => result.push_str(&(value * 24.0 * 60.0).floor().to_string()),
1071                "S" => result.push_str(&(value * 24.0 * 60.0 * 60.0).floor().to_string()),
1072                _ => {
1073                    result.push('[');
1074                    result.push_str(&bracket);
1075                    result.push(']');
1076                }
1077            }
1078            prev_token_was_hour = false;
1079            continue;
1080        }
1081        if ch == '"' {
1082            while let Some(c) = chars.next() {
1083                if c == '"' {
1084                    break;
1085                }
1086                result.push(c);
1087            }
1088            continue;
1089        }
1090        if ch == '\\' {
1091            if let Some(c) = chars.next() {
1092                result.push(c);
1093            }
1094            continue;
1095        }
1096
1097        let upper = ch.to_ascii_uppercase();
1098        if upper == 'Y' {
1099            let count = 1 + count_repeats(&mut chars, 'y', 'Y');
1100            let year = dt.year();
1101            if count >= 4 {
1102                result.push_str(&format!("{:04}", year));
1103            } else {
1104                result.push_str(&format!("{:02}", year % 100));
1105            }
1106            prev_token_was_hour = false;
1107        } else if upper == 'M' {
1108            let count = 1 + count_repeats(&mut chars, 'm', 'M');
1109            let dt = if use_elapsed_hour { current_dt } else { dt };
1110            if prev_token_was_hour || use_elapsed_hour {
1111                // Minutes.
1112                result.push_str(&format!("{:02}", dt.minute()));
1113            } else {
1114                // Month.
1115                match count {
1116                    1 => result.push_str(&dt.month().to_string()),
1117                    2 => result.push_str(&format!("{:02}", dt.month())),
1118                    3 => result.push_str(short_month_name(dt.month(), locale_code)),
1119                    4 => result.push_str(long_month_name(dt.month(), locale_code)),
1120                    5 => result.push(short_month_name(dt.month(), locale_code).chars().next().unwrap()),
1121                    _ => result.push_str(short_month_name(dt.month(), locale_code)),
1122                }
1123            }
1124            prev_token_was_hour = false;
1125        } else if upper == 'D' {
1126            let count = 1 + count_repeats(&mut chars, 'd', 'D');
1127            match count {
1128                1 => result.push_str(&dt.day().to_string()),
1129                2 => result.push_str(&format!("{:02}", dt.day())),
1130                3 => result.push_str(short_weekday_name(dt.weekday(), locale_code)),
1131                4 => result.push_str(long_weekday_name(dt.weekday(), locale_code)),
1132                _ => result.push_str(&format!("{:02}", dt.day())),
1133            }
1134            prev_token_was_hour = false;
1135        } else if upper == 'H' {
1136            let count = 1 + count_repeats(&mut chars, 'h', 'H');
1137            let dt = if use_elapsed_hour { current_dt } else { dt };
1138            let hour24 = dt.hour();
1139            let hour12 = if hour24 == 0 {
1140                12
1141            } else if hour24 > 12 {
1142                hour24 - 12
1143            } else {
1144                hour24
1145            };
1146            if count >= 2 {
1147                result.push_str(&format!("{:02}", hour24));
1148            } else {
1149                result.push_str(&hour12.to_string());
1150            }
1151            prev_token_was_hour = true;
1152        } else if upper == 'S' {
1153            let count = 1 + count_repeats(&mut chars, 's', 'S');
1154            let dt = if use_elapsed_hour { current_dt } else { dt };
1155            if count >= 2 {
1156                result.push_str(&format!("{:02}", dt.second()));
1157            } else {
1158                result.push_str(&dt.second().to_string());
1159            }
1160            prev_token_was_hour = false;
1161        } else if upper == 'A' {
1162            // AM/PM or A/P
1163            let ahead: String = chars.clone().take(4).collect();
1164            let ahead_upper = ahead.to_ascii_uppercase();
1165            if ahead_upper.starts_with("M/PM") {
1166                result.push_str(if dt.hour() < 12 { "AM" } else { "PM" });
1167                for _ in 0..4 {
1168                    chars.next();
1169                }
1170            } else if ahead_upper.starts_with("/P") {
1171                result.push(if dt.hour() < 12 { 'A' } else { 'P' });
1172                chars.next(); // '/'
1173                chars.next(); // 'P'
1174            } else {
1175                result.push(ch);
1176            }
1177            prev_token_was_hour = false;
1178        } else if ch == '0' {
1179            // Milliseconds: 0..000
1180            let count = 1 + count_repeats(&mut chars, '0', '0').min(2);
1181            let ms = dt.nanosecond() / 1_000_000;
1182            result.push_str(&format!("{:03}", ms)[..count]);
1183            prev_token_was_hour = false;
1184        } else {
1185            result.push(ch);
1186            // ':' and '/' are separators and do not break the hour→minute context.
1187            if ch != ':' && ch != '/' {
1188                prev_token_was_hour = false;
1189            }
1190        }
1191    }
1192
1193    result
1194}
1195
1196fn count_repeats(
1197    chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
1198    lower: char,
1199    upper: char,
1200) -> usize {
1201    let mut count = 0;
1202    while let Some(&c) = chars.peek() {
1203        if c == lower || c == upper {
1204            chars.next();
1205            count += 1;
1206        } else {
1207            break;
1208        }
1209    }
1210    count
1211}
1212
1213struct LocaleNames {
1214    short_months: [&'static str; 12],
1215    long_months: [&'static str; 12],
1216    short_weekdays: [&'static str; 7],
1217    long_weekdays: [&'static str; 7],
1218}
1219
1220fn locale_names(locale_code: Option<&str>) -> &'static LocaleNames {
1221    const ENGLISH: LocaleNames = LocaleNames {
1222        short_months: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
1223        long_months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
1224        short_weekdays: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
1225        long_weekdays: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
1226    };
1227    const ZH_CN: LocaleNames = LocaleNames {
1228        short_months: ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"],
1229        long_months: ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"],
1230        short_weekdays: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
1231        long_weekdays: ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"],
1232    };
1233    const ZH_TW: LocaleNames = LocaleNames {
1234        short_months: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
1235        long_months: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
1236        short_weekdays: ["週一", "週二", "週三", "週四", "週五", "週六", "週日"],
1237        long_weekdays: ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"],
1238    };
1239    const JA_JP: LocaleNames = LocaleNames {
1240        short_months: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
1241        long_months: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
1242        short_weekdays: ["月", "火", "水", "木", "金", "土", "日"],
1243        long_weekdays: ["月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日", "日曜日"],
1244    };
1245    const KO_KR: LocaleNames = LocaleNames {
1246        short_months: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
1247        long_months: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
1248        short_weekdays: ["월", "화", "수", "목", "금", "토", "일"],
1249        long_weekdays: ["월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"],
1250    };
1251    const DE_DE: LocaleNames = LocaleNames {
1252        short_months: ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"],
1253        long_months: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"],
1254        short_weekdays: ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"],
1255        long_weekdays: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"],
1256    };
1257    const FR_FR: LocaleNames = LocaleNames {
1258        short_months: ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."],
1259        long_months: ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"],
1260        short_weekdays: ["lun.", "mar.", "mer.", "jeu.", "ven.", "sam.", "dim."],
1261        long_weekdays: ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"],
1262    };
1263    const ES_ES: LocaleNames = LocaleNames {
1264        short_months: ["ene", "feb", "mar", "abr", "mayo", "jun", "jul", "ago", "sept", "oct", "nov", "dic"],
1265        long_months: ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"],
1266        short_weekdays: ["lun", "mar", "mié", "jue", "vie", "sáb", "dom"],
1267        long_weekdays: ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"],
1268    };
1269    const IT_IT: LocaleNames = LocaleNames {
1270        short_months: ["gen", "feb", "mar", "apr", "mag", "giu", "lug", "ago", "set", "ott", "nov", "dic"],
1271        long_months: ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"],
1272        short_weekdays: ["lun", "mar", "mer", "gio", "ven", "sab", "dom"],
1273        long_weekdays: ["lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato", "domenica"],
1274    };
1275    const RU_RU: LocaleNames = LocaleNames {
1276        short_months: ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"],
1277        long_months: ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"],
1278        short_weekdays: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
1279        long_weekdays: ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"],
1280    };
1281
1282    let code = locale_code.unwrap_or("").to_ascii_uppercase();
1283    // Excel locale IDs are hex strings; map by exact match or by primary language prefix.
1284    match code.as_str() {
1285        "804" | "1004" | "20804" => &ZH_CN,
1286        "404" | "1404" | "0C04" | "100C" | "140C" | "0404" => &ZH_TW,
1287        "411" | "10411" => &JA_JP,
1288        "412" | "10412" => &KO_KR,
1289        "407" => &DE_DE,
1290        "40C" => &FR_FR,
1291        "40A" => &ES_ES,
1292        "410" => &IT_IT,
1293        "419" => &RU_RU,
1294        _ => &ENGLISH,
1295    }
1296}
1297
1298fn short_month_name(month: u32, locale_code: Option<&str>) -> &'static str {
1299    if month < 1 || month > 12 {
1300        return "";
1301    }
1302    locale_names(locale_code).short_months[(month - 1) as usize]
1303}
1304
1305fn long_month_name(month: u32, locale_code: Option<&str>) -> &'static str {
1306    if month < 1 || month > 12 {
1307        return "";
1308    }
1309    locale_names(locale_code).long_months[(month - 1) as usize]
1310}
1311
1312fn short_weekday_name(wd: chrono::Weekday, locale_code: Option<&str>) -> &'static str {
1313    let idx = wd.num_days_from_monday() as usize;
1314    locale_names(locale_code).short_weekdays[idx]
1315}
1316
1317fn long_weekday_name(wd: chrono::Weekday, locale_code: Option<&str>) -> &'static str {
1318    let idx = wd.num_days_from_monday() as usize;
1319    locale_names(locale_code).long_weekdays[idx]
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325
1326    #[test]
1327    fn test_built_in_map() {
1328        assert_eq!(built_in_num_fmt_code(14), Some("mm-dd-yy"));
1329        assert_eq!(built_in_num_fmt_code(0), Some("General"));
1330        assert_eq!(built_in_num_fmt_code(9999), None);
1331    }
1332
1333    #[test]
1334    fn test_general() {
1335        assert_eq!(apply_number_format(1234.56, 0, None, false), "1234.56");
1336        assert_eq!(apply_number_format(100.0, 0, None, false), "100");
1337    }
1338
1339    #[test]
1340    fn test_number() {
1341        assert_eq!(apply_number_format(1234.5, 1, None, false), "1235");
1342        assert_eq!(apply_number_format(1234.5, 2, None, false), "1234.50");
1343        assert_eq!(apply_number_format(1234.5, 3, None, false), "1,235");
1344        assert_eq!(apply_number_format(1234.5, 4, None, false), "1,234.50");
1345    }
1346
1347    #[test]
1348    fn test_percentage() {
1349        assert_eq!(apply_number_format(0.1234, 9, None, false), "12%");
1350        assert_eq!(apply_number_format(0.1234, 10, None, false), "12.34%");
1351    }
1352
1353    #[test]
1354    fn test_scientific() {
1355        assert_eq!(apply_number_format(12345.0, 11, None, false), "1.23E+04");
1356        // Format 48 (##0.0E+0) is rendered with standard scientific notation
1357        // in this implementation.
1358        assert_eq!(apply_number_format(12345.0, 48, None, false), "1.2E+04");
1359    }
1360
1361    #[test]
1362    fn test_date() {
1363        let serial = 45486.0; // 2024-07-13
1364        assert_eq!(apply_number_format(serial, 14, None, false), "07-13-24");
1365        assert_eq!(apply_number_format(serial, 15, None, false), "13-Jul-24");
1366        assert_eq!(apply_number_format(serial, 16, None, false), "13-Jul");
1367        assert_eq!(apply_number_format(serial, 17, None, false), "Jul-24");
1368    }
1369
1370    #[test]
1371    fn test_time() {
1372        let serial = 0.5; // 12:00:00
1373        assert_eq!(apply_number_format(serial, 18, None, false), "12:00 PM");
1374        assert_eq!(apply_number_format(serial, 20, None, false), "12:00");
1375        assert_eq!(apply_number_format(serial, 21, None, false), "12:00:00");
1376    }
1377
1378    #[test]
1379    fn test_custom_currency() {
1380        assert_eq!(
1381            apply_number_format(1234.5, 164, Some("$#,##0.00"), false),
1382            "$1,234.50"
1383        );
1384    }
1385
1386    #[test]
1387    fn test_custom_date() {
1388        let serial = 45486.0;
1389        assert_eq!(
1390            apply_number_format(serial, 164, Some("yyyy-mm-dd"), false),
1391            "2024-07-13"
1392        );
1393    }
1394
1395    #[test]
1396    fn test_is_date_time_pattern() {
1397        assert!(is_date_time_pattern(""));
1398        assert!(is_date_time_pattern("yyyy-mm-dd"));
1399        assert!(is_date_time_pattern("hh:mm:ss"));
1400        assert!(is_date_time_pattern("[h]:mm:ss"));
1401        assert!(is_date_time_pattern("m/d/yyyy h:mm AM/PM"));
1402        assert!(is_date_time_pattern("[$-409]dddd, mmmm d, yyyy"));
1403        assert!(is_date_time_pattern("[red]yyyy-mm-dd"));
1404        assert!(is_date_time_pattern("\\yyyy-mm-dd"));
1405        assert!(is_date_time_pattern("\"Date: \"yyyy-mm-dd"));
1406
1407        assert!(!is_date_time_pattern("0.00"));
1408        assert!(!is_date_time_pattern("#,##0"));
1409        assert!(!is_date_time_pattern("0%"));
1410        assert!(!is_date_time_pattern("0.00E+00"));
1411        assert!(!is_date_time_pattern("# ?/?"));
1412        assert!(!is_date_time_pattern("yyyy-mm-dd;0.00"));
1413        assert!(!is_date_time_pattern("@"));
1414        assert!(!is_date_time_pattern("yyyy-mm-dd;@"));
1415        assert!(!is_date_time_pattern("h:mm;@"));
1416        assert!(is_date_time_pattern("\"@\"yyyy-mm-dd"));
1417    }
1418
1419    #[test]
1420    fn test_builtin_accounting_formats() {
1421        // Built-in accounting formats reserve a trailing space in the positive
1422        // section so that positive and negative values line up.
1423        assert_eq!(apply_number_format(1234.0, 37, None, false), "1,234 ");
1424        assert_eq!(apply_number_format(-1234.0, 37, None, false), "(1,234)");
1425        assert_eq!(apply_number_format(1234.0, 38, None, false), "1,234 ");
1426        assert_eq!(apply_number_format(-1234.0, 38, None, false), "(1,234)");
1427        assert_eq!(apply_number_format(1234.5, 39, None, false), "1,234.50 ");
1428        assert_eq!(apply_number_format(-1234.5, 39, None, false), "(1,234.50)");
1429        assert_eq!(apply_number_format(1234.5, 40, None, false), "1,234.50 ");
1430        assert_eq!(apply_number_format(-1234.5, 40, None, false), "(1,234.50)");
1431    }
1432
1433    #[test]
1434    fn test_elapsed_time() {
1435        assert_eq!(format_number(1.5, "[h]", false), "36");
1436        assert_eq!(format_number(1.5, "[m]", false), "2160");
1437        assert_eq!(format_number(1.5, "[s]", false), "129600");
1438        assert_eq!(format_number(1.5, "[h]:mm:ss", false), "36:00:00");
1439    }
1440
1441    #[test]
1442    fn test_locale_date_names() {
1443        let serial = 45486.0; // 2024-07-13 Saturday
1444        assert_eq!(
1445            format_number(serial, "[$-409]dddd, mmmm d, yyyy", false),
1446            "Saturday, July 13, 2024"
1447        );
1448        assert_eq!(
1449            format_number(serial, "[$-804]dddd, mmmm d, yyyy", false),
1450            "星期六, 七月 13, 2024"
1451        );
1452        assert_eq!(
1453            format_number(serial, "[$-411]dddd, mmmm d, yyyy", false),
1454            "土曜日, 7月 13, 2024"
1455        );
1456        assert_eq!(
1457            format_number(serial, "[$-412]dddd, mmmm d, yyyy", false),
1458            "토요일, 7월 13, 2024"
1459        );
1460    }
1461
1462    #[test]
1463    fn test_dbnum_switch() {
1464        assert_eq!(format_number(1234.0, "[DBNum1]0", false), "一二三四");
1465        assert_eq!(format_number(1234.0, "[DBNum2]0", false), "壹贰叁肆");
1466        assert_eq!(format_number(1234.0, "[DBNum3]0", false), "1234");
1467    }
1468
1469    #[test]
1470    fn test_currency_locale_brackets() {
1471        assert_eq!(
1472            format_number(1234.5, "[$$]#,##0.00", false),
1473            "$1,234.50"
1474        );
1475        assert_eq!(
1476            format_number(1234.5, "[$€-40C]#,##0.00", false),
1477            "€1,234.50"
1478        );
1479        assert_eq!(
1480            format_number(45486.0, "[$-409]yyyy-mm-dd", false),
1481            "2024-07-13"
1482        );
1483    }
1484}