mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-25 03:06:53 -06:00
XLSX theme: keep the major/minor ea and cs typefaces through open + save
A theme's <a:fontScheme> 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 <a:ea typeface=""/> and <a:cs typeface=""/> 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.
This commit is contained in:
@@ -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 (`<a:ea>`); empty when the file did not set one.
|
||||
#[serde(default)]
|
||||
pub major_font_ea: String,
|
||||
/// The body font's East Asian typeface (`<a:ea>`); empty when the file did not set one.
|
||||
#[serde(default)]
|
||||
pub minor_font_ea: String,
|
||||
/// The heading font's complex-script typeface (`<a:cs>`); empty when the file did not set one.
|
||||
#[serde(default)]
|
||||
pub major_font_cs: String,
|
||||
/// The body font's complex-script typeface (`<a:cs>`); 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::<Theme>(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::<Theme>(&json).unwrap(), t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ fn scheme_rgb(e: &El) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The typeface of `<fontScheme>/<font>/<kind>` (`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("</a:clrScheme>");
|
||||
let _ = write!(
|
||||
s,
|
||||
"<a:fontScheme name=\"{}\"><a:majorFont><a:latin typeface=\"{}\"/><a:ea typeface=\"\"/><a:cs typeface=\"\"/></a:majorFont><a:minorFont><a:latin typeface=\"{}\"/><a:ea typeface=\"\"/><a:cs typeface=\"\"/></a:minorFont></a:fontScheme>",
|
||||
"<a:fontScheme name=\"{}\"><a:majorFont><a:latin typeface=\"{}\"/><a:ea typeface=\"{}\"/><a:cs typeface=\"{}\"/></a:majorFont><a:minorFont><a:latin typeface=\"{}\"/><a:ea typeface=\"{}\"/><a:cs typeface=\"{}\"/></a:minorFont></a:fontScheme>",
|
||||
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("<a:fmtScheme name=\"Craft\"><a:fillStyleLst>");
|
||||
@@ -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#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Theme"><a:themeElements><a:clrScheme name="Office"/><a:fontScheme name="Office"><a:majorFont><a:latin typeface="Calibri"/><a:ea typeface="游ゴシック"/><a:cs typeface="Arial"/></a:majorFont><a:minorFont><a:ea typeface="MS Pゴシック"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
|
||||
|
||||
#[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 `<a:latin>` 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#"<a:majorFont><a:latin typeface="Calibri"/><a:ea typeface="游ゴシック"/><a:cs typeface="Arial"/></a:majorFont>"#),
|
||||
"{x}"
|
||||
);
|
||||
assert!(
|
||||
x.contains(r#"<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface="MS Pゴシック"/><a:cs typeface=""/></a:minorFont>"#),
|
||||
"{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#"<a:majorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>"#), "{x}");
|
||||
assert!(x.contains(r#"<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>"#), "{x}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_empty_ea_and_cs_read_as_unset() {
|
||||
let xml = EA_THEME
|
||||
.replace(r#"<a:ea typeface="游ゴシック"/>"#, r#"<a:ea typeface=""/>"#)
|
||||
.replace(r#"<a:ea typeface="MS Pゴシック"/>"#, r#"<a:ea typeface=""/>"#);
|
||||
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#"<a:majorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface="Arial"/></a:majorFont>"#), "{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<B>\"C\"".into(), ..Theme::default() };
|
||||
let x = write_theme(&t);
|
||||
assert!(x.contains(r#"<a:ea typeface="游&ゴシック"/>"#), "{x}");
|
||||
assert!(x.contains(r#"<a:cs typeface="A<B>"C""/>"#), "{x}");
|
||||
assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (`<a:ea>`) and complex-script (`<a:cs>`) 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 (`<a:ea>`) and complex-script (`<a:cs>`) 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 |
|
||||
|
||||
@@ -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 (`<a:ea>`) 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 (`<a:ea>`) 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 (`<a:ea>`) and complex-script (`<a:cs>`) 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 |
|
||||
|
||||
Reference in New Issue
Block a user