mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Integration: track t13
# Conflicts: # ROADMAP.md # contributors/people.toml # crates/engine/src/cmd/data.rs # crates/xlsx/src/tests/mod.rs # docs/chart-parity.md # docs/file-format-parity.md # docs/function-parity.md # docs/gaps.md # docs/localization-parity.md # docs/target-app-parity.md
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -78,3 +78,15 @@ sync_github_name = true
|
||||
[people.Stefanfra]
|
||||
real_name = "Stefan Fransen"
|
||||
sync_github_name = true
|
||||
|
||||
[people.allenyllee]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Harrison-Booth]
|
||||
real_name = "Harrison Booth"
|
||||
sync_github_name = false
|
||||
|
||||
+157
-12
@@ -1061,13 +1061,43 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"time" => ValidationKind::Time,
|
||||
"textLength" => ValidationKind::TextLength,
|
||||
"custom" => ValidationKind::Custom,
|
||||
_ => ValidationKind::Any,
|
||||
"any" => ValidationKind::Any,
|
||||
// An unknown type used to give an "any value" rule, saved as `type="none"`: no validation.
|
||||
other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))),
|
||||
};
|
||||
if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) {
|
||||
return Err(bad("data.validation", "a list validation needs `formula1`"));
|
||||
}
|
||||
const OPERATORS: [&str; 12] = [
|
||||
"between",
|
||||
"notBetween",
|
||||
"equal",
|
||||
"notEqual",
|
||||
"greater",
|
||||
"greaterThan",
|
||||
"less",
|
||||
"lessThan",
|
||||
"greaterOrEqual",
|
||||
"greaterThanOrEqual",
|
||||
"lessOrEqual",
|
||||
"lessThanOrEqual",
|
||||
];
|
||||
if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) {
|
||||
return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", "))));
|
||||
}
|
||||
let error_style = match str_param(p, "errorStyle") {
|
||||
None | Some("stop") => ErrorStyle::Stop,
|
||||
Some("warning") => ErrorStyle::Warning,
|
||||
Some("information") => ErrorStyle::Information,
|
||||
Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))),
|
||||
};
|
||||
let f1 = str_param(p, "formula1").unwrap_or("");
|
||||
let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() };
|
||||
let dv = Validation {
|
||||
ranges: ranges.clone(),
|
||||
kind,
|
||||
op: op_param(str_param(p, "operator")),
|
||||
f1: str_param(p, "formula1").unwrap_or("").trim_start_matches('=').to_string(),
|
||||
f1,
|
||||
f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()),
|
||||
allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true),
|
||||
in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true),
|
||||
@@ -1076,11 +1106,7 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
show_input: true,
|
||||
error_title: str_param(p, "errorTitle").unwrap_or("").into(),
|
||||
error_message: str_param(p, "errorMessage").unwrap_or("").into(),
|
||||
error_style: match str_param(p, "errorStyle") {
|
||||
Some("warning") => ErrorStyle::Warning,
|
||||
Some("information") => ErrorStyle::Information,
|
||||
_ => ErrorStyle::Stop,
|
||||
},
|
||||
error_style,
|
||||
show_error: true,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
@@ -1091,14 +1117,36 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather
|
||||
/// than a reference or a name to evaluate.
|
||||
fn is_literal_list(f: &str) -> bool {
|
||||
f.starts_with('"')
|
||||
|| (!f.contains('!')
|
||||
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
|
||||
}
|
||||
|
||||
/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's
|
||||
/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves
|
||||
/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a
|
||||
/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before.
|
||||
fn list_source(f: &str) -> String {
|
||||
let t = f.trim();
|
||||
if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) {
|
||||
return f.trim_start_matches('=').to_string();
|
||||
}
|
||||
format!("\"{}\"", t.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// List entries of a list validation (literal or from a range).
|
||||
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
|
||||
let f = dv.f1.trim();
|
||||
if f.starts_with('"')
|
||||
|| (!f.contains('!')
|
||||
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
|
||||
{
|
||||
return f.trim_matches('"').split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
|
||||
if is_literal_list(f) {
|
||||
// A quoted list is a string literal: `""` inside stands for one `"`.
|
||||
let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) {
|
||||
Some(body) => body.replace("\"\"", "\""),
|
||||
None => f.trim_matches('"').to_string(),
|
||||
};
|
||||
return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
|
||||
}
|
||||
let at = dv.ranges.first().map(|r| r.start).unwrap_or_default();
|
||||
match gridcraft_calc::evaluate(wb, sheet, at, f) {
|
||||
@@ -1506,6 +1554,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_validation_without_formula1_is_an_error() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err());
|
||||
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err());
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
|
||||
s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap();
|
||||
assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
|
||||
let mut s = Session::new();
|
||||
@@ -1738,4 +1797,90 @@ mod tests {
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a"));
|
||||
assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() {
|
||||
// In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula.
|
||||
// References, names and formulas stay unquoted, and every rule survives a save and reopen.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap();
|
||||
let rules = [
|
||||
("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#),
|
||||
("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#),
|
||||
("C1", " 1,2,3 ", r#""1,2,3""#),
|
||||
("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#),
|
||||
("E1", "=$K$1:$K$3", "$K$1:$K$3"),
|
||||
("F1", "$K$1:$K$3", "$K$1:$K$3"),
|
||||
("G1", "=Colors", "Colors"),
|
||||
("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"),
|
||||
("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"),
|
||||
];
|
||||
for (cell, source, _) in rules {
|
||||
s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap();
|
||||
}
|
||||
let accepted = [
|
||||
("A1", "In progress"),
|
||||
("B1", "Closed"),
|
||||
("C1", "2"),
|
||||
("D1", r#"Say "hi""#),
|
||||
("E1", "Green"),
|
||||
("F1", "Blue"),
|
||||
("G1", "Red"),
|
||||
("H1", "Green"),
|
||||
];
|
||||
let check = |wb: &Workbook| {
|
||||
for (cell, input) in accepted {
|
||||
let at = CellRef::parse(cell).unwrap();
|
||||
assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}");
|
||||
assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending");
|
||||
}
|
||||
};
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
check(&wb);
|
||||
|
||||
let sheet_xml = |wb: &Workbook| {
|
||||
let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap();
|
||||
xml
|
||||
};
|
||||
let xml = sheet_xml(&wb);
|
||||
for (cell, _, saved) in rules {
|
||||
assert!(xml.contains(&format!(r#"sqref="{cell}"><formula1>{saved}</formula1>"#)), "{cell}: {saved} missing from {xml}");
|
||||
}
|
||||
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
|
||||
check(&reopened);
|
||||
assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() {
|
||||
// A typo used to give a rule that validates nothing (`type="none"`) or the wrong check,
|
||||
// with no error.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for params in [
|
||||
json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}),
|
||||
json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}),
|
||||
json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}),
|
||||
json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}),
|
||||
] {
|
||||
let e = s.execute("data.validation", params.clone()).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown"), "{params}: {e}");
|
||||
}
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
|
||||
for params in [
|
||||
json!({"range": "A1", "type": "any"}),
|
||||
json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}),
|
||||
json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}),
|
||||
] {
|
||||
s.execute("data.validation", params).unwrap();
|
||||
}
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert_eq!(sh.validations.len(), 3);
|
||||
assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1305,7 +1305,16 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.comments.insert(
|
||||
at,
|
||||
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
|
||||
Comment {
|
||||
author: author.clone(),
|
||||
text: text.clone(),
|
||||
replies: vec![],
|
||||
threaded,
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
},
|
||||
);
|
||||
Ok(json!({"cell": at.a1()}))
|
||||
})
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -323,6 +323,34 @@ pub struct Comment {
|
||||
/// read from the file (`dT`); empty when unknown, e.g. for comments made here.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dates: Vec<String>,
|
||||
/// Where the note's box sits and how big it is, as read from the file; `None` (a note made
|
||||
/// here, or a file without the shape) writes the default box next to the cell.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note_box: Option<NoteBox>,
|
||||
}
|
||||
|
||||
/// A note box's place and size, from the note's VML shape: Excel's `<x:Anchor>` with its corners
|
||||
/// stored relative to the note's cell, so the box moves with the note when rows or columns are
|
||||
/// inserted or deleted, and the shape's width and height.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NoteBox {
|
||||
/// Top-left corner: columns and rows from the note's cell (negative = left of or above it),
|
||||
/// then the pixel offset into that column and row.
|
||||
pub from: NoteCorner,
|
||||
/// Bottom-right corner, the same way.
|
||||
pub to: NoteCorner,
|
||||
/// The shape's width and height in points (the VML style's `width`/`height`), when given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size_pt: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// One corner of a [`NoteBox`].
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NoteCorner {
|
||||
pub col: i64,
|
||||
pub col_px: u32,
|
||||
pub row: i64,
|
||||
pub row_px: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
+229
-31
@@ -19,11 +19,20 @@ use std::sync::OnceLock;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_FONT_FILE: u64 = 64 << 20;
|
||||
|
||||
/// A face keeps at most this many family names, so a hostile `name` table can't grow the catalog.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_FAMILY_NAMES: usize = 16;
|
||||
|
||||
/// One face of one installed font file.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Face {
|
||||
/// Typographic family name, e.g. `"Segoe UI"`.
|
||||
/// Typographic family name, e.g. `"Segoe UI"` (the English one when the font has it): what the
|
||||
/// font list shows.
|
||||
pub family: String,
|
||||
/// Every family name the face carries, `family` first, then each language's typographic and
|
||||
/// legacy family names (`"Malgun Gothic"`, `"맑은 고딕"`). A workbook may name the font by any of
|
||||
/// them: Korean Excel saves `맑은 고딕` (#1898).
|
||||
pub names: Vec<String>,
|
||||
/// The file the face lives in.
|
||||
pub path: PathBuf,
|
||||
/// Face index within the file (collections hold several, e.g. a family's Regular and Bold).
|
||||
@@ -88,6 +97,13 @@ fn style_weight(style: &str) -> f32 {
|
||||
TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0)
|
||||
}
|
||||
|
||||
impl Face {
|
||||
/// Whether the face goes by `name` in any language.
|
||||
pub fn answers_to(&self, name: &str) -> bool {
|
||||
self.names.iter().any(|n| n.eq_ignore_ascii_case(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a style name reads as italic or oblique.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn style_italic(style: &str) -> bool {
|
||||
@@ -95,9 +111,10 @@ fn style_italic(style: &str) -> bool {
|
||||
s.contains("italic") || s.contains("oblique")
|
||||
}
|
||||
|
||||
/// Every face in `data` as `(index, family, style)`. Damaged data yields nothing, never a panic.
|
||||
/// Every face in `data` as `(index, family, names, style)`, where `names` holds every family name
|
||||
/// (see [`Face::names`]). Damaged data yields nothing, never a panic.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> {
|
||||
fn file_faces(data: &[u8]) -> Vec<(u32, String, Vec<String>, String)> {
|
||||
use skrifa::raw::FileRef;
|
||||
use skrifa::{MetadataProvider, string::StringId};
|
||||
let count = match FileRef::new(data) {
|
||||
@@ -108,12 +125,23 @@ fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> {
|
||||
let name = |f: &skrifa::FontRef<'_>, ids: &[StringId]| -> Option<String> {
|
||||
ids.iter().find_map(|id| f.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
};
|
||||
let family_ids = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME];
|
||||
(0..count)
|
||||
.filter_map(|i| {
|
||||
let f = skrifa::FontRef::from_index(data, i).ok()?;
|
||||
let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
|
||||
let family = name(&f, &family_ids)?;
|
||||
let mut names = vec![family.clone()];
|
||||
for s in family_ids.iter().flat_map(|id| f.localized_strings(*id)) {
|
||||
if names.len() >= MAX_FAMILY_NAMES {
|
||||
break;
|
||||
}
|
||||
let s = s.to_string();
|
||||
if !s.is_empty() && !names.iter().any(|n| n.eq_ignore_ascii_case(&s)) {
|
||||
names.push(s);
|
||||
}
|
||||
}
|
||||
let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
|
||||
Some((i, family, style))
|
||||
Some((i, family, names, style))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -143,8 +171,8 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&path) else { continue };
|
||||
for (index, family, style) in file_faces(&data) {
|
||||
out.push(Face { family, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) });
|
||||
for (index, family, names, style) in file_faces(&data) {
|
||||
out.push(Face { family, names, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,29 +216,50 @@ pub fn scan_done() -> bool {
|
||||
CATALOG.get().is_some()
|
||||
}
|
||||
|
||||
/// The faces of one installed family, regular-weight first (the order roles pick from).
|
||||
/// The faces of one installed family, by any of its names, regular-weight first (the order roles
|
||||
/// pick from).
|
||||
pub fn faces_of(family: &str) -> Vec<Face> {
|
||||
let Some(cat) = catalog_now() else { return Vec::new() };
|
||||
let mut v: Vec<Face> = cat.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).cloned().collect();
|
||||
catalog_now().map(|cat| faces_named(cat, family)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The faces in `cat` that go by `wanted`. When two families claim one name the winner is fixed,
|
||||
/// not left to the scan's directory order: a family whose own (English) name it is wins, else the
|
||||
/// family of the first claiming face by path and index. Only the faces that carry the name are
|
||||
/// kept, so a legacy name (`"Arial Black"`, name ID 1) picks its own face out of its family.
|
||||
fn faces_named(cat: &[Face], wanted: &str) -> Vec<Face> {
|
||||
let mut v: Vec<Face> = if cat.iter().any(|f| f.family.eq_ignore_ascii_case(wanted)) {
|
||||
cat.iter().filter(|f| f.family.eq_ignore_ascii_case(wanted)).cloned().collect()
|
||||
} else {
|
||||
let owner = cat.iter().filter(|f| f.answers_to(wanted)).min_by(|a, b| a.path.cmp(&b.path).then(a.index.cmp(&b.index)));
|
||||
match owner {
|
||||
Some(o) => cat.iter().filter(|f| f.family.eq_ignore_ascii_case(&o.family) && f.answers_to(wanted)).cloned().collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
v.sort_by(|a, b| a.weight.total_cmp(&b.weight).then(a.italic.cmp(&b.italic)).then(a.index.cmp(&b.index)));
|
||||
v
|
||||
}
|
||||
|
||||
/// The subset of `names` that is installed, sorted and deduped. These are the families whose files
|
||||
/// get bound for a workbook.
|
||||
/// get bound for a workbook, under the name the workbook uses: that is the name
|
||||
/// [`cell_family`](crate::theme::cell_family) looks up, so `맑은 고딕` binds as `맑은 고딕`, not as
|
||||
/// the catalog's `Malgun Gothic`.
|
||||
pub fn installed_among(names: &[String]) -> Vec<String> {
|
||||
let Some(cat) = catalog_now() else { return Vec::new() };
|
||||
catalog_now().map(|cat| installed_in(cat, names)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`installed_among`] over a given catalog. Names dedupe exactly, not case-folded: a role key is
|
||||
/// built from the cell's exact string, so `Calibri` and `calibri` each need their own binding (the
|
||||
/// font data behind them is shared, see [`face_key`]).
|
||||
fn installed_in(cat: &[Face], names: &[String]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
for wanted in names {
|
||||
if !seen.insert(wanted.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = cat.iter().find(|f| f.family.eq_ignore_ascii_case(wanted)) {
|
||||
out.push(f.family.clone());
|
||||
if seen.insert(wanted.as_str()) && cat.iter().any(|f| f.answers_to(wanted)) {
|
||||
out.push(wanted.clone());
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|a| a.to_lowercase());
|
||||
out.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -254,13 +303,14 @@ fn pick_face(faces: &[Face], bold: bool, italic: bool) -> Option<&Face> {
|
||||
.or_else(|| faces.first())
|
||||
}
|
||||
|
||||
/// A stable key for a face's font data, unique per file/index within the process.
|
||||
fn face_key(family: &str, face: &Face) -> String {
|
||||
let file = face.path.file_name().and_then(|s| s.to_str()).unwrap_or("font");
|
||||
format!("fnt-src:{family}:{file}:{}", face.index)
|
||||
/// A stable key for a face's font data, one per file and index: every name a family is bound under
|
||||
/// (`Malgun Gothic`, `맑은 고딕`) shares the data instead of loading the file again.
|
||||
fn face_key(face: &Face) -> String {
|
||||
format!("fnt-src:{}:{}", face.path.display(), face.index)
|
||||
}
|
||||
|
||||
/// Bind one installed family's faces into `fonts`, one egui family per cell role, and return the
|
||||
/// Bind one installed family's faces into `fonts` under `family` (the name the workbook uses), one
|
||||
/// egui family per cell role, and return the
|
||||
/// role keys that are now bound. A role with no exact face chains to the family's regular face, so
|
||||
/// no role is ever left pointing at missing data (egui panics on that). A family with no faces
|
||||
/// binds nothing.
|
||||
@@ -278,14 +328,16 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
for face in [Some(primary), pick_face(faces, false, false)] {
|
||||
let Some(face) = face else { continue };
|
||||
let key = face_key(family, face);
|
||||
let key = face_key(face);
|
||||
if chain.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&face.path) else { continue };
|
||||
let mut fd = egui::FontData::from_owned(bytes);
|
||||
fd.index = face.index;
|
||||
fonts.font_data.entry(key.clone()).or_insert_with(|| std::sync::Arc::new(fd));
|
||||
if !fonts.font_data.contains_key(&key) {
|
||||
let Ok(bytes) = std::fs::read(&face.path) else { continue };
|
||||
let mut fd = egui::FontData::from_owned(bytes);
|
||||
fd.index = face.index;
|
||||
fonts.font_data.insert(key.clone(), std::sync::Arc::new(fd));
|
||||
}
|
||||
chain.push(key);
|
||||
}
|
||||
if chain.is_empty() {
|
||||
@@ -299,7 +351,7 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face
|
||||
bound
|
||||
}
|
||||
|
||||
/// Bind every installed face of `family` (see [`bind_faces`]).
|
||||
/// Bind every installed face of `family`, found by any of its names (see [`bind_faces`]).
|
||||
pub fn bind_family(fonts: &mut egui::FontDefinitions, family: &str) -> Vec<String> {
|
||||
bind_faces(fonts, family, &faces_of(family))
|
||||
}
|
||||
@@ -407,7 +459,7 @@ mod tests {
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let path = dir.join("Ubuntu-Light.ttf");
|
||||
std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write");
|
||||
let face = Face { family: "Ubuntu".into(), path, index: 0, weight: 300.0, italic: false };
|
||||
let face = Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into()], path, index: 0, weight: 300.0, italic: false };
|
||||
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&face));
|
||||
@@ -420,4 +472,150 @@ mod tests {
|
||||
assert!(bind_faces(&mut fonts, "No Such Font", &[]).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A font that is just a table directory and a `name` table holding `records` as
|
||||
/// `(Windows language id, name id, text)`: all [`file_faces`] reads.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn font_with_names(records: &[(u16, u16, &str)]) -> Vec<u8> {
|
||||
fn be(v: &mut Vec<u8>, x: u16) {
|
||||
v.extend_from_slice(&x.to_be_bytes());
|
||||
}
|
||||
let mut name: Vec<u8> = Vec::new();
|
||||
let mut strings: Vec<u8> = Vec::new();
|
||||
for x in [0, records.len() as u16, (6 + 12 * records.len()) as u16] {
|
||||
be(&mut name, x);
|
||||
}
|
||||
for (language, id, text) in records {
|
||||
let utf16: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
// Platform 3 (Windows), encoding 1 (Unicode BMP).
|
||||
for x in [3, 1, *language, *id, utf16.len() as u16, strings.len() as u16] {
|
||||
be(&mut name, x);
|
||||
}
|
||||
strings.extend(utf16);
|
||||
}
|
||||
name.extend(strings);
|
||||
let mut font: Vec<u8> = 0x0001_0000u32.to_be_bytes().to_vec();
|
||||
for x in [1, 16, 0, 0] {
|
||||
be(&mut font, x);
|
||||
}
|
||||
font.extend(b"name");
|
||||
font.extend(0u32.to_be_bytes());
|
||||
font.extend(28u32.to_be_bytes());
|
||||
font.extend((name.len() as u32).to_be_bytes());
|
||||
font.extend(name);
|
||||
font
|
||||
}
|
||||
|
||||
const EN: u16 = 0x0409;
|
||||
const KO: u16 = 0x0412;
|
||||
|
||||
fn face(family: &str, names: &[&str], path: &str, weight: f32) -> Face {
|
||||
Face { family: family.into(), names: names.iter().map(|s| s.to_string()).collect(), path: path.into(), index: 0, weight, italic: false }
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_face_keeps_its_family_names_in_every_language() {
|
||||
// Korean Excel saves `맑은 고딕`, so the Korean name has to be kept next to the English one (#1898).
|
||||
let font = font_with_names(&[(EN, 1, "Malgun Gothic"), (EN, 2, "Regular"), (KO, 1, "맑은 고딕"), (KO, 2, "보통")]);
|
||||
let faces = file_faces(&font);
|
||||
assert_eq!(faces.len(), 1);
|
||||
let (_, family, names, style) = &faces[0];
|
||||
assert_eq!(family, "Malgun Gothic", "the font list keeps the English name");
|
||||
assert_eq!(names, &["Malgun Gothic", "맑은 고딕"]);
|
||||
assert_eq!(style, "Regular");
|
||||
|
||||
// Both name IDs are kept: a legacy family name (ID 1) names a single face (#1606).
|
||||
let font = font_with_names(&[(EN, 1, "Noto Sans KR Medium"), (EN, 2, "Regular"), (EN, 16, "Noto Sans KR"), (EN, 17, "Medium")]);
|
||||
let (_, family, names, _) = &file_faces(&font)[0];
|
||||
assert_eq!(family, "Noto Sans KR");
|
||||
assert_eq!(names, &["Noto Sans KR", "Noto Sans KR Medium"]);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_name_table_full_of_names_is_capped() {
|
||||
let texts: Vec<String> = (0..100).map(|i| format!("Family {i}")).collect();
|
||||
let records: Vec<(u16, u16, &str)> = texts.iter().enumerate().map(|(i, t)| (0x0400 + i as u16, 1, t.as_str())).collect();
|
||||
let (_, _, names, _) = &file_faces(&font_with_names(&records))[0];
|
||||
assert_eq!(names.len(), MAX_FAMILY_NAMES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_family_is_found_by_any_of_its_names() {
|
||||
let names = ["Malgun Gothic", "맑은 고딕"];
|
||||
let cat = [face("Malgun Gothic", &names, "malgunbd.ttf", 700.0), face("Malgun Gothic", &names, "malgun.ttf", 400.0)];
|
||||
for wanted in ["Malgun Gothic", "malgun gothic", "맑은 고딕"] {
|
||||
let found: Vec<f32> = faces_named(&cat, wanted).iter().map(|f| f.weight).collect();
|
||||
assert_eq!(found, [400.0, 700.0], "{wanted:?}: both faces, regular first");
|
||||
}
|
||||
assert!(faces_named(&cat, "맑은").is_empty(), "a part of a name is not a name");
|
||||
assert!(faces_named(&cat, "").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_two_families_claim_has_a_fixed_owner() {
|
||||
let a = face("Alpha", &["Alpha", "공유"], "a/alpha.ttf", 400.0);
|
||||
let b = face("Beta", &["Beta", "공유"], "b/beta.ttf", 400.0);
|
||||
// Whatever order the scan saw the files in, the first by path wins.
|
||||
for cat in [[a.clone(), b.clone()], [b.clone(), a.clone()]] {
|
||||
let found = faces_named(&cat, "공유");
|
||||
assert_eq!(found.iter().map(|f| f.family.as_str()).collect::<Vec<_>>(), ["Alpha"]);
|
||||
}
|
||||
// A family's own name beats another family's alias, even one scanned earlier.
|
||||
let c = face("Gamma", &["Gamma", "Beta"], "0/gamma.ttf", 400.0);
|
||||
let found = faces_named(&[c, b], "Beta");
|
||||
assert_eq!(found.iter().map(|f| f.family.as_str()).collect::<Vec<_>>(), ["Beta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_name_picks_its_own_face_out_of_the_family() {
|
||||
let cat = [
|
||||
face("Noto Sans KR", &["Noto Sans KR"], "NotoSansKR-Regular.otf", 400.0),
|
||||
face("Noto Sans KR", &["Noto Sans KR", "Noto Sans KR Medium"], "NotoSansKR-Medium.otf", 500.0),
|
||||
];
|
||||
assert_eq!(faces_named(&cat, "Noto Sans KR").len(), 2);
|
||||
let medium = faces_named(&cat, "Noto Sans KR Medium");
|
||||
assert_eq!(medium.iter().map(|f| f.weight).collect::<Vec<_>>(), [500.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_names_are_kept_as_the_workbook_spells_them() {
|
||||
// The binding is keyed on the name `cell_family` looks up, so every spelling in use is
|
||||
// returned as written: the English and the Korean name both bind, over the same faces.
|
||||
let cat = [face("Malgun Gothic", &["Malgun Gothic", "맑은 고딕"], "malgun.ttf", 400.0)];
|
||||
let used: Vec<String> = ["맑은 고딕", "Malgun Gothic", "Not Installed", "맑은 고딕", "malgun gothic"].map(String::from).to_vec();
|
||||
assert_eq!(installed_in(&cat, &used), ["Malgun Gothic", "malgun gothic", "맑은 고딕"]);
|
||||
assert!(installed_in(&[], &used).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_family_bound_under_two_names_shares_its_font_data() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-fonts-alias-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let path = dir.join("Ubuntu-Light.ttf");
|
||||
std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write");
|
||||
let f = Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into(), "우분투".into()], path, index: 0, weight: 300.0, italic: false };
|
||||
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let before = fonts.font_data.len();
|
||||
let mut bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&f));
|
||||
bound.extend(bind_faces(&mut fonts, "우분투", std::slice::from_ref(&f)));
|
||||
assert!(bound.contains(&role_key("우분투", false, false)), "bound under the workbook's name");
|
||||
assert!(bound.contains(&role_key("Ubuntu", true, true)));
|
||||
assert_eq!(fonts.font_data.len(), before + 1, "one file, loaded once");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// On a Windows machine with Malgun Gothic, the real file carries its Korean name.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn malgun_gothic_answers_to_its_korean_name_when_installed() {
|
||||
let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows"));
|
||||
let Ok(data) = std::fs::read(root.join("Fonts").join("malgun.ttf")) else { return };
|
||||
let faces = file_faces(&data);
|
||||
assert!(faces.iter().any(|(_, family, names, _)| family == "Malgun Gothic" && names.iter().any(|n| n == "맑은 고딕")), "{faces:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+185
-12
@@ -243,12 +243,8 @@ pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
|
||||
for (p, index) in paths {
|
||||
if let Ok(bytes) = std::fs::read(p) {
|
||||
if !font_parses(&bytes, *index) {
|
||||
log::warn!("skipping font {p} (face {index}): not a font file egui can read");
|
||||
continue;
|
||||
}
|
||||
let mut fd = FontData::from_owned(bytes);
|
||||
if let Some(bytes) = font_file(p, *index) {
|
||||
let mut fd = FontData::from_static(bytes);
|
||||
fd.index = *index;
|
||||
return Some(fd);
|
||||
}
|
||||
@@ -256,6 +252,30 @@ fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The interface and script fallback font files, each read once per run and kept for the rest of
|
||||
/// it. egui copies an owned font's bytes into its text engine and keeps the original too
|
||||
/// (`epaint`'s `blob_from_font_data`), so every one of these files was in memory twice, about
|
||||
/// 100 MB on a Mac; bytes it borrows for `'static` it shares instead. These faces are loaded by
|
||||
/// every font rebuild for as long as the app runs, so keeping them costs nothing, and rebuilds no
|
||||
/// longer read them from disk again. Only a file that parses is kept: a missing or broken one is
|
||||
/// looked for again next time.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn font_file(path: &str, index: u32) -> Option<&'static [u8]> {
|
||||
static FILES: std::sync::Mutex<std::collections::BTreeMap<String, &'static [u8]>> = std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||
let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(&bytes) = files.get(path) {
|
||||
return font_parses(bytes, index).then_some(bytes);
|
||||
}
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
if !font_parses(&bytes, index) {
|
||||
log::warn!("skipping font {path} (face {index}): not a font file egui can read");
|
||||
return None;
|
||||
}
|
||||
let bytes: &'static [u8] = Box::leak(bytes.into_boxed_slice());
|
||||
files.insert(path.to_string(), bytes);
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not
|
||||
/// parse (and its key from every family), so a bad font costs a fallback, never a crash.
|
||||
pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) {
|
||||
@@ -360,6 +380,8 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
|
||||
add(format!("{d}/crosextra/{carlito}"), 0);
|
||||
add(format!("{d}/carlito/{carlito}"), 0);
|
||||
}
|
||||
// Fedora keeps each family in its own package folder, as for the UI fonts (#54).
|
||||
add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0);
|
||||
add(home_fonts(carlito), 0);
|
||||
add(format!("{mac_sup}/{arial_mac}"), 0);
|
||||
add(format!("{win}\\{arial_win}"), 0);
|
||||
@@ -367,6 +389,8 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
|
||||
add(format!("{d}/liberation/{lib}"), 0);
|
||||
add(format!("{d}/dejavu/{dejavu}"), 0);
|
||||
}
|
||||
add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0);
|
||||
add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0);
|
||||
}
|
||||
"serif" => {
|
||||
add(format!("{mac_sup}/Times New Roman.ttf"), 0);
|
||||
@@ -375,6 +399,8 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
|
||||
add(format!("{d}/liberation/LiberationSerif-Regular.ttf"), 0);
|
||||
add(format!("{d}/dejavu/DejaVuSerif.ttf"), 0);
|
||||
}
|
||||
add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0);
|
||||
add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0);
|
||||
}
|
||||
"mono" => {
|
||||
add(format!("{mac_sys}/SFNSMono.ttf"), 0);
|
||||
@@ -383,6 +409,14 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
|
||||
for d in lin {
|
||||
add(format!("{d}/dejavu/DejaVuSansMono.ttf"), 0);
|
||||
}
|
||||
add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0);
|
||||
// Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice
|
||||
// (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always.
|
||||
for d in lin {
|
||||
add(format!("{d}/liberation/LiberationMono-Regular.ttf"), 0);
|
||||
}
|
||||
add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0);
|
||||
add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -432,7 +466,12 @@ pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -
|
||||
let paths = candidates(role);
|
||||
let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect();
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
if let Some(fd) = try_load(&refs) {
|
||||
if let Some(mut fd) = try_load(&refs) {
|
||||
// Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina
|
||||
// screen (#1184). Core Text never runs them, so this draws it the way macOS does.
|
||||
if cfg!(target_os = "macos") && role == UI_BOLD {
|
||||
fd.tweak.hinting = Some(false);
|
||||
}
|
||||
let key = format!("sys-{role}");
|
||||
fonts.font_data.insert(key.clone(), Arc::new(fd));
|
||||
chain.push(key);
|
||||
@@ -526,6 +565,7 @@ fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> {
|
||||
second,
|
||||
("sys-cjk-hangul", cjk_hangul()),
|
||||
("sys-thai", thai()),
|
||||
("sys-lao", lao()),
|
||||
("sys-hebrew", hebrew()),
|
||||
("sys-symbols", symbols()),
|
||||
("sys-symbols-2", symbols_more()),
|
||||
@@ -534,9 +574,23 @@ fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> {
|
||||
|
||||
/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`,
|
||||
/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux
|
||||
/// container (`chromeos/notocjk`, #1762), plus local and flat layouts.
|
||||
/// container (`chromeos/notocjk`, #1762), plus local and
|
||||
/// flat layouts. Flatpak exposes the same host layouts through read-only font mounts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const LINUX_CJK_DIRS: [&str; 11] = [
|
||||
const LINUX_CJK_DIRS: [&str; 22] = [
|
||||
// Flatpak mounts host fonts here without a broad filesystem permission. Keep the same
|
||||
// distribution layouts as below; /usr/share/fonts inside the sandbox belongs to the runtime.
|
||||
"/run/host/fonts/opentype/noto",
|
||||
"/run/host/fonts/google-noto-cjk",
|
||||
"/run/host/fonts/noto-cjk",
|
||||
"/run/host/fonts/truetype/noto",
|
||||
"/run/host/fonts/noto",
|
||||
"/run/host/fonts/truetype",
|
||||
"/run/host/fonts/TTF",
|
||||
"/run/host/fonts",
|
||||
"/run/host/local-fonts",
|
||||
"/run/host/local-fonts/noto-cjk",
|
||||
"/run/host/user-fonts",
|
||||
"/usr/share/fonts/opentype/noto",
|
||||
"/usr/share/fonts/google-noto-cjk",
|
||||
"/usr/share/fonts/noto-cjk",
|
||||
@@ -635,6 +689,28 @@ fn thai() -> Vec<(String, u32)> {
|
||||
v.push((format!("{d}/tlwg/Garuda.ttf"), 0));
|
||||
v.push((format!("{d}/garuda/Garuda.ttf"), 0));
|
||||
}
|
||||
// Fedora ships the variable Noto Sans Thai; its default instance is Regular.
|
||||
v.push(("/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf".into(), 0));
|
||||
v
|
||||
}
|
||||
|
||||
/// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam
|
||||
/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn lao() -> Vec<(String, u32)> {
|
||||
let mac_sup = "/System/Library/Fonts/Supplemental";
|
||||
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
||||
let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"];
|
||||
let mut v: Vec<(String, u32)> = Vec::new();
|
||||
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
|
||||
v.push((format!("{win}\\dokchamp.ttf"), 0)); // DokChampa
|
||||
v.push((format!("{mac_sup}/Lao Sangam MN.ttf"), 0));
|
||||
v.push((format!("{mac_sup}/Lao MN.ttc"), 0));
|
||||
for d in lin {
|
||||
v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0));
|
||||
v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0));
|
||||
v.push((format!("{d}/Phetsarath_OT.ttf"), 0));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -667,6 +743,7 @@ fn hebrew() -> Vec<(String, u32)> {
|
||||
v.push((format!("{d}/dejavu/DejaVuSans.ttf"), 0));
|
||||
v.push((format!("{d}/freefont/FreeSans.ttf"), 0));
|
||||
}
|
||||
v.push(("/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf".into(), 0));
|
||||
v
|
||||
}
|
||||
|
||||
@@ -864,11 +941,11 @@ mod tests {
|
||||
let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
keys(HanOrder::Japanese),
|
||||
["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
|
||||
["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
|
||||
);
|
||||
assert_eq!(
|
||||
keys(HanOrder::Chinese),
|
||||
["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
|
||||
["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
|
||||
);
|
||||
assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese);
|
||||
for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] {
|
||||
@@ -876,6 +953,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_fallback_probes_flatpak_fonts_with_the_correct_locale_faces() {
|
||||
// Exercise the candidates the fallback loader actually consumes, not just the directory
|
||||
// constant. This must work on CI without an installed CJK font or a Flatpak runtime.
|
||||
for (candidates, face) in [(cjk_han_japanese(), 0), (cjk_han_simplified(), 2), (cjk_hangul(), 1)] {
|
||||
for directory in [
|
||||
"/run/host/fonts/opentype/noto",
|
||||
"/run/host/fonts/google-noto-cjk",
|
||||
"/run/host/fonts/noto-cjk",
|
||||
"/run/host/fonts/truetype/noto",
|
||||
"/run/host/fonts/noto",
|
||||
"/run/host/fonts/truetype",
|
||||
"/run/host/fonts/TTF",
|
||||
"/run/host/fonts",
|
||||
"/run/host/local-fonts",
|
||||
"/run/host/local-fonts/noto-cjk",
|
||||
"/run/host/user-fonts",
|
||||
] {
|
||||
let path = format!("{directory}/NotoSansCJK-Regular.ttc");
|
||||
assert!(candidates.contains(&(path.clone(), face)), "missing Flatpak CJK candidate {path}, face {face}");
|
||||
}
|
||||
}
|
||||
let chinese = cjk_han_simplified();
|
||||
assert!(chinese.contains(&("/run/host/fonts/truetype/wqy/wqy-microhei.ttc".into(), 0)));
|
||||
assert!(chinese.contains(&("/run/host/fonts/wenquanyi/wqy-microhei.ttc".into(), 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_faces_load_each_file_once() {
|
||||
// The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded
|
||||
@@ -897,11 +1001,12 @@ mod tests {
|
||||
// Real render check, per script: the proportional family (what cells and the UI use) must
|
||||
// resolve a sample string to a real glyph, not the tofu replacement char. Skips a script
|
||||
// whose face is absent (a minimal machine) and fails if a face is present but not wired in.
|
||||
let cases: [(&str, Vec<(String, u32)>, &str); 7] = [
|
||||
let cases: [(&str, Vec<(String, u32)>, &str); 8] = [
|
||||
("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"),
|
||||
("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"),
|
||||
("sys-cjk-hangul", cjk_hangul(), "한글"),
|
||||
("sys-thai", thai(), "ทดสอบ"),
|
||||
("sys-lao", lao(), "ພາສາລາວ"),
|
||||
("sys-hebrew", hebrew(), "עברית"),
|
||||
("ui", candidates(UI), "Русский"),
|
||||
// Czech interface labels: every Czech accented letter, in both cases.
|
||||
@@ -999,4 +1104,72 @@ mod tests {
|
||||
assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn interface_fonts_are_read_once_and_shared_with_egui() {
|
||||
// egui copies owned font bytes into its text engine and keeps the original too; borrowed
|
||||
// bytes it shares. A rebuild of the font set reuses the file read the first time.
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id()));
|
||||
std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap();
|
||||
let p = path.to_string_lossy().into_owned();
|
||||
let first = try_load(&[(p.as_str(), 0)]).unwrap();
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read");
|
||||
let (std::borrow::Cow::Borrowed(a), std::borrow::Cow::Borrowed(b)) = (&first.font, &second.font) else {
|
||||
panic!("interface font bytes must be borrowed, so egui shares them instead of copying");
|
||||
};
|
||||
assert!(std::ptr::eq(*a, *b));
|
||||
assert_eq!(*a, epaint_default_fonts::UBUNTU_LIGHT);
|
||||
assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_bold_interface_face_is_drawn_unhinted() {
|
||||
// #1184: Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a
|
||||
// Retina screen. Core Text never runs them, so on macOS the face goes unhinted like every
|
||||
// other app draws it; the regular face and the other platforms keep egui's default.
|
||||
let fonts = font_definitions_for_language(Language::En);
|
||||
let hinting = |key: &str| fonts.font_data.get(key).map(|fd| fd.tweak.hinting);
|
||||
if let Some(bold) = hinting(&format!("sys-{UI_BOLD}")) {
|
||||
assert_eq!(bold, if cfg!(target_os = "macos") { Some(false) } else { None });
|
||||
}
|
||||
if let Some(regular) = hinting(&format!("sys-{UI}")) {
|
||||
assert_eq!(regular, None, "only the bold face is affected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fedora_package_folders_are_searched_after_the_other_linux_layouts() {
|
||||
// #1694: Fedora keeps each font family in its own package folder, so without these entries
|
||||
// no cell face is found there and bold and italic cells draw as regular text. Each comes
|
||||
// after the existing Linux folders for the same font, so wherever one of those matched,
|
||||
// nothing changes.
|
||||
let paths = |cands: Vec<(String, u32)>| cands.into_iter().map(|(p, _)| p).collect::<Vec<_>>();
|
||||
let cases: [(&str, Vec<String>, &str, &str); 9] = [
|
||||
(CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"),
|
||||
(CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"),
|
||||
(CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"),
|
||||
(CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"),
|
||||
(CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"),
|
||||
(SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"),
|
||||
(MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"),
|
||||
("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"),
|
||||
("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"),
|
||||
];
|
||||
for (role, list, fedora_file, earlier) in &cases {
|
||||
let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}"));
|
||||
let last_earlier = list.iter().rposition(|p| p.ends_with(earlier));
|
||||
assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate");
|
||||
assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with");
|
||||
assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}");
|
||||
}
|
||||
// Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed.
|
||||
let mono = paths(candidates(MONO));
|
||||
let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf"));
|
||||
let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf"));
|
||||
assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}");
|
||||
let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf");
|
||||
assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRe
|
||||
use gridcraft_formula::Expr;
|
||||
use gridcraft_model::{
|
||||
AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, EditRange, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo,
|
||||
Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, ValidationKind,
|
||||
XmlColumnPr,
|
||||
NoteBox, NoteCorner, Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation,
|
||||
ValidationKind, XmlColumnPr,
|
||||
};
|
||||
|
||||
use crate::IoError;
|
||||
@@ -212,12 +212,13 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
||||
read_threaded(cx, &x, &mut sheet);
|
||||
}
|
||||
}
|
||||
// Which notes are displayed lives in the VML shapes (`<x:Visible/>`). Best effort: the VML is
|
||||
// only read for that flag, so a part that doesn't parse is skipped without a warning.
|
||||
// Which notes are displayed (`<x:Visible/>`) and where their boxes sit live in the VML shapes.
|
||||
// Best effort: the VML is only read for those, so a part that doesn't parse is skipped without
|
||||
// a warning.
|
||||
if !sheet.comments.is_empty() {
|
||||
for rel in rels.iter().filter(|r| r.kind == "vmlDrawing") {
|
||||
if let Ok(Some(x)) = cx.pkg.read_xml(&rel.target) {
|
||||
read_note_visibility(&x, &mut sheet, 0);
|
||||
read_note_shapes(&x, &mut sheet, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -895,34 +896,63 @@ fn read_comments(x: &El, sheet: &mut Sheet) {
|
||||
{
|
||||
text = rest.strip_prefix('\n').unwrap_or(rest).to_string();
|
||||
}
|
||||
sheet.comments.insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![] });
|
||||
sheet
|
||||
.comments
|
||||
.insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![], note_box: None });
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the notes whose VML shape carries `<x:Visible/>` as shown. Shapes may sit in groups.
|
||||
fn read_note_visibility(x: &El, sheet: &mut Sheet, depth: usize) {
|
||||
/// Reads the notes' VML shapes: a shape carrying `<x:Visible/>` marks its note as shown, and its
|
||||
/// `<x:Anchor>` and style size become the note's box. Shapes may sit in groups.
|
||||
fn read_note_shapes(x: &El, sheet: &mut Sheet, depth: usize) {
|
||||
if depth > xml::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for e in &x.children {
|
||||
if e.name != "shape" {
|
||||
read_note_visibility(e, sheet, depth + 1);
|
||||
read_note_shapes(e, sheet, depth + 1);
|
||||
continue;
|
||||
}
|
||||
let Some(cd) = e.child("ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue };
|
||||
if cd.child("Visible").is_none() {
|
||||
continue;
|
||||
}
|
||||
let num = |k: &str| cd.child(k).and_then(|v| v.text.trim().parse::<u32>().ok());
|
||||
if let (Some(row), Some(col)) = (num("Row"), num("Column"))
|
||||
&& let Some(c) = sheet.comments.get_mut(&CellRef::new(row, col))
|
||||
&& !c.threaded
|
||||
{
|
||||
let (Some(row), Some(col)) = (num("Row"), num("Column")) else { continue };
|
||||
let cell = CellRef::new(row, col);
|
||||
let Some(c) = sheet.comments.get_mut(&cell) else { continue };
|
||||
if cd.child("Visible").is_some() && !c.threaded {
|
||||
c.visible = true;
|
||||
}
|
||||
if let Some(anchor) = cd.child("Anchor") {
|
||||
c.note_box = note_box(&anchor.text, e.attr("style").unwrap_or(""), cell).filter(|b| *b != crate::sheet_write::DEFAULT_NOTE_BOX);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A note's box from its VML `<x:Anchor>` (`LeftColumn, LeftOffset, TopRow, TopOffset,
|
||||
/// RightColumn, RightOffset, BottomRow, BottomOffset`; offsets in pixels) and its shape's style,
|
||||
/// the corners made relative to the note's `cell`. `None` when the anchor isn't eight in-sheet
|
||||
/// numbers.
|
||||
fn note_box(anchor: &str, style: &str, cell: CellRef) -> Option<NoteBox> {
|
||||
let v: Vec<u32> = anchor.split(',').map(|p| p.trim().parse::<u32>().ok()).collect::<Option<_>>()?;
|
||||
let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = <[u32; 8]>::try_from(v).ok()?;
|
||||
if c1.max(c2) >= MAX_COLS || r1.max(r2) >= MAX_ROWS {
|
||||
return None;
|
||||
}
|
||||
let corner = |c: u32, dx: u32, r: u32, dy: u32| NoteCorner {
|
||||
col: i64::from(c) - i64::from(cell.col),
|
||||
col_px: dx,
|
||||
row: i64::from(r) - i64::from(cell.row),
|
||||
row_px: dy,
|
||||
};
|
||||
let pt = |key: &str| {
|
||||
style
|
||||
.split(';')
|
||||
.filter_map(|d| d.split_once(':'))
|
||||
.find(|(k, _)| k.trim().eq_ignore_ascii_case(key))
|
||||
.and_then(|(_, v)| v.trim().strip_suffix("pt")?.trim().parse::<f64>().ok().filter(|n| n.is_finite() && *n > 0.0 && *n < 1e6))
|
||||
};
|
||||
Some(NoteBox { from: corner(c1, dx1, r1, dy1), to: corner(c2, dx2, r2, dy2), size_pt: pt("width").zip(pt("height")) })
|
||||
}
|
||||
|
||||
fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) {
|
||||
let mut by_id: HashMap<String, CellRef> = HashMap::new();
|
||||
let mut replies: Vec<(String, String, String, String)> = vec![];
|
||||
@@ -938,9 +968,10 @@ fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) {
|
||||
by_id.insert(id.to_string(), cell);
|
||||
}
|
||||
let dates = if date.is_empty() { vec![] } else { vec![date] };
|
||||
sheet
|
||||
.comments
|
||||
.insert(cell, Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates });
|
||||
sheet.comments.insert(
|
||||
cell,
|
||||
Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates, note_box: None },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_model::{
|
||||
Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, Orientation, Sheet, SparklineKind, Style, Table,
|
||||
TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
|
||||
Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, NoteBox, NoteCorner, Orientation, Sheet,
|
||||
SparklineKind, Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
|
||||
};
|
||||
|
||||
use crate::drawing::{Obj, image_content_type, image_ext};
|
||||
@@ -1286,6 +1286,24 @@ fn protected_ranges_xml(sheet: &Sheet) -> String {
|
||||
if s.is_empty() { s } else { format!("<protectedRanges>{s}</protectedRanges>") }
|
||||
}
|
||||
|
||||
/// The box a note made here gets: two columns right of its cell, four rows tall, 108 x 59.25 pt.
|
||||
/// A box read from a file that equals it is kept as `None`, so a note's model survives a save.
|
||||
pub(crate) const DEFAULT_NOTE_BOX: NoteBox = NoteBox {
|
||||
from: NoteCorner { col: 1, col_px: 15, row: 0, row_px: 2 },
|
||||
to: NoteCorner { col: 3, col_px: 15, row: 4, row_px: 16 },
|
||||
size_pt: Some(DEFAULT_NOTE_SIZE_PT),
|
||||
};
|
||||
const DEFAULT_NOTE_SIZE_PT: (f64, f64) = (108.0, 59.25);
|
||||
|
||||
/// A note box's `<x:Anchor>` text and its width and height in points: the box read from the file
|
||||
/// or [`DEFAULT_NOTE_BOX`], its corners moved with the note's `cell` and kept on the sheet.
|
||||
fn note_anchor(cell: CellRef, b: Option<&NoteBox>) -> (String, (f64, f64)) {
|
||||
let b = b.unwrap_or(&DEFAULT_NOTE_BOX);
|
||||
let at = |base: u32, d: i64, max: u32| (i64::from(base) + d).clamp(0, i64::from(max) - 1);
|
||||
let corner = |k: &NoteCorner| format!("{}, {}, {}, {}", at(cell.col, k.col, MAX_COLS), k.col_px, at(cell.row, k.row, MAX_ROWS), k.row_px);
|
||||
(format!("{}, {}", corner(&b.from), corner(&b.to)), b.size_pt.unwrap_or(DEFAULT_NOTE_SIZE_PT))
|
||||
}
|
||||
|
||||
/// Legacy VML shapes Excel uses to display notes.
|
||||
fn vml_xml(sheet: &Sheet, n: u32) -> String {
|
||||
let mut s = format!(
|
||||
@@ -1294,14 +1312,13 @@ fn vml_xml(sheet: &Sheet, n: u32) -> String {
|
||||
for (i, (cell, c)) in sheet.comments.iter().enumerate() {
|
||||
let id = n as u64 * 1024 + 1 + i as u64;
|
||||
let vis = if c.visible { "visible" } else { "hidden" };
|
||||
let (anchor, (w, h)) = note_anchor(*cell, c.note_box.as_ref());
|
||||
let _ = write!(
|
||||
s,
|
||||
"<v:shape id=\"_x0000_s{id}\" type=\"#_x0000_t202\" style=\"position:absolute;margin-left:59.25pt;margin-top:1.5pt;width:108pt;height:59.25pt;z-index:{};visibility:{vis}\" fillcolor=\"#ffffe1\" o:insetmode=\"auto\"><v:fill color2=\"#ffffe1\"/><v:shadow on=\"t\" color=\"black\" obscured=\"t\"/><v:path o:connecttype=\"none\"/><v:textbox style=\"mso-direction-alt:auto\"><div style=\"text-align:left\"></div></v:textbox><x:ClientData ObjectType=\"Note\"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>{}, 15, {}, 2, {}, 15, {}, 16</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>{}</x:Row><x:Column>{}</x:Column>{}</x:ClientData></v:shape>",
|
||||
"<v:shape id=\"_x0000_s{id}\" type=\"#_x0000_t202\" style=\"position:absolute;margin-left:59.25pt;margin-top:1.5pt;width:{}pt;height:{}pt;z-index:{};visibility:{vis}\" fillcolor=\"#ffffe1\" o:insetmode=\"auto\"><v:fill color2=\"#ffffe1\"/><v:shadow on=\"t\" color=\"black\" obscured=\"t\"/><v:path o:connecttype=\"none\"/><v:textbox style=\"mso-direction-alt:auto\"><div style=\"text-align:left\"></div></v:textbox><x:ClientData ObjectType=\"Note\"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>{anchor}</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>{}</x:Row><x:Column>{}</x:Column>{}</x:ClientData></v:shape>",
|
||||
num(w),
|
||||
num(h),
|
||||
i + 1,
|
||||
cell.col + 1,
|
||||
cell.row,
|
||||
cell.col + 3,
|
||||
cell.row + 4,
|
||||
cell.row,
|
||||
cell.col,
|
||||
if c.visible { "<x:Visible/>" } else { "" }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! #1608: a note's box keeps the place and size the file gave it through open and save.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::{Comment, NoteBox, NoteCorner, Workbook};
|
||||
|
||||
use super::minimal;
|
||||
use crate::{read_xlsx, write_xlsx};
|
||||
|
||||
const RELS: &str = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/></Relationships>"#;
|
||||
const COMMENTS: &str = r#"<?xml version="1.0"?><comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>A</author></authors><commentList><comment ref="B2" authorId="0"><text><t>note</t></text></comment></commentList></comments>"#;
|
||||
/// The issue's note on B2: Excel's box over B1, 224.25 x 89.25 pt.
|
||||
const VML: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout><v:shape id="_x0000_s1025" type="#_x0000_t202" style="position:absolute;margin-left:66pt;margin-top:2.25pt;width:224.25pt;height:89.25pt;z-index:1;visibility:hidden" fillcolor="#ffffe1" o:insetmode="auto"><v:textbox><div>note</div></v:textbox><x:ClientData ObjectType="Note"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>
|
||||
1, 10, 0, 2, 5, 32, 5, 8</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>1</x:Row><x:Column>1</x:Column></x:ClientData></v:shape></xml>"##;
|
||||
|
||||
fn package() -> Vec<u8> {
|
||||
minimal(
|
||||
r#"<sheetData><row r="2"><c r="B2"><v>1</v></c></row></sheetData><legacyDrawing r:id="rId2"/>"#,
|
||||
&[("xl/worksheets/_rels/sheet1.xml.rels", RELS), ("xl/comments1.xml", COMMENTS), ("xl/drawings/vmlDrawing1.vml", VML)],
|
||||
"",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
/// The written VML part (GridCraft writes one per sheet with notes).
|
||||
fn written_vml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let name = (0..z.len()).map(|i| z.by_index(i).unwrap().name().to_string()).find(|n| n.ends_with(".vml")).expect("a VML part");
|
||||
let mut s = String::new();
|
||||
z.by_name(&name).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn b2() -> CellRef {
|
||||
CellRef::new(1, 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notes_box_is_read_relative_to_its_cell() {
|
||||
let (wb, _) = read_xlsx(&package()).unwrap();
|
||||
let c = &wb.sheets[0].comments[&b2()];
|
||||
assert_eq!(
|
||||
c.note_box,
|
||||
Some(NoteBox {
|
||||
from: NoteCorner { col: 0, col_px: 10, row: -1, row_px: 2 },
|
||||
to: NoteCorner { col: 4, col_px: 32, row: 4, row_px: 8 },
|
||||
size_pt: Some((224.25, 89.25)),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unedited_notes_box_is_saved_where_and_as_big_as_it_was() {
|
||||
let (wb, _) = read_xlsx(&package()).unwrap();
|
||||
let vml = written_vml(&write_xlsx(&wb).unwrap());
|
||||
assert!(vml.contains("<x:Anchor>1, 10, 0, 2, 5, 32, 5, 8</x:Anchor>"), "{vml}");
|
||||
assert!(vml.contains("width:224.25pt;height:89.25pt"), "{vml}");
|
||||
// and once more through GridCraft's own output
|
||||
let (wb2, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert_eq!(wb2.sheets[0].comments[&b2()].note_box, wb.sheets[0].comments[&b2()].note_box);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notes_box_moves_with_its_note() {
|
||||
// A row and a column inserted before the note move it to C3; its box follows it.
|
||||
let (mut wb, _) = read_xlsx(&package()).unwrap();
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
let c = s.comments.remove(&b2()).unwrap();
|
||||
s.comments.insert(CellRef::new(2, 2), c);
|
||||
let vml = written_vml(&write_xlsx(&wb).unwrap());
|
||||
assert!(vml.contains("<x:Anchor>2, 10, 1, 2, 6, 32, 6, 8</x:Anchor>"), "{vml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_that_would_start_before_the_sheet_is_clamped_to_it() {
|
||||
// The B2 note's box starts one row above it; moved to A1 that row doesn't exist.
|
||||
let (mut wb, _) = read_xlsx(&package()).unwrap();
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
let c = s.comments.remove(&b2()).unwrap();
|
||||
s.comments.insert(CellRef::new(0, 0), c);
|
||||
let vml = written_vml(&write_xlsx(&wb).unwrap());
|
||||
assert!(vml.contains("<x:Anchor>0, 10, 0, 2, 4, 32, 4, 8</x:Anchor>"), "{vml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_note_gets_the_default_box() {
|
||||
let mut wb = Workbook::new();
|
||||
let note = Comment {
|
||||
author: "A".into(),
|
||||
text: "t".into(),
|
||||
replies: vec![],
|
||||
threaded: false,
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
};
|
||||
wb.sheet_mut(0).unwrap().comments.insert(b2(), note);
|
||||
let vml = written_vml(&write_xlsx(&wb).unwrap());
|
||||
assert!(vml.contains("<x:Anchor>2, 15, 1, 2, 4, 15, 5, 16</x:Anchor>") && vml.contains("width:108pt;height:59.25pt"), "{vml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_anchor_is_ignored() {
|
||||
for anchor in ["1, 10, 0", "1, x, 0, 2, 5, 32, 5, 8", "-1, 10, 0, 2, 5, 32, 5, 8", "99999999999, 0, 0, 0, 0, 0, 0, 0"] {
|
||||
let vml = VML.replace("\n 1, 10, 0, 2, 5, 32, 5, 8", anchor);
|
||||
let bytes = minimal(
|
||||
r#"<sheetData/><legacyDrawing r:id="rId2"/>"#,
|
||||
&[("xl/worksheets/_rels/sheet1.xml.rels", RELS), ("xl/comments1.xml", COMMENTS), ("xl/drawings/vmlDrawing1.vml", &vml)],
|
||||
"",
|
||||
"",
|
||||
);
|
||||
let (wb, _) = read_xlsx(&bytes).unwrap();
|
||||
assert_eq!(wb.sheets[0].comments[&b2()].note_box, None, "{anchor}");
|
||||
}
|
||||
}
|
||||
@@ -217,11 +217,21 @@ fn sample() -> Workbook {
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
},
|
||||
);
|
||||
s.comments.insert(
|
||||
at("C3"),
|
||||
Comment { author: "Bob".into(), text: "second".into(), replies: vec![], threaded: false, resolved: false, visible: true, dates: vec![] },
|
||||
Comment {
|
||||
author: "Bob".into(),
|
||||
text: "second".into(),
|
||||
replies: vec![],
|
||||
threaded: false,
|
||||
resolved: false,
|
||||
visible: true,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
},
|
||||
);
|
||||
s.comments.insert(
|
||||
at("D4"),
|
||||
@@ -233,11 +243,21 @@ fn sample() -> Workbook {
|
||||
resolved: true,
|
||||
visible: false,
|
||||
dates: vec!["2026-10-11T09:30:00.00".into(), String::new(), "2026-10-11T10:15:00.00".into()],
|
||||
note_box: None,
|
||||
},
|
||||
);
|
||||
s.comments.insert(
|
||||
at("E5"),
|
||||
Comment { author: "Cy".into(), text: "open thread".into(), replies: vec![], threaded: true, resolved: false, visible: false, dates: vec![] },
|
||||
Comment {
|
||||
author: "Cy".into(),
|
||||
text: "open thread".into(),
|
||||
replies: vec![],
|
||||
threaded: true,
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
},
|
||||
);
|
||||
s.autofilter = Some(AutoFilter {
|
||||
range: rr("K1:N20"),
|
||||
@@ -437,7 +457,16 @@ fn sample() -> Workbook {
|
||||
s3.visibility = Visibility::VeryHidden;
|
||||
s3.comments.insert(
|
||||
at("A1"),
|
||||
Comment { author: "Bob".into(), text: "other sheet".into(), replies: vec![], threaded: true, resolved: false, visible: false, dates: vec![] },
|
||||
Comment {
|
||||
author: "Bob".into(),
|
||||
text: "other sheet".into(),
|
||||
replies: vec![],
|
||||
threaded: true,
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
},
|
||||
);
|
||||
wb.sheets.push(Arc::new(s3));
|
||||
wb.names.push(DefinedName { name: "Rate".into(), scope: None, formula: "0.07".into(), comment: "c".into(), hidden: false });
|
||||
@@ -1005,6 +1034,7 @@ fn bad_comment_dates_are_left_out() {
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec!["\"/><evil".into()],
|
||||
note_box: None,
|
||||
};
|
||||
s.comments.insert(at("A1"), c);
|
||||
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Chart parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** trivial (series that refer to workbook-scoped defined names (`[0]!Name` in the file) plot their data instead of `#NAME?` (#1686); previously trivial: chart sheets are kept on save, still not editable (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (stale chart notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip; PRs #158 and #161 merged) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Excel's chart types and chart elements against GridCraft. Part of
|
||||
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
|
||||
|
||||
Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crates/chart/src`
|
||||
(layout and rendering), `crates/xlsx/src/chart.rs` (DrawingML chart parts), merged PRs #62 and #116,
|
||||
open PRs #158, #161, and issue #190.
|
||||
(layout and rendering), `crates/xlsx/src/chart.rs` (DrawingML chart parts), merged PRs #62, #116,
|
||||
#158 and #161, and issue #190.
|
||||
|
||||
## Chart types
|
||||
|
||||
@@ -20,13 +20,15 @@ open PRs #158, #161, and issue #190.
|
||||
| Area | area, stacked, 100%, 3-D ×3 | 2 of 6 | no 100%, no 3-D |
|
||||
| X Y (scatter) | markers, smooth lines (±markers), straight lines (±markers) | 2 of 5 | no smooth lines |
|
||||
| Bubble | bubble, 3-D bubble | 1 of 2 | |
|
||||
| Stock | high-low-close, open-high-low-close, volume-HLC, volume-OHLC | 1 kind | combo/stock lose their kind in XLSX (PR #158) |
|
||||
| Stock | high-low-close, open-high-low-close, volume-HLC, volume-OHLC | 1 kind | |
|
||||
| Surface | 3-D surface, wireframe, contour, wireframe contour | 0 of 4 | |
|
||||
| Radar | radar, with markers, filled | 1 of 3 | |
|
||||
| Treemap, Sunburst | 1 each | 2 of 2 | lose their kind in XLSX (PR #161) |
|
||||
| Treemap, Sunburst | 1 each | 2 of 2 | |
|
||||
| Histogram, Pareto | 2 | 1 of 2 | no Pareto |
|
||||
| Box & Whisker | 1 | 1 of 1 | exclusive (default) and inclusive quartiles per series; XLSX round-trip; `chart.set {quartiles}` changes all series |
|
||||
| Waterfall, Funnel | 2 | 2 of 2 | lose their kind in XLSX (PR #161) |
|
||||
| Box & Whisker | 1 | 1 of 1 | |
|
||||
| Waterfall, Funnel | 2 | 2 of 2 | |
|
||||
| Filled map | 1 | 0 | needs geographic boundary data (licensing decision) |
|
||||
| Combo | custom combinations, secondary axis | yes (line on secondary axis) | |
|
||||
| **Total** | ~60 subtypes | **25 (~42%)** | usage-weighted breadth higher (~65%): column, bar, line, pie and scatter dominate |
|
||||
@@ -76,7 +78,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690).
|
||||
|
||||
## Work (40–60 h)
|
||||
|
||||
1. Kinds survive XLSX round-trip (merge PRs #158, #161): 2–4 h. Chart resizing (#190): 1–2 h.
|
||||
1. Chart resizing (#190): 1–2 h.
|
||||
2. Preserve unmodelled chart XML on round-trip (keep the original part, patch only edited fields): 6–10 h.
|
||||
3. Axis options, label options, gridlines per axis, legend formatting: 10–15 h.
|
||||
4. Error bars; Format Trendline pane (trendlines landed): 4–6 h.
|
||||
@@ -88,6 +90,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | trivial | Stale notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip (PRs #158, #161) |
|
||||
| 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) |
|
||||
| 2026-10-16 | minor | Box & whisker quartiles default to exclusive, honor each series on draw and XLSX round-trip; `chart.set {quartiles}` changes the method (#1453) |
|
||||
| 2026-10-13 | minor | Multi-level category axes: read, drawn (outer levels under or beside the labels), written back with level caches |
|
||||
|
||||
@@ -47,6 +47,9 @@ values) is still dropped.
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), pictures in headers and footers (`&G`, #1257: their VML drawing and images are written back unchanged; they are not shown, and can't be inserted), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state and their box's place and size, #1608) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures |
|
||||
| `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) |
|
||||
| `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` |
|
||||
@@ -120,6 +123,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
| 2026-10-16 | trivial | XLSX reads and writes the workbook's show-objects setting (`workbookPr showObjects`: all, placeholders, none), which Ctrl+6 switches; a file without it is saved without it |
|
||||
| 2026-10-16 | trivial | XLSX: pictures in headers and footers (`&G`, the VML drawing `<legacyDrawingHF>` names and its images) are kept on save; they are still not shown (#1257) |
|
||||
| 2026-10-16 | minor | XLSX column imports refuse more than 1,000,000 cumulative expanded assignments per sheet before expanding the next span; repeated overlaps cannot bypass the work limit (#1022) |
|
||||
| 2026-10-16 | minor | XLSX: a note keeps its box's place and size (`<x:Anchor>` and the VML width and height) through open and save, moving with its cell; notes made here get the default box (#1608) |
|
||||
| 2026-10-15 | minor | XLSX: formulas and PivotTable sources quote sheet names like Excel (a leading `.`, R1C1-like and cell-like names), so `'.x'!A1` no longer saves as `.x!A1`, which Excel could not open (#1458) |
|
||||
| 2026-10-15 | minor | XLSX text preserves CR and CRLF across save/reopen; shared strings use `_x000D_` and other XML text uses ` ` (#1488) |
|
||||
| 2026-10-15 | minor | XLSX save keeps legacy `INDEX`/`MATCH` formulas scalar instead of marking them as dynamic arrays (#1505) |
|
||||
|
||||
@@ -77,17 +77,18 @@ This improves series preparation; it does not change the model-fitting or season
|
||||
| Feature | Status |
|
||||
|---|---|
|
||||
| A1 and R1C1 references, absolute/relative/mixed, F4 cycling | done |
|
||||
| 3-D references (`Sheet1:Sheet3!A1`) | done; endpoint-deletion bug (#140) |
|
||||
| 3-D references (`Sheet1:Sheet3!A1`) | done; deleting a span's end sheet keeps the rest (#140) |
|
||||
| Structured references (`Table1[Col]`, `[#Totals]`, `@`; the unbracketed `[[#This Row],Col]` some writers emit) | done |
|
||||
| Defined names (workbook and sheet scope), Create from Selection | done |
|
||||
| Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) |
|
||||
| Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale spills after a structural edit or a cleared blocker fixed (#133, #139; PRs #148, #157) |
|
||||
| Recalculation after clearing large sparse ranges | every stored cell changed by Clear is reported to recalculation, without a million-coordinate cutoff; removed formula spills are cleared, and removing empty merges still unblocks spills (#1032) |
|
||||
| SUBTOTAL / AGGREGATE input size | references trim to the used range; all areas, arrays and values of one call are charged against the 16-million-cell limit before any cell is read (skipped hidden, nested and error cells count), and `#NUM!` past it (#2175) |
|
||||
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
|
||||
| Implicit intersection `@`, legacy CSE array formulas | done, the entered range filled (PR #156) |
|
||||
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
|
||||
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done |
|
||||
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened |
|
||||
| 15-significant-digit comparison semantics | **missing** (#57: `=0.1+0.2=0.3` is FALSE) |
|
||||
| 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 |
|
||||
| External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update |
|
||||
| Localized function names and `;` separators | **missing** (#45); en-US only |
|
||||
| Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them |
|
||||
@@ -124,6 +125,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
||||
| 2026-10-21 | minor | SUBTOTAL and AGGREGATE charge every argument against the evaluator's 16-million-cell limit before scanning; a value in XFD1048576 no longer makes `SUBTOTAL(9,A:XFD)` read the whole sheet (#2175) |
|
||||
| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which matches `ß` with `ẞ` and keeps dotless `ı` apart from `i`) |
|
||||
| 2026-10-19 | minor | Long flat operator chains (#916): 4,096 operands (Excel's 8,192-character limit) no longer overflow the stack when entered, filled, adjusted or saved; chains past 64 operands are no longer saved as dynamic-array formulas; Spanish entry no longer refuses chains past 256 operands |
|
||||
| 2026-10-17 | minor | 15-significant-digit comparison moved from missing to done (#57), and the resolved caveats on the 3-D reference, dynamic-array and legacy-array rows cleared (#140, #133, #139, #156) |
|
||||
| 2026-10-16 | minor | FORECAST.ETS gap interpolation uses a forward-only known-point scan instead of rescanning each gap for every missing value; numerical behavior and missing-point limits are preserved (#1042) |
|
||||
| 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation |
|
||||
| 2026-10-15 | minor | ADDRESS quotes a sheet name the way Excel does: names that read as an A1 cell (`A1`) or an R1C1 reference (`R1C1`, `RC`) and names starting with `.` get quotes; `TRUE` / `FALSE` stay bare (#1458) |
|
||||
|
||||
+14
-9
@@ -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 |
|
||||
|
||||
@@ -42,7 +42,11 @@ right-to-left sheets and full complex-script support. Part of
|
||||
localized function names and `;` separators (#45). German with localized numbers and formulas
|
||||
is in PR #163 (open).
|
||||
- **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32;
|
||||
#142 is still open pending confirmation). There is no complex-text shaping: Arabic and
|
||||
#142 is still open pending confirmation). Native CJK fallback also probes Flatpak's read-only
|
||||
`/run/host/fonts`, `/run/host/local-fonts` and `/run/host/user-fonts` layouts, so installed
|
||||
Noto CJK and WenQuanYi faces can render Chinese without a broad filesystem grant. This does
|
||||
not change the installed-font catalogue or add bundled fonts. Lao falls back to Lao Sangam MN / Lao MN (macOS),
|
||||
Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). There is no complex-text shaping: Arabic and
|
||||
Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left**
|
||||
(`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`, `.xls` WINDOW2 fRightToLeft) mirrors the grid (#146):
|
||||
column A and the row headers on the right, the vertical scrollbar on the left; hit-testing,
|
||||
@@ -118,8 +122,10 @@ language (none so far) and of localized function names.
|
||||
| 2026-10-19 | minor | Save as Web Page and copied HTML mark right-to-left sheets `dir="rtl"`, let General-aligned Arabic and Hebrew text align by its own direction (`dir="auto"`), and keep numbers on right-to-left sheets left to right and on the left |
|
||||
| 2026-10-19 | minor | French and Serbian kept the context in three `context\|label` translations ("Options\|Général", "Opcije\|Opšte" for `Options\|General`; also `Options\|Save`, `Sheet Options\|View`). `tr` shows a found translation as it is, so File › Options headings and the Sheet Options "View" checkboxes showed the prefix. Fixed; a test checks that no catalog shows a `\|` |
|
||||
| 2026-10-17 | minor | Vietnamese catalog (`i18n/vi.rs`, every key in the master table); `vi` locale tags resolve to Vietnamese |
|
||||
| 2026-10-17 | minor | Lao text falls back to an installed Lao face (`sys-lao`) instead of tofu boxes |
|
||||
| 2026-10-15 | minor | Function help in the interface language: argument hints, autocomplete, Insert Function (search, categories) and the Formulas-tab menus read per-language tables of descriptions and argument names keyed by the catalog's English text; Spanish keeps its 10 functions in the new format |
|
||||
| 2026-10-15 | minor | Italian catalog (`i18n/it.rs`, 971 entries, ~44%): every key the Brazilian Portuguese catalog translates plus the PivotTable Fields pane strings; `it` locale tags (it-IT, it-CH…) resolve to Italian. Labels follow Excel's Italian terminology; native-speaker review still pending |
|
||||
| 2026-10-14 | minor | CJK fallback probes the read-only Flatpak host font mounts with the existing distribution layouts and TTC locale indices; no new sandbox permissions or bundled fonts |
|
||||
| 2026-10-12 | minor | Turkish ribbon catalog (`i18n/tr.rs`, 382 labels); `tr` locale tags (tr-TR, tr_TR…) resolve to Turkish |
|
||||
| 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them |
|
||||
| 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) |
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user