Skip to main content

chartml_core/format/
number.rs

1/// d3-format compatible number formatter.
2///
3/// Spec mini-language: `[[fill]align][sign][symbol][0][width][,][.precision][~][type]`
4
5#[derive(Debug, Clone)]
6pub struct NumberFormatter {
7    spec: FormatSpec,
8}
9
10#[derive(Debug, Clone)]
11struct FormatSpec {
12    fill: char,
13    align: Align,
14    sign: Sign,
15    symbol: Symbol,
16    _zero: bool,
17    width: Option<usize>,
18    comma: bool,
19    precision: Option<usize>,
20    trim: bool,
21    format_type: FormatType,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq)]
25enum Align {
26    Left,
27    Right,
28    Center,
29    SignFirst,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq)]
33enum Sign {
34    Minus,
35    Plus,
36    Space,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq)]
40enum Symbol {
41    None,
42    Dollar,
43    Hash,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq)]
47enum FormatType {
48    None,
49    Decimal,
50    Exponent,
51    Fixed,
52    General,
53    Rounded,
54    SiPrefix,
55    Percentage,
56    Hex,
57    HexUpper,
58    Octal,
59    Binary,
60}
61
62fn is_align_char(c: char) -> bool {
63    matches!(c, '<' | '>' | '^' | '=')
64}
65
66fn parse_spec(spec_str: &str) -> FormatSpec {
67    let chars: Vec<char> = spec_str.chars().collect();
68    let len = chars.len();
69    let mut i = 0;
70
71    // Defaults
72    let mut fill = ' ';
73    let mut align = Align::Right;
74    let mut sign = Sign::Minus;
75    let mut symbol = Symbol::None;
76    let mut zero = false;
77    let mut width: Option<usize> = None;
78    let mut comma = false;
79    let mut precision: Option<usize> = None;
80    let mut trim = false;
81    let mut format_type = FormatType::None;
82    let mut explicit_align = false;
83
84    // Parse fill+align: if second char is an align char, first char is fill
85    if len >= 2 && is_align_char(chars[1]) {
86        fill = chars[0];
87        align = match chars[1] {
88            '<' => Align::Left,
89            '>' => Align::Right,
90            '^' => Align::Center,
91            '=' => Align::SignFirst,
92            _ => unreachable!(),
93        };
94        explicit_align = true;
95        i = 2;
96    } else if len >= 1 && is_align_char(chars[0]) {
97        align = match chars[0] {
98            '<' => Align::Left,
99            '>' => Align::Right,
100            '^' => Align::Center,
101            '=' => Align::SignFirst,
102            _ => unreachable!(),
103        };
104        explicit_align = true;
105        i = 1;
106    }
107
108    // Parse sign
109    if i < len {
110        match chars[i] {
111            '-' => { sign = Sign::Minus; i += 1; }
112            '+' => { sign = Sign::Plus; i += 1; }
113            ' ' => { sign = Sign::Space; i += 1; }
114            _ => {}
115        }
116    }
117
118    // Parse symbol
119    if i < len {
120        match chars[i] {
121            '$' => { symbol = Symbol::Dollar; i += 1; }
122            '#' => { symbol = Symbol::Hash; i += 1; }
123            _ => {}
124        }
125    }
126
127    // Parse zero
128    if i < len && chars[i] == '0' {
129        // Only treat as zero-fill flag if followed by a digit (width) or end/non-digit
130        // But we need to distinguish "0" as zero flag from "0" as part of precision etc.
131        // In d3-format, 0 here means zero-fill and implies align = SignFirst if no explicit align
132        zero = true;
133        if !explicit_align {
134            fill = '0';
135            align = Align::SignFirst;
136        }
137        i += 1;
138    }
139
140    // Parse width (digits)
141    {
142        let start = i;
143        while i < len && chars[i].is_ascii_digit() {
144            i += 1;
145        }
146        if i > start {
147            width = Some(chars[start..i].iter().collect::<String>().parse().unwrap());
148        }
149    }
150
151    // Parse comma
152    if i < len && chars[i] == ',' {
153        comma = true;
154        i += 1;
155    }
156
157    // Parse .precision
158    if i < len && chars[i] == '.' {
159        i += 1;
160        let start = i;
161        while i < len && chars[i].is_ascii_digit() {
162            i += 1;
163        }
164        if i > start {
165            precision = Some(chars[start..i].iter().collect::<String>().parse().unwrap());
166        } else {
167            precision = Some(0);
168        }
169    }
170
171    // Parse trim ~
172    if i < len && chars[i] == '~' {
173        trim = true;
174        i += 1;
175    }
176
177    // Parse type
178    if i < len {
179        format_type = match chars[i] {
180            'd' => FormatType::Decimal,
181            'e' => FormatType::Exponent,
182            'f' => FormatType::Fixed,
183            'g' => FormatType::General,
184            'r' => FormatType::Rounded,
185            's' => FormatType::SiPrefix,
186            '%' => FormatType::Percentage,
187            'x' => FormatType::Hex,
188            'X' => FormatType::HexUpper,
189            'o' => FormatType::Octal,
190            'b' => FormatType::Binary,
191            _ => FormatType::None,
192        };
193    }
194
195    FormatSpec {
196        fill,
197        align,
198        sign,
199        symbol,
200        _zero: zero,
201        width,
202        comma,
203        precision,
204        trim,
205        format_type,
206    }
207}
208
209/// SI prefix table: (threshold, divisor, suffix)
210const SI_PREFIXES: &[(f64, f64, &str)] = &[
211    (1e24, 1e24, "Y"),
212    (1e21, 1e21, "Z"),
213    (1e18, 1e18, "E"),
214    (1e15, 1e15, "P"),
215    (1e12, 1e12, "T"),
216    (1e9, 1e9, "G"),
217    (1e6, 1e6, "M"),
218    (1e3, 1e3, "k"),
219    (1.0, 1.0, ""),
220    (1e-3, 1e-3, "m"),
221    (1e-6, 1e-6, "\u{03bc}"),
222    (1e-9, 1e-9, "n"),
223];
224
225fn si_prefix(value: f64) -> (f64, &'static str) {
226    let abs = value.abs();
227    if abs == 0.0 {
228        return (0.0, "");
229    }
230    for &(threshold, divisor, suffix) in SI_PREFIXES {
231        if abs >= threshold {
232            return (value / divisor, suffix);
233        }
234    }
235    // Very small values: use nano
236    (value / 1e-9, "n")
237}
238
239fn insert_commas(int_part: &str) -> String {
240    let len = int_part.len();
241    if len <= 3 {
242        return int_part.to_string();
243    }
244    let mut result = String::with_capacity(len + len / 3);
245    for (idx, ch) in int_part.chars().enumerate() {
246        if idx > 0 && (len - idx).is_multiple_of(3) {
247            result.push(',');
248        }
249        result.push(ch);
250    }
251    result
252}
253
254fn trim_trailing_zeros(s: &str) -> String {
255    if s.contains('.') {
256        let trimmed = s.trim_end_matches('0');
257        if trimmed.ends_with('.') {
258            trimmed.strip_suffix('.').unwrap_or(trimmed).to_string()
259        } else {
260            trimmed.to_string()
261        }
262    } else {
263        s.to_string()
264    }
265}
266
267fn format_with_commas(formatted: &str) -> String {
268    // Split into integer part and decimal part (and possible suffix)
269    // The formatted string might be like "1234.56" or "1234"
270    if let Some(dot_pos) = formatted.find('.') {
271        let int_part = &formatted[..dot_pos];
272        let rest = &formatted[dot_pos..];
273        format!("{}{}", insert_commas(int_part), rest)
274    } else {
275        insert_commas(formatted)
276    }
277}
278
279impl NumberFormatter {
280    pub fn new(spec_str: &str) -> Self {
281        Self {
282            spec: parse_spec(spec_str),
283        }
284    }
285
286    pub fn format(&self, value: f64) -> String {
287        let spec = &self.spec;
288        let is_negative = value < 0.0;
289        let abs_value = value.abs();
290
291        // Format the core number (without sign, symbol, padding)
292        let (mut formatted, suffix) = self.format_core(abs_value);
293
294        // Apply trim
295        if spec.trim {
296            formatted = trim_trailing_zeros(&formatted);
297        }
298
299        // Apply comma grouping
300        if spec.comma {
301            formatted = format_with_commas(&formatted);
302        }
303
304        // Append suffix (for SI prefix, percentage)
305        formatted.push_str(&suffix);
306
307        // Build sign string
308        let sign_str = if is_negative {
309            "-"
310        } else {
311            match spec.sign {
312                Sign::Plus => "+",
313                Sign::Space => " ",
314                Sign::Minus => "",
315            }
316        };
317
318        // Build symbol string
319        let symbol_str = match spec.symbol {
320            Symbol::Dollar => "$",
321            // Hash prefix is handled inside format_core for hex/octal/binary
322            Symbol::Hash => "",
323            Symbol::None => "",
324        };
325
326        // In d3-format, for negative numbers with $, the sign comes before the symbol: -$1,234
327        // For positive with sign: +$1,234
328        // The order is: sign + symbol + number
329        let body = format!("{}{}{}", sign_str, symbol_str, formatted);
330
331        // Apply width/alignment
332        self.apply_padding(&body, sign_str, symbol_str, &formatted)
333    }
334
335    fn format_core(&self, abs_value: f64) -> (String, String) {
336        let spec = &self.spec;
337        let precision = spec.precision;
338
339        match spec.format_type {
340            FormatType::Fixed => {
341                let p = precision.unwrap_or(6);
342                (format!("{:.prec$}", abs_value, prec = p), String::new())
343            }
344            FormatType::Decimal => {
345                let rounded = abs_value.round() as i64;
346                (format!("{}", rounded), String::new())
347            }
348            FormatType::Exponent => {
349                let p = precision.unwrap_or(6);
350                (format!("{:.prec$e}", abs_value, prec = p), String::new())
351            }
352            FormatType::General => {
353                let p = precision.unwrap_or(6);
354                // Use general formatting: scientific if exponent < -4 or >= precision
355                let formatted = format_general(abs_value, p);
356                (formatted, String::new())
357            }
358            FormatType::Percentage => {
359                let pct_value = abs_value * 100.0;
360                let p = precision.unwrap_or(6);
361                (format!("{:.prec$}", pct_value, prec = p), "%".to_string())
362            }
363            FormatType::SiPrefix => {
364                let (scaled, suffix) = si_prefix(abs_value);
365                let p = precision.unwrap_or(6);
366                // SI prefix with precision means significant digits
367                if spec.precision.is_some() {
368                    let formatted = format_significant(scaled, p);
369                    (formatted, suffix.to_string())
370                } else {
371                    // Default: use enough digits
372                    let formatted = format_default_si(scaled);
373                    (formatted, suffix.to_string())
374                }
375            }
376            FormatType::Hex => {
377                let int_val = abs_value.round() as u64;
378                let prefix = if spec.symbol == Symbol::Hash { "0x" } else { "" };
379                (format!("{}{:x}", prefix, int_val), String::new())
380            }
381            FormatType::HexUpper => {
382                let int_val = abs_value.round() as u64;
383                let prefix = if spec.symbol == Symbol::Hash { "0X" } else { "" };
384                (format!("{}{:X}", prefix, int_val), String::new())
385            }
386            FormatType::Octal => {
387                let int_val = abs_value.round() as u64;
388                let prefix = if spec.symbol == Symbol::Hash { "0o" } else { "" };
389                (format!("{}{:o}", prefix, int_val), String::new())
390            }
391            FormatType::Binary => {
392                let int_val = abs_value.round() as u64;
393                let prefix = if spec.symbol == Symbol::Hash { "0b" } else { "" };
394                (format!("{}{:b}", prefix, int_val), String::new())
395            }
396            FormatType::Rounded => {
397                let p = precision.unwrap_or(6);
398                (format_significant(abs_value, p), String::new())
399            }
400            FormatType::None => {
401                // Like g, but trims trailing zeros
402                let p = precision.unwrap_or(12);
403                let formatted = format_general(abs_value, p);
404                (trim_trailing_zeros(&formatted), String::new())
405            }
406        }
407    }
408
409    fn apply_padding(
410        &self,
411        body: &str,
412        sign_str: &str,
413        symbol_str: &str,
414        number_part: &str,
415    ) -> String {
416        let spec = &self.spec;
417        let w = match spec.width {
418            Some(w) => w,
419            None => return body.to_string(),
420        };
421
422        let body_len = body.chars().count();
423        if body_len >= w {
424            return body.to_string();
425        }
426
427        let pad_len = w - body_len;
428        let padding: String = std::iter::repeat_n(spec.fill, pad_len).collect();
429
430        match spec.align {
431            Align::Left => format!("{}{}", body, padding),
432            Align::Right => format!("{}{}", padding, body),
433            Align::Center => {
434                let left = pad_len / 2;
435                let right = pad_len - left;
436                let lpad: String = std::iter::repeat_n(spec.fill, left).collect();
437                let rpad: String = std::iter::repeat_n(spec.fill, right).collect();
438                format!("{}{}{}", lpad, body, rpad)
439            }
440            Align::SignFirst => {
441                // Padding goes after sign+symbol but before the number
442                format!("{}{}{}{}", sign_str, symbol_str, padding, number_part)
443            }
444        }
445    }
446}
447
448fn format_general(value: f64, precision: usize) -> String {
449    if value == 0.0 {
450        return format!("{:.prec$}", 0.0, prec = precision.saturating_sub(1).max(0));
451    }
452    let exp = value.log10().floor() as i32;
453    if exp < -4 || exp >= precision as i32 {
454        format!("{:.prec$e}", value, prec = precision.saturating_sub(1))
455    } else {
456        let decimal_places = if precision as i32 > exp + 1 {
457            (precision as i32 - exp - 1) as usize
458        } else {
459            0
460        };
461        format!("{:.prec$}", value, prec = decimal_places)
462    }
463}
464
465fn format_significant(value: f64, sig_figs: usize) -> String {
466    if value == 0.0 {
467        return format!("{:.prec$}", 0.0, prec = sig_figs.saturating_sub(1));
468    }
469    let magnitude = value.abs().log10().floor() as i32;
470    let decimal_places = if sig_figs as i32 > magnitude + 1 {
471        (sig_figs as i32 - magnitude - 1) as usize
472    } else {
473        0
474    };
475    format!("{:.prec$}", value, prec = decimal_places)
476}
477
478fn format_default_si(value: f64) -> String {
479    // For ~s without explicit precision, show the number with enough precision
480    // to represent the value faithfully. Use up to 12 significant figures then trim.
481    if value == 0.0 {
482        return "0".to_string();
483    }
484    let magnitude = value.abs().log10().floor() as i32;
485    let sig_figs = 12;
486    let decimal_places = if sig_figs > magnitude + 1 {
487        (sig_figs - magnitude - 1) as usize
488    } else {
489        0
490    };
491    format!("{:.prec$}", value, prec = decimal_places)
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn test_currency_no_decimals() {
500        assert_eq!(NumberFormatter::new("$,.0f").format(1234567.0), "$1,234,567");
501    }
502
503    #[test]
504    fn test_currency_two_decimals() {
505        assert_eq!(NumberFormatter::new("$,.2f").format(1234.5), "$1,234.50");
506    }
507
508    #[test]
509    fn test_comma_no_decimals() {
510        assert_eq!(NumberFormatter::new(",.0f").format(1234567.0), "1,234,567");
511    }
512
513    #[test]
514    fn test_comma_two_decimals() {
515        assert_eq!(NumberFormatter::new(",.2f").format(1234.56), "1,234.56");
516    }
517
518    #[test]
519    fn test_percentage_zero_decimals() {
520        assert_eq!(NumberFormatter::new(".0%").format(0.1234), "12%");
521    }
522
523    #[test]
524    fn test_percentage_one_decimal() {
525        assert_eq!(NumberFormatter::new(".1%").format(0.1234), "12.3%");
526    }
527
528    #[test]
529    fn test_percentage_two_decimals() {
530        assert_eq!(NumberFormatter::new(".2%").format(0.1234), "12.34%");
531    }
532
533    #[test]
534    fn test_si_prefix_trim() {
535        assert_eq!(NumberFormatter::new("~s").format(1234.0), "1.234k");
536    }
537
538    #[test]
539    fn test_si_prefix_precision() {
540        assert_eq!(NumberFormatter::new(".3s").format(1234.0), "1.23k");
541    }
542
543    #[test]
544    fn test_si_prefix_mega_trim() {
545        assert_eq!(NumberFormatter::new("~s").format(1234567.0), "1.234567M");
546    }
547
548    #[test]
549    fn test_decimal() {
550        assert_eq!(NumberFormatter::new("d").format(1234.0), "1234");
551    }
552
553    #[test]
554    fn test_sign_comma() {
555        assert_eq!(NumberFormatter::new("+,.0f").format(1234.0), "+1,234");
556    }
557
558    #[test]
559    fn test_zero_fixed() {
560        assert_eq!(NumberFormatter::new(".0f").format(0.0), "0");
561    }
562
563    #[test]
564    fn test_zero_currency() {
565        assert_eq!(NumberFormatter::new("$,.0f").format(0.0), "$0");
566    }
567
568    #[test]
569    fn test_precision_fixed() {
570        assert_eq!(NumberFormatter::new(".2f").format(3.14159), "3.14");
571    }
572
573    #[test]
574    fn test_negative_currency() {
575        assert_eq!(NumberFormatter::new("$,.0f").format(-1234.0), "-$1,234");
576    }
577
578    #[test]
579    fn test_si_prefix_milli() {
580        assert_eq!(NumberFormatter::new("~s").format(0.001234), "1.234m");
581    }
582
583    #[test]
584    fn test_si_prefix_giga() {
585        assert_eq!(NumberFormatter::new("~s").format(1e9), "1G");
586    }
587
588    #[test]
589    fn test_insert_commas() {
590        assert_eq!(insert_commas("1"), "1");
591        assert_eq!(insert_commas("12"), "12");
592        assert_eq!(insert_commas("123"), "123");
593        assert_eq!(insert_commas("1234"), "1,234");
594        assert_eq!(insert_commas("12345"), "12,345");
595        assert_eq!(insert_commas("123456"), "123,456");
596        assert_eq!(insert_commas("1234567"), "1,234,567");
597    }
598
599    #[test]
600    fn test_parse_spec_basic() {
601        let spec = parse_spec("$,.2f");
602        assert_eq!(spec.symbol, Symbol::Dollar);
603        assert!(spec.comma);
604        assert_eq!(spec.precision, Some(2));
605        assert_eq!(spec.format_type, FormatType::Fixed);
606    }
607
608    #[test]
609    fn test_parse_spec_percentage() {
610        let spec = parse_spec(".1%");
611        assert_eq!(spec.precision, Some(1));
612        assert_eq!(spec.format_type, FormatType::Percentage);
613    }
614
615    #[test]
616    fn test_parse_spec_si() {
617        let spec = parse_spec("~s");
618        assert!(spec.trim);
619        assert_eq!(spec.format_type, FormatType::SiPrefix);
620    }
621
622    #[test]
623    fn test_width_padding() {
624        assert_eq!(NumberFormatter::new("10d").format(42.0), "        42");
625    }
626
627    #[test]
628    fn test_zero_padding() {
629        assert_eq!(NumberFormatter::new("010d").format(42.0), "0000000042");
630    }
631
632    #[test]
633    fn test_left_align() {
634        assert_eq!(NumberFormatter::new("<10d").format(42.0), "42        ");
635    }
636}