From 9852cd23b9c8d6a38689c70330110c0e37f2958e Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 27 Jun 2026 09:46:00 -0700 Subject: [PATCH 1/5] =?UTF-8?q?fix(intl):=20#5581=20=E2=80=94=20NumberForm?= =?UTF-8?q?at=20numbering-system=20digit=20transliteration=20+=20roundingI?= =?UTF-8?q?ncrement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two spec-grounded gaps in `Intl.NumberFormat` rendering, both surfaced by the intl402/NumberFormat test262 cluster (#5581): 1. **Numbering systems.** Output digits were always Latin (`latn`); a formatter resolved to a non-Latin system (`-u-nu-arab`, the `numberingSystem` option, etc.) still emitted ASCII digits. Added `numbering_system_digits` (the full 77-system glyph table, generated from test262's `numberingSystemDigits`) and a `transliterate_parts_digits` pass that rewrites the digit glyphs of the `integer`/`fraction`/`exponentInteger` segments — separators, signs, and currency/unit/compact literals keep their locale glyphs. Applied in the shared `number_parts_from_resolved` core so `format`, `formatToParts`, and the DurationFormat consumer all benefit; `latn`/unknown is a no-op. 2. **roundingIncrement.** The option was validated and surfaced in resolvedOptions but never applied during rendering. Added `round_to_increment` (rounds the scaled integer to the nearest multiple of the increment, breaking ties via the shared `round_decision` core) and wired it into the standard notation branch. Refactored the ApplyUnsignedRoundingMode logic out of `rounding_up` into `round_decision` so fraction- and increment-rounding share one mode implementation. Flips 21 intl402/NumberFormat test262 cases (all 14 roundingIncrement tests, numbering-systems.js, and the fraction/significant cases that were gated on the non-Latin digit assertion) with zero regressions across the tree. Partial progress on #5581; the remaining NumberFormat clusters (units, accounting currency, compact-locale tables, formatRange) and the #5580/#5582 Temporal/ DateTimeFormat clusters are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../perry-runtime/src/intl/number_format.rs | 223 +++++++++++++++++- 1 file changed, 220 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/intl/number_format.rs b/crates/perry-runtime/src/intl/number_format.rs index 753347192..332d6ed24 100644 --- a/crates/perry-runtime/src/intl/number_format.rs +++ b/crates/perry-runtime/src/intl/number_format.rs @@ -181,11 +181,21 @@ pub(crate) fn rounding_up(last_kept: u8, dropped: &[u8]) -> bool { if dropped.iter().all(|&d| d == b'0') { return false; // exact — never rounds. } - let (mode, neg) = ROUND_CTX.with(|c| c.get()); let first = dropped.first().copied().unwrap_or(b'0'); let rest_zero = dropped[1..].iter().all(|&d| d == b'0'); let exactly_half = first == b'5' && rest_zero; let more_half = first > b'5' || (first == b'5' && !rest_zero); + round_decision(more_half, exactly_half, (last_kept - b'0') % 2 == 1) +} + +/// Core ECMA-402 ApplyUnsignedRoundingMode decision, shared by fraction- and +/// increment-rounding. `more_half`/`exactly_half` classify the dropped remainder +/// against half the rounding unit; `kept_is_odd` is the parity of the retained +/// quantity (only consulted for `halfEven`). Returns whether to round the +/// magnitude up. Callers must guarantee a nonzero remainder before calling — +/// the directional modes (`ceil`/`floor`/`expand`) round up unconditionally. +fn round_decision(more_half: bool, exactly_half: bool, kept_is_odd: bool) -> bool { + let (mode, neg) = ROUND_CTX.with(|c| c.get()); let half_or_more = more_half || exactly_half; match mode { ROUND_CEIL => !neg, @@ -207,11 +217,87 @@ pub(crate) fn rounding_up(last_kept: u8, dropped: &[u8]) -> bool { } } ROUND_HALF_TRUNC => more_half, - ROUND_HALF_EVEN => more_half || (exactly_half && (last_kept - b'0') % 2 == 1), + ROUND_HALF_EVEN => more_half || (exactly_half && kept_is_odd), _ => half_or_more, // halfExpand (default) } } +/// Round the decimal `int_part.frac_part` to the nearest multiple of +/// `increment` at exactly `frac_digits` fractional places, under the active +/// rounding mode (ECMA-402 `roundingIncrement`). Returns `(int, frac)` with the +/// fraction zero-padded to `frac_digits`. `roundingIncrement` is only resolvable +/// alongside fixed fraction-digit rounding (`minimumFractionDigits == +/// maximumFractionDigits`, no significant digits), so a single fraction width +/// fully describes the result. +pub(crate) fn round_to_increment( + int_part: &str, + frac_part: &str, + frac_digits: usize, + increment: u128, +) -> (String, String) { + // Scale by 10^frac_digits so the increment acts on integers: the first + // `int_len + frac_digits` digits form the scaled integer `q`; any remaining + // digits are the dropped fractional tail used to break ties. + let mut combined: Vec = Vec::with_capacity(int_part.len() + frac_part.len()); + combined.extend(int_part.bytes()); + combined.extend(frac_part.bytes()); + let cut = int_part.len() + frac_digits; + while combined.len() < cut { + combined.push(b'0'); + } + let dropped = &combined[cut..]; + // Astronomically large operands (q or the tail past u128) fall back to plain + // fraction rounding — unreachable for any realistic formatter input. + let fallback = || round_to_fraction(int_part, frac_part, frac_digits); + let Ok(q) = std::str::from_utf8(&combined[..cut]).unwrap().parse::() else { + return fallback(); + }; + let dropped_zero = dropped.iter().all(|&d| d == b'0'); + let dropped_int: u128 = if dropped.is_empty() { + 0 + } else { + match std::str::from_utf8(dropped).unwrap().parse() { + Ok(v) => v, + Err(_) => return fallback(), + } + }; + let rem = q % increment; + let base = q - rem; + let m = if rem == 0 && dropped_zero { + q // exact multiple — no rounding. + } else { + // Position of `rem.dropped` within [0, increment): compare against + // increment/2 by cross-multiplying out the dropped fraction + // (2·rem·10^k + 2·dropped) vs increment·10^k, with k = dropped digits. + let classify = || -> Option<(bool, bool)> { + let pow10 = 10u128.checked_pow(dropped.len() as u32)?; + let lhs = rem + .checked_mul(2)? + .checked_mul(pow10)? + .checked_add(dropped_int.checked_mul(2)?)?; + let rhs = increment.checked_mul(pow10)?; + Some((lhs > rhs, lhs == rhs)) + }; + let Some((more_half, exactly_half)) = classify() else { + return fallback(); + }; + if round_decision(more_half, exactly_half, (base / increment) % 2 == 1) { + base + increment + } else { + base + } + }; + // Place the decimal point `frac_digits` from the right of the scaled integer. + let mut m_str = m.to_string().into_bytes(); + while m_str.len() <= frac_digits { + m_str.insert(0, b'0'); + } + let split = m_str.len() - frac_digits; + let int_str = String::from_utf8(m_str[..split].to_vec()).unwrap(); + let frac_str = String::from_utf8(m_str[split..].to_vec()).unwrap(); + (strip_leading_zeros(int_str), frac_str) +} + /// Round the decimal value `int_part.frac_part` to exactly `frac_digits` /// fractional places under the active rounding mode, operating on the digit /// strings so the result is independent of the binary float's representation @@ -442,12 +528,134 @@ pub(crate) fn number_instance_parts( number_parts_from_resolved(&r, value) } +/// The ten digit glyphs for a Unicode numbering system, or `None` for `latn` +/// (and any unrecognized system, which falls back to Latin digits). Generated +/// from the test262 `numberingSystemDigits` table; covers every system with a +/// simple sequential-or-tabulated digit mapping. +pub(crate) fn numbering_system_digits(name: &str) -> Option<[char; 10]> { + match name { + // BEGIN generated numbering-system digit table + "adlm" => Some(['\u{1e950}', '\u{1e951}', '\u{1e952}', '\u{1e953}', '\u{1e954}', '\u{1e955}', '\u{1e956}', '\u{1e957}', '\u{1e958}', '\u{1e959}']), + "ahom" => Some(['\u{11730}', '\u{11731}', '\u{11732}', '\u{11733}', '\u{11734}', '\u{11735}', '\u{11736}', '\u{11737}', '\u{11738}', '\u{11739}']), + "arab" => Some(['\u{660}', '\u{661}', '\u{662}', '\u{663}', '\u{664}', '\u{665}', '\u{666}', '\u{667}', '\u{668}', '\u{669}']), + "arabext" => Some(['\u{6f0}', '\u{6f1}', '\u{6f2}', '\u{6f3}', '\u{6f4}', '\u{6f5}', '\u{6f6}', '\u{6f7}', '\u{6f8}', '\u{6f9}']), + "bali" => Some(['\u{1b50}', '\u{1b51}', '\u{1b52}', '\u{1b53}', '\u{1b54}', '\u{1b55}', '\u{1b56}', '\u{1b57}', '\u{1b58}', '\u{1b59}']), + "beng" => Some(['\u{9e6}', '\u{9e7}', '\u{9e8}', '\u{9e9}', '\u{9ea}', '\u{9eb}', '\u{9ec}', '\u{9ed}', '\u{9ee}', '\u{9ef}']), + "bhks" => Some(['\u{11c50}', '\u{11c51}', '\u{11c52}', '\u{11c53}', '\u{11c54}', '\u{11c55}', '\u{11c56}', '\u{11c57}', '\u{11c58}', '\u{11c59}']), + "brah" => Some(['\u{11066}', '\u{11067}', '\u{11068}', '\u{11069}', '\u{1106a}', '\u{1106b}', '\u{1106c}', '\u{1106d}', '\u{1106e}', '\u{1106f}']), + "cakm" => Some(['\u{11136}', '\u{11137}', '\u{11138}', '\u{11139}', '\u{1113a}', '\u{1113b}', '\u{1113c}', '\u{1113d}', '\u{1113e}', '\u{1113f}']), + "cham" => Some(['\u{aa50}', '\u{aa51}', '\u{aa52}', '\u{aa53}', '\u{aa54}', '\u{aa55}', '\u{aa56}', '\u{aa57}', '\u{aa58}', '\u{aa59}']), + "deva" => Some(['\u{966}', '\u{967}', '\u{968}', '\u{969}', '\u{96a}', '\u{96b}', '\u{96c}', '\u{96d}', '\u{96e}', '\u{96f}']), + "diak" => Some(['\u{11950}', '\u{11951}', '\u{11952}', '\u{11953}', '\u{11954}', '\u{11955}', '\u{11956}', '\u{11957}', '\u{11958}', '\u{11959}']), + "fullwide" => Some(['\u{ff10}', '\u{ff11}', '\u{ff12}', '\u{ff13}', '\u{ff14}', '\u{ff15}', '\u{ff16}', '\u{ff17}', '\u{ff18}', '\u{ff19}']), + "gara" => Some(['\u{10d40}', '\u{10d41}', '\u{10d42}', '\u{10d43}', '\u{10d44}', '\u{10d45}', '\u{10d46}', '\u{10d47}', '\u{10d48}', '\u{10d49}']), + "gong" => Some(['\u{11da0}', '\u{11da1}', '\u{11da2}', '\u{11da3}', '\u{11da4}', '\u{11da5}', '\u{11da6}', '\u{11da7}', '\u{11da8}', '\u{11da9}']), + "gonm" => Some(['\u{11d50}', '\u{11d51}', '\u{11d52}', '\u{11d53}', '\u{11d54}', '\u{11d55}', '\u{11d56}', '\u{11d57}', '\u{11d58}', '\u{11d59}']), + "gujr" => Some(['\u{ae6}', '\u{ae7}', '\u{ae8}', '\u{ae9}', '\u{aea}', '\u{aeb}', '\u{aec}', '\u{aed}', '\u{aee}', '\u{aef}']), + "gukh" => Some(['\u{16130}', '\u{16131}', '\u{16132}', '\u{16133}', '\u{16134}', '\u{16135}', '\u{16136}', '\u{16137}', '\u{16138}', '\u{16139}']), + "guru" => Some(['\u{a66}', '\u{a67}', '\u{a68}', '\u{a69}', '\u{a6a}', '\u{a6b}', '\u{a6c}', '\u{a6d}', '\u{a6e}', '\u{a6f}']), + "hanidec" => Some(['\u{3007}', '\u{4e00}', '\u{4e8c}', '\u{4e09}', '\u{56db}', '\u{4e94}', '\u{516d}', '\u{4e03}', '\u{516b}', '\u{4e5d}']), + "hmng" => Some(['\u{16b50}', '\u{16b51}', '\u{16b52}', '\u{16b53}', '\u{16b54}', '\u{16b55}', '\u{16b56}', '\u{16b57}', '\u{16b58}', '\u{16b59}']), + "hmnp" => Some(['\u{1e140}', '\u{1e141}', '\u{1e142}', '\u{1e143}', '\u{1e144}', '\u{1e145}', '\u{1e146}', '\u{1e147}', '\u{1e148}', '\u{1e149}']), + "java" => Some(['\u{a9d0}', '\u{a9d1}', '\u{a9d2}', '\u{a9d3}', '\u{a9d4}', '\u{a9d5}', '\u{a9d6}', '\u{a9d7}', '\u{a9d8}', '\u{a9d9}']), + "kali" => Some(['\u{a900}', '\u{a901}', '\u{a902}', '\u{a903}', '\u{a904}', '\u{a905}', '\u{a906}', '\u{a907}', '\u{a908}', '\u{a909}']), + "kawi" => Some(['\u{11f50}', '\u{11f51}', '\u{11f52}', '\u{11f53}', '\u{11f54}', '\u{11f55}', '\u{11f56}', '\u{11f57}', '\u{11f58}', '\u{11f59}']), + "khmr" => Some(['\u{17e0}', '\u{17e1}', '\u{17e2}', '\u{17e3}', '\u{17e4}', '\u{17e5}', '\u{17e6}', '\u{17e7}', '\u{17e8}', '\u{17e9}']), + "knda" => Some(['\u{ce6}', '\u{ce7}', '\u{ce8}', '\u{ce9}', '\u{cea}', '\u{ceb}', '\u{cec}', '\u{ced}', '\u{cee}', '\u{cef}']), + "krai" => Some(['\u{16d70}', '\u{16d71}', '\u{16d72}', '\u{16d73}', '\u{16d74}', '\u{16d75}', '\u{16d76}', '\u{16d77}', '\u{16d78}', '\u{16d79}']), + "lana" => Some(['\u{1a80}', '\u{1a81}', '\u{1a82}', '\u{1a83}', '\u{1a84}', '\u{1a85}', '\u{1a86}', '\u{1a87}', '\u{1a88}', '\u{1a89}']), + "lanatham" => Some(['\u{1a90}', '\u{1a91}', '\u{1a92}', '\u{1a93}', '\u{1a94}', '\u{1a95}', '\u{1a96}', '\u{1a97}', '\u{1a98}', '\u{1a99}']), + "laoo" => Some(['\u{ed0}', '\u{ed1}', '\u{ed2}', '\u{ed3}', '\u{ed4}', '\u{ed5}', '\u{ed6}', '\u{ed7}', '\u{ed8}', '\u{ed9}']), + "lepc" => Some(['\u{1c40}', '\u{1c41}', '\u{1c42}', '\u{1c43}', '\u{1c44}', '\u{1c45}', '\u{1c46}', '\u{1c47}', '\u{1c48}', '\u{1c49}']), + "limb" => Some(['\u{1946}', '\u{1947}', '\u{1948}', '\u{1949}', '\u{194a}', '\u{194b}', '\u{194c}', '\u{194d}', '\u{194e}', '\u{194f}']), + "mathbold" => Some(['\u{1d7ce}', '\u{1d7cf}', '\u{1d7d0}', '\u{1d7d1}', '\u{1d7d2}', '\u{1d7d3}', '\u{1d7d4}', '\u{1d7d5}', '\u{1d7d6}', '\u{1d7d7}']), + "mathdbl" => Some(['\u{1d7d8}', '\u{1d7d9}', '\u{1d7da}', '\u{1d7db}', '\u{1d7dc}', '\u{1d7dd}', '\u{1d7de}', '\u{1d7df}', '\u{1d7e0}', '\u{1d7e1}']), + "mathmono" => Some(['\u{1d7f6}', '\u{1d7f7}', '\u{1d7f8}', '\u{1d7f9}', '\u{1d7fa}', '\u{1d7fb}', '\u{1d7fc}', '\u{1d7fd}', '\u{1d7fe}', '\u{1d7ff}']), + "mathsanb" => Some(['\u{1d7ec}', '\u{1d7ed}', '\u{1d7ee}', '\u{1d7ef}', '\u{1d7f0}', '\u{1d7f1}', '\u{1d7f2}', '\u{1d7f3}', '\u{1d7f4}', '\u{1d7f5}']), + "mathsans" => Some(['\u{1d7e2}', '\u{1d7e3}', '\u{1d7e4}', '\u{1d7e5}', '\u{1d7e6}', '\u{1d7e7}', '\u{1d7e8}', '\u{1d7e9}', '\u{1d7ea}', '\u{1d7eb}']), + "mlym" => Some(['\u{d66}', '\u{d67}', '\u{d68}', '\u{d69}', '\u{d6a}', '\u{d6b}', '\u{d6c}', '\u{d6d}', '\u{d6e}', '\u{d6f}']), + "modi" => Some(['\u{11650}', '\u{11651}', '\u{11652}', '\u{11653}', '\u{11654}', '\u{11655}', '\u{11656}', '\u{11657}', '\u{11658}', '\u{11659}']), + "mong" => Some(['\u{1810}', '\u{1811}', '\u{1812}', '\u{1813}', '\u{1814}', '\u{1815}', '\u{1816}', '\u{1817}', '\u{1818}', '\u{1819}']), + "mroo" => Some(['\u{16a60}', '\u{16a61}', '\u{16a62}', '\u{16a63}', '\u{16a64}', '\u{16a65}', '\u{16a66}', '\u{16a67}', '\u{16a68}', '\u{16a69}']), + "mtei" => Some(['\u{abf0}', '\u{abf1}', '\u{abf2}', '\u{abf3}', '\u{abf4}', '\u{abf5}', '\u{abf6}', '\u{abf7}', '\u{abf8}', '\u{abf9}']), + "mymr" => Some(['\u{1040}', '\u{1041}', '\u{1042}', '\u{1043}', '\u{1044}', '\u{1045}', '\u{1046}', '\u{1047}', '\u{1048}', '\u{1049}']), + "mymrepka" => Some(['\u{116da}', '\u{116db}', '\u{116dc}', '\u{116dd}', '\u{116de}', '\u{116df}', '\u{116e0}', '\u{116e1}', '\u{116e2}', '\u{116e3}']), + "mymrpao" => Some(['\u{116d0}', '\u{116d1}', '\u{116d2}', '\u{116d3}', '\u{116d4}', '\u{116d5}', '\u{116d6}', '\u{116d7}', '\u{116d8}', '\u{116d9}']), + "mymrshan" => Some(['\u{1090}', '\u{1091}', '\u{1092}', '\u{1093}', '\u{1094}', '\u{1095}', '\u{1096}', '\u{1097}', '\u{1098}', '\u{1099}']), + "mymrtlng" => Some(['\u{a9f0}', '\u{a9f1}', '\u{a9f2}', '\u{a9f3}', '\u{a9f4}', '\u{a9f5}', '\u{a9f6}', '\u{a9f7}', '\u{a9f8}', '\u{a9f9}']), + "nagm" => Some(['\u{1e4f0}', '\u{1e4f1}', '\u{1e4f2}', '\u{1e4f3}', '\u{1e4f4}', '\u{1e4f5}', '\u{1e4f6}', '\u{1e4f7}', '\u{1e4f8}', '\u{1e4f9}']), + "newa" => Some(['\u{11450}', '\u{11451}', '\u{11452}', '\u{11453}', '\u{11454}', '\u{11455}', '\u{11456}', '\u{11457}', '\u{11458}', '\u{11459}']), + "nkoo" => Some(['\u{7c0}', '\u{7c1}', '\u{7c2}', '\u{7c3}', '\u{7c4}', '\u{7c5}', '\u{7c6}', '\u{7c7}', '\u{7c8}', '\u{7c9}']), + "olck" => Some(['\u{1c50}', '\u{1c51}', '\u{1c52}', '\u{1c53}', '\u{1c54}', '\u{1c55}', '\u{1c56}', '\u{1c57}', '\u{1c58}', '\u{1c59}']), + "onao" => Some(['\u{1e5f1}', '\u{1e5f2}', '\u{1e5f3}', '\u{1e5f4}', '\u{1e5f5}', '\u{1e5f6}', '\u{1e5f7}', '\u{1e5f8}', '\u{1e5f9}', '\u{1e5fa}']), + "orya" => Some(['\u{b66}', '\u{b67}', '\u{b68}', '\u{b69}', '\u{b6a}', '\u{b6b}', '\u{b6c}', '\u{b6d}', '\u{b6e}', '\u{b6f}']), + "osma" => Some(['\u{104a0}', '\u{104a1}', '\u{104a2}', '\u{104a3}', '\u{104a4}', '\u{104a5}', '\u{104a6}', '\u{104a7}', '\u{104a8}', '\u{104a9}']), + "outlined" => Some(['\u{1ccf0}', '\u{1ccf1}', '\u{1ccf2}', '\u{1ccf3}', '\u{1ccf4}', '\u{1ccf5}', '\u{1ccf6}', '\u{1ccf7}', '\u{1ccf8}', '\u{1ccf9}']), + "rohg" => Some(['\u{10d30}', '\u{10d31}', '\u{10d32}', '\u{10d33}', '\u{10d34}', '\u{10d35}', '\u{10d36}', '\u{10d37}', '\u{10d38}', '\u{10d39}']), + "saur" => Some(['\u{a8d0}', '\u{a8d1}', '\u{a8d2}', '\u{a8d3}', '\u{a8d4}', '\u{a8d5}', '\u{a8d6}', '\u{a8d7}', '\u{a8d8}', '\u{a8d9}']), + "segment" => Some(['\u{1fbf0}', '\u{1fbf1}', '\u{1fbf2}', '\u{1fbf3}', '\u{1fbf4}', '\u{1fbf5}', '\u{1fbf6}', '\u{1fbf7}', '\u{1fbf8}', '\u{1fbf9}']), + "shrd" => Some(['\u{111d0}', '\u{111d1}', '\u{111d2}', '\u{111d3}', '\u{111d4}', '\u{111d5}', '\u{111d6}', '\u{111d7}', '\u{111d8}', '\u{111d9}']), + "sind" => Some(['\u{112f0}', '\u{112f1}', '\u{112f2}', '\u{112f3}', '\u{112f4}', '\u{112f5}', '\u{112f6}', '\u{112f7}', '\u{112f8}', '\u{112f9}']), + "sinh" => Some(['\u{de6}', '\u{de7}', '\u{de8}', '\u{de9}', '\u{dea}', '\u{deb}', '\u{dec}', '\u{ded}', '\u{dee}', '\u{def}']), + "sora" => Some(['\u{110f0}', '\u{110f1}', '\u{110f2}', '\u{110f3}', '\u{110f4}', '\u{110f5}', '\u{110f6}', '\u{110f7}', '\u{110f8}', '\u{110f9}']), + "sund" => Some(['\u{1bb0}', '\u{1bb1}', '\u{1bb2}', '\u{1bb3}', '\u{1bb4}', '\u{1bb5}', '\u{1bb6}', '\u{1bb7}', '\u{1bb8}', '\u{1bb9}']), + "sunu" => Some(['\u{11bf0}', '\u{11bf1}', '\u{11bf2}', '\u{11bf3}', '\u{11bf4}', '\u{11bf5}', '\u{11bf6}', '\u{11bf7}', '\u{11bf8}', '\u{11bf9}']), + "takr" => Some(['\u{116c0}', '\u{116c1}', '\u{116c2}', '\u{116c3}', '\u{116c4}', '\u{116c5}', '\u{116c6}', '\u{116c7}', '\u{116c8}', '\u{116c9}']), + "talu" => Some(['\u{19d0}', '\u{19d1}', '\u{19d2}', '\u{19d3}', '\u{19d4}', '\u{19d5}', '\u{19d6}', '\u{19d7}', '\u{19d8}', '\u{19d9}']), + "tamldec" => Some(['\u{be6}', '\u{be7}', '\u{be8}', '\u{be9}', '\u{bea}', '\u{beb}', '\u{bec}', '\u{bed}', '\u{bee}', '\u{bef}']), + "telu" => Some(['\u{c66}', '\u{c67}', '\u{c68}', '\u{c69}', '\u{c6a}', '\u{c6b}', '\u{c6c}', '\u{c6d}', '\u{c6e}', '\u{c6f}']), + "thai" => Some(['\u{e50}', '\u{e51}', '\u{e52}', '\u{e53}', '\u{e54}', '\u{e55}', '\u{e56}', '\u{e57}', '\u{e58}', '\u{e59}']), + "tibt" => Some(['\u{f20}', '\u{f21}', '\u{f22}', '\u{f23}', '\u{f24}', '\u{f25}', '\u{f26}', '\u{f27}', '\u{f28}', '\u{f29}']), + "tirh" => Some(['\u{114d0}', '\u{114d1}', '\u{114d2}', '\u{114d3}', '\u{114d4}', '\u{114d5}', '\u{114d6}', '\u{114d7}', '\u{114d8}', '\u{114d9}']), + "tnsa" => Some(['\u{16ac0}', '\u{16ac1}', '\u{16ac2}', '\u{16ac3}', '\u{16ac4}', '\u{16ac5}', '\u{16ac6}', '\u{16ac7}', '\u{16ac8}', '\u{16ac9}']), + "tols" => Some(['\u{11de0}', '\u{11de1}', '\u{11de2}', '\u{11de3}', '\u{11de4}', '\u{11de5}', '\u{11de6}', '\u{11de7}', '\u{11de8}', '\u{11de9}']), + "vaii" => Some(['\u{a620}', '\u{a621}', '\u{a622}', '\u{a623}', '\u{a624}', '\u{a625}', '\u{a626}', '\u{a627}', '\u{a628}', '\u{a629}']), + "wara" => Some(['\u{118e0}', '\u{118e1}', '\u{118e2}', '\u{118e3}', '\u{118e4}', '\u{118e5}', '\u{118e6}', '\u{118e7}', '\u{118e8}', '\u{118e9}']), + "wcho" => Some(['\u{1e2f0}', '\u{1e2f1}', '\u{1e2f2}', '\u{1e2f3}', '\u{1e2f4}', '\u{1e2f5}', '\u{1e2f6}', '\u{1e2f7}', '\u{1e2f8}', '\u{1e2f9}']), + // END generated numbering-system digit table + _ => None, + } +} + +/// Rewrite the ASCII (`latn`) digit glyphs in the numeric segments of a typed +/// parts list into the resolved numbering system. Only digit-bearing segment +/// types are touched — separators, signs, currency/unit/compact literals keep +/// their locale glyphs. A `latn` (or unknown) system is a no-op. +pub(crate) fn transliterate_parts_digits(parts: &mut [(&'static str, String)], system: &str) { + let Some(digits) = numbering_system_digits(system) else { + return; + }; + for (ty, v) in parts.iter_mut() { + if matches!(*ty, "integer" | "fraction" | "exponentInteger") { + *v = v + .chars() + .map(|c| { + if c.is_ascii_digit() { + digits[(c as u8 - b'0') as usize] + } else { + c + } + }) + .collect(); + } + } +} + /// Build the typed parts from an already-resolved [`NfResolved`] (the shared -/// rendering core behind `format` / `formatToParts`). +/// rendering core behind `format` / `formatToParts`), then transliterate the +/// digit glyphs into the resolved numbering system. pub(crate) fn number_parts_from_resolved( r: &NfResolved, value: f64, ) -> Vec<(&'static str, String)> { + let mut parts = number_parts_core(r, value); + transliterate_parts_digits(&mut parts, &r.numbering_system); + parts +} + +/// The Latin-digit rendering core. [`number_parts_from_resolved`] wraps this to +/// apply numbering-system transliteration. +fn number_parts_core(r: &NfResolved, value: f64) -> Vec<(&'static str, String)> { // Currency keeps its existing locale-specific symbol rendering. if r.style == "currency" { return currency_instance_parts(r, value); @@ -588,6 +796,15 @@ pub(crate) fn number_parts_from_resolved( _ => { let (mut i_out, f_out) = if r.use_sig { round_to_significant(int_part, frac_part, r.min_sig, r.max_sig) + } else if r.rounding_increment != 1.0 { + // roundingIncrement fixes minFrac == maxFrac, so the fraction is + // already exactly `max_frac` wide — no trailing-zero trimming. + round_to_increment( + int_part, + frac_part, + r.max_frac as usize, + r.rounding_increment as u128, + ) } else { let (i, f) = round_to_fraction(int_part, frac_part, r.max_frac as usize); (i, trim_fraction(&f, r.min_frac as usize)) From 53a8cabc7203fb6dd15fca309e6d9b6ef8e1f368 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 27 Jun 2026 13:43:36 -0700 Subject: [PATCH 2/5] =?UTF-8?q?fix(intl):=20#5581=20=E2=80=94=20NumberForm?= =?UTF-8?q?at=20significant-digit=20formatting=20of=20zero=20infinite-loop?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `round_to_significant` rendered a zero value as `("0", "")` and then ran the min-significant-digits padding loop `while significant_count(int, frac) < min_sig { frac.push('0') }`. But `significant_count` of an all-zeros decimal is always 0, so for any value of `0` formatted with significant digits (or compact notation, which rounds by significant digits) the loop never terminated — `format(0)` hung. The hang was latent (every locale, not numbering-specific); the preceding numbering-system commit merely unblocked the test262 rounding-mode cases far enough past the digit-set gate to reach `format(0)` and expose it. Render zero as a single "0" padded to `min_sig` total displayed digits (ECMA-402 ToRawPrecision: minSig 3 → "0.00") and return early, before the nonzero-only normalization loops. Flips the 9 intl402/NumberFormat `format-rounding-mode-*` cases plus format-significant-digits.js (+10); no regressions across NumberFormat / DurationFormat / PluralRules. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-runtime/src/intl/number_format.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/intl/number_format.rs b/crates/perry-runtime/src/intl/number_format.rs index 332d6ed24..819d7c5d5 100644 --- a/crates/perry-runtime/src/intl/number_format.rs +++ b/crates/perry-runtime/src/intl/number_format.rs @@ -375,7 +375,14 @@ pub(crate) fn round_to_significant( let combined: String = format!("{int_part}{frac_part}"); let first_sig = combined.bytes().position(|d| d != b'0'); let (mut int_out, mut frac_out) = match first_sig { - None => ("0".to_string(), String::new()), + // Zero has no significant digit to anchor on: render it as a single "0" + // padded to `min_sig` total displayed digits (minSig 3 → "0.00"). Return + // early — the trailing-zero normalization below assumes a nonzero value + // and would otherwise spin forever (significant_count is always 0 here). + None => { + let frac = "0".repeat(min_sig.max(1).saturating_sub(1) as usize); + return ("0".to_string(), frac); + } Some(fs) => { let msd_exp = int_part.len() as i32 - 1 - fs as i32; let frac_needed = max_sig as i32 - 1 - msd_exp; From 0e74aafa9184a2a8c0fa0a795f1947ebec36bc4c Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 27 Jun 2026 23:33:38 -0700 Subject: [PATCH 3/5] =?UTF-8?q?fix(intl):=20#5581=20=E2=80=94=20honor=20ro?= =?UTF-8?q?undingIncrement=20in=20currency/scientific/compact=20paths=20(C?= =?UTF-8?q?odeRabbit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review on #5737: `roundingIncrement` was only applied in the standard decimal branch, so currency, scientific/engineering, and the fraction-mode compact path still ignored it (e.g. nearest-0.05 configs). Per ECMA-402 roundingIncrement is gated on fixed fraction-digit rounding, not on notation, and Node applies it uniformly — to the currency value, the scientific *mantissa*, and the compact value, all at maxFrac places. Factored the fraction-rounding step into a shared `round_fraction_or_increment` helper and routed the standard, scientific/engineering, and compact branches through it; the currency path (a separate native-float renderer) snaps its magnitude onto the increment grid up front via the same digit-string `round_to_increment`, respecting roundingMode. With `roundingIncrement == 1` (the overwhelmingly common case) the helper is byte-identical to the previous `round_to_fraction` + `trim_fraction`, so existing output is unchanged. Verified perry now matches Node byte-for-byte for currency (nearest 0.05), scientific (mantissa rounding), and compact increment configs. NumberFormat test262 unchanged at 158 pass / identical failure set (these notation+increment combos have no test262 coverage); DurationFormat (92) and PluralRules (39) unchanged — zero regressions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../perry-runtime/src/intl/number_format.rs | 82 +++++++++++++------ 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/crates/perry-runtime/src/intl/number_format.rs b/crates/perry-runtime/src/intl/number_format.rs index 819d7c5d5..901424b02 100644 --- a/crates/perry-runtime/src/intl/number_format.rs +++ b/crates/perry-runtime/src/intl/number_format.rs @@ -298,6 +298,30 @@ pub(crate) fn round_to_increment( (strip_leading_zeros(int_str), frac_str) } +/// Fraction-rounding step shared by every notation path. Honors +/// `roundingIncrement` when set (which ECMA-402 only permits alongside fixed +/// `minFrac == maxFrac` fraction-digit rounding, so no trailing-zero trimming is +/// needed); otherwise rounds to `maxFrac` places and trims down to `minFrac`. +/// With `roundingIncrement == 1` this is byte-identical to a bare +/// `round_to_fraction` + `trim_fraction`. +pub(crate) fn round_fraction_or_increment( + int_part: &str, + frac_part: &str, + r: &NfResolved, +) -> (String, String) { + if r.rounding_increment != 1.0 { + round_to_increment( + int_part, + frac_part, + r.max_frac as usize, + r.rounding_increment as u128, + ) + } else { + let (i, f) = round_to_fraction(int_part, frac_part, r.max_frac as usize); + (i, trim_fraction(&f, r.min_frac as usize)) + } +} + /// Round the decimal value `int_part.frac_part` to exactly `frac_digits` /// fractional places under the active rounding mode, operating on the digit /// strings so the result is independent of the binary float's representation @@ -725,17 +749,13 @@ fn number_parts_core(r: &NfResolved, value: f64) -> Vec<(&'static str, String)> } else { (sig_digits, "") }; + // The fraction path rounds the mantissa to `maxFrac` places (honoring + // roundingIncrement); significant rounding already normalizes its own + // trailing zeros. let (mut i_out, f_out) = if r.use_sig { round_to_significant(m_int, m_frac, r.min_sig, r.max_sig) } else { - round_to_fraction(m_int, m_frac, r.max_frac as usize) - }; - // Significant rounding already normalizes trailing zeros; only the - // fraction path trims down to the minimum fraction count. - let f_out = if r.use_sig { - f_out - } else { - trim_fraction(&f_out, r.min_frac as usize) + round_fraction_or_increment(m_int, m_frac, r) }; while (i_out.len() as u32) < r.min_int { i_out.insert(0, '0'); @@ -803,18 +823,8 @@ fn number_parts_core(r: &NfResolved, value: f64) -> Vec<(&'static str, String)> _ => { let (mut i_out, f_out) = if r.use_sig { round_to_significant(int_part, frac_part, r.min_sig, r.max_sig) - } else if r.rounding_increment != 1.0 { - // roundingIncrement fixes minFrac == maxFrac, so the fraction is - // already exactly `max_frac` wide — no trailing-zero trimming. - round_to_increment( - int_part, - frac_part, - r.max_frac as usize, - r.rounding_increment as u128, - ) } else { - let (i, f) = round_to_fraction(int_part, frac_part, r.max_frac as usize); - (i, trim_fraction(&f, r.min_frac as usize)) + round_fraction_or_increment(int_part, frac_part, r) }; while (i_out.len() as u32) < r.min_int { i_out.insert(0, '0'); @@ -862,8 +872,7 @@ pub(crate) fn compact_round(int_part: &str, frac_part: &str, r: &NfResolved) -> } else if r.use_sig { round_to_significant(int_part, frac_part, r.min_sig, r.max_sig) } else { - let (i, f) = round_to_fraction(int_part, frac_part, r.max_frac as usize); - (i, trim_fraction(&f, r.min_frac as usize)) + round_fraction_or_increment(int_part, frac_part, r) } } @@ -889,12 +898,31 @@ pub(crate) fn push_style_suffix( /// `number_instance_parts`. pub(crate) fn currency_instance_parts(r: &NfResolved, value: f64) -> Vec<(&'static str, String)> { let locale = &r.locale; - let digits = format_number_parts( - value, - locale, - Some(r.currency.as_deref().map_or(2, currency_fraction_digits) as usize), - None, - ); + let frac_digits = r.currency.as_deref().map_or(2, currency_fraction_digits) as usize; + // The native float renderer below doesn't honor roundingIncrement; when set, + // snap the magnitude onto the increment grid first (digit-string rounding, + // respecting roundingMode) so the renderer formats an already-gridded value. + let value = if r.rounding_increment != 1.0 && value.is_finite() { + let negative = value < 0.0 || (value == 0.0 && value.is_sign_negative()); + set_round_ctx(&r.rounding_mode, negative); + let abs = value.abs(); + let shortest = format!("{abs}"); + let (ip, fp) = shortest.split_once('.').unwrap_or((&shortest, "")); + let (i, f) = round_to_increment(ip, fp, frac_digits, r.rounding_increment as u128); + let mag: f64 = if f.is_empty() { + i.parse().unwrap_or(abs) + } else { + format!("{i}.{f}").parse().unwrap_or(abs) + }; + if negative { + -mag + } else { + mag + } + } else { + value + }; + let digits = format_number_parts(value, locale, Some(frac_digits), None); let mut numeric: Vec<(&'static str, String)> = Vec::new(); split_numeric_parts(&digits, locale, &mut numeric); let mut parts: Vec<(&'static str, String)> = Vec::new(); From 61cc67ff8059d0395e370ccbeb2163610e2c8d61 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 27 Jun 2026 23:55:35 -0700 Subject: [PATCH 4/5] style: cargo fmt number_format.rs (rustfmt wrapping) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflow the generated numbering-system digit table and round_to_increment to satisfy cargo fmt --check (lint gate). Whitespace only — no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../perry-runtime/src/intl/number_format.rs | 710 ++++++++++++++++-- 1 file changed, 632 insertions(+), 78 deletions(-) diff --git a/crates/perry-runtime/src/intl/number_format.rs b/crates/perry-runtime/src/intl/number_format.rs index 901424b02..050b087df 100644 --- a/crates/perry-runtime/src/intl/number_format.rs +++ b/crates/perry-runtime/src/intl/number_format.rs @@ -249,7 +249,10 @@ pub(crate) fn round_to_increment( // Astronomically large operands (q or the tail past u128) fall back to plain // fraction rounding — unreachable for any realistic formatter input. let fallback = || round_to_fraction(int_part, frac_part, frac_digits); - let Ok(q) = std::str::from_utf8(&combined[..cut]).unwrap().parse::() else { + let Ok(q) = std::str::from_utf8(&combined[..cut]) + .unwrap() + .parse::() + else { return fallback(); }; let dropped_zero = dropped.iter().all(|&d| d == b'0'); @@ -566,83 +569,634 @@ pub(crate) fn number_instance_parts( pub(crate) fn numbering_system_digits(name: &str) -> Option<[char; 10]> { match name { // BEGIN generated numbering-system digit table - "adlm" => Some(['\u{1e950}', '\u{1e951}', '\u{1e952}', '\u{1e953}', '\u{1e954}', '\u{1e955}', '\u{1e956}', '\u{1e957}', '\u{1e958}', '\u{1e959}']), - "ahom" => Some(['\u{11730}', '\u{11731}', '\u{11732}', '\u{11733}', '\u{11734}', '\u{11735}', '\u{11736}', '\u{11737}', '\u{11738}', '\u{11739}']), - "arab" => Some(['\u{660}', '\u{661}', '\u{662}', '\u{663}', '\u{664}', '\u{665}', '\u{666}', '\u{667}', '\u{668}', '\u{669}']), - "arabext" => Some(['\u{6f0}', '\u{6f1}', '\u{6f2}', '\u{6f3}', '\u{6f4}', '\u{6f5}', '\u{6f6}', '\u{6f7}', '\u{6f8}', '\u{6f9}']), - "bali" => Some(['\u{1b50}', '\u{1b51}', '\u{1b52}', '\u{1b53}', '\u{1b54}', '\u{1b55}', '\u{1b56}', '\u{1b57}', '\u{1b58}', '\u{1b59}']), - "beng" => Some(['\u{9e6}', '\u{9e7}', '\u{9e8}', '\u{9e9}', '\u{9ea}', '\u{9eb}', '\u{9ec}', '\u{9ed}', '\u{9ee}', '\u{9ef}']), - "bhks" => Some(['\u{11c50}', '\u{11c51}', '\u{11c52}', '\u{11c53}', '\u{11c54}', '\u{11c55}', '\u{11c56}', '\u{11c57}', '\u{11c58}', '\u{11c59}']), - "brah" => Some(['\u{11066}', '\u{11067}', '\u{11068}', '\u{11069}', '\u{1106a}', '\u{1106b}', '\u{1106c}', '\u{1106d}', '\u{1106e}', '\u{1106f}']), - "cakm" => Some(['\u{11136}', '\u{11137}', '\u{11138}', '\u{11139}', '\u{1113a}', '\u{1113b}', '\u{1113c}', '\u{1113d}', '\u{1113e}', '\u{1113f}']), - "cham" => Some(['\u{aa50}', '\u{aa51}', '\u{aa52}', '\u{aa53}', '\u{aa54}', '\u{aa55}', '\u{aa56}', '\u{aa57}', '\u{aa58}', '\u{aa59}']), - "deva" => Some(['\u{966}', '\u{967}', '\u{968}', '\u{969}', '\u{96a}', '\u{96b}', '\u{96c}', '\u{96d}', '\u{96e}', '\u{96f}']), - "diak" => Some(['\u{11950}', '\u{11951}', '\u{11952}', '\u{11953}', '\u{11954}', '\u{11955}', '\u{11956}', '\u{11957}', '\u{11958}', '\u{11959}']), - "fullwide" => Some(['\u{ff10}', '\u{ff11}', '\u{ff12}', '\u{ff13}', '\u{ff14}', '\u{ff15}', '\u{ff16}', '\u{ff17}', '\u{ff18}', '\u{ff19}']), - "gara" => Some(['\u{10d40}', '\u{10d41}', '\u{10d42}', '\u{10d43}', '\u{10d44}', '\u{10d45}', '\u{10d46}', '\u{10d47}', '\u{10d48}', '\u{10d49}']), - "gong" => Some(['\u{11da0}', '\u{11da1}', '\u{11da2}', '\u{11da3}', '\u{11da4}', '\u{11da5}', '\u{11da6}', '\u{11da7}', '\u{11da8}', '\u{11da9}']), - "gonm" => Some(['\u{11d50}', '\u{11d51}', '\u{11d52}', '\u{11d53}', '\u{11d54}', '\u{11d55}', '\u{11d56}', '\u{11d57}', '\u{11d58}', '\u{11d59}']), - "gujr" => Some(['\u{ae6}', '\u{ae7}', '\u{ae8}', '\u{ae9}', '\u{aea}', '\u{aeb}', '\u{aec}', '\u{aed}', '\u{aee}', '\u{aef}']), - "gukh" => Some(['\u{16130}', '\u{16131}', '\u{16132}', '\u{16133}', '\u{16134}', '\u{16135}', '\u{16136}', '\u{16137}', '\u{16138}', '\u{16139}']), - "guru" => Some(['\u{a66}', '\u{a67}', '\u{a68}', '\u{a69}', '\u{a6a}', '\u{a6b}', '\u{a6c}', '\u{a6d}', '\u{a6e}', '\u{a6f}']), - "hanidec" => Some(['\u{3007}', '\u{4e00}', '\u{4e8c}', '\u{4e09}', '\u{56db}', '\u{4e94}', '\u{516d}', '\u{4e03}', '\u{516b}', '\u{4e5d}']), - "hmng" => Some(['\u{16b50}', '\u{16b51}', '\u{16b52}', '\u{16b53}', '\u{16b54}', '\u{16b55}', '\u{16b56}', '\u{16b57}', '\u{16b58}', '\u{16b59}']), - "hmnp" => Some(['\u{1e140}', '\u{1e141}', '\u{1e142}', '\u{1e143}', '\u{1e144}', '\u{1e145}', '\u{1e146}', '\u{1e147}', '\u{1e148}', '\u{1e149}']), - "java" => Some(['\u{a9d0}', '\u{a9d1}', '\u{a9d2}', '\u{a9d3}', '\u{a9d4}', '\u{a9d5}', '\u{a9d6}', '\u{a9d7}', '\u{a9d8}', '\u{a9d9}']), - "kali" => Some(['\u{a900}', '\u{a901}', '\u{a902}', '\u{a903}', '\u{a904}', '\u{a905}', '\u{a906}', '\u{a907}', '\u{a908}', '\u{a909}']), - "kawi" => Some(['\u{11f50}', '\u{11f51}', '\u{11f52}', '\u{11f53}', '\u{11f54}', '\u{11f55}', '\u{11f56}', '\u{11f57}', '\u{11f58}', '\u{11f59}']), - "khmr" => Some(['\u{17e0}', '\u{17e1}', '\u{17e2}', '\u{17e3}', '\u{17e4}', '\u{17e5}', '\u{17e6}', '\u{17e7}', '\u{17e8}', '\u{17e9}']), - "knda" => Some(['\u{ce6}', '\u{ce7}', '\u{ce8}', '\u{ce9}', '\u{cea}', '\u{ceb}', '\u{cec}', '\u{ced}', '\u{cee}', '\u{cef}']), - "krai" => Some(['\u{16d70}', '\u{16d71}', '\u{16d72}', '\u{16d73}', '\u{16d74}', '\u{16d75}', '\u{16d76}', '\u{16d77}', '\u{16d78}', '\u{16d79}']), - "lana" => Some(['\u{1a80}', '\u{1a81}', '\u{1a82}', '\u{1a83}', '\u{1a84}', '\u{1a85}', '\u{1a86}', '\u{1a87}', '\u{1a88}', '\u{1a89}']), - "lanatham" => Some(['\u{1a90}', '\u{1a91}', '\u{1a92}', '\u{1a93}', '\u{1a94}', '\u{1a95}', '\u{1a96}', '\u{1a97}', '\u{1a98}', '\u{1a99}']), - "laoo" => Some(['\u{ed0}', '\u{ed1}', '\u{ed2}', '\u{ed3}', '\u{ed4}', '\u{ed5}', '\u{ed6}', '\u{ed7}', '\u{ed8}', '\u{ed9}']), - "lepc" => Some(['\u{1c40}', '\u{1c41}', '\u{1c42}', '\u{1c43}', '\u{1c44}', '\u{1c45}', '\u{1c46}', '\u{1c47}', '\u{1c48}', '\u{1c49}']), - "limb" => Some(['\u{1946}', '\u{1947}', '\u{1948}', '\u{1949}', '\u{194a}', '\u{194b}', '\u{194c}', '\u{194d}', '\u{194e}', '\u{194f}']), - "mathbold" => Some(['\u{1d7ce}', '\u{1d7cf}', '\u{1d7d0}', '\u{1d7d1}', '\u{1d7d2}', '\u{1d7d3}', '\u{1d7d4}', '\u{1d7d5}', '\u{1d7d6}', '\u{1d7d7}']), - "mathdbl" => Some(['\u{1d7d8}', '\u{1d7d9}', '\u{1d7da}', '\u{1d7db}', '\u{1d7dc}', '\u{1d7dd}', '\u{1d7de}', '\u{1d7df}', '\u{1d7e0}', '\u{1d7e1}']), - "mathmono" => Some(['\u{1d7f6}', '\u{1d7f7}', '\u{1d7f8}', '\u{1d7f9}', '\u{1d7fa}', '\u{1d7fb}', '\u{1d7fc}', '\u{1d7fd}', '\u{1d7fe}', '\u{1d7ff}']), - "mathsanb" => Some(['\u{1d7ec}', '\u{1d7ed}', '\u{1d7ee}', '\u{1d7ef}', '\u{1d7f0}', '\u{1d7f1}', '\u{1d7f2}', '\u{1d7f3}', '\u{1d7f4}', '\u{1d7f5}']), - "mathsans" => Some(['\u{1d7e2}', '\u{1d7e3}', '\u{1d7e4}', '\u{1d7e5}', '\u{1d7e6}', '\u{1d7e7}', '\u{1d7e8}', '\u{1d7e9}', '\u{1d7ea}', '\u{1d7eb}']), - "mlym" => Some(['\u{d66}', '\u{d67}', '\u{d68}', '\u{d69}', '\u{d6a}', '\u{d6b}', '\u{d6c}', '\u{d6d}', '\u{d6e}', '\u{d6f}']), - "modi" => Some(['\u{11650}', '\u{11651}', '\u{11652}', '\u{11653}', '\u{11654}', '\u{11655}', '\u{11656}', '\u{11657}', '\u{11658}', '\u{11659}']), - "mong" => Some(['\u{1810}', '\u{1811}', '\u{1812}', '\u{1813}', '\u{1814}', '\u{1815}', '\u{1816}', '\u{1817}', '\u{1818}', '\u{1819}']), - "mroo" => Some(['\u{16a60}', '\u{16a61}', '\u{16a62}', '\u{16a63}', '\u{16a64}', '\u{16a65}', '\u{16a66}', '\u{16a67}', '\u{16a68}', '\u{16a69}']), - "mtei" => Some(['\u{abf0}', '\u{abf1}', '\u{abf2}', '\u{abf3}', '\u{abf4}', '\u{abf5}', '\u{abf6}', '\u{abf7}', '\u{abf8}', '\u{abf9}']), - "mymr" => Some(['\u{1040}', '\u{1041}', '\u{1042}', '\u{1043}', '\u{1044}', '\u{1045}', '\u{1046}', '\u{1047}', '\u{1048}', '\u{1049}']), - "mymrepka" => Some(['\u{116da}', '\u{116db}', '\u{116dc}', '\u{116dd}', '\u{116de}', '\u{116df}', '\u{116e0}', '\u{116e1}', '\u{116e2}', '\u{116e3}']), - "mymrpao" => Some(['\u{116d0}', '\u{116d1}', '\u{116d2}', '\u{116d3}', '\u{116d4}', '\u{116d5}', '\u{116d6}', '\u{116d7}', '\u{116d8}', '\u{116d9}']), - "mymrshan" => Some(['\u{1090}', '\u{1091}', '\u{1092}', '\u{1093}', '\u{1094}', '\u{1095}', '\u{1096}', '\u{1097}', '\u{1098}', '\u{1099}']), - "mymrtlng" => Some(['\u{a9f0}', '\u{a9f1}', '\u{a9f2}', '\u{a9f3}', '\u{a9f4}', '\u{a9f5}', '\u{a9f6}', '\u{a9f7}', '\u{a9f8}', '\u{a9f9}']), - "nagm" => Some(['\u{1e4f0}', '\u{1e4f1}', '\u{1e4f2}', '\u{1e4f3}', '\u{1e4f4}', '\u{1e4f5}', '\u{1e4f6}', '\u{1e4f7}', '\u{1e4f8}', '\u{1e4f9}']), - "newa" => Some(['\u{11450}', '\u{11451}', '\u{11452}', '\u{11453}', '\u{11454}', '\u{11455}', '\u{11456}', '\u{11457}', '\u{11458}', '\u{11459}']), - "nkoo" => Some(['\u{7c0}', '\u{7c1}', '\u{7c2}', '\u{7c3}', '\u{7c4}', '\u{7c5}', '\u{7c6}', '\u{7c7}', '\u{7c8}', '\u{7c9}']), - "olck" => Some(['\u{1c50}', '\u{1c51}', '\u{1c52}', '\u{1c53}', '\u{1c54}', '\u{1c55}', '\u{1c56}', '\u{1c57}', '\u{1c58}', '\u{1c59}']), - "onao" => Some(['\u{1e5f1}', '\u{1e5f2}', '\u{1e5f3}', '\u{1e5f4}', '\u{1e5f5}', '\u{1e5f6}', '\u{1e5f7}', '\u{1e5f8}', '\u{1e5f9}', '\u{1e5fa}']), - "orya" => Some(['\u{b66}', '\u{b67}', '\u{b68}', '\u{b69}', '\u{b6a}', '\u{b6b}', '\u{b6c}', '\u{b6d}', '\u{b6e}', '\u{b6f}']), - "osma" => Some(['\u{104a0}', '\u{104a1}', '\u{104a2}', '\u{104a3}', '\u{104a4}', '\u{104a5}', '\u{104a6}', '\u{104a7}', '\u{104a8}', '\u{104a9}']), - "outlined" => Some(['\u{1ccf0}', '\u{1ccf1}', '\u{1ccf2}', '\u{1ccf3}', '\u{1ccf4}', '\u{1ccf5}', '\u{1ccf6}', '\u{1ccf7}', '\u{1ccf8}', '\u{1ccf9}']), - "rohg" => Some(['\u{10d30}', '\u{10d31}', '\u{10d32}', '\u{10d33}', '\u{10d34}', '\u{10d35}', '\u{10d36}', '\u{10d37}', '\u{10d38}', '\u{10d39}']), - "saur" => Some(['\u{a8d0}', '\u{a8d1}', '\u{a8d2}', '\u{a8d3}', '\u{a8d4}', '\u{a8d5}', '\u{a8d6}', '\u{a8d7}', '\u{a8d8}', '\u{a8d9}']), - "segment" => Some(['\u{1fbf0}', '\u{1fbf1}', '\u{1fbf2}', '\u{1fbf3}', '\u{1fbf4}', '\u{1fbf5}', '\u{1fbf6}', '\u{1fbf7}', '\u{1fbf8}', '\u{1fbf9}']), - "shrd" => Some(['\u{111d0}', '\u{111d1}', '\u{111d2}', '\u{111d3}', '\u{111d4}', '\u{111d5}', '\u{111d6}', '\u{111d7}', '\u{111d8}', '\u{111d9}']), - "sind" => Some(['\u{112f0}', '\u{112f1}', '\u{112f2}', '\u{112f3}', '\u{112f4}', '\u{112f5}', '\u{112f6}', '\u{112f7}', '\u{112f8}', '\u{112f9}']), - "sinh" => Some(['\u{de6}', '\u{de7}', '\u{de8}', '\u{de9}', '\u{dea}', '\u{deb}', '\u{dec}', '\u{ded}', '\u{dee}', '\u{def}']), - "sora" => Some(['\u{110f0}', '\u{110f1}', '\u{110f2}', '\u{110f3}', '\u{110f4}', '\u{110f5}', '\u{110f6}', '\u{110f7}', '\u{110f8}', '\u{110f9}']), - "sund" => Some(['\u{1bb0}', '\u{1bb1}', '\u{1bb2}', '\u{1bb3}', '\u{1bb4}', '\u{1bb5}', '\u{1bb6}', '\u{1bb7}', '\u{1bb8}', '\u{1bb9}']), - "sunu" => Some(['\u{11bf0}', '\u{11bf1}', '\u{11bf2}', '\u{11bf3}', '\u{11bf4}', '\u{11bf5}', '\u{11bf6}', '\u{11bf7}', '\u{11bf8}', '\u{11bf9}']), - "takr" => Some(['\u{116c0}', '\u{116c1}', '\u{116c2}', '\u{116c3}', '\u{116c4}', '\u{116c5}', '\u{116c6}', '\u{116c7}', '\u{116c8}', '\u{116c9}']), - "talu" => Some(['\u{19d0}', '\u{19d1}', '\u{19d2}', '\u{19d3}', '\u{19d4}', '\u{19d5}', '\u{19d6}', '\u{19d7}', '\u{19d8}', '\u{19d9}']), - "tamldec" => Some(['\u{be6}', '\u{be7}', '\u{be8}', '\u{be9}', '\u{bea}', '\u{beb}', '\u{bec}', '\u{bed}', '\u{bee}', '\u{bef}']), - "telu" => Some(['\u{c66}', '\u{c67}', '\u{c68}', '\u{c69}', '\u{c6a}', '\u{c6b}', '\u{c6c}', '\u{c6d}', '\u{c6e}', '\u{c6f}']), - "thai" => Some(['\u{e50}', '\u{e51}', '\u{e52}', '\u{e53}', '\u{e54}', '\u{e55}', '\u{e56}', '\u{e57}', '\u{e58}', '\u{e59}']), - "tibt" => Some(['\u{f20}', '\u{f21}', '\u{f22}', '\u{f23}', '\u{f24}', '\u{f25}', '\u{f26}', '\u{f27}', '\u{f28}', '\u{f29}']), - "tirh" => Some(['\u{114d0}', '\u{114d1}', '\u{114d2}', '\u{114d3}', '\u{114d4}', '\u{114d5}', '\u{114d6}', '\u{114d7}', '\u{114d8}', '\u{114d9}']), - "tnsa" => Some(['\u{16ac0}', '\u{16ac1}', '\u{16ac2}', '\u{16ac3}', '\u{16ac4}', '\u{16ac5}', '\u{16ac6}', '\u{16ac7}', '\u{16ac8}', '\u{16ac9}']), - "tols" => Some(['\u{11de0}', '\u{11de1}', '\u{11de2}', '\u{11de3}', '\u{11de4}', '\u{11de5}', '\u{11de6}', '\u{11de7}', '\u{11de8}', '\u{11de9}']), - "vaii" => Some(['\u{a620}', '\u{a621}', '\u{a622}', '\u{a623}', '\u{a624}', '\u{a625}', '\u{a626}', '\u{a627}', '\u{a628}', '\u{a629}']), - "wara" => Some(['\u{118e0}', '\u{118e1}', '\u{118e2}', '\u{118e3}', '\u{118e4}', '\u{118e5}', '\u{118e6}', '\u{118e7}', '\u{118e8}', '\u{118e9}']), - "wcho" => Some(['\u{1e2f0}', '\u{1e2f1}', '\u{1e2f2}', '\u{1e2f3}', '\u{1e2f4}', '\u{1e2f5}', '\u{1e2f6}', '\u{1e2f7}', '\u{1e2f8}', '\u{1e2f9}']), + "adlm" => Some([ + '\u{1e950}', + '\u{1e951}', + '\u{1e952}', + '\u{1e953}', + '\u{1e954}', + '\u{1e955}', + '\u{1e956}', + '\u{1e957}', + '\u{1e958}', + '\u{1e959}', + ]), + "ahom" => Some([ + '\u{11730}', + '\u{11731}', + '\u{11732}', + '\u{11733}', + '\u{11734}', + '\u{11735}', + '\u{11736}', + '\u{11737}', + '\u{11738}', + '\u{11739}', + ]), + "arab" => Some([ + '\u{660}', '\u{661}', '\u{662}', '\u{663}', '\u{664}', '\u{665}', '\u{666}', '\u{667}', + '\u{668}', '\u{669}', + ]), + "arabext" => Some([ + '\u{6f0}', '\u{6f1}', '\u{6f2}', '\u{6f3}', '\u{6f4}', '\u{6f5}', '\u{6f6}', '\u{6f7}', + '\u{6f8}', '\u{6f9}', + ]), + "bali" => Some([ + '\u{1b50}', '\u{1b51}', '\u{1b52}', '\u{1b53}', '\u{1b54}', '\u{1b55}', '\u{1b56}', + '\u{1b57}', '\u{1b58}', '\u{1b59}', + ]), + "beng" => Some([ + '\u{9e6}', '\u{9e7}', '\u{9e8}', '\u{9e9}', '\u{9ea}', '\u{9eb}', '\u{9ec}', '\u{9ed}', + '\u{9ee}', '\u{9ef}', + ]), + "bhks" => Some([ + '\u{11c50}', + '\u{11c51}', + '\u{11c52}', + '\u{11c53}', + '\u{11c54}', + '\u{11c55}', + '\u{11c56}', + '\u{11c57}', + '\u{11c58}', + '\u{11c59}', + ]), + "brah" => Some([ + '\u{11066}', + '\u{11067}', + '\u{11068}', + '\u{11069}', + '\u{1106a}', + '\u{1106b}', + '\u{1106c}', + '\u{1106d}', + '\u{1106e}', + '\u{1106f}', + ]), + "cakm" => Some([ + '\u{11136}', + '\u{11137}', + '\u{11138}', + '\u{11139}', + '\u{1113a}', + '\u{1113b}', + '\u{1113c}', + '\u{1113d}', + '\u{1113e}', + '\u{1113f}', + ]), + "cham" => Some([ + '\u{aa50}', '\u{aa51}', '\u{aa52}', '\u{aa53}', '\u{aa54}', '\u{aa55}', '\u{aa56}', + '\u{aa57}', '\u{aa58}', '\u{aa59}', + ]), + "deva" => Some([ + '\u{966}', '\u{967}', '\u{968}', '\u{969}', '\u{96a}', '\u{96b}', '\u{96c}', '\u{96d}', + '\u{96e}', '\u{96f}', + ]), + "diak" => Some([ + '\u{11950}', + '\u{11951}', + '\u{11952}', + '\u{11953}', + '\u{11954}', + '\u{11955}', + '\u{11956}', + '\u{11957}', + '\u{11958}', + '\u{11959}', + ]), + "fullwide" => Some([ + '\u{ff10}', '\u{ff11}', '\u{ff12}', '\u{ff13}', '\u{ff14}', '\u{ff15}', '\u{ff16}', + '\u{ff17}', '\u{ff18}', '\u{ff19}', + ]), + "gara" => Some([ + '\u{10d40}', + '\u{10d41}', + '\u{10d42}', + '\u{10d43}', + '\u{10d44}', + '\u{10d45}', + '\u{10d46}', + '\u{10d47}', + '\u{10d48}', + '\u{10d49}', + ]), + "gong" => Some([ + '\u{11da0}', + '\u{11da1}', + '\u{11da2}', + '\u{11da3}', + '\u{11da4}', + '\u{11da5}', + '\u{11da6}', + '\u{11da7}', + '\u{11da8}', + '\u{11da9}', + ]), + "gonm" => Some([ + '\u{11d50}', + '\u{11d51}', + '\u{11d52}', + '\u{11d53}', + '\u{11d54}', + '\u{11d55}', + '\u{11d56}', + '\u{11d57}', + '\u{11d58}', + '\u{11d59}', + ]), + "gujr" => Some([ + '\u{ae6}', '\u{ae7}', '\u{ae8}', '\u{ae9}', '\u{aea}', '\u{aeb}', '\u{aec}', '\u{aed}', + '\u{aee}', '\u{aef}', + ]), + "gukh" => Some([ + '\u{16130}', + '\u{16131}', + '\u{16132}', + '\u{16133}', + '\u{16134}', + '\u{16135}', + '\u{16136}', + '\u{16137}', + '\u{16138}', + '\u{16139}', + ]), + "guru" => Some([ + '\u{a66}', '\u{a67}', '\u{a68}', '\u{a69}', '\u{a6a}', '\u{a6b}', '\u{a6c}', '\u{a6d}', + '\u{a6e}', '\u{a6f}', + ]), + "hanidec" => Some([ + '\u{3007}', '\u{4e00}', '\u{4e8c}', '\u{4e09}', '\u{56db}', '\u{4e94}', '\u{516d}', + '\u{4e03}', '\u{516b}', '\u{4e5d}', + ]), + "hmng" => Some([ + '\u{16b50}', + '\u{16b51}', + '\u{16b52}', + '\u{16b53}', + '\u{16b54}', + '\u{16b55}', + '\u{16b56}', + '\u{16b57}', + '\u{16b58}', + '\u{16b59}', + ]), + "hmnp" => Some([ + '\u{1e140}', + '\u{1e141}', + '\u{1e142}', + '\u{1e143}', + '\u{1e144}', + '\u{1e145}', + '\u{1e146}', + '\u{1e147}', + '\u{1e148}', + '\u{1e149}', + ]), + "java" => Some([ + '\u{a9d0}', '\u{a9d1}', '\u{a9d2}', '\u{a9d3}', '\u{a9d4}', '\u{a9d5}', '\u{a9d6}', + '\u{a9d7}', '\u{a9d8}', '\u{a9d9}', + ]), + "kali" => Some([ + '\u{a900}', '\u{a901}', '\u{a902}', '\u{a903}', '\u{a904}', '\u{a905}', '\u{a906}', + '\u{a907}', '\u{a908}', '\u{a909}', + ]), + "kawi" => Some([ + '\u{11f50}', + '\u{11f51}', + '\u{11f52}', + '\u{11f53}', + '\u{11f54}', + '\u{11f55}', + '\u{11f56}', + '\u{11f57}', + '\u{11f58}', + '\u{11f59}', + ]), + "khmr" => Some([ + '\u{17e0}', '\u{17e1}', '\u{17e2}', '\u{17e3}', '\u{17e4}', '\u{17e5}', '\u{17e6}', + '\u{17e7}', '\u{17e8}', '\u{17e9}', + ]), + "knda" => Some([ + '\u{ce6}', '\u{ce7}', '\u{ce8}', '\u{ce9}', '\u{cea}', '\u{ceb}', '\u{cec}', '\u{ced}', + '\u{cee}', '\u{cef}', + ]), + "krai" => Some([ + '\u{16d70}', + '\u{16d71}', + '\u{16d72}', + '\u{16d73}', + '\u{16d74}', + '\u{16d75}', + '\u{16d76}', + '\u{16d77}', + '\u{16d78}', + '\u{16d79}', + ]), + "lana" => Some([ + '\u{1a80}', '\u{1a81}', '\u{1a82}', '\u{1a83}', '\u{1a84}', '\u{1a85}', '\u{1a86}', + '\u{1a87}', '\u{1a88}', '\u{1a89}', + ]), + "lanatham" => Some([ + '\u{1a90}', '\u{1a91}', '\u{1a92}', '\u{1a93}', '\u{1a94}', '\u{1a95}', '\u{1a96}', + '\u{1a97}', '\u{1a98}', '\u{1a99}', + ]), + "laoo" => Some([ + '\u{ed0}', '\u{ed1}', '\u{ed2}', '\u{ed3}', '\u{ed4}', '\u{ed5}', '\u{ed6}', '\u{ed7}', + '\u{ed8}', '\u{ed9}', + ]), + "lepc" => Some([ + '\u{1c40}', '\u{1c41}', '\u{1c42}', '\u{1c43}', '\u{1c44}', '\u{1c45}', '\u{1c46}', + '\u{1c47}', '\u{1c48}', '\u{1c49}', + ]), + "limb" => Some([ + '\u{1946}', '\u{1947}', '\u{1948}', '\u{1949}', '\u{194a}', '\u{194b}', '\u{194c}', + '\u{194d}', '\u{194e}', '\u{194f}', + ]), + "mathbold" => Some([ + '\u{1d7ce}', + '\u{1d7cf}', + '\u{1d7d0}', + '\u{1d7d1}', + '\u{1d7d2}', + '\u{1d7d3}', + '\u{1d7d4}', + '\u{1d7d5}', + '\u{1d7d6}', + '\u{1d7d7}', + ]), + "mathdbl" => Some([ + '\u{1d7d8}', + '\u{1d7d9}', + '\u{1d7da}', + '\u{1d7db}', + '\u{1d7dc}', + '\u{1d7dd}', + '\u{1d7de}', + '\u{1d7df}', + '\u{1d7e0}', + '\u{1d7e1}', + ]), + "mathmono" => Some([ + '\u{1d7f6}', + '\u{1d7f7}', + '\u{1d7f8}', + '\u{1d7f9}', + '\u{1d7fa}', + '\u{1d7fb}', + '\u{1d7fc}', + '\u{1d7fd}', + '\u{1d7fe}', + '\u{1d7ff}', + ]), + "mathsanb" => Some([ + '\u{1d7ec}', + '\u{1d7ed}', + '\u{1d7ee}', + '\u{1d7ef}', + '\u{1d7f0}', + '\u{1d7f1}', + '\u{1d7f2}', + '\u{1d7f3}', + '\u{1d7f4}', + '\u{1d7f5}', + ]), + "mathsans" => Some([ + '\u{1d7e2}', + '\u{1d7e3}', + '\u{1d7e4}', + '\u{1d7e5}', + '\u{1d7e6}', + '\u{1d7e7}', + '\u{1d7e8}', + '\u{1d7e9}', + '\u{1d7ea}', + '\u{1d7eb}', + ]), + "mlym" => Some([ + '\u{d66}', '\u{d67}', '\u{d68}', '\u{d69}', '\u{d6a}', '\u{d6b}', '\u{d6c}', '\u{d6d}', + '\u{d6e}', '\u{d6f}', + ]), + "modi" => Some([ + '\u{11650}', + '\u{11651}', + '\u{11652}', + '\u{11653}', + '\u{11654}', + '\u{11655}', + '\u{11656}', + '\u{11657}', + '\u{11658}', + '\u{11659}', + ]), + "mong" => Some([ + '\u{1810}', '\u{1811}', '\u{1812}', '\u{1813}', '\u{1814}', '\u{1815}', '\u{1816}', + '\u{1817}', '\u{1818}', '\u{1819}', + ]), + "mroo" => Some([ + '\u{16a60}', + '\u{16a61}', + '\u{16a62}', + '\u{16a63}', + '\u{16a64}', + '\u{16a65}', + '\u{16a66}', + '\u{16a67}', + '\u{16a68}', + '\u{16a69}', + ]), + "mtei" => Some([ + '\u{abf0}', '\u{abf1}', '\u{abf2}', '\u{abf3}', '\u{abf4}', '\u{abf5}', '\u{abf6}', + '\u{abf7}', '\u{abf8}', '\u{abf9}', + ]), + "mymr" => Some([ + '\u{1040}', '\u{1041}', '\u{1042}', '\u{1043}', '\u{1044}', '\u{1045}', '\u{1046}', + '\u{1047}', '\u{1048}', '\u{1049}', + ]), + "mymrepka" => Some([ + '\u{116da}', + '\u{116db}', + '\u{116dc}', + '\u{116dd}', + '\u{116de}', + '\u{116df}', + '\u{116e0}', + '\u{116e1}', + '\u{116e2}', + '\u{116e3}', + ]), + "mymrpao" => Some([ + '\u{116d0}', + '\u{116d1}', + '\u{116d2}', + '\u{116d3}', + '\u{116d4}', + '\u{116d5}', + '\u{116d6}', + '\u{116d7}', + '\u{116d8}', + '\u{116d9}', + ]), + "mymrshan" => Some([ + '\u{1090}', '\u{1091}', '\u{1092}', '\u{1093}', '\u{1094}', '\u{1095}', '\u{1096}', + '\u{1097}', '\u{1098}', '\u{1099}', + ]), + "mymrtlng" => Some([ + '\u{a9f0}', '\u{a9f1}', '\u{a9f2}', '\u{a9f3}', '\u{a9f4}', '\u{a9f5}', '\u{a9f6}', + '\u{a9f7}', '\u{a9f8}', '\u{a9f9}', + ]), + "nagm" => Some([ + '\u{1e4f0}', + '\u{1e4f1}', + '\u{1e4f2}', + '\u{1e4f3}', + '\u{1e4f4}', + '\u{1e4f5}', + '\u{1e4f6}', + '\u{1e4f7}', + '\u{1e4f8}', + '\u{1e4f9}', + ]), + "newa" => Some([ + '\u{11450}', + '\u{11451}', + '\u{11452}', + '\u{11453}', + '\u{11454}', + '\u{11455}', + '\u{11456}', + '\u{11457}', + '\u{11458}', + '\u{11459}', + ]), + "nkoo" => Some([ + '\u{7c0}', '\u{7c1}', '\u{7c2}', '\u{7c3}', '\u{7c4}', '\u{7c5}', '\u{7c6}', '\u{7c7}', + '\u{7c8}', '\u{7c9}', + ]), + "olck" => Some([ + '\u{1c50}', '\u{1c51}', '\u{1c52}', '\u{1c53}', '\u{1c54}', '\u{1c55}', '\u{1c56}', + '\u{1c57}', '\u{1c58}', '\u{1c59}', + ]), + "onao" => Some([ + '\u{1e5f1}', + '\u{1e5f2}', + '\u{1e5f3}', + '\u{1e5f4}', + '\u{1e5f5}', + '\u{1e5f6}', + '\u{1e5f7}', + '\u{1e5f8}', + '\u{1e5f9}', + '\u{1e5fa}', + ]), + "orya" => Some([ + '\u{b66}', '\u{b67}', '\u{b68}', '\u{b69}', '\u{b6a}', '\u{b6b}', '\u{b6c}', '\u{b6d}', + '\u{b6e}', '\u{b6f}', + ]), + "osma" => Some([ + '\u{104a0}', + '\u{104a1}', + '\u{104a2}', + '\u{104a3}', + '\u{104a4}', + '\u{104a5}', + '\u{104a6}', + '\u{104a7}', + '\u{104a8}', + '\u{104a9}', + ]), + "outlined" => Some([ + '\u{1ccf0}', + '\u{1ccf1}', + '\u{1ccf2}', + '\u{1ccf3}', + '\u{1ccf4}', + '\u{1ccf5}', + '\u{1ccf6}', + '\u{1ccf7}', + '\u{1ccf8}', + '\u{1ccf9}', + ]), + "rohg" => Some([ + '\u{10d30}', + '\u{10d31}', + '\u{10d32}', + '\u{10d33}', + '\u{10d34}', + '\u{10d35}', + '\u{10d36}', + '\u{10d37}', + '\u{10d38}', + '\u{10d39}', + ]), + "saur" => Some([ + '\u{a8d0}', '\u{a8d1}', '\u{a8d2}', '\u{a8d3}', '\u{a8d4}', '\u{a8d5}', '\u{a8d6}', + '\u{a8d7}', '\u{a8d8}', '\u{a8d9}', + ]), + "segment" => Some([ + '\u{1fbf0}', + '\u{1fbf1}', + '\u{1fbf2}', + '\u{1fbf3}', + '\u{1fbf4}', + '\u{1fbf5}', + '\u{1fbf6}', + '\u{1fbf7}', + '\u{1fbf8}', + '\u{1fbf9}', + ]), + "shrd" => Some([ + '\u{111d0}', + '\u{111d1}', + '\u{111d2}', + '\u{111d3}', + '\u{111d4}', + '\u{111d5}', + '\u{111d6}', + '\u{111d7}', + '\u{111d8}', + '\u{111d9}', + ]), + "sind" => Some([ + '\u{112f0}', + '\u{112f1}', + '\u{112f2}', + '\u{112f3}', + '\u{112f4}', + '\u{112f5}', + '\u{112f6}', + '\u{112f7}', + '\u{112f8}', + '\u{112f9}', + ]), + "sinh" => Some([ + '\u{de6}', '\u{de7}', '\u{de8}', '\u{de9}', '\u{dea}', '\u{deb}', '\u{dec}', '\u{ded}', + '\u{dee}', '\u{def}', + ]), + "sora" => Some([ + '\u{110f0}', + '\u{110f1}', + '\u{110f2}', + '\u{110f3}', + '\u{110f4}', + '\u{110f5}', + '\u{110f6}', + '\u{110f7}', + '\u{110f8}', + '\u{110f9}', + ]), + "sund" => Some([ + '\u{1bb0}', '\u{1bb1}', '\u{1bb2}', '\u{1bb3}', '\u{1bb4}', '\u{1bb5}', '\u{1bb6}', + '\u{1bb7}', '\u{1bb8}', '\u{1bb9}', + ]), + "sunu" => Some([ + '\u{11bf0}', + '\u{11bf1}', + '\u{11bf2}', + '\u{11bf3}', + '\u{11bf4}', + '\u{11bf5}', + '\u{11bf6}', + '\u{11bf7}', + '\u{11bf8}', + '\u{11bf9}', + ]), + "takr" => Some([ + '\u{116c0}', + '\u{116c1}', + '\u{116c2}', + '\u{116c3}', + '\u{116c4}', + '\u{116c5}', + '\u{116c6}', + '\u{116c7}', + '\u{116c8}', + '\u{116c9}', + ]), + "talu" => Some([ + '\u{19d0}', '\u{19d1}', '\u{19d2}', '\u{19d3}', '\u{19d4}', '\u{19d5}', '\u{19d6}', + '\u{19d7}', '\u{19d8}', '\u{19d9}', + ]), + "tamldec" => Some([ + '\u{be6}', '\u{be7}', '\u{be8}', '\u{be9}', '\u{bea}', '\u{beb}', '\u{bec}', '\u{bed}', + '\u{bee}', '\u{bef}', + ]), + "telu" => Some([ + '\u{c66}', '\u{c67}', '\u{c68}', '\u{c69}', '\u{c6a}', '\u{c6b}', '\u{c6c}', '\u{c6d}', + '\u{c6e}', '\u{c6f}', + ]), + "thai" => Some([ + '\u{e50}', '\u{e51}', '\u{e52}', '\u{e53}', '\u{e54}', '\u{e55}', '\u{e56}', '\u{e57}', + '\u{e58}', '\u{e59}', + ]), + "tibt" => Some([ + '\u{f20}', '\u{f21}', '\u{f22}', '\u{f23}', '\u{f24}', '\u{f25}', '\u{f26}', '\u{f27}', + '\u{f28}', '\u{f29}', + ]), + "tirh" => Some([ + '\u{114d0}', + '\u{114d1}', + '\u{114d2}', + '\u{114d3}', + '\u{114d4}', + '\u{114d5}', + '\u{114d6}', + '\u{114d7}', + '\u{114d8}', + '\u{114d9}', + ]), + "tnsa" => Some([ + '\u{16ac0}', + '\u{16ac1}', + '\u{16ac2}', + '\u{16ac3}', + '\u{16ac4}', + '\u{16ac5}', + '\u{16ac6}', + '\u{16ac7}', + '\u{16ac8}', + '\u{16ac9}', + ]), + "tols" => Some([ + '\u{11de0}', + '\u{11de1}', + '\u{11de2}', + '\u{11de3}', + '\u{11de4}', + '\u{11de5}', + '\u{11de6}', + '\u{11de7}', + '\u{11de8}', + '\u{11de9}', + ]), + "vaii" => Some([ + '\u{a620}', '\u{a621}', '\u{a622}', '\u{a623}', '\u{a624}', '\u{a625}', '\u{a626}', + '\u{a627}', '\u{a628}', '\u{a629}', + ]), + "wara" => Some([ + '\u{118e0}', + '\u{118e1}', + '\u{118e2}', + '\u{118e3}', + '\u{118e4}', + '\u{118e5}', + '\u{118e6}', + '\u{118e7}', + '\u{118e8}', + '\u{118e9}', + ]), + "wcho" => Some([ + '\u{1e2f0}', + '\u{1e2f1}', + '\u{1e2f2}', + '\u{1e2f3}', + '\u{1e2f4}', + '\u{1e2f5}', + '\u{1e2f6}', + '\u{1e2f7}', + '\u{1e2f8}', + '\u{1e2f9}', + ]), // END generated numbering-system digit table _ => None, } From 4e83e80d04063d6b0ee479a8fcc8614e39938632 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sun, 28 Jun 2026 00:32:50 -0700 Subject: [PATCH 5/5] =?UTF-8?q?fix(intl):=20#5581=20=E2=80=94=20address=20?= =?UTF-8?q?CodeRabbit=20review=20(increment=20overflow,=20currency=20scale?= =?UTF-8?q?,=20mantissa=20carry)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three correctness findings from CodeRabbit on #5737, each verified byte-for-byte against Node: 1. roundingIncrement on wide fraction scales / large values. `round_to_increment` parsed the scaled integer as `u128`, so `maximumFractionDigits` near its 100 ceiling (or very large values) overflowed and silently fell back to plain fraction rounding, dropping the increment. The scaled integer is now kept as a digit string and reduced with small-divisor modular arithmetic (decimal_mod_small / decimal_add_small / decimal_sub_small); the increment (≤ 5000) and the dropped tail (bounded by the shortest decimal) stay small. Parity for halfEven is read off `q mod 2·increment`, avoiding any wide division. 2. Currency increment grid used the default scale. `currency_instance_parts` rounded/snapped on the currency's default fraction digits, ignoring minimum/maximumFractionDigits — so e.g. 3-digit USD snapped on 0.05 instead of 0.005. It now uses the resolved width `r.max_frac` (which already folds in the currency default plus options) for both the increment grid and the display, so grid and precision agree (1.234 → $1.235). Identical to before for the default-width common case. 3. Scientific/engineering mantissa carry. The exponent was computed before rounding, so a carry (9.9 → 10) emitted `10E0` instead of `1E1`. After rounding, a grown mantissa now renormalizes: recompute the exponent from msd+1 and reshape the significand — scientific `1E1`, while engineering keeps the digit when the new magnitude stays in the same power-of-1000 band (`9.9 → 10E0`, `999.9 → 1E3`, `99999 → 100E3`). NumberFormat test262 unchanged at 158 pass / identical failure set (these cases have no test262 coverage in the failing set); DurationFormat (92) and PluralRules (39) unchanged — zero regressions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../perry-runtime/src/intl/number_format.rs | 140 +++++++++++++----- 1 file changed, 106 insertions(+), 34 deletions(-) diff --git a/crates/perry-runtime/src/intl/number_format.rs b/crates/perry-runtime/src/intl/number_format.rs index 050b087df..414d9377e 100644 --- a/crates/perry-runtime/src/intl/number_format.rs +++ b/crates/perry-runtime/src/intl/number_format.rs @@ -222,6 +222,52 @@ fn round_decision(more_half: bool, exactly_half: bool, kept_is_odd: bool) -> boo } } +/// Remainder of a big-endian ASCII decimal-digit string modulo a small divisor. +/// Folds digit-by-digit so the operand width is unbounded (the scaled integer in +/// increment rounding can exceed `u128` for wide `maximumFractionDigits`). +fn decimal_mod_small(digits: &[u8], divisor: u64) -> u64 { + let mut r: u64 = 0; + for &d in digits { + r = (r * 10 + (d - b'0') as u64) % divisor; + } + r +} + +/// Add a small value into a big-endian ASCII decimal-digit string in place, +/// prepending digits on overflow (`"99"` + 5 → `"104"`). +fn decimal_add_small(digits: &mut Vec, add: u64) { + let mut carry = add; + let mut i = digits.len(); + while carry > 0 { + if i == 0 { + digits.insert(0, b'0'); + i = 1; + } + i -= 1; + let sum = (digits[i] - b'0') as u64 + carry; + digits[i] = (sum % 10) as u8 + b'0'; + carry = sum / 10; + } +} + +/// Subtract a small value (assumed ≤ the represented number) from a big-endian +/// ASCII decimal-digit string in place (`"104"` − 5 → `"099"`). +fn decimal_sub_small(digits: &mut [u8], sub: u64) { + let mut borrow = sub; + let mut i = digits.len(); + while borrow > 0 { + i -= 1; + let s = (borrow % 10) as i64; + borrow /= 10; + let mut v = (digits[i] - b'0') as i64 - s; + if v < 0 { + v += 10; + borrow += 1; + } + digits[i] = v as u8 + b'0'; + } +} + /// Round the decimal `int_part.frac_part` to the nearest multiple of /// `increment` at exactly `frac_digits` fractional places, under the active /// rounding mode (ECMA-402 `roundingIncrement`). Returns `(int, frac)` with the @@ -237,7 +283,10 @@ pub(crate) fn round_to_increment( ) -> (String, String) { // Scale by 10^frac_digits so the increment acts on integers: the first // `int_len + frac_digits` digits form the scaled integer `q`; any remaining - // digits are the dropped fractional tail used to break ties. + // digits are the dropped fractional tail used to break ties. `q` is kept as a + // digit string (it can exceed u128 for wide fraction scales / large values), + // and reduced via small-divisor modular arithmetic; the sanctioned increment + // (≤ 5000) and the dropped tail (bounded by the shortest decimal) stay small. let mut combined: Vec = Vec::with_capacity(int_part.len() + frac_part.len()); combined.extend(int_part.bytes()); combined.extend(frac_part.bytes()); @@ -245,36 +294,28 @@ pub(crate) fn round_to_increment( while combined.len() < cut { combined.push(b'0'); } - let dropped = &combined[cut..]; - // Astronomically large operands (q or the tail past u128) fall back to plain - // fraction rounding — unreachable for any realistic formatter input. - let fallback = || round_to_fraction(int_part, frac_part, frac_digits); - let Ok(q) = std::str::from_utf8(&combined[..cut]) - .unwrap() - .parse::() - else { - return fallback(); - }; + let dropped = combined[cut..].to_vec(); + let mut q_digits = combined[..cut].to_vec(); + let inc = increment as u64; let dropped_zero = dropped.iter().all(|&d| d == b'0'); + // The dropped tail comes from the shortest round-trip decimal, so it fits + // u128; an unexpectedly long tail falls back to plain fraction rounding. let dropped_int: u128 = if dropped.is_empty() { 0 } else { - match std::str::from_utf8(dropped).unwrap().parse() { + match std::str::from_utf8(&dropped).unwrap().parse() { Ok(v) => v, - Err(_) => return fallback(), + Err(_) => return round_to_fraction(int_part, frac_part, frac_digits), } }; - let rem = q % increment; - let base = q - rem; - let m = if rem == 0 && dropped_zero { - q // exact multiple — no rounding. - } else { + let rem = decimal_mod_small(&q_digits, inc); + if !(rem == 0 && dropped_zero) { // Position of `rem.dropped` within [0, increment): compare against // increment/2 by cross-multiplying out the dropped fraction // (2·rem·10^k + 2·dropped) vs increment·10^k, with k = dropped digits. let classify = || -> Option<(bool, bool)> { let pow10 = 10u128.checked_pow(dropped.len() as u32)?; - let lhs = rem + let lhs = (rem as u128) .checked_mul(2)? .checked_mul(pow10)? .checked_add(dropped_int.checked_mul(2)?)?; @@ -282,22 +323,24 @@ pub(crate) fn round_to_increment( Some((lhs > rhs, lhs == rhs)) }; let Some((more_half, exactly_half)) = classify() else { - return fallback(); + return round_to_fraction(int_part, frac_part, frac_digits); }; - if round_decision(more_half, exactly_half, (base / increment) % 2 == 1) { - base + increment - } else { - base + // Parity of q/increment (consulted only by halfEven): q ≡ rem (mod inc), + // so `q mod 2·inc` is `rem` for an even quotient and `rem+inc` for odd. + let kept_is_odd = decimal_mod_small(&q_digits, inc.saturating_mul(2)) != rem; + // Round down to the lower multiple, then up one increment if required. + decimal_sub_small(&mut q_digits, rem); + if round_decision(more_half, exactly_half, kept_is_odd) { + decimal_add_small(&mut q_digits, inc); } - }; + } // Place the decimal point `frac_digits` from the right of the scaled integer. - let mut m_str = m.to_string().into_bytes(); - while m_str.len() <= frac_digits { - m_str.insert(0, b'0'); + while q_digits.len() <= frac_digits { + q_digits.insert(0, b'0'); } - let split = m_str.len() - frac_digits; - let int_str = String::from_utf8(m_str[..split].to_vec()).unwrap(); - let frac_str = String::from_utf8(m_str[split..].to_vec()).unwrap(); + let split = q_digits.len() - frac_digits; + let int_str = String::from_utf8(q_digits[..split].to_vec()).unwrap(); + let frac_str = String::from_utf8(q_digits[split..].to_vec()).unwrap(); (strip_leading_zeros(int_str), frac_str) } @@ -1284,7 +1327,7 @@ fn number_parts_core(r: &NfResolved, value: f64) -> Vec<(&'static str, String)> match r.notation.as_str() { "scientific" | "engineering" => { let msd = decimal_msd_exponent(int_part, frac_part); - let exp = if r.notation == "engineering" { + let mut exp = if r.notation == "engineering" { (msd as f64 / 3.0).floor() as i32 * 3 } else { msd @@ -1306,11 +1349,36 @@ fn number_parts_core(r: &NfResolved, value: f64) -> Vec<(&'static str, String)> // The fraction path rounds the mantissa to `maxFrac` places (honoring // roundingIncrement); significant rounding already normalizes its own // trailing zeros. - let (mut i_out, f_out) = if r.use_sig { + let (mut i_out, mut f_out) = if r.use_sig { round_to_significant(m_int, m_frac, r.min_sig, r.max_sig) } else { round_fraction_or_increment(m_int, m_frac, r) }; + // Rounding can carry the mantissa into an extra integer digit + // (9.9 → 10); the significand is then exactly 10^(msd+1). Recompute the + // exponent from the grown magnitude and reshape so scientific emits + // `1E1` rather than `10E0` (engineering keeps the digit when the new + // magnitude still falls in the same power-of-1000 band, e.g. `100E3`). + if i_out.len() > int_digits { + let new_msd = msd + 1; + exp = if r.notation == "engineering" { + (new_msd as f64 / 3.0).floor() as i32 * 3 + } else { + new_msd + }; + let new_int_digits = (new_msd - exp + 1).max(1) as usize; + let sig = format!("{i_out}{f_out}"); + let sig = if sig.len() < new_int_digits { + format!("{:0 Vec<(&'static str, String)> { let locale = &r.locale; - let frac_digits = r.currency.as_deref().map_or(2, currency_fraction_digits) as usize; + // Use the *resolved* fraction width (which already folds in the currency's + // default digits plus any minimum/maximumFractionDigits options) so the + // increment grid and the displayed precision agree — e.g. 3 fraction digits + // snap on 0.005 steps, not the currency-default 0.05. + let frac_digits = r.max_frac as usize; // The native float renderer below doesn't honor roundingIncrement; when set, // snap the magnitude onto the increment grid first (digit-string rounding, // respecting roundingMode) so the renderer formats an already-gridded value.