From 75305dfedf73e3227304108c75358d07bff5ccc4 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:51:41 +0200 Subject: [PATCH] XLSX: keep the workbook's default font on save (fixes #805) Cell format 0 always mapped to the model's built-in Calibri 11 default, so the file's Normal font was replaced on save and every default-formatted cell changed size. The Normal style now takes the font of cell format 0, and a Normal font in the theme's body face is written with scheme="minor" again. --- crates/model/src/style.rs | 6 ++++ crates/xlsx/src/read.rs | 4 +++ crates/xlsx/src/styles.rs | 13 ++++++--- crates/xlsx/src/tests/roundtrip.rs | 47 ++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index 126727eda..d3976876e 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -377,6 +377,12 @@ impl Default for StyleTable { } impl StyleTable { + /// A table whose default (Normal) style is `normal`, such as a file's own Normal font. + pub fn with_default(normal: Style) -> Self { + let mut index = HashMap::new(); + index.insert(normal.clone(), StyleId::DEFAULT); + StyleTable { styles: vec![normal], index } + } pub fn get(&self, id: StyleId) -> &Style { self.styles.get(id.0 as usize).or_else(|| self.styles.first()).unwrap_or(&DEFAULT_STYLE) } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index a25444d88..c4a1d2754 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -120,6 +120,10 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if let Some(x) = cx.optional_xml(&r.target)? { let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x, &checkboxes); cx.num_fmts = num_fmts; + // Cell format 0 is the Normal style: its font is what every unformatted cell shows. + if let Some(normal) = xfs.first() { + cx.styles = StyleTable::with_default(Style { font: normal.font.clone(), ..Style::default() }); + } cx.xf_map = xfs.into_iter().enumerate().map(|(i, s)| if i == 0 { StyleId::DEFAULT } else { cx.styles.intern(s) }).collect(); cx.dxfs = dxfs; cx.palette = palette; diff --git a/crates/xlsx/src/styles.rs b/crates/xlsx/src/styles.rs index 1c98a544c..01f1fa632 100644 --- a/crates/xlsx/src/styles.rs +++ b/crates/xlsx/src/styles.rs @@ -396,7 +396,8 @@ pub fn color_attrs(c: &Color) -> String { } } -fn font_xml(f: &Font, dxf: bool) -> String { +/// `minor`: the font follows the theme's body font (``). +fn font_xml(f: &Font, dxf: bool, minor: bool) -> String { let mut s = String::from(""); if f.bold { s.push_str(""); @@ -429,6 +430,9 @@ fn font_xml(f: &Font, dxf: bool) -> String { } if !dxf { let _ = write!(s, "", esc_attr(&f.name)); + if minor { + s.push_str(""); + } } s.push_str(""); s @@ -667,7 +671,7 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St || d.font.color != Color::Auto || d.font.vert != VertAlign::Baseline { - dx.push_str(&font_xml(&d.font, true)); + dx.push_str(&font_xml(&d.font, true, false)); } if d.num_fmt != def.num_fmt { let id = nf.id(d.num_fmt.as_str()); @@ -699,8 +703,9 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St s.push_str(""); } let _ = write!(s, "", fonts.len()); - for f in &fonts { - s.push_str(&font_xml(f, false)); + for (i, f) in fonts.iter().enumerate() { + // A Normal font in the theme's body face follows the theme, as Excel writes it (#805). + s.push_str(&font_xml(f, false, i == 0 && f.name == wb.theme.minor_font)); } let _ = write!(s, "", fills.len()); for f in &fills { diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 960157254..a1457fd8d 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1014,3 +1014,50 @@ fn package_kind_sets_workbook_content_type() { } assert_eq!(crate::XlsxKind::from_path("noext"), crate::XlsxKind::Workbook); } + +/// The workbook's default font (`fonts[0]`, the Normal style's) is what every default-formatted +/// cell shows, typed or empty; saving keeps it instead of resetting it to Calibri 11 (#805). +#[test] +fn default_font_survives_a_round_trip() { + let styles = |font0: &str| { + format!( + r#"{font0} + + + + "# + ) + }; + let rels = r#""#; + let body = r#"typed text1"#; + let font0 = |bytes: &[u8]| { + let mut pkg = Package::open(bytes).unwrap(); + let st = pkg.read_xml("xl/styles.xml").unwrap().unwrap(); + let f = st.child("fonts").unwrap().kids("font").next().unwrap().clone(); + let val = |n: &str| f.child(n).and_then(|e| e.attr("val")).map(str::to_string); + (val("sz"), val("name"), val("family"), val("scheme")) + }; + + let file = + super::minimal(body, &[("xl/styles.xml", &styles(r#""#))], rels, ""); + let (wb, _) = read_xlsx(&file).unwrap(); + let saved = write_xlsx(&wb).unwrap(); + assert_eq!(font0(&saved), (Some("10".into()), Some("Arial".into()), Some("2".into()), None)); + let (back, _) = read_xlsx(&saved).unwrap(); + let normal = &back.styles.get(StyleId::DEFAULT).font; + assert_eq!((normal.name.as_str(), normal.size), ("Arial", 10.0), "empty cells and new input take the Normal font"); + let s = back.sheet(0).unwrap(); + assert_eq!(s.cell(at("A1")).unwrap().style, StyleId::DEFAULT); + let b1 = &back.styles.get(s.cell(at("B1")).unwrap().style).font; + assert!(b1.bold && b1.size == 14.0, "an explicit font is still its own: {b1:?}"); + + // Excel's own Normal font follows the theme's body font; the scheme link survives too. + let file = super::minimal( + body, + &[("xl/styles.xml", &styles(r#""#))], + rels, + "", + ); + let saved = write_xlsx(&read_xlsx(&file).unwrap().0).unwrap(); + assert_eq!(font0(&saved), (Some("12".into()), Some("Calibri".into()), Some("2".into()), Some("minor".into()))); +}