From 98cb3c72f8d902c8c8eb90890b7d23c90bac7e2a Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sun, 30 Aug 2026 11:59:24 +0700 Subject: [PATCH] fix: render placeholder-free number format sections Signed-off-by: Yonghye Kwon --- src/helper/number_format.rs | 79 +++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/src/helper/number_format.rs b/src/helper/number_format.rs index 7b5cede7..4c9a3568 100644 --- a/src/helper/number_format.rs +++ b/src/helper/number_format.rs @@ -88,19 +88,24 @@ pub fn to_formatted_string, P: AsRef>(value: S, format: P) -> _ => {} } - // Convert any other escaped characters to quoted strings, e.g. (\T to "T") + let Ok(parsed_val) = value.parse::() else { + return value.to_string(); + }; + // Inspect the selected raw section before escaped characters are rewritten. + // This allows placeholder-free sections to retain their literal meaning. + let raw_sections: Vec<&str> = split(get_section_regex(), &format).collect(); + let (_, raw_split_format, _) = split_format(raw_sections, parsed_val); + if let Some(literal) = literal_only_section(&raw_split_format) { + return literal.trim().to_string(); + } + + // Convert any other escaped characters to quoted strings, e.g. (\T to "T") let mut format = get_escape_regex().replace_all(&format, r#""$0""#); // Get the sections, there can be up to four sections, separated with a // semi-colon (but only if not a quoted literal) - let sections: Vec<&str> = split(get_section_regex(), &format).collect(); - - let Ok(parsed_val) = value.parse::() else { - return value.to_string(); - }; - let (_, split_format, split_value) = split_format(sections, parsed_val); format = Cow::Owned(split_format); value = Cow::Owned(split_value); @@ -136,6 +141,54 @@ pub fn to_formatted_string, P: AsRef>(value: S, format: P) -> value.trim().to_string() } +/// Decode a section that contains literals but no value placeholder. +/// +/// Excel uses these sections for displays such as a dash in place of zero: +/// `#,##0;[Red](#,##0);\-\ \ `. Quoted text and backslash escapes are +/// literal. `_x` becomes one space, while `*x` contributes one fill character +/// because this string formatter has no cell width. Value-dependent formats +/// stay on the existing date/number paths. +fn literal_only_section(format: &str) -> Option { + if get_date_time_regex().is_match(format).unwrap_or(false) { + return None; + } + + let mut result = String::with_capacity(format.len()); + let mut chars = format.chars(); + let mut in_quotes = false; + + while let Some(ch) = chars.next() { + if in_quotes { + if ch == '"' { + in_quotes = false; + } else { + result.push(ch); + } + continue; + } + + match ch { + '"' => in_quotes = true, + '\\' | '*' => result.push(chars.next()?), + '_' => { + chars.next()?; + result.push(' '); + } + '[' => { + for control in chars.by_ref() { + if control == ']' { + break; + } + } + } + '0' | '#' | '?' | '@' | '%' => return None, + _ => result.push(ch), + } + } + + (!in_quotes).then_some(result) +} + fn split_format(sections: Vec<&str>, value: f64) -> (String, String, String) { let mut converted_sections: Vec = Vec::new(); @@ -423,3 +476,15 @@ fn test_to_formatted_string_date() { ); assert_eq!(r"2", to_formatted_string(&value, "d")); } + +#[test] +fn test_to_formatted_string_zero_section_with_only_escaped_literals() { + let format = r"#,##0_);[Red]\(#,##0\);\-\ \ "; + + assert_eq!("-", to_formatted_string("0", format)); + assert_eq!( + "FALSE", + to_formatted_string("0", r#""TRUE";"TRUE";"FALSE""#) + ); + assert_eq!("", to_formatted_string("0", "0;-0;")); +}