From 6d1b96d9d80084d1af760838cba406baff3cacd4 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Wed, 22 Jul 2026 01:17:53 +0900 Subject: [PATCH 1/2] fix: round number formats half away from zero like Excel Decimal display values were truncated toward zero (107310.6 with #,##0 printed 107,310) and short fractions were multiplied instead of padded (39.1 with 0.00 printed 39.100). Percentages routed ties through binary doubles (1.065 with 0% printed 106% instead of 107%). Round the decimal string directly: half away from zero at the format's fractional width, zero-padding short fractions, with string-based thousands grouping, and shift percentages by moving the decimal point. Signed-off-by: Yonghye Kwon --- src/helper/number_format/number_formater.rs | 166 ++++++++++++++---- .../number_format/percentage_formater.rs | 78 ++++++-- 2 files changed, 201 insertions(+), 43 deletions(-) diff --git a/src/helper/number_format/number_formater.rs b/src/helper/number_format/number_formater.rs index 7477d96e..0ebcbe68 100644 --- a/src/helper/number_format/number_formater.rs +++ b/src/helper/number_format/number_formater.rs @@ -1,7 +1,5 @@ use std::borrow::Cow; -use thousands::Separable; - use super::fraction_formater::format_as_fraction; use crate::helper::utils::compile_regex; @@ -85,45 +83,104 @@ fn format_straight_numeric_value( use_thousands: bool, _number_regex: &str, ) -> String { - let mut value = value.to_string(); - let empty = String::new(); let right = matches.get(3).unwrap_or(&empty); - // minimun width of formatted number (including dot) + // Excel rounds the decimal display value half away from zero to the + // format's fractional width. Work on the decimal string directly: + // routing ties back through binary floats corrupts them + // (39.105 * 100 = 3910.4999… would round down). + let value = round_decimal_string(value, right.len()); + if use_thousands { - value = value.parse::().unwrap_or(0.0).separate_with_commas(); + return group_thousands(&value); + } + value +} + +/// Round a plain decimal string (as produced by `f64::to_string`) to +/// `decimals` fractional digits, half away from zero, in pure decimal +/// arithmetic. Zero-pads when the value has fewer fractional digits than the +/// format asks for. Falls back to float formatting for exponent notation. +pub(crate) fn round_decimal_string(value: &str, decimals: usize) -> String { + if value.contains(['e', 'E']) { + let parsed = value.parse::().unwrap_or(0.0); + return format!("{:.*}", decimals, parsed); } - let blocks: Vec<&str> = value.split('.').collect(); - let left_value = (*blocks.first().unwrap()).to_string(); - let mut right_value = match blocks.get(1) { - Some(v) => (*v).to_string(), - None => String::from("0"), + + let (sign, digits) = match value.strip_prefix('-') { + Some(rest) => ("-", rest), + None => ("", value), }; - if right.is_empty() { - return left_value; - } - if right.len() != right_value.len() { - if right_value == "0" { - right_value.clone_from(right); - } else if right.len() > right_value.len() { - let pow = 10i64.pow(u32::try_from(right.len().min(18)).expect("REASON")); - right_value = format!( - "{}", - right_value.parse::().unwrap_or(0).saturating_mul(pow) - ); - } else { - let mut right_value_conv: String = right_value.chars().take(right.len()).collect(); - let ajst_str: String = right_value.chars().skip(right.len()).take(1).collect(); - let ajst_int = ajst_str.parse::().unwrap_or(0); - if ajst_int > 4 { - right_value_conv = (right_value_conv.parse::().unwrap_or(0) + 1).to_string(); + let (int_part, frac_part) = digits.split_once('.').unwrap_or((digits, "")); + + if frac_part.len() <= decimals { + let mut result = format!("{sign}{int_part}"); + if decimals > 0 { + result.push('.'); + result.push_str(frac_part); + result.push_str(&"0".repeat(decimals - frac_part.len())); + } + return result; + } + + let mut kept: Vec = int_part + .bytes() + .chain(frac_part.bytes().take(decimals)) + .map(|b| b - b'0') + .collect(); + if frac_part.as_bytes()[decimals] >= b'5' { + let mut carry = 1u8; + for digit in kept.iter_mut().rev() { + *digit += carry; + carry = *digit / 10; + *digit %= 10; + if carry == 0 { + break; } - right_value = right_value_conv; + } + if carry > 0 { + kept.insert(0, carry); } } - value = format!("{left_value}.{right_value}"); - value + + let all: String = kept.iter().map(|d| char::from(b'0' + d)).collect(); + let split_at = all.len() - decimals; + let int_digits = all[..split_at].trim_start_matches('0'); + let int_digits = if int_digits.is_empty() { "0" } else { int_digits }; + let mut result = format!("{sign}{int_digits}"); + if decimals > 0 { + result.push('.'); + result.push_str(&all[split_at..]); + } + result +} + +/// Insert comma group separators into the integer part of a plain decimal +/// string, preserving sign and fraction. String-based so values beyond the +/// integer range keep their digits. +pub(crate) fn group_thousands(value: &str) -> String { + let (sign, digits) = match value.strip_prefix('-') { + Some(rest) => ("-", rest), + None => ("", value), + }; + let (int_part, frac_part) = match digits.split_once('.') { + Some((int_part, frac_part)) => (int_part, Some(frac_part)), + None => (digits, None), + }; + + let mut grouped = String::with_capacity(int_part.len() + int_part.len() / 3); + for (index, ch) in int_part.chars().enumerate() { + if index > 0 && (int_part.len() - index) % 3 == 0 { + grouped.push(','); + } + grouped.push(ch); + } + + match frac_part { + Some(frac) => format!("{sign}{grouped}.{frac}"), + None => format!("{sign}{grouped}"), + } } #[allow(dead_code)] @@ -227,3 +284,48 @@ fn complex_number_format_mask(number: f64, mask: &str, split_on_point: bool) -> let result = process_complex_number_format_mask(number, mask); format!("{}{}", if sign { "-" } else { "" }, result) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_as_number_rounds_half_away_from_zero() { + // Excel rounds display values; truncating turned 107310.6 into 107,310. + assert_eq!(format_as_number(107310.6, "#,##0"), "107,311"); + assert_eq!(format_as_number(12.5, "0"), "13"); + assert_eq!(format_as_number(-12.5, "0"), "-13"); + assert_eq!(format_as_number(99999.5, "0"), "100000"); + } + + #[test] + fn format_as_number_pads_decimals_to_format_width() { + // 39.1 with a two-decimal format printed "39.100" (the fractional + // digits were multiplied instead of zero-padded). + assert_eq!(format_as_number(39.1, "0.00"), "39.10"); + assert_eq!(format_as_number(2229.7, "#,##0.00"), "2,229.70"); + assert_eq!(format_as_number(84.2, "0.00"), "84.20"); + assert_eq!(format_as_number(39.0, "0.00"), "39.00"); + } + + #[test] + fn format_as_number_rounds_excess_decimals() { + assert_eq!(format_as_number(39.105, "0.00"), "39.11"); + assert_eq!(format_as_number(0.125, "0.00"), "0.13"); + assert_eq!(format_as_number(1.005, "0.00"), "1.01"); + assert_eq!(format_as_number(57.67, "0.00"), "57.67"); + } + + #[test] + fn format_as_number_keeps_currency_prefix() { + assert_eq!(format_as_number(39.1, "$0.00"), "$39.10"); + assert_eq!(format_as_number(107310.6, "$#,##0"), "$107,311"); + } + + #[test] + fn format_as_number_thousands_grouping_survives_rounding() { + assert_eq!(format_as_number(999999.5, "#,##0"), "1,000,000"); + assert_eq!(format_as_number(1234567.891, "#,##0.00"), "1,234,567.89"); + assert_eq!(format_as_number(-1234.5, "#,##0"), "-1,235"); + } +} diff --git a/src/helper/number_format/percentage_formater.rs b/src/helper/number_format/percentage_formater.rs index 93e0e5f3..0f49d8c0 100644 --- a/src/helper/number_format/percentage_formater.rs +++ b/src/helper/number_format/percentage_formater.rs @@ -1,19 +1,75 @@ +use super::number_formater::round_decimal_string; use std::borrow::Cow; pub(crate) fn format_as_percentage(value: f64, format: &str) -> Cow<'_, str> { - let mut value = value.to_string(); - let mut format = Cow::Borrowed(format); - format = Cow::Owned(format.replace('%', "")); + let format = format.replace('%', ""); let blocks: Vec<&str> = format.split('.').collect(); - let len = match blocks.get(1) { + let decimals = match blocks.get(1) { Some(v) => v.len(), None => 0, }; - value = format!( - "{:0width$.len$}%", - (100f64 * &value.parse::().unwrap_or(0.0)).round(), - width = 1, - len = len - ); - Cow::Owned(value) + // Shift the decimal string right by two places (×100) and round half + // away from zero. Multiplying the binary double corrupts ties: 1.065 + // becomes 106.4999… and displays as 106% where Excel shows 107%. + let shifted = shift_decimal_right_two(&value.to_string()); + Cow::Owned(format!("{}%", round_decimal_string(&shifted, decimals))) +} + +/// Multiply a plain decimal string by 100 by moving the decimal point, +/// keeping the digits exact. Falls back to float math for exponent notation. +fn shift_decimal_right_two(value: &str) -> String { + if value.contains(['e', 'E']) { + return (value.parse::().unwrap_or(0.0) * 100.0).to_string(); + } + let (sign, digits) = match value.strip_prefix('-') { + Some(rest) => ("-", rest), + None => ("", value), + }; + let (int_part, frac_part) = digits.split_once('.').unwrap_or((digits, "")); + let mut frac = frac_part.to_string(); + while frac.len() < 2 { + frac.push('0'); + } + let moved = &frac[..2]; + let rest = &frac[2..]; + let combined = format!("{int_part}{moved}"); + let trimmed = combined.trim_start_matches('0'); + let int_final = if trimmed.is_empty() { "0" } else { trimmed }; + if rest.is_empty() { + format!("{sign}{int_final}") + } else { + format!("{sign}{int_final}.{rest}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_as_percentage_keeps_decimal_precision() { + // Rounding to an integer before applying the decimal format code + // turned 17.309...% into "17.0%". + assert_eq!(format_as_percentage(0.1730909090909091, "0.0%"), "17.3%"); + } + + #[test] + fn format_as_percentage_integer_format() { + assert_eq!(format_as_percentage(0.1730909090909091, "0%"), "17%"); + } + + #[test] + fn format_as_percentage_two_decimals() { + assert_eq!(format_as_percentage(0.5, "0.00%"), "50.00%"); + } + + #[test] + fn format_as_percentage_rounds_half_away_from_zero() { + // 21,300 / 20,000 = 1.065 → Excel displays 107%, not 106%: the + // binary double is 106.4999…, but Excel rounds the 15-digit decimal + // display value (106.5) away from zero. + assert_eq!(format_as_percentage(1.065, "0%"), "107%"); + assert_eq!(format_as_percentage(0.125, "0.0%"), "12.5%"); + assert_eq!(format_as_percentage(0.10649999999999999, "0%"), "11%"); + } } From 865316ab9cc5b07de67c45f178d1863c56fa0195 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Wed, 22 Jul 2026 13:22:41 +0900 Subject: [PATCH 2/2] style(number_format): satisfy nightly rustfmt and clippy uninlined-format-args - Inline format args in round_decimal_string (clippy::uninlined-format-args) - Apply nightly cargo fmt to number_formater.rs and percentage_formater.rs Co-Authored-By: Claude Opus 4.8 (1M context) --- src/helper/number_format/number_formater.rs | 8 ++++++-- src/helper/number_format/percentage_formater.rs | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/helper/number_format/number_formater.rs b/src/helper/number_format/number_formater.rs index 0ebcbe68..66c6b680 100644 --- a/src/helper/number_format/number_formater.rs +++ b/src/helper/number_format/number_formater.rs @@ -105,7 +105,7 @@ fn format_straight_numeric_value( pub(crate) fn round_decimal_string(value: &str, decimals: usize) -> String { if value.contains(['e', 'E']) { let parsed = value.parse::().unwrap_or(0.0); - return format!("{:.*}", decimals, parsed); + return format!("{parsed:.decimals$}"); } let (sign, digits) = match value.strip_prefix('-') { @@ -147,7 +147,11 @@ pub(crate) fn round_decimal_string(value: &str, decimals: usize) -> String { let all: String = kept.iter().map(|d| char::from(b'0' + d)).collect(); let split_at = all.len() - decimals; let int_digits = all[..split_at].trim_start_matches('0'); - let int_digits = if int_digits.is_empty() { "0" } else { int_digits }; + let int_digits = if int_digits.is_empty() { + "0" + } else { + int_digits + }; let mut result = format!("{sign}{int_digits}"); if decimals > 0 { result.push('.'); diff --git a/src/helper/number_format/percentage_formater.rs b/src/helper/number_format/percentage_formater.rs index 0f49d8c0..4172d984 100644 --- a/src/helper/number_format/percentage_formater.rs +++ b/src/helper/number_format/percentage_formater.rs @@ -1,6 +1,7 @@ -use super::number_formater::round_decimal_string; use std::borrow::Cow; +use super::number_formater::round_decimal_string; + pub(crate) fn format_as_percentage(value: f64, format: &str) -> Cow<'_, str> { let format = format.replace('%', ""); let blocks: Vec<&str> = format.split('.').collect();