From 2c6ffe489c98184e1022f802dadc84adfe5b1dca Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:37:15 +0900 Subject: [PATCH] XLSX theme: keep the major/minor ea and cs typefaces through open + save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A theme's carries latin, ea (East Asian) and cs (complex script) typefaces for each of the major and minor fonts. The model held only the latin ones and the writer emitted and unconditionally, so a Japanese workbook's theme fonts (e.g. 游ゴシック, MS Pゴシック) were silently deleted on open + save. Theme gains major_font_ea, minor_font_ea, major_font_cs and minor_font_cs (#[serde(default)], so older GridCraft JSON still loads; empty means the file did not set one). The XLSX reader and writer round-trip them escaped, and a theme without them writes exactly the empty attributes it wrote before. Rendering and font resolution are unchanged: the UI does not consume ea yet, and wiring it in is a follow-up. --- crates/model/src/style.rs | 42 +++++++++++++++++- crates/xlsx/src/theme.rs | 86 +++++++++++++++++++++++++++++++++++-- docs/file-format-parity.md | 8 ++-- docs/localization-parity.md | 5 ++- 4 files changed, 129 insertions(+), 12 deletions(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index d3976876e..77edb1fd3 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -119,16 +119,33 @@ pub struct Theme { pub colors: [u32; 12], pub major_font: String, pub minor_font: String, + /// The heading font's East Asian typeface (``); empty when the file did not set one. + #[serde(default)] + pub major_font_ea: String, + /// The body font's East Asian typeface (``); empty when the file did not set one. + #[serde(default)] + pub minor_font_ea: String, + /// The heading font's complex-script typeface (``); empty when the file did not set one. + #[serde(default)] + pub major_font_cs: String, + /// The body font's complex-script typeface (``); empty when the file did not set one. + #[serde(default)] + pub minor_font_cs: String, } impl Default for Theme { - /// GridCraft's own default theme ("Craft"): original palette. + /// GridCraft's own default theme ("Craft"): original palette. The East Asian and + /// complex-script typefaces stay empty: the file did not set one. fn default() -> Self { Theme { name: "Craft".into(), colors: [0xFFFFFF, 0x000000, 0xE8E8E8, 0x0E2841, 0x156082, 0xE97132, 0x196B24, 0x0F9ED5, 0xA02B93, 0x4EA72E, 0x467886, 0x96607D], major_font: crate::DEFAULT_FONT.into(), minor_font: crate::DEFAULT_FONT.into(), + major_font_ea: String::new(), + minor_font_ea: String::new(), + major_font_cs: String::new(), + minor_font_cs: String::new(), } } } @@ -444,4 +461,27 @@ mod tests { assert_eq!(Color::Theme(1, 500).resolve(&th), Some([128, 128, 128])); assert_eq!(Color::Auto.resolve(&th), None); } + + #[test] + fn theme_json_keeps_ea_and_cs_and_reads_old_json() { + // JSON written before the ea/cs fields existed still deserializes (`serde(default)`). + let mut old = serde_json::to_value(Theme::default()).unwrap(); + let obj = old.as_object_mut().unwrap(); + for key in ["major_font_ea", "minor_font_ea", "major_font_cs", "minor_font_cs"] { + assert!(obj.remove(key).is_some(), "the default theme serializes {key}"); + } + assert_eq!(serde_json::from_value::(old).unwrap(), Theme::default()); + + // Set fields serialize and come back. + let t = Theme { + major_font_ea: "游ゴシック".into(), + minor_font_ea: "MS Pゴシック".into(), + major_font_cs: "Arial".into(), + minor_font_cs: "Times New Roman".into(), + ..Theme::default() + }; + let json = serde_json::to_string(&t).unwrap(); + assert!(json.contains("major_font_ea") && json.contains("游ゴシック"), "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), t); + } } diff --git a/crates/xlsx/src/theme.rs b/crates/xlsx/src/theme.rs index 68e25bd69..2080fa49b 100644 --- a/crates/xlsx/src/theme.rs +++ b/crates/xlsx/src/theme.rs @@ -41,6 +41,12 @@ fn scheme_rgb(e: &El) -> Option { None } +/// The typeface of `//` (`latin`, `ea` or `cs`), `None` when absent or +/// empty (an empty typeface means "not set" and must not overwrite the default). +fn typeface<'a>(fs: &'a El, font: &str, kind: &str) -> Option<&'a str> { + fs.path(&[font, kind]).and_then(|e| e.attr("typeface")).filter(|f| !f.is_empty()) +} + pub fn read_theme(root: &El) -> Theme { let mut t = Theme::default(); if let Some(n) = root.attr("name") { @@ -57,12 +63,24 @@ pub fn read_theme(root: &El) -> Theme { } } if let Some(fs) = el.child("fontScheme") { - if let Some(f) = fs.path(&["majorFont", "latin"]).and_then(|l| l.attr("typeface")).filter(|f| !f.is_empty()) { + if let Some(f) = typeface(fs, "majorFont", "latin") { t.major_font = f.to_string(); } - if let Some(f) = fs.path(&["minorFont", "latin"]).and_then(|l| l.attr("typeface")).filter(|f| !f.is_empty()) { + if let Some(f) = typeface(fs, "minorFont", "latin") { t.minor_font = f.to_string(); } + if let Some(f) = typeface(fs, "majorFont", "ea") { + t.major_font_ea = f.to_string(); + } + if let Some(f) = typeface(fs, "minorFont", "ea") { + t.minor_font_ea = f.to_string(); + } + if let Some(f) = typeface(fs, "majorFont", "cs") { + t.major_font_cs = f.to_string(); + } + if let Some(f) = typeface(fs, "minorFont", "cs") { + t.minor_font_cs = f.to_string(); + } } t } @@ -80,10 +98,14 @@ pub fn write_theme(t: &Theme) -> String { s.push_str(""); let _ = write!( s, - "", + "", esc_attr(name), esc_attr(&t.major_font), - esc_attr(&t.minor_font) + esc_attr(&t.major_font_ea), + esc_attr(&t.major_font_cs), + esc_attr(&t.minor_font), + esc_attr(&t.minor_font_ea), + esc_attr(&t.minor_font_cs) ); // Format scheme: three entries per list, as the schema requires. s.push_str(""); @@ -113,6 +135,9 @@ pub fn write_theme(t: &Theme) -> String { mod tests { use super::*; + /// A Japanese Office theme: the heading font sets latin, ea and cs; the body font only ea. + const EA_THEME: &str = r#""#; + #[test] fn roundtrip() { let mut t = Theme::default(); @@ -125,4 +150,57 @@ mod tests { let back = read_theme(&crate::xml::parse(x.as_bytes()).unwrap()); assert_eq!(back, t); } + + #[test] + fn ea_and_cs_typefaces_roundtrip() { + let t = read_theme(&crate::xml::parse(EA_THEME.as_bytes()).unwrap()); + assert_eq!(t.major_font, "Calibri"); + assert_eq!(t.major_font_ea, "游ゴシック"); + assert_eq!(t.major_font_cs, "Arial"); + // No `` under the minor font: the default stays. + assert_eq!(t.minor_font, Theme::default().minor_font); + assert_eq!(t.minor_font_ea, "MS Pゴシック"); + assert!(t.minor_font_cs.is_empty()); + + let x = write_theme(&t); + assert!( + x.contains(r#""#), + "{x}" + ); + assert!( + x.contains(r#""#), + "{x}" + ); + assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t); + } + + #[test] + fn theme_without_ea_or_cs_writes_empty_attributes() { + let t = Theme::default(); + let x = write_theme(&t); + assert!(x.contains(r#""#), "{x}"); + assert!(x.contains(r#""#), "{x}"); + } + + #[test] + fn explicit_empty_ea_and_cs_read_as_unset() { + let xml = EA_THEME + .replace(r#""#, r#""#) + .replace(r#""#, r#""#); + let t = read_theme(&crate::xml::parse(xml.as_bytes()).unwrap()); + assert!(t.major_font_ea.is_empty() && t.minor_font_ea.is_empty()); + assert_eq!(t.major_font_cs, "Arial"); + let x = write_theme(&t); + assert!(x.contains(r#""#), "{x}"); + assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t); + } + + #[test] + fn ea_and_cs_typefaces_are_escaped() { + let t = Theme { major_font_ea: "游&ゴシック".into(), minor_font_cs: "A\"C\"".into(), ..Theme::default() }; + let x = write_theme(&t); + assert!(x.contains(r#""#), "{x}"); + assert!(x.contains(r#""#), "{x}"); + assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t); + } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5a7588986..07989d5a5 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 (.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-17 · **Change:** trivial (a workbook theme's major/minor East Asian (``) and complex-script (``) typefaces are kept through a save; previously trivial: 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) · **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** @@ -22,10 +22,7 @@ and repair-free opening remain unverified. | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | -| `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | @@ -85,6 +82,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | A workbook theme's major/minor East Asian (``) and complex-script (``) typefaces are read and written back, so a Japanese theme's fonts (e.g. 游ゴシック) survive open + save; they were deleted before because the model had nowhere to keep them. Rendering still resolves fonts without them (follow-up) | | 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..2bad6d4d0 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-17 · **Change:** trivial (a Japanese workbook keeps its theme's East Asian typeface (``) through open + save; the UI does not use it for rendering yet) · **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, @@ -56,7 +56,7 @@ separators, number/date formats, machine translation with agent review, and scri | French | fr | 0 (0%) | no | Latin: yes | no | none | 6–10 h | | Portuguese | pt-BR | 923 (~42%) | dialogs and tooltips (dialogs take effect with #669) | Latin: yes | no | menus, File view, dialogs | 2–4 h | | Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h | -| Japanese | ja | 268 (~12%) | no | CJK system-font fallback; IME unverified; no furigana display | no | menus only | 8–12 h | +| Japanese | ja | 268 (~12%) | no | CJK system-font fallback; the theme's East Asian typeface (``) survives open + save (rendering does not use it yet); IME unverified; no furigana display | no | menus only | 8–12 h | | German | de | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h | | Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME unverified | no | menus only | 6–10 h | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics: unverified | no | none | 6–10 h | @@ -83,6 +83,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | XLSX: a workbook theme's major/minor East Asian (``) and complex-script (``) typefaces are kept through open + save, so a Japanese workbook keeps its theme's East Asian font; rendering does not resolve them yet (follow-up) | | 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 |