From 3d0ed97d71d627b37d792e452a2f05b7eda02c10 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:41:45 +0200 Subject: [PATCH] XLS: a FORMAT record shorter than its character count keeps the characters it holds (fixes #731) --- crates/xlsx/src/tests/xls_tests.rs | 36 ++++++++++++++++++++++++++++++ crates/xlsx/src/xls/mod.rs | 14 +++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795..b7e2c1f 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -476,3 +476,39 @@ fn sheets_share_one_record_budget() { let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); assert!(err.contains("same substream"), "{err}"); } + +#[test] +fn a_format_shorter_than_its_count_keeps_its_characters() { + // A FORMAT whose cch is one more than the characters in the record (seen in real files): + // Excel opens them, the record's characters are the format code. + let short = rec(0x041E, &cat(&[&165u16.to_le_bytes(), &6u16.to_le_bytes(), &[0], b"0.000"])); + let xf = |ifmt: u16| rec(0x00E0, &cat(&[&u16s(&[0, ifmt, 0]), &[0; 14]])); + let globals = |pos: u32| { + cat(&[ + &bof(0x0005), + &short, + &rec(0x041E, &cat(&[&164u16.to_le_bytes(), &xlstr("yyyy-mm-dd")])), + &xf(0), + &xf(164), + &xf(165), + &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), + &eof(), + ]) + }; + let sheet = cat(&[ + &bof(0x0010), + &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(1, 0, 1), &0.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(2, 0, 2), &1.25f64.to_le_bytes()])), + &eof(), + ]); + let stream = cat(&[&globals(globals(0).len() as u32), &sheet]); + let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap(); + let sh = &wb.sheets[0]; + let fmt = |a1: &str| wb.styles.get(sh.style_id(CellRef::parse(a1).unwrap())).num_fmt.as_str().to_string(); + assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0)); + assert_eq!(v(&wb, 0, "A3"), Value::Number(1.25)); + assert_eq!(fmt("A2"), "yyyy-mm-dd"); + assert_eq!(fmt("A3"), "0.000"); + assert!(report.warnings.iter().any(|w| w.contains("declared length")), "{:?}", report.warnings); +} diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66..78f7a42 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -143,7 +143,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo } }; let mut report = ReadReport::default(); - let g = globals(&stream, &mut budget)?; + let g = globals(&stream, &mut budget, &mut report)?; let mut wb = Workbook::new(); wb.sheets.clear(); @@ -205,7 +205,7 @@ fn bof(rec: &Record<'_>) -> Result<(u16, u16), IoError> { Ok((c.u16()?, c.u16()?)) } -fn globals(stream: &[u8], budget: &mut Budget) -> Result { +fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Result { let mut recs = Records::new(stream, 0); let first = recs.next_record()?.ok_or_else(|| invalid("empty Workbook stream"))?; let (vers, dt) = bof(&first)?; @@ -249,7 +249,15 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { 0x0022 => g.date1904 = c.u16()? == 1, 0x041E => { let ifmt = c.u16()?; - let code = c.string()?; + let cch = usize::from(c.u16()?); + let high = c.u8()? & 1 != 0; + // Some writers count one character more than the record holds; Excel opens + // those files, so keep the characters that are there. + let fit = c.remaining() / if high { 2 } else { 1 }; + if cch > fit { + report.warn("Some number formats were shorter than their declared length; the characters present were kept."); + } + let code = c.chars(cch.min(fit), high)?; budget.text(code.len())?; g.formats.insert(ifmt, code); }