Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ encoding_rs = "0.8.35"
log = "0.4"
pdf-inspector = "0.1.7"
quick-xml = "0.41.0"
ssfmt = { version = "0.1.2", default-features = false }
zip = { version = "8.6.0", default-features = false, features = ["deflate"] }

[profile.release]
Expand Down
241 changes: 241 additions & 0 deletions docs/plans/xlsx-number-formatting.md

Large diffs are not rendered by default.

243 changes: 219 additions & 24 deletions src/formats/sheet/mod.rs

Large diffs are not rendered by default.

260 changes: 260 additions & 0 deletions src/formats/sheet/numfmt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
//! Excel number-format rendering for numeric spreadsheet cells.
//!
//! Delegates to `ssfmt` (an Excel-compatible ECMA-376 number-format renderer);
//! this module owns the policy around it: which codes are eligible for
//! rendering at all, and the fallback contract (`None` -> the caller renders
//! the raw value exactly as before this feature).

use std::collections::HashMap;

/// Render `value` with an Excel number-format `code`.
///
/// Returns `None` when the code is not renderable by this pipeline:
/// `General`, empty, date/time-like, text (`@`), or rejected by the
/// renderer. `None` means the caller falls back to its raw rendering; a
/// rendering attempt never fails the conversion. A present-but-empty
/// selected section renders as blank (`Some("")`), matching Excel's
/// display for `0.00;` and `0.00;;`.
pub fn render(value: f64, code: &str) -> Option<String> {
if !renderable_code(code) {
return None;
}
if selected_section(code, value).is_some_and(|s| s.trim().is_empty()) {
return Some(String::new());
}
ssfmt::format(value, code, &ssfmt::FormatOptions::default()).ok()
}

/// Resolve the format code for a style's `numFmtId`.
///
/// Custom ids resolve from the workbook's `numFmts` table. Builtin ids
/// resolve through ssfmt's ECMA-376 table, filtered by the same
/// renderability guard as custom codes, so date, text, General, and
/// locale-currency ids (41-44, absent from ssfmt's table) fall through to
/// `None` (the caller keeps today's behavior for those cell kinds)
/// without a hand-maintained id list.
pub fn code_for_style(num_fmt_id: u32, custom: &HashMap<u32, String>) -> Option<String> {
if let Some(code) = custom.get(&num_fmt_id) {
return renderable_code(code).then(|| code.clone());
}
ssfmt::builtin_formats::format_code_from_id(num_fmt_id)
.filter(|code| renderable_code(code))
.map(str::to_owned)
}

/// Whether a code is eligible for numeric rendering.
///
/// Guards `General`, empty codes, and date/time-like codes. Only the first
/// `;`-separated section is scanned, mirroring calamine's classifier (see
/// `detect_custom_number_format` in calamine `formats.rs`), which stops at
/// the first section and converts cells only when the first section is
/// date-like. Date letters flag only outside quoted literals, escapes, and
/// bracket contents, so `[Red]`, `[$-409]`, and `"mm"` stay renderable
/// while `dd/mm/yyyy` and `[h]:mm:ss` fall back to the caller (calamine
/// already converts those cells; this guard is defensive). A fourth-section
/// text placeholder (`0.00%;0.00%;0.00%;@`) therefore stays renderable.
fn renderable_code(code: &str) -> bool {
if code.is_empty() || code.eq_ignore_ascii_case("general") {
return false;
}
let mut escaped = false;
let mut quoted = false;
let mut brackets = 0u8;
let mut after_ampm = false;
for s in code.chars() {
match (s, escaped, quoted) {
(_, true, _) => escaped = false,
('\\' | '_' | '*', false, false) => escaped = true,
('"', _, true) => quoted = false,
(_, _, true) => {}
('"', _, false) => quoted = true,
// Only the first section decides renderability.
(';', false, false) if brackets == 0 => return true,
// Text placeholder in the first section: never rendered.
('@', _, false) => return false,
('[', _, _) => brackets += 1,
(']', _, _) => brackets = brackets.saturating_sub(1),
('a' | 'A', _, _) => after_ampm = true,
('p' | 'P', _, _) if after_ampm => return false,
('d' | 'm' | 'h' | 'y' | 's' | 'D' | 'M' | 'H' | 'Y' | 'S', _, _) if brackets == 0 => {
return false;
}
_ => {}
}
}
true
}

/// The format section Excel applies to `value`: the first for positives,
/// the second for negatives when present, the third for zeros when present.
fn selected_section(code: &str, value: f64) -> Option<&str> {
let mut sections = Vec::new();
let mut start = 0;
let mut escaped = false;
let mut quoted = false;
let mut brackets = 0u8;
for (i, s) in code.char_indices() {
match (s, escaped, quoted) {
(_, true, _) => escaped = false,
('\\' | '_' | '*', false, false) => escaped = true,
('"', _, true) => quoted = false,
(_, _, true) => {}
('"', _, false) => quoted = true,
('[', _, _) => brackets += 1,
(']', _, _) => brackets = brackets.saturating_sub(1),
(';', false, false) if brackets == 0 => {
sections.push(&code[start..i]);
start = i + 1;
}
_ => {}
}
}
sections.push(&code[start..]);
let idx = if value < 0.0 && sections.len() > 1 {
1
} else if value == 0.0 && sections.len() > 2 {
2
} else {
0
};
sections.get(idx).copied()
}

#[cfg(test)]
mod tests {
use super::*;

fn render_or_raw(value: f64, code: &str) -> String {
render(value, code).unwrap_or_else(|| value.to_string())
}

#[test]
fn percent_formats_multiply_by_100() {
assert_eq!(render(0.653035934239184, "0%"), Some("65%".to_string()));
assert_eq!(render(0.653035934239184, "0.00%"), Some("65.30%".to_string()));
assert_eq!(render(0.075, "0.0%"), Some("7.5%".to_string()));
}

#[test]
fn currency_and_literals_render() {
assert_eq!(render(56701.0309278351, "$#,##0.00"), Some("$56,701.03".to_string()));
assert_eq!(render(1234.5, "[$$-409]#,##0.00"), Some("$1,234.50".to_string()));
}

#[test]
fn decimals_grouping_and_scaling_render() {
assert_eq!(render(1234.5, "#,##0.00"), Some("1,234.50".to_string()));
assert_eq!(render(1234567.0, "#,##0"), Some("1,234,567".to_string()));
assert_eq!(render(1000.0, "#,##0"), Some("1,000".to_string()));
// Trailing-comma scaling: ssfmt rounds the scaled digits; its
// integer fast path truncates the exact half (documented quirk).
assert_eq!(render(1234567.9, "#,##0,"), Some("1,235".to_string()));
assert_eq!(render(1234567.0, "#,##0,"), Some("1,234".to_string()));
}

#[test]
fn negatives_follow_section_rules() {
assert_eq!(render(-1.5, "0.00;(0.00)"), Some("(1.50)".to_string()));
assert_eq!(render(-1.5, "0.00"), Some("-1.50".to_string()));
// Color brackets in the negative section render without color.
assert_eq!(render(-1.5, "#,##0 ;[Red](#,##0)"), Some("(2)".to_string()));
// A present-but-empty selected section displays blank, like Excel.
assert_eq!(render(-1.5, "0.00;"), Some(String::new()));
assert_eq!(render(0.0, "0.00;;"), Some(String::new()));
assert_eq!(render(0.0, "0.00;0.00"), Some("0.00".to_string()));
}

#[test]
fn scientific_notation_renders() {
assert_eq!(render(123456.0, "0.00E+00"), Some("1.23E+05".to_string()));
assert_eq!(render(123456.0, "##0.0E+0"), Some("123.5E+3".to_string()));
}

#[test]
fn tie_values_round_half_away_from_zero() {
assert_eq!(render(0.5, "0"), Some("1".to_string()));
assert_eq!(render(2.5, "0"), Some("3".to_string()));
assert_eq!(render(1.25, "0.0"), Some("1.3".to_string()));
assert_eq!(render(-0.5, "0"), Some("-1".to_string()));
// 1.005 as f64 is just under the tie; Excel displays 1.00 the same.
assert_eq!(render(1.005, "0.00"), Some("1.00".to_string()));
}

#[test]
fn fractions_render() {
assert_eq!(render(0.5, "# ?/?"), Some(" 1/2".to_string()));
}

#[test]
fn general_and_empty_codes_fall_back() {
assert_eq!(render(1.5, "General"), None);
assert_eq!(render(1.5, "general"), None);
assert_eq!(render(1.5, ""), None);
}

#[test]
fn date_like_codes_fall_back() {
assert_eq!(render(1.5, "dd/mm/yyyy"), None);
assert_eq!(render(1.5, "[h]:mm:ss"), None);
assert_eq!(render(1.5, "h:mm AM/PM"), None);
assert_eq!(render(46031.0, "m/d/yy"), None);
// Quote/escape/bracket literals must not trip the date guard.
assert_eq!(render(1234.5, "\"Total\" #,##0.00"), Some("Total 1,234.50".to_string()));
assert_eq!(render(1234.5, "[$$-409]#,##0.00"), Some("$1,234.50".to_string()));
}

#[test]
fn text_format_falls_back() {
assert_eq!(render(3.5, "@"), None);
}

#[test]
fn text_placeholder_in_later_sections_stays_renderable() {
// Only the first section decides renderability, like Excel/calamine.
assert_eq!(render(0.653, "0.00%;0.00%;0.00%;@"), Some("65.30%".to_string()));
assert_eq!(render(0.653, "0.00%;\"x\";0.00%;@"), Some("65.30%".to_string()));
}

#[test]
fn renderer_errors_fall_back() {
assert_eq!(render(1.5, "0.00["), None);
assert_eq!(render(1.5, "0.00[xyz"), None);
}

#[test]
fn renderer_never_panics_on_adversarial_codes() {
// Renderer output is pinned loosely (non-empty on success); the
// contract under test is fallback-on-error, never a panic.
let _ = render_or_raw(1.5, "0.00E+00_XYZ");
let _ = render_or_raw(-0.653, "0.00%");
assert_eq!(render(-0.653, "0.00%"), Some("-65.30%".to_string()));
}

#[test]
fn builtin_ids_resolve_through_ktd5() {
let none = HashMap::new();
assert_eq!(code_for_style(9, &none), Some("0%".to_string()));
assert_eq!(code_for_style(4, &none), Some("#,##0.00".to_string()));
assert_eq!(code_for_style(2, &none), Some("0.00".to_string()));
assert_eq!(code_for_style(11, &none), Some("0.00E+00".to_string()));
assert_eq!(code_for_style(37, &none), Some("#,##0 ;(#,##0)".to_string()));
assert_eq!(code_for_style(48, &none), Some("##0.0E+0".to_string()));
assert_eq!(code_for_style(0, &none), None);
assert_eq!(code_for_style(14, &none), None);
assert_eq!(code_for_style(27, &none), None);
assert_eq!(code_for_style(45, &none), None);
assert_eq!(code_for_style(49, &none), None);
assert_eq!(code_for_style(41, &none), None);
assert_eq!(code_for_style(164, &none), None);
let mut custom = HashMap::new();
custom.insert(164, "0.00%".to_string());
assert_eq!(code_for_style(164, &custom), Some("0.00%".to_string()));
}

#[test]
fn date_like_custom_codes_fall_back() {
let mut custom = HashMap::new();
custom.insert(164, "dd/mm/yyyy".to_string());
assert_eq!(code_for_style(164, &custom), None);
}
}
Loading