From 180928c001c121a6b66ac6476b5bafb6e3895533 Mon Sep 17 00:00:00 2001
From: CouchRiv <270190108+CouchRiv@users.noreply.github.com>
Date: Mon, 12 Oct 2026 23:05:09 -0400
Subject: [PATCH 01/17] Data validation: a list typed without quotes is saved
as a quoted list
data.validation {type: "list", formula1: "Open,In progress,Closed"}, which is
how Excel's Source box takes a list, worked inside GridCraft but was saved as
Open,In progress,Closed. In a file an inline list is a
string literal, "Open,In progress,Closed" (what Excel writes and what our own
reader expects); unquoted, formula1 reads as a formula of names.
- validation quotes such a source when the rule is created, doubling inner
quotes, using the test list_items already used to tell a literal list from
a reference or a name (now is_literal_list). A source typed with = (a
formula), a reference, a name, a sheet-qualified range and an already quoted
list are stored as before.
- list_items reads a quoted list as a string literal: "" inside stands for
one quote, so an item with a quote reads back the same after the change and
from a file.
Creating the rule is the one place that still knows whether the user typed =,
so the XLSX writer is left alone: it can't tell OFFSET(...) entered as a
formula from a literal list, and a rule read from a file is already quoted.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/cmd/data.rs | 94 ++++++++++++++++++++++++++++++++---
1 file changed, 88 insertions(+), 6 deletions(-)
diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs
index e4aa3baa6..f268f40e0 100644
--- a/crates/engine/src/cmd/data.rs
+++ b/crates/engine/src/cmd/data.rs
@@ -929,11 +929,13 @@ fn validation(s: &mut Session, p: &Json) -> Result {
"custom" => ValidationKind::Custom,
_ => ValidationKind::Any,
};
+ let f1 = str_param(p, "formula1").unwrap_or("");
+ let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() };
let dv = Validation {
ranges: ranges.clone(),
kind,
op: op_param(str_param(p, "operator")),
- f1: str_param(p, "formula1").unwrap_or("").trim_start_matches('=').to_string(),
+ f1,
f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()),
allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true),
in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true),
@@ -957,14 +959,36 @@ fn validation(s: &mut Session, p: &Json) -> Result {
})
}
+/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather
+/// than a reference or a name to evaluate.
+fn is_literal_list(f: &str) -> bool {
+ f.starts_with('"')
+ || (!f.contains('!')
+ && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
+}
+
+/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's
+/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves
+/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a
+/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before.
+fn list_source(f: &str) -> String {
+ let t = f.trim();
+ if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) {
+ return f.trim_start_matches('=').to_string();
+ }
+ format!("\"{}\"", t.replace('"', "\"\""))
+}
+
/// List entries of a list validation (literal or from a range).
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec {
let f = dv.f1.trim();
- if f.starts_with('"')
- || (!f.contains('!')
- && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
- {
- return f.trim_matches('"').split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
+ if is_literal_list(f) {
+ // A quoted list is a string literal: `""` inside stands for one `"`.
+ let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) {
+ Some(body) => body.replace("\"\"", "\""),
+ None => f.trim_matches('"').to_string(),
+ };
+ return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
}
let at = dv.ranges.first().map(|r| r.start).unwrap_or_default();
match gridcraft_calc::evaluate(wb, sheet, at, f) {
@@ -1381,4 +1405,62 @@ mod tests {
assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]);
assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]);
}
+
+ #[test]
+ fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() {
+ // In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula.
+ // References, names and formulas stay unquoted, and every rule survives a save and reopen.
+ let mut s = Session::new();
+ s.new_workbook();
+ s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap();
+ s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap();
+ let rules = [
+ ("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#),
+ ("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#),
+ ("C1", " 1,2,3 ", r#""1,2,3""#),
+ ("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#),
+ ("E1", "=$K$1:$K$3", "$K$1:$K$3"),
+ ("F1", "$K$1:$K$3", "$K$1:$K$3"),
+ ("G1", "=Colors", "Colors"),
+ ("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"),
+ ("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"),
+ ];
+ for (cell, source, _) in rules {
+ s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap();
+ }
+ let accepted = [
+ ("A1", "In progress"),
+ ("B1", "Closed"),
+ ("C1", "2"),
+ ("D1", r#"Say "hi""#),
+ ("E1", "Green"),
+ ("F1", "Blue"),
+ ("G1", "Red"),
+ ("H1", "Green"),
+ ];
+ let check = |wb: &Workbook| {
+ for (cell, input) in accepted {
+ let at = CellRef::parse(cell).unwrap();
+ assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}");
+ assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending");
+ }
+ };
+ let wb = s.doc().unwrap().wb.clone();
+ check(&wb);
+
+ let sheet_xml = |wb: &Workbook| {
+ let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap();
+ let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
+ let mut xml = String::new();
+ std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap();
+ xml
+ };
+ let xml = sheet_xml(&wb);
+ for (cell, _, saved) in rules {
+ assert!(xml.contains(&format!(r#"sqref="{cell}">{saved}"#)), "{cell}: {saved} missing from {xml}");
+ }
+ let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
+ check(&reopened);
+ assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
+ }
}
From 1a6979c948ccd8c727546c1fa2e6ab234d6f1eb8 Mon Sep 17 00:00:00 2001
From: CouchRiv <270190108+CouchRiv@users.noreply.github.com>
Date: Mon, 12 Oct 2026 23:32:31 -0400
Subject: [PATCH 02/17] Data validation: refuse an unknown type, operator or
error style
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
data.validation took any `type`, `operator` or `errorStyle` and fell back
to a default: a typo such as `type: "lst"` saved an "any value" rule
(`type="none"`), which validates nothing, and an unknown operator became
`between`. Each is now an error that lists the accepted values; `any`
stays a type, and the operator aliases (`greaterThan`…) still work.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/cmd/data.rs | 61 +++++++++++++++++++++++++++++++----
1 file changed, 55 insertions(+), 6 deletions(-)
diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs
index f268f40e0..46cc12b1e 100644
--- a/crates/engine/src/cmd/data.rs
+++ b/crates/engine/src/cmd/data.rs
@@ -927,7 +927,32 @@ fn validation(s: &mut Session, p: &Json) -> Result {
"time" => ValidationKind::Time,
"textLength" => ValidationKind::TextLength,
"custom" => ValidationKind::Custom,
- _ => ValidationKind::Any,
+ "any" => ValidationKind::Any,
+ // An unknown type used to give an "any value" rule, saved as `type="none"`: no validation.
+ other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))),
+ };
+ const OPERATORS: [&str; 12] = [
+ "between",
+ "notBetween",
+ "equal",
+ "notEqual",
+ "greater",
+ "greaterThan",
+ "less",
+ "lessThan",
+ "greaterOrEqual",
+ "greaterThanOrEqual",
+ "lessOrEqual",
+ "lessThanOrEqual",
+ ];
+ if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) {
+ return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", "))));
+ }
+ let error_style = match str_param(p, "errorStyle") {
+ None | Some("stop") => ErrorStyle::Stop,
+ Some("warning") => ErrorStyle::Warning,
+ Some("information") => ErrorStyle::Information,
+ Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))),
};
let f1 = str_param(p, "formula1").unwrap_or("");
let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() };
@@ -944,11 +969,7 @@ fn validation(s: &mut Session, p: &Json) -> Result {
show_input: true,
error_title: str_param(p, "errorTitle").unwrap_or("").into(),
error_message: str_param(p, "errorMessage").unwrap_or("").into(),
- error_style: match str_param(p, "errorStyle") {
- Some("warning") => ErrorStyle::Warning,
- Some("information") => ErrorStyle::Information,
- _ => ErrorStyle::Stop,
- },
+ error_style,
show_error: true,
};
edit(s, |cx| {
@@ -1463,4 +1484,32 @@ mod tests {
check(&reopened);
assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
}
+
+ #[test]
+ fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() {
+ // A typo used to give a rule that validates nothing (`type="none"`) or the wrong check,
+ // with no error.
+ let mut s = Session::new();
+ s.new_workbook();
+ for params in [
+ json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}),
+ json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}),
+ json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}),
+ json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}),
+ ] {
+ let e = s.execute("data.validation", params.clone()).unwrap_err().to_string();
+ assert!(e.contains("unknown"), "{params}: {e}");
+ }
+ assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
+ for params in [
+ json!({"range": "A1", "type": "any"}),
+ json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}),
+ json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}),
+ ] {
+ s.execute("data.validation", params).unwrap();
+ }
+ let sh = s.doc().unwrap().wb.active().unwrap().clone();
+ assert_eq!(sh.validations.len(), 3);
+ assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some());
+ }
}
From 938097f5b697568f3bb5b0eed6e69cb0e61e9c4b Mon Sep 17 00:00:00 2001
From: allenyllee <3991134+allenyllee@users.noreply.github.com>
Date: Wed, 14 Oct 2026 11:44:01 +0800
Subject: [PATCH 03/17] I1231: load Flatpak host fonts for native CJK fallback
---
contributors/people.toml | 3 +++
crates/ui-egui/src/theme.rs | 46 ++++++++++++++++++++++++++++++++++---
docs/localization-parity.md | 10 +++++---
3 files changed, 53 insertions(+), 6 deletions(-)
diff --git a/contributors/people.toml b/contributors/people.toml
index d522b84e0..d44ca8ead 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -65,3 +65,6 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.allenyllee]
+sync_github_name = true
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
index 485b9a2f3..4d7a36d65 100644
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -499,9 +499,22 @@ fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> {
/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`,
/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), plus local and
-/// flat layouts.
+/// flat layouts. Flatpak exposes the same host layouts through read-only font mounts.
#[cfg(not(target_arch = "wasm32"))]
-const LINUX_CJK_DIRS: [&str; 10] = [
+const LINUX_CJK_DIRS: [&str; 21] = [
+ // Flatpak mounts host fonts here without a broad filesystem permission. Keep the same
+ // distribution layouts as below; /usr/share/fonts inside the sandbox belongs to the runtime.
+ "/run/host/fonts/opentype/noto",
+ "/run/host/fonts/google-noto-cjk",
+ "/run/host/fonts/noto-cjk",
+ "/run/host/fonts/truetype/noto",
+ "/run/host/fonts/noto",
+ "/run/host/fonts/truetype",
+ "/run/host/fonts/TTF",
+ "/run/host/fonts",
+ "/run/host/local-fonts",
+ "/run/host/local-fonts/noto-cjk",
+ "/run/host/user-fonts",
"/usr/share/fonts/opentype/noto",
"/usr/share/fonts/google-noto-cjk",
"/usr/share/fonts/noto-cjk",
@@ -827,6 +840,33 @@ mod tests {
}
}
+ #[test]
+ fn cjk_fallback_probes_flatpak_fonts_with_the_correct_locale_faces() {
+ // Exercise the candidates the fallback loader actually consumes, not just the directory
+ // constant. This must work on CI without an installed CJK font or a Flatpak runtime.
+ for (candidates, face) in [(cjk_han_japanese(), 0), (cjk_han_simplified(), 2), (cjk_hangul(), 1)] {
+ for directory in [
+ "/run/host/fonts/opentype/noto",
+ "/run/host/fonts/google-noto-cjk",
+ "/run/host/fonts/noto-cjk",
+ "/run/host/fonts/truetype/noto",
+ "/run/host/fonts/noto",
+ "/run/host/fonts/truetype",
+ "/run/host/fonts/TTF",
+ "/run/host/fonts",
+ "/run/host/local-fonts",
+ "/run/host/local-fonts/noto-cjk",
+ "/run/host/user-fonts",
+ ] {
+ let path = format!("{directory}/NotoSansCJK-Regular.ttc");
+ assert!(candidates.contains(&(path.clone(), face)), "missing Flatpak CJK candidate {path}, face {face}");
+ }
+ }
+ let chinese = cjk_han_simplified();
+ assert!(chinese.contains(&("/run/host/fonts/truetype/wqy/wqy-microhei.ttc".into(), 0)));
+ assert!(chinese.contains(&("/run/host/fonts/wenquanyi/wqy-microhei.ttc".into(), 0)));
+ }
+
#[test]
fn fallback_faces_load_each_file_once() {
// The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded
@@ -850,7 +890,7 @@ mod tests {
// whose face is absent (a minimal machine) and fails if a face is present but not wired in.
let cases: [(&str, Vec<(String, u32)>, &str); 7] = [
("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"),
- ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"),
+ ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置 繁體中文 儲存與重新開啟 中文標點 ,。!?"),
("sys-cjk-hangul", cjk_hangul(), "한글"),
("sys-thai", thai(), "ทดสอบ"),
("sys-hebrew", hebrew(), "עברית"),
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 09197ff6c..576fb77d1 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-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (CJK fallback probes Flatpak host font mounts) · **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,
@@ -26,8 +26,11 @@ right-to-left sheets and full complex-script support. Part of
localized function names and `;` separators (#45). German with localized numbers and formulas
is in PR #163 (open).
- **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32;
- #142 is still open pending confirmation). There is no complex-text shaping: Arabic and
- Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left**
+ #142 is still open pending confirmation). Native CJK fallback also probes Flatpak's read-only
+ `/run/host/fonts`, `/run/host/local-fonts` and `/run/host/user-fonts` layouts, so installed
+ Noto CJK and WenQuanYi faces can render Chinese without a broad filesystem grant. This does
+ not change the installed-font catalogue or add bundled fonts. There is no complex-text shaping:
+ Arabic and Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left**
(`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`) mirrors the grid (#146):
column A and the row headers on the right, the vertical scrollbar on the left; hit-testing,
arrow keys, editor, popups and floating objects follow; text stays left-to-right inside its box
@@ -78,6 +81,7 @@ language (none so far) and of localized function names.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | minor | CJK fallback probes the read-only Flatpak host font mounts with the existing distribution layouts and TTC locale indices; no new sandbox permissions or bundled fonts |
| 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them |
| 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) |
| 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open |
From ef894c20e5f68a57df46c32199637e0d6d7b7917 Mon Sep 17 00:00:00 2001
From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Date: Wed, 14 Oct 2026 19:31:01 +0200
Subject: [PATCH 04/17] Reject a list data validation that has no formula1
(#1303)
---
crates/engine/src/cmd/data.rs | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs
index 383624b70..c467b9efa 100644
--- a/crates/engine/src/cmd/data.rs
+++ b/crates/engine/src/cmd/data.rs
@@ -951,6 +951,9 @@ fn validation(s: &mut Session, p: &Json) -> Result {
"custom" => ValidationKind::Custom,
_ => ValidationKind::Any,
};
+ if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) {
+ return Err(bad("data.validation", "a list validation needs `formula1`"));
+ }
let dv = Validation {
ranges: ranges.clone(),
kind,
@@ -1345,6 +1348,17 @@ mod tests {
}
}
+ #[test]
+ fn list_validation_without_formula1_is_an_error() {
+ let mut s = Session::new();
+ s.new_workbook();
+ assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err());
+ assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err());
+ assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
+ s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap();
+ assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true));
+ }
+
#[test]
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
let mut s = Session::new();
From 81422c315d808e76035eb540b353722fbd58d6cc Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Fri, 16 Oct 2026 02:19:55 +0200
Subject: [PATCH 05/17] Contributors: add my name for the About window
---
contributors/people.toml | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/contributors/people.toml b/contributors/people.toml
index d522b84e0..f18fcfafa 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -65,3 +65,8 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.marcoarnulfo]
+real_name = "Marco Arnulfo"
+display_name = "Marco Arnulfo"
+sync_github_name = false
From c5baa829e3202990c22a2c34b74626fb75e7f457 Mon Sep 17 00:00:00 2001
From: Bryan Mark
Date: Fri, 16 Oct 2026 14:26:53 +0900
Subject: [PATCH 06/17] Memory: interface fonts are read once and shared with
egui, not copied into it
egui copies an owned font's bytes into its text engine (epaint's
blob_from_font_data clones the Cow) and keeps the original FontData too,
so every interface and script fallback font was in memory twice: about
100 MB on a Mac, where the fallbacks alone (Hiragino Sans GB, Hiragino
Sans, Apple SD Gothic Neo, Thonburi...) are 89 MB of files. try_load now
keeps each file it reads for the rest of the run and hands egui borrowed
'static bytes, which it shares. These faces are loaded by every font
rebuild anyway, so keeping them costs nothing, and rebuilds no longer
read them from disk again. A missing or broken file is looked for again
next time. Which faces load, and their order, are unchanged; workbook
fonts (bind_faces) load as before.
Measured on macOS 27 (Apple M3 Max), offscreen snapshot held after its
picture, settled footprint: empty workbook 258 -> 158 MB, the sample
workbook 261 -> 160 MB, a cell in five scripts 258 -> 158 MB; the
rendered pictures are byte-for-byte the same.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 4 ++-
crates/ui-egui/src/theme.rs | 51 ++++++++++++++++++++++++++++++++-----
2 files changed, 48 insertions(+), 7 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 070552d07..648137851 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** minor (interface fonts held once instead of twice) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
+| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
@@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
+| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
index 485b9a2f3..e39ba89e3 100644
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -209,12 +209,8 @@ pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool {
#[cfg(not(target_arch = "wasm32"))]
fn try_load(paths: &[(&str, u32)]) -> Option {
for (p, index) in paths {
- if let Ok(bytes) = std::fs::read(p) {
- if !font_parses(&bytes, *index) {
- log::warn!("skipping font {p} (face {index}): not a font file egui can read");
- continue;
- }
- let mut fd = FontData::from_owned(bytes);
+ if let Some(bytes) = font_file(p, *index) {
+ let mut fd = FontData::from_static(bytes);
fd.index = *index;
return Some(fd);
}
@@ -222,6 +218,30 @@ fn try_load(paths: &[(&str, u32)]) -> Option {
None
}
+/// The interface and script fallback font files, each read once per run and kept for the rest of
+/// it. egui copies an owned font's bytes into its text engine and keeps the original too
+/// (`epaint`'s `blob_from_font_data`), so every one of these files was in memory twice, about
+/// 100 MB on a Mac; bytes it borrows for `'static` it shares instead. These faces are loaded by
+/// every font rebuild for as long as the app runs, so keeping them costs nothing, and rebuilds no
+/// longer read them from disk again. Only a file that parses is kept: a missing or broken one is
+/// looked for again next time.
+#[cfg(not(target_arch = "wasm32"))]
+fn font_file(path: &str, index: u32) -> Option<&'static [u8]> {
+ static FILES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new());
+ let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+ if let Some(&bytes) = files.get(path) {
+ return font_parses(bytes, index).then_some(bytes);
+ }
+ let bytes = std::fs::read(path).ok()?;
+ if !font_parses(&bytes, index) {
+ log::warn!("skipping font {path} (face {index}): not a font file egui can read");
+ return None;
+ }
+ let bytes: &'static [u8] = Box::leak(bytes.into_boxed_slice());
+ files.insert(path.to_string(), bytes);
+ Some(bytes)
+}
+
/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not
/// parse (and its key from every family), so a bad font costs a fallback, never a crash.
pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) {
@@ -934,4 +954,23 @@ mod tests {
assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len()));
let _ = std::fs::remove_dir_all(&dir);
}
+
+ #[cfg(not(target_arch = "wasm32"))]
+ #[test]
+ fn interface_fonts_are_read_once_and_shared_with_egui() {
+ // egui copies owned font bytes into its text engine and keeps the original too; borrowed
+ // bytes it shares. A rebuild of the font set reuses the file read the first time.
+ let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id()));
+ std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap();
+ let p = path.to_string_lossy().into_owned();
+ let first = try_load(&[(p.as_str(), 0)]).unwrap();
+ std::fs::remove_file(&path).unwrap();
+ let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read");
+ let (std::borrow::Cow::Borrowed(a), std::borrow::Cow::Borrowed(b)) = (&first.font, &second.font) else {
+ panic!("interface font bytes must be borrowed, so egui shares them instead of copying");
+ };
+ assert!(std::ptr::eq(*a, *b));
+ assert_eq!(*a, epaint_default_fonts::UBUNTU_LIGHT);
+ assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused");
+ }
}
From 042ac7060faf558aff444ee2dddb077d6a4addc5 Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Fri, 16 Oct 2026 16:50:12 +0200
Subject: [PATCH 07/17] =?UTF-8?q?macOS:=20bold=20interface=20text=20draws?=
=?UTF-8?q?=20Cyrillic=20=D0=B5=20open,=20not=20like=20=D3=A9=20(fixes=20#?=
=?UTF-8?q?1184)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
crates/ui-egui/src/theme.rs | 22 +++++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
index 485b9a2f3..2ad0070d3 100644
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -397,7 +397,12 @@ pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -
let paths = candidates(role);
let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect();
let mut chain: Vec = Vec::new();
- if let Some(fd) = try_load(&refs) {
+ if let Some(mut fd) = try_load(&refs) {
+ // Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina
+ // screen (#1184). Core Text never runs them, so this draws it the way macOS does.
+ if cfg!(target_os = "macos") && role == UI_BOLD {
+ fd.tweak.hinting = Some(false);
+ }
let key = format!("sys-{role}");
fonts.font_data.insert(key.clone(), Arc::new(fd));
chain.push(key);
@@ -934,4 +939,19 @@ mod tests {
assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len()));
let _ = std::fs::remove_dir_all(&dir);
}
+
+ #[test]
+ fn macos_bold_interface_face_is_drawn_unhinted() {
+ // #1184: Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a
+ // Retina screen. Core Text never runs them, so on macOS the face goes unhinted like every
+ // other app draws it; the regular face and the other platforms keep egui's default.
+ let fonts = font_definitions_for_language(Language::En);
+ let hinting = |key: &str| fonts.font_data.get(key).map(|fd| fd.tweak.hinting);
+ if let Some(bold) = hinting(&format!("sys-{UI_BOLD}")) {
+ assert_eq!(bold, if cfg!(target_os = "macos") { Some(false) } else { None });
+ }
+ if let Some(regular) = hinting(&format!("sys-{UI}")) {
+ assert_eq!(regular, None, "only the bold face is affected");
+ }
+ }
}
From 6adb26c719979c33ec83e30159b5ee220255ffa9 Mon Sep 17 00:00:00 2001
From: Harrison Booth
Date: Fri, 16 Oct 2026 10:44:57 -0500
Subject: [PATCH 08/17] contributors: add Harrison-Booth
Co-Authored-By: Claude Sonnet 5.5
---
contributors/people.toml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/contributors/people.toml b/contributors/people.toml
index d522b84e0..d1b2d8c22 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -65,3 +65,7 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.Harrison-Booth]
+real_name = "Harrison Booth"
+sync_github_name = false
From 553972255700f8db4a8366c11a0299f3dcd2d09d Mon Sep 17 00:00:00 2001
From: tydude001
Date: Fri, 16 Oct 2026 15:30:07 -0500
Subject: [PATCH 09/17] Fonts: find the cell, serif, mono, Thai and Hebrew
faces in Fedora's package folders (#1694)
Fedora keeps each font family in its own package folder, and the cell, serif, mono, Thai and
Hebrew candidates only listed the Debian/Ubuntu/Arch folders, so no cell face was found there and
bold and italic cells drew as regular text in the built-in font. Add Fedora's folders after the
existing Linux entries for each font, as #54 did for the UI fonts, and let mono fall back to
Liberation Mono and then Fedora's default Noto Sans Mono, since DejaVu Sans Mono is optional there.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/theme.rs | 51 +++++++++++++++++++++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
index 485b9a2f3..789960c37 100644
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -325,6 +325,8 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
add(format!("{d}/crosextra/{carlito}"), 0);
add(format!("{d}/carlito/{carlito}"), 0);
}
+ // Fedora keeps each family in its own package folder, as for the UI fonts (#54).
+ add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0);
add(home_fonts(carlito), 0);
add(format!("{mac_sup}/{arial_mac}"), 0);
add(format!("{win}\\{arial_win}"), 0);
@@ -332,6 +334,8 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
add(format!("{d}/liberation/{lib}"), 0);
add(format!("{d}/dejavu/{dejavu}"), 0);
}
+ add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0);
+ add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0);
}
"serif" => {
add(format!("{mac_sup}/Times New Roman.ttf"), 0);
@@ -340,6 +344,8 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
add(format!("{d}/liberation/LiberationSerif-Regular.ttf"), 0);
add(format!("{d}/dejavu/DejaVuSerif.ttf"), 0);
}
+ add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0);
+ add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0);
}
"mono" => {
add(format!("{mac_sys}/SFNSMono.ttf"), 0);
@@ -348,6 +354,14 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
for d in lin {
add(format!("{d}/dejavu/DejaVuSansMono.ttf"), 0);
}
+ add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0);
+ // Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice
+ // (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always.
+ for d in lin {
+ add(format!("{d}/liberation/LiberationMono-Regular.ttf"), 0);
+ }
+ add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0);
+ add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0);
}
_ => {}
}
@@ -593,6 +607,8 @@ fn thai() -> Vec<(String, u32)> {
v.push((format!("{d}/tlwg/Garuda.ttf"), 0));
v.push((format!("{d}/garuda/Garuda.ttf"), 0));
}
+ // Fedora ships the variable Noto Sans Thai; its default instance is Regular.
+ v.push(("/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf".into(), 0));
v
}
@@ -619,6 +635,7 @@ fn hebrew() -> Vec<(String, u32)> {
v.push((format!("{d}/dejavu/DejaVuSans.ttf"), 0));
v.push((format!("{d}/freefont/FreeSans.ttf"), 0));
}
+ v.push(("/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf".into(), 0));
v
}
@@ -934,4 +951,38 @@ mod tests {
assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len()));
let _ = std::fs::remove_dir_all(&dir);
}
+
+ #[test]
+ fn fedora_package_folders_are_searched_after_the_other_linux_layouts() {
+ // #1694: Fedora keeps each font family in its own package folder, so without these entries
+ // no cell face is found there and bold and italic cells draw as regular text. Each comes
+ // after the existing Linux folders for the same font, so wherever one of those matched,
+ // nothing changes.
+ let paths = |cands: Vec<(String, u32)>| cands.into_iter().map(|(p, _)| p).collect::>();
+ let cases: [(&str, Vec, &str, &str); 9] = [
+ (CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"),
+ (CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"),
+ (CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"),
+ (CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"),
+ (CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"),
+ (SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"),
+ (MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"),
+ ("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"),
+ ("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"),
+ ];
+ for (role, list, fedora_file, earlier) in &cases {
+ let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}"));
+ let last_earlier = list.iter().rposition(|p| p.ends_with(earlier));
+ assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate");
+ assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with");
+ assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}");
+ }
+ // Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed.
+ let mono = paths(candidates(MONO));
+ let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf"));
+ let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf"));
+ assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}");
+ let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf");
+ assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}");
+ }
}
From fac8f858cba9173d6d40653ab1281e330c30f439 Mon Sep 17 00:00:00 2001
From: Keooudone Nanhthavilay
Date: Sat, 17 Oct 2026 17:39:11 +0700
Subject: [PATCH 10/17] Fonts: render Lao text with an installed Lao face
instead of tofu
egui's bundled fonts have no Lao, and none of the script fallback faces
(CJK, Thai, Hebrew, symbols) carry it on macOS, so Lao text in a cell,
the formula bar or the interface drew as boxes unless the cell's own font
had Lao glyphs. Add a `sys-lao` fallback: Leelawadee UI / DokChampa on
Windows, Lao Sangam MN / Lao MN on macOS, Noto Sans Lao / Phetsarath OT
on Linux. `fallback_covers_its_script` now checks a Lao sample.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/theme.rs | 28 +++++++++++++++++++++++++---
docs/localization-parity.md | 6 ++++--
2 files changed, 29 insertions(+), 5 deletions(-)
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
index 485b9a2f3..787a59b82 100644
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -491,6 +491,7 @@ fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> {
second,
("sys-cjk-hangul", cjk_hangul()),
("sys-thai", thai()),
+ ("sys-lao", lao()),
("sys-hebrew", hebrew()),
("sys-symbols", symbols()),
("sys-symbols-2", symbols_more()),
@@ -596,6 +597,26 @@ fn thai() -> Vec<(String, u32)> {
v
}
+/// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam
+/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT.
+#[cfg(not(target_arch = "wasm32"))]
+fn lao() -> Vec<(String, u32)> {
+ let mac_sup = "/System/Library/Fonts/Supplemental";
+ let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
+ let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"];
+ let mut v: Vec<(String, u32)> = Vec::new();
+ v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
+ v.push((format!("{win}\\dokchamp.ttf"), 0)); // DokChampa
+ v.push((format!("{mac_sup}/Lao Sangam MN.ttf"), 0));
+ v.push((format!("{mac_sup}/Lao MN.ttc"), 0));
+ for d in lin {
+ v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0));
+ v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0));
+ v.push((format!("{d}/Phetsarath_OT.ttf"), 0));
+ }
+ v
+}
+
/// Hebrew faces. Windows has David and FrankRuehl (and Tahoma/Courier New carry Hebrew); macOS has
/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans.
#[cfg(not(target_arch = "wasm32"))]
@@ -815,11 +836,11 @@ mod tests {
let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>();
assert_eq!(
keys(HanOrder::Japanese),
- ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
+ ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
);
assert_eq!(
keys(HanOrder::Chinese),
- ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
+ ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
);
assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese);
for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs] {
@@ -848,11 +869,12 @@ mod tests {
// Real render check, per script: the proportional family (what cells and the UI use) must
// resolve a sample string to a real glyph, not the tofu replacement char. Skips a script
// whose face is absent (a minimal machine) and fails if a face is present but not wired in.
- let cases: [(&str, Vec<(String, u32)>, &str); 7] = [
+ let cases: [(&str, Vec<(String, u32)>, &str); 8] = [
("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"),
("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"),
("sys-cjk-hangul", cjk_hangul(), "한글"),
("sys-thai", thai(), "ทดสอบ"),
+ ("sys-lao", lao(), "ພາສາລາວ"),
("sys-hebrew", hebrew(), "עברית"),
("ui", candidates(UI), "Русский"),
// Czech interface labels: every Czech accented letter, in both cases.
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 09197ff6c..49ef1059f 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-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Lao system-font fallback) · **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,
@@ -26,7 +26,8 @@ right-to-left sheets and full complex-script support. Part of
localized function names and `;` separators (#45). German with localized numbers and formulas
is in PR #163 (open).
- **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32;
- #142 is still open pending confirmation). There is no complex-text shaping: Arabic and
+ #142 is still open pending confirmation); Lao falls back to Lao Sangam MN / Lao MN (macOS),
+ Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). There is no complex-text shaping: Arabic and
Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left**
(`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`) mirrors the grid (#146):
column A and the row headers on the right, the vertical scrollbar on the left; hit-testing,
@@ -78,6 +79,7 @@ language (none so far) and of localized function names.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-17 | minor | Lao text falls back to an installed Lao face (`sys-lao`) instead of tofu boxes |
| 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them |
| 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) |
| 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open |
From 5f2fb705b8c41b37d2e22ae8b8a803821045ee09 Mon Sep 17 00:00:00 2001
From: Francisco Herrera
Date: Sun, 18 Oct 2026 16:42:19 -0500
Subject: [PATCH 11/17] Docs: correct stale chart round-trip notes
---
docs/chart-parity.md | 17 +++++++++--------
docs/gaps.md | 9 +++++----
docs/roadmap.md | 2 +-
docs/target-app-parity.md | 5 +++--
4 files changed, 18 insertions(+), 15 deletions(-)
diff --git a/docs/chart-parity.md b/docs/chart-parity.md
index 9058b0935..3a1d688d2 100644
--- a/docs/chart-parity.md
+++ b/docs/chart-parity.md
@@ -1,13 +1,13 @@
# Chart parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (chart sheets are kept on save, still not editable (#727); previously minor: trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (stale chart notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip; PRs #158 and #161 merged) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's chart types and chart elements against GridCraft. Part of
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crates/chart/src`
-(layout and rendering), `crates/xlsx/src/chart.rs` (DrawingML chart parts), merged PRs #62 and #116,
-open PRs #158, #161, and issue #190.
+(layout and rendering), `crates/xlsx/src/chart.rs` (DrawingML chart parts), merged PRs #62, #116,
+#158 and #161, and issue #190.
## Chart types
@@ -20,13 +20,13 @@ open PRs #158, #161, and issue #190.
| Area | area, stacked, 100%, 3-D ×3 | 2 of 6 | no 100%, no 3-D |
| X Y (scatter) | markers, smooth lines (±markers), straight lines (±markers) | 2 of 5 | no smooth lines |
| Bubble | bubble, 3-D bubble | 1 of 2 | |
-| Stock | high-low-close, open-high-low-close, volume-HLC, volume-OHLC | 1 kind | combo/stock lose their kind in XLSX (PR #158) |
+| Stock | high-low-close, open-high-low-close, volume-HLC, volume-OHLC | 1 kind | |
| Surface | 3-D surface, wireframe, contour, wireframe contour | 0 of 4 | |
| Radar | radar, with markers, filled | 1 of 3 | |
-| Treemap, Sunburst | 1 each | 2 of 2 | lose their kind in XLSX (PR #161) |
+| Treemap, Sunburst | 1 each | 2 of 2 | |
| Histogram, Pareto | 2 | 1 of 2 | no Pareto |
-| Box & Whisker | 1 | 1 of 1 | loses its kind in XLSX (PR #161) |
-| Waterfall, Funnel | 2 | 2 of 2 | lose their kind in XLSX (PR #161) |
+| Box & Whisker | 1 | 1 of 1 | |
+| Waterfall, Funnel | 2 | 2 of 2 | |
| Filled map | 1 | 0 | needs geographic boundary data (licensing decision) |
| Combo | custom combinations, secondary axis | yes (line on secondary axis) | |
| **Total** | ~60 subtypes | **25 (~42%)** | usage-weighted breadth higher (~65%): column, bar, line, pie and scatter dominate |
@@ -68,7 +68,7 @@ back.
## Work (40–60 h)
-1. Kinds survive XLSX round-trip (merge PRs #158, #161): 2–4 h. Chart resizing (#190): 1–2 h.
+1. Chart resizing (#190): 1–2 h.
2. Preserve unmodelled chart XML on round-trip (keep the original part, patch only edited fields): 6–10 h.
3. Axis options, label options, gridlines per axis, legend formatting: 10–15 h.
4. Error bars; Format Trendline pane (trendlines landed): 4–6 h.
@@ -80,6 +80,7 @@ back.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-18 | trivial | Stale notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip (PRs #158, #161) |
| 2026-10-12 | trivial | chart sheets are kept on save, still not editable (#727) |
| 2026-10-11 | minor | Trendlines: all six types modelled, round-tripped through `c:trendline`, drawn with forecast and equation/R² labels; `chart.addElement` `trendline` |
| 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) |
diff --git a/docs/gaps.md b/docs/gaps.md
index 03e8672c5..6f64d16a7 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -42,11 +42,12 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
- **Impact:** open-edit-save still simplifies workbooks with slicers, timelines or Power Pivot.
- **Estimate:** 6–10 h. [file-format-parity.md](file-format-parity.md)
-### 4. Charts lose type and options on round trip; shallow chart model (F, FF)
-- **Missing:** kinds that survive XLSX (stock, combo, histogram, box, waterfall, funnel, treemap,
- sunburst), axis scaling, label options, error bars, per-point formatting.
+### 4. Charts lose options on round trip; shallow chart model (F, FF)
+- **Missing:** axis scaling, label options, error bars, per-point formatting.
- **Evidence:** `Chart` holds only title, legend position, two flags and axis titles
- (`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed.
+ (`crates/model/src/features.rs`); all 2-D kinds survive XLSX round trip (#158, #161); charts
+ can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read,
+ written, drawn) landed.
- **Impact:** charts are in most business workbooks.
- **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md)
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 062dfc708..4894d77b1 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -28,7 +28,7 @@ workflow, would make GridCraft pre-alpha.
| Open a colleague's XLSX, edit, save, hand it back | partial, not blocking | Typical workbooks open and save with cells, styles, CF, validation, tables, names and pivots; unmodelled parts are dropped with a warning (gap #3); Excel-side repair prompts unverified (gap #1); ~100 MB files fail (#175) | 0 for alpha (gaps #1–#3 are beta gates) |
| Format and present: number formats, styles, conditional formatting, print or PDF | yes | Full number-format language, 47 cell styles, all CF kinds, Page Break Preview, PDF export and printing. PDF can't carry non-Latin text (gap #5) | 0 |
| Analyze: sort, filter, tables, PivotTable | yes | Multi-level sort, AutoFilter, tables with totals, PivotTables with field list, layouts, grouping, refresh and XLSX round-trip. Calculated fields done; slicers missing (gap #8) | 0 |
-| Chart the data | partial, not blocking | Column, bar, line, pie, area, scatter and combo charts are created, edited and survive save and reopen; histogram, box, waterfall, funnel, treemap, sunburst and stock lose their kind in XLSX (fix in PRs #158, #161); no axis options or trendlines (gap #4) | 0 for alpha (2–4 h to merge the kind fixes) |
+| Chart the data | partial, not blocking | Column, bar, line, pie, area, scatter, combo, histogram, box, waterfall, funnel, treemap, sunburst and stock charts are created, edited and survive save and reopen; trendlines are drawn; no axis options (gap #4) | 0 for alpha |
| Import and export CSV | yes | Encoding and delimiter detection on read, CSV/TSV write; exports raw rather than displayed values (gap #19) | 0 |
**Passes:** every core workflow works end to end on macOS with save and reopen; the two partial
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index b623124d9..948a9048f 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# Target-app parity: GridCraft vs Microsoft Excel
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and row formatting) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (stale chart notes: kinds survive XLSX round-trip and trendlines landed) · **Target:** Microsoft Excel (Microsoft 365)
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
@@ -85,7 +85,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
| Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes |
| Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables |
| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, values on rows or columns, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel |
-| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) |
+| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Kinds survive XLSX round trip (#158, #161). [chart-parity.md](chart-parity.md) |
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; no Sensitivity report or options dialog). Missing: Forecast Sheet, Analyze Data |
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
@@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
| Date | Change | Summary |
|---|---|---|
+| 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed |
| 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) |
| 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change |
| 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) |
From 92e1e969979ee9a7c51c2dee1c48d85ef579d190 Mon Sep 17 00:00:00 2001
From: Hamza
Date: Sat, 17 Oct 2026 14:11:29 +0500
Subject: [PATCH 12/17] =?UTF-8?q?Docs:=20=C2=A76's=20calculation=20bugs=20?=
=?UTF-8?q?have=20all=20landed?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
gaps.md §6 still listed four things as missing and function-parity.md still
marked 15-significant-digit comparison as missing, citing #57 — all five
reports are closed and their PRs merged. The work list reads as the place to
pick work from, so a closed entry sends someone after work that is done.
Checked by behaviour, not by issue state, where that was possible:
=0.1+0.2=0.3 -> TRUE
=(0.1+0.2)-0.3 -> 5.55E-17 (full precision kept,
as in Excel: the 15-digit rule
is for comparison)
=MATCH(0.3,{0.30000000000000004},0) -> 1
=COUNTIF({0.30000000000000004},0.3) -> 1
a spill blocked and then unblocked -> #SPILL! then the spill again,
with its dependent back to 6
and by their regression tests for the other two:
`deleting_a_3d_span_end_sheet_keeps_the_rest` (#140) and
`sheet_operations_keep_names_on_their_sheets` (#134).
§6 is left open rather than deleted: it says wrong numbers are the worst kind of
bug, and five closed reports are not the same as the category being clear —
that needs §15's oracle suite. The function counts are untouched; this is
formula-language semantics, not the inventory.
---
docs/function-parity.md | 11 ++++++-----
docs/gaps.md | 17 ++++++++++-------
2 files changed, 16 insertions(+), 12 deletions(-)
diff --git a/docs/function-parity.md b/docs/function-parity.md
index 2343e0de9..8ac2b197b 100644
--- a/docs/function-parity.md
+++ b/docs/function-parity.md
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
-> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (MEDIAN and AGGREGATE median avoid intermediate overflow) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-17 · **Change:** minor (15-significant-digit comparison done; three resolved caveats cleared) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -72,16 +72,16 @@ PIVOTBY, REGEXTEST…) are all present.
| Feature | Status |
|---|---|
| A1 and R1C1 references, absolute/relative/mixed, F4 cycling | done |
-| 3-D references (`Sheet1:Sheet3!A1`) | done; endpoint-deletion bug (#140) |
+| 3-D references (`Sheet1:Sheet3!A1`) | done; deleting a span's end sheet keeps the rest (#140) |
| Structured references (`Table1[Col]`, `[#Totals]`, `@`; the unbracketed `[[#This Row],Col]` some writers emit) | done |
| Defined names (workbook and sheet scope), Create from Selection | done |
-| Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) |
+| Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale spills after a structural edit or a cleared blocker fixed (#133, #139; PRs #148, #157) |
| Recalculation after clearing large sparse ranges | every stored cell changed by Clear is reported to recalculation, without a million-coordinate cutoff; removed formula spills are cleared, and removing empty merges still unblocks spills (#1032) |
-| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
+| Implicit intersection `@`, legacy CSE array formulas | done, the entered range filled (PR #156) |
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done |
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened |
-| 15-significant-digit comparison semantics | **missing** (#57: `=0.1+0.2=0.3` is FALSE) |
+| 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 |
| External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update |
| Localized function names and `;` separators | **missing** (#45); en-US only |
| Error values | the seven classic errors plus `#SPILL!`, `#CALC!`, `#GETTING_DATA` done; `#FIELD!`, `#BLOCKED!`, `#CONNECT!`, `#BUSY!`, `#UNKNOWN!`, `#PYTHON!` missing (minor: they come from data types, cloud and Python features) |
@@ -114,6 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
+| 2026-10-17 | minor | 15-significant-digit comparison moved from missing to done (#57), and the resolved caveats on the 3-D reference, dynamic-array and legacy-array rows cleared (#140, #133, #139, #156) |
| 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation |
| 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) |
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
diff --git a/docs/gaps.md b/docs/gaps.md
index 03e8672c5..8ad64058e 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Gaps
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (§6's five reports have all landed) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open
@@ -59,12 +59,14 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
- **Estimate:** 12–20 h. [localization-parity.md](localization-parity.md)
### 6. Calculation correctness bugs (F)
-- **Missing:** 15-significant-digit equality; spill updates after structural edits and cleared
- blockers; 3-D span deletion; qualified names after sheet rename.
-- **Evidence:** #57, #133, #139, #140, #134; PRs #148, #155, #156, #157. Iterative calculation
- for circular references landed (no Options dialog yet).
-- **Impact:** wrong numbers are worse than missing features.
-- **Estimate:** 10–15 h. [function-parity.md](function-parity.md#formula-language)
+- **Missing:** nothing known. The four this entry tracked have landed: 15-significant-digit
+ equality (#57, so `=0.1+0.2=0.3` is TRUE and MATCH and COUNTIF agree), spill updates after
+ structural edits and cleared blockers (#133, #139; PRs #148, #157), 3-D span deletion (#140),
+ and qualified names after a sheet rename (#134). Iterative calculation for circular references
+ landed too (no Options dialog yet, which is §13's).
+- **Impact:** wrong numbers are worse than missing features, so this entry is kept until the
+ oracle suite of §15 can say the category is clear rather than only these five reports being so.
+- **Estimate:** — [function-parity.md](function-parity.md#formula-language)
### 7. Legacy `.xls` only partly read; encrypted workbooks can't be saved with a password (FF)
- **Evidence:** #121: Excel 97-2003 (BIFF8) `.xls` files now open (values, formulas, number
@@ -168,6 +170,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
+| 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite |
| 2026-10-12 | minor | rows auto-fit oversized fonts on open and whole-row/column formatting (#833) |
| 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder |
| 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) |
From 1abb771f58cf1cbba1f02ee5530a6ca5b9a463f1 Mon Sep 17 00:00:00 2001
From: Zaxaerith <189452125+Zaxaerith@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:43:15 +0800
Subject: [PATCH 13/17] docs: fix Trunk website link (#2076)
Co-Authored-By: Codex
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index ef9b906fa..2c224559c 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,7 @@ cd apps/gridcraft-web && trunk build --release # → dist/web (serve it with
cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sample=sales opens a sample)
```
-You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the
+You need [trunk](https://trunk-rs.github.io/trunk/) and the `wasm32-unknown-unknown` target. Open uses the
browser's file picker (dropping files works too) and Save downloads the workbook.
### Ribbon access keys
From a4a36e8460e68aca2e25e121f40b497305beb25b Mon Sep 17 00:00:00 2001
From: chessive
Date: Wed, 21 Oct 2026 14:45:39 +0900
Subject: [PATCH 14/17] Fonts: a cell font named in another language matches
its installed font (fixes #1898)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Korean Excel saves a cell's font under its Korean name (맑은 고딕 for
Malgun Gothic), but the font scan kept one family name per face, the
English one, so those cells were drawn in the default face. Each face now
keeps its typographic and legacy family names in every language, and a
workbook font is bound under the name the workbook uses, which is the
name cell_family looks up. A name two families share goes to the family
whose English name it is, else to the first face by path, so the scan's
directory order no longer decides it. Names bound under two spellings
share one copy of the font data.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/fonts.rs | 260 +++++++++++++++++++++++++++++++-----
1 file changed, 229 insertions(+), 31 deletions(-)
diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs
index d5a8ea1d9..1a381b9d4 100644
--- a/crates/ui-egui/src/fonts.rs
+++ b/crates/ui-egui/src/fonts.rs
@@ -19,11 +19,20 @@ use std::sync::OnceLock;
#[cfg(not(target_arch = "wasm32"))]
const MAX_FONT_FILE: u64 = 64 << 20;
+/// A face keeps at most this many family names, so a hostile `name` table can't grow the catalog.
+#[cfg(not(target_arch = "wasm32"))]
+const MAX_FAMILY_NAMES: usize = 16;
+
/// One face of one installed font file.
#[derive(Clone, Debug)]
pub struct Face {
- /// Typographic family name, e.g. `"Segoe UI"`.
+ /// Typographic family name, e.g. `"Segoe UI"` (the English one when the font has it): what the
+ /// font list shows.
pub family: String,
+ /// Every family name the face carries, `family` first, then each language's typographic and
+ /// legacy family names (`"Malgun Gothic"`, `"맑은 고딕"`). A workbook may name the font by any of
+ /// them: Korean Excel saves `맑은 고딕` (#1898).
+ pub names: Vec,
/// The file the face lives in.
pub path: PathBuf,
/// Face index within the file (collections hold several, e.g. a family's Regular and Bold).
@@ -88,6 +97,13 @@ fn style_weight(style: &str) -> f32 {
TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0)
}
+impl Face {
+ /// Whether the face goes by `name` in any language.
+ pub fn answers_to(&self, name: &str) -> bool {
+ self.names.iter().any(|n| n.eq_ignore_ascii_case(name))
+ }
+}
+
/// Whether a style name reads as italic or oblique.
#[cfg(not(target_arch = "wasm32"))]
fn style_italic(style: &str) -> bool {
@@ -95,9 +111,10 @@ fn style_italic(style: &str) -> bool {
s.contains("italic") || s.contains("oblique")
}
-/// Every face in `data` as `(index, family, style)`. Damaged data yields nothing, never a panic.
+/// Every face in `data` as `(index, family, names, style)`, where `names` holds every family name
+/// (see [`Face::names`]). Damaged data yields nothing, never a panic.
#[cfg(not(target_arch = "wasm32"))]
-fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> {
+fn file_faces(data: &[u8]) -> Vec<(u32, String, Vec, String)> {
use skrifa::raw::FileRef;
use skrifa::{MetadataProvider, string::StringId};
let count = match FileRef::new(data) {
@@ -108,12 +125,23 @@ fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> {
let name = |f: &skrifa::FontRef<'_>, ids: &[StringId]| -> Option {
ids.iter().find_map(|id| f.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
};
+ let family_ids = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME];
(0..count)
.filter_map(|i| {
let f = skrifa::FontRef::from_index(data, i).ok()?;
- let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
+ let family = name(&f, &family_ids)?;
+ let mut names = vec![family.clone()];
+ for s in family_ids.iter().flat_map(|id| f.localized_strings(*id)) {
+ if names.len() >= MAX_FAMILY_NAMES {
+ break;
+ }
+ let s = s.to_string();
+ if !s.is_empty() && !names.iter().any(|n| n.eq_ignore_ascii_case(&s)) {
+ names.push(s);
+ }
+ }
let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
- Some((i, family, style))
+ Some((i, family, names, style))
})
.collect()
}
@@ -143,8 +171,8 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec {
continue;
}
let Ok(data) = std::fs::read(&path) else { continue };
- for (index, family, style) in file_faces(&data) {
- out.push(Face { family, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) });
+ for (index, family, names, style) in file_faces(&data) {
+ out.push(Face { family, names, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) });
}
}
}
@@ -188,29 +216,50 @@ pub fn scan_done() -> bool {
CATALOG.get().is_some()
}
-/// The faces of one installed family, regular-weight first (the order roles pick from).
+/// The faces of one installed family, by any of its names, regular-weight first (the order roles
+/// pick from).
pub fn faces_of(family: &str) -> Vec {
- let Some(cat) = catalog_now() else { return Vec::new() };
- let mut v: Vec = cat.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).cloned().collect();
+ catalog_now().map(|cat| faces_named(cat, family)).unwrap_or_default()
+}
+
+/// The faces in `cat` that go by `wanted`. When two families claim one name the winner is fixed,
+/// not left to the scan's directory order: a family whose own (English) name it is wins, else the
+/// family of the first claiming face by path and index. Only the faces that carry the name are
+/// kept, so a legacy name (`"Arial Black"`, name ID 1) picks its own face out of its family.
+fn faces_named(cat: &[Face], wanted: &str) -> Vec {
+ let mut v: Vec = if cat.iter().any(|f| f.family.eq_ignore_ascii_case(wanted)) {
+ cat.iter().filter(|f| f.family.eq_ignore_ascii_case(wanted)).cloned().collect()
+ } else {
+ let owner = cat.iter().filter(|f| f.answers_to(wanted)).min_by(|a, b| a.path.cmp(&b.path).then(a.index.cmp(&b.index)));
+ match owner {
+ Some(o) => cat.iter().filter(|f| f.family.eq_ignore_ascii_case(&o.family) && f.answers_to(wanted)).cloned().collect(),
+ None => Vec::new(),
+ }
+ };
v.sort_by(|a, b| a.weight.total_cmp(&b.weight).then(a.italic.cmp(&b.italic)).then(a.index.cmp(&b.index)));
v
}
/// The subset of `names` that is installed, sorted and deduped. These are the families whose files
-/// get bound for a workbook.
+/// get bound for a workbook, under the name the workbook uses: that is the name
+/// [`cell_family`](crate::theme::cell_family) looks up, so `맑은 고딕` binds as `맑은 고딕`, not as
+/// the catalog's `Malgun Gothic`.
pub fn installed_among(names: &[String]) -> Vec {
- let Some(cat) = catalog_now() else { return Vec::new() };
+ catalog_now().map(|cat| installed_in(cat, names)).unwrap_or_default()
+}
+
+/// [`installed_among`] over a given catalog. Names dedupe exactly, not case-folded: a role key is
+/// built from the cell's exact string, so `Calibri` and `calibri` each need their own binding (the
+/// font data behind them is shared, see [`face_key`]).
+fn installed_in(cat: &[Face], names: &[String]) -> Vec {
let mut out: Vec = Vec::new();
- let mut seen: HashSet = HashSet::new();
+ let mut seen: HashSet<&str> = HashSet::new();
for wanted in names {
- if !seen.insert(wanted.to_lowercase()) {
- continue;
- }
- if let Some(f) = cat.iter().find(|f| f.family.eq_ignore_ascii_case(wanted)) {
- out.push(f.family.clone());
+ if seen.insert(wanted.as_str()) && cat.iter().any(|f| f.answers_to(wanted)) {
+ out.push(wanted.clone());
}
}
- out.sort_by_key(|a| a.to_lowercase());
+ out.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
out
}
@@ -254,13 +303,14 @@ fn pick_face(faces: &[Face], bold: bool, italic: bool) -> Option<&Face> {
.or_else(|| faces.first())
}
-/// A stable key for a face's font data, unique per file/index within the process.
-fn face_key(family: &str, face: &Face) -> String {
- let file = face.path.file_name().and_then(|s| s.to_str()).unwrap_or("font");
- format!("fnt-src:{family}:{file}:{}", face.index)
+/// A stable key for a face's font data, one per file and index: every name a family is bound under
+/// (`Malgun Gothic`, `맑은 고딕`) shares the data instead of loading the file again.
+fn face_key(face: &Face) -> String {
+ format!("fnt-src:{}:{}", face.path.display(), face.index)
}
-/// Bind one installed family's faces into `fonts`, one egui family per cell role, and return the
+/// Bind one installed family's faces into `fonts` under `family` (the name the workbook uses), one
+/// egui family per cell role, and return the
/// role keys that are now bound. A role with no exact face chains to the family's regular face, so
/// no role is ever left pointing at missing data (egui panics on that). A family with no faces
/// binds nothing.
@@ -278,14 +328,16 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face
let mut chain: Vec = Vec::new();
for face in [Some(primary), pick_face(faces, false, false)] {
let Some(face) = face else { continue };
- let key = face_key(family, face);
+ let key = face_key(face);
if chain.contains(&key) {
continue;
}
- let Ok(bytes) = std::fs::read(&face.path) else { continue };
- let mut fd = egui::FontData::from_owned(bytes);
- fd.index = face.index;
- fonts.font_data.entry(key.clone()).or_insert_with(|| std::sync::Arc::new(fd));
+ if !fonts.font_data.contains_key(&key) {
+ let Ok(bytes) = std::fs::read(&face.path) else { continue };
+ let mut fd = egui::FontData::from_owned(bytes);
+ fd.index = face.index;
+ fonts.font_data.insert(key.clone(), std::sync::Arc::new(fd));
+ }
chain.push(key);
}
if chain.is_empty() {
@@ -299,7 +351,7 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face
bound
}
-/// Bind every installed face of `family` (see [`bind_faces`]).
+/// Bind every installed face of `family`, found by any of its names (see [`bind_faces`]).
pub fn bind_family(fonts: &mut egui::FontDefinitions, family: &str) -> Vec {
bind_faces(fonts, family, &faces_of(family))
}
@@ -407,7 +459,7 @@ mod tests {
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("Ubuntu-Light.ttf");
std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write");
- let face = Face { family: "Ubuntu".into(), path, index: 0, weight: 300.0, italic: false };
+ let face = Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into()], path, index: 0, weight: 300.0, italic: false };
let mut fonts = egui::FontDefinitions::default();
let bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&face));
@@ -420,4 +472,150 @@ mod tests {
assert!(bind_faces(&mut fonts, "No Such Font", &[]).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// A font that is just a table directory and a `name` table holding `records` as
+ /// `(Windows language id, name id, text)`: all [`file_faces`] reads.
+ #[cfg(not(target_arch = "wasm32"))]
+ fn font_with_names(records: &[(u16, u16, &str)]) -> Vec {
+ fn be(v: &mut Vec, x: u16) {
+ v.extend_from_slice(&x.to_be_bytes());
+ }
+ let mut name: Vec = Vec::new();
+ let mut strings: Vec = Vec::new();
+ for x in [0, records.len() as u16, (6 + 12 * records.len()) as u16] {
+ be(&mut name, x);
+ }
+ for (language, id, text) in records {
+ let utf16: Vec = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
+ // Platform 3 (Windows), encoding 1 (Unicode BMP).
+ for x in [3, 1, *language, *id, utf16.len() as u16, strings.len() as u16] {
+ be(&mut name, x);
+ }
+ strings.extend(utf16);
+ }
+ name.extend(strings);
+ let mut font: Vec = 0x0001_0000u32.to_be_bytes().to_vec();
+ for x in [1, 16, 0, 0] {
+ be(&mut font, x);
+ }
+ font.extend(b"name");
+ font.extend(0u32.to_be_bytes());
+ font.extend(28u32.to_be_bytes());
+ font.extend((name.len() as u32).to_be_bytes());
+ font.extend(name);
+ font
+ }
+
+ const EN: u16 = 0x0409;
+ const KO: u16 = 0x0412;
+
+ fn face(family: &str, names: &[&str], path: &str, weight: f32) -> Face {
+ Face { family: family.into(), names: names.iter().map(|s| s.to_string()).collect(), path: path.into(), index: 0, weight, italic: false }
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ #[test]
+ fn a_face_keeps_its_family_names_in_every_language() {
+ // Korean Excel saves `맑은 고딕`, so the Korean name has to be kept next to the English one (#1898).
+ let font = font_with_names(&[(EN, 1, "Malgun Gothic"), (EN, 2, "Regular"), (KO, 1, "맑은 고딕"), (KO, 2, "보통")]);
+ let faces = file_faces(&font);
+ assert_eq!(faces.len(), 1);
+ let (_, family, names, style) = &faces[0];
+ assert_eq!(family, "Malgun Gothic", "the font list keeps the English name");
+ assert_eq!(names, &["Malgun Gothic", "맑은 고딕"]);
+ assert_eq!(style, "Regular");
+
+ // Both name IDs are kept: a legacy family name (ID 1) names a single face (#1606).
+ let font = font_with_names(&[(EN, 1, "Noto Sans KR Medium"), (EN, 2, "Regular"), (EN, 16, "Noto Sans KR"), (EN, 17, "Medium")]);
+ let (_, family, names, _) = &file_faces(&font)[0];
+ assert_eq!(family, "Noto Sans KR");
+ assert_eq!(names, &["Noto Sans KR", "Noto Sans KR Medium"]);
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ #[test]
+ fn a_name_table_full_of_names_is_capped() {
+ let texts: Vec = (0..100).map(|i| format!("Family {i}")).collect();
+ let records: Vec<(u16, u16, &str)> = texts.iter().enumerate().map(|(i, t)| (0x0400 + i as u16, 1, t.as_str())).collect();
+ let (_, _, names, _) = &file_faces(&font_with_names(&records))[0];
+ assert_eq!(names.len(), MAX_FAMILY_NAMES);
+ }
+
+ #[test]
+ fn a_family_is_found_by_any_of_its_names() {
+ let names = ["Malgun Gothic", "맑은 고딕"];
+ let cat = [face("Malgun Gothic", &names, "malgunbd.ttf", 700.0), face("Malgun Gothic", &names, "malgun.ttf", 400.0)];
+ for wanted in ["Malgun Gothic", "malgun gothic", "맑은 고딕"] {
+ let found: Vec = faces_named(&cat, wanted).iter().map(|f| f.weight).collect();
+ assert_eq!(found, [400.0, 700.0], "{wanted:?}: both faces, regular first");
+ }
+ assert!(faces_named(&cat, "맑은").is_empty(), "a part of a name is not a name");
+ assert!(faces_named(&cat, "").is_empty());
+ }
+
+ #[test]
+ fn a_name_two_families_claim_has_a_fixed_owner() {
+ let a = face("Alpha", &["Alpha", "공유"], "a/alpha.ttf", 400.0);
+ let b = face("Beta", &["Beta", "공유"], "b/beta.ttf", 400.0);
+ // Whatever order the scan saw the files in, the first by path wins.
+ for cat in [[a.clone(), b.clone()], [b.clone(), a.clone()]] {
+ let found = faces_named(&cat, "공유");
+ assert_eq!(found.iter().map(|f| f.family.as_str()).collect::>(), ["Alpha"]);
+ }
+ // A family's own name beats another family's alias, even one scanned earlier.
+ let c = face("Gamma", &["Gamma", "Beta"], "0/gamma.ttf", 400.0);
+ let found = faces_named(&[c, b], "Beta");
+ assert_eq!(found.iter().map(|f| f.family.as_str()).collect::>(), ["Beta"]);
+ }
+
+ #[test]
+ fn a_legacy_name_picks_its_own_face_out_of_the_family() {
+ let cat = [
+ face("Noto Sans KR", &["Noto Sans KR"], "NotoSansKR-Regular.otf", 400.0),
+ face("Noto Sans KR", &["Noto Sans KR", "Noto Sans KR Medium"], "NotoSansKR-Medium.otf", 500.0),
+ ];
+ assert_eq!(faces_named(&cat, "Noto Sans KR").len(), 2);
+ let medium = faces_named(&cat, "Noto Sans KR Medium");
+ assert_eq!(medium.iter().map(|f| f.weight).collect::>(), [500.0]);
+ }
+
+ #[test]
+ fn installed_names_are_kept_as_the_workbook_spells_them() {
+ // The binding is keyed on the name `cell_family` looks up, so every spelling in use is
+ // returned as written: the English and the Korean name both bind, over the same faces.
+ let cat = [face("Malgun Gothic", &["Malgun Gothic", "맑은 고딕"], "malgun.ttf", 400.0)];
+ let used: Vec = ["맑은 고딕", "Malgun Gothic", "Not Installed", "맑은 고딕", "malgun gothic"].map(String::from).to_vec();
+ assert_eq!(installed_in(&cat, &used), ["Malgun Gothic", "malgun gothic", "맑은 고딕"]);
+ assert!(installed_in(&[], &used).is_empty());
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ #[test]
+ fn a_family_bound_under_two_names_shares_its_font_data() {
+ let dir = std::env::temp_dir().join(format!("gridcraft-fonts-alias-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).expect("temp dir");
+ let path = dir.join("Ubuntu-Light.ttf");
+ std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write");
+ let f = Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into(), "우분투".into()], path, index: 0, weight: 300.0, italic: false };
+
+ let mut fonts = egui::FontDefinitions::default();
+ let before = fonts.font_data.len();
+ let mut bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&f));
+ bound.extend(bind_faces(&mut fonts, "우분투", std::slice::from_ref(&f)));
+ assert!(bound.contains(&role_key("우분투", false, false)), "bound under the workbook's name");
+ assert!(bound.contains(&role_key("Ubuntu", true, true)));
+ assert_eq!(fonts.font_data.len(), before + 1, "one file, loaded once");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// On a Windows machine with Malgun Gothic, the real file carries its Korean name.
+ #[cfg(windows)]
+ #[test]
+ fn malgun_gothic_answers_to_its_korean_name_when_installed() {
+ let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows"));
+ let Ok(data) = std::fs::read(root.join("Fonts").join("malgun.ttf")) else { return };
+ let faces = file_faces(&data);
+ assert!(faces.iter().any(|(_, family, names, _)| family == "Malgun Gothic" && names.iter().any(|n| n == "맑은 고딕")), "{faces:?}");
+ }
}
From a35d241caf654a4c320283ece685d0eeed369181 Mon Sep 17 00:00:00 2001
From: Gert Pellin
Date: Fri, 16 Oct 2026 04:45:33 +0200
Subject: [PATCH 15/17] XLSX: a note keeps its box's place and size through
open and save (#1608)
The reader took only from a note's VML shape and vml_xml
wrote every note with a fixed anchor (two columns right of the cell,
four rows tall) and a 108 x 59.25 pt style, so a plain open and save
moved and resized every note box Excel had stored.
Comment gets an optional NoteBox: the corners relative to the
note's cell (so the box follows the note when rows or columns move) and
the shape's width and height. The writer puts it back, clamped to the
sheet; notes made here, and boxes equal to the default, keep the
default box.
---
crates/engine/src/cmd/insert.rs | 11 ++-
crates/model/src/cellmap.rs | 1 +
crates/model/src/features.rs | 28 +++++++
crates/xlsx/src/sheet_read.rs | 69 ++++++++++++-----
crates/xlsx/src/sheet_write.rs | 33 ++++++--
crates/xlsx/src/tests/malformed.rs | 11 ++-
crates/xlsx/src/tests/mod.rs | 1 +
crates/xlsx/src/tests/note_box.rs | 117 +++++++++++++++++++++++++++++
crates/xlsx/src/tests/roundtrip.rs | 36 ++++++++-
docs/file-format-parity.md | 3 +-
10 files changed, 277 insertions(+), 33 deletions(-)
create mode 100644 crates/xlsx/src/tests/note_box.rs
diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs
index 9f044613d..9987afa14 100644
--- a/crates/engine/src/cmd/insert.rs
+++ b/crates/engine/src/cmd/insert.rs
@@ -1250,7 +1250,16 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result {
edit(s, |cx| {
cx.sheet_mut(sheet)?.comments.insert(
at,
- Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
+ Comment {
+ author: author.clone(),
+ text: text.clone(),
+ replies: vec![],
+ threaded,
+ resolved: false,
+ visible: false,
+ dates: vec![],
+ note_box: None,
+ },
);
Ok(json!({"cell": at.a1()}))
})
diff --git a/crates/model/src/cellmap.rs b/crates/model/src/cellmap.rs
index dbbe202f5..f72b7b5af 100644
--- a/crates/model/src/cellmap.rs
+++ b/crates/model/src/cellmap.rs
@@ -41,6 +41,7 @@ mod tests {
resolved: false,
visible: false,
dates: vec![],
+ note_box: None,
},
);
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()), external: false });
diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs
index c35254f0e..b04e148af 100644
--- a/crates/model/src/features.rs
+++ b/crates/model/src/features.rs
@@ -323,6 +323,34 @@ pub struct Comment {
/// read from the file (`dT`); empty when unknown, e.g. for comments made here.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dates: Vec,
+ /// Where the note's box sits and how big it is, as read from the file; `None` (a note made
+ /// here, or a file without the shape) writes the default box next to the cell.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub note_box: Option,
+}
+
+/// A note box's place and size, from the note's VML shape: Excel's `` with its corners
+/// stored relative to the note's cell, so the box moves with the note when rows or columns are
+/// inserted or deleted, and the shape's width and height.
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+pub struct NoteBox {
+ /// Top-left corner: columns and rows from the note's cell (negative = left of or above it),
+ /// then the pixel offset into that column and row.
+ pub from: NoteCorner,
+ /// Bottom-right corner, the same way.
+ pub to: NoteCorner,
+ /// The shape's width and height in points (the VML style's `width`/`height`), when given.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub size_pt: Option<(f64, f64)>,
+}
+
+/// One corner of a [`NoteBox`].
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NoteCorner {
+ pub col: i64,
+ pub col_px: u32,
+ pub row: i64,
+ pub row_px: u32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs
index 0194d2268..7e75c1bc3 100644
--- a/crates/xlsx/src/sheet_read.rs
+++ b/crates/xlsx/src/sheet_read.rs
@@ -8,8 +8,8 @@ use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRe
use gridcraft_formula::Expr;
use gridcraft_model::{
AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, EditRange, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo,
- Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, ValidationKind,
- XmlColumnPr,
+ NoteBox, NoteCorner, Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation,
+ ValidationKind, XmlColumnPr,
};
use crate::IoError;
@@ -210,12 +210,13 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result`). Best effort: the VML is
- // only read for that flag, so a part that doesn't parse is skipped without a warning.
+ // Which notes are displayed (``) and where their boxes sit live in the VML shapes.
+ // Best effort: the VML is only read for those, so a part that doesn't parse is skipped without
+ // a warning.
if !sheet.comments.is_empty() {
for rel in rels.iter().filter(|r| r.kind == "vmlDrawing") {
if let Ok(Some(x)) = cx.pkg.read_xml(&rel.target) {
- read_note_visibility(&x, &mut sheet, 0);
+ read_note_shapes(&x, &mut sheet, 0);
}
}
}
@@ -882,34 +883,63 @@ fn read_comments(x: &El, sheet: &mut Sheet) {
{
text = rest.strip_prefix('\n').unwrap_or(rest).to_string();
}
- sheet.comments.insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![] });
+ sheet
+ .comments
+ .insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![], note_box: None });
}
}
-/// Marks the notes whose VML shape carries `` as shown. Shapes may sit in groups.
-fn read_note_visibility(x: &El, sheet: &mut Sheet, depth: usize) {
+/// Reads the notes' VML shapes: a shape carrying `` marks its note as shown, and its
+/// `` and style size become the note's box. Shapes may sit in groups.
+fn read_note_shapes(x: &El, sheet: &mut Sheet, depth: usize) {
if depth > xml::MAX_DEPTH {
return;
}
for e in &x.children {
if e.name != "shape" {
- read_note_visibility(e, sheet, depth + 1);
+ read_note_shapes(e, sheet, depth + 1);
continue;
}
let Some(cd) = e.child("ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue };
- if cd.child("Visible").is_none() {
- continue;
- }
let num = |k: &str| cd.child(k).and_then(|v| v.text.trim().parse::().ok());
- if let (Some(row), Some(col)) = (num("Row"), num("Column"))
- && let Some(c) = sheet.comments.get_mut(&CellRef::new(row, col))
- && !c.threaded
- {
+ let (Some(row), Some(col)) = (num("Row"), num("Column")) else { continue };
+ let cell = CellRef::new(row, col);
+ let Some(c) = sheet.comments.get_mut(&cell) else { continue };
+ if cd.child("Visible").is_some() && !c.threaded {
c.visible = true;
}
+ if let Some(anchor) = cd.child("Anchor") {
+ c.note_box = note_box(&anchor.text, e.attr("style").unwrap_or(""), cell).filter(|b| *b != crate::sheet_write::DEFAULT_NOTE_BOX);
+ }
}
}
+/// A note's box from its VML `` (`LeftColumn, LeftOffset, TopRow, TopOffset,
+/// RightColumn, RightOffset, BottomRow, BottomOffset`; offsets in pixels) and its shape's style,
+/// the corners made relative to the note's `cell`. `None` when the anchor isn't eight in-sheet
+/// numbers.
+fn note_box(anchor: &str, style: &str, cell: CellRef) -> Option {
+ let v: Vec = anchor.split(',').map(|p| p.trim().parse::().ok()).collect::