diff --git a/README.md b/README.md index ef9b906fa..2c224559c 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ cd apps/gridcraft-web && trunk build --release # → dist/web (serve it with cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sample=sales opens a sample) ``` -You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the +You need [trunk](https://trunk-rs.github.io/trunk/) and the `wasm32-unknown-unknown` target. Open uses the browser's file picker (dropping files works too) and Save downloads the workbook. ### Ribbon access keys diff --git a/ROADMAP.md b/ROADMAP.md index e833c63d2..9f944a2ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/contributors/people.toml b/contributors/people.toml index 02c2d02ba..a3fc72ad4 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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 diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 7a357e038..52a716cff 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -1061,13 +1061,43 @@ fn validation(s: &mut Session, p: &Json) -> Result { "time" => ValidationKind::Time, "textLength" => ValidationKind::TextLength, "custom" => ValidationKind::Custom, - _ => ValidationKind::Any, + "any" => ValidationKind::Any, + // An unknown type used to give an "any value" rule, saved as `type="none"`: no validation. + other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))), }; + 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 { 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 { }) } +/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather +/// than a reference or a name to evaluate. +fn is_literal_list(f: &str) -> bool { + f.starts_with('"') + || (!f.contains('!') + && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true)) +} + +/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's +/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves +/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a +/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before. +fn list_source(f: &str) -> String { + let t = f.trim(); + if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) { + return f.trim_start_matches('=').to_string(); + } + format!("\"{}\"", t.replace('"', "\"\"")) +} + /// List entries of a list validation (literal or from a range). pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { let f = dv.f1.trim(); - if f.starts_with('"') - || (!f.contains('!') - && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true)) - { - return f.trim_matches('"').split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); + if is_literal_list(f) { + // A quoted list is a string literal: `""` inside stands for one `"`. + let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) { + Some(body) => body.replace("\"\"", "\""), + None => f.trim_matches('"').to_string(), + }; + return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); } let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); match gridcraft_calc::evaluate(wb, sheet, at, f) { @@ -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}">{saved}"#)), "{cell}: {saved} missing from {xml}"); + } + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); + check(&reopened); + assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules"); + } + + #[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()); + } } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 0c43e8f49..f07718cc8 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1305,7 +1305,16 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result { edit(s, |cx| { cx.sheet_mut(sheet)?.comments.insert( at, - Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] }, + Comment { + author: author.clone(), + text: text.clone(), + replies: vec![], + threaded, + resolved: false, + visible: false, + dates: vec![], + note_box: None, + }, ); Ok(json!({"cell": at.a1()})) }) diff --git a/crates/model/src/cellmap.rs b/crates/model/src/cellmap.rs index dbbe202f5..f72b7b5af 100644 --- a/crates/model/src/cellmap.rs +++ b/crates/model/src/cellmap.rs @@ -41,6 +41,7 @@ mod tests { resolved: false, visible: false, dates: vec![], + note_box: None, }, ); s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()), external: false }); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 204aab0f6..aa250b9b0 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -323,6 +323,34 @@ pub struct Comment { /// read from the file (`dT`); empty when unknown, e.g. for comments made here. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub dates: Vec, + /// Where the note's box sits and how big it is, as read from the file; `None` (a note made + /// here, or a file without the shape) writes the default box next to the cell. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note_box: Option, +} + +/// A note box's place and size, from the note's VML shape: Excel's `` with its corners +/// stored relative to the note's cell, so the box moves with the note when rows or columns are +/// inserted or deleted, and the shape's width and height. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct NoteBox { + /// Top-left corner: columns and rows from the note's cell (negative = left of or above it), + /// then the pixel offset into that column and row. + pub from: NoteCorner, + /// Bottom-right corner, the same way. + pub to: NoteCorner, + /// The shape's width and height in points (the VML style's `width`/`height`), when given. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_pt: Option<(f64, f64)>, +} + +/// One corner of a [`NoteBox`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct NoteCorner { + pub col: i64, + pub col_px: u32, + pub row: i64, + pub row_px: u32, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index d5a8ea1d9..1a381b9d4 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -19,11 +19,20 @@ use std::sync::OnceLock; #[cfg(not(target_arch = "wasm32"))] const MAX_FONT_FILE: u64 = 64 << 20; +/// A face keeps at most this many family names, so a hostile `name` table can't grow the catalog. +#[cfg(not(target_arch = "wasm32"))] +const MAX_FAMILY_NAMES: usize = 16; + /// One face of one installed font file. #[derive(Clone, Debug)] pub struct Face { - /// Typographic family name, e.g. `"Segoe UI"`. + /// Typographic family name, e.g. `"Segoe UI"` (the English one when the font has it): what the + /// font list shows. pub family: String, + /// Every family name the face carries, `family` first, then each language's typographic and + /// legacy family names (`"Malgun Gothic"`, `"맑은 고딕"`). A workbook may name the font by any of + /// them: Korean Excel saves `맑은 고딕` (#1898). + pub names: Vec, /// The file the face lives in. pub path: PathBuf, /// Face index within the file (collections hold several, e.g. a family's Regular and Bold). @@ -88,6 +97,13 @@ fn style_weight(style: &str) -> f32 { TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0) } +impl Face { + /// Whether the face goes by `name` in any language. + pub fn answers_to(&self, name: &str) -> bool { + self.names.iter().any(|n| n.eq_ignore_ascii_case(name)) + } +} + /// Whether a style name reads as italic or oblique. #[cfg(not(target_arch = "wasm32"))] fn style_italic(style: &str) -> bool { @@ -95,9 +111,10 @@ fn style_italic(style: &str) -> bool { s.contains("italic") || s.contains("oblique") } -/// Every face in `data` as `(index, family, style)`. Damaged data yields nothing, never a panic. +/// Every face in `data` as `(index, family, names, style)`, where `names` holds every family name +/// (see [`Face::names`]). Damaged data yields nothing, never a panic. #[cfg(not(target_arch = "wasm32"))] -fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> { +fn file_faces(data: &[u8]) -> Vec<(u32, String, Vec, String)> { use skrifa::raw::FileRef; use skrifa::{MetadataProvider, string::StringId}; let count = match FileRef::new(data) { @@ -108,12 +125,23 @@ fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> { let name = |f: &skrifa::FontRef<'_>, ids: &[StringId]| -> Option { ids.iter().find_map(|id| f.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) }; + let family_ids = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]; (0..count) .filter_map(|i| { let f = skrifa::FontRef::from_index(data, i).ok()?; - let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; + let family = name(&f, &family_ids)?; + let mut names = vec![family.clone()]; + for s in family_ids.iter().flat_map(|id| f.localized_strings(*id)) { + if names.len() >= MAX_FAMILY_NAMES { + break; + } + let s = s.to_string(); + if !s.is_empty() && !names.iter().any(|n| n.eq_ignore_ascii_case(&s)) { + names.push(s); + } + } let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); - Some((i, family, style)) + Some((i, family, names, style)) }) .collect() } @@ -143,8 +171,8 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec { continue; } let Ok(data) = std::fs::read(&path) else { continue }; - for (index, family, style) in file_faces(&data) { - out.push(Face { family, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) }); + for (index, family, names, style) in file_faces(&data) { + out.push(Face { family, names, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) }); } } } @@ -188,29 +216,50 @@ pub fn scan_done() -> bool { CATALOG.get().is_some() } -/// The faces of one installed family, regular-weight first (the order roles pick from). +/// The faces of one installed family, by any of its names, regular-weight first (the order roles +/// pick from). pub fn faces_of(family: &str) -> Vec { - let Some(cat) = catalog_now() else { return Vec::new() }; - let mut v: Vec = cat.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).cloned().collect(); + catalog_now().map(|cat| faces_named(cat, family)).unwrap_or_default() +} + +/// The faces in `cat` that go by `wanted`. When two families claim one name the winner is fixed, +/// not left to the scan's directory order: a family whose own (English) name it is wins, else the +/// family of the first claiming face by path and index. Only the faces that carry the name are +/// kept, so a legacy name (`"Arial Black"`, name ID 1) picks its own face out of its family. +fn faces_named(cat: &[Face], wanted: &str) -> Vec { + let mut v: Vec = if cat.iter().any(|f| f.family.eq_ignore_ascii_case(wanted)) { + cat.iter().filter(|f| f.family.eq_ignore_ascii_case(wanted)).cloned().collect() + } else { + let owner = cat.iter().filter(|f| f.answers_to(wanted)).min_by(|a, b| a.path.cmp(&b.path).then(a.index.cmp(&b.index))); + match owner { + Some(o) => cat.iter().filter(|f| f.family.eq_ignore_ascii_case(&o.family) && f.answers_to(wanted)).cloned().collect(), + None => Vec::new(), + } + }; v.sort_by(|a, b| a.weight.total_cmp(&b.weight).then(a.italic.cmp(&b.italic)).then(a.index.cmp(&b.index))); v } /// The subset of `names` that is installed, sorted and deduped. These are the families whose files -/// get bound for a workbook. +/// get bound for a workbook, under the name the workbook uses: that is the name +/// [`cell_family`](crate::theme::cell_family) looks up, so `맑은 고딕` binds as `맑은 고딕`, not as +/// the catalog's `Malgun Gothic`. pub fn installed_among(names: &[String]) -> Vec { - let Some(cat) = catalog_now() else { return Vec::new() }; + catalog_now().map(|cat| installed_in(cat, names)).unwrap_or_default() +} + +/// [`installed_among`] over a given catalog. Names dedupe exactly, not case-folded: a role key is +/// built from the cell's exact string, so `Calibri` and `calibri` each need their own binding (the +/// font data behind them is shared, see [`face_key`]). +fn installed_in(cat: &[Face], names: &[String]) -> Vec { let mut out: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); + let mut seen: HashSet<&str> = HashSet::new(); for wanted in names { - if !seen.insert(wanted.to_lowercase()) { - continue; - } - if let Some(f) = cat.iter().find(|f| f.family.eq_ignore_ascii_case(wanted)) { - out.push(f.family.clone()); + if seen.insert(wanted.as_str()) && cat.iter().any(|f| f.answers_to(wanted)) { + out.push(wanted.clone()); } } - out.sort_by_key(|a| a.to_lowercase()); + out.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b))); out } @@ -254,13 +303,14 @@ fn pick_face(faces: &[Face], bold: bool, italic: bool) -> Option<&Face> { .or_else(|| faces.first()) } -/// A stable key for a face's font data, unique per file/index within the process. -fn face_key(family: &str, face: &Face) -> String { - let file = face.path.file_name().and_then(|s| s.to_str()).unwrap_or("font"); - format!("fnt-src:{family}:{file}:{}", face.index) +/// A stable key for a face's font data, one per file and index: every name a family is bound under +/// (`Malgun Gothic`, `맑은 고딕`) shares the data instead of loading the file again. +fn face_key(face: &Face) -> String { + format!("fnt-src:{}:{}", face.path.display(), face.index) } -/// Bind one installed family's faces into `fonts`, one egui family per cell role, and return the +/// Bind one installed family's faces into `fonts` under `family` (the name the workbook uses), one +/// egui family per cell role, and return the /// role keys that are now bound. A role with no exact face chains to the family's regular face, so /// no role is ever left pointing at missing data (egui panics on that). A family with no faces /// binds nothing. @@ -278,14 +328,16 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face let mut chain: Vec = Vec::new(); for face in [Some(primary), pick_face(faces, false, false)] { let Some(face) = face else { continue }; - let key = face_key(family, face); + let key = face_key(face); if chain.contains(&key) { continue; } - let Ok(bytes) = std::fs::read(&face.path) else { continue }; - let mut fd = egui::FontData::from_owned(bytes); - fd.index = face.index; - fonts.font_data.entry(key.clone()).or_insert_with(|| std::sync::Arc::new(fd)); + if !fonts.font_data.contains_key(&key) { + let Ok(bytes) = std::fs::read(&face.path) else { continue }; + let mut fd = egui::FontData::from_owned(bytes); + fd.index = face.index; + fonts.font_data.insert(key.clone(), std::sync::Arc::new(fd)); + } chain.push(key); } if chain.is_empty() { @@ -299,7 +351,7 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face bound } -/// Bind every installed face of `family` (see [`bind_faces`]). +/// Bind every installed face of `family`, found by any of its names (see [`bind_faces`]). pub fn bind_family(fonts: &mut egui::FontDefinitions, family: &str) -> Vec { bind_faces(fonts, family, &faces_of(family)) } @@ -407,7 +459,7 @@ mod tests { std::fs::create_dir_all(&dir).expect("temp dir"); let path = dir.join("Ubuntu-Light.ttf"); std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write"); - let face = Face { family: "Ubuntu".into(), path, index: 0, weight: 300.0, italic: false }; + let face = Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into()], path, index: 0, weight: 300.0, italic: false }; let mut fonts = egui::FontDefinitions::default(); let bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&face)); @@ -420,4 +472,150 @@ mod tests { assert!(bind_faces(&mut fonts, "No Such Font", &[]).is_empty()); let _ = std::fs::remove_dir_all(&dir); } + + /// A font that is just a table directory and a `name` table holding `records` as + /// `(Windows language id, name id, text)`: all [`file_faces`] reads. + #[cfg(not(target_arch = "wasm32"))] + fn font_with_names(records: &[(u16, u16, &str)]) -> Vec { + fn be(v: &mut Vec, x: u16) { + v.extend_from_slice(&x.to_be_bytes()); + } + let mut name: Vec = Vec::new(); + let mut strings: Vec = Vec::new(); + for x in [0, records.len() as u16, (6 + 12 * records.len()) as u16] { + be(&mut name, x); + } + for (language, id, text) in records { + let utf16: Vec = text.encode_utf16().flat_map(u16::to_be_bytes).collect(); + // Platform 3 (Windows), encoding 1 (Unicode BMP). + for x in [3, 1, *language, *id, utf16.len() as u16, strings.len() as u16] { + be(&mut name, x); + } + strings.extend(utf16); + } + name.extend(strings); + let mut font: Vec = 0x0001_0000u32.to_be_bytes().to_vec(); + for x in [1, 16, 0, 0] { + be(&mut font, x); + } + font.extend(b"name"); + font.extend(0u32.to_be_bytes()); + font.extend(28u32.to_be_bytes()); + font.extend((name.len() as u32).to_be_bytes()); + font.extend(name); + font + } + + const EN: u16 = 0x0409; + const KO: u16 = 0x0412; + + fn face(family: &str, names: &[&str], path: &str, weight: f32) -> Face { + Face { family: family.into(), names: names.iter().map(|s| s.to_string()).collect(), path: path.into(), index: 0, weight, italic: false } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_face_keeps_its_family_names_in_every_language() { + // Korean Excel saves `맑은 고딕`, so the Korean name has to be kept next to the English one (#1898). + let font = font_with_names(&[(EN, 1, "Malgun Gothic"), (EN, 2, "Regular"), (KO, 1, "맑은 고딕"), (KO, 2, "보통")]); + let faces = file_faces(&font); + assert_eq!(faces.len(), 1); + let (_, family, names, style) = &faces[0]; + assert_eq!(family, "Malgun Gothic", "the font list keeps the English name"); + assert_eq!(names, &["Malgun Gothic", "맑은 고딕"]); + assert_eq!(style, "Regular"); + + // Both name IDs are kept: a legacy family name (ID 1) names a single face (#1606). + let font = font_with_names(&[(EN, 1, "Noto Sans KR Medium"), (EN, 2, "Regular"), (EN, 16, "Noto Sans KR"), (EN, 17, "Medium")]); + let (_, family, names, _) = &file_faces(&font)[0]; + assert_eq!(family, "Noto Sans KR"); + assert_eq!(names, &["Noto Sans KR", "Noto Sans KR Medium"]); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_name_table_full_of_names_is_capped() { + let texts: Vec = (0..100).map(|i| format!("Family {i}")).collect(); + let records: Vec<(u16, u16, &str)> = texts.iter().enumerate().map(|(i, t)| (0x0400 + i as u16, 1, t.as_str())).collect(); + let (_, _, names, _) = &file_faces(&font_with_names(&records))[0]; + assert_eq!(names.len(), MAX_FAMILY_NAMES); + } + + #[test] + fn a_family_is_found_by_any_of_its_names() { + let names = ["Malgun Gothic", "맑은 고딕"]; + let cat = [face("Malgun Gothic", &names, "malgunbd.ttf", 700.0), face("Malgun Gothic", &names, "malgun.ttf", 400.0)]; + for wanted in ["Malgun Gothic", "malgun gothic", "맑은 고딕"] { + let found: Vec = faces_named(&cat, wanted).iter().map(|f| f.weight).collect(); + assert_eq!(found, [400.0, 700.0], "{wanted:?}: both faces, regular first"); + } + assert!(faces_named(&cat, "맑은").is_empty(), "a part of a name is not a name"); + assert!(faces_named(&cat, "").is_empty()); + } + + #[test] + fn a_name_two_families_claim_has_a_fixed_owner() { + let a = face("Alpha", &["Alpha", "공유"], "a/alpha.ttf", 400.0); + let b = face("Beta", &["Beta", "공유"], "b/beta.ttf", 400.0); + // Whatever order the scan saw the files in, the first by path wins. + for cat in [[a.clone(), b.clone()], [b.clone(), a.clone()]] { + let found = faces_named(&cat, "공유"); + assert_eq!(found.iter().map(|f| f.family.as_str()).collect::>(), ["Alpha"]); + } + // A family's own name beats another family's alias, even one scanned earlier. + let c = face("Gamma", &["Gamma", "Beta"], "0/gamma.ttf", 400.0); + let found = faces_named(&[c, b], "Beta"); + assert_eq!(found.iter().map(|f| f.family.as_str()).collect::>(), ["Beta"]); + } + + #[test] + fn a_legacy_name_picks_its_own_face_out_of_the_family() { + let cat = [ + face("Noto Sans KR", &["Noto Sans KR"], "NotoSansKR-Regular.otf", 400.0), + face("Noto Sans KR", &["Noto Sans KR", "Noto Sans KR Medium"], "NotoSansKR-Medium.otf", 500.0), + ]; + assert_eq!(faces_named(&cat, "Noto Sans KR").len(), 2); + let medium = faces_named(&cat, "Noto Sans KR Medium"); + assert_eq!(medium.iter().map(|f| f.weight).collect::>(), [500.0]); + } + + #[test] + fn installed_names_are_kept_as_the_workbook_spells_them() { + // The binding is keyed on the name `cell_family` looks up, so every spelling in use is + // returned as written: the English and the Korean name both bind, over the same faces. + let cat = [face("Malgun Gothic", &["Malgun Gothic", "맑은 고딕"], "malgun.ttf", 400.0)]; + let used: Vec = ["맑은 고딕", "Malgun Gothic", "Not Installed", "맑은 고딕", "malgun gothic"].map(String::from).to_vec(); + assert_eq!(installed_in(&cat, &used), ["Malgun Gothic", "malgun gothic", "맑은 고딕"]); + assert!(installed_in(&[], &used).is_empty()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_family_bound_under_two_names_shares_its_font_data() { + let dir = std::env::temp_dir().join(format!("gridcraft-fonts-alias-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("Ubuntu-Light.ttf"); + std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write"); + let f = Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into(), "우분투".into()], path, index: 0, weight: 300.0, italic: false }; + + let mut fonts = egui::FontDefinitions::default(); + let before = fonts.font_data.len(); + let mut bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&f)); + bound.extend(bind_faces(&mut fonts, "우분투", std::slice::from_ref(&f))); + assert!(bound.contains(&role_key("우분투", false, false)), "bound under the workbook's name"); + assert!(bound.contains(&role_key("Ubuntu", true, true))); + assert_eq!(fonts.font_data.len(), before + 1, "one file, loaded once"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// On a Windows machine with Malgun Gothic, the real file carries its Korean name. + #[cfg(windows)] + #[test] + fn malgun_gothic_answers_to_its_korean_name_when_installed() { + let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows")); + let Ok(data) = std::fs::read(root.join("Fonts").join("malgun.ttf")) else { return }; + let faces = file_faces(&data); + assert!(faces.iter().any(|(_, family, names, _)| family == "Malgun Gothic" && names.iter().any(|n| n == "맑은 고딕")), "{faces:?}"); + } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c2f96b0c3..d454f0380 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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 { 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 { None } +/// The interface and script fallback font files, each read once per run and kept for the rest of +/// it. egui copies an owned font's bytes into its text engine and keeps the original too +/// (`epaint`'s `blob_from_font_data`), so every one of these files was in memory twice, about +/// 100 MB on a Mac; bytes it borrows for `'static` it shares instead. These faces are loaded by +/// every font rebuild for as long as the app runs, so keeping them costs nothing, and rebuilds no +/// longer read them from disk again. Only a file that parses is kept: a missing or broken one is +/// looked for again next time. +#[cfg(not(target_arch = "wasm32"))] +fn font_file(path: &str, index: u32) -> Option<&'static [u8]> { + static FILES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); + let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(&bytes) = files.get(path) { + return font_parses(bytes, index).then_some(bytes); + } + let bytes = std::fs::read(path).ok()?; + if !font_parses(&bytes, index) { + log::warn!("skipping font {path} (face {index}): not a font file egui can read"); + return None; + } + let bytes: &'static [u8] = Box::leak(bytes.into_boxed_slice()); + files.insert(path.to_string(), bytes); + Some(bytes) +} + /// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not /// parse (and its key from every family), so a bad font costs a fallback, never a crash. pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) { @@ -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 = 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::>(); 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::>(); + let cases: [(&str, Vec, &str, &str); 9] = [ + (CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"), + (CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"), + (CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"), + (CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"), + (CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"), + (SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"), + (MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"), + ("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"), + ("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"), + ]; + for (role, list, fedora_file, earlier) in &cases { + let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}")); + let last_earlier = list.iter().rposition(|p| p.ends_with(earlier)); + assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate"); + assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with"); + assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}"); + } + // Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed. + let mono = paths(candidates(MONO)); + let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf")); + let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf")); + assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}"); + let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf"); + assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}"); + } } diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 08eb8a1b3..83955ee1f 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -8,8 +8,8 @@ use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRe use gridcraft_formula::Expr; use gridcraft_model::{ AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, EditRange, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, - Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, ValidationKind, - XmlColumnPr, + NoteBox, NoteCorner, Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, + ValidationKind, XmlColumnPr, }; use crate::IoError; @@ -212,12 +212,13 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result`). Best effort: the VML is - // only read for that flag, so a part that doesn't parse is skipped without a warning. + // Which notes are displayed (``) and where their boxes sit live in the VML shapes. + // Best effort: the VML is only read for those, so a part that doesn't parse is skipped without + // a warning. if !sheet.comments.is_empty() { for rel in rels.iter().filter(|r| r.kind == "vmlDrawing") { if let Ok(Some(x)) = cx.pkg.read_xml(&rel.target) { - read_note_visibility(&x, &mut sheet, 0); + read_note_shapes(&x, &mut sheet, 0); } } } @@ -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 `` as shown. Shapes may sit in groups. -fn read_note_visibility(x: &El, sheet: &mut Sheet, depth: usize) { +/// Reads the notes' VML shapes: a shape carrying `` marks its note as shown, and its +/// `` and style size become the note's box. Shapes may sit in groups. +fn read_note_shapes(x: &El, sheet: &mut Sheet, depth: usize) { if depth > xml::MAX_DEPTH { return; } for e in &x.children { if e.name != "shape" { - read_note_visibility(e, sheet, depth + 1); + read_note_shapes(e, sheet, depth + 1); continue; } let Some(cd) = e.child("ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue }; - if cd.child("Visible").is_none() { - continue; - } let num = |k: &str| cd.child(k).and_then(|v| v.text.trim().parse::().ok()); - if let (Some(row), Some(col)) = (num("Row"), num("Column")) - && let Some(c) = sheet.comments.get_mut(&CellRef::new(row, col)) - && !c.threaded - { + let (Some(row), Some(col)) = (num("Row"), num("Column")) else { continue }; + let cell = CellRef::new(row, col); + let Some(c) = sheet.comments.get_mut(&cell) else { continue }; + if cd.child("Visible").is_some() && !c.threaded { c.visible = true; } + if let Some(anchor) = cd.child("Anchor") { + c.note_box = note_box(&anchor.text, e.attr("style").unwrap_or(""), cell).filter(|b| *b != crate::sheet_write::DEFAULT_NOTE_BOX); + } } } +/// A note's box from its VML `` (`LeftColumn, LeftOffset, TopRow, TopOffset, +/// RightColumn, RightOffset, BottomRow, BottomOffset`; offsets in pixels) and its shape's style, +/// the corners made relative to the note's `cell`. `None` when the anchor isn't eight in-sheet +/// numbers. +fn note_box(anchor: &str, style: &str, cell: CellRef) -> Option { + let v: Vec = anchor.split(',').map(|p| p.trim().parse::().ok()).collect::>()?; + 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::().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 = 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 }, + ); } } } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 6f486e2b0..97579750b 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -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!("{s}") } } +/// 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 `` 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, - "
{}, 15, {}, 2, {}, 15, {}, 16False{}{}{}
", + "
{anchor}False{}{}{}
", + num(w), + num(h), i + 1, - cell.col + 1, - cell.row, - cell.col + 3, - cell.row + 4, cell.row, cell.col, if c.visible { "" } else { "" } diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index 6d9d4379f..e746b01de 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -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(); diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 5fea7d9c4..c8f199a47 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -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; diff --git a/crates/xlsx/src/tests/note_box.rs b/crates/xlsx/src/tests/note_box.rs new file mode 100644 index 000000000..09a3d68e6 --- /dev/null +++ b/crates/xlsx/src/tests/note_box.rs @@ -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#""#; +const COMMENTS: &str = r#"Anote"#; +/// The issue's note on B2: Excel's box over B1, 224.25 x 89.25 pt. +const VML: &str = r##""##; + +fn package() -> Vec { + minimal( + r#"1"#, + &[("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("1, 10, 0, 2, 5, 32, 5, 8"), "{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("2, 10, 1, 2, 6, 32, 6, 8"), "{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("0, 10, 0, 2, 4, 32, 4, 8"), "{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("2, 15, 1, 2, 4, 15, 5, 16") && 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#""#, + &[("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}"); + } +} diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index b755adb0c..e03c3b0cc 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -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!["\"/> **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 | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fb32e0466..1e21108a1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 `` 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 `` 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 `` 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 `` 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 (`` 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 ` ` (#1488) | | 2026-10-15 | minor | XLSX save keeps legacy `INDEX`/`MATCH` formulas scalar instead of marking them as dynamic arrays (#1505) | diff --git a/docs/function-parity.md b/docs/function-parity.md index 28d5a1574..042e0287d 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -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 ``), 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) | diff --git a/docs/gaps.md b/docs/gaps.md index 025df6d69..7e2e40dc0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 63a995d9d..91ba8481e 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -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%) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 062dfc708..4894d77b1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -28,7 +28,7 @@ workflow, would make GridCraft pre-alpha. | Open a colleague's XLSX, edit, save, hand it back | partial, not blocking | Typical workbooks open and save with cells, styles, CF, validation, tables, names and pivots; unmodelled parts are dropped with a warning (gap #3); Excel-side repair prompts unverified (gap #1); ~100 MB files fail (#175) | 0 for alpha (gaps #1–#3 are beta gates) | | Format and present: number formats, styles, conditional formatting, print or PDF | yes | Full number-format language, 47 cell styles, all CF kinds, Page Break Preview, PDF export and printing. PDF can't carry non-Latin text (gap #5) | 0 | | Analyze: sort, filter, tables, PivotTable | yes | Multi-level sort, AutoFilter, tables with totals, PivotTables with field list, layouts, grouping, refresh and XLSX round-trip. Calculated fields done; slicers missing (gap #8) | 0 | -| Chart the data | partial, not blocking | Column, bar, line, pie, area, scatter and combo charts are created, edited and survive save and reopen; histogram, box, waterfall, funnel, treemap, sunburst and stock lose their kind in XLSX (fix in PRs #158, #161); no axis options or trendlines (gap #4) | 0 for alpha (2–4 h to merge the kind fixes) | +| Chart the data | partial, not blocking | Column, bar, line, pie, area, scatter, combo, histogram, box, waterfall, funnel, treemap, sunburst and stock charts are created, edited and survive save and reopen; trendlines are drawn; no axis options (gap #4) | 0 for alpha | | Import and export CSV | yes | Encoding and delimiter detection on read, CSV/TSV write; exports raw rather than displayed values (gap #19) | 0 | **Passes:** every core workflow works end to end on macOS with save and reopen; the two partial diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f0b4be13c..08a9124bd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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) |