Integration: track t13

# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	crates/engine/src/cmd/data.rs
#	crates/xlsx/src/tests/mod.rs
#	docs/chart-parity.md
#	docs/file-format-parity.md
#	docs/function-parity.md
#	docs/gaps.md
#	docs/localization-parity.md
#	docs/target-app-parity.md
This commit is contained in:
aiongg
2026-10-22 03:05:18 +00:00
22 changed files with 904 additions and 108 deletions
+1 -1
View File
@@ -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
+2
View File
@@ -143,6 +143,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | Memory: undo steps share a sheet's picture bytes and the workbook's VBA project instead of copying them: 50 edits on a sheet with a 20 MB picture keep 22 MB instead of 992 MB |
| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes |
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
| 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-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
@@ -225,6 +226,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | minor | Progress log: undo steps share picture and macro bytes |
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
| 2026-10-14 | minor | Progress log: spill anchor reading its old spill area |
| 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict |
+12
View File
@@ -78,3 +78,15 @@ sync_github_name = true
[people.Stefanfra]
real_name = "Stefan Fransen"
sync_github_name = true
[people.allenyllee]
sync_github_name = true
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.Harrison-Booth]
real_name = "Harrison Booth"
sync_github_name = false
+157 -12
View File
@@ -1061,13 +1061,43 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
"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)"))),
};
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`"));
}
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() };
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),
@@ -1076,11 +1106,7 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
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| {
@@ -1091,14 +1117,36 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// 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<String> {
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) {
@@ -1506,6 +1554,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();
@@ -1738,4 +1797,90 @@ mod tests {
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a"));
assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c"));
}
#[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}"><formula1>{saved}</formula1>"#)), "{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");
}
#[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());
}
}
+10 -1
View File
@@ -1305,7 +1305,16 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
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()}))
})
+1
View File
@@ -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 });
+28
View File
@@ -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<String>,
/// 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<NoteBox>,
}
/// A note box's place and size, from the note's VML shape: Excel's `<x:Anchor>` 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)]
+229 -31
View File
@@ -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<String>,
/// 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>, 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<String> {
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<Face> {
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<Face> {
let Some(cat) = catalog_now() else { return Vec::new() };
let mut v: Vec<Face> = 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<Face> {
let mut v: Vec<Face> = 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<String> {
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<String> {
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = 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<String> = 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<String> {
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<u8> {
fn be(v: &mut Vec<u8>, x: u16) {
v.extend_from_slice(&x.to_be_bytes());
}
let mut name: Vec<u8> = Vec::new();
let mut strings: Vec<u8> = 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<u8> = 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<u8> = 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<String> = (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<f32> = 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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), [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<String> = ["맑은 고딕", "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:?}");
}
}
+185 -12
View File
@@ -243,12 +243,8 @@ pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool {
#[cfg(not(target_arch = "wasm32"))]
fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
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);
}
@@ -256,6 +252,30 @@ fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
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::collections::BTreeMap<String, &'static [u8]>> = 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) {
@@ -360,6 +380,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);
@@ -367,6 +389,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);
@@ -375,6 +399,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);
@@ -383,6 +409,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);
}
_ => {}
}
@@ -432,7 +466,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<String> = 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);
@@ -526,6 +565,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()),
@@ -534,9 +574,23 @@ 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`), ChromeOS's Linux
/// container (`chromeos/notocjk`, #1762), plus local and flat layouts.
/// container (`chromeos/notocjk`, #1762), plus local and
/// flat layouts. Flatpak exposes the same host layouts through read-only font mounts.
#[cfg(not(target_arch = "wasm32"))]
const LINUX_CJK_DIRS: [&str; 11] = [
const LINUX_CJK_DIRS: [&str; 22] = [
// 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",
@@ -635,6 +689,28 @@ 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
}
/// 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
}
@@ -667,6 +743,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
}
@@ -864,11 +941,11 @@ mod tests {
let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::<Vec<_>>();
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, Language::Fr, Language::Tr] {
@@ -876,6 +953,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
@@ -897,11 +1001,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.
@@ -999,4 +1104,72 @@ 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");
}
#[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");
}
}
#[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::<Vec<_>>();
let cases: [(&str, Vec<String>, &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:?}");
}
}
+50 -19
View File
@@ -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;
@@ -212,12 +212,13 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
read_threaded(cx, &x, &mut sheet);
}
}
// Which notes are displayed lives in the VML shapes (`<x:Visible/>`). 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 (`<x:Visible/>`) 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);
}
}
}
@@ -895,34 +896,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 `<x:Visible/>` 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 `<x:Visible/>` marks its note as shown, and its
/// `<x:Anchor>` 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::<u32>().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 `<x:Anchor>` (`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<NoteBox> {
let v: Vec<u32> = anchor.split(',').map(|p| p.trim().parse::<u32>().ok()).collect::<Option<_>>()?;
let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = <[u32; 8]>::try_from(v).ok()?;
if c1.max(c2) >= MAX_COLS || r1.max(r2) >= MAX_ROWS {
return None;
}
let corner = |c: u32, dx: u32, r: u32, dy: u32| NoteCorner {
col: i64::from(c) - i64::from(cell.col),
col_px: dx,
row: i64::from(r) - i64::from(cell.row),
row_px: dy,
};
let pt = |key: &str| {
style
.split(';')
.filter_map(|d| d.split_once(':'))
.find(|(k, _)| k.trim().eq_ignore_ascii_case(key))
.and_then(|(_, v)| v.trim().strip_suffix("pt")?.trim().parse::<f64>().ok().filter(|n| n.is_finite() && *n > 0.0 && *n < 1e6))
};
Some(NoteBox { from: corner(c1, dx1, r1, dy1), to: corner(c2, dx2, r2, dy2), size_pt: pt("width").zip(pt("height")) })
}
fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) {
let mut by_id: HashMap<String, CellRef> = HashMap::new();
let mut replies: Vec<(String, String, String, String)> = vec![];
@@ -938,9 +968,10 @@ fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) {
by_id.insert(id.to_string(), cell);
}
let dates = if date.is_empty() { vec![] } else { vec![date] };
sheet
.comments
.insert(cell, Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates });
sheet.comments.insert(
cell,
Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates, note_box: None },
);
}
}
}
+25 -8
View File
@@ -3,10 +3,10 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::fmt::Write as _;
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{
Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, Orientation, Sheet, SparklineKind, Style, Table,
TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, NoteBox, NoteCorner, Orientation, Sheet,
SparklineKind, Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
};
use crate::drawing::{Obj, image_content_type, image_ext};
@@ -1286,6 +1286,24 @@ fn protected_ranges_xml(sheet: &Sheet) -> String {
if s.is_empty() { s } else { format!("<protectedRanges>{s}</protectedRanges>") }
}
/// The box a note made here gets: two columns right of its cell, four rows tall, 108 x 59.25 pt.
/// A box read from a file that equals it is kept as `None`, so a note's model survives a save.
pub(crate) const DEFAULT_NOTE_BOX: NoteBox = NoteBox {
from: NoteCorner { col: 1, col_px: 15, row: 0, row_px: 2 },
to: NoteCorner { col: 3, col_px: 15, row: 4, row_px: 16 },
size_pt: Some(DEFAULT_NOTE_SIZE_PT),
};
const DEFAULT_NOTE_SIZE_PT: (f64, f64) = (108.0, 59.25);
/// A note box's `<x:Anchor>` text and its width and height in points: the box read from the file
/// or [`DEFAULT_NOTE_BOX`], its corners moved with the note's `cell` and kept on the sheet.
fn note_anchor(cell: CellRef, b: Option<&NoteBox>) -> (String, (f64, f64)) {
let b = b.unwrap_or(&DEFAULT_NOTE_BOX);
let at = |base: u32, d: i64, max: u32| (i64::from(base) + d).clamp(0, i64::from(max) - 1);
let corner = |k: &NoteCorner| format!("{}, {}, {}, {}", at(cell.col, k.col, MAX_COLS), k.col_px, at(cell.row, k.row, MAX_ROWS), k.row_px);
(format!("{}, {}", corner(&b.from), corner(&b.to)), b.size_pt.unwrap_or(DEFAULT_NOTE_SIZE_PT))
}
/// Legacy VML shapes Excel uses to display notes.
fn vml_xml(sheet: &Sheet, n: u32) -> String {
let mut s = format!(
@@ -1294,14 +1312,13 @@ fn vml_xml(sheet: &Sheet, n: u32) -> String {
for (i, (cell, c)) in sheet.comments.iter().enumerate() {
let id = n as u64 * 1024 + 1 + i as u64;
let vis = if c.visible { "visible" } else { "hidden" };
let (anchor, (w, h)) = note_anchor(*cell, c.note_box.as_ref());
let _ = write!(
s,
"<v:shape id=\"_x0000_s{id}\" type=\"#_x0000_t202\" style=\"position:absolute;margin-left:59.25pt;margin-top:1.5pt;width:108pt;height:59.25pt;z-index:{};visibility:{vis}\" fillcolor=\"#ffffe1\" o:insetmode=\"auto\"><v:fill color2=\"#ffffe1\"/><v:shadow on=\"t\" color=\"black\" obscured=\"t\"/><v:path o:connecttype=\"none\"/><v:textbox style=\"mso-direction-alt:auto\"><div style=\"text-align:left\"></div></v:textbox><x:ClientData ObjectType=\"Note\"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>{}, 15, {}, 2, {}, 15, {}, 16</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>{}</x:Row><x:Column>{}</x:Column>{}</x:ClientData></v:shape>",
"<v:shape id=\"_x0000_s{id}\" type=\"#_x0000_t202\" style=\"position:absolute;margin-left:59.25pt;margin-top:1.5pt;width:{}pt;height:{}pt;z-index:{};visibility:{vis}\" fillcolor=\"#ffffe1\" o:insetmode=\"auto\"><v:fill color2=\"#ffffe1\"/><v:shadow on=\"t\" color=\"black\" obscured=\"t\"/><v:path o:connecttype=\"none\"/><v:textbox style=\"mso-direction-alt:auto\"><div style=\"text-align:left\"></div></v:textbox><x:ClientData ObjectType=\"Note\"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>{anchor}</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>{}</x:Row><x:Column>{}</x:Column>{}</x:ClientData></v:shape>",
num(w),
num(h),
i + 1,
cell.col + 1,
cell.row,
cell.col + 3,
cell.row + 4,
cell.row,
cell.col,
if c.visible { "<x:Visible/>" } else { "" }
+10 -1
View File
@@ -130,7 +130,16 @@ fn hostile_threaded_comments_persons_and_note_vml() {
use gridcraft_model::{Comment, Workbook};
let mut wb = Workbook::new();
let s = std::sync::Arc::make_mut(&mut wb.sheets[0]);
let c = |threaded| Comment { author: "A".into(), text: "t".into(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] };
let c = |threaded| Comment {
author: "A".into(),
text: "t".into(),
replies: vec![],
threaded,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
};
s.comments.insert(CellRef::new(0, 0), c(true));
s.comments.insert(CellRef::new(1, 1), c(false));
let good = write_xlsx(&wb).unwrap();
+1
View File
@@ -13,6 +13,7 @@ mod ink_roundtrip;
mod literal_category_roundtrip;
mod malformed;
mod multilevel_category_roundtrip;
mod note_box;
mod ods_tests;
mod passthrough_tests;
mod phonetic_tests;
+117
View File
@@ -0,0 +1,117 @@
//! #1608: a note's box keeps the place and size the file gave it through open and save.
use std::io::Read;
use gridcraft_core::CellRef;
use gridcraft_model::{Comment, NoteBox, NoteCorner, Workbook};
use super::minimal;
use crate::{read_xlsx, write_xlsx};
const RELS: &str = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/></Relationships>"#;
const COMMENTS: &str = r#"<?xml version="1.0"?><comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>A</author></authors><commentList><comment ref="B2" authorId="0"><text><t>note</t></text></comment></commentList></comments>"#;
/// The issue's note on B2: Excel's box over B1, 224.25 x 89.25 pt.
const VML: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout><v:shape id="_x0000_s1025" type="#_x0000_t202" style="position:absolute;margin-left:66pt;margin-top:2.25pt;width:224.25pt;height:89.25pt;z-index:1;visibility:hidden" fillcolor="#ffffe1" o:insetmode="auto"><v:textbox><div>note</div></v:textbox><x:ClientData ObjectType="Note"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>
1, 10, 0, 2, 5, 32, 5, 8</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>1</x:Row><x:Column>1</x:Column></x:ClientData></v:shape></xml>"##;
fn package() -> Vec<u8> {
minimal(
r#"<sheetData><row r="2"><c r="B2"><v>1</v></c></row></sheetData><legacyDrawing r:id="rId2"/>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", RELS), ("xl/comments1.xml", COMMENTS), ("xl/drawings/vmlDrawing1.vml", VML)],
"",
"",
)
}
/// The written VML part (GridCraft writes one per sheet with notes).
fn written_vml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let name = (0..z.len()).map(|i| z.by_index(i).unwrap().name().to_string()).find(|n| n.ends_with(".vml")).expect("a VML part");
let mut s = String::new();
z.by_name(&name).unwrap().read_to_string(&mut s).unwrap();
s
}
fn b2() -> CellRef {
CellRef::new(1, 1)
}
#[test]
fn a_notes_box_is_read_relative_to_its_cell() {
let (wb, _) = read_xlsx(&package()).unwrap();
let c = &wb.sheets[0].comments[&b2()];
assert_eq!(
c.note_box,
Some(NoteBox {
from: NoteCorner { col: 0, col_px: 10, row: -1, row_px: 2 },
to: NoteCorner { col: 4, col_px: 32, row: 4, row_px: 8 },
size_pt: Some((224.25, 89.25)),
})
);
}
#[test]
fn an_unedited_notes_box_is_saved_where_and_as_big_as_it_was() {
let (wb, _) = read_xlsx(&package()).unwrap();
let vml = written_vml(&write_xlsx(&wb).unwrap());
assert!(vml.contains("<x:Anchor>1, 10, 0, 2, 5, 32, 5, 8</x:Anchor>"), "{vml}");
assert!(vml.contains("width:224.25pt;height:89.25pt"), "{vml}");
// and once more through GridCraft's own output
let (wb2, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(wb2.sheets[0].comments[&b2()].note_box, wb.sheets[0].comments[&b2()].note_box);
}
#[test]
fn a_notes_box_moves_with_its_note() {
// A row and a column inserted before the note move it to C3; its box follows it.
let (mut wb, _) = read_xlsx(&package()).unwrap();
let s = wb.sheet_mut(0).unwrap();
let c = s.comments.remove(&b2()).unwrap();
s.comments.insert(CellRef::new(2, 2), c);
let vml = written_vml(&write_xlsx(&wb).unwrap());
assert!(vml.contains("<x:Anchor>2, 10, 1, 2, 6, 32, 6, 8</x:Anchor>"), "{vml}");
}
#[test]
fn a_box_that_would_start_before_the_sheet_is_clamped_to_it() {
// The B2 note's box starts one row above it; moved to A1 that row doesn't exist.
let (mut wb, _) = read_xlsx(&package()).unwrap();
let s = wb.sheet_mut(0).unwrap();
let c = s.comments.remove(&b2()).unwrap();
s.comments.insert(CellRef::new(0, 0), c);
let vml = written_vml(&write_xlsx(&wb).unwrap());
assert!(vml.contains("<x:Anchor>0, 10, 0, 2, 4, 32, 4, 8</x:Anchor>"), "{vml}");
}
#[test]
fn a_new_note_gets_the_default_box() {
let mut wb = Workbook::new();
let note = Comment {
author: "A".into(),
text: "t".into(),
replies: vec![],
threaded: false,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
};
wb.sheet_mut(0).unwrap().comments.insert(b2(), note);
let vml = written_vml(&write_xlsx(&wb).unwrap());
assert!(vml.contains("<x:Anchor>2, 15, 1, 2, 4, 15, 5, 16</x:Anchor>") && vml.contains("width:108pt;height:59.25pt"), "{vml}");
}
#[test]
fn a_damaged_anchor_is_ignored() {
for anchor in ["1, 10, 0", "1, x, 0, 2, 5, 32, 5, 8", "-1, 10, 0, 2, 5, 32, 5, 8", "99999999999, 0, 0, 0, 0, 0, 0, 0"] {
let vml = VML.replace("\n 1, 10, 0, 2, 5, 32, 5, 8", anchor);
let bytes = minimal(
r#"<sheetData/><legacyDrawing r:id="rId2"/>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", RELS), ("xl/comments1.xml", COMMENTS), ("xl/drawings/vmlDrawing1.vml", &vml)],
"",
"",
);
let (wb, _) = read_xlsx(&bytes).unwrap();
assert_eq!(wb.sheets[0].comments[&b2()].note_box, None, "{anchor}");
}
}
+33 -3
View File
@@ -217,11 +217,21 @@ fn sample() -> Workbook {
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
s.comments.insert(
at("C3"),
Comment { author: "Bob".into(), text: "second".into(), replies: vec![], threaded: false, resolved: false, visible: true, dates: vec![] },
Comment {
author: "Bob".into(),
text: "second".into(),
replies: vec![],
threaded: false,
resolved: false,
visible: true,
dates: vec![],
note_box: None,
},
);
s.comments.insert(
at("D4"),
@@ -233,11 +243,21 @@ fn sample() -> Workbook {
resolved: true,
visible: false,
dates: vec!["2026-10-11T09:30:00.00".into(), String::new(), "2026-10-11T10:15:00.00".into()],
note_box: None,
},
);
s.comments.insert(
at("E5"),
Comment { author: "Cy".into(), text: "open thread".into(), replies: vec![], threaded: true, resolved: false, visible: false, dates: vec![] },
Comment {
author: "Cy".into(),
text: "open thread".into(),
replies: vec![],
threaded: true,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
s.autofilter = Some(AutoFilter {
range: rr("K1:N20"),
@@ -437,7 +457,16 @@ fn sample() -> Workbook {
s3.visibility = Visibility::VeryHidden;
s3.comments.insert(
at("A1"),
Comment { author: "Bob".into(), text: "other sheet".into(), replies: vec![], threaded: true, resolved: false, visible: false, dates: vec![] },
Comment {
author: "Bob".into(),
text: "other sheet".into(),
replies: vec![],
threaded: true,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
wb.sheets.push(Arc::new(s3));
wb.names.push(DefinedName { name: "Rate".into(), scope: None, formula: "0.07".into(), comment: "c".into(), hidden: false });
@@ -1005,6 +1034,7 @@ fn bad_comment_dates_are_left_out() {
resolved: false,
visible: false,
dates: vec!["\"/><evil".into()],
note_box: None,
};
s.comments.insert(at("A1"), c);
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
+9 -6
View File
@@ -1,13 +1,13 @@
# Chart parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** trivial (series that refer to workbook-scoped defined names (`[0]!Name` in the file) plot their data instead of `#NAME?` (#1686); previously trivial: chart sheets are kept on save, still not editable (#727)) · **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,15 @@ 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 | exclusive (default) and inclusive quartiles per series; XLSX round-trip; `chart.set {quartiles}` changes all series |
| 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 |
@@ -76,7 +78,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690).
## 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.
@@ -88,6 +90,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690).
| 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-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) |
| 2026-10-16 | minor | Box & whisker quartiles default to exclusive, honor each series on draw and XLSX round-trip; `chart.set {quartiles}` changes the method (#1453) |
| 2026-10-13 | minor | Multi-level category axes: read, drawn (outer levels under or beside the labels), written back with level caches |
+4
View File
@@ -47,6 +47,9 @@ values) is still dropped.
| `.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 and the per-script `<a:font script>` lists 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), pictures in headers and footers (`&G`, #1257: their VML drawing and images are written back unchanged; they are not shown, and can't be inserted), 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 (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), 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 (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists 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, 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` 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 their box's place and size, #1608) 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 |
| `.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` |
@@ -120,6 +123,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| 2026-10-16 | trivial | XLSX reads and writes the workbook's show-objects setting (`workbookPr showObjects`: all, placeholders, none), which Ctrl+6 switches; a file without it is saved without it |
| 2026-10-16 | trivial | XLSX: pictures in headers and footers (`&G`, the VML drawing `<legacyDrawingHF>` names and its images) are kept on save; they are still not shown (#1257) |
| 2026-10-16 | minor | XLSX column imports refuse more than 1,000,000 cumulative expanded assignments per sheet before expanding the next span; repeated overlaps cannot bypass the work limit (#1022) |
| 2026-10-16 | minor | XLSX: a note keeps its box's place and size (`<x:Anchor>` and the VML width and height) through open and save, moving with its cell; notes made here get the default box (#1608) |
| 2026-10-15 | minor | XLSX: formulas and PivotTable sources quote sheet names like Excel (a leading `.`, R1C1-like and cell-like names), so `'.x'!A1` no longer saves as `.x!A1`, which Excel could not open (#1458) |
| 2026-10-15 | minor | XLSX text preserves CR and CRLF across save/reopen; shared strings use `_x000D_` and other XML text uses `&#13;` (#1488) |
| 2026-10-15 | minor | XLSX save keeps legacy `INDEX`/`MATCH` formulas scalar instead of marking them as dynamic arrays (#1505) |
+5 -3
View File
@@ -77,17 +77,18 @@ This improves series preparation; it does not change the model-fitting or season
| 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) |
| SUBTOTAL / AGGREGATE input size | references trim to the used range; all areas, arrays and values of one call are charged against the 16-million-cell limit before any cell is read (skipped hidden, nested and error cells count), and `#NUM!` past it (#2175) |
| 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 `<calcPr iterate iterateCount iterateDelta>`), 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 |
| Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them |
@@ -124,6 +125,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| 2026-10-21 | minor | SUBTOTAL and AGGREGATE charge every argument against the evaluator's 16-million-cell limit before scanning; a value in XFD1048576 no longer makes `SUBTOTAL(9,A:XFD)` read the whole sheet (#2175) |
| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which matches `ß` with `ẞ` and keeps dotless `ı` apart from `i`) |
| 2026-10-19 | minor | Long flat operator chains (#916): 4,096 operands (Excel's 8,192-character limit) no longer overflow the stack when entered, filled, adjusted or saved; chains past 64 operands are no longer saved as dynamic-array formulas; Spanish entry no longer refuses chains past 256 operands |
| 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-16 | minor | FORECAST.ETS gap interpolation uses a forward-only known-point scan instead of rescanning each gap for every missing value; numerical behavior and missing-point limits are preserved (#1042) |
| 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-15 | minor | ADDRESS quotes a sheet name the way Excel does: names that read as an A1 cell (`A1`) or an R1C1 reference (`R1C1`, `RC`) and names starting with `.` get quotes; `TRUE` / `FALSE` stay bare (#1458) |
+14 -9
View File
@@ -42,11 +42,13 @@ 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. Multi-level category axes (year beside month) are read, drawn and written back.
(`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)
@@ -59,12 +61,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
@@ -175,6 +179,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step |
| 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-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) |
| 2026-10-13 | trivial | §4: multi-level category axes landed |
| 2026-10-12 | minor | §9: next/previous sheet commands retain the current visible sheet and selection when there is no visible destination |
+7 -1
View File
@@ -42,7 +42,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
#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. 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`, `.xls` WINDOW2 fRightToLeft) mirrors the grid (#146):
column A and the row headers on the right, the vertical scrollbar on the left; hit-testing,
@@ -118,8 +122,10 @@ language (none so far) and of localized function names.
| 2026-10-19 | minor | Save as Web Page and copied HTML mark right-to-left sheets `dir="rtl"`, let General-aligned Arabic and Hebrew text align by its own direction (`dir="auto"`), and keep numbers on right-to-left sheets left to right and on the left |
| 2026-10-19 | minor | French and Serbian kept the context in three `context\|label` translations ("Options\|Général", "Opcije\|Opšte" for `Options\|General`; also `Options\|Save`, `Sheet Options\|View`). `tr` shows a found translation as it is, so File › Options headings and the Sheet Options "View" checkboxes showed the prefix. Fixed; a test checks that no catalog shows a `\|` |
| 2026-10-17 | minor | Vietnamese catalog (`i18n/vi.rs`, every key in the master table); `vi` locale tags resolve to Vietnamese |
| 2026-10-17 | minor | Lao text falls back to an installed Lao face (`sys-lao`) instead of tofu boxes |
| 2026-10-15 | minor | Function help in the interface language: argument hints, autocomplete, Insert Function (search, categories) and the Formulas-tab menus read per-language tables of descriptions and argument names keyed by the catalog's English text; Spanish keeps its 10 functions in the new format |
| 2026-10-15 | minor | Italian catalog (`i18n/it.rs`, 971 entries, ~44%): every key the Brazilian Portuguese catalog translates plus the PivotTable Fields pane strings; `it` locale tags (it-IT, it-CH…) resolve to Italian. Labels follow Excel's Italian terminology; native-speaker review still pending |
| 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 | Turkish ribbon catalog (`i18n/tr.rs`, 382 labels); `tr` locale tags (tr-TR, tr_TR…) resolve to Turkish |
| 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%) |
+1 -1
View File
@@ -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
+3
View File
@@ -87,6 +87,8 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
| 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) |
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. 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) |
| 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) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
@@ -333,6 +335,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them |
| 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed |
| 2026-10-16 | minor | Advanced Filter copy-to clears only its actual output and rejects overflow without changing cells (#1031) |
| 2026-10-15 | minor | Row auto-fit: line breaks count only in wrapping cells (Excel: wrap off shows one line, AutoFit gives one line); unwrapped multi-line cells from files no longer grow rows on open (no score change) |
| 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) |