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 |