diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 4712351ca..5961fd274 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -296,7 +296,11 @@ fn underline(s: &mut Session, p: &Json) -> Result { fn font_name(s: &mut Session, p: &Json) -> Result { let Some(name) = str_param(p, "name").map(str::to_string) else { return Err(bad("home.fontName", "missing `name`")) }; - apply_style(s, p, |st| st.font.name = name.clone()) + apply_style(s, p, |st| { + st.font.name = name.clone(); + st.font.charset = None; + st.font.family = None; + }) } fn font_size(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1aacf1a61..a30780f6f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1207,6 +1207,24 @@ fn structured_refs_to_columns_with_edge_spaces() { assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3")], [n(5.0), n(5.0), n(5.0)]); } +#[test] +fn changing_the_font_name_drops_the_old_fonts_charset_and_family() { + let mut s = s(); + { + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + let arabic = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| { + st.font.name = "Traditional Arabic".into(); + st.font.charset = Some(178); + st.font.family = Some(1); + }); + wb.sheet_mut(0).unwrap().set_style(CellRef::parse("A1").unwrap(), arabic); + } + s.execute("home.fontName", json!({"range": "A1", "name": "Arial"})).unwrap(); + let d = s.doc().unwrap(); + let font = &d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("A1").unwrap())).font; + assert_eq!((font.name.as_str(), font.charset, font.family), ("Arial", None, None)); +} + #[test] fn table_rename_keeps_a_defined_name_with_the_same_name() { let mut s = s(); diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index 800bb34d1..14c90510d 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -161,6 +161,12 @@ pub struct Font { pub strike: bool, pub color: Color, pub vert: VertAlign, + /// Windows character set the font was chosen for (178 = Arabic); kept so Excel substitutes a font for the same script. + #[serde(default)] + pub charset: Option, + /// Font family class (1 roman, 2 swiss, 3 modern…); `None` is swiss, the default. + #[serde(default)] + pub family: Option, } impl Eq for Font {} @@ -168,7 +174,7 @@ impl Hash for Font { fn hash(&self, h: &mut H) { self.name.hash(h); self.size.to_bits().hash(h); - (self.bold, self.italic, self.underline, self.strike, self.color, self.vert).hash(h); + (self.bold, self.italic, self.underline, self.strike, self.color, self.vert, self.charset, self.family).hash(h); } } @@ -183,6 +189,8 @@ impl Default for Font { strike: false, color: Color::Auto, vert: VertAlign::Baseline, + charset: None, + family: None, } } } diff --git a/crates/xlsx/src/styles.rs b/crates/xlsx/src/styles.rs index 42a3853da..e209d276b 100644 --- a/crates/xlsx/src/styles.rs +++ b/crates/xlsx/src/styles.rs @@ -57,6 +57,9 @@ fn bool_prop(e: &El, name: &str) -> bool { e.child(name).is_some_and(|c| c.flag("val", true)) } +const DEFAULT_FONT_FAMILY: u8 = 2; +const MAX_FONT_FAMILY: u8 = 14; + pub fn read_font(e: &El, palette: &[u32]) -> Font { let mut f = Font::default(); if let Some(n) = e.child("name").or_else(|| e.child("rFont")).and_then(|n| n.attr("val")) { @@ -84,6 +87,12 @@ pub fn read_font(e: &El, palette: &[u32]) -> Font { _ => VertAlign::Baseline, }; } + f.charset = e.child("charset").and_then(|c| c.attr_u32("val")).and_then(|v| u8::try_from(v).ok()); + f.family = e + .child("family") + .and_then(|c| c.attr_u32("val")) + .and_then(|v| u8::try_from(v).ok()) + .filter(|v| *v <= MAX_FONT_FAMILY && *v != DEFAULT_FONT_FAMILY); f.color = opt_color(e.child("color"), palette); // Text 1 (dk1) is what "automatic" font colour means in practice. if f.color == Color::Theme(1, 0) { @@ -445,7 +454,11 @@ fn font_xml(f: &Font, dxf: Option<&DxfStated>, minor: bool) -> String { s.push_str(""); } if dxf.is_none() { - let _ = write!(s, "", esc_attr(&f.name)); + let family = f.family.filter(|v| *v <= MAX_FONT_FAMILY).unwrap_or(DEFAULT_FONT_FAMILY); + let _ = write!(s, "", esc_attr(&f.name)); + if let Some(charset) = f.charset { + let _ = write!(s, ""); + } if minor { s.push_str(""); } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 7c7c19056..6b8dad43d 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1128,6 +1128,64 @@ fn default_font_survives_a_round_trip() { assert_eq!(font0(&saved), (Some("12".into()), Some("Calibri".into()), Some("2".into()), Some("minor".into()))); } +fn one_font_workbook(font: &str) -> Vec { + let styles = format!( + r#"{font}"# + ); + let rels = r#""#; + super::minimal("", &[("xl/styles.xml", &styles)], rels, "") +} + +#[test] +fn font_charset_and_family_roundtrip() { + let file = one_font_workbook(r#""#); + let (wb, _) = read_xlsx(&file).unwrap(); + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let xml = String::from_utf8(pkg.read("xl/styles.xml").unwrap().unwrap()).unwrap(); + assert!(xml.contains(""), "{xml}"); + + let (back, _) = read_xlsx(&saved).unwrap(); + let font = &back.styles.get(StyleId::DEFAULT).font; + let back_saved = write_xlsx(&back).unwrap(); + let mut pkg = Package::open(&back_saved).unwrap(); + let xml = String::from_utf8(pkg.read("xl/styles.xml").unwrap().unwrap()).unwrap(); + assert!(xml.contains(""), "{xml}"); + assert_eq!(font.name, "Traditional Arabic"); + assert_eq!(font.family, Some(1)); + assert_eq!(font.charset, Some(178)); +} + +#[test] +fn fonts_without_charset_write_unchanged() { + let file = one_font_workbook(r#""#); + let saved = write_xlsx(&read_xlsx(&file).unwrap().0).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let xml = String::from_utf8(pkg.read("xl/styles.xml").unwrap().unwrap()).unwrap(); + assert!(xml.contains(""), "{xml}"); + assert!(!xml.contains(""#); + let (wb, _) = read_xlsx(&file).unwrap(); + assert_eq!(wb.styles.get(StyleId::DEFAULT).font.family, None); + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let xml = String::from_utf8(pkg.read("xl/styles.xml").unwrap().unwrap()).unwrap(); + assert!(xml.contains(""), "{xml}"); + + let mut set_directly = wb.clone(); + let wide = set_directly.styles.derive(StyleId::DEFAULT, |st| st.font.family = Some(200)); + set_directly.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), Value::text("x")); + set_directly.sheet_mut(0).unwrap().set_style(CellRef::new(0, 0), wide); + let saved = write_xlsx(&set_directly).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let xml = String::from_utf8(pkg.read("xl/styles.xml").unwrap().unwrap()).unwrap(); + assert!(!xml.contains(""), "{xml}"); +} + #[test] fn solid_data_bar_survives_with_sparklines_and_gradient_neighbours() { let mut wb = Workbook::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 728b3f27a..05a2f0bd5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (XLSX PivotTables with two value fields keep their layout in Excel and PivotCharts stay bound to their PivotTable (#1447); .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (XLSX PivotTables with two value fields keep their layout in Excel and PivotCharts stay bound to their PivotTable (#1447); .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; XLSX font charset and family values are preserved on save) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | XLSX font charset and family values are preserved on save, retaining the character set and family used for substitute font selection | | 2026-10-19 | minor | .xls import skips dialog sheets (WsBool `fDialog`), which share the worksheet BOF type and were read as empty worksheets | | 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over | | 2026-10-19 | trivial | XLSX text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`, ECMA-376 Part 1 §21.1.2.2.7): read from the first paragraph and written on every paragraph; output without it is unchanged | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 64876633d..483ebcf82 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (defined-name length limit counts characters, so long Arabic names are accepted; Table names are limited to 255 characters; right-to-left sheets in `.xls` files open right-to-left; text boxes and shapes keep right-to-left paragraph direction through XLSX save; Save as Web Page and copied HTML keep right-to-left; French and Serbian `context|label` translations no longer show the context) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (defined-name length limit counts characters, so long Arabic names are accepted; Table names are limited to 255 characters; right-to-left sheets in `.xls` files open right-to-left; text boxes and shapes keep right-to-left paragraph direction through XLSX save; Save as Web Page and copied HTML keep right-to-left; French and Serbian `context|label` translations no longer show the context; Arabic XLSX font fallback metadata is preserved on save) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -83,6 +83,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | XLSX fonts retain charset and family metadata so systems can select a substitute font with the intended script when the named font is unavailable | | 2026-10-19 | minor | Defined-name length counts characters, not bytes: Arabic names up to 255 letters are accepted, and Create from Selection keeps long Arabic headers | | 2026-10-19 | minor | Table names are limited to 255 Unicode characters | | 2026-10-19 | minor | Right-to-left sheets in legacy `.xls` files (common in older Arabic workbooks) open right-to-left |