Skip to main content

azul_layout/solver3/
counters.rs

1//! CSS Counter Support
2//!
3//! Implements CSS counters for ordered lists and generated content as per CSS spec.
4//! Counters are cached per-node in the `LayoutCache` and computed during layout traversal.
5//!
6//! This module is the single canonical home for numbering-system formatting
7//! (decimal, roman, alphabetic, greek). The low-level converters
8//! [`to_roman`], [`to_alphabetic`], and [`to_greek`] back both [`format_counter`]
9//! (list markers) and `super::pagination::CounterFormat` (paged-media page counters),
10//! so the same number renders identically in both contexts.
11
12use alloc::string::String;
13
14use azul_css::props::style::lists::StyleListStyleType;
15
16/// Formats a counter value into a string based on the list style type.
17///
18/// Implements CSS counter styles for various numbering systems.
19#[must_use]
20pub fn format_counter(value: i32, style: StyleListStyleType) -> String {
21    match style {
22        StyleListStyleType::None => String::new(),
23        StyleListStyleType::Disc => "•".to_string(),
24        StyleListStyleType::Circle => "◦".to_string(),
25        StyleListStyleType::Square => "▪".to_string(),
26        StyleListStyleType::Decimal => value.to_string(),
27        StyleListStyleType::DecimalLeadingZero => format!("{value:02}"),
28        StyleListStyleType::LowerAlpha => decimal_fallback(value, with_sign(value, |n| to_alphabetic(n, false))),
29        StyleListStyleType::UpperAlpha => decimal_fallback(value, with_sign(value, |n| to_alphabetic(n, true))),
30        StyleListStyleType::LowerRoman => with_sign(value, |n| to_roman(n, false)),
31        StyleListStyleType::UpperRoman => with_sign(value, |n| to_roman(n, true)),
32        StyleListStyleType::LowerGreek => decimal_fallback(value, with_sign(value, |n| to_greek(n, false))),
33        StyleListStyleType::UpperGreek => decimal_fallback(value, with_sign(value, |n| to_greek(n, true))),
34    }
35}
36
37/// CSS fallback: when an alphabetic/greek counter style cannot represent a value
38/// (e.g. `value == 0`, where `to_alphabetic`/`to_greek` yield an empty string), the
39/// spec falls back to `decimal` so the marker is never blank.
40fn decimal_fallback(value: i32, formatted: String) -> String {
41    if formatted.is_empty() {
42        value.to_string()
43    } else {
44        formatted
45    }
46}
47
48// --- Formatting Helpers ---
49
50/// Formats the magnitude of `value`, prefixing `-` for negatives.
51///
52/// Avoids the lossy `value as u32` cast: a negative counter such as `-3` in
53/// `lower-roman` formats as `-iii` instead of wrapping to a huge unsigned value.
54#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/counter/fixed-point cast
55fn with_sign<F: Fn(usize) -> String>(value: i32, format: F) -> String {
56    if value < 0 {
57        let magnitude = i64::from(value).unsigned_abs() as usize;
58        format!("-{}", format(magnitude))
59    } else {
60        format(value as usize)
61    }
62}
63
64/// Converts a number to alphabetic representation (a, b, c, ..., z, aa, ab, ...).
65///
66/// This implements the CSS `lower-alpha` and `upper-alpha` counter styles.
67#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/counter/fixed-point cast
68pub(crate) fn to_alphabetic(mut num: usize, uppercase: bool) -> String {
69    if num == 0 {
70        return String::new();
71    }
72
73    let mut result = String::new();
74    let base = if uppercase { b'A' } else { b'a' };
75
76    while num > 0 {
77        let remainder = ((num - 1) % 26) as u8;
78        result.insert(0, (base + remainder) as char);
79        num = (num - 1) / 26;
80    }
81
82    result
83}
84
85/// Converts a number to Roman numeral representation.
86///
87/// This implements the CSS `lower-roman` and `upper-roman` counter styles.
88pub(crate) fn to_roman(mut num: usize, uppercase: bool) -> String {
89    const MAX_ROMAN: usize = 3999;
90    if num == 0 {
91        return "0".to_string();
92    }
93    if num > MAX_ROMAN {
94        // Roman numerals traditionally don't go beyond 3999
95        return num.to_string();
96    }
97
98    let numerals = [
99        (1000, "m"),
100        (900, "cm"),
101        (500, "d"),
102        (400, "cd"),
103        (100, "c"),
104        (90, "xc"),
105        (50, "l"),
106        (40, "xl"),
107        (10, "x"),
108        (9, "ix"),
109        (5, "v"),
110        (4, "iv"),
111        (1, "i"),
112    ];
113
114    let mut result = String::new();
115    for (value, numeral) in &numerals {
116        while num >= *value {
117            result.push_str(numeral);
118            num -= value;
119        }
120    }
121
122    if uppercase {
123        result.to_uppercase()
124    } else {
125        result
126    }
127}
128
129/// Converts a number to Greek letter representation.
130///
131/// This implements the CSS `lower-greek` and `upper-greek` counter styles.
132/// Supports α, β, γ, ... (24 letters). For numbers > 24, wraps as αα, αβ, etc.
133pub(crate) fn to_greek(num: usize, uppercase: bool) -> String {
134    const GREEK_LOWER: &[char] = &[
135        'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'ο', 'π', 'ρ', 'σ',
136        'τ', 'υ', 'φ', 'χ', 'ψ', 'ω',
137    ];
138    const GREEK_UPPER: &[char] = &[
139        'Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ', 'Η', 'Θ', 'Ι', 'Κ', 'Λ', 'Μ', 'Ν', 'Ξ', 'Ο', 'Π', 'Ρ', 'Σ',
140        'Τ', 'Υ', 'Φ', 'Χ', 'Ψ', 'Ω',
141    ];
142
143    if num == 0 {
144        return String::new();
145    }
146
147    let letters = if uppercase { GREEK_UPPER } else { GREEK_LOWER };
148
149    if num <= letters.len() {
150        return letters[num - 1].to_string();
151    }
152
153    let mut result = String::new();
154    let mut remaining = num;
155    while remaining > 0 {
156        remaining -= 1;
157        result.insert(0, letters[remaining % letters.len()]);
158        remaining /= letters.len();
159    }
160    result
161}
162
163#[cfg(test)]
164#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
165mod autotest_generated {
166    use super::*;
167
168    // ------------------------------------------------------------------
169    // Fixtures / helpers
170    // ------------------------------------------------------------------
171
172    /// Every variant of the enum under test — used for "no panic on any style"
173    /// sweeps.
174    const ALL_STYLES: [StyleListStyleType; 12] = [
175        StyleListStyleType::None,
176        StyleListStyleType::Disc,
177        StyleListStyleType::Circle,
178        StyleListStyleType::Square,
179        StyleListStyleType::Decimal,
180        StyleListStyleType::DecimalLeadingZero,
181        StyleListStyleType::LowerRoman,
182        StyleListStyleType::UpperRoman,
183        StyleListStyleType::LowerGreek,
184        StyleListStyleType::UpperGreek,
185        StyleListStyleType::LowerAlpha,
186        StyleListStyleType::UpperAlpha,
187    ];
188
189    /// Adversarial `i32` inputs: the saturation points, the sign boundary, and
190    /// the roman-numeral cliff at 3999/4000.
191    const EDGE_VALUES: [i32; 12] = [
192        i32::MIN,
193        i32::MIN + 1,
194        -4000,
195        -3999,
196        -27,
197        -1,
198        0,
199        1,
200        26,
201        3999,
202        4000,
203        i32::MAX,
204    ];
205
206    const GREEK_LOWER_LETTERS: [char; 24] = [
207        'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'ο', 'π', 'ρ', 'σ',
208        'τ', 'υ', 'φ', 'χ', 'ψ', 'ω',
209    ];
210    const GREEK_UPPER_LETTERS: [char; 24] = [
211        'Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ', 'Η', 'Θ', 'Ι', 'Κ', 'Λ', 'Μ', 'Ν', 'Ξ', 'Ο', 'Π', 'Ρ', 'Σ',
212        'Τ', 'Υ', 'Φ', 'Χ', 'Ψ', 'Ω',
213    ];
214
215    /// Independent decoder for the bijective base-26 alphabetic system.
216    /// Returns `None` if `s` contains a character outside the expected case.
217    fn decode_alphabetic(s: &str, uppercase: bool) -> Option<u128> {
218        if s.is_empty() {
219            return None;
220        }
221        let base = if uppercase { b'A' } else { b'a' };
222        let mut acc: u128 = 0;
223        for b in s.bytes() {
224            if b < base || b >= base + 26 {
225                return None;
226            }
227            acc = acc * 26 + u128::from(b - base + 1);
228        }
229        Some(acc)
230    }
231
232    /// Independent decoder for the bijective base-24 greek system.
233    fn decode_greek(s: &str, uppercase: bool) -> Option<u128> {
234        let letters = if uppercase {
235            &GREEK_UPPER_LETTERS
236        } else {
237            &GREEK_LOWER_LETTERS
238        };
239        if s.is_empty() {
240            return None;
241        }
242        let mut acc: u128 = 0;
243        for c in s.chars() {
244            let idx = letters.iter().position(|l| *l == c)?;
245            acc = acc * 24 + (idx as u128 + 1);
246        }
247        Some(acc)
248    }
249
250    /// Independent subtractive-notation roman decoder.
251    fn decode_roman(s: &str) -> Option<u32> {
252        fn digit(c: char) -> Option<i64> {
253            match c {
254                'i' => Some(1),
255                'v' => Some(5),
256                'x' => Some(10),
257                'l' => Some(50),
258                'c' => Some(100),
259                'd' => Some(500),
260                'm' => Some(1000),
261                _ => None,
262            }
263        }
264        if s.is_empty() {
265            return None;
266        }
267        let digits: Option<Vec<i64>> = s.chars().map(digit).collect();
268        let digits = digits?;
269        // Accumulate signed: subtractive pairs go negative before the following
270        // larger numeral is added, so an unsigned accumulator would underflow.
271        let mut total: i64 = 0;
272        for (i, d) in digits.iter().enumerate() {
273            // A smaller numeral placed before a larger one is subtractive.
274            if digits[i + 1..].iter().any(|next| next > d) {
275                total -= *d;
276            } else {
277                total += *d;
278            }
279        }
280        u32::try_from(total).ok()
281    }
282
283    // ------------------------------------------------------------------
284    // to_alphabetic — numeric: zero / min_max / overflow / round-trip
285    // ------------------------------------------------------------------
286
287    #[test]
288    fn to_alphabetic_zero_is_empty_not_a_panic() {
289        // 0 is not representable in a bijective base — the function signals this
290        // with an empty string (the caller is expected to `decimal_fallback`).
291        assert_eq!(to_alphabetic(0, false), "");
292        assert_eq!(to_alphabetic(0, true), "");
293    }
294
295    #[test]
296    fn to_alphabetic_known_values() {
297        assert_eq!(to_alphabetic(1, false), "a");
298        assert_eq!(to_alphabetic(26, false), "z");
299        // The carry boundary: 27 must roll over to two letters, not wrap to "a".
300        assert_eq!(to_alphabetic(27, false), "aa");
301        assert_eq!(to_alphabetic(28, false), "ab");
302        assert_eq!(to_alphabetic(52, false), "az");
303        assert_eq!(to_alphabetic(53, false), "ba");
304        assert_eq!(to_alphabetic(702, false), "zz");
305        assert_eq!(to_alphabetic(703, false), "aaa");
306    }
307
308    #[test]
309    fn to_alphabetic_uppercase_only_shifts_case() {
310        for n in 1..=1000usize {
311            let lower = to_alphabetic(n, false);
312            let upper = to_alphabetic(n, true);
313            assert_eq!(upper, lower.to_uppercase(), "case mismatch at {n}");
314            assert!(
315                upper.bytes().all(|b| b.is_ascii_uppercase()),
316                "non-uppercase byte at {n}: {upper}"
317            );
318            assert!(
319                lower.bytes().all(|b| b.is_ascii_lowercase()),
320                "non-lowercase byte at {n}: {lower}"
321            );
322        }
323    }
324
325    #[test]
326    fn to_alphabetic_round_trips_through_an_independent_decoder() {
327        for n in 1..=5000u128 {
328            for uppercase in [false, true] {
329                let encoded = to_alphabetic(n as usize, uppercase);
330                assert_eq!(
331                    decode_alphabetic(&encoded, uppercase),
332                    Some(n),
333                    "round-trip failed for {n} (uppercase={uppercase}) -> {encoded}"
334                );
335            }
336        }
337    }
338
339    /// Asserts that no two inputs in `markers` produced the same string.
340    fn assert_all_distinct(markers: &[String], what: &str) {
341        let mut sorted: Vec<&String> = markers.iter().collect();
342        sorted.sort();
343        for pair in sorted.windows(2) {
344            assert_ne!(pair[0], pair[1], "duplicate {what} marker: {}", pair[0]);
345        }
346    }
347
348    #[test]
349    fn to_alphabetic_is_injective() {
350        // Two different counters must never render the same marker.
351        let markers: Vec<String> = (1..=2000usize).map(|n| to_alphabetic(n, false)).collect();
352        assert_all_distinct(&markers, "alphabetic");
353    }
354
355    #[test]
356    fn to_alphabetic_usize_max_terminates_and_stays_ascii() {
357        // `num = (num - 1) / 26` strictly decreases, so this must terminate;
358        // the `base + remainder` byte add must not overflow past 'z'/'Z'.
359        let lower = to_alphabetic(usize::MAX, false);
360        let upper = to_alphabetic(usize::MAX, true);
361        assert!(!lower.is_empty());
362        assert!(lower.bytes().all(|b: u8| b.is_ascii_lowercase()));
363        assert!(upper.bytes().all(|b: u8| b.is_ascii_uppercase()));
364        // The extreme still encodes *exactly* — no digit dropped, no wrap.
365        assert_eq!(decode_alphabetic(&lower, false), Some(usize::MAX as u128));
366        assert_eq!(decode_alphabetic(&upper, true), Some(usize::MAX as u128));
367        assert_eq!(lower.len(), upper.len());
368    }
369
370    #[test]
371    fn to_alphabetic_magnitude_of_i32_min_does_not_panic() {
372        // The magnitude that `with_sign` hands over for i32::MIN.
373        let magnitude = 2_147_483_648usize;
374        let s = to_alphabetic(magnitude, false);
375        assert!(!s.is_empty());
376        assert_eq!(decode_alphabetic(&s, false), Some(magnitude as u128));
377    }
378
379    // ------------------------------------------------------------------
380    // to_roman — numeric: zero / limits / overflow / round-trip
381    // ------------------------------------------------------------------
382
383    #[test]
384    fn to_roman_zero_falls_back_to_decimal_zero() {
385        // Roman has no zero; the impl emits "0" rather than an empty marker.
386        assert_eq!(to_roman(0, false), "0");
387        assert_eq!(to_roman(0, true), "0");
388    }
389
390    #[test]
391    fn to_roman_known_values() {
392        assert_eq!(to_roman(1, false), "i");
393        assert_eq!(to_roman(4, false), "iv");
394        assert_eq!(to_roman(9, false), "ix");
395        assert_eq!(to_roman(14, false), "xiv");
396        assert_eq!(to_roman(40, false), "xl");
397        assert_eq!(to_roman(90, false), "xc");
398        assert_eq!(to_roman(400, false), "cd");
399        assert_eq!(to_roman(900, false), "cm");
400        assert_eq!(to_roman(1990, false), "mcmxc");
401        assert_eq!(to_roman(2024, false), "mmxxiv");
402        assert_eq!(to_roman(3999, false), "mmmcmxcix");
403        assert_eq!(to_roman(2024, true), "MMXXIV");
404        assert_eq!(to_roman(3999, true), "MMMCMXCIX");
405    }
406
407    #[test]
408    fn to_roman_at_and_past_the_3999_cliff() {
409        // 3999 is the last representable numeral...
410        assert_eq!(to_roman(3999, false), "mmmcmxcix");
411        // ...and 4000 must degrade to decimal instead of emitting "mmmm" or
412        // looping forever.
413        assert_eq!(to_roman(4000, false), "4000");
414        assert_eq!(to_roman(4000, true), "4000");
415        assert_eq!(to_roman(4001, false), "4001");
416    }
417
418    #[test]
419    fn to_roman_usize_max_degrades_to_decimal() {
420        assert_eq!(to_roman(usize::MAX, false), usize::MAX.to_string());
421        assert_eq!(to_roman(usize::MAX, true), usize::MAX.to_string());
422        // The i32::MIN magnitude handed over by `with_sign`.
423        assert_eq!(to_roman(2_147_483_648, false), "2147483648");
424    }
425
426    #[test]
427    fn to_roman_round_trips_over_the_whole_representable_range() {
428        for n in 1..=3999u32 {
429            let lower = to_roman(n as usize, false);
430            let upper = to_roman(n as usize, true);
431            assert_eq!(
432                decode_roman(&lower),
433                Some(n),
434                "round-trip failed for {n} -> {lower}"
435            );
436            assert_eq!(upper, lower.to_uppercase(), "case mismatch at {n}");
437            // No numeral may repeat more than 3 times in a row (mmm is the max).
438            assert!(
439                !lower.contains("iiii")
440                    && !lower.contains("xxxx")
441                    && !lower.contains("cccc")
442                    && !lower.contains("mmmm"),
443                "malformed numeral at {n}: {lower}"
444            );
445            assert!(lower.bytes().all(|b| b"ivxlcdm".contains(&b)));
446        }
447    }
448
449    // ------------------------------------------------------------------
450    // to_greek — numeric + unicode: zero / wrap / round-trip
451    // ------------------------------------------------------------------
452
453    #[test]
454    fn to_greek_zero_is_empty_not_a_panic() {
455        assert_eq!(to_greek(0, false), "");
456        assert_eq!(to_greek(0, true), "");
457    }
458
459    #[test]
460    fn to_greek_known_values_and_wrap_boundary() {
461        assert_eq!(to_greek(1, false), "α");
462        assert_eq!(to_greek(2, false), "β");
463        assert_eq!(to_greek(24, false), "ω");
464        // 24 letters, then the documented wrap to two letters.
465        assert_eq!(to_greek(25, false), "αα");
466        assert_eq!(to_greek(26, false), "αβ");
467        assert_eq!(to_greek(48, false), "αω");
468        assert_eq!(to_greek(49, false), "βα");
469        assert_eq!(to_greek(1, true), "Α");
470        assert_eq!(to_greek(24, true), "Ω");
471        assert_eq!(to_greek(25, true), "ΑΑ");
472    }
473
474    #[test]
475    fn to_greek_emits_multibyte_chars_without_slicing_bugs() {
476        // Each greek letter is 2 bytes in UTF-8: byte len must be 2x char count,
477        // and the string must survive a full char walk (i.e. `insert(0, ..)` never
478        // split a code point).
479        let s = to_greek(25, false);
480        assert_eq!(s.chars().count(), 2);
481        assert_eq!(s.len(), 4);
482        assert!(s.chars().all(|c| GREEK_LOWER_LETTERS.contains(&c)));
483        assert!(s.is_char_boundary(0) && s.is_char_boundary(2) && s.is_char_boundary(4));
484    }
485
486    #[test]
487    fn to_greek_round_trips_through_an_independent_decoder() {
488        for n in 1..=5000u128 {
489            for uppercase in [false, true] {
490                let encoded = to_greek(n as usize, uppercase);
491                assert_eq!(
492                    decode_greek(&encoded, uppercase),
493                    Some(n),
494                    "round-trip failed for {n} (uppercase={uppercase}) -> {encoded}"
495                );
496            }
497        }
498    }
499
500    #[test]
501    fn to_greek_is_injective() {
502        let markers: Vec<String> = (1..=2000usize).map(|n| to_greek(n, true)).collect();
503        assert_all_distinct(&markers, "greek");
504    }
505
506    #[test]
507    fn to_greek_usize_max_terminates_and_stays_in_the_alphabet() {
508        // `remaining = (remaining - 1) / 24` strictly decreases -> must terminate.
509        let lower = to_greek(usize::MAX, false);
510        let upper = to_greek(usize::MAX, true);
511        assert!(!lower.is_empty());
512        assert!(lower.chars().all(|c| GREEK_LOWER_LETTERS.contains(&c)));
513        assert!(upper.chars().all(|c| GREEK_UPPER_LETTERS.contains(&c)));
514        // The extreme still encodes *exactly* — no letter dropped, no wrap.
515        assert_eq!(decode_greek(&lower, false), Some(usize::MAX as u128));
516        assert_eq!(decode_greek(&upper, true), Some(usize::MAX as u128));
517        assert_eq!(lower.chars().count(), upper.chars().count());
518    }
519
520    // ------------------------------------------------------------------
521    // with_sign — numeric: sign handling, no lossy unsigned wrap
522    // ------------------------------------------------------------------
523
524    #[test]
525    fn with_sign_passes_the_magnitude_not_a_wrapped_cast() {
526        // The whole point of `with_sign`: -3 must hand `3` to the formatter,
527        // NOT `(-3 as u32) == 4294967293`.
528        assert_eq!(with_sign(-3, |n| n.to_string()), "-3");
529        assert_eq!(with_sign(-1, |n| n.to_string()), "-1");
530        assert_eq!(with_sign(0, |n| n.to_string()), "0");
531        assert_eq!(with_sign(1, |n| n.to_string()), "1");
532        assert_eq!(with_sign(i32::MAX, |n| n.to_string()), "2147483647");
533    }
534
535    #[test]
536    fn with_sign_handles_i32_min_without_overflow() {
537        // `-i32::MIN` overflows i32 — the impl must widen to i64 first.
538        assert_eq!(with_sign(i32::MIN, |n| n.to_string()), "-2147483648");
539        assert_eq!(with_sign(i32::MIN + 1, |n| n.to_string()), "-2147483647");
540    }
541
542    #[test]
543    fn with_sign_zero_is_unsigned() {
544        // 0 is not negative, so no "-0" marker may be produced.
545        let s = with_sign(0, |n| to_alphabetic(n, false));
546        assert!(!s.starts_with('-'), "produced a signed zero: {s}");
547        assert_eq!(s, "");
548    }
549
550    #[test]
551    fn with_sign_prefixes_exactly_one_minus() {
552        for v in [-1, -26, -3999, -4000, i32::MIN] {
553            let s = with_sign(v, |n| to_roman(n, false));
554            assert!(s.starts_with('-'), "missing sign for {v}: {s}");
555            assert_eq!(s.matches('-').count(), 1, "double sign for {v}: {s}");
556            assert!(s.len() > 1, "sign with no magnitude for {v}");
557        }
558    }
559
560    #[test]
561    fn with_sign_is_transparent_to_the_formatter_output() {
562        // A formatter returning an empty string yields just the sign — the sign is
563        // never swallowed (`decimal_fallback` is what rescues the empty case).
564        assert_eq!(with_sign(-5, |_| String::new()), "-");
565        assert_eq!(with_sign(5, |_| String::new()), "");
566        // Unicode from the formatter passes through byte-for-byte.
567        assert_eq!(with_sign(-5, |_| "αβγ".to_string()), "-αβγ");
568    }
569
570    #[test]
571    fn with_sign_negation_is_symmetric_across_the_range() {
572        for v in [1i32, 2, 26, 27, 3999, 4000, i32::MAX] {
573            let pos = with_sign(v, |n| to_alphabetic(n, false));
574            let neg = with_sign(-v, |n| to_alphabetic(n, false));
575            assert_eq!(neg, format!("-{pos}"), "asymmetric at {v}");
576        }
577    }
578
579    // ------------------------------------------------------------------
580    // decimal_fallback — numeric: the "never blank" invariant
581    // ------------------------------------------------------------------
582
583    #[test]
584    fn decimal_fallback_replaces_empty_with_decimal() {
585        assert_eq!(decimal_fallback(0, String::new()), "0");
586        assert_eq!(decimal_fallback(-1, String::new()), "-1");
587        assert_eq!(decimal_fallback(i32::MAX, String::new()), "2147483647");
588        assert_eq!(decimal_fallback(i32::MIN, String::new()), "-2147483648");
589    }
590
591    #[test]
592    fn decimal_fallback_passes_non_empty_through_untouched() {
593        // Even a "wrong looking" formatted value is preserved: the fallback keys
594        // off emptiness only, never off the numeric value.
595        assert_eq!(decimal_fallback(5, "a".to_string()), "a");
596        assert_eq!(decimal_fallback(0, "z".to_string()), "z");
597        assert_eq!(decimal_fallback(0, "α".to_string()), "α");
598        // Whitespace is NOT empty -> not replaced.
599        assert_eq!(decimal_fallback(7, " ".to_string()), " ");
600        // A lone NUL byte counts as non-empty too.
601        assert_eq!(decimal_fallback(7, "\0".to_string()), "\0");
602    }
603
604    #[test]
605    fn decimal_fallback_output_is_never_blank_for_any_i32() {
606        for v in EDGE_VALUES {
607            assert!(
608                !decimal_fallback(v, String::new()).is_empty(),
609                "blank marker for {v}"
610            );
611        }
612    }
613
614    // ------------------------------------------------------------------
615    // format_counter — serializer: no panic, well-formed, spec-shaped
616    // ------------------------------------------------------------------
617
618    #[test]
619    fn format_counter_no_panic_on_edge_values_for_every_style() {
620        for style in ALL_STYLES {
621            for v in EDGE_VALUES {
622                let s = format_counter(v, style);
623                if style == StyleListStyleType::None {
624                    assert!(s.is_empty(), "`none` must render nothing, got {s:?}");
625                } else {
626                    // The core CSS invariant: a marker is never blank.
627                    assert!(!s.is_empty(), "blank marker for {v} in {style:?}");
628                    // ...and never blank-looking either.
629                    assert!(
630                        !s.chars().all(char::is_whitespace),
631                        "whitespace-only marker for {v} in {style:?}"
632                    );
633                }
634            }
635        }
636    }
637
638    #[test]
639    fn format_counter_is_deterministic() {
640        for style in ALL_STYLES {
641            for v in EDGE_VALUES {
642                assert_eq!(format_counter(v, style), format_counter(v, style));
643            }
644        }
645    }
646
647    #[test]
648    fn format_counter_default_style_is_disc_and_ignores_the_value() {
649        assert_eq!(format_counter(0, StyleListStyleType::default()), "•");
650        for v in EDGE_VALUES {
651            assert_eq!(format_counter(v, StyleListStyleType::default()), "•");
652        }
653    }
654
655    #[test]
656    fn format_counter_bullet_styles_ignore_the_value() {
657        for (style, expected) in [
658            (StyleListStyleType::Disc, "•"),
659            (StyleListStyleType::Circle, "◦"),
660            (StyleListStyleType::Square, "▪"),
661        ] {
662            for v in EDGE_VALUES {
663                let s = format_counter(v, style);
664                assert_eq!(s, expected, "bullet changed with value {v}");
665                assert_eq!(s.chars().count(), 1);
666            }
667        }
668    }
669
670    #[test]
671    fn format_counter_decimal_matches_i32_display() {
672        for v in EDGE_VALUES {
673            assert_eq!(format_counter(v, StyleListStyleType::Decimal), v.to_string());
674        }
675    }
676
677    #[test]
678    fn format_counter_decimal_leading_zero_pads_single_digits() {
679        assert_eq!(
680            format_counter(0, StyleListStyleType::DecimalLeadingZero),
681            "00"
682        );
683        assert_eq!(
684            format_counter(5, StyleListStyleType::DecimalLeadingZero),
685            "05"
686        );
687        assert_eq!(
688            format_counter(9, StyleListStyleType::DecimalLeadingZero),
689            "09"
690        );
691        assert_eq!(
692            format_counter(10, StyleListStyleType::DecimalLeadingZero),
693            "10"
694        );
695        assert_eq!(
696            format_counter(100, StyleListStyleType::DecimalLeadingZero),
697            "100"
698        );
699        assert_eq!(
700            format_counter(i32::MAX, StyleListStyleType::DecimalLeadingZero),
701            "2147483647"
702        );
703    }
704
705    #[test]
706    fn format_counter_decimal_leading_zero_negative_current_behavior() {
707        // NOTE (reported, not weakened): `format!("{value:02}")` pads the *total*
708        // width including the sign, so -5 renders as "-5". CSS
709        // `decimal-leading-zero` pads the digits only, i.e. "-05". This test pins
710        // the current behavior so the deviation is visible if/when it is fixed.
711        assert_eq!(
712            format_counter(-5, StyleListStyleType::DecimalLeadingZero),
713            "-5"
714        );
715        assert_eq!(
716            format_counter(i32::MIN, StyleListStyleType::DecimalLeadingZero),
717            "-2147483648"
718        );
719    }
720
721    #[test]
722    fn format_counter_alpha_known_values_and_zero_fallback() {
723        assert_eq!(format_counter(1, StyleListStyleType::LowerAlpha), "a");
724        assert_eq!(format_counter(26, StyleListStyleType::LowerAlpha), "z");
725        assert_eq!(format_counter(27, StyleListStyleType::LowerAlpha), "aa");
726        assert_eq!(format_counter(1, StyleListStyleType::UpperAlpha), "A");
727        assert_eq!(format_counter(27, StyleListStyleType::UpperAlpha), "AA");
728        // 0 has no alphabetic representation -> decimal fallback, not a blank.
729        assert_eq!(format_counter(0, StyleListStyleType::LowerAlpha), "0");
730        assert_eq!(format_counter(0, StyleListStyleType::UpperAlpha), "0");
731        // Negatives keep the sign instead of wrapping to a giant unsigned value.
732        assert_eq!(format_counter(-1, StyleListStyleType::LowerAlpha), "-a");
733        assert_eq!(format_counter(-3, StyleListStyleType::UpperAlpha), "-C");
734    }
735
736    #[test]
737    fn format_counter_roman_known_values_and_limits() {
738        assert_eq!(format_counter(1, StyleListStyleType::LowerRoman), "i");
739        assert_eq!(format_counter(4, StyleListStyleType::LowerRoman), "iv");
740        assert_eq!(format_counter(2024, StyleListStyleType::UpperRoman), "MMXXIV");
741        assert_eq!(format_counter(3999, StyleListStyleType::LowerRoman), "mmmcmxcix");
742        // Past the roman range -> decimal, never an unbounded "mmmm..." string.
743        assert_eq!(format_counter(4000, StyleListStyleType::LowerRoman), "4000");
744        assert_eq!(format_counter(4000, StyleListStyleType::UpperRoman), "4000");
745        // No roman zero.
746        assert_eq!(format_counter(0, StyleListStyleType::LowerRoman), "0");
747        assert_eq!(format_counter(0, StyleListStyleType::UpperRoman), "0");
748        assert_eq!(format_counter(-3, StyleListStyleType::LowerRoman), "-iii");
749        assert_eq!(format_counter(-14, StyleListStyleType::UpperRoman), "-XIV");
750    }
751
752    #[test]
753    fn format_counter_roman_at_i32_min_matches_signed_decimal() {
754        // The magnitude of i32::MIN is far past MAX_ROMAN, so the marker degrades
755        // to decimal — and must equal the plain decimal rendering, i.e. the
756        // `unsigned_abs` widening must not have wrapped.
757        assert_eq!(
758            format_counter(i32::MIN, StyleListStyleType::LowerRoman),
759            "-2147483648"
760        );
761        assert_eq!(
762            format_counter(i32::MIN, StyleListStyleType::LowerRoman),
763            format_counter(i32::MIN, StyleListStyleType::Decimal)
764        );
765    }
766
767    #[test]
768    fn format_counter_greek_known_values_and_zero_fallback() {
769        assert_eq!(format_counter(1, StyleListStyleType::LowerGreek), "α");
770        assert_eq!(format_counter(24, StyleListStyleType::LowerGreek), "ω");
771        assert_eq!(format_counter(25, StyleListStyleType::LowerGreek), "αα");
772        assert_eq!(format_counter(1, StyleListStyleType::UpperGreek), "Α");
773        assert_eq!(format_counter(24, StyleListStyleType::UpperGreek), "Ω");
774        // 0 -> decimal fallback, not a blank marker.
775        assert_eq!(format_counter(0, StyleListStyleType::LowerGreek), "0");
776        assert_eq!(format_counter(0, StyleListStyleType::UpperGreek), "0");
777        assert_eq!(format_counter(-2, StyleListStyleType::LowerGreek), "-β");
778        assert_eq!(format_counter(-1, StyleListStyleType::UpperGreek), "-Α");
779    }
780
781    #[test]
782    fn format_counter_negative_is_positive_with_a_minus_for_letter_styles() {
783        // Guards the regression the `with_sign` doc calls out: a lossy `as u32`
784        // cast would make -3 render as a huge unsigned counter, not "-iii"/"-c".
785        for style in [
786            StyleListStyleType::LowerAlpha,
787            StyleListStyleType::UpperAlpha,
788            StyleListStyleType::LowerRoman,
789            StyleListStyleType::UpperRoman,
790            StyleListStyleType::LowerGreek,
791            StyleListStyleType::UpperGreek,
792        ] {
793            for v in [1i32, 2, 3, 24, 25, 26, 27, 3999] {
794                let pos = format_counter(v, style);
795                let neg = format_counter(-v, style);
796                assert_eq!(neg, format!("-{pos}"), "asymmetric at {v} in {style:?}");
797                assert!(!pos.starts_with('-'), "positive gained a sign at {v}");
798            }
799        }
800    }
801
802    #[test]
803    fn format_counter_letter_styles_never_leak_huge_unsigned_markers() {
804        // A wrapped cast would produce a marker for -1 as long as the one for
805        // 4294967295. Bound the length instead of trusting the exact string.
806        for style in [
807            StyleListStyleType::LowerAlpha,
808            StyleListStyleType::UpperGreek,
809        ] {
810            let s = format_counter(-1, style);
811            assert_eq!(s.chars().count(), 2, "suspiciously long marker: {s}");
812        }
813    }
814
815    #[test]
816    fn format_counter_marker_length_stays_bounded_at_i32_extremes() {
817        // No style may blow up into a megabyte-long marker at the i32 extremes.
818        for style in ALL_STYLES {
819            for v in [i32::MIN, i32::MAX] {
820                let s = format_counter(v, style);
821                assert!(
822                    s.chars().count() <= 32,
823                    "marker for {v} in {style:?} is {} chars: {s}",
824                    s.chars().count()
825                );
826            }
827        }
828    }
829}