From c7701f6e137cd7594a242ba3cd6b2bfde55879c2 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:40:27 -0500 Subject: [PATCH 01/86] =?UTF-8?q?Functions=20given=20by=20name:=20GROUPBY(?= =?UTF-8?q?=E2=80=A6,SUM),=20BYROW(=E2=80=A6,SUM),=20MAP(=E2=80=A6,ABS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel lets a worksheet function be passed by name where a function is expected. In GridCraft GROUPBY(A2:A9,B2:B9,SUM) and PIVOTBY gave #NAME? (their documented form), and BYROW, BYCOL, MAP, REDUCE, SCAN and MAKEARRAY gave #VALUE!. A name that is a worksheet function, and not a LET/LAMBDA variable or a defined name, now works as a function there. Reading a file, the _xleta. prefix Excel writes for these names is removed. --- crates/calc/src/eval.rs | 43 +++++++++++++++++++++++++++++++----- crates/calc/src/tests.rs | 21 ++++++++++++++++++ crates/formula/src/parser.rs | 4 ++++ crates/formula/src/tests.rs | 1 + 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39fd..357e3bf8b 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -737,6 +737,20 @@ impl<'h> Evaluator<'h> { Ev::V(Value::Bool(r)) } "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), + "GROUPBY" | "PIVOTBY" => { + // The aggregation may be a function given by name (`SUM`): pass it as its name. + let i = if name == "GROUPBY" { 2 } else { 3 }; + match args.get(i).and_then(|a| self.function_by_name(a)) { + Some(f) => { + let mut args = args.to_vec(); + if let Some(a) = args.get_mut(i) { + *a = Expr::Text(f.into()); + } + self.builtin(name, &args) + } + None => self.builtin(name, args), + } + } "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => { self.host.note_volatile(); self.builtin(name, args) @@ -903,7 +917,26 @@ impl<'h> Evaluator<'h> { r } - fn lambda_arg(&mut self, e: Option<&Expr>) -> Option> { + /// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`, + /// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has + /// that name. + fn function_by_name(&self, e: &Expr) -> Option { + let Expr::Name(n) = e else { return None }; + if self.env.iter().any(|(k, _)| k.eq_ignore_ascii_case(n)) || self.host.workbook().name(n, self.sheet).is_some() { + return None; + } + let u = n.to_ascii_uppercase(); + gridcraft_functions::lookup(&u).map(|_| u) + } + + /// The function argument of a LAMBDA helper; a function given by name takes `arity` + /// arguments. + fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option> { + if let Some(f) = e.and_then(|x| self.function_by_name(x)) { + let params: Vec = (0..arity).map(|i| format!("_xleta.{i}")).collect(); + let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect()); + return Some(Arc::new(Lambda { params, body, env: Vec::new() })); + } match e.map(|x| self.eval(x)) { Some(Ev::L(l)) => Some(l), _ => None, @@ -922,7 +955,7 @@ impl<'h> Evaluator<'h> { if args.len() < 2 { return err(CellError::Value); } - let Some(l) = self.lambda_arg(args.last()) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) }; let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); @@ -945,7 +978,7 @@ impl<'h> Evaluator<'h> { } let mut acc = self.value(&args[0]); let arr = as_array(self.value(&args[1])); - let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; if l.params.len() != 2 { return err(CellError::Value); } @@ -969,7 +1002,7 @@ impl<'h> Evaluator<'h> { return err(CellError::Value); } let arr = as_array(self.value(&args[0])); - let Some(l) = self.lambda_arg(args.get(1)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; let by_row = name == "BYROW"; let n = if by_row { arr.rows } else { arr.cols }; @@ -998,7 +1031,7 @@ impl<'h> Evaluator<'h> { if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 { return err(CellError::Value); } - let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; if l.params.len() != 2 { return err(CellError::Value); } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 513340983..5d71a264a 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -501,6 +501,27 @@ fn let_lambda() { assert_eq!(t.num("A4"), 14.0); } +#[test] +fn functions_given_by_name() { + let mut t = T::new(); + for (i, (k, v)) in [("a", 1), ("b", -2), ("a", 3)].iter().enumerate() { + t.set(&format!("A{}", i + 1), k); + t.set(&format!("B{}", i + 1), &v.to_string()); + } + t.set("D1", "=BYROW(B1:B3,SUM)"); + assert_eq!(t.num("D2"), -2.0); + t.set("E1", "=MAP(B1:B3,ABS)"); + assert_eq!(t.num("E2"), 2.0); + t.set("F1", "=REDUCE(0,B1:B3,SUM)"); + assert_eq!(t.num("F1"), 2.0); + t.set("G1", "=GROUPBY(A1:A3,B1:B3,SUM,0,0)"); + assert_eq!(t.get("G1"), Value::from("a")); + assert_eq!(t.num("H1"), 4.0); + // A LET variable of that name is still the variable. + t.set("J1", "=LET(ABS,LAMBDA(x,x*10),SUM(MAP(B1:B3,ABS)))"); + assert_eq!(t.num("J1"), 20.0); +} + #[test] fn names_and_sheets() { let mut t = T::new(); diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 4fd3b7bb2..568f7c3a8 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -357,6 +357,10 @@ impl Parser { if w.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Err(self.err(format!("unexpected `{w}`"))); } + // A function passed by name is saved as `_xleta.SUM`. + if let Some(f) = w.get(..7).filter(|p| p.eq_ignore_ascii_case("_xleta.")).and_then(|_| w.get(7..)) { + return Ok(Expr::Name(f.to_ascii_uppercase())); + } Ok(Expr::Name(w.to_string())) } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c231..ad9adc288 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -48,6 +48,7 @@ fn normalises() { assert_eq!(rt("=sum(a1:b2)"), "SUM(A1:B2)"); assert_eq!(rt("= 1 + 2"), "1+2"); assert_eq!(rt("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); + assert_eq!(rt("_xlfn.GROUPBY(A1:A3,B1:B3,_xleta.SUM)"), "GROUPBY(A1:A3,B1:B3,SUM)"); assert_eq!(rt("true"), "TRUE"); assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1"); } From d64b30b3a462973e27e1a1c9bbdee4153d4306ab Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:56 +0200 Subject: [PATCH 02/86] Funnel chart: hide value labels when data labels are off (#544) --- crates/chart/src/render.rs | 12 +++++++----- crates/chart/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 63d30d982..d36e2abbb 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -254,7 +254,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { Doughnut => pie(ctx, chart, data, area, 0.5, false), Sunburst => pie(ctx, chart, data, area, 0.2, true), Treemap => treemap(ctx, chart, data, area), - Funnel => funnel(ctx, data, area), + Funnel => funnel(ctx, chart, data, area), Radar => radar(ctx, chart, data, area), _ => { let area = axis_titles(ctx, chart, area); @@ -1301,7 +1301,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { // ---------------------------------------------------------------- funnel -fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let Some(s) = data.series.first() else { return }; let n = data.categories.len().max(s.values.len()).min(MAX_CATS); if n == 0 { @@ -1331,9 +1331,11 @@ fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) { } if show { let yc = y + bh / 2.0; - let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); - let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; - ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); + if chart.data_labels { + let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); + let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; + ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); + } let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a94267713..f4a12ff88 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -902,3 +902,25 @@ fn union_series_references() { c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000)); let _ = resolve(&wb, 0, &c); } + +#[test] +fn funnel_value_labels_follow_the_data_labels_flag() { + let data = ChartData { + categories: vec!["a".into(), "b".into(), "c".into()], + series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)], + }; + let mut c = chart(ChartKind::Funnel); + c.data_labels = false; + let off = render(&c, &data, 500.0, 350.0, &ApproxMeasure); + c.data_labels = true; + let on = render(&c, &data, 500.0, 350.0, &ApproxMeasure); + let values = ["$11,111", "$22,222", "$33,333"]; + for v in values { + assert!(texts(&on).contains(&v), "{v} missing with labels on"); + assert!(!texts(&off).contains(&v), "{v} drawn with labels off"); + } + for cat in ["a", "b", "c"] { + assert!(texts(&off).contains(&cat)); + assert!(texts(&on).contains(&cat)); + } +} From 344888ceb583ebb4433f8dcc036892b9fa9f3861 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:20 +0200 Subject: [PATCH 03/86] Keep every range of a conditional format or validation sqref (#512) --- crates/xlsx/src/sheet_read.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 77b46d95b..e7019a412 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -541,7 +541,7 @@ pub fn parse_iso_datetime(s: &str, sys: DateSystem) -> Option { } pub fn parse_sqref(s: Option<&str>) -> Vec { - s.unwrap_or("").split_whitespace().filter_map(RangeRef::parse).take(10_000).collect() + s.unwrap_or("").split_whitespace().filter_map(RangeRef::parse).collect() } fn cf_op(s: Option<&str>) -> CfOperator { @@ -973,4 +973,12 @@ mod tests { assert_eq!(parse_iso_datetime("garbage", DateSystem::D1900), None); assert_eq!(parse_iso_datetime("2024-13-01", DateSystem::D1900), None); } + + #[test] + fn sqref_keeps_more_than_ten_thousand_ranges() { + let sq = (1..=12_000).map(|r| format!("A{r}")).collect::>().join(" "); + let ranges = parse_sqref(Some(&sq)); + assert_eq!(ranges.len(), 12_000); + assert_eq!(ranges.last().copied(), RangeRef::parse("A12000")); + } } From a06219aeebecae952cf809eb5b4e2174d4dc6b87 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:33:05 +0200 Subject: [PATCH 04/86] fix(grid): filter dropdown installs the AutoFilter on the clicked table (#509) --- crates/ui-egui/src/grid.rs | 45 ++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..70a49ee25 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -72,7 +72,8 @@ pub struct GridState { pub drag_select: bool, pub rect: Option, pub cells_rect: Option, - pub filter_menu: Option<(u32, Pos2)>, + /// Column and row of the clicked header cell, and where to open the menu. + pub filter_menu: Option<(u32, u32, Pos2)>, pub context_menu: Option, pub marching_phase: f32, /// The selection outline on its way to a new selection. @@ -1808,7 +1809,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } // Filter dropdown buttons. if is_filter_button(sh, geo, p) { - app.grid.filter_menu = Some((c.col, geo.mirror_pos(p))); + app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p))); return; } // Point mode while editing a formula: clicking inserts a reference. @@ -2480,20 +2481,20 @@ fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) } +/// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column. +fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { + sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) +} + fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { - let Some((col, at)) = app.grid.filter_menu else { return }; + let Some((col, row, at)) = app.grid.filter_menu else { return }; let Some(d) = app.session.active() else { return }; let wb = d.wb.clone(); let si = wb.active_sheet; let Some(sh) = wb.sheet(si) else { return }; let _ = geo; // Tables get an autofilter on demand. - let table_range = sh - .tables - .iter() - .find(|t| t.range.start.row <= at.y as u32 || true) - .filter(|t| col >= t.range.start.col && col <= t.range.end.col) - .map(|t| t.range); + let table_range = filter_table_range(sh, col, row); if sh.autofilter.is_none() && let Some(r) = table_range { @@ -3053,6 +3054,32 @@ mod selection_fill_tests { mod tests { use super::*; + #[test] + fn filter_menu_uses_the_table_whose_header_was_clicked() { + use gridcraft_engine::model::Table; + let table = |id: u32, r0: u32, r1: u32| Table { + id, + name: format!("T{id}"), + range: RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, 0)), + header_row: true, + totals_row: false, + columns: vec![], + style: String::new(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + }; + let mut sh = Sheet::new("S"); + sh.tables.push(table(1, 0, 2)); + sh.tables.push(table(2, 4, 7)); + assert_eq!(filter_table_range(&sh, 0, 4), Some(sh.tables[1].range)); + assert_eq!(filter_table_range(&sh, 0, 0), Some(sh.tables[0].range)); + assert_eq!(filter_table_range(&sh, 1, 4), None); + } + #[test] fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); From 34125d7e43f572e13b57ef19e0e6d168730ea08c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:26 +0200 Subject: [PATCH 05/86] Quote sheet names when printing sheet-qualified defined names (#567) --- crates/formula/src/adjust.rs | 7 +++---- crates/formula/src/printer.rs | 10 +++++++++- crates/formula/src/tests.rs | 11 +++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 6f01e0507..d4881aa9e 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -264,11 +264,10 @@ pub fn rename_sheet(e: Expr, old: &str, new: &str) -> Expr { } Expr::Ref(r) } - // Sheet-qualified defined name `Old!MyName` is stored as one string. + // Sheet-qualified defined name `Old!MyName` is stored as one string, with the bare sheet name as the parser + // stores it (the printer adds the quotes). Expr::Name(n) => match n.split_at_checked(old.len()) { - Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => { - Expr::Name(format!("{}{rest}", crate::printer::quote_sheet(new))) - } + Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => Expr::Name(format!("{new}{rest}")), _ => Expr::Name(n), }, other => other, diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index a63599bb4..a1ff7a748 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -178,7 +178,15 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { Some(at) => s.push_str(&reference_r1c1(r, at)), None => s.push_str(&reference_a1(r)), }, - Expr::Name(n) => s.push_str(n), + Expr::Name(n) => match n.rsplit_once('!') { + // A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back. + Some((sheet, name)) => { + s.push_str("e_sheet(sheet)); + s.push('!'); + s.push_str(name); + } + _ => s.push_str(n), + }, Expr::Struct(st) => s.push_str(&struct_ref(st)), Expr::Unary(op, x) => match op { UnOp::Neg => { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c231..66e1f0184 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -5,6 +5,13 @@ fn rt(src: &str) -> String { print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}"))) } +#[test] +fn quoted_sheet_qualified_name_keeps_quotes() { + assert_eq!(rt("'My Sheet'!Total"), "'My Sheet'!Total"); + assert_eq!(rt("'Data'!Total"), "Data!Total"); + assert_eq!(rt("'It''s'!Total+1"), "'It''s'!Total+1"); +} + #[test] fn roundtrips() { for f in [ @@ -183,6 +190,10 @@ fn rename_sheet_updates_qualified_names() { assert_eq!(print(&rename_sheet(e, "data", "Numbers")), "Numbers!MyName+MyName+Other!MyName"); let e = parse("Data!MyName").unwrap(); assert_eq!(print(&rename_sheet(e, "Data", "New Name")), "'New Name'!MyName"); + // Stored bare, like a parsed name, so the evaluator finds the sheet and a second rename still matches. + let e = rename_sheet(parse("Data!MyName").unwrap(), "Data", "It's"); + assert_eq!(e, Expr::Name("It's!MyName".into())); + assert_eq!(print(&rename_sheet(e, "It's", "Totals")), "Totals!MyName"); } #[test] From 5fb854be9cb9bf5e5aa362d8d700b5482696d538 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:28:58 +0200 Subject: [PATCH 06/86] Chart: color pie, doughnut, treemap and sunburst points from the workbook theme (#579) --- crates/chart/src/lib.rs | 3 ++ crates/chart/src/render.rs | 3 +- crates/chart/src/resolve.rs | 5 ++- crates/chart/src/tests.rs | 80 +++++++++++++++++++++++++++++++------ 4 files changed, 77 insertions(+), 14 deletions(-) diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs index 083ff8b5d..697e2a7a7 100644 --- a/crates/chart/src/lib.rs +++ b/crates/chart/src/lib.rs @@ -124,6 +124,9 @@ impl Measure for ApproxMeasure { pub struct ChartData { pub categories: Vec, pub series: Vec, + /// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme. + #[serde(default)] + pub palette: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 63d30d982..6874cc2f1 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -129,7 +129,7 @@ fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { fn point_color(data: &ChartData, j: usize) -> Rgba { match (j, data.series.first()) { (0, Some(s)) => s.color, - _ => series_color(&Theme::default(), j), + _ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)), } } @@ -190,6 +190,7 @@ fn sanitize_vals(v: &[Option]) -> Vec> { fn sanitize(d: &ChartData) -> ChartData { ChartData { + palette: d.palette.iter().take(MAX_POINTS).copied().collect(), categories: d.categories.iter().take(MAX_POINTS).cloned().collect(), series: d .series diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 2c17eff22..e059103ca 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -16,6 +16,8 @@ pub(crate) const MAX_TRENDLINES: usize = 16; /// Tints used for series beyond the six theme accents: lighter, darker, lighter still… const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2]; +/// `series_color` repeats after this many colours (six accents × the tints). +pub(crate) const PALETTE_CYCLE: usize = 6 * CYCLE_TINTS.len(); /// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them. pub fn series_color(theme: &Theme, i: usize) -> Rgba { @@ -239,5 +241,6 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { } let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0); let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect()); - ChartData { categories, series } + let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE)); + ChartData { categories, series, palette } } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a94267713..f283f8d5b 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData { let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2); b.secondary = kind == ChartKind::Combo; ChartData { + palette: vec![], categories: cats.iter().map(|s| s.to_string()).collect(), series: vec![ series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1), @@ -362,7 +363,7 @@ fn scatter_with_text_x_values_numbers_the_points() { let d = resolve(&wb, 0, &c); assert_eq!(d.series[0].x, None, "{kind:?}"); let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); - let empty = render(&c, &ChartData { categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); + let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points"); // Numeric X values are kept. c.series[0].categories = Some("Sheet1!$C$1:$C$3".into()); @@ -445,7 +446,11 @@ fn pie_starts_at_twelve_clockwise() { #[test] fn negative_values_put_axis_below_zero() { let c = chart(ChartKind::ColumnClustered); - let d = ChartData { categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] }; + let d = ChartData { + palette: vec![], + categories: vec![], + series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)], + }; let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); let t = texts(&prims); assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}"); @@ -456,8 +461,13 @@ fn negative_values_put_axis_below_zero() { fn edge_cases_never_panic() { let m = ApproxMeasure; let empty = ChartData::default(); - let zeros = ChartData { categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)] }; + let zeros = ChartData { + palette: vec![], + categories: vec!["a".into(), "b".into()], + series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)], + }; let weird = ChartData { + palette: vec![], categories: vec![String::new(); 3], series: vec![SeriesData { name: "w".repeat(500), @@ -488,7 +498,7 @@ fn thousands_of_points_are_decimated() { let vals: Vec = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect(); let mut c = chart(ChartKind::Line); c.data_labels = true; - let d = ChartData { categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure); let max_line = prims .iter() @@ -513,7 +523,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = smooth; - let d = ChartData { categories: cats.clone(), series: vec![s] }; + let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] }; render(&chart(ChartKind::Line), &d, 600.0, 400.0, m) .iter() .filter_map(|p| match p { @@ -530,7 +540,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { // Rendering either way must stay finite and inside the canvas. let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; - let d = ChartData { categories: cats, series: vec![s] }; + let d = ChartData { palette: vec![], categories: cats, series: vec![s] }; assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0); } @@ -540,7 +550,7 @@ fn smooth_line_splits_at_gaps() { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; s.values[1] = None; // gap between two data points - let d = ChartData { categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; + let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m); // Two separate runs (before/after the gap) → at least two line primitives. let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count(); @@ -617,7 +627,7 @@ fn fuzz_random_data_and_sizes() { }) .collect(); let ncat = (r.next() % 50) as usize; - let d = ChartData { categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; + let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; let mut c = chart(kind); c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize]; c.data_labels = r.next().is_multiple_of(2); @@ -792,7 +802,7 @@ fn line_chart_draws_trendline_forecast_and_labels() { let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line); s.trendlines = vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }]; - let d = ChartData { categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; + let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; let mut c = chart(ChartKind::Line); c.legend = LegendPos::None; // keeps legend swatches out of the line counts let prims = render(&c, &d, 600.0, 400.0, &m); @@ -816,7 +826,7 @@ fn line_chart_draws_trendline_forecast_and_labels() { assert!(lines_of(&pie, tc).is_empty()); // One point: nothing to fit, nothing drawn. s.values.truncate(1); - let one = ChartData { categories: vec!["a".into()], series: vec![s] }; + let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] }; assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty()); } @@ -836,7 +846,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] }, TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] }, ]; - let d = ChartData { categories: vec![], series: vec![s] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![s] }; let mut c = chart(ChartKind::Scatter); c.legend = LegendPos::None; let prims = render(&c, &d, 600.0, 400.0, &m); @@ -851,6 +861,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { #[test] fn treemap_and_histogram_shapes() { let d = ChartData { + palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)], }; @@ -871,7 +882,7 @@ fn treemap_and_histogram_shapes() { assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}"); let vals: Vec = (0..100).map(|i| i as f64).collect(); - let d = ChartData { categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count(); assert_eq!(bins, 10); @@ -902,3 +913,48 @@ fn union_series_references() { c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000)); let _ = resolve(&wb, 0, &c); } + +#[test] +fn pie_points_follow_workbook_theme() { + let mut wb = Workbook::new(); + wb.theme.colors[4] = 0x4F6D8A; + wb.theme.colors[5] = 0x8AA1B8; + let mut c = chart(ChartKind::Pie); + c.series = vec![Series { + name: None, + categories: None, + values: "{5,3}".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + trendlines: Vec::new(), + }]; + let d = resolve(&wb, 0, &c); + let expected = vec![series_color(&wb.theme, 0), series_color(&wb.theme, 1)]; + assert_eq!(expected, vec![[0x4F, 0x6D, 0x8A, 0xFF], [0x8A, 0xA1, 0xB8, 0xFF]]); + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend, expected); + let fills: Vec = render(&c, &d, 480.0, 288.0, &ApproxMeasure) + .iter() + .filter_map(|p| match p { + Prim::Wedge { fill, .. } => Some(*fill), + _ => None, + }) + .collect(); + assert_eq!(fills, expected); + for kind in [ChartKind::Doughnut, ChartKind::Treemap, ChartKind::Sunburst] { + c.kind = kind; + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend, expected, "{kind:?}"); + } + // Past the 36-colour cycle the points keep following the workbook theme. + c.kind = ChartKind::Pie; + c.series[0].values = format!("{{{}}}", ["1"; 40].join(",")); + let d = resolve(&wb, 0, &c); + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend.len(), 40); + assert_eq!(legend.get(37), Some(&series_color(&wb.theme, 37))); + assert_ne!(series_color(&wb.theme, 37), series_color(&gridcraft_model::Theme::default(), 37)); +} From 389c539da4f12e8c26fa10e198abd7df8c7056a6 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:14 +0200 Subject: [PATCH 07/86] Honour the gridlines flag on box-and-whisker and stock charts (#543) --- crates/chart/src/render.rs | 12 ++++++------ crates/chart/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 63d30d982..0e4ebfd32 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -266,8 +266,8 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area), Histogram => histogram(ctx, chart, data, area), Waterfall => waterfall(ctx, chart, data, area), - BoxWhisker => box_whisker(ctx, data, area), - Stock => stock(ctx, data, area), + BoxWhisker => box_whisker(ctx, chart, data, area), + Stock => stock(ctx, chart, data, area), _ => cartesian(ctx, chart, data, area, false), } } @@ -1557,12 +1557,12 @@ fn quantile(sorted: &[f64], q: f64) -> f64 { a + (b - a) * frac } -fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } @@ -1601,7 +1601,7 @@ fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) { // ---------------------------------------------------------------- stock -fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); if ncat == 0 { return; @@ -1610,7 +1610,7 @@ fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) { let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a94267713..db821b7f7 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -902,3 +902,24 @@ fn union_series_references() { c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000)); let _ = resolve(&wb, 0, &c); } + +#[test] +fn box_and_stock_charts_honour_the_gridlines_flag() { + // Horizontal full-width lines in the gridline colour are the value-axis gridlines. + let grid: Rgba = [217, 217, 217, 255]; + let is_grid = |p: &Prim| match p { + Prim::Line { color, pts, .. } => *color == grid && pts.len() == 2 && pts[0][1] == pts[1][1] && pts[1][0] - pts[0][0] > 200.0, + _ => false, + }; + let grids = |prims: &[Prim]| prims.iter().filter(|p| is_grid(p)).count(); + for kind in [ChartKind::BoxWhisker, ChartKind::Stock, ChartKind::ColumnClustered] { + let mut d = sample(kind); + d.series.push(series("Close", &[10000.0, 12000.0, 9000.0, 14000.0, 20000.0, 16000.0], [90, 90, 90, 255], kind)); + let mut c = chart(kind); + let on = render(&c, &d, 500.0, 350.0, &ApproxMeasure); + c.gridlines = false; + let off = render(&c, &d, 500.0, 350.0, &ApproxMeasure); + assert!(grids(&on) > 0, "{kind:?}: gridlines on must draw lines"); + assert_eq!(grids(&off), 0, "{kind:?}: gridlines off must hide them"); + } +} From 2d0fb534a20c650f39cf79e415d323a9e35972f0 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:40 +0200 Subject: [PATCH 08/86] Fix saving a custom view or script when the stored list is not a JSON object (#517) --- crates/engine/src/cmd/extra.rs | 16 ++++++-- crates/engine/tests/stored_list_shape.rs | 48 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/stored_list_shape.rs diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index a08f08013..5a23b8dd7 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -701,6 +701,16 @@ fn stash_get(wb: &Workbook, key: &str) -> Json { .unwrap_or(json!({})) } +/// Like `stash_get`, for the name-keyed lists (views, scripts): a stored value that is not a JSON object is treated as empty. +fn stash_map(wb: &Workbook, key: &str) -> Json { + let v = stash_get(wb, key); + if v.is_object() { + v + } else { + json!({}) + } +} + fn stash_set(wb: &mut Workbook, key: &str, v: &Json) { let text = v.to_string(); let formula = format!("\"{}\"", text.replace('"', "\"\"")); @@ -723,7 +733,7 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc fn custom_views(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let mut views = stash_get(&d.wb, VIEWS_NAME); + let mut views = stash_map(&d.wb, VIEWS_NAME); if let Some(name) = str_param(p, "save") { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); @@ -938,7 +948,7 @@ fn new_script(s: &mut Session, p: &Json) -> Result { return Err(bad("automate.newScript", "`commands` must be an array")); } edit(s, |cx| { - let mut all = stash_get(&cx.wb, SCRIPTS_NAME); + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); all[name.as_str()] = cmds.clone(); stash_set(&mut cx.wb, SCRIPTS_NAME, &all); Ok(json!({"name": name})) @@ -977,7 +987,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result { fn delete_script(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); edit(s, |cx| { - let mut all = stash_get(&cx.wb, SCRIPTS_NAME); + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); if let Some(o) = all.as_object_mut() { o.remove(&name); } diff --git a/crates/engine/tests/stored_list_shape.rs b/crates/engine/tests/stored_list_shape.rs new file mode 100644 index 000000000..0d38cd5f9 --- /dev/null +++ b/crates/engine/tests/stored_list_shape.rs @@ -0,0 +1,48 @@ +use gridcraft_engine::Session; +use serde_json::json; + +const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\""]; + +fn with_name(name: &str, refers_to: &str) -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap(); + s +} + +#[test] +fn saving_a_view_works_when_stored_list_is_not_an_object() { + for text in NOT_OBJECTS { + let mut s = with_name("_sc_custom_views", text); + let out = s.execute("view.customViews", json!({"save": "v"})).unwrap(); + assert!(out.get("v").is_some(), "{text}: {out}"); + } +} + +#[test] +fn saving_a_view_keeps_the_keys_of_a_stored_object() { + let mut s = with_name("_sc_custom_views", "\"{\"\"a\"\":1}\""); + let out = s.execute("view.customViews", json!({"save": "v"})).unwrap(); + assert!(out.get("a").is_some() && out.get("v").is_some(), "{out}"); +} + +#[test] +fn creating_a_script_works_when_stored_list_is_not_an_object() { + for text in NOT_OBJECTS { + let mut s = with_name("_sc_scripts", text); + s.execute("automate.newScript", json!({"name": "s1", "commands": []})).unwrap(); + let list = s.execute("automate.allScripts", json!({})).unwrap(); + assert!(list.to_string().contains("s1"), "{text}: {list}"); + } +} + +#[test] +fn stopping_a_recording_works_when_stored_list_is_not_an_object() { + for text in NOT_OBJECTS { + let mut s = with_name("_sc_scripts", text); + s.execute("automate.recordActions", json!({})).unwrap(); + s.execute("automate.recordActions", json!({"stop": true, "name": "s1"})).unwrap(); + let list = s.execute("automate.allScripts", json!({})).unwrap(); + assert!(list.to_string().contains("s1"), "{text}: {list}"); + } +} From c20f4c2fcbbc94cebba313d58dc469d44ae00d42 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:35 +0200 Subject: [PATCH 09/86] Fail Web Page export on sheets taller than its row limit instead of dropping cells (#514) --- crates/engine/src/io.rs | 33 ++++++++++++++++++++++++++++----- docs/file-format-parity.md | 2 +- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index e613611d1..7dc4dffdf 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -167,7 +167,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()), FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()), FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), - FileKind::Html => Ok(to_html(wb, sheet).into_bytes()), + FileKind::Html => to_html(wb, sheet).map(String::into_bytes), } } @@ -285,9 +285,12 @@ impl std::fmt::Write for ClipboardHtml { } } +/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells. +const HTML_EXPORT_MAX_ROWS: u32 = 100_001; + /// A sheet as a standalone HTML table (Save as Web Page). -pub fn to_html(wb: &Workbook, sheet: usize) -> String { - let Some(sh) = wb.sheet(sheet) else { return String::new() }; +pub fn to_html(wb: &Workbook, sheet: usize) -> Result { + let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) }; let mut out = String::new(); let _ = write!( out, @@ -295,7 +298,13 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String { esc(&sh.name) ); if let Some(r) = sh.used_range() { - for row in r.start.row..=r.end.row.min(r.start.row + 100_000) { + if r.height() > HTML_EXPORT_MAX_ROWS { + return Err(EngineError::Other(format!( + "the sheet is too tall for a web page ({} rows, at most {HTML_EXPORT_MAX_ROWS}). Save as .xlsx or CSV instead.", + r.height() + ))); + } + for row in r.start.row..=r.end.row { if sh.is_row_hidden(row) { continue; } @@ -318,7 +327,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String { } } out.push_str(""); - out + Ok(out) } #[cfg(not(target_arch = "wasm32"))] @@ -418,6 +427,20 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> { mod tests { use super::*; + #[test] + fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near")); + sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far")); + assert!(save_bytes(&wb, "book.html").is_err()); + let sh = wb.sheet_mut(0).unwrap(); + sh.remove_cell(CellRef::new(149_999, 0)); + sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far")); + let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap(); + assert!(html.contains(">near") && html.contains(">far")); + } + #[test] fn macros_are_saved_only_into_xlsm() { let mut wb = Workbook::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 851065956..22bc4ec72 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -40,7 +40,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | -| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table) | Formatting not exported | +| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported | | `.mht` / `.mhtml` single-file web page | R/W (Windows) | no | no | | | `.json` | via Power Query | GridCraft's own workbook JSON (debug) and JSON import | GridCraft JSON | Not Power Query's JSON shaping; notes and hyperlinks break export (#126) | | `.xml` with XML maps | Developer ▸ Import/Export | no | no | | From 5fbd726a11869c674a53f4ac6e544715139910fb Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:51 +0200 Subject: [PATCH 10/86] Treat non-object params as empty in themeColors, background and drawBorder (#516) --- crates/engine/src/cmd/extra.rs | 11 ++++++++--- crates/engine/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index a08f08013..21dbd909b 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1052,8 +1052,13 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { }) } +/// Copy of the params that is always a JSON object, so keys can be assigned; any other JSON value counts as no params (like null). +fn object_params(p: &Json) -> Json { + Json::Object(p.as_object().cloned().unwrap_or_default()) +} + fn theme_colors(s: &mut Session, p: &Json) -> Result { - let mut params = p.clone(); + let mut params = object_params(p); if params.get("name").is_none() { params["name"] = json!(s.doc()?.wb.theme.name.clone()); } @@ -1074,7 +1079,7 @@ fn background(s: &mut Session, p: &Json) -> Result { Ok(Json::Null) }); } - let mut params = p.clone(); + let mut params = object_params(p); params["alt"] = json!("Sheet background"); params["at"] = json!("A1"); s.execute("insert.picture", params) @@ -1090,7 +1095,7 @@ fn draw_border(s: &mut Session, p: &Json) -> Result { "outline" => "outside", _ => "all", }; - let mut params = p.clone(); + let mut params = object_params(p); params["preset"] = json!(preset); s.execute("home.borders", params) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc113e..8aa6e9505 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1696,6 +1696,23 @@ fn every_command_survives_empty_params() { } } +#[test] +fn non_object_params_do_not_panic_in_wrapper_commands() { + // Issue #516. + for cmd in ["pageLayout.themeColors", "pageLayout.background", "home.drawBorder"] { + for params in [json!([]), json!(5), json!("x"), json!(true)] { + let mut s = s(); + let r = s.execute(cmd, params.clone()); + assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "{cmd} {params}: {r:?}"); + } + } + // A saved script step forwards its params unchanged into the same call. + let mut s = s(); + s.execute("automate.newScript", json!({"name": "bad", "commands": [{"command": "home.drawBorder", "params": []}]})).unwrap(); + let r = s.execute("automate.runScript", json!({"name": "bad"})); + assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "script: {r:?}"); +} + fn fmt_of(s: &Session, a: &str) -> String { let wb = &s.doc().unwrap().wb; let sh = wb.active().unwrap(); From 3ceb50f4043a1a5a01a371b4d1c1b0b2134eb6ee Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:34 +0200 Subject: [PATCH 11/86] Keep a solid data bar solid across an XLSX save and reopen (#550) --- crates/xlsx/src/read.rs | 3 ++ crates/xlsx/src/sheet_read.rs | 28 ++++++++++++- crates/xlsx/src/sheet_write.rs | 67 +++++++++++++++++++++++++++--- crates/xlsx/src/tests/roundtrip.rs | 28 +++++++++++++ 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index a25444d88..9b16fef62 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -28,6 +28,8 @@ pub struct Ctx<'a> { pub num_fmts: std::collections::HashMap, /// Value metadata blocks, indexed by the worksheet's one-based `vm` attribute. pub cell_pictures: Vec>>, + /// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`. + pub cf_ext_ids: Vec<(String, usize)>, } impl Ctx<'_> { @@ -79,6 +81,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { next_id: 1, num_fmts: Default::default(), cell_pictures: vec![], + cf_ext_ids: vec![], }; let root_rels = cx.pkg.rels("")?; let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into()); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 77b46d95b..a457ffd4a 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -60,6 +60,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result { @@ -596,6 +597,12 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { let formulas: Vec = r.kids("formula").map(|f| from_file(f.text.trim())).collect(); let f0 = formulas.first().cloned().unwrap_or_default(); let text = r.attr("text").unwrap_or("").to_string(); + // The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there). + let ext_id = r + .child("extLst") + .and_then(|l| l.kids("ext").find_map(|x| x.child("id"))) + .map(|i| i.text.trim().to_string()) + .filter(|i| !i.is_empty()); let rule = match r.attr("type").unwrap_or("") { "cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style }, "expression" => CfRule::Expression { formula: f0, style }, @@ -649,6 +656,9 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { continue; } }; + if let Some(id) = ext_id { + cx.cf_ext_ids.push((id, sheet.cond_formats.len())); + } sheet.cond_formats.push(CondFormat { ranges: ranges.clone(), rule, @@ -953,7 +963,23 @@ fn read_ext(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { } } } - "conditionalFormattings" => cx.warn("some Excel 2010 conditional format extensions were skipped"), + "conditionalFormattings" => { + let mut skipped = false; + for r in c.kids("conditionalFormatting").flat_map(|f| f.kids("cfRule")) { + let idx = r.attr("id").and_then(|id| cx.cf_ext_ids.iter().find(|(i, _)| i == id)).map(|(_, n)| *n); + match (idx.and_then(|n| sheet.cond_formats.get_mut(n)), r.child("dataBar")) { + (Some(CondFormat { rule: CfRule::DataBar { gradient, .. }, .. }), Some(db)) => { + *gradient = db.flag("gradient", true); + // Only the fill style is modelled; borders, lengths, axis or negative colours are lost. + skipped |= db.attrs.iter().any(|(k, _)| k != "gradient") || db.children.iter().any(|k| k.name != "cfvo"); + } + _ => skipped = true, + } + } + if skipped { + cx.warn("some Excel 2010 conditional format extensions were skipped"); + } + } _ => {} } } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 0bfcb7d85..8637c430c 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -573,9 +573,33 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( } let _ = write!(s, "{parts}", tables.len()); } - // Sparklines (Excel 2010 extension). + // Excel 2010 extensions: solid data bars, then sparklines. + let solid_bars: Vec<(&CondFormat, u32)> = sheet + .cond_formats + .iter() + .enumerate() + .filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. })) + .map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1))) + .collect(); + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); + } + if !solid_bars.is_empty() { + let _ = write!( + s, + "" + ); + for (cf, p) in &solid_bars { + if let CfRule::DataBar { min, max, .. } = &cf.rule { + let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max)); + let _ = write!(s, "{lo}{hi}"); + let _ = write!(s, "{}", sqref(&cf.ranges)); + } + } + s.push_str(""); + } if !sheet.sparklines.is_empty() { - s.push_str(""); + let _ = write!(s, ""); for sp in &sheet.sparklines { let ty = match sp.kind { SparklineKind::Line => "", @@ -593,7 +617,10 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( sp.cell.a1() ); } - s.push_str(""); + s.push_str(""); + } + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); } s.push_str(""); (s, rels) @@ -714,6 +741,33 @@ fn quote(t: &str) -> String { format!("\"{}\"", t.replace('"', "\"\"")) } +const X14_NS: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; +const XM_NS: &str = "http://schemas.microsoft.com/office/excel/2006/main"; + +/// The id linking a solid data bar's `cfRule` to its x14 extension; unique per sheet because priorities are. +fn solid_bar_id(priority: u32) -> String { + format!("{{00000000-0000-0000-0000-{priority:012}}}") +} + +/// The `cfRule` extension that points a solid data bar at its x14 rule. +fn solid_bar_ext(priority: u32) -> String { + let id = solid_bar_id(priority); + format!("{id}") +} + +/// An x14 `cfvo`: unlike the 2006 one it has no `val` attribute, the value is an `xm:f` child. +fn x14_cfvo(v: &CfValueKind) -> String { + let (ty, val) = match v { + CfValueKind::Min => return "".into(), + CfValueKind::Max => return "".into(), + CfValueKind::Number(n) => ("num", num(*n)), + CfValueKind::Percent(n) => ("percent", num(*n)), + CfValueKind::Percentile(n) => ("percentile", num(*n)), + CfValueKind::Formula(f) => ("formula", text_to_file(f)), + }; + format!("{}", esc(&val)) +} + fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; @@ -809,12 +863,13 @@ fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { b.push_str(""); format!("{b}") } - CfRule::DataBar { min, max, color, show_value, .. } => format!( - "{}{}", + CfRule::DataBar { min, max, color, show_value, gradient } => format!( + "{}{}{}", if *show_value { "" } else { " showValue=\"0\"" }, cfvo_xml(min), cfvo_xml(max), - if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) } + if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) }, + if *gradient { String::new() } else { solid_bar_ext(priority) } ), CfRule::IconSet { set, thresholds, reverse, show_value } => { let mut b = format!("9"), "{xml}"); + let (back, rep) = read_xlsx(&bytes).unwrap(); + assert!(rep.warnings.is_empty(), "{:?}", rep.warnings); + let b = &back.sheets[0]; + assert_eq!(b.cond_formats, wb.sheets[0].cond_formats); + assert_eq!(b.sparklines.len(), 1); +} From 8517c7c48dd27d69a211f83c13ebc9645c28b6b2 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:47 +0200 Subject: [PATCH 12/86] xlsx: warn when hyperlinks past the 10,000-cell cap are dropped on read (#513) --- crates/xlsx/src/sheet_read.rs | 5 +++-- crates/xlsx/src/tests/malformed.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 77b46d95b..3e5c83b32 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -104,7 +104,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_hyperlinks(e, &rels, &mut sheet), + "hyperlinks" => read_hyperlinks(cx, name, e, &rels, &mut sheet), "autoFilter" => sheet.autofilter = read_autofilter(cx, e), "printOptions" => { sheet.print.gridlines = e.flag("gridLines", false); @@ -709,7 +709,7 @@ fn read_validation(v: &El) -> Option { }) } -fn read_hyperlinks(e: &El, rels: &[Rel], sheet: &mut Sheet) { +fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) { let mut cells = 0u64; for h in e.kids("hyperlink") { let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue }; @@ -727,6 +727,7 @@ fn read_hyperlinks(e: &El, rels: &[Rel], sheet: &mut Sheet) { let link = Hyperlink { target, tooltip: h.attr("tooltip").map(str::to_string) }; for c in range.iter() { if cells >= MAX_HYPERLINK_CELLS { + cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped")); return; } cells += 1; diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index b14e53ab8..4700f8bdb 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -193,3 +193,19 @@ fn hostile_protected_ranges_stay_locked() { assert!(!matches!(h.verify("password"), Ok(true)), "{}", r.title); } } + +#[test] +fn hyperlinks_past_the_cell_cap_are_reported() { + // A styles part, so the only possible warning is the hyperlink one. + let parts = [("xl/styles.xml", r#""#)]; + let rels = r#""#; + let body = r#""#; + let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap(); + assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 10_000); + assert!(rep.warnings.iter().any(|w| w.contains("hyperlinks past the first 10000 cells were dropped")), "{:?}", rep.warnings); + // A small list reads in full with no warning. + let body = r#""#; + let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap(); + assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 3); + assert!(rep.warnings.is_empty(), "{:?}", rep.warnings); +} From 5334ae5cbc376c88c22b2c0f94b795def7069179 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:45 +0200 Subject: [PATCH 13/86] Keep manually hidden and collapsed rows when removing an AutoFilter (#508) --- crates/engine/src/cmd/data.rs | 47 +++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3baa6..f2505ebe5 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -454,9 +454,13 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result { if has { return edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; + // Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden. + let filtered: Vec = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect(); sh.autofilter = None; - for info in sh.rows.values_mut() { - info.hidden = false; + for r in filtered { + if let Some(info) = sh.rows.get_mut(&r) { + info.hidden = false; + } } cx.structural = true; Ok(json!({"filter": false})) @@ -1300,6 +1304,45 @@ mod tests { assert!(!ok(&mut s, "B1", "150")); } + #[test] + fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("data.group", json!({"rows": "2:4"})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + let hidden = |s: &Session| -> Vec { + let sh = s.doc().unwrap().wb.active().unwrap(); + sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect() + }; + let before = hidden(&s); + assert_eq!(before, [1, 2, 3, 9]); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none()); + assert_eq!(hidden(&s), before); + assert_eq!(row_state(&s, 4..=4), [(false, true)]); + } + + #[test] + fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap(); + s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + s.execute("selection.set", json!({"range": "E1"})).unwrap(); + // The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice. + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::>(), [2, 9]); + } + /// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6). fn nested_rows() -> Session { let mut s = Session::new(); From ba5e871329fb814ea258836e92258d0602169e36 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:33:14 +0200 Subject: [PATCH 14/86] Run the fill-handle double-click fill before the handle press returns (#507) --- crates/ui-egui/src/grid.rs | 42 +++++++------ .../ui-egui/tests/fill_handle_double_click.rs | 59 +++++++++++++++++++ 2 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/tests/fill_handle_double_click.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..23df0d402 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1743,6 +1743,29 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } + // Double-click the fill handle: a double-click is also a click, so run it before the handle press returns. + if resp.double_clicked() + && let Some(p) = pos + && on_handle(p) + { + // Fill down as far as the neighbouring column goes. + let src = sel.current(); + let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; + let mut last = src.end.row; + while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000 + { + last += 1; + } + if last > src.end.row { + let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); + let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); + } + app.grid.drag = Drag::None; + app.grid.drag_select = false; + resp.request_focus(); + return; + } + // Press: decide the drag mode. if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { // Where the press began (a drag is only recognised after the pointer has moved). @@ -2046,25 +2069,6 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.drag_select = false; } - // Double-click the fill handle. - if resp.double_clicked() - && let Some(p) = pos - && on_handle(p) - { - // Fill down as far as the neighbouring column goes. - let src = sel.current(); - let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; - let mut last = src.end.row; - while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000 - { - last += 1; - } - if last > src.end.row { - let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); - let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); - } - } - // Right-click: context menu. if resp.secondary_clicked() && let Some(p) = pos diff --git a/crates/ui-egui/tests/fill_handle_double_click.rs b/crates/ui-egui/tests/fill_handle_double_click.rs new file mode 100644 index 000000000..88b87376f --- /dev/null +++ b/crates/ui-egui/tests/fill_handle_double_click.rs @@ -0,0 +1,59 @@ +//! Double-clicking the fill handle fills down to the neighbouring column's last used row, +//! even with the pointer up for a frame between the two clicks. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5], [6]]})).unwrap(); + session.execute("range.setValues", json!({"range": "B1", "values": [[10], [20], [30]]})).unwrap(); + session.execute("selection.set", json!({"range": "B1:B3"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn handle(h: &egui_kittest::Harness<'_, SheetApp>) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::new(2, 1)).right_bottom() - vec2(1.0, 1.0) +} + +fn button(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, pressed: bool) { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn number(h: &egui_kittest::Harness<'_, SheetApp>, row: u32) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(row, 1)) +} + +#[test] +fn double_click_on_the_fill_handle_fills_down_with_a_frame_between_the_clicks() { + let mut h = harness(); + let pos = handle(&h); + button(&mut h, pos, true); + button(&mut h, pos, false); + h.step(); // One idle frame between the clicks, well inside the double-click interval. + button(&mut h, pos, true); + button(&mut h, pos, false); + h.run_steps(2); + + assert_eq!([number(&h, 3), number(&h, 4), number(&h, 5)], [Value::Number(40.0), Value::Number(50.0), Value::Number(60.0)]); + assert_eq!(h.state().session.active().unwrap().selection.a1(), "B1:B6"); + assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "edit.autoFill").count(), 1); +} From 0ccc94edd34ebae3308ab8dff61d151a35dc8ef7 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:47 +0200 Subject: [PATCH 15/86] Save a table that has only a header row and a totals row (#511) --- crates/xlsx/src/sheet_write.rs | 2 +- crates/xlsx/src/tests/roundtrip.rs | 36 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 0bfcb7d85..0020adf8b 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -81,7 +81,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let tables: Vec<(&Table, Vec)> = sheet .tables .iter() - .filter(|t| t.range.height() > t.header_row as u32 + t.totals_row as u32 || (t.header_row && !t.totals_row && t.range.height() >= 1)) + .filter(|t| t.range.height() > t.header_row as u32 + t.totals_row as u32 || (t.header_row && t.range.height() > t.totals_row as u32)) .map(|t| (t, table_columns(t))) .collect(); for (t, cols) in &tables { diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 960157254..6d50c9ddf 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -988,6 +988,42 @@ fn table_newline_column_and_backslash_name_survive() { assert!(gridcraft_formula::parse("COUNTA(\\_Prime.1[Name])").is_ok(), "backslash table name parses as a structured reference"); } +/// A table that is only a header row and a totals row (Insert Table on one row, then Total +/// Row) is written like the header-only and one-data-row shapes, not dropped on save. +#[test] +fn header_and_totals_table_without_data_row_survives() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + s.tables.push(Table { + id: 1, + name: "Table1".into(), + range: rr("A1:B2"), + header_row: true, + totals_row: true, + columns: vec![ + TableColumn { name: "Name".into(), totals: TotalsFn::None, totals_label: Some("Total".into()), formula: None }, + TableColumn { name: "Amount".into(), totals: TotalsFn::Sum, totals_label: None, formula: None }, + ], + style: "TableStyleMedium2".into(), + banded_rows: true, + banded_cols: false, + first_col: true, + last_col: true, + filter_button: true, + filter: vec![], + }); + let (back, report) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + let tables = &back.sheet(0).unwrap().tables; + assert_eq!(tables.len(), 1); + let t = &tables[0]; + assert_eq!((t.name.as_str(), t.range.a1(), t.header_row, t.totals_row), ("Table1", "A1:B2".to_string(), true, true)); + assert_eq!((t.style.as_str(), t.banded_rows, t.first_col, t.last_col), ("TableStyleMedium2", true, true, true)); + assert_eq!(t.columns[0].totals_label.as_deref(), Some("Total")); + assert_eq!(t.columns[1].totals, TotalsFn::Sum); + assert_eq!(back.sheet(0).unwrap().value(at("A1")), Value::text("Name")); +} + /// Templates and macro-enabled files differ from `.xlsx` only in the workbook part's content /// type; a template saved with the workbook type is rejected by Excel. #[test] From 9da1814cdf3fa4fa2d3b67aa77d69be3edc9fd72 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:35 +0200 Subject: [PATCH 16/86] Refuse a PivotTable that cannot fit before aggregating all column groups (#515) --- crates/engine/src/cmd/pivot.rs | 34 +++++++++++++++++++++++++++++++++ crates/engine/src/pivot.rs | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 0d73b0a4a..d44f2e01d 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -2039,4 +2039,38 @@ mod tests { assert!(e.contains("field name is not valid"), "{e}"); assert!(s.execute("insert.pivotTable", json!({"source": "NoSuchTable"})).is_err()); } + + #[test] + fn too_many_value_columns_are_refused() { + let mut s = Session::new(); + s.new_workbook(); + let mut records: Vec = vec![json!(["Cat", "Sub", "V"])]; + for r in 0..64 { + for c in 0..64 { + records.push(json!([format!("c{r}"), format!("s{c}"), 1.0])); + } + } + s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); + let req = |n: usize| { + let values: Vec = (0..n).map(|_| json!({"field": "V"})).collect(); + json!({"source": "A1:C4097", "rows": ["Cat"], "columns": ["Sub"], "values": values}) + }; + let e = s.execute("insert.pivotTable", req(256)).unwrap_err().to_string(); + assert!(e.contains("will not fit"), "{e}"); + assert!(s.execute("insert.pivotTable", req(128)).is_ok()); + } + + #[test] + fn collapsed_column_items_count_as_one_column_group() { + let mut s = Session::new(); + s.new_workbook(); + let mut records: Vec = vec![json!(["Grp", "Sub", "V"])]; + records.extend((0..64).map(|c| json!(["g", format!("s{c}"), 1.0]))); + s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); + let values: Vec = (0..256).map(|_| json!({"field": "V"})).collect(); + s.execute("insert.pivotTable", json!({"source": "A1:C65", "columns": ["Grp"], "values": values})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Grp"})).unwrap(); + // 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit. + s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap(); + } } diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index c76bf4b9e..a53ee405d 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -693,6 +693,12 @@ enum ValSrc { Calc(usize), } +/// True when `col_groups` distinct column leaves (plus the grand-total column) times `values` value fields +/// (at least one data column per group) already exceed the sheet width. This is a lower bound on the final column count. +fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -> bool { + (col_groups + usize::from(grand_total)).saturating_mul(values.max(1)) > MAX_COLS as usize +} + /// Computes a PivotTable report from the workbook. pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result { let src = read_source(wb, &pt.source, pivot_sheet)?; @@ -752,6 +758,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result> = HashSet::new(); let mut col_set: HashSet> = HashSet::new(); + let mut col_leaves: HashSet> = HashSet::new(); let filter_fmts: Vec = filters.iter().map(|f| fmt_of(f.0)).collect(); let row_fmts: Vec = rows.iter().map(|(c, _)| fmt_of(*c)).collect(); let col_fmts: Vec = cols.iter().map(|(c, _)| fmt_of(*c)).collect(); @@ -788,8 +795,22 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result 5_000_000 { return Err("The PivotTable report is too large.".into()); } @@ -1428,3 +1449,17 @@ pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) { p.last_range = None; } } + +#[cfg(test)] +mod fit_tests { + use super::data_cols_exceed_sheet; + + #[test] + fn column_budget_counts_value_fields_and_grand_total() { + assert!(!data_cols_exceed_sheet(64, true, 128)); + assert!(data_cols_exceed_sheet(64, true, 256)); + assert!(!data_cols_exceed_sheet(64, false, 256)); + assert!(!data_cols_exceed_sheet(16_384, false, 0)); + assert!(data_cols_exceed_sheet(16_385, false, 0)); + } +} From 8663be2c320a55016ac95b91476a46cad69a5f2b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:33:17 +0200 Subject: [PATCH 17/86] Keep a long outline group's bracket once its first 4096 lines scroll away (#510) --- crates/ui-egui/src/outline.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 7eaf1337e..62e2cc36d 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -142,6 +142,13 @@ impl Dir<'_> { let (a0, a1) = (a.max(top), (a + size).min(self.hi)); (size > 0.0 && a1 > a0).then_some((a0, a1)) } + /// The first on-screen line in `start..=end`: the frozen ones first, then the scrolling ones from the line + /// at the pane's edge, so a long group is not scanned from its far-off start. + fn first_visible(&self, start: u32, end: u32) -> Option<(u32, (f32, f32))> { + let at = if self.rows { self.geo.row_at(self.sh, self.split) } else { self.geo.col_at(self.sh, self.split) }; + let scrolled = start.max(self.frozen).max(at); + (start..self.frozen.min(end.saturating_add(1))).chain(scrolled..=end).find_map(|l| self.visible(l).map(|v| (l, v))) + } fn point(&self, along: f32, across: f32) -> Pos2 { if self.rows { pos2(across, along) } else { pos2(along, across) } } @@ -198,7 +205,7 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { continue; } // The bracket, over the part of the detail that is on screen. - let first = (grp.start..=grp.end).take(4096).find_map(|l| dir.visible(l).map(|v| (l, v))); + let first = dir.first_visible(grp.start, grp.end); let Some((first_line, (a0, _))) = first else { continue }; let a1 = match button { Some(mid) => mid - side / 2.0, @@ -298,6 +305,28 @@ mod tests { assert_eq!(g.head_left(), g.rect.left()); } + #[test] + fn long_groups_keep_their_bracket_past_4096_lines() { + for rows in [true, false] { + let mut sh = Sheet::new("S"); + let map = if rows { &mut sh.rows } else { &mut sh.cols }; + for i in 0..5000u32 { + map.entry(i).or_default().outline = 1; + } + for line in [0u32, 4095, 4096, 4200, 4970] { + let scroll = if rows { vec2(0.0, sh.row_top(line) as f32) } else { vec2(sh.col_left(line) as f32, 0.0) }; + let g = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll); + let l = layout(&g, &sh); + assert_eq!(l.brackets.len(), 1, "rows={rows} line={line}"); + assert_eq!(l.brackets[0].tick.is_some(), line == 0, "rows={rows} line={line}"); + } + // Past the group's summary line nothing is left to bracket. + let scroll = if rows { vec2(0.0, sh.row_top(5100) as f32) } else { vec2(sh.col_left(5100) as f32, 0.0) }; + let g = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll); + assert!(layout(&g, &sh).brackets.is_empty(), "rows={rows}"); + } + } + #[test] fn bands_push_the_headers_and_cells_in() { let sh = sheet(); From a33a5741d83ee748d37a2090885f5293c45452c4 Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:25:35 +0200 Subject: [PATCH 18/86] i18n(cs): translate 120 labels and tooltips already looked up via tr() Ribbon tooltips and menu items, cell and header context menus, formula bar, sheet tabs and status controls had no Czech entry and fell back to English. Catalog-only: cs.rs, no new tr() call sites, no other language. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 127 ++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index ca2e55942..3e25f82f7 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -391,6 +391,133 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Další\nfunkce", "Sheet Options|View" => "Zobrazit", "Print" => "Tisknout", + + // Additional runtime labels and tooltips, grouped by source file. + // formula_bar.rs (alphabetical keys). + "Cancel" => "Zrušit", + "Expand formula bar" => "Rozbalit řádek vzorců", + "Insert Function" => "Vložit funkci", + // grid.rs (alphabetical keys). + "Copy" => "Kopírovat", + "Cut" => "Vyjmout", + "Define Name…" => "Definovat název…", + "Delete…" => "Odstranit…", + "Filter by Selected Cell's Value" => "Filtrovat podle hodnoty vybrané buňky", + "Hide" => "Skrýt", + "Insert…" => "Vložit…", + "Link…" => "Odkaz…", + "New Comment" => "Nový komentář", + "Pick From Drop-down List…" => "Vybrat z rozevíracího seznamu…", + "Unhide" => "Zobrazit", + // ribbon.rs (alphabetical keys). + "$ English (United States)" => "$ Angličtina (Spojené státy)", + "Add Chart Element" => "Přidat prvek grafu", + "Align Left" => "Zarovnat doleva", + "Align Right" => "Zarovnat doprava", + "Bottom Align" => "Zarovnat dolů", + "Calculate Now (F9)" => "Přepočítat (F9)", + "Calculate Sheet (⇧F9)" => "Přepočítat list (⇧F9)", + "Calculation Options" => "Možnosti výpočtu", + "Cell Styles" => "Styly buněk", + "Change Chart Type" => "Změnit typ grafu", + "Check Accessibility" => "Zkontrolovat přístupnost", + "Clear Filter" => "Vymazat filtr", + "Column or Bar" => "Sloupcový nebo pruhový graf", + "Comments pane" => "Podokno komentářů", + "Convert the last ink stroke to a shape" => "Převést poslední tah rukopisu na obrazec", + "Convert to Range" => "Převést na oblast", + "Custom Sort" => "Vlastní řazení", + "Delete Comment" => "Odstranit komentář", + "Delete Sheet" => "Odstranit list", + "Delete chart" => "Odstranit graf", + "Down" => "Dolů", + "Edit chart title" => "Upravit název grafu", + "Erase ink strokes" => "Vymazat tahy rukopisu", + "Every command run in this session (replayable)" => "Všechny příkazy spuštěné v této relaci (lze znovu přehrát)", + "Filter (⌘⇧F)" => "Filtr (⌘⇧F)", + "Find & Select" => "Najít a vybrat", + "Flash Fill (⌘E)" => "Dynamické doplňování (⌘E)", + "Forecast Sheet" => "List prognózy", + "Format Chart pane" => "Podokno formátu grafu", + "Format as Table" => "Formátovat jako tabulku", + "Hide Sheet" => "Skrýt list", + "Hierarchy & more" => "Hierarchické a další grafy", + "Highlighter" => "Zvýrazňovač", + "How agents drive GridCraft (MCP / control channel)" => "Jak agenti ovládají GridCraft (MCP / řídicí kanál)", + "Import a text or CSV file" => "Importovat textový soubor nebo CSV", + "Insert\nSlicer" => "Vložit\nprůřez", + "Insert Function (⇧F3)" => "Vložit funkci (⇧F3)", + "Insert Link (⌘K)" => "Vložit odkaz (⌘K)", + "Insert Sheet" => "Vložit list", + "Insert Slicer" => "Vložit průřez", + "Insert a picture" => "Vložit obrázek", + "Insert a slicer to filter a table" => "Vložit průřez pro filtrování tabulky", + "Insert an icon" => "Vložit ikonu", + "Line or Area" => "Spojnicový nebo plošný graf", + "Macros" => "Makra", + "Macros and scripts in this workbook (Alt+F8)" => "Makra a skripty v tomto sešitu (Alt+F8)", + "Middle Align" => "Zarovnat doprostřed", + "More commands" => "Další příkazy", + "Move or Copy Sheet…" => "Přesunout nebo zkopírovat list…", + "Name Manager" => "Správce názvů", + "Page Breaks" => "Konce stránek", + "Paper Size" => "Velikost papíru", + "Paste (⌘V)" => "Vložit (⌘V)", + "Pen\nBlack" => "Pero\nčerné", + "Pen\nBlue" => "Pero\nmodré", + "Pen\nRed" => "Pero\nčervené", + "Pie or Doughnut" => "Výsečový nebo prstencový graf", + "Print Area" => "Oblast tisku", + "Print Titles" => "Tisk názvů", + "Protect\nSheet" => "Zamknout\nlist", + "Protect Sheet" => "Zamknout list", + "Protect Workbook structure" => "Zamknout strukturu sešitu", + "Recommended Charts" => "Doporučené grafy", + "Redo (⌘Y)" => "Znovu (⌘Y)", + "Remove Duplicates" => "Odebrat duplicity", + "Rename Sheet" => "Přejmenovat list", + "Save (⌘S)" => "Uložit (⌘S)", + "Scatter or Bubble" => "Bodový nebo bublinový graf", + "Search and run any command" => "Vyhledat a spustit libovolný příkaz", + "Search commands (⌘⇧Space)" => "Vyhledat příkazy (⌘⇧Space)", + "Select Data" => "Vybrat data", + "Select and move ink and shapes" => "Vybrat a přesunout rukopis a obrazce", + "Series…" => "Řady…", + "Share: export or save a copy" => "Sdílet: exportovat nebo uložit kopii", + "Show Formulas (⌃`)" => "Zobrazit vzorce (⌃`)", + "Slicer" => "Průřez", + "Sort & Filter" => "Seřadit a filtrovat", + "Spelling (F7)" => "Pravopis (F7)", + "Statistic" => "Statistické grafy", + "Switch Row/Column" => "Prohodit řádky/sloupce", + "Table (⌘T)" => "Tabulka (⌘T)", + "Table Styles" => "Styly tabulky", + "Text Box" => "Textové pole", + "Text to Columns" => "Text do sloupců", + "Top Align" => "Zarovnat nahoru", + "Undo (⌘Z)" => "Zpět (⌘Z)", + "Undo list" => "Seznam akcí k vrácení", + "Unhide Sheet…" => "Zobrazit list…", + "Up" => "Nahoru", + "Version, contributors and the AI models that helped" => "Verze, přispěvatelé a modely AI, které pomáhaly", + "Watch\nWindow" => "Okno\nsledování", + "What-If Analysis" => "Citlivostní analýza", + "Workbook\nStatistics" => "Statistika\nsešitu", + "Workbook Statistics" => "Statistika sešitu", + "Zoom to 100%" => "Přiblížit na 100 %", + "Zoom to Selection" => "Přiblížit na výběr", + "Zoom…" => "Lupa…", + "£ English (United Kingdom)" => "£ Angličtina (Spojené království)", + "¥ Japanese" => "¥ Japonština", + "€ Euro" => "€ Euro", + // tabs.rs (alphabetical keys). + "New sheet (⇧F11)" => "Nový list (⇧F11)", + "Next sheet" => "Další list", + "Previous sheet" => "Předchozí list", + "Zoom In" => "Přiblížit", + "Zoom Out" => "Oddálit", + // widgets.rs (alphabetical keys). + "Dismiss" => "Zavřít", _ => return None, }) } From 0514e537ac5608fafad3579345b002f01fb39109 Mon Sep 17 00:00:00 2001 From: derechopiano <257026466+derechopiano@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:56:52 +0100 Subject: [PATCH 19/86] #441: Open AutoFilter header menus with Alt+Down --- crates/ui-egui/src/keymap.rs | 3 + crates/ui-egui/tests/keyboard_shortcuts.rs | 65 ++++++++++++++++++++++ docs/ui-parity.md | 4 +- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 21bf41ff4..94f6d30bd 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -218,6 +218,9 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic Shortcut::DropDown => { if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) { app.grid.list_picker = Some(active); + } else if sh.has_filter_button(active) { + let at = geo.mirror_pos(crate::grid::filter_button_rect(geo, sh, active).center()); + app.grid.filter_menu = Some((active.col, at)); } } Shortcut::Zoom(step) => { diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index 82d3f2fa8..ae0ce5c66 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -149,3 +149,68 @@ fn space_shortcuts_per_platform() { // Cmd+Space is Spotlight on macOS. assert_eq!(space(mac_cmd, true), None); } + +#[test] +fn alt_down_opens_autofilter_headers_without_changing_the_workbook() { + for rtl in [false, true] { + for cell in ["A1", "B1"] { + let mut session = book(cell); + session.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + session.execute("view.rightToLeft", json!({"on": rtl})).unwrap(); + let mut h = harness(session); + let before = h.state().session.active().unwrap().wb.clone(); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + let (column, at) = h.state().grid.filter_menu.expect("Alt+Down must open the filter header menu"); + let d = h.state().session.active().unwrap(); + let active = CellRef::parse(cell).unwrap(); + assert_eq!(column, active.col); + assert_eq!(d.selection.active, active); + assert_eq!(d.wb, before); + let sh = d.wb.active().unwrap(); + let geo = gridcraft_ui_egui::grid::Geo::new(sh, h.state().grid.rect.unwrap(), h.state().view().scroll); + assert_eq!(at, geo.mirror_pos(gridcraft_ui_egui::grid::filter_button_rect(&geo, sh, active).center())); + // Opening focuses the existing search box; typing must not edit the header. + h.input_mut().events.push(Event::Text("1".into())); + h.run_steps(2); + assert_eq!(h.ctx.data(|data| data.get_temp::(egui::Id::new(("filter_search", column)))).as_deref(), Some("1")); + assert!(h.state().editor.is_none()); + assert_eq!(h.state().session.active().unwrap().wb, before); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().grid.filter_menu.is_none()); + } + } +} + +#[test] +fn alt_down_keeps_validation_priority_and_plain_down_navigation() { + let mut session = book("A1"); + session.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + session.execute("data.validation", json!({"range": "A1", "type": "list", "formula1": "\"First,Second\""})).unwrap(); + let mut h = harness(session); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert_eq!(h.state().grid.list_picker, Some(CellRef::parse("A1").unwrap())); + assert!(h.state().grid.filter_menu.is_none()); + key(&mut h, Key::Escape, Modifiers::NONE); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2"); + assert!(h.state().grid.filter_menu.is_none()); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert!(h.state().grid.filter_menu.is_none(), "a data cell has no filter button"); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2"); +} + +#[test] +fn alt_down_only_opens_enabled_table_filter_buttons() { + for enabled in [false, true] { + let mut session = book("B1"); + session.execute("insert.table", json!({"range": "A1:B3"})).unwrap(); + if !enabled { + session.execute("table.headerRow", json!({"on": false})).unwrap(); + } + session.execute("selection.set", json!({"cell": "B1"})).unwrap(); + let mut h = harness(session); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert_eq!(h.state().grid.filter_menu.map(|(column, _)| column), enabled.then_some(1)); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbccc..940305f88 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Alt+Down opens AutoFilter header menus) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -117,6 +117,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Ctrl+Alt+V | Cmd+Control+V, Cmd+Option+V | Paste Special | **added** | | Ctrl+Alt+= / Ctrl+Alt+- | — | zoom in / out (10% steps) | **added** | | Alt+=, Alt+Down, Alt+Page Down / Up | Option+Down, Option+Page Down / Up | AutoSum, drop-down list, one screen right / left | **added** | +| Alt+Down on a filter header | Option+Down | Open the existing AutoFilter popup; validation dropdowns keep priority | **added** (AutoFilter portion of #441; Ribbon dropdowns and column value picker are separate work) | | Alt+F1, Alt+Shift+F1, Shift+F11 | Shift+F11 | embedded chart, new sheet | **added** | | Shift+F2, Ctrl+Shift+F2 | Shift+F2, Cmd+Shift+F2 | note, threaded comment | **added** | | Shift+F3, Ctrl+F3, Ctrl+Shift+F3 | Shift+F3, Cmd+F3 (Define Name), Cmd+Shift+F3 | Insert Function, Name Manager, Create from Selection | **added** | @@ -160,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | From bf9a202222963cf5febea36a3b64d9e772362e1a Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 12 Oct 2026 20:29:00 +1100 Subject: [PATCH 20/86] Compute the Top 10 filter threshold once per column, not once per row (#661) apply_filter cloned and sorted the column's numbers for every row it tested, O(n^2 log n). The threshold is now computed once per criterion with the other column stats. Adds a test for top/bottom items and percent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ut3hr3a13szpQdQ7gbYbfc --- crates/engine/src/cmd/data.rs | 27 ++++++++++++++++----------- crates/engine/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3baa6..0effb86d0 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -570,14 +570,23 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { let Some(af) = sh.autofilter.clone() else { return }; let r = af.range; let mut hide = Vec::new(); - // Precompute per-column numbers for top10/average. - let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); + // Precompute per-column top10 threshold and average once, not per row. + let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); for (off, c) in &af.criteria { if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) { let col = r.start.col + off; - let nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); + let mut nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::() / nums.len() as f64 }; - col_stats.insert(*off, (nums, avg)); + let thr = match c { + FilterCriterion::Top10 { bottom, percent, count } => { + nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); + let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; + let k = k.clamp(1, nums.len().max(1)); + nums.get(k - 1).copied() + } + _ => None, + }; + col_stats.insert(*off, (thr, avg)); } } for row in r.start.row + 1..=r.end.row { @@ -604,13 +613,9 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { None => x, } } - FilterCriterion::Top10 { bottom, percent, count } => match (v.as_f64(), col_stats.get(off)) { - (Some(n), Some((nums, _))) => { - let mut sorted = nums.clone(); - sorted.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); - let k = if *percent { ((sorted.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; - let k = k.clamp(1, sorted.len().max(1)); - let thr = sorted.get(k - 1).copied().unwrap_or(n); + FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(off)) { + (Some(n), Some((thr, _))) => { + let thr = thr.unwrap_or(n); if *bottom { n <= thr } else { n >= thr } } _ => false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc113e..2d1282282 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -805,6 +805,24 @@ fn sort_and_filter() { assert_eq!(r["hiddenRows"], 1); } +/// Top 10 keeps the top or bottom N items or percent, ties at the threshold included. +#[test] +fn filter_top10_items_and_percent() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Score"], [5], [1], [9], [3], [9], [7], ["x"]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + s.execute("data.filter", json!({})).unwrap(); + // Top 2 items: 9, 9 (the "x" text row is hidden too). + let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2}})).unwrap(); + assert_eq!(r["hiddenRows"], 5); + // Bottom 2 items: 1, 3. + let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2, "bottom": true}})).unwrap(); + assert_eq!(r["hiddenRows"], 5); + // Top 50 percent of 6 numbers = 3 items; the threshold 7 keeps 9, 9, 7. + let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 50, "percent": true}})).unwrap(); + assert_eq!(r["hiddenRows"], 4); +} + /// Turning the filter on with only the header row selected filters the data below it (#201). #[test] fn filter_on_a_selected_header_row_covers_the_data_below() { From 2589f3f7d46f7ff3f40f87a320c5adfc139b0d39 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 13:17:09 +0200 Subject: [PATCH 21/86] Data validation: hide the input message while the cell's list is open (#505) The input message is a tooltip-order area just below the cell, and the in-cell dropdown opens at the same spot, so the message covered the first items of the list. It now stays hidden while the list picker is open on the active cell and comes back when the list closes. --- crates/ui-egui/src/grid.rs | 3 +- .../ui-egui/tests/validation_input_message.rs | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/validation_input_message.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..80a39c821 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2715,8 +2715,9 @@ pub(crate) fn paint_overlays( p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside); crate::icons::paint(p, r.shrink(3.0), crate::icons::Icon::Chevron, Color32::from_gray(60)); } - // Input message of the active cell's validation. + // Input message of the active cell's validation, hidden while its list is open: it would cover the items. if app.editor.is_none() + && app.grid.list_picker != Some(sel.active) && let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active))) && dv.show_input && !(dv.input_title.is_empty() && dv.input_message.is_empty()) diff --git a/crates/ui-egui/tests/validation_input_message.rs b/crates/ui-egui/tests/validation_input_message.rs new file mode 100644 index 000000000..e05dbcefe --- /dev/null +++ b/crates/ui-egui/tests/validation_input_message.rs @@ -0,0 +1,56 @@ +//! A list validation's input message must not cover the dropdown it describes (#505). + +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const MESSAGE: &str = "Pick a colour from the list"; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("data.validation", json!({"range": "B2", "type": "list", "formula1": "\"Red,Green,Blue\"", "inputMessage": MESSAGE})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn message_shown(h: &Harness) -> bool { + h.query_by_label(MESSAGE).is_some() +} + +#[test] +fn input_message_hides_while_the_list_is_open() { + let mut h = harness(); + assert!(message_shown(&h), "the selected cell's input message is shown"); + + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let geo = grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + let pos = grid::validation_arrow(sh, &geo, CellRef::parse("B2").unwrap()).unwrap().center(); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + + assert!(h.state().grid.list_picker.is_some(), "the dropdown arrow opens the list"); + assert!(h.query_by_label("Green").is_some(), "the list shows its items"); + assert!(!message_shown(&h), "the input message must not cover the open list"); + + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().grid.list_picker.is_none(), "Escape closes the list"); + assert!(message_shown(&h), "the input message comes back once the list is closed"); +} From ef15e22815c84fc3043798c860e19dbe6c16e207 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 13:27:27 +0200 Subject: [PATCH 22/86] Charts: a newly inserted chart is selected, so Delete removes it (#668, #256) Only the Recommended Charts dialog selected the chart it inserted. The Insert gallery buttons and Alt+F1 dropped the id insert.chart returns, so the selection stayed on the source cells and Delete cleared the data instead of the chart. SheetApp::run now selects the new chart whenever insert.chart succeeds, which covers every caller. --- crates/ui-egui/src/lib.rs | 7 +++++++ crates/ui-egui/tests/interaction.rs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec29139d3..417a4269b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -344,6 +344,13 @@ impl SheetApp { } let r = self.session.run(id, params); self.after_engine(); + // The new chart is selected, as after inserting any object, so Delete removes it, not its data. + if id == "insert.chart" + && let Ok(r) = &r + && let Some(chart) = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()) + { + self.selected_chart = Some(chart); + } if id == "file.exportPdf" && let Ok(r) = &r { diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 709393389..4c59bf056 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -224,6 +224,25 @@ fn delete_key_removes_the_selected_chart() { assert_eq!(value(&h, "A1"), Value::Number(1.0)); } +#[test] +fn inserted_chart_is_selected_so_delete_removes_it() { + // #668, #256: after Insert › Chart the selection stayed on the source cells, so Delete + // cleared the data and left the chart behind. + let mut session = blank(); + session.execute("range.setValues", json!({"range": "A1:B2", "values": [[1, 2], [3, 4]]})).unwrap(); + session.execute("selection.set", json!({"range": "A1:B2"})).unwrap(); + let mut h = harness(session); + + let id = h.state_mut().run("insert.chart", json!({"type": "column"})).unwrap()["chart"].as_u64().unwrap() as u32; + h.run_steps(2); + assert_eq!(h.state().selected_chart, Some(id), "the new chart is selected"); + + key(&mut h, Key::Delete, Modifiers::NONE); + + assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty()); + assert_eq!(value(&h, "A1"), Value::Number(1.0)); +} + #[test] fn renders_every_ribbon_tab_without_panicking() { let mut s = Session::new(); From f7f04df4d79f7aba84a35a5033e489ef4a53c727 Mon Sep 17 00:00:00 2001 From: derechopiano <257026466+derechopiano@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:55:18 +0100 Subject: [PATCH 23/86] #681: Reveal the selected worksheet tab after navigation --- crates/ui-egui/src/tabs.rs | 12 ++ crates/ui-egui/tests/sheet_tabs.rs | 176 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 3 +- docs/ui-parity.md | 4 +- 4 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/sheet_tabs.rs diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index e55f575c6..5bd54568d 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -40,6 +40,15 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // Keep worksheet tabs in their own horizontally scrollable // viewport, leaving room for the New Sheet button and grid scrollbar. let tabs_width = (ui.available_width() - 180.0).max(100.0); + // Reveal the selected sheet after navigation or layout changes, but + // allow browsing other tabs without snapping back on every frame. + let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); + let reveal_id = ui.id().with("worksheet_tabs_reveal"); + let reveal_active = ui.ctx().data_mut(|data| { + let changed = data.get_temp::(reveal_id) != Some(reveal_key); + data.insert_temp(reveal_id, reveal_key); + changed + }); ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { // Translate vertical wheel movement into horizontal // worksheet-tab scrolling. @@ -68,6 +77,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x; let w = (tw + 32.0).max(72.0); let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); + if active && reveal_active { + resp.scroll_to_me(None); + } let r = r.shrink2(vec2(0.0, 1.0)); if active { ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active); diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs new file mode 100644 index 000000000..966ea60f0 --- /dev/null +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -0,0 +1,176 @@ +//! Overflowing worksheet tabs, exercised through their actual painted geometry and input. +use egui::{Color32, Event, Modifiers, PointerButton, Rect, Shape, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(width: f32, active: usize) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap(); + for i in 1..30 { + s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap(); + } + s.execute("sheet.activate", json!({"sheet":active})).unwrap(); + let mut fonts_ready = false; + let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state( + move |ui, app: &mut SheetApp| { + if !fonts_ready { + ui.ctx().set_fonts(gridcraft_ui_egui::theme::font_definitions()); + fonts_ready = true; + return; + } + app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0))); + gridcraft_ui_egui::tabs::sheet_tabs(app, ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(20); + h +} + +fn tab(h: &Harness, name: &str) -> Option<(Rect, Rect)> { + h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == name => Some((t.visual_bounding_rect(), s.clip_rect)), + _ => None, + }) +} + +fn assert_visible(h: &Harness, name: &str) { + let (text, clip) = tab(h, name).unwrap_or_else(|| panic!("{name} was not painted in the tab viewport")); + assert!(clip.contains_rect(text), "{name} is clipped: text={text:?}, viewport={clip:?}"); +} + +fn click(h: &mut Harness, at: egui::Pos2) { + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); +} + +#[test] +fn opening_and_activating_late_sheets_reveals_their_tabs() { + for width in [500.0, 1000.0] { + let mut h = harness(width, 29); + assert_visible(&h, "Tab29"); + h.state_mut().run("sheet.activate", json!({"sheet":0})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab00"); + h.state_mut().run("sheet.activate", json!({"sheet":20})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab20"); + } +} + +#[test] +fn navigation_arrows_reveal_the_selected_tab() { + let mut h = harness(700.0, 0); + let (_, clip) = tab(&h, "Tab00").unwrap(); + let next = pos2(clip.left() - 20.0, clip.center().y); + for _ in 0..29 { + click(&mut h, next); + } + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); + assert_visible(&h, "Tab29"); +} + +#[test] +fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() { + for delta in [vec2(0.0, -350.0), vec2(-350.0, 0.0)] { + let mut h = harness(700.0, 0); + let before = h.state().session.doc().unwrap().wb.clone(); + let (_, clip) = tab(&h, "Tab00").unwrap(); + h.input_mut().events.extend([ + Event::PointerMoved(clip.center()), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::Start, + delta: vec2(0.0, 0.0), + modifiers: Modifiers::NONE, + }, + ]); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::Move, + delta, + modifiers: Modifiers::NONE, + }); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::End, + delta: vec2(0.0, 0.0), + modifiers: Modifiers::NONE, + }); + h.run_steps(40); + assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "gesture should browse away from the selected tab: {delta:?}"); + assert_eq!(h.state().session.doc().unwrap().wb, before, "browsing tabs must not edit the workbook"); + h.run_steps(40); + assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "idle frames must not snap back"); + h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab29"); + } +} +#[test] +fn hidden_sheets_long_names_and_window_resize_keep_active_tab_visible() { + let mut h = harness(1000.0, 0); + for sheet in 1..10 { + h.state_mut().run("sheet.hide", json!({"sheet":sheet})).unwrap(); + } + let name = "Quarterly results and forecasts"; + h.state_mut().run("sheet.rename", json!({"sheet":29,"name":name})).unwrap(); + h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap(); + h.run_steps(20); + assert_visible(&h, name); + h.set_size(vec2(500.0, 120.0)); + h.run_steps(20); + assert_visible(&h, name); + h.set_size(vec2(1000.0, 120.0)); + h.run_steps(20); + assert_visible(&h, name); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); +} + +#[test] +fn new_sheet_button_stays_reachable_and_reveals_the_inserted_tab() { + let mut h = harness(700.0, 29); + let (_, clip) = tab(&h, "Tab29").unwrap(); + click(&mut h, pos2(clip.right() + 18.0, clip.center().y)); + let d = h.state().session.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 31); + let name = d.wb.active().unwrap().name.clone(); + assert_visible(&h, &name); +} + +#[test] +fn overflow_leaves_the_worksheet_scrollbar_reachable() { + for width in [500.0, 700.0, 1200.0] { + let mut h = harness(width, 0); + let (bar, clip) = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Rect(r) if r.fill == Color32::from_gray(190) && r.rect.height() == 8.0 => Some((r.rect, s.clip_rect)), + _ => None, + }) + .expect("worksheet scrollbar thumb must be painted"); + assert!(clip.contains_rect(bar), "worksheet scrollbar is offscreen: {bar:?}, {clip:?}"); + let at = bar.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerMoved(at + vec2(12.0, 0.0))); + h.step(); + assert!(h.state().view().scroll.x > 0.0, "worksheet scrollbar must respond to dragging"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8b8209256..484de9e91 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (stability: command-sequence fuzzing against undo/redo, XLSX round-trip and full-recalc invariants) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (sheet-tab navigation reveals the selected tab without interrupting manual scrolling) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | | 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h | | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbccc..37fd5b22a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (sheet-tab navigation reveals the selected tab without interrupting manual scrolling) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | -| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | rename focus bug on macOS (#145) | +| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | From ae54c84bc90cda1032df8b7af03d6c82b09f9d0a Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 13 Oct 2026 00:10:54 +1100 Subject: [PATCH 24/86] Key the cached Top 10 cutoff by criterion, not column (#661) Two Top 10 criteria on one column (possible in native JSON files) shared one cache entry, so the last cutoff overwrote the first. Reported by @derechopiano. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ut3hr3a13szpQdQ7gbYbfc --- crates/engine/src/cmd/data.rs | 14 +++++++------- crates/engine/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 0effb86d0..f9b831cb9 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -570,9 +570,9 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { let Some(af) = sh.autofilter.clone() else { return }; let r = af.range; let mut hide = Vec::new(); - // Precompute per-column top10 threshold and average once, not per row. - let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); - for (off, c) in &af.criteria { + // Precompute per-criterion top10 threshold and average once, not per row. + let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); + for (i, (off, c)) in af.criteria.iter().enumerate() { if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) { let col = r.start.col + off; let mut nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); @@ -586,12 +586,12 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { } _ => None, }; - col_stats.insert(*off, (thr, avg)); + col_stats.insert(i, (thr, avg)); } } for row in r.start.row + 1..=r.end.row { let mut visible = true; - for (off, crit) in &af.criteria { + for (i, (off, crit)) in af.criteria.iter().enumerate() { let c = CellRef::new(row, r.start.col + off); let v = sh.value(c); let text = crate::display::cell_text(wb, sh, c); @@ -613,14 +613,14 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { None => x, } } - FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(off)) { + FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) { (Some(n), Some((thr, _))) => { let thr = thr.unwrap_or(n); if *bottom { n <= thr } else { n >= thr } } _ => false, }, - FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(off)) { + FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) { (Some(n), Some((_, avg))) => { if *above { n > *avg diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2d1282282..8c8b08fb7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -823,6 +823,28 @@ fn filter_top10_items_and_percent() { assert_eq!(r["hiddenRows"], 4); } +/// Two Top 10 criteria on one column (possible in a native JSON file) each keep their own +/// cutoff: Top 3 and Bottom 4 of 1..5 keep 3 and 4. +#[test] +fn filter_top10_two_criteria_on_one_column() { + use gridcraft_model::FilterCriterion; + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["N"], [1], [2], [3], [4], [5]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + s.execute("data.filter", json!({})).unwrap(); + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + if let Some(af) = sh.autofilter.as_mut() { + af.criteria = vec![ + (0, FilterCriterion::Top10 { bottom: false, percent: false, count: 3 }), + (0, FilterCriterion::Top10 { bottom: true, percent: false, count: 4 }), + ]; + } + s.execute("data.reapply", json!({})).unwrap(); + let hidden: Vec = s.active().unwrap().wb.sheet(0).unwrap().rows.iter().filter(|(_, r)| r.hidden).map(|(k, _)| *k).collect(); + // Rows are 0-based: values 1, 2 and 5 sit on rows 1, 2 and 5. + assert_eq!(hidden, vec![1, 2, 5]); +} + /// Turning the filter on with only the header row selected filters the data below it (#201). #[test] fn filter_on_a_selected_header_row_covers_the_data_below() { From dfb780a544ea1131b63f3cbac49dd6eaa0f9e8f2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 15:56:22 +0200 Subject: [PATCH 25/86] UI: clicking a column or row header in point mode inserts N:N / n:n (#691) --- crates/ui-egui/src/grid.rs | 16 ++++ crates/ui-egui/tests/formula_point_headers.rs | 75 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 crates/ui-egui/tests/formula_point_headers.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..5f4583c74 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1770,6 +1770,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let c = geo.col_at(sh, p.x); + if point_header(app, wb, sh, RangeRef::cols(c, c)) { + return; + } if resp.drag_started() && !mods.any() && sel.ranges.len() == 1 @@ -1790,6 +1793,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let r = geo.row_at(sh, p.y); + if point_header(app, wb, sh, RangeRef::rows(r, r)) { + return; + } let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0))); app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r }); @@ -2097,6 +2103,16 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & keyboard(app, &ctx, resp, geo, sh); } +/// Point mode on a header: inserts the whole-column or whole-row reference instead of selecting. +fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) -> bool { + let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; + let r = range.a1(); + let r = if ed.sheet != wb.active_sheet { format!("{}!{r}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { r }; + ed.insert_ref(&r); + ed.request_focus = true; + true +} + fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { let cell = CellRef::new( geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), diff --git a/crates/ui-egui/tests/formula_point_headers.rs b/crates/ui-egui/tests/formula_point_headers.rs new file mode 100644 index 000000000..4313fb9f6 --- /dev/null +++ b/crates/ui-egui/tests/formula_point_headers.rs @@ -0,0 +1,75 @@ +//! Point mode on the headers: while a formula waits for a reference, a header click inserts a +//! whole-column or whole-row reference, driven through real egui pointer events. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("selection.set", json!({"range": "F8"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + h +} + +fn header_center(h: &egui_kittest::Harness<'_, SheetApp>, rows: bool, index: u32) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + if rows { + pos2(geo.rect.left() + geo.header_w * 0.5, geo.y(sheet, index) + sheet.row_height(index) * geo.z * 0.5) + } else { + pos2(geo.x(sheet, index) + sheet.col_width(index) * geo.z * 0.5, geo.rect.top() + geo.header_h * 0.5) + } +} + +fn click(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2) { + for pressed in [true, false] { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); + } + h.run_steps(2); +} + +#[test] +fn column_header_click_inserts_a_column_reference() { + let mut h = harness(); + let at = header_center(&h, false, 2); + click(&mut h, at); + let app = h.state(); + assert_eq!(app.editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(C:C")); + assert_eq!(app.session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn row_header_click_inserts_a_row_reference() { + let mut h = harness(); + let at = header_center(&h, true, 3); + click(&mut h, at); + let app = h.state(); + assert_eq!(app.editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(4:4")); + assert_eq!(app.session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn a_second_header_click_replaces_the_pointed_reference() { + let mut h = harness(); + let first = header_center(&h, false, 2); + click(&mut h, first); + let second = header_center(&h, false, 4); + click(&mut h, second); + assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(E:E")); +} From 746a1e036420525e4c2838f8109b20eee7fc84cb Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 11:03:13 -0400 Subject: [PATCH 26/86] File: Export as PDF in the File menu, and the palette asks for a path (#407) --- apps/gridcraft/src/native_menu.rs | 10 +++++++ crates/engine/src/cmd/print.rs | 17 ++++++++++++ crates/ui-egui/src/lib.rs | 28 +++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 3 ++ crates/ui-egui/tests/file_menu.rs | 46 +++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+) diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index b127543c8..fca3cddbb 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -24,6 +24,7 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Close", "file.close", Some("CMD+W")), ("Save", "file.save", Some("CMD+S")), ("Save As…", "file.saveAs", Some("CMD+SHIFT+S")), + ("Export as PDF…", "file.exportPdf", None), ("Export as CSV…", "dialog:saveCopy:csv", None), ("Save as Web Page…", "dialog:saveCopy:html", None), ("-", "", None), @@ -315,6 +316,15 @@ impl NativeMenu { mod tests { use super::*; + #[test] + fn file_menu_has_export_as_pdf_dispatching_the_command() { + let tree = tree(); + let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu"); + let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu"); + assert_eq!(item.1, "file.exportPdf"); + assert!(gridcraft_engine::find_command("file.exportPdf").is_some()); + } + #[test] fn theme_choices_use_the_shared_preference_command() { let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default()); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 986351ec5..2aeffbc6a 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -2046,6 +2046,23 @@ mod tests { assert!(s.execute("file.exportPdf", json!({})).is_ok()); } + #[test] + fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() { + let mut s = s(); + for params in [json!({}), json!({"path": ""}), json!({"path": null})] { + let r = s.execute("file.exportPdf", params).unwrap(); + assert!(r.get("path").is_none(), "no file is written without a path"); + let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + } + assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog"); + // Headless callers (CLI, MCP) get the same bytes, not an error. + assert!(s.execute_headless("file.exportPdf", json!({})).is_ok()); + // With no workbook open it is a clear error, not a panic. + let mut none = Session::new(); + assert!(none.execute("file.exportPdf", json!({})).is_err()); + } + #[test] fn bands_unit() { let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec29139d3..d80c32a56 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -408,11 +408,39 @@ impl SheetApp { if id == "file.print" { return self.print_now(params); } + // Menu-style Export as PDF with no file named: ask where to save, like Save As does. + // Programmatic callers use `run` and keep getting the PDF bytes back. + if id == "file.exportPdf" && self.services.pick_save.is_some() && params.get("path").and_then(Json::as_str).is_none_or(str::is_empty) { + return self.export_pdf_with_picker(params); + } if let Err(e) = self.run(id, params) { self.alert(e); } } + /// Asks for a `.pdf` path with the save picker, then re-runs `file.exportPdf` with it. Other + /// params (range, sheets, fitToPage) are kept. A cancelled picker does nothing. + fn export_pdf_with_picker(&mut self, mut params: Json) { + if !self.commit_edit(0, 0, false, false) { + return; + } + let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string(); + let Some(pick) = &self.services.pick_save else { return }; + let Some(path) = pick(&format!("{stem}.pdf")) else { return }; + // The picker may suggest a workbook extension; the export writes a PDF. + let path = std::path::Path::new(&path).with_extension("pdf").to_string_lossy().into_owned(); + match &mut params { + Json::Object(map) => { + map.insert("path".into(), Json::String(path)); + } + other => *other = json!({"path": path}), + } + if let Err(e) = self.run("file.exportPdf", params) { + self.alert(e); + } + } + /// Shows a command error: a toast for an unavailable command, else a message box. fn alert(&mut self, e: String) { if e.contains("is not available right now") { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291accd..c4c3a2a12 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -131,6 +131,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true); egui::Popup::menu(&share).show(|ui| { + if ui.button("Export as PDF…").clicked() { + app.run_or_alert("file.exportPdf", json!({})); + } for (label, fmt) in [("Save a Copy as Excel Workbook (.xlsx)…", "xlsx"), ("Export as CSV…", "csv"), ("Export as Web Page (.html)…", "html")] { diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index c6ef2b212..691eef367 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -171,3 +171,49 @@ fn save_as_offers_pdf_and_leaves_the_workbook_file_alone() { assert!(d.path.is_none() && d.is_dirty(), "a PDF is a copy, not the workbook's file"); let _ = std::fs::remove_dir_all(&dir); } + +fn pdf_picker_harness(dir: &std::path::Path) -> (Arc>>, egui_kittest::Harness<'static, SheetApp>) { + let asked = Arc::new(Mutex::new(Vec::::new())); + let (capture, target) = (asked.clone(), dir.to_path_buf()); + let h = harness(Services { + pick_save: Some(Box::new(move |suggested| { + capture.lock().unwrap().push(suggested.to_string()); + Some(target.join("Report.xlsx").to_string_lossy().into_owned()) + })), + ..Default::default() + }); + (asked, h) +} + +#[test] +fn the_palette_export_as_pdf_asks_where_to_save() { + let dir = std::env::temp_dir().join(format!("gridcraft-export-pdf-palette-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (asked, mut h) = pdf_picker_harness(&dir); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + // The palette and menus go through run_or_alert. + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({})); + assert!(asked.lock().unwrap().first().is_some_and(|s| s.ends_with(".pdf")), "the picker is offered a .pdf name"); + assert!(std::fs::read(dir.join("Report.pdf")).unwrap().starts_with(b"%PDF-"), "the PDF is written, whatever extension the picker returned"); + assert!(h.state().message.is_none()); + let d = h.state().session.active().unwrap(); + assert!(d.path.is_none(), "a PDF is a copy, not the workbook's file"); + // Other params survive the round trip through the picker. + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"range": "nope"})); + assert!(h.state().message.is_some(), "a bad range is reported, not swallowed"); + // A path in the params skips the picker. + let before = asked.lock().unwrap().len(); + let named = dir.join("Named.pdf"); + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"path": named.to_string_lossy()})); + assert_eq!(asked.lock().unwrap().len(), before); + assert!(named.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_cancelled_export_pdf_picker_writes_nothing() { + let mut h = harness(Services { pick_save: Some(Box::new(|_| None)), ..Default::default() }); + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({})); + assert!(h.state().message.is_none()); + assert!(h.state_mut().session.take_ui_requests().is_empty()); +} From d4874373a437db4d12ff5bcb731ff85877259365 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 12 Oct 2026 23:54:44 +0800 Subject: [PATCH 27/86] #551: preserve editable ink through XLSX round trips --- ROADMAP.md | 4 +- crates/engine/tests/ink_roundtrip.rs | 135 ++++++++++++++++ crates/xlsx/src/drawing.rs | 11 ++ crates/xlsx/src/drawing/ink.rs | 207 +++++++++++++++++++++++++ crates/xlsx/src/tests/ink_roundtrip.rs | 194 +++++++++++++++++++++++ crates/xlsx/src/tests/mod.rs | 1 + docs/file-format-parity.md | 10 +- docs/gaps.md | 5 +- 8 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/ink_roundtrip.rs create mode 100644 crates/xlsx/src/drawing/ink.rs create mode 100644 crates/xlsx/src/tests/ink_roundtrip.rs diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..f40210f0d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink survives XLSX saves) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/engine/tests/ink_roundtrip.rs b/crates/engine/tests/ink_roundtrip.rs new file mode 100644 index 000000000..bff1b61ed --- /dev/null +++ b/crates/engine/tests/ink_roundtrip.rs @@ -0,0 +1,135 @@ +//! Native ink remains editable after XLSX saves, including a save after an unrelated cell edit. + +use gridcraft_engine::Session; +use gridcraft_engine::cmd::draw::{ink_points, ink_width}; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{Shape, ShapeKind}; +use serde_json::json; + +fn workbook() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session +} + +fn reopen(session: &mut Session) -> Session { + let saved = session.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.open", json!({"name": "ink.xlsx", "base64": saved["base64"]})).unwrap(); + reopened +} + +fn shapes(session: &Session) -> &[Shape] { + &session.doc().unwrap().wb.active().unwrap().shapes +} + +fn assert_anchor(actual: &Shape, expected: &Shape) { + assert_eq!(actual.anchor.cell, expected.anchor.cell); + assert_eq!(actual.anchor.mode, expected.anchor.mode); + for (name, actual, expected) in [ + ("dx", actual.anchor.dx, expected.anchor.dx), + ("dy", actual.anchor.dy, expected.anchor.dy), + ("width", actual.anchor.width, expected.anchor.width), + ("height", actual.anchor.height, expected.anchor.height), + ] { + assert!((actual - expected).abs() < 0.001, "anchor {name}: {actual} != {expected}"); + } +} + +fn assert_ink(actual: &Shape, expected: &Shape) { + assert_eq!(actual.kind, ShapeKind::Ink); + let actual_points = ink_points(actual); + let expected_points = ink_points(expected); + assert_eq!(actual_points.len(), expected_points.len()); + for (actual, expected) in actual_points.iter().zip(&expected_points) { + for axis in 0..2 { + // DrawingML quantizes coordinates to integer EMUs (9,525 per model unit). + assert!((actual[axis] - expected[axis]).abs() < 0.001, "point {actual:?} != {expected:?}"); + } + } + assert_eq!(actual.line, expected.line); + assert_eq!(ink_width(actual), ink_width(expected)); + assert_anchor(actual, expected); +} + +#[test] +fn ink_geometry_and_style_survive_two_saves_and_a_cell_edit() { + let mut session = workbook(); + session + .execute("draw.stroke", json!({"points": [[130.5, 45.25], [172.5, 86.25], [151.5, 112.25], [215.5, 59.25]], "color": "#A12BCD", "width": 3})) + .unwrap(); + session + .execute("draw.stroke", json!({"points": [[300, 180], [360, 180], [360, 230], [300, 230], [300, 180]], "color": "#16843B", "width": 7})) + .unwrap(); + let expected = shapes(&session).to_vec(); + assert_eq!(expected.len(), 2); + + for pass in 0..2 { + session = reopen(&mut session); + assert_eq!(shapes(&session).len(), expected.len()); + for (actual, expected) in shapes(&session).iter().zip(&expected) { + assert_ink(actual, expected); + } + if pass == 0 { + session.execute("cell.set", json!({"cell": "Z30", "input": "42"})).unwrap(); + } + } + assert_eq!(session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("Z30").unwrap()), Value::Number(42.0)); +} + +#[test] +fn reopened_ink_can_still_be_converted_to_a_shape() { + for (points, expected_kind, expected_name) in [ + (vec![[100, 100], [200, 100], [200, 180], [100, 180], [100, 100]], ShapeKind::Rectangle, "Rectangle"), + (vec![[100, 100], [140, 110], [220, 130]], ShapeKind::Line, "Line"), + ] { + let mut session = workbook(); + session.execute("draw.stroke", json!({"points": points, "color": "#A12BCD", "width": 5})).unwrap(); + let original = shapes(&session)[0].clone(); + session = reopen(&mut session); + let id = shapes(&session)[0].id; + let converted = session.execute("draw.inkToShape", json!({"id": id})).unwrap(); + assert_eq!(converted["kind"], expected_name); + let shape = &shapes(&session)[0]; + assert_eq!(shape.kind, expected_kind); + assert_eq!(shape.line, original.line); + assert!(shape.text.is_empty()); + assert_anchor(shape, &original); + } +} + +#[test] +fn ordinary_shape_text_is_not_mistaken_for_ink_points() { + let mut session = workbook(); + session + .execute( + "insert.shape", + json!({"kind": "rectangle", "at": "D6", "width": 120, "height": 60, "fill": "#112233", "line": "#A12BCD", "text": "0.0,0.0;50.0,20.0"}), + ) + .unwrap(); + let original = shapes(&session)[0].clone(); + for _ in 0..2 { + session = reopen(&mut session); + assert_eq!(shapes(&session).len(), 1); + let shape = &shapes(&session)[0]; + assert_eq!(shape.kind, ShapeKind::Rectangle); + assert_eq!(shape.text, original.text); + assert_eq!(shape.fill, original.fill); + assert_eq!(shape.line, original.line); + assert_anchor(shape, &original); + assert!(session.execute("draw.inkToShape", json!({})).is_err()); + } +} + +#[test] +fn horizontal_vertical_and_signed_highlighter_strokes_survive_reopening() { + for points in [vec![[25.1, 50.2], [140.3, 50.2]], vec![[40.2, 10.1], [40.2, 100.3]], vec![[-12.3, -4.2], [20.1, 10.2], [80.3, -1.4]]] { + let mut session = workbook(); + session.execute("draw.stroke", json!({"points": points, "color": "#16843B", "width": 12})).unwrap(); + let expected = shapes(&session)[0].clone(); + for _ in 0..2 { + session = reopen(&mut session); + assert_ink(&shapes(&session)[0], &expected); + } + } +} diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 076148a53..6dbd17e7e 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -11,6 +11,8 @@ use crate::read::Ctx; use crate::tables::EMU_PER_PX; use crate::xml::{El, esc, esc_attr}; +mod ink; + // ---------------------------------------------------------------- reading fn emu_px(v: Option) -> f32 { @@ -195,6 +197,9 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( fn read_shape(obj: &El, anchor: Anchor) -> Option { let sp = obj.child("spPr")?; + if let Some(shape) = ink::read(obj, anchor) { + return Some(shape); + } let prst = sp.child("prstGeom").and_then(|g| g.attr("prst")).unwrap_or("rect"); let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false)); let ln = sp.child("ln"); @@ -358,6 +363,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { let (open, close) = anchor_open(sheet, &sh.anchor); close_tag = close; s.push_str(&open); + if let Some(xml) = ink::write(sh, theme, id) { + s.push_str(&xml); + s.push_str(""); + s.push_str(close_tag); + continue; + } let (prst, tx) = match sh.kind { ShapeKind::Rectangle => ("rect", false), ShapeKind::RoundedRectangle => ("roundRect", false), diff --git a/crates/xlsx/src/drawing/ink.rs b/crates/xlsx/src/drawing/ink.rs new file mode 100644 index 000000000..b48d99ee8 --- /dev/null +++ b/crates/xlsx/src/drawing/ink.rs @@ -0,0 +1,207 @@ +//! The open, solid polylines the model can represent as editable ink. This is DrawingML +//! geometry, not Office InkML. Unsupported geometry keeps the existing shape-import fallback. + +use super::*; + +const MAX_POINTS: usize = 20_000; +const MAX_COORD: f64 = 1e7; + +fn bounded(v: f64) -> bool { + v.is_finite() && v.abs() <= MAX_COORD +} + +fn only_children(e: &El, names: &[&str]) -> bool { + e.children.iter().all(|c| names.contains(&c.name.as_str())) +} + +fn only_attrs(e: &El, names: &[&str]) -> bool { + e.attrs.iter().all(|(k, _)| names.contains(&k.as_str())) +} + +fn one<'a>(e: &'a El, name: &str) -> Option<&'a El> { + let mut found = e.children.iter().filter(|c| c.name == name); + let first = found.next()?; + found.next().is_none().then_some(first) +} + +/// Do not partially import a path: losing a curve, subpath, transform or line effect changes +/// the drawing. Accept only the explicit subset emitted below, including its line styling. +pub(super) fn read(obj: &El, anchor: Anchor) -> Option { + if obj.name != "sp" + || !only_children(obj, &["nvSpPr", "spPr"]) + || !only_attrs(obj, &["macro", "textlink"]) + || obj.attrs.iter().any(|(_, v)| !v.is_empty()) + || obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|e| e.flag("txBox", false)) + { + return None; + } + let sp = one(obj, "spPr")?; + if !only_children(sp, &["xfrm", "custGeom", "noFill", "ln"]) || !sp.attrs.is_empty() { + return None; + } + let no_fill = one(sp, "noFill")?; + if !no_fill.attrs.is_empty() || !no_fill.children.is_empty() { + return None; + } + let xf = one(sp, "xfrm")?; + if !xf.attrs.is_empty() || !only_children(xf, &["off", "ext"]) { + return None; + } + let off = one(xf, "off")?; + if off.attr_i64("x")? != 0 || off.attr_i64("y")? != 0 { + return None; + } + let ext = one(xf, "ext")?; + let ew = ext.attr_i64("cx")?; + let eh = ext.attr_i64("cy")?; + if ew <= 0 || eh <= 0 || anchor.width <= 0.0 || anchor.height <= 0.0 || !bounded(anchor.width as f64) || !bounded(anchor.height as f64) { + return None; + } + let same_extent = + |emu: i64, extent: f32| (emu as f64 / EMU_PER_PX - extent as f64).abs() <= 0.001_f64.max(extent as f64 * f32::EPSILON as f64 * 2.0); + if !same_extent(ew, anchor.width) || !same_extent(eh, anchor.height) { + return None; + } + let geom = one(sp, "custGeom")?; + if !geom.attrs.is_empty() || !only_children(geom, &["avLst", "gdLst", "ahLst", "cxnLst", "rect", "pathLst"]) { + return None; + } + // Text bounds have no effect on this unfilled geometry, which has no caption. + if geom.children.iter().any(|c| !matches!(c.name.as_str(), "pathLst" | "rect") && (!c.children.is_empty() || !c.attrs.is_empty())) { + return None; + } + if geom.kids("rect").count() > 1 || geom.kids("rect").any(|r| !r.children.is_empty() || !only_attrs(r, &["l", "t", "r", "b"])) { + return None; + } + let paths = one(geom, "pathLst")?; + if paths.children.len() != 1 { + return None; + } + let path = one(paths, "path")?; + let w = path.attr_i64("w")?; + let h = path.attr_i64("h")?; + if w <= 0 + || h <= 0 + || path.attr("fill") != Some("none") + || !only_attrs(path, &["w", "h", "fill", "stroke", "extrusionOk"]) + || path.attr("stroke").is_some_and(|_| path.attr_bool("stroke") != Some(true)) + || path.attr("extrusionOk").is_some_and(|_| path.attr_bool("extrusionOk") != Some(false)) + || !(2..=MAX_POINTS).contains(&path.children.len()) + { + return None; + } + let ln = one(sp, "ln")?; + if !only_attrs(ln, &["w", "cap", "cmpd", "algn"]) + || ln.attr("cap") != Some("flat") + || ln.attr("cmpd").is_some_and(|v| v != "sng") + || ln.attr("algn").is_some_and(|v| v != "ctr") + || !only_children(ln, &["solidFill", "miter"]) + { + return None; + } + let width_emu = ln.attr_i64("w")?; + let unit = EMU_PER_PX as i64; + if width_emu % unit != 0 || !(1..=40).contains(&(width_emu / unit)) { + return None; + } + let width = width_emu / unit; + let join = one(ln, "miter")?; + if !join.attrs.is_empty() || !join.children.is_empty() { + return None; + } + let fill = one(ln, "solidFill")?; + if fill.children.len() != 1 { + return None; + } + let color = one(fill, "srgbClr")?; + if !only_attrs(color, &["val"]) || !only_children(color, &["alpha"]) { + return None; + } + let expected_alpha = if width > 8 { 45_000 } else { 100_000 }; + if color.children.is_empty() { + if expected_alpha != 100_000 { + return None; + } + } else { + let alpha = one(color, "alpha")?; + if !only_attrs(alpha, &["val"]) || !alpha.children.is_empty() || alpha.attr_i64("val")? != expected_alpha { + return None; + } + } + let line = Color::from_hex(color.attr("val")?)?; + let mut text = String::new(); + for (i, command) in path.children.iter().enumerate() { + if command.name != if i == 0 { "moveTo" } else { "lnTo" } || !command.attrs.is_empty() || command.children.len() != 1 { + return None; + } + let pt = one(command, "pt")?; + if !only_attrs(pt, &["x", "y"]) || !pt.children.is_empty() { + return None; + } + let x = pt.attr_i64("x")? as f64 * anchor.width as f64 / w as f64; + let y = pt.attr_i64("y")? as f64 * anchor.height as f64 / h as f64; + if !bounded(x) || !bounded(y) { + return None; + } + if i != 0 { + text.push(';'); + } + let _ = write!(text, "{},{}", x as f32, y as f32); + } + Some(Shape { id: 0, kind: ShapeKind::Ink, anchor, fill: Color::Rgb((width as u32) << 16), line, text, format: Default::default() }) +} + +/// Invalid/native out-of-range data uses the existing rectangle fallback. A successful +/// result is complete before it is appended, so malformed points never yield half a stroke. +pub(super) fn write(shape: &Shape, theme: &Theme, id: usize) -> Option { + if shape.kind != ShapeKind::Ink || shape.format.no_line { + return None; + } + let width = match shape.fill { + Color::Rgb(rgb) => ((rgb >> 16) & 0xff).max(1), + _ => 2, + }; + if !(1..=40).contains(&width) { + return None; + } + let a = &shape.anchor; + if !bounded(a.width as f64) || !bounded(a.height as f64) || a.width <= 0.0 || a.height <= 0.0 || !a.dx.is_finite() || !a.dy.is_finite() { + return None; + } + let w = (a.width as f64 * EMU_PER_PX).round().max(1.0) as i64; + let h = (a.height as f64 * EMU_PER_PX).round().max(1.0) as i64; + let mut points = Vec::new(); + for point in shape.text.split(';') { + if points.len() == MAX_POINTS { + return None; + } + let (x, y) = point.split_once(',')?; + let (x, y) = (x.parse::().ok()?, y.parse::().ok()?); + if !bounded(x) || !bounded(y) { + return None; + } + points.push(((x * EMU_PER_PX).round() as i64, (y * EMU_PER_PX).round() as i64)); + } + if points.len() < 2 { + return None; + } + let [r, g, b] = shape.line.resolve(theme)?; + let mut xml = format!( + "{}", + xfrm(a, "a:xfrm") + ); + for (i, (x, y)) in points.iter().enumerate() { + let tag = if i == 0 { "moveTo" } else { "lnTo" }; + let _ = write!(xml, ""); + } + let _ = write!( + xml, + "", + width as i64 * EMU_PER_PX as i64 + ); + if width > 8 { + xml.push_str(""); + } + xml.push_str(""); + Some(xml) +} diff --git a/crates/xlsx/src/tests/ink_roundtrip.rs b/crates/xlsx/src/tests/ink_roundtrip.rs new file mode 100644 index 000000000..28827652a --- /dev/null +++ b/crates/xlsx/src/tests/ink_roundtrip.rs @@ -0,0 +1,194 @@ +//! Ink uses visible DrawingML geometry, and only representable polylines become editable ink. + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_model::{Anchor, AnchorMode, Color, Shape, ShapeKind, Sheet, Workbook}; + +use super::minimal; +use crate::xml::{self, El}; +use crate::{read_xlsx, write_xlsx}; + +const POINTS: &str = r#""#; +const PATH: &str = r#""#; + +fn drawing() -> String { + format!( + r#" + 01905000285750 + + + {PATH}{POINTS} + + "# + ) +} + +fn read_drawing(drawing: &str) -> Workbook { + let rels = r#""#; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/drawing1.xml", drawing)], + "", + "", + ); + read_xlsx(&bytes).unwrap().0 +} + +fn points(shape: &Shape) -> Vec<[f64; 2]> { + shape + .text + .split(';') + .map(|pair| { + let (x, y) = pair.split_once(',').unwrap(); + [x.parse().unwrap(), y.parse().unwrap()] + }) + .collect() +} + +fn has_caption(element: &El) -> bool { + (element.name == "t" && !element.text.is_empty()) || element.children.iter().any(has_caption) +} + +#[test] +fn exported_ink_has_a_visible_unfilled_path_and_correct_stroke() { + let mut sheet = Sheet::new("Ink"); + for (id, width) in [(1, 3), (2, 12)] { + sheet.shapes.push(Shape { + id, + kind: ShapeKind::Ink, + anchor: Anchor { width: 100.0, height: 50.0, mode: AnchorMode::Absolute, ..Default::default() }, + fill: Color::Rgb(width << 16), + line: Color::Rgb(0xA12BCD), + text: "-10.0,5.0;50.0,40.0;90.0,10.0".into(), + format: Default::default(), + }); + } + let wb = Workbook { sheets: vec![Arc::new(sheet)], ..Default::default() }; + let bytes = write_xlsx(&wb).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut drawing = String::new(); + archive.by_name("xl/drawings/drawing1.xml").unwrap().read_to_string(&mut drawing).unwrap(); + let root = xml::parse(drawing.as_bytes()).unwrap(); + let shapes: Vec<_> = root.children.iter().filter_map(|anchor| anchor.child("sp")).collect(); + assert_eq!(shapes.len(), 2); + for (shape, width) in shapes.iter().zip([3, 12]) { + assert!(!has_caption(shape), "stroke coordinates must not be visible text"); + let properties = shape.child("spPr").unwrap(); + assert!(properties.child("prstGeom").is_none()); + assert!(properties.child("noFill").is_some()); + let paths: Vec<_> = properties.path(&["custGeom", "pathLst"]).unwrap().kids("path").collect(); + assert_eq!(paths.len(), 1); + let path = paths[0]; + assert_eq!(path.attr("fill"), Some("none")); + let w = path.attr_i64("w").unwrap(); + let h = path.attr_i64("h").unwrap(); + assert!(w > 0 && h > 0); + assert_eq!(path.children.iter().map(|c| c.name.as_str()).collect::>(), ["moveTo", "lnTo", "lnTo"]); + for (command, [x, y]) in path.children.iter().zip([[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]) { + let point = command.child("pt").unwrap(); + let actual_x = point.attr_i64("x").unwrap() as f64 * 100.0 / w as f64; + let actual_y = point.attr_i64("y").unwrap() as f64 * 50.0 / h as f64; + assert!((actual_x - x).abs() < 0.001 && (actual_y - y).abs() < 0.001); + } + let line = properties.child("ln").unwrap(); + assert_eq!(line.attr_i64("w"), Some(9525 * width)); + assert_eq!(line.attr("cap"), Some("flat")); + assert!(line.child("miter").is_some()); + let color = line.path(&["solidFill", "srgbClr"]).unwrap(); + assert_eq!(color.attr("val"), Some("A12BCD")); + if width > 8 { + assert_eq!(color.child_val("alpha"), Some("45000")); + } else { + assert!(color.child("alpha").is_none_or(|a| a.attr("val") == Some("100000"))); + } + } +} + +#[test] +fn numeric_polyline_from_an_independent_package_is_scaled_into_editable_ink() { + for (width, alpha) in [(3, ""), (12, r#""#)] { + let xml = drawing() + .replace("w=\"28575\"", &format!("w=\"{}\"", width * 9525)) + .replace(r#""#, &format!(r#"{alpha}"#)); + let wb = read_drawing(&xml); + let shapes = &wb.sheet(0).unwrap().shapes; + assert_eq!(shapes.len(), 1); + let shape = &shapes[0]; + assert_eq!(shape.kind, ShapeKind::Ink); + assert_eq!(points(shape), [[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]); + assert_eq!(shape.line, Color::Rgb(0xA12BCD)); + assert_eq!(shape.fill, Color::Rgb(width << 16)); + assert_eq!((shape.anchor.dx, shape.anchor.dy), (20.0, 30.0)); + assert_eq!((shape.anchor.width, shape.anchor.height), (100.0, 50.0)); + } +} + +#[test] +fn imported_points_are_not_snapped_to_native_tenth_units() { + let xml = drawing().replace("w=\"200\"", "w=\"2000000\"").replace( + POINTS, + r#""#, + ); + let wb = read_drawing(&xml); + let shape = &wb.sheet(0).unwrap().shapes[0]; + assert_eq!(shape.kind, ShapeKind::Ink); + let original = points(shape); + assert!((original[0][0] - 0.10005).abs() < 0.00000001, "an imported coordinate is not necessarily a native tenth-unit point"); + let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let after = &reopened.sheet(0).unwrap().shapes[0]; + assert_eq!(after.kind, ShapeKind::Ink); + for (actual, expected) in points(after).iter().zip(original) { + for axis in 0..2 { + // A saved DrawingML coordinate is quantized to one EMU, plus f32 model precision. + assert!((actual[axis] - expected[axis]).abs() < 0.00006); + } + } +} + +#[test] +fn unsupported_geometry_or_appearance_is_not_reclassified_as_editable_ink() { + let original = drawing(); + let cases = [ + ("formula coordinate", original.replace("x=\"-20\"", "x=\"wd2\"")), + ("nonfinite coordinate", original.replace("x=\"-20\"", "x=\"NaN\"")), + ("overflow coordinate", original.replace("x=\"-20\"", "x=\"9223372036854775808\"")), + ("zero path width", original.replace("w=\"200\"", "w=\"0\"")), + ("missing point", original.replace(r#""#, "")), + ( + "curve", + original + .replace(r#""#, r#""#), + ), + ("closed path", original.replace("", "")), + ("multiple paths", original.replace("", &format!("{PATH}{POINTS}"))), + ("filled path", original.replace("fill=\"none\"", "fill=\"norm\"")), + ("filled shape", original.replace("", "")), + ("rotation", original.replace("", "")), + ("reflection", original.replace("", "")), + ("square caps", original.replace("cap=\"flat\"", "cap=\"sq\"")), + ("round join", original.replace("", "")), + ("dash", original.replace("", "")), + ("arrow", original.replace("", "")), + ( + "caption", + original.replace("", "Caption"), + ), + ("fractional width", original.replace("w=\"28575\"", "w=\"33338\"")), + ("unsupported opacity", original.replace(r#""#, r#""#)), + ]; + for (name, xml) in cases { + let wb = read_drawing(&xml); + assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink), "{name} was silently converted to editable ink"); + } +} + +#[test] +fn oversized_polyline_is_not_truncated_into_different_ink() { + let mut commands = String::from(r#""#); + for _ in 0..20_000 { + commands.push_str(r#""#); + } + let wb = read_drawing(&drawing().replace(POINTS, &commands)); + assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink)); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index d5d2f2f7d..e1d86feed 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -4,6 +4,7 @@ mod array_export; mod csv_tests; mod encrypted_tests; mod fixtures; +mod ink_roundtrip; mod malformed; mod ods_tests; mod passthrough_tests; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 851065956..cf5c6d8f8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink round-trips as DrawingML geometry) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -13,6 +13,13 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and ## Workbook formats +Ink created with Draw is written as standard, unfilled DrawingML polylines, without visible point +captions or private metadata. Supported numeric polylines reopen as editable ink, retaining points, +colour, integer width and anchor. Curves, filled paths, transforms and unsupported line styles are +not interpreted as ink. This does not implement Office InkML or fix inserted icons (#551). +Command and package tests cover repeated saves and unrelated cell edits; external Excel rendering +and repair-free opening remain unverified. + | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes); 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` | @@ -78,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Ink paths and editing survive XLSX save/reopen; strict polyline import and malformed-input controls | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b899..fdd7492a5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (ink XLSX preservation; icons still pending) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -125,6 +125,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- conversion; cloud storage requested (#86). **Estimate:** 35–55 h (co-authoring scope needs the owner). ### 17. Insert objects (F) +- Ink strokes now survive XLSX save/reopen as editable polylines. Inserted icons still lose their + identity/artwork on save (#551); native Office InkML import is not implemented. - SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170; pictures in cells (#61, PR #105). **Estimate:** 25–40 h. @@ -160,6 +162,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | §17: ink geometry survives XLSX saves; the icon portion of #551 remains | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | From 1a49d18dd1aed1dbca22f37674a5fcb08524ae48 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:02:02 +0200 Subject: [PATCH 28/86] Comments pane: one Show Comments button, and the pane keeps its width (fixes #710) --- crates/ui-egui/src/panes.rs | 34 +++++---- crates/ui-egui/src/ribbon.rs | 3 - crates/ui-egui/tests/comments_pane.rs | 105 ++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/tests/comments_pane.rs diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 3100cfeda..aaa9bec32 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -17,7 +17,10 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { } let Some(pane) = app.grid.pane.clone().or_else(|| app.session.comments_pane.then(|| "comments".to_string())) else { return }; let t = Tokens::get(ui.ctx()); - egui::Panel::right("task_pane").default_size(300.0).resizable(true).frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| { + // Capped at half the window so a pane can never cover the sheet, whatever its content asks for. + let max = (ui.available_width() / 2.0).max(300.0); + let panel = egui::Panel::right("task_pane").default_size(300.0).max_size(max).resizable(true); + panel.frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| { ui.horizontal(|ui| { let title = match pane.as_str() { "comments" => "Comments", @@ -62,7 +65,7 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { .stroke(egui::Stroke::new(1.0, Color32::from_gray(215))) .show(ui, |ui| { ui.set_width(ui.available_width()); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { if ui.link(egui::RichText::new(&cell).strong()).clicked() { let _ = app.run("selection.set", json!({"cell": cell})); app.grid.ensure_visible = true; @@ -78,18 +81,23 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { } let key = egui::Id::new(("reply", &cell)); let mut draft: String = ui.ctx().data_mut(|m| m.get_temp(key)).unwrap_or_default(); + // Buttons first, right to left, so the reply field gets exactly the width they leave: + // a field sized by a guess wider than the row pushes the resizable pane wider every frame. ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut draft).hint_text("Reply…").desired_width(ui.available_width() - 120.0)); - if (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) || ui.small_button("Send").clicked()) && !draft.is_empty() { - app.run_or_alert("review.replyComment", json!({"cell": cell, "text": draft})); - draft.clear(); - } - if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { - app.run_or_alert("review.resolveComment", json!({"cell": cell, "resolved": !c.resolved})); - } - if ui.small_button("Delete").clicked() { - app.run_or_alert("review.deleteComment", json!({"cell": cell})); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Delete").clicked() { + app.run_or_alert("review.deleteComment", json!({"cell": cell})); + } + if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { + app.run_or_alert("review.resolveComment", json!({"cell": cell, "resolved": !c.resolved})); + } + let send = ui.small_button("Send").clicked(); + let r = ui.add(egui::TextEdit::singleline(&mut draft).hint_text("Reply…").desired_width(ui.available_width())); + if (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) || send) && !draft.is_empty() { + app.run_or_alert("review.replyComment", json!({"cell": cell, "text": draft})); + draft.clear(); + } + }); }); ui.ctx().data_mut(|m| m.insert_temp(key, draft)); }); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291accd..d25dfa1ca 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2022,9 +2022,6 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.open_dialog("comment", json!({"threaded": true})); } - if big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false).clicked() { - app.grid.pane = Some("comments".into()); - } let __k = big_button(ui, Icon::Delete, "Delete", "Delete Comment", false); control_keytip(app, ui, &__k, "review.deleteComment"); if __k.clicked() { diff --git a/crates/ui-egui/tests/comments_pane.rs b/crates/ui-egui/tests/comments_pane.rs new file mode 100644 index 000000000..fe615084b --- /dev/null +++ b/crates/ui-egui/tests/comments_pane.rs @@ -0,0 +1,105 @@ +//! The Comments pane: one Show Comments button, and a pane that keeps its width (#710). + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const WIDTH: f32 = 1200.0; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(WIDTH, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn grid_right(h: &Harness) -> f32 { + h.state().grid.rect.unwrap().right() +} + +/// A comment whose author line and reply row are wide enough to push the pane if they don't wrap. +fn open_pane_on_long_comment(h: &mut Harness) { + let text = "A long comment that should wrap inside the pane instead of pushing it wider. ".repeat(6); + let author = "Someone With A Rather Long Display Name, Finance Department, Head Office"; + h.state_mut().run("review.newComment", json!({"cell": "B2", "text": text, "author": author})).unwrap(); + h.state_mut().run("review.showComments", json!({"on": true})).unwrap(); + h.run_steps(3); +} + +fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([Event::PointerMoved(from), press(from, true)]); + h.step(); + for i in 1..=6 { + let t = i as f32 / 6.0; + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * t)); + h.step(); + } + h.input_mut().events.push(press(to, false)); + h.run_steps(3); +} + +#[test] +fn the_pane_does_not_grow_on_its_own_and_keeps_a_manual_resize() { + let mut h = harness(); + // Between the grid and the right edge of the window, before any pane is open. + let gutter = WIDTH - grid_right(&h); + open_pane_on_long_comment(&mut h); + let opened = grid_right(&h); + assert!(WIDTH - opened < WIDTH / 2.0, "the pane opens at its default width, got {}", WIDTH - opened); + h.run_steps(60); + assert_eq!(grid_right(&h), opened, "the pane must not widen itself frame after frame"); + + // Drag the pane's left edge 150px to the left: the new width stays. + let edge = pos2(opened + gutter, 450.0); + drag(&mut h, edge, edge - vec2(150.0, 0.0)); + let resized = grid_right(&h); + // The grab point is a few pixels off the exact edge, so allow some slack on the 150px. + assert!(opened - resized > 120.0, "the drag widens the pane, grid edge {opened} -> {resized}"); + h.run_steps(60); + assert_eq!(grid_right(&h), resized, "a manual resize must hold"); + + // Dragged almost to the window's left edge, the pane stops at half the window. + let edge = pos2(resized + gutter, 450.0); + drag(&mut h, edge, pos2(60.0, 450.0)); + assert!(grid_right(&h) > WIDTH / 2.0 - gutter - 30.0, "the pane is capped at half the window, grid edge {}", grid_right(&h)); +} + +/// How many separate ribbon buttons show `tip` when hovered, scanning the big-button row. +fn buttons_with_tip(h: &mut Harness, tip: &str) -> usize { + h.ctx.all_styles_mut(|s| s.interaction.tooltip_delay = 0.0); + let (mut count, mut inside) = (0, false); + let mut x = 2.0; + while x < WIDTH { + h.input_mut().events.push(Event::PointerMoved(pos2(x, 120.0))); + h.run_steps(6); + let shown = h.query_all_by_value(tip).next().is_some(); + if shown && !inside { + count += 1; + } + inside = shown; + x += 8.0; + } + count +} + +#[test] +fn review_has_a_single_show_comments_button() { + let mut h = harness(); + h.state_mut().ui.ribbon_tab = "Review".into(); + h.run_steps(2); + // The scan has to find a known single button, or a count of one below would prove nothing. + assert_eq!(buttons_with_tip(&mut h, "New Comment"), 1); + assert_eq!(buttons_with_tip(&mut h, "Comments pane"), 1); +} From 1f7fd07c0d3a21fad43b0cdacb61744222ff15d2 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:24:23 -0400 Subject: [PATCH 29/86] =?UTF-8?q?PivotTables:=20values=20on=20rows=20(?= =?UTF-8?q?=CE=A3=20Values=20in=20Rows,=20dataOnRows)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With two or more value fields the Σ Values pseudo-field can sit in Rows as well as Columns, at any position among the row fields. The model records it (`PivotTable.values_on_rows`, `values_position`) and the engine lays it out like Excel in compact, outline and tabular form: one row item per value field at the Σ Values level, per-value subtotals ("East Sum of Sales") and grand totals ("Total Sum of Sales"). XLSX reads `dataOnRows` and the `-2` field's place in rowFields or colFields and writes them back; the "values on rows are shown on columns" warning is gone. `pivot.moveField` moves "Σ Values" between Rows and Columns or within one, axis field moves keep it in place, and the PivotTable Fields pane lists it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/pivot.rs | 206 +++++++++++++++++++++++--- crates/engine/src/pivot.rs | 208 ++++++++++++++++++++++----- crates/model/src/features.rs | 7 + crates/ui-egui/src/pivot_pane.rs | 20 ++- crates/xlsx/src/pivot.rs | 78 ++++++---- crates/xlsx/src/tests/pivot_tests.rs | 67 +++++++++ docs/gaps.md | 6 +- docs/target-app-parity.md | 7 +- 8 files changed, 513 insertions(+), 86 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 0d73b0a4a..5a35c10a6 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Move Field", [], None, - "{pivot?, field, from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}", + "{pivot?, field (\"Σ Values\" moves the value fields between rows and columns), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}", has_doc, move_field ), @@ -333,6 +333,70 @@ fn in_axes(pt: &PivotTable, field: &str) -> bool { pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field)) } +/// Name of the Σ Values pseudo-field (where the value fields sit when there are two or more). +const VALUES_FIELD: &str = "Σ Values"; + +/// Whether `field` names Σ Values: `Σ Values`, or `Values` unless a source field has that name. +fn is_values_field(src: &pv::Source, field: &str) -> bool { + let f = field.trim(); + f == VALUES_FIELD || (f.eq_ignore_ascii_case("values") && src.col(f).is_none()) +} + +/// Keeps Σ Values in place after a field left (`inserted` false) or arrived at index `i` of +/// Rows (`rows`) or Columns. +fn shift_values(pt: &mut PivotTable, rows: bool, i: usize, inserted: bool) { + if pt.values_on_rows != rows { + return; + } + let Some(p) = pt.values_position else { return }; + // Σ Values stays just ahead of the field it preceded. + let p = match inserted { + true if i <= p => p + 1, + false if i < p => p - 1, + _ => p, + }; + let n = if rows { pt.rows.len() } else { pt.columns.len() }; + pt.values_position = (p < n).then_some(p); +} + +/// Takes `field` out of Rows and Columns (Σ Values keeps its place); returns its first entry. +fn take_from_axes(pt: &mut PivotTable, field: &str) -> Option { + let mut first = None; + for rows in [true, false] { + loop { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let Some(i) = list.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)) else { break }; + let f = list.remove(i); + shift_values(pt, rows, i, false); + first.get_or_insert(f); + } + } + first +} + +/// Inserts `f` into Rows (`rows`) or Columns at `position` (default: the end). +fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: Option) { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let at = position.unwrap_or(list.len()).min(list.len()); + list.insert(at, f); + shift_values(pt, rows, at, true); +} + +/// Moves Σ Values between Rows and Columns, or within one, to `position` (default: the end). +fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option) -> Result { + let current = if pt.values_on_rows { "rows" } else { "columns" }; + if from != current { + return Err(bad(cmd, format!("`{VALUES_FIELD}` isn't in the {from} area"))); + } + if !matches!(to, "rows" | "columns") { + return Err(other("The field you are moving cannot be placed in that PivotTable area.")); + } + pt.values_on_rows = to == "rows"; + let n = if pt.values_on_rows { pt.rows.len() } else { pt.columns.len() }; + pt.values_position = position.filter(|p| *p < n); + Ok(json!({"field": VALUES_FIELD, "to": to})) +} + /// The canonical source field name for `field`. fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result { src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data"))) @@ -384,21 +448,11 @@ fn add_to_area( if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) { return Err(other(format!("A PivotTable can have at most {} row and column fields.", pv::MAX_AXIS_FIELDS))); } - let existing = pt - .rows - .iter() - .chain(pt.columns.iter()) - .find(|f| f.source_col.eq_ignore_ascii_case(&field)) - .cloned() - .unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() }); - pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); - pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); + let existing = take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() }); // A report filter that selects nothing in particular goes away; a selection stays // as the field's hidden items. pt.filters.retain(|f| !(f.source_col.eq_ignore_ascii_case(&field) && f.selected.is_none())); - let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns }; - let at = position.unwrap_or(list.len()).min(list.len()); - list.insert(at, existing); + insert_axis_field(pt, area == "rows", existing, position); } "values" => { if pt.values.len() >= pv::MAX_VALUE_FIELDS { @@ -421,8 +475,7 @@ fn add_to_area( pt.values.insert(at, v); } _ => { - pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); - pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); + take_from_axes(pt, &field); let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); let entry = match existing { Some(i) => pt.filters.remove(i), @@ -439,12 +492,14 @@ fn add_to_area( fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result { match area { "rows" | "columns" => { - let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns }; + let rows = area == "rows"; + let list = if rows { &mut pt.rows } else { &mut pt.columns }; let i = list .iter() .position(|f| f.source_col.eq_ignore_ascii_case(field)) .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the {area} area")))?; let f = list.remove(i); + shift_values(pt, rows, i, false); pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&f.source_col)); Ok(f.source_col) } @@ -699,6 +754,8 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { "style": pt.style, "showHeaders": pt.show_headers, "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), + "valuesOnRows": pt.values_on_rows, + "valuesPosition": pt.values_position, }) } @@ -890,18 +947,22 @@ fn move_field(s: &mut Session, p: &Json) -> Result { let to = area_param(C, str_param(p, "to"))?; let pos = position_param(p); update(s, p, |pt, src, _| { + if is_values_field(src, &field) { + return move_values_field(C, pt, from, to, pos); + } if from == to { // Reorder within the area. match from { "rows" | "columns" => { - let list = if from == "rows" { &mut pt.rows } else { &mut pt.columns }; + let rows = from == "rows"; + let list = if rows { &mut pt.rows } else { &mut pt.columns }; let i = list .iter() .position(|f| f.source_col.eq_ignore_ascii_case(&field)) .ok_or_else(|| bad(C, format!("`{field}` isn't in the {from} area")))?; let f = list.remove(i); - let at = pos.unwrap_or(list.len()).min(list.len()); - list.insert(at, f); + shift_values(pt, rows, i, false); + insert_axis_field(pt, rows, f, pos); } "values" => { let i = pt @@ -1722,6 +1783,113 @@ mod tests { assert_eq!(text(&s, "A5"), "Apples"); } + #[test] + fn values_on_rows_layouts() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap(); + assert_eq!(text(&s, "C3"), "Sum of Units"); + // Σ Values after Region: each region holds one line per value field. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + let p = pt(&s); + assert!(p.values_on_rows); + assert_eq!(p.values_position, None); + let r = p.last_range.unwrap(); + assert_eq!(r.width(), 2); + // header + 3 × (region + 2 values) + 2 grand totals + assert_eq!(r.height(), 1 + 9 + 2); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(val(&s, "B4"), Value::Empty); + assert_eq!(text(&s, "A5"), "Sum of Sales"); + assert_eq!(val(&s, "B5"), Value::Number(415.0)); + assert_eq!(text(&s, "A6"), "Sum of Units"); + assert_eq!(val(&s, "B6"), Value::Number(33.0)); + assert_eq!(text(&s, "A13"), "Total Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(TOTAL)); + assert_eq!(text(&s, "A14"), "Total Sum of Units"); + assert_eq!(val(&s, "B14"), Value::Number(72.0)); + // Σ Values before Region: the value fields are the outer items. + s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, Some(0)); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(val(&s, "B4"), Value::Empty); + assert_eq!(text(&s, "A5"), "East"); + assert_eq!(val(&s, "B5"), Value::Number(415.0)); + assert_eq!(text(&s, "A8"), "Sum of Units"); + assert_eq!(text(&s, "A9"), "East"); + assert_eq!(val(&s, "B9"), Value::Number(33.0)); + assert_eq!(text(&s, "A12"), "Total Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(72.0)); + // Adding a row field in front keeps Σ Values ahead of Region. + s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, Some(1)); + s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap(); + assert_eq!(pt(&s).values_position, Some(0)); + // No row field: one line per value field and no grand total. + s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap(); + let r = pt(&s).last_range.unwrap(); + assert_eq!(r.height(), 1 + 2); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(val(&s, "B4"), Value::Number(TOTAL)); + assert_eq!(text(&s, "A5"), "Sum of Units"); + assert_eq!(val(&s, "B5"), Value::Number(72.0)); + // Back to Columns. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap(); + assert!(!pt(&s).values_on_rows); + assert_eq!(text(&s, "A3"), "Sum of Sales"); + assert_eq!(text(&s, "B3"), "Sum of Units"); + assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err()); + assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err()); + } + + #[test] + fn values_on_rows_between_row_fields() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap(); + // Compact: East, then per value field its products, then per-value subtotals of East. + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "A5"), "Sum of Sales"); + assert_eq!(text(&s, "A6"), "Apples"); + assert_eq!(val(&s, "B6"), Value::Number(160.0)); + assert_eq!(text(&s, "A9"), "Sum of Units"); + assert_eq!(text(&s, "A10"), "Apples"); + assert_eq!(val(&s, "B10"), Value::Number(16.0)); + assert_eq!(text(&s, "A13"), "East Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(415.0)); + assert_eq!(text(&s, "A14"), "East Sum of Units"); + assert_eq!(val(&s, "B14"), Value::Number(33.0)); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2); + // Outline: one column per level, "Values" heads the Σ Values column. + s.execute("pivot.layout", json!({"layout": "outline"})).unwrap(); + assert_eq!(pt(&s).last_range.unwrap().width(), 4); + assert_eq!(text(&s, "A3"), "Region"); + assert_eq!(text(&s, "B3"), "Values"); + assert_eq!(text(&s, "C3"), "Product"); + assert_eq!(text(&s, "B5"), "Sum of Sales"); + assert_eq!(text(&s, "C6"), "Apples"); + assert_eq!(val(&s, "D6"), Value::Number(160.0)); + // Tabular: labels start on their parent's row; totals per value field below each group. + s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "B4"), "Sum of Sales"); + assert_eq!(text(&s, "C4"), "Apples"); + assert_eq!(val(&s, "D4"), Value::Number(160.0)); + assert_eq!(text(&s, "B7"), "Sum of Units"); + assert_eq!(text(&s, "C7"), "Apples"); + assert_eq!(val(&s, "D7"), Value::Number(16.0)); + assert_eq!(text(&s, "A10"), "East Sum of Sales"); + assert_eq!(val(&s, "D10"), Value::Number(415.0)); + assert_eq!(text(&s, "A11"), "East Sum of Units"); + let r = pt(&s).last_range.unwrap(); + let last = r.end.row + 1; + assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales"); + assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units"); + assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0)); + } + #[test] fn refresh_after_source_edits() { let mut s = sales(); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index c76bf4b9e..ed381a3c1 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -560,6 +560,8 @@ struct Line { values: bool, /// (column, value, indent, number format) labels: Vec<(usize, Value, u8, Option)>, + /// The value field of the line when Σ Values sits in Rows (`None`: one per data column). + vi: Option, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -579,6 +581,10 @@ struct Ctx<'a> { rows: Vec<&'a PivotField>, row_items: Vec, row_fmts: Vec, + /// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields). + vpos: Option, + /// Value field captions. + val_names: Vec, } impl Ctx<'_> { @@ -588,26 +594,102 @@ impl Ctx<'_> { (it.value.clone(), fmt, it.label.clone()) } - fn flatten(&self, nodes: &[Node], depth: usize, prefix: &mut Vec, out: &mut Vec) { + /// (column, indent) of a row level (Σ Values counts as a level) in Compact/Outline form. + fn level_cell(&self, level: usize) -> (usize, u8) { + if self.pt.layout == PivotLayout::Outline { (level, 0) } else { (0, level.min(250) as u8) } + } + + /// Σ Values comes right here, at row field depth `depth` (`vi`: the value field already chosen above). + fn values_here(&self, depth: usize, vi: Option) -> bool { + vi.is_none() && self.vpos == Some(depth) + } + + /// Per-value subtotal lines of an item with Σ Values below it ("East Sum of Sales"). + fn value_subtotals(&self, col: usize, indent: u8, label: &str, prefix: &[u32], out: &mut Vec) { + for (vi, name) in self.val_names.iter().enumerate() { + out.push(Line { + kind: LineKind::Subtotal, + leaf: false, + path: prefix.to_vec(), + values: true, + labels: vec![(col, Value::text(format!("{label} {name}")), indent, None)], + vi: Some(vi), + }); + } + } + + /// The Σ Values level at row field depth `depth`: one item per value field, each followed by + /// the row fields below it (`nodes`) or, innermost, holding that value. + fn flatten_values(&self, nodes: &[Node], depth: usize, prefix: &mut Vec, out: &mut Vec) { + let (col, indent) = self.level_cell(depth); + for (vi, name) in self.val_names.iter().enumerate() { + let labels = vec![(col, Value::text(name.as_str()), indent, None)]; + let leaf = nodes.is_empty(); + out.push(Line { kind: LineKind::Item(!leaf), leaf, path: prefix.clone(), values: leaf, labels, vi: Some(vi) }); + if !leaf { + self.flatten(nodes, depth, Some(vi), prefix, out); + } + } + } + + fn flatten(&self, nodes: &[Node], depth: usize, vi: Option, prefix: &mut Vec, out: &mut Vec) { + if self.values_here(depth, vi) { + self.flatten_values(nodes, depth, prefix, out); + return; + } let n = self.rows.len(); - let outline = self.pt.layout == PivotLayout::Outline; + // Σ Values still to come below this level: totals need one line per value field. + let values_below = vi.is_none() && self.vpos.is_some(); + let (col, indent) = self.level_cell(depth + usize::from(vi.is_some() && self.vpos.is_some())); for node in nodes { prefix.push(node.item); let (v, fmt, label) = self.label_cell(depth, node.item); - let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) }; let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); - if depth + 1 >= n || collapsed || node.children.is_empty() { + let leaf = depth + 1 >= n || collapsed || node.children.is_empty(); + if leaf && !values_below { out.push(Line { kind: LineKind::Item(depth + 1 < n), leaf: true, path: prefix.clone(), values: true, labels: vec![(col, v, indent, fmt)], + vi, }); + } else if leaf { + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: false, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten_values(&[], depth + 1, prefix, out); + } else if values_below { + // Subtotals per value field, below the group (one line can't hold them all). + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: false, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten(&node.children, depth + 1, None, prefix, out); + if self.pt.subtotals { + self.value_subtotals(col, indent, &label, prefix, out); + } } else { let top = self.pt.subtotals && self.pt.subtotals_top; - out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] }); - self.flatten(&node.children, depth + 1, prefix, out); + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: top, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten(&node.children, depth + 1, vi, prefix, out); if self.pt.subtotals && !self.pt.subtotals_top { out.push(Line { kind: LineKind::Subtotal, @@ -615,6 +697,7 @@ impl Ctx<'_> { path: prefix.clone(), values: true, labels: vec![(col, Value::text(format!("{label} Total")), indent, None)], + vi, }); } } @@ -622,7 +705,8 @@ impl Ctx<'_> { } } - fn flatten_tabular( + /// Tabular form of [`Self::flatten_values`]: the first line also carries the parents' labels. + fn flatten_tabular_values( &self, nodes: &[Node], depth: usize, @@ -630,24 +714,57 @@ impl Ctx<'_> { pending: &[(usize, Value, u8, Option)], out: &mut Vec, ) { + for (vi, name) in self.val_names.iter().enumerate() { + let mut labels: Vec<(usize, Value, u8, Option)> = if vi == 0 { pending.to_vec() } else { vec![] }; + labels.push((depth, Value::text(name.as_str()), 0, None)); + if nodes.is_empty() { + out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi: Some(vi) }); + } else { + self.flatten_tabular(nodes, depth, Some(vi), prefix, &labels, out); + } + } + } + + fn flatten_tabular( + &self, + nodes: &[Node], + depth: usize, + vi: Option, + prefix: &mut Vec, + pending: &[(usize, Value, u8, Option)], + out: &mut Vec, + ) { + if self.values_here(depth, vi) { + self.flatten_tabular_values(nodes, depth, prefix, pending, out); + return; + } let n = self.rows.len(); + let values_below = vi.is_none() && self.vpos.is_some(); + let col = depth + usize::from(vi.is_some() && self.vpos.is_some()); for (i, node) in nodes.iter().enumerate() { prefix.push(node.item); let (v, fmt, label) = self.label_cell(depth, node.item); let mut labels: Vec<(usize, Value, u8, Option)> = if i == 0 { pending.to_vec() } else { vec![] }; - labels.push((depth, v, 0, fmt)); + labels.push((col, v, 0, fmt)); let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); if depth + 1 >= n || collapsed || node.children.is_empty() { - out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels }); + if values_below { + self.flatten_tabular_values(&[], depth + 1, prefix, &labels, out); + } else { + out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi }); + } } else { - self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out); - if self.pt.subtotals { + self.flatten_tabular(&node.children, depth + 1, vi, prefix, &labels, out); + if self.pt.subtotals && values_below { + self.value_subtotals(col, 0, &label, prefix, out); + } else if self.pt.subtotals { out.push(Line { kind: LineKind::Subtotal, leaf: false, path: prefix.clone(), values: true, - labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)], + labels: vec![(col, Value::text(format!("{label} Total")), 0, None)], + vi, }); } } @@ -745,6 +862,14 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result = (pt.values_on_rows && k > 1).then(|| { + let p = pt.values_position.unwrap_or(pt.rows.len()); + pt.rows.iter().take(p).filter(|f| src.col(&f.source_col).is_some()).count() + }); + let on_rows = vpos.is_some(); + let row_levels = rows.len() + usize::from(on_rows); // ---- aggregate let mut row_items: Vec = rows.iter().map(|_| FieldItems::default()).collect(); @@ -806,24 +931,32 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result = row_tree.iter().map(|n| n.item).collect(); - let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() }; + let val_names: Vec = vals.iter().map(|(_, v)| v.name.clone()).collect(); + let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone(), vpos, val_names: val_names.clone() }; let mut lines: Vec = Vec::new(); - if rows.is_empty() { - lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] }); + if row_levels == 0 { + lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![], vi: None }); } else { if pt.layout == PivotLayout::Tabular { - ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines); + ctx.flatten_tabular(&row_tree, 0, None, &mut Vec::new(), &[], &mut lines); } else { - ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines); + ctx.flatten(&row_tree, 0, None, &mut Vec::new(), &mut lines); } - if pt.grand_totals_cols { - lines.push(Line { + // With only Σ Values in Rows its items are the totals already. + if pt.grand_totals_cols && !rows.is_empty() { + let grand = |text: String, vi: Option| Line { kind: LineKind::Grand, leaf: false, path: vec![], values: true, - labels: vec![(0, Value::text("Grand Total"), 0, None)], - }); + labels: vec![(0, Value::text(text), 0, None)], + vi, + }; + if on_rows { + lines.extend(val_names.iter().enumerate().map(|(vi, name)| grand(format!("Total {name}"), Some(vi)))); + } else { + lines.push(grand("Grand Total".into(), None)); + } } } let mut specs: Vec = Vec::new(); @@ -837,7 +970,10 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result)> = Vec::new(); for (si, _) in specs.iter().enumerate() { - if k == 0 { + if on_rows { + // Each line names its value field; one data column per column item. + data_cols.push((si, None)); + } else if k == 0 { if !cols.is_empty() { data_cols.push((si, None)); } @@ -852,12 +988,12 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result Result = row_fields.iter().map(|f| f.source_col.as_str()).collect(); + if let Some(p) = vpos { + names.insert(p.min(names.len()), "Values"); + } + for (i, name) in names.into_iter().enumerate() { + g.set(r, i, Value::text(name), CellKind::Header, 0, None); } } }; @@ -933,7 +1074,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result 1); + let levels = cols.len() + usize::from(k > 1 && !on_rows); let first_level = g.rows.len(); for _ in 0..levels { g.push_row(RowKind::Header)?; @@ -1048,17 +1189,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result 1 || parts.is_empty()) { parts.push(val_name(*vi)); + } else if parts.is_empty() { + parts.push("Total".into()); } Some(((l + j) as u32, parts.join(" - "))) }) .filter(|_| k > 0) .collect(); - let label_col = if rows.is_empty() { + let label_col = if row_levels == 0 { None } else if compact { Some(0) } else { - Some(rows.len().saturating_sub(1) as u32) + Some(row_levels.saturating_sub(1) as u32) }; for line in &lines { let rk = match line.kind { @@ -1082,8 +1225,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result, /// Calculated fields, usable in the Values area by name. pub calculated_fields: Vec, + /// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather + /// than Columns (Excel's "values on rows"). + pub values_on_rows: bool, + /// Position of Σ Values among the fields of its area (`None` = after the last). + pub values_position: Option, } /// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in @@ -1007,6 +1012,8 @@ impl Default for PivotTable { show_headers: true, last_range: None, calculated_fields: vec![], + values_on_rows: false, + values_position: None, } } } diff --git a/crates/ui-egui/src/pivot_pane.rs b/crates/ui-egui/src/pivot_pane.rs index 14d69db96..065702622 100644 --- a/crates/ui-egui/src/pivot_pane.rs +++ b/crates/ui-egui/src/pivot_pane.rs @@ -64,13 +64,26 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }) .unwrap_or_default() }; + // Σ Values (two or more value fields) sits in Rows or Columns; it moves only between them. + const VALUES_FIELD: &str = "Σ Values"; + let values_key = if info["valuesOnRows"].as_bool().unwrap_or(false) { "rows" } else { "columns" }; + let values_at = info["valuesPosition"].as_u64().map(|p| p as usize); + let area_with_values = |key: &str| -> Vec { + let mut v = area(key); + if key == values_key && area("values").len() > 1 { + let at = values_at.unwrap_or(v.len()).min(v.len()); + v.insert(at, VALUES_FIELD.to_string()); + } + v + }; ui.columns(2, |cols| { for (i, (key, label)) in [("filters", "Filters"), ("columns", "Columns"), ("rows", "Rows"), ("values", "Values")].iter().enumerate() { let ui = &mut cols[i % 2]; ui.label(egui::RichText::new(*label).strong()); egui::Frame::NONE.fill(t.window).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| { ui.set_min_size(vec2(ui.available_width(), 60.0)); - for f in area(key) { + for f in area_with_values(key) { + let is_values = f == VALUES_FIELD; let b = ui.add( egui::Button::new(egui::RichText::new(format!("{f} ▾")).font(theme::ui_font(12.0))) .fill(Color32::WHITE) @@ -83,10 +96,13 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { ("filters", "Move to Report Filter"), ("values", "Move to Values"), ] { - if to != *key && ui.button(l).clicked() { + if to != *key && (!is_values || matches!(to, "rows" | "columns")) && ui.button(l).clicked() { app.run_or_alert("pivot.moveField", json!({"pivot": name, "field": f, "from": key, "to": to})); } } + if is_values { + return; + } if *key == "values" { ui.separator(); for (func, l) in [("sum", "Sum"), ("count", "Count"), ("average", "Average"), ("max", "Max"), ("min", "Min")] { diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 1d16ab34d..0c480ed43 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -810,6 +810,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn PivotLayout::Outline => s.push_str(" compact=\"0\" compactData=\"0\" outline=\"1\" outlineData=\"1\""), PivotLayout::Tabular => s.push_str(" compact=\"0\" compactData=\"0\""), } + if pt.values_on_rows { + s.push_str(" dataOnRows=\"1\""); + } if !pt.show_headers { s.push_str(" showHeaders=\"0\""); } @@ -826,13 +829,19 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn let end = pt.last_range.map(|r| r.end).unwrap_or(a); let loc = RangeRef::new(a, CellRef::new(end.row.max(a.row), end.col.max(a.col))); let k = vals.len(); - let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(k > 1) }; - let first_data_col = if row_idx.is_empty() && col_idx.is_empty() { + // Σ Values (two or more value fields): its index among the written fields of its axis. + let (row_map, col_map) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len())); + let values_at = |map: &[Option], n: usize| pt.values_position.map_or(n, |p| map.iter().take(p).filter(|x| x.is_some()).count()); + let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, row_idx.len())); + let col_values = (k > 1 && !pt.values_on_rows).then(|| values_at(col_map, col_idx.len())); + let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(col_values.is_some()) }; + let row_levels = row_idx.len() + usize::from(row_values.is_some()); + let first_data_col = if row_levels == 0 && col_idx.is_empty() { 0 } else if compact { 1 } else { - row_idx.len().max(1) + row_levels.max(1) }; let _ = write!(s, ""); - if !row_idx.is_empty() { - let _ = write!(s, "", row_idx.len()); - for i in &row_idx { - let _ = write!(s, ""); - } - s.push_str(""); - } - let values_field = k > 1; - if !col_idx.is_empty() || values_field { - let _ = write!(s, "", col_idx.len() + usize::from(values_field)); - for i in &col_idx { - let _ = write!(s, ""); - } - if values_field { - s.push_str(""); - } - s.push_str(""); - } + axis_fields_xml(&mut s, "rowFields", &row_idx, row_values); + axis_fields_xml(&mut s, "colFields", &col_idx, col_values); if !pages.is_empty() { let _ = write!(s, "", pages.len()); for (i, f) in &pages { @@ -1011,6 +1004,25 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn s } +/// ``/``: the axis's cache field indices, with Σ Values (`-2`) at `values_at`. +fn axis_fields_xml(s: &mut String, tag: &str, idx: &[usize], values_at: Option) { + let n = idx.len() + usize::from(values_at.is_some()); + if n == 0 { + return; + } + let _ = write!(s, "<{tag} count=\"{n}\">"); + for (j, i) in idx.iter().enumerate() { + if values_at == Some(j) { + s.push_str(""); + } + let _ = write!(s, ""); + } + if values_at.is_some_and(|p| p >= idx.len()) { + s.push_str(""); + } + let _ = write!(s, ""); +} + // ================================================================ reading /// A cache field as read. @@ -1253,18 +1265,21 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option = Vec::new(); - let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> Vec { + // (fields, position of Σ Values (`-2`) among them) + let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> (Vec, Option) { let mut v = Vec::new(); + let mut values_at = None; for &i in list { + if i == -2 { + values_at = Some(v.len()); + continue; + } if i < 0 { continue; } @@ -1290,7 +1305,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option, x: &El, cache: &CacheIn, id: u32) -> Option Workbook { show_headers: false, last_range: Some(rr("A2:H12")), calculated_fields: vec![], + values_on_rows: false, + values_position: None, }; // Pivot 2: table source, date grouping on two levels, show-values-as, multi-select page // filter and a hidden-items filter on a column field. @@ -166,6 +168,8 @@ fn workbook() -> Workbook { show_headers: true, last_range: Some(rr("K2:Z30")), calculated_fields: vec![], + values_on_rows: false, + values_position: None, }; // Pivot 3: defaults, defined-name source, compact. let p3 = PivotTable { @@ -508,3 +512,66 @@ fn calculated_fields_round_trip() { assert_eq!(a, b, "pivot {} differs", a.name); } } + +/// The `x` attributes of an axis list (`rowFields`/`colFields`) in a pivotTable part. +fn axis_xs(table: &str, tag: &str) -> Vec { + let Some(start) = table.find(&format!("<{tag} ")) else { return vec![] }; + let end = table[start..].find(&format!("")).map_or(table.len(), |e| start + e); + table[start..end].split(""#; + let (wb, report) = read_xlsx(&with_pivot(table, Some(GOOD_CACHE))).unwrap(); + assert!(report.warnings.iter().all(|w| !w.contains("values on rows")), "{:?}", report.warnings); + let pt = &wb.sheet(0).unwrap().pivots[0]; + assert!(pt.values_on_rows); + assert_eq!(pt.values_position, None); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert!(back.sheet(0).unwrap().pivots[0].values_on_rows); +} diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b899..b5614a4ed 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (§8: PivotTable values on rows) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -79,7 +79,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, - `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields done). + `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields + and values on rows done). - **Impact:** pivots are the analyst's main tool. - **Estimate:** 20–30 h for beta (25–40 h full). @@ -160,6 +161,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | §8: values on rows (Σ Values in Rows, `dataOnRows`) laid out, moved and round-tripped | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8b8209256..1c3f044e9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (stability: command-sequence fuzzing against undo/redo, XLSX round-trip and full-recalc invariants) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (PivotTables: values on rows) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -84,7 +84,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), Insert Copied Cells missing (#21), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) | | Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes | | Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | -| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, 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 | +| 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. 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). Missing: Solver, Forecast Sheet, Analyze Data | @@ -301,7 +301,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land average, colour), tables with totals rows, remove duplicates, text to columns, data validation with error alerts, grouping/outline, subtotals. - PivotTables: Insert/Recommended PivotTables, field list pane, compact/outline/tabular layouts, - date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip. + values on rows or columns, date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip. - Page layout & print: Page Break Preview, print settings, PDF export and printing. - What-if: Goal Seek, scenarios, data tables. Spelling (system word list) and thesaurus. - Draw: pen, highlighter, eraser, ink strokes and Ink to Shape. Task panes: Comments (threaded @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | | 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | | 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h | | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | From 857f538c08e3c18190b2fab53b691aef70935f74 Mon Sep 17 00:00:00 2001 From: dantebertuzzi Date: Mon, 12 Oct 2026 13:15:06 -0300 Subject: [PATCH 30/86] cell.set / set_cell: accept a number for `input` (#697) `input` is documented as "text, number or =formula", but only a string was read; a JSON number was dropped and the command failed (or, on 0.5.0, silently cleared the cell). The undocumented `value` alias already went through `json_to_input`. Both keys now share that converter, so numbers and booleans are entered as typed. The MCP `set_cell` schema types `input` as string, number or boolean to match its description. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 24 +++++++++++++++++++----- crates/mcp/src/tests.rs | 3 +++ crates/mcp/src/tools.rs | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3da230b3b..a2e8c4791 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -219,11 +219,8 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { Some(c) => c, None => s.doc()?.selection.active, }; - let input = str_param(p, "input") - .or_else(|| str_param(p, "value")) - .map(str::to_string) - .or_else(|| p.get("value").map(json_to_input)) - .ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; + // Both spellings take a string, number or boolean, as typed (#697). + let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; let array = bool_param(p, "array").unwrap_or(false); let d = s.doc()?; if let Some(sh) = d.wb.sheet(sheet) @@ -1583,6 +1580,23 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result { mod tests { use super::*; + #[test] + fn cell_set_takes_numbers_for_input_and_value() { + // #697: a JSON number for `input` used to clear the cell instead of entering it. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap(); + s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!(123.5)); + assert_eq!(get(&mut s, "A2")["value"], json!(77.0)); + assert_eq!(get(&mut s, "A3")["value"], json!(true)); + assert_eq!(get(&mut s, "A4")["value"], json!(200.5)); + assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); + } + #[test] fn legacy_array_entry() { let mut s = Session::new(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index eb1f20503..40f031f1a 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -202,6 +202,9 @@ fn basic_tools() { assert!(f.as_array().unwrap().iter().any(|x| x["name"] == "VLOOKUP")); assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "SUM(1,2,3)"}))["result"], json!(6.0)); ok(&mut s, "set_cell", json!({"cell": "A1", "input": "5"})); + // #697: a number for `input` is entered, not rejected or cleared. + ok(&mut s, "set_cell", json!({"cell": "B1", "input": 42.5})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B1"}))["value"], json!(42.5)); ok(&mut s, "execute_command", json!({"command": "cell.set", "params": {"cell": "A2", "input": "=A1*2"}})); assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A2"}))["value"], json!(10.0)); assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=A1+A2", "cell": "C1"}))["result"], json!(15.0)); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index eb741b8bc..eed4cd754 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -180,7 +180,7 @@ pub fn tool_definitions() -> Vec { "Enter one cell exactly as if typed: a number, text, or a formula starting with = (\"=AVERAGE(B2:B9)\"). \ Dependent cells recalculate. Returns the command result; use get_cell to read the computed value.", obj( - json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": string("Typed input: number, text or =formula")}), + json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": {"type": ["string", "number", "boolean"], "description": "Typed input: number, text or =formula"}}), &["cell", "input"], ), false, From de04cbe917017e480dba853960efe0db7536adc2 Mon Sep 17 00:00:00 2001 From: dantebertuzzi Date: Mon, 12 Oct 2026 13:29:17 -0300 Subject: [PATCH 31/86] i18n(pt-BR): translate 546 more labels, dialog strings and tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brazilian Portuguese had 377 catalog entries, so most dialogs, menus and tooltips showed in English with the interface set to Português (Brasil). This adds every key the other catalogs translate (counting the open Spanish-dialog #669, Russian #676, Czech #652, Polish #650, Turkish #663 and Italian #713 PRs), bringing pt-BR to 923 entries. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/pt.rs | 546 ++++++++++++++++++++++++++++++++++ docs/localization-parity.md | 5 +- 2 files changed, 549 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index b554ef977..a02fd07ea 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -394,6 +394,552 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Cálculo da pasta de trabalho", "Enable iterative calculation" => "Habilitar cálculo iterativo", "Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.", + "OK" => "OK", + "Cancel" => "Cancelar", + "Don't Save" => "Não Salvar", + "Open…" => "Abrir…", + "Show password" => "Mostrar senha", + "Start a new workbook" => "Criar uma pasta de trabalho", + "Household Budget" => "Orçamento Doméstico", + "Quarterly Sales Dashboard" => "Painel de Vendas Trimestrais", + "Gradebook" => "Boletim de Notas", + "this workbook" => "esta pasta de trabalho", + "This workbook" => "Esta pasta de trabalho", + "Do you want to save the changes you made to {title}?" => "Deseja salvar as alterações feitas em {title}?", + "Save a copy as .{format}" => "Salvar uma cópia como .{format}", + "Select a chart first." => "Selecione um gráfico primeiro.", + "Text Box" => "Caixa de Texto", + "Select Data" => "Selecionar Dados", + "Row Height" => "Altura da Linha", + "Column Width" => "Largura da Coluna", + "Standard Width" => "Largura Padrão", + "Rename Sheet" => "Renomear Planilha", + "Move or Copy" => "Mover ou Copiar", + "Protect Sheet" => "Proteger Planilha", + "Protect Structure and Windows" => "Proteger Estrutura e Janelas", + "Unprotect Workbook" => "Desproteger Pasta de Trabalho", + "Unprotect Sheet" => "Desproteger Planilha", + "Password" => "Senha", + "Unhide" => "Reexibir", + "Paste Special" => "Colar Especial", + "Series" => "Série", + "New Comment" => "Novo Comentário", + "Insert Hyperlink" => "Inserir Hiperlink", + "Create Sparklines" => "Criar Minigráficos", + "Page Setup" => "Configurar Página", + "Convert Text to Columns" => "Converter Texto em Colunas", + "Remove Duplicates" => "Remover Duplicatas", + "Goal Seek" => "Atingir Meta", + "Create Forecast Worksheet" => "Criar Planilha de Previsão", + "Solver Parameters" => "Parâmetros do Solver", + "Solver Results" => "Resultados do Solver", + "Edit Name" => "Editar Nome", + "New Name" => "Novo Nome", + "Format Cells" => "Formatar Células", + "Formula Builder" => "Construtor de Fórmulas", + "Find and Replace" => "Localizar e Substituir", + "Go To" => "Ir para", + "Insert Calculated Field" => "Inserir Campo Calculado", + "Go To Special" => "Ir para Especial", + "Name Manager" => "Gerenciador de Nomes", + "Conditional Formatting Rules Manager" => "Gerenciador de Regras de Formatação Condicional", + "Search Commands" => "Pesquisar Comandos", + "Agent Control" => "Controle por Agentes", + "About GridCraft" => "Sobre o GridCraft", + "Insert Chart" => "Inserir Gráfico", + "Select Data Source" => "Selecionar Fonte de Dados", + "Action Journal" => "Registro de Ações", + "Macros" => "Macros", + "Workbook Statistics" => "Estatísticas da Pasta de Trabalho", + "Accessibility" => "Acessibilidade", + "Chart Title" => "Título do Gráfico", + "Place Picture in Cell" => "Colocar Imagem na Célula", + "Place Picture over Cells" => "Colocar Imagem sobre as Células", + "Pick From List" => "Escolher na Lista", + "Save a Copy" => "Salvar uma Cópia", + "Save Changes?" => "Salvar Alterações?", + "Shift cells right" => "Deslocar células para a direita", + "Shift cells down" => "Deslocar células para baixo", + "Shift cells left" => "Deslocar células para a esquerda", + "Shift cells up" => "Deslocar células para cima", + "Entire row" => "Linha inteira", + "Entire column" => "Coluna inteira", + "Row height (pixels):" => "Altura da linha (pixels):", + "Column width (characters):" => "Largura da coluna (caracteres):", + "Standard column width (pixels):" => "Largura padrão da coluna (pixels):", + "Name:" => "Nome:", + "Before sheet (position, 0 = first):" => "Antes da planilha (posição, 0 = primeira):", + "Create a copy" => "Criar uma cópia", + "Sheets: {sheets}" => "Planilhas: {sheets}", + "Password (optional):" => "Senha (opcional):", + "Password:" => "Senha:", + "Allow all users of this sheet to:" => "Permitir que todos os usuários desta planilha possam:", + "Format cells" => "Formatar células", + "Format columns" => "Formatar colunas", + "Format rows" => "Formatar linhas", + "Insert rows" => "Inserir linhas", + "Delete rows" => "Excluir linhas", + "Use AutoFilter" => "Usar AutoFiltro", + " (password, unlocked)" => " (senha, desbloqueado)", + " (password)" => " (senha)", + "No ranges yet." => "Nenhum intervalo ainda.", + "Ranges that stay editable when the sheet is protected:" => "Intervalos que continuam editáveis quando a planilha está protegida:", + "Action" => "Ação", + "Modify" => "Modificar", + "Unlock with password" => "Desbloquear com senha", + "Title:" => "Título:", + "Refers to cells:" => "Refere-se às células:", + "Range password:" => "Senha do intervalo:", + "New title (Modify):" => "Novo título (Modificar):", + "Remove the password (Modify)" => "Remover a senha (Modificar)", + "'{file}' is protected." => "'{file}' está protegido.", + "Unhide sheet:" => "Reexibir planilha:", + "Hidden: {sheets}" => "Ocultas: {sheets}", + "All" => "Tudo", + "Formulas (F)" => "Fórmulas (F)", + "Values (V)" => "Valores (V)", + "Formats (T)" => "Formatos (T)", + "Comments and notes (C)" => "Comentários e notas (C)", + "Validation" => "Validação", + "All except borders" => "Tudo, exceto bordas", + "Column widths" => "Larguras da coluna", + "Formulas and number formats" => "Fórmulas e formatos de número", + "Values and number formats" => "Valores e formatos de número", + "Operation" => "Operação", + "Add" => "Adicionar", + "Subtract" => "Subtrair", + "Multiply" => "Multiplicar", + "Divide" => "Dividir", + "Skip blanks" => "Ignorar em branco", + "Transpose (E)" => "Transpor (E)", + "Series in" => "Série em", + "Columns" => "Colunas", + "Rows" => "Linhas", + "Type" => "Tipo", + "Linear" => "Linear", + "Growth" => "Crescimento", + "Date unit" => "Unidade de data", + "Day" => "Dia", + "Weekday" => "Dia da semana", + "Month" => "Mês", + "Year" => "Ano", + "Step value:" => "Incremento:", + "Stop value:" => "Limite:", + "Start a conversation:" => "Iniciar uma conversa:", + "Note:" => "Nota:", + "Display:" => "Texto para exibição:", + "Link to (web address, e-mail or Sheet!A1):" => "Vincular a (endereço da Web, e-mail ou Planilha!A1):", + "ScreenTip:" => "Dica de tela:", + "Data Range:" => "Intervalo de dados:", + "Location Range:" => "Intervalo de local:", + "Markers" => "Marcadores", + "Footer:" => "Rodapé:", + "Left margin (in):" => "Margem esquerda (pol.):", + "Right margin (in):" => "Margem direita (pol.):", + "Top margin (in):" => "Margem superior (pol.):", + "Bottom margin (in):" => "Margem inferior (pol.):", + "Header (in):" => "Cabeçalho (pol.):", + "Footer (in):" => "Rodapé (pol.):", + "Tab" => "Tabulação", + "Semicolon" => "Ponto e vírgula", + "Comma" => "Vírgula", + "Space" => "Espaço", + "Other:" => "Outros:", + "Treat consecutive delimiters as one" => "Considerar delimitadores consecutivos como um só", + "Destination:" => "Destino:", + "My data has headers" => "Meus dados contêm cabeçalhos", + "Columns (blank = all, e.g. A,C):" => "Colunas (em branco = todas, p. ex. A,C):", + "Timeline range (blank: first column of the selection):" => "Intervalo da linha do tempo (em branco: primeira coluna da seleção):", + "Values range (blank: second column of the selection):" => "Intervalo de valores (em branco: segunda coluna da seleção):", + "Confidence interval" => "Intervalo de confiança", + "Confidence level (%):" => "Nível de confiança (%):", + "Seasonality (auto, 0 for none, or a season length):" => "Sazonalidade (automática, 0 para nenhuma ou a duração de um período):", + "Fill missing points using" => "Preencher pontos ausentes usando", + "Interpolation" => "Interpolação", + "Zeros" => "Zeros", + "Aggregate duplicates using" => "Agregar duplicatas usando", + "Average" => "Média", + "Count" => "Contagem", + "CountA" => "Cont.Valores", + "Max" => "Máx", + "Median" => "Mediana", + "Min" => "Mín", + "Sum" => "Soma", + "Product" => "Produto", + "Include forecast statistics" => "Incluir estatísticas de previsão", + "Chart" => "Gráfico", + "Line chart" => "Gráfico de linhas", + "Column chart" => "Gráfico de colunas", + "At each change in (column letter):" => "A cada alteração em (letra da coluna):", + "Use function" => "Usar função", + "Add subtotal to (columns, e.g. C,D):" => "Adicionar subtotal a (colunas, p. ex. C,D):", + "Magnification" => "Ampliação", + "Custom (%):" => "Personalizado (%):", + "Scope (Workbook or sheet name):" => "Escopo (Pasta de Trabalho ou nome da planilha):", + "Comment:" => "Comentário:", + "Refers to:" => "Refere-se a:", + "Allow" => "Permitir", + "Any value" => "Qualquer valor", + "Whole number" => "Número inteiro", + "Decimal" => "Decimal", + "List" => "Lista", + "Text length" => "Comprimento do texto", + "between" => "está entre", + "not between" => "não está entre", + "equal to" => "é igual a", + "not equal to" => "é diferente de", + "greater than" => "é maior do que", + "less than" => "é menor do que", + "greater than or equal to" => "é maior ou igual a", + "less than or equal to" => "é menor ou igual a", + "Minimum / Source / Formula:" => "Mínimo / Fonte / Fórmula:", + "Maximum:" => "Máximo:", + "Ignore blank" => "Ignorar em branco", + "In-cell dropdown" => "Menu suspenso na célula", + "Input message:" => "Mensagem de entrada:", + "Error style" => "Estilo de erro", + "Stop" => "Parar", + "Warning" => "Aviso", + "Information" => "Informações", + "Error message:" => "Mensagem de erro:", + "Format cells that are (value):" => "Formatar células que são (valor):", + "and:" => "e:", + "Format cells that contain the text:" => "Formatar células que contêm o texto:", + "Format cells that contain a date occurring:" => "Formatar células que contêm uma data:", + "Yesterday" => "Ontem", + "Today" => "Hoje", + "Tomorrow" => "Amanhã", + "In the last 7 days" => "Nos últimos 7 dias", + "Last week" => "Semana passada", + "This week" => "Esta semana", + "Next week" => "Próxima semana", + "Last month" => "Mês passado", + "This month" => "Este mês", + "Next month" => "Próximo mês", + "Format values where this formula is true:" => "Formatar valores em que esta fórmula é verdadeira:", + "with" => "com", + "Light Red Fill with Dark Red Text" => "Preenchimento Vermelho Claro e Texto Vermelho Escuro", + "Yellow Fill with Dark Yellow Text" => "Preenchimento Amarelo e Texto Amarelo Escuro", + "Green Fill with Dark Green Text" => "Preenchimento Verde e Texto Verde Escuro", + "Light Red Fill" => "Preenchimento Vermelho Claro", + "Red Text" => "Texto Vermelho", + "Red Border" => "Borda Vermelha", + "Formula:" => "Fórmula:", + "Select" => "Selecionar", + "Blanks" => "Células em branco", + "Errors" => "Erros", + "Numbers" => "Números", + "Current region" => "Região atual", + "Last cell" => "Última célula", + "Visible cells only" => "Somente células visíveis", + "Conditional formats" => "Formatos condicionais", + "Data validation" => "Validação de dados", + "Horizontal axis title:" => "Título do eixo horizontal:", + "Vertical axis title:" => "Título do eixo vertical:", + "No picture selected" => "Nenhuma imagem selecionada", + "Picture file path (PNG or JPEG):" => "Caminho do arquivo de imagem (PNG ou JPEG):", + "Description (optional):" => "Descrição (opcional):", + "Fits inside the selected cell and moves with its content." => "Ajusta-se dentro da célula selecionada e move-se com o conteúdo dela.", + "Choose a PNG or JPEG no larger than 16 MB." => "Escolha um PNG ou JPEG de no máximo 16 MB.", + "About" => "Sobre", + "Contributors" => "Colaboradores", + "Models" => "Modelos", + "Version {version}" => "Versão {version}", + "Join the ArtCraft Discord" => "Participe do Discord da ArtCraft", + "This workbook has no scripts or macros." => "Esta pasta de trabalho não tem scripts nem macros.", + "(unnamed)" => "(sem nome)", + "— VBA project (preserved, not run)" => "— Projeto VBA (preservado, não executado)", + "— 1 step" => "— 1 etapa", + "— {steps} steps" => "— {steps} etapas", + "Alignment" => "Alinhamento", + "Font" => "Fonte", + "Border" => "Borda", + "Format Cells|Fill" => "Preenchimento", + "Protection" => "Proteção", + "Category:" => "Categoria:", + "Sample" => "Exemplo", + "Type (custom format code):" => "Tipo (código de formato personalizado):", + "Vertical" => "Vertical", + "Justify" => "Justificar", + "Center Across Selection" => "Centralizar Seleção", + "Distributed" => "Distribuído", + "Top" => "Superior", + "Bottom" => "Inferior", + "Indent" => "Recuo", + "Orientation (degrees)" => "Orientação (graus)", + "Wrap text" => "Quebrar texto automaticamente", + "Shrink to fit" => "Reduzir para caber", + "Bold" => "Negrito", + "Italic" => "Itálico", + "None" => "Nenhum", + "Single" => "Simples", + "Double" => "Duplo", + "Single Accounting" => "Simples (Contábil)", + "Double Accounting" => "Duplo (Contábil)", + "Superscript" => "Sobrescrito", + "Subscript" => "Subscrito", + "Color:" => "Cor:", + "Outline" => "Contorno", + "Diagonal ↘" => "Diagonal ↘", + "Diagonal ↗" => "Diagonal ↗", + "none" => "nenhuma", + "thin" => "fina", + "medium" => "média", + "thick" => "espessa", + "dashed" => "tracejada", + "dotted" => "pontilhada", + "double" => "dupla", + "Background Color:" => "Cor do plano de fundo:", + "Locked" => "Bloqueadas", + "Hidden" => "Ocultas", + "Search:" => "Pesquisar:", + "e.g. lookup, average, date" => "p. ex. pesquisa, média, data", + "Date & Time" => "Data e Hora", + "Lookup & Reference" => "Pesquisa e Referência", + "Math & Trig" => "Matemática e Trigonometria", + "Statistical" => "Estatística", + "Engineering" => "Engenharia", + "Database" => "Banco de Dados", + "Compatibility" => "Compatibilidade", + "Web" => "Web", + "Cube" => "Cubo", + "Add-in and Automation" => "Suplemento e Automação", + "Find" => "Localizar", + "Replace" => "Substituir", + "Find what:" => "Localizar:", + "Replace with:" => "Substituir por:", + "Match case" => "Diferenciar maiúsculas e minúsculas", + "Match entire cell contents" => "Coincidir conteúdo da célula inteira", + "Within workbook" => "Na pasta de trabalho", + "Replace All" => "Substituir Tudo", + "Find All" => "Localizar Tudo", + "Find Next" => "Localizar Próxima", + "All done. We made {count} replacements." => "Tudo pronto. Fizemos {count} substituições.", + "{count} cell(s) found" => "{count} célula(s) encontrada(s)", + "Reference:" => "Referência:", + "Special…" => "Especial…", + "My list has headers" => "Meus dados contêm cabeçalhos", + "Sort by" => "Classificar por", + "Then by" => "E depois por", + "Cell Values" => "Valores de Célula", + "Cell Color" => "Cor da Célula", + "A to Z" => "De A a Z", + "Z to A" => "De Z a A", + "+ Add Level" => "+ Adicionar Nível", + "New…" => "Novo…", + "Edit…" => "Editar…", + "Name" => "Nome", + "Value" => "Valor", + "Refers To" => "Refere-se a", + "Scope" => "Escopo", + "Workbook" => "Pasta de Trabalho", + "No names defined" => "Nenhum nome definido", + "Recommended Charts" => "Gráficos Recomendados", + "All Charts" => "Todos os Gráficos", + "Clustered Column" => "Colunas Agrupadas", + "Stacked Column" => "Colunas Empilhadas", + "100% Stacked Column" => "Colunas 100% Empilhadas", + "Clustered Bar" => "Barras Agrupadas", + "Stacked Bar" => "Barras Empilhadas", + "100% Stacked Bar" => "Barras 100% Empilhadas", + "Line" => "Linhas", + "Line with Markers" => "Linhas com Marcadores", + "Stacked Line" => "Linhas Empilhadas", + "Pie" => "Pizza", + "Doughnut" => "Rosca", + "Area" => "Área", + "Stacked Area" => "Área Empilhada", + "Scatter" => "Dispersão", + "Scatter with Lines" => "Dispersão com Linhas", + "Radar" => "Radar", + "Histogram" => "Histograma", + "Waterfall" => "Cascata", + "Funnel" => "Funil", + "Combo" => "Combinação", + "Chart data range:" => "Intervalo de dados do gráfico:", + "Legend Entries (Series)" => "Entradas de Legenda (Série)", + "Series{n}" => "Série{n}", + "Keep" => "Manter", + "Remove" => "Remover", + "Horizontal (Category) Axis Labels:" => "Rótulos do Eixo Horizontal (Categorias):", + "There are no conditional formatting rules on this sheet." => "Não há regras de formatação condicional nesta planilha.", + "applies to" => "aplica-se a", + "Type a command, e.g. freeze, chart, bold…" => "Digite um comando, p. ex. congelar, gráfico, negrito…", + "Set cell:" => "Definir célula:", + "To value:" => "Para valor:", + "By changing cell:" => "Alternando célula:", + "Goal Seeking found a solution: {value}" => "Atingir Meta encontrou uma solução: {value}", + "Analysis Tools" => "Ferramentas de Análise", + "Anova: Single Factor" => "Anova: fator único", + "Correlation" => "Correlação", + "Covariance" => "Covariância", + "Descriptive Statistics" => "Estatística descritiva", + "Exponential Smoothing" => "Ajuste exponencial", + "F-Test Two-Sample for Variances" => "Teste F: duas amostras para variâncias", + "Moving Average" => "Média móvel", + "Rank and Percentile" => "Ordem e percentil", + "Regression" => "Regressão", + "t-Test: Paired Two Sample for Means" => "Teste t: duas amostras em par para médias", + "t-Test: Two-Sample Assuming Equal Variances" => "Teste t: duas amostras presumindo variâncias equivalentes", + "t-Test: Two-Sample Assuming Unequal Variances" => "Teste t: duas amostras presumindo variâncias diferentes", + "Output options" => "Opções de saída", + "Output Range:" => "Intervalo de saída:", + "New Worksheet Ply:" => "Nova planilha:", + "Grouped By:" => "Agrupado por:", + "Labels in first row" => "Rótulos na primeira linha", + "Input Range:" => "Intervalo de entrada:", + "Variable 1 Range:" => "Intervalo da variável 1:", + "Variable 2 Range:" => "Intervalo da variável 2:", + "Summary statistics" => "Resumo estatístico", + "Confidence Level for Mean (%), blank for none:" => "Nível de confiabilidade para a média (%), em branco para nenhum:", + "Kth Largest (blank for none):" => "K-ésimo maior (em branco para nenhum):", + "Kth Smallest (blank for none):" => "K-ésimo menor (em branco para nenhum):", + "Bin Range (blank for automatic bins):" => "Intervalo do bloco (em branco para blocos automáticos):", + "Pareto (sorted histogram)" => "Pareto (histograma classificado)", + "Cumulative Percentage" => "Porcentagem cumulativa", + "Alpha:" => "Alfa:", + "Input Y Range:" => "Intervalo Y de entrada:", + "Input X Range:" => "Intervalo X de entrada:", + "Constant is Zero" => "Constante é zero", + "Confidence Level (%):" => "Nível de confiança (%):", + "Residuals" => "Resíduos", + "Standardized Residuals" => "Resíduos padronizados", + "Normal Probability" => "Plotagem de probabilidade normal", + "Interval:" => "Intervalo:", + "Standard Errors" => "Erros padrão", + "Damping factor:" => "Fator de amortecimento:", + "Hypothesized Mean Difference:" => "Hipótese da diferença de média:", + "Set Objective:" => "Definir Objetivo:", + "To:" => "Para:", + "Value Of:" => "Valor de:", + "By Changing Variable Cells:" => "Alterando Células Variáveis:", + "Subject to the Constraints:" => "Sujeito às Restrições:", + "Make Unconstrained Variables Non-Negative" => "Tornar Variáveis Irrestritas Não Negativas", + "Select a Solving Method:" => "Selecionar um Método de Solução:", + "GRG Nonlinear" => "GRG Não Linear", + "Solve" => "Resolver", + "Keep Solver Solution" => "Manter Solução do Solver", + "Restore Original Values" => "Restaurar Valores Originais", + "The spelling check is complete for the entire sheet." => "A verificação ortográfica da planilha inteira foi concluída.", + "Not in Dictionary ({cell}):" => "Não consta no dicionário ({cell}):", + "Suggestions:" => "Sugestões:", + "(No suggestions)" => "(Nenhuma sugestão)", + "Ignore Once" => "Ignorar uma vez", + "Add to Dictionary" => "Adicionar ao dicionário", + "Change" => "Alterar", + "Change All" => "Alterar todas", + "$ English (United States)" => "$ Inglês (Estados Unidos)", + "Add Chart Element" => "Adicionar Elemento Gráfico", + "Align Left" => "Alinhar à Esquerda", + "Align Right" => "Alinhar à Direita", + "Bottom Align" => "Alinhar Embaixo", + "Calculate Now (F9)" => "Calcular Agora (F9)", + "Calculate Sheet (⇧F9)" => "Calcular Planilha (⇧F9)", + "Calculation Options" => "Opções de Cálculo", + "Cell Styles" => "Estilos de Célula", + "Change Chart Type" => "Alterar Tipo de Gráfico", + "Check Accessibility" => "Verificar Acessibilidade", + "Clear Filter" => "Limpar Filtro", + "Column or Bar" => "Coluna ou Barra", + "Comments pane" => "Painel de comentários", + "Convert the last ink stroke to a shape" => "Converter o último traço de tinta em forma", + "Convert to Range" => "Converter em Intervalo", + "Copy" => "Copiar", + "Custom Sort" => "Classificação Personalizada", + "Cut" => "Recortar", + "Define Name…" => "Definir Nome…", + "Delete Comment" => "Excluir Comentário", + "Delete Sheet" => "Excluir Planilha", + "Delete chart" => "Excluir gráfico", + "Delete…" => "Excluir…", + "Dismiss" => "Fechar", + "Down" => "Para Baixo", + "Edit chart title" => "Editar título do gráfico", + "Erase ink strokes" => "Apagar traços de tinta", + "Every command run in this session (replayable)" => "Todos os comandos executados nesta sessão (podem ser repetidos)", + "Expand formula bar" => "Expandir barra de fórmulas", + "Filter (⌘⇧F)" => "Filtro (⌘⇧F)", + "Filter by Selected Cell's Value" => "Filtrar pelo Valor da Célula Selecionada", + "Find & Select" => "Localizar e Selecionar", + "Flash Fill (⌘E)" => "Preenchimento Relâmpago (⌘E)", + "Forecast Sheet" => "Planilha de Previsão", + "Forecast end (date or number; blank: a third of the history):" => "Fim da previsão (data ou número; em branco: um terço do histórico):", + "Format Chart pane" => "Painel Formatar Gráfico", + "Format as Table" => "Formatar como Tabela", + "Hide" => "Ocultar", + "Hide Sheet" => "Ocultar Planilha", + "Hierarchy & more" => "Hierarquia e mais", + "Highlighter" => "Marca-texto", + "How agents drive GridCraft (MCP / control channel)" => "Como os agentes controlam o GridCraft (MCP / canal de controle)", + "Import a text or CSV file" => "Importar um arquivo de texto ou CSV", + "Insert Function" => "Inserir Função", + "Insert Function (⇧F3)" => "Inserir Função (⇧F3)", + "Insert Link (⌘K)" => "Inserir Link (⌘K)", + "Insert Sheet" => "Inserir Planilha", + "Insert Slicer" => "Inserir Segmentação de Dados", + "Insert a picture" => "Inserir uma imagem", + "Insert a slicer to filter a table" => "Inserir uma segmentação de dados para filtrar uma tabela", + "Insert an icon" => "Inserir um ícone", + "Insert…" => "Inserir…", + "Line or Area" => "Linha ou Área", + "Link…" => "Link…", + "Macros and scripts in this workbook (Alt+F8)" => "Macros e scripts nesta pasta de trabalho (Alt+F8)", + "Middle Align" => "Alinhar no Meio", + "More commands" => "Mais comandos", + "Move or Copy Sheet…" => "Mover ou Copiar Planilha…", + "New sheet (⇧F11)" => "Nova planilha (⇧F11)", + "Next sheet" => "Próxima planilha", + "Page Breaks" => "Quebras", + "Paper Size" => "Tamanho", + "Paste (⌘V)" => "Colar (⌘V)", + "Pick From Drop-down List…" => "Escolher na Lista Suspensa…", + "Pie or Doughnut" => "Pizza ou Rosca", + "Previous sheet" => "Planilha anterior", + "Print Area" => "Área de Impressão", + "Print Titles" => "Imprimir Títulos", + "Protect Workbook structure" => "Proteger estrutura da pasta de trabalho", + "Redo (⌘Y)" => "Refazer (⌘Y)", + "Save (⌘S)" => "Salvar (⌘S)", + "Scatter or Bubble" => "Dispersão ou Bolhas", + "Search and run any command" => "Pesquisar e executar qualquer comando", + "Search commands (⌘⇧Space)" => "Pesquisar comandos (⌘⇧Space)", + "Select and move ink and shapes" => "Selecionar e mover tinta e formas", + "Series…" => "Série…", + "Share: export or save a copy" => "Compartilhar: exportar ou salvar uma cópia", + "Show Formulas (⌃`)" => "Mostrar Fórmulas (⌃`)", + "Slicer" => "Segmentação de Dados", + "Sort & Filter" => "Classificar e Filtrar", + "Spelling (F7)" => "Ortografia (F7)", + "Statistic" => "Estatístico", + "Switch Row/Column" => "Alternar Linha/Coluna", + "Table (⌘T)" => "Tabela (⌘T)", + "Table Styles" => "Estilos de Tabela", + "Text to Columns" => "Texto para Colunas", + "The output range already holds data. Replace it with the report?" => "O intervalo de saída já contém dados. Substituí-los pelo relatório?", + "Top Align" => "Alinhar em Cima", + "Undo (⌘Z)" => "Desfazer (⌘Z)", + "Undo list" => "Lista de desfazer", + "Unhide Sheet…" => "Reexibir Planilha…", + "Up" => "Para Cima", + "Version, contributors and the AI models that helped" => "Versão, colaboradores e os modelos de IA que ajudaram", + "What-If Analysis" => "Teste de Hipóteses", + "Zoom In" => "Ampliar", + "Zoom Out" => "Reduzir", + "Zoom to 100%" => "Zoom em 100%", + "Zoom to Selection" => "Zoom na Seleção", + "Zoom…" => "Zoom…", + "£ English (United Kingdom)" => "£ Inglês (Reino Unido)", + "¥ Japanese" => "¥ Japonês", + "€ Euro" => "€ Euro", + "Tab|Insert" => "Inserir", + "AutoSave" => "Salvamento Automático", + "Insert\nSlicer" => "Inserir\nSegmentação", + "Pen\nBlack" => "Caneta\nPreta", + "Pen\nBlue" => "Caneta\nAzul", + "Pen\nRed" => "Caneta\nVermelha", + "Protect\nSheet" => "Proteger\nPlanilha", + "Watch\nWindow" => "Janela de\nInspeção", + "Workbook\nStatistics" => "Estatísticas da\nPasta de Trabalho", _ => return None, }) } diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e7d1de9f7..d11b5fac1 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German ribbon catalog added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Brazilian Portuguese catalog: dialogs, menus and tooltips) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -48,7 +48,7 @@ separators, number/date formats, machine translation with agent review, and scri | Hindi | hi | 0 (0%) | no | Devanagari: **no shaping** | no | none | 12–18 h | | Arabic | ar | 0 (0%) | no | **no shaping, no RTL UI**; Sheet Right-to-Left mirrors the grid (#146) | no | none | 25–35 h | | French | fr | 0 (0%) | no | Latin: yes | no | none | 6–10 h | -| Portuguese | pt-BR | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h | +| Portuguese | pt-BR | 923 (~42%) | dialogs and tooltips (dialogs take effect with #669) | Latin: yes | no | menus, File view, dialogs | 2–4 h | | Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h | | Japanese | ja | 268 (~12%) | no | CJK system-font fallback; IME unverified; no furigana display | no | menus only | 8–12 h | | German | de | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h | @@ -77,6 +77,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | | 2026-10-11 | minor | Sheet Right-to-Left mirrors the grid (#146) | | 2026-10-11 | minor | Czech (cs) ribbon table: 266 labels (~12%), menus only | From ea5db94fe320a131b75c39cc5483a4d98e3900bc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:35:01 +0200 Subject: [PATCH 32/86] Numfmt: a negative number that rounds to zero shows no minus sign (fixes #729) --- crates/numfmt/src/lib.rs | 3 ++- crates/numfmt/src/render.rs | 5 +++++ crates/numfmt/src/tests.rs | 27 ++++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 674e84446..9fcc1468d 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -261,7 +261,8 @@ impl NumberFormat { let Some(mut out) = out else { return (Formatted { text: OVERFLOW.into(), color: sec.color, fill: None, numeric: true }, true); }; - if minus && sec.kind != SecKind::Text { + // Excel drops the minus sign when the first section shows a negative number as all zeros. + if minus && sec.kind != SecKind::Text && !out.zero { out.prepend("-"); } (Formatted { text: out.text, color: sec.color, fill: out.fill, numeric: true }, false) diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 7befe591a..d6c7d67f0 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -11,6 +11,8 @@ use crate::parse::{DatePart, Section, Tok}; pub(crate) struct Out { pub text: String, pub fill: Option<(char, usize)>, + /// True when every digit shown is zero (a number section that rounded to zero). + pub zero: bool, } impl Out { @@ -247,6 +249,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let mut pieces: Vec = vec![String::new(); n]; let mut before: Vec = vec![String::new(); n + 1]; let mut lits: Vec> = Vec::new(); + let mut zero = false; if sec.fraction { lits = vec![None; n]; render_fraction(sec, x, &mut pieces, &mut lits); @@ -310,6 +313,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { } else { fixed(x, frac_idx.len()) }; + zero = int_s.chars().chain(frac_s.chars()).all(|c| c == '0'); fill_int(toks, &int_idx, &int_s, sec.thousands, false, &mut pieces); if int_idx.is_empty() && !int_s.is_empty() @@ -338,6 +342,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { _ => out.push(pieces.get(i).map(String::as_str).unwrap_or("")), } } + out.zero = zero; out } diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 6c2306c4f..1cd88ef70 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -24,7 +24,7 @@ fn numbers() { assert_eq!(f("0", -2.5), "-3"); assert_eq!(f("0.00", 2.675), "2.68"); assert_eq!(f("0.00", 1.005), "1.01"); - assert_eq!(f("0.0", -0.04), "-0.0"); + assert_eq!(f("0.0", -0.04), "0.0"); assert_eq!(f("00000", 123.0), "00123"); assert_eq!(f("000-00-0000", 123456789.0), "123-45-6789"); assert_eq!(f("#.##", 0.5), ".5"); @@ -105,6 +105,29 @@ fn scientific() { assert_eq!(f("0.00E+00", -12345.0), "-1.23E+04"); } +/// A negative number that rounds to zero in the first section shows no minus sign; a negative +/// section of its own keeps its text (observed Excel results). +#[test] +fn negative_rounding_to_zero_drops_the_sign() { + let cases: &[(&str, f64, &str)] = &[ + ("0.00", -0.0033, "0.00"), + ("0.00", -2.89e-9, "0.00"), + ("0.00", -0.005, "-0.01"), + ("0", -0.4, "0"), + ("0", -0.5, "-1"), + ("0.0", -0.004, "0.0"), + ("#,##0.00", -0.004, "0.00"), + ("0.00%", -2.89e-9, "0.00%"), + ("0.00%", -0.0033, "-0.33%"), + ("0.00;(0.00)", -0.0033, "(0.00)"), + ("0.00;-0.00;\"zero\"", -0.0033, "-0.00"), + ("0.00E+00", -2.89e-9, "-2.89E-09"), + ]; + for &(code, n, want) in cases { + assert_eq!(f(code, n), want, "{code} with {n}"); + } +} + #[test] fn fractions() { assert_eq!(f("# ?/?", 1.5), "1 1/2"); @@ -340,6 +363,8 @@ fn text_fn() { assert_eq!(text_function(&Value::Number(5.0), "", S), Ok("".into())); assert_eq!(text_function(&Value::Number(-1.0), "m/d/yyyy", S), Err(CellError::Value)); assert_eq!(text_function(&Value::Bool(true), "0", S), Ok("TRUE".into())); + assert_eq!(text_function(&Value::Number(-0.0033), "0", S), Ok("0".into())); + assert_eq!(text_function(&Value::Number(-0.4), "0", S), Ok("0".into())); // Booleans go through the text section as TRUE/FALSE (observed Excel results). let cases: &[(bool, &str, &str)] = &[ (true, ";;;@", "TRUE"), From 15b346e6caf13bc749d7e89cbaab6b308dd8f607 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:36:00 +0200 Subject: [PATCH 33/86] macOS: register the Window menu as NSApp's windowsMenu, so the tiling shortcuts work (fixes #716) --- Cargo.lock | 2 ++ apps/gridcraft/Cargo.toml | 10 ++++++++ apps/gridcraft/src/native_menu.rs | 10 ++++++++ apps/gridcraft/tests/mac_menu_appkit.rs | 34 +++++++++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 apps/gridcraft/tests/mac_menu_appkit.rs diff --git a/Cargo.lock b/Cargo.lock index ade3a5adb..1815ac7e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1508,6 +1508,8 @@ dependencies = [ "image", "log", "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "pollster 1.0.1", "rfd", "serde_json", diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index c867d24a2..cd8311b34 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -42,3 +42,13 @@ muda = { version = "0.21", default-features = false } # by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our # workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" + +# tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit); +# pinned exactly to the same versions. +[target.'cfg(target_os = "macos")'.dev-dependencies] +objc2 = "=0.6.5" +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSMenu", "NSMenuItem", "NSResponder"] } + +[[test]] +name = "mac_menu_appkit" +harness = false diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index b127543c8..14692421d 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -215,6 +215,7 @@ impl NativeMenu { ]); let _ = menu.append(&app_menu); let mut theme_items = Vec::new(); + let mut window_menu = None; for (title, entries) in tree() { let sub = Submenu::new(title, true); for (label, id, acc) in entries { @@ -237,8 +238,17 @@ impl NativeMenu { let _ = sub.append(&item); } let _ = menu.append(&sub); + if title == "Window" { + window_menu = Some(sub); + } } menu.init_for_nsapp(); + // NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the + // system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks + // the submenu up in the installed main menu. + if let Some(window) = window_menu { + window.set_as_windows_menu_for_nsapp(); + } let (tx, rx) = channel(); let ctx = ctx.clone(); MenuEvent::set_event_handler(Some(move |e: MenuEvent| { diff --git a/apps/gridcraft/tests/mac_menu_appkit.rs b/apps/gridcraft/tests/mac_menu_appkit.rs new file mode 100644 index 000000000..e713f6699 --- /dev/null +++ b/apps/gridcraft/tests/mac_menu_appkit.rs @@ -0,0 +1,34 @@ +//! The macOS menu bar as AppKit sees it, on the main thread (`harness = false`): install it through +//! the real muda backend (`src/native_menu.rs`) and read NSApp back. The Window menu must be NSApp's +//! `windowsMenu`: that is where macOS adds Move & Resize, Fill and Center, the targets of the system +//! window tiling shortcuts (Fn+Ctrl+arrows, Fn+F), and lists the open windows (#716). + +// The app's own module, built into this test (cargo builds harness = false tests with cfg(test), so its +// unit tests compile here too without a harness to run them). +#[cfg(target_os = "macos")] +#[path = "../src/native_menu.rs"] +#[allow(unused_imports, dead_code)] +mod native_menu; + +#[cfg(target_os = "macos")] +fn main() { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + + let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread"); + let nsapp = NSApplication::sharedApplication(mtm); + nsapp.finishLaunching(); + + let ctx = egui::Context::default(); + let _menu = native_menu::NativeMenu::install(&ctx); + + let bar = nsapp.mainMenu().expect("a main menu"); + let tops: Vec = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect(); + assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]); + assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu"); + + println!("mac_menu_appkit: ok"); +} + +#[cfg(not(target_os = "macos"))] +fn main() {} From 9e28655a8a0bcba6fc18a49db815ed99ac9e6fd3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:37:51 +0200 Subject: [PATCH 34/86] ROMAN: forms 2 and 3 subtract IC, XM and VM like Excel (fixes #733) --- crates/functions/src/math.rs | 47 +++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index bab219ead..b9aebad16 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -634,16 +634,18 @@ fn radians(a: &[Arg], _c: &mut dyn Ctx) -> R { const NUMERALS: [(char, u32); 7] = [('I', 1), ('V', 5), ('X', 10), ('L', 50), ('C', 100), ('D', 500), ('M', 1000)]; -/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; higher forms -/// allow subtracting progressively smaller numerals (max ratio 10, 20, 50, 100, 1000). +/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; each higher +/// form widens one of two limits on the subtracted numeral's ratio to the larger one, alternating: +/// the limit for I/X/C (10, 10, 100, 100, 1000) and for V/L (none, 20, 20, 200, 200). So form 2 +/// allows IC and XM but not VD, and form 3 adds VD and VM but not ID. fn roman_tokens(form: u32) -> Vec<(String, u32)> { - let max_ratio = [10, 20, 50, 100, 1000].get(form as usize).copied().unwrap_or(10); + let (pow10_max, five_max) = [(10, 0), (10, 20), (100, 20), (100, 200), (1000, 200)].get(form as usize).copied().unwrap_or((10, 0)); let mut toks: Vec<(String, u32)> = NUMERALS.iter().map(|(c, v)| (c.to_string(), *v)).collect(); for (i, (sc, sv)) in NUMERALS.iter().enumerate() { for (lc, lv) in NUMERALS.iter().skip(i + 1) { let ratio = lv / sv; let pow10 = matches!(sc, 'I' | 'X' | 'C'); - let ok = if form == 0 { pow10 && ratio <= 10 } else { ratio <= max_ratio }; + let ok = ratio <= if pow10 { pow10_max } else { five_max }; let value = lv - sv; // Skip pairs that equal a single numeral (VX = V) – they are never shorter. if ok && !NUMERALS.iter().any(|(_, v)| *v == value) { @@ -1553,6 +1555,43 @@ mod tests { is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num); } + /// Every number where Excel's forms 2 and 3 differ from a plain ratio limit (issue #733): + /// form 2 subtracts I from C and X from M, form 3 also V from M. + #[test] + fn roman_forms_two_and_three_match_excel() { + const HUNDREDS: [&str; 10] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"]; + const ONES: [&str; 10] = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; + let mut cases = Vec::new(); + for thousands in 0..4usize { + let m = "M".repeat(thousands); + for (h, hundreds) in HUNDREDS.iter().enumerate() { + if h != 4 && h != 9 { + cases.push((thousands * 1000 + h * 100 + 99, 2.0, format!("{m}{hundreds}IC"))); + } + } + for (o, ones) in ONES.iter().enumerate() { + cases.push((thousands * 1000 + 990 + o, 2.0, format!("{m}XM{ones}"))); + } + for (o, ones) in ONES.iter().enumerate().take(5) { + cases.push((thousands * 1000 + 995 + o, 3.0, format!("{m}VM{ones}"))); + } + } + assert_eq!(cases.len(), 92); + for (number, form, want) in &cases { + is_text(ev2("ROMAN", *number as f64, *form), want); + } + // Neighbours that already matched: form 2 still has no VD or ID, form 3 no ID. + is_text(ev2("ROMAN", 495.0, 2.0), "XDV"); + is_text(ev2("ROMAN", 495.0, 3.0), "VD"); + is_text(ev2("ROMAN", 1499.0, 2.0), "MXDIX"); + is_text(ev2("ROMAN", 999.0, 0.0), "CMXCIX"); + is_text(ev2("ROMAN", 999.0, 1.0), "LMVLIV"); + is_text(ev2("ROMAN", 999.0, 4.0), "IM"); + is_text(ev2("ROMAN", 995.0, 4.0), "VM"); + is_text(ev2("ROMAN", 99.0, 1.0), "VCIV"); + is_text(ev2("ROMAN", 99.0, 4.0), "IC"); + } + #[test] fn matrices() { let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]); From 0dc76090d7c435c1451fe41998ce860e65d07f7e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:38:01 +0200 Subject: [PATCH 35/86] functions: MODE skips typed text and logical arguments (fixes #736) --- crates/functions/src/stat.rs | 52 ++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 25449bd06..4afa10825 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -79,10 +79,11 @@ fn mode_sngl(v: &[f64]) -> R { } /// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers -/// count, like [`numbers`]. Scalar arguments are stricter than in SUM: Excel answers `#VALUE!` -/// for direct text (even numeric text such as `"1"`) and for a single-cell reference to text or -/// a blank cell (observed: `MODE(B8,"1",2,3)` and `MODE(B9,2,D7)` with D7 blank are `#VALUE!`). -/// Arguments are processed left to right, so the first error wins. +/// count, like [`numbers`]. Scalar arguments differ from SUM: text and logical values are skipped +/// whether typed or referenced (even numeric text such as `"1"`, so `MODE(1,"1",2,3)` is `#N/A`, +/// and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference to a blank cell is +/// `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to right, so the +/// first error wins. fn mode_numbers(args: &[Arg]) -> R> { let mut out = Vec::new(); for a in args { @@ -98,9 +99,8 @@ fn mode_numbers(args: &[Arg]) -> R> { } Value::Number(n) => out.push(*n), Value::Error(e) => return Err(*e), - Value::Text(_) | Value::Empty => return Err(CellError::Value), - Value::Bool(_) if a.from_ref => {} - v => out.push(v.to_number()?), + Value::Text(_) | Value::Bool(_) => {} + Value::Empty => return Err(CellError::Value), } } Ok(out) @@ -2279,9 +2279,9 @@ mod tests { assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); } - /// MODE argument semantics observed in Excel (FormulaEvalTestData, row "Mode"): multi-cell - /// ranges skip text and blanks, but direct text and single-cell references to text or blank - /// cells are `#VALUE!`; the first error from the left wins. + /// MODE argument semantics from the Excel-cached results in FormulaEvalTestData (row "Mode"): + /// text is skipped, typed or referenced, but a single-cell reference to a blank cell is + /// `#VALUE!`; the first error from the left wins. #[test] fn mode_scalar_arguments() { let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]); @@ -2290,9 +2290,9 @@ mod tests { (vec![rf(data)], Ok(1.0)), (vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)), // MODE(C9,E16,"1",B8): E16 holds the text "1.1". - (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::Value)), - (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::Value)), - (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Value)), + (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::NA)), + (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), // MODE(B9,2,D7) with D7 blank. (vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)), (vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)), @@ -2305,7 +2305,31 @@ mod tests { } } } - is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), n(1.0)]), CellError::Value); + is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), rf(Value::Empty)]), CellError::Value); + } + + /// Text and logical values typed as arguments are skipped, not coerced (Excel 365, #736). + #[test] + fn mode_skips_typed_text_and_logicals() { + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(1.0), n(1.0), t("x")], Ok(1.0)), + (vec![n(1.0), n(2.0), t("x")], Err(CellError::NA)), + (vec![n(1.0), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), + (vec![n(1.0), b(true), b(true), n(2.0)], Err(CellError::NA)), + (vec![n(1.0), n(1.0), b(true)], Ok(1.0)), + ]; + for name in ["MODE", "MODE.SNGL"] { + for (args, want) in &cases { + match want { + Ok(x) => close(ev(name, args.clone()), *x), + Err(err) => is_err(ev(name, args.clone()), *err), + } + } + } + assert_eq!(rows_of(&ev("MODE.MULT", vec![n(1.0), t("1"), n(1.0), t("x")])), vec![vec![nv(1.0)]]); + is_err(ev("MODE.MULT", vec![n(1.0), b(true), b(true), n(2.0)]), CellError::NA); } #[test] From 3d0ed97d71d627b37d792e452a2f05b7eda02c10 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:41:45 +0200 Subject: [PATCH 36/86] XLS: a FORMAT record shorter than its character count keeps the characters it holds (fixes #731) --- crates/xlsx/src/tests/xls_tests.rs | 36 ++++++++++++++++++++++++++++++ crates/xlsx/src/xls/mod.rs | 14 +++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795ec..b7e2c1f8f 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -476,3 +476,39 @@ fn sheets_share_one_record_budget() { let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); assert!(err.contains("same substream"), "{err}"); } + +#[test] +fn a_format_shorter_than_its_count_keeps_its_characters() { + // A FORMAT whose cch is one more than the characters in the record (seen in real files): + // Excel opens them, the record's characters are the format code. + let short = rec(0x041E, &cat(&[&165u16.to_le_bytes(), &6u16.to_le_bytes(), &[0], b"0.000"])); + let xf = |ifmt: u16| rec(0x00E0, &cat(&[&u16s(&[0, ifmt, 0]), &[0; 14]])); + let globals = |pos: u32| { + cat(&[ + &bof(0x0005), + &short, + &rec(0x041E, &cat(&[&164u16.to_le_bytes(), &xlstr("yyyy-mm-dd")])), + &xf(0), + &xf(164), + &xf(165), + &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), + &eof(), + ]) + }; + let sheet = cat(&[ + &bof(0x0010), + &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(1, 0, 1), &0.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(2, 0, 2), &1.25f64.to_le_bytes()])), + &eof(), + ]); + let stream = cat(&[&globals(globals(0).len() as u32), &sheet]); + let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap(); + let sh = &wb.sheets[0]; + let fmt = |a1: &str| wb.styles.get(sh.style_id(CellRef::parse(a1).unwrap())).num_fmt.as_str().to_string(); + assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0)); + assert_eq!(v(&wb, 0, "A3"), Value::Number(1.25)); + assert_eq!(fmt("A2"), "yyyy-mm-dd"); + assert_eq!(fmt("A3"), "0.000"); + assert!(report.warnings.iter().any(|w| w.contains("declared length")), "{:?}", report.warnings); +} diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66a3..78f7a42de 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -143,7 +143,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo } }; let mut report = ReadReport::default(); - let g = globals(&stream, &mut budget)?; + let g = globals(&stream, &mut budget, &mut report)?; let mut wb = Workbook::new(); wb.sheets.clear(); @@ -205,7 +205,7 @@ fn bof(rec: &Record<'_>) -> Result<(u16, u16), IoError> { Ok((c.u16()?, c.u16()?)) } -fn globals(stream: &[u8], budget: &mut Budget) -> Result { +fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Result { let mut recs = Records::new(stream, 0); let first = recs.next_record()?.ok_or_else(|| invalid("empty Workbook stream"))?; let (vers, dt) = bof(&first)?; @@ -249,7 +249,15 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { 0x0022 => g.date1904 = c.u16()? == 1, 0x041E => { let ifmt = c.u16()?; - let code = c.string()?; + let cch = usize::from(c.u16()?); + let high = c.u8()? & 1 != 0; + // Some writers count one character more than the record holds; Excel opens + // those files, so keep the characters that are there. + let fit = c.remaining() / if high { 2 } else { 1 }; + if cch > fit { + report.warn("Some number formats were shorter than their declared length; the characters present were kept."); + } + let code = c.chars(cch.min(fit), high)?; budget.text(code.len())?; g.formats.insert(ifmt, code); } From dbfed89a36163dce5d6cb33b808fb260ea003ea2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:43:28 +0200 Subject: [PATCH 37/86] XLS import: read the iterative calculation settings (part of #724) CalcIter, CalcCount and CalcDelta repeat in every worksheet substream and were skipped, so a workbook saved with iterative calculation on opened with it off and its circular references showed #CIRC!. They now set wb.calc from the first worksheet, as does for .xlsx. --- crates/xlsx/src/tests/xls_tests.rs | 19 +++++++++++++++++++ crates/xlsx/src/xls/mod.rs | 23 ++++++++++++++++------- docs/file-format-parity.md | 5 +++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795ec..5f8073443 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -476,3 +476,22 @@ fn sheets_share_one_record_budget() { let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); assert!(err.contains("same substream"), "{err}"); } + +#[test] +fn reads_iterative_calculation_settings() { + // CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only the first + // sheet carries them, so the sheet after it must not reset them to the defaults. + let calc = cat(&[&rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); + let sheet1 = cat(&[&bof(0x0010), &calc, &eof()]); + let sheet2 = cat(&[&bof(0x0010), &eof()]); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); + let p1 = globals(0, 0).len() as u32; + let p2 = p1 + sheet1.len() as u32; + let (wb, _) = read_xls(&compound("Workbook", &cat(&[&globals(p1, p2), &sheet1, &sheet2]))).unwrap(); + assert!(wb.calc.iterative); + assert_eq!((wb.calc.max_iterations, wb.calc.max_change), (50, 0.01)); + // Without the records: Excel's defaults, iteration off. + let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap(); + assert_eq!(wb.calc, gridcraft_model::CalcSettings::default()); +} diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66a3..df27a73c3 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -4,9 +4,9 @@ //! //! Read: worksheets, shared strings, cell values, formulas (text and cached results, including //! shared and array formulas), number formats, the 1904 date system, merged cells, column -//! widths, row heights, defined names, print areas and print titles. Everything else (fonts, -//! fills, borders, charts, pictures, comments, conditional formats, validation, macros) is -//! skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. +//! widths, row heights, defined names, print areas, print titles and the iterative calculation +//! settings. Everything else (fonts, fills, borders, charts, pictures, comments, conditional +//! formats, validation, macros) is skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. mod formula; mod ftab; @@ -16,7 +16,7 @@ use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; +use gridcraft_model::{CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; use crate::cfb::Compound; use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; @@ -175,12 +175,16 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo continue; } wb.check_sheet_name(&bs.name, None).map_err(invalid)?; - let Some(mut sheet) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { + let Some((mut sheet, calc)) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { report.warn("Dialog sheets and unreadable sheets were skipped."); model_index.push(None); continue; }; sheet.visibility = bs.visibility; + // Every worksheet repeats the workbook's iteration settings; Excel applies the first. + if wb.sheets.is_empty() { + wb.calc = calc; + } model_index.push(Some(wb.sheets.len())); wb.sheets.push(Arc::new(sheet)); } @@ -393,6 +397,7 @@ struct SheetState<'g> { pending_string: Option, dropped: usize, unsupported: Vec<&'static str>, + calc: CalcSettings, } impl SheetState<'_> { @@ -462,7 +467,7 @@ fn worksheet( styles: &[StyleId], budget: &mut Budget, report: &mut ReadReport, -) -> Result, IoError> { +) -> Result, IoError> { if bs.pos >= stream.len() { return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name))); } @@ -483,6 +488,7 @@ fn worksheet( pending_string: None, dropped: 0, unsupported: vec![], + calc: CalcSettings::default(), }; let mut depth = 1usize; while let Some(rec) = recs.next_record()? { @@ -525,7 +531,7 @@ fn worksheet( for why in &st.unsupported { report.warn(format!("Some formulas use {why}; their saved results were kept as values.")); } - Ok(Some(st.sheet)) + Ok(Some((st.sheet, st.calc))) } fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> { @@ -620,6 +626,9 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, st.arrays.insert(at, (range, f)); } } + 0x000C => st.calc.set_max_iterations(u32::from(c.u16()?)), + 0x0011 => st.calc.iterative = c.u16()? == 1, + 0x0010 => st.calc.set_max_change(c.f64()?), 0x0236 => report.warn("What-if data tables are not supported; their values were kept."), 0x00E5 => { let n = usize::from(c.u16()?); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 851065956..ad458ca1e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the iterative calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -22,7 +22,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles. Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, iterative calculation settings (enabled, maximum iterations, maximum change). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -78,6 +78,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | `.xls` import reads the iterative calculation settings (CalcIter, CalcCount, CalcDelta), so circular models calculate instead of showing `#CIRC!` (#724) | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | From a34f106e9327ab1872bbec9e6bf21326b01dfa6a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:44:23 +0200 Subject: [PATCH 38/86] XLS: open compound files whose last sector isn't padded (fixes #730) --- crates/xlsx/src/cfb.rs | 83 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/crates/xlsx/src/cfb.rs b/crates/xlsx/src/cfb.rs index 3a7471852..5f85e4301 100644 --- a/crates/xlsx/src/cfb.rs +++ b/crates/xlsx/src/cfb.rs @@ -209,13 +209,19 @@ impl<'a> Compound<'a> { } /// Follows a FAT chain from `start`, reading at most `limit` bytes. A chain longer than the - /// FAT (so a cycle) or pointing outside the file is an error. + /// FAT (so a cycle) or pointing outside the file is an error. The file may end inside the + /// chain's last sector (some writers don't pad it, and Excel opens those files): that sector + /// reads short, and the caller checks the stream's declared size against what was read. fn read_chain(&self, start: u32, limit: u64) -> Result, String> { let limit = usize::try_from(limit.min(MAX_STREAM)).unwrap_or(usize::MAX); let mut out = Vec::new(); let mut id = start; let mut seen = vec![false; self.fat.len()]; + let mut cut = false; while id != END_OF_CHAIN && out.len() < limit { + if cut { + return Err("damaged compound file: sector out of range (the file may be truncated)".into()); + } if id >= MAX_REG_SECT { return Err("damaged compound file: broken sector chain".into()); } @@ -226,20 +232,26 @@ impl<'a> Compound<'a> { } let start = (id as usize).checked_add(1).and_then(|i| i.checked_mul(self.sector_size)); let s = start - .and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?)) + .and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?.min(self.bytes.len()))) .ok_or("damaged compound file: sector out of range (the file may be truncated)")?; + cut = s.len() < self.sector_size; out.extend_from_slice(s); id = *self.fat.get(id as usize).ok_or("damaged compound file: sector missing from the FAT")?; } Ok(out) } - /// Follows a mini FAT chain inside the mini stream container. + /// Follows a mini FAT chain inside the mini stream container. Like `read_chain`, the + /// container may end inside the chain's last mini sector. fn read_mini_chain(&self, container: &[u8], start: u32, limit: usize) -> Result, String> { let mut out = Vec::new(); let mut id = start; let mut seen = vec![false; self.mini_fat.len()]; + let mut cut = false; while id != END_OF_CHAIN && out.len() < limit { + if cut { + return Err("damaged compound file: mini sector out of range".into()); + } if id >= MAX_REG_SECT { return Err("damaged compound file: broken mini sector chain".into()); } @@ -249,8 +261,10 @@ impl<'a> Compound<'a> { None => return Err("damaged compound file: mini sector missing from the mini FAT".into()), } let at = (id as usize).checked_mul(self.mini_sector_size); - let s = - at.and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?)).ok_or("damaged compound file: mini sector out of range")?; + let s = at + .and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?.min(container.len()))) + .ok_or("damaged compound file: mini sector out of range")?; + cut = s.len() < self.mini_sector_size; out.extend_from_slice(s); id = *self.mini_fat.get(id as usize).ok_or("damaged compound file: mini sector missing from the mini FAT")?; } @@ -379,4 +393,63 @@ pub(crate) mod tests { b[516..520].copy_from_slice(&1u32.to_le_bytes()); assert!(!is_encrypted(&b)); } + + /// `compound_file` with one regular-sector stream of `len` bytes (FAT in sector 0, directory + /// in sector 1, the stream from sector 2), cut right after the stream's last byte, the way + /// some writers leave the final sector unpadded. + fn unpadded(len: usize) -> (Vec, Vec) { + let data: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + let mut file = crate::testing::compound_file(&[("Workbook", &data)]); + file.truncate(512 * 3 + len); + (file, data) + } + + #[test] + fn an_unpadded_last_sector_still_reads() { + let (file, data) = unpadded(5000); + assert_ne!(file.len() % 512, 0); + let cf = Compound::open(&file).unwrap(); + assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data); + // Bytes the stream needs that the file doesn't hold are still damage. + let cf = Compound::open(&file[..file.len() - 1]).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } + + #[test] + fn an_unpadded_last_mini_sector_still_reads() { + let data: Vec = (0..1000u32).map(|i| (i % 251) as u8).collect(); + let mut file = crate::testing::compound_file(&[("Workbook", &data)]); + // Header, FAT, directory, mini FAT, then the mini stream holding the stream's bytes. + file.truncate(512 * 4 + data.len()); + let cf = Compound::open(&file).unwrap(); + assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data); + let cf = Compound::open(&file[..file.len() - 1]).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } + + #[test] + fn a_cut_sector_inside_a_chain_is_damage() { + let (mut file, _) = unpadded(5000); + // The cut sector (11) points on to the directory sector and the stream claims those + // bytes too: the partial sector would splice 392 bytes into the middle of the stream. + file[512 + 11 * 4..512 + 12 * 4].copy_from_slice(&1u32.to_le_bytes()); + file[1024 + 128 + 120..1024 + 128 + 124].copy_from_slice(&5512u32.to_le_bytes()); + let cf = Compound::open(&file).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } + + #[test] + fn a_cut_mini_sector_inside_a_chain_is_damage() { + let small = [7u8; 100]; + let data: Vec = (0..1000u32).map(|i| (i % 251) as u8).collect(); + let mut file = crate::testing::compound_file(&[("Small", &small), ("Workbook", &data)]); + // "Small" holds mini sectors 0-1, "Workbook" 2-17; the mini stream starts at sector 3. + file.truncate(512 * 4 + 128 + data.len()); + assert_eq!(Compound::open(&file).unwrap().root_stream("Workbook").unwrap().unwrap(), data); + // The cut mini sector (17) points on to sector 0 and the stream claims those bytes too. + file[1536 + 17 * 4..1536 + 18 * 4].copy_from_slice(&0u32.to_le_bytes()); + file[1024 + 2 * 128 + 120..1024 + 2 * 128 + 124].copy_from_slice(&1064u32.to_le_bytes()); + let cf = Compound::open(&file).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } } From f3e05e0ad8b4fa9bc1cc5c5f4c6bc8f632c07f01 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:47:53 +0200 Subject: [PATCH 39/86] XLS import: read the calculation mode and precision as displayed (fixes #724) CalcMode repeats in every worksheet substream like the iteration records and sets wb.calc.mode from the first worksheet; CalcPrecision sits in the globals and sets precision_as_displayed, matching calcMode and fullPrecision on the .xlsx side. --- crates/xlsx/src/tests/xls_tests.rs | 11 +++++++---- crates/xlsx/src/xls/mod.rs | 21 ++++++++++++++++----- docs/file-format-parity.md | 6 +++--- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index 5f8073443..f284188aa 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -479,18 +479,21 @@ fn sheets_share_one_record_budget() { #[test] fn reads_iterative_calculation_settings() { - // CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only the first - // sheet carries them, so the sheet after it must not reset them to the defaults. - let calc = cat(&[&rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); + // CalcMode, CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only + // the first sheet carries them, so the sheet after it must not reset them to the defaults. + // CalcPrecision sits in the globals. + let calc = cat(&[&rec(0x000D, &u16s(&[0])), &rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); let sheet1 = cat(&[&bof(0x0010), &calc, &eof()]); let sheet2 = cat(&[&bof(0x0010), &eof()]); let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); - let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); + let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &rec(0x000E, &u16s(&[0])), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); let p1 = globals(0, 0).len() as u32; let p2 = p1 + sheet1.len() as u32; let (wb, _) = read_xls(&compound("Workbook", &cat(&[&globals(p1, p2), &sheet1, &sheet2]))).unwrap(); assert!(wb.calc.iterative); assert_eq!((wb.calc.max_iterations, wb.calc.max_change), (50, 0.01)); + assert_eq!(wb.calc.mode, gridcraft_model::CalcMode::Manual); + assert!(wb.calc.precision_as_displayed); // Without the records: Excel's defaults, iteration off. let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap(); assert_eq!(wb.calc, gridcraft_model::CalcSettings::default()); diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index df27a73c3..407a4a464 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -4,9 +4,10 @@ //! //! Read: worksheets, shared strings, cell values, formulas (text and cached results, including //! shared and array formulas), number formats, the 1904 date system, merged cells, column -//! widths, row heights, defined names, print areas, print titles and the iterative calculation -//! settings. Everything else (fonts, fills, borders, charts, pictures, comments, conditional -//! formats, validation, macros) is skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. +//! widths, row heights, defined names, print areas, print titles and the calculation settings +//! (mode, iteration, precision as displayed). Everything else (fonts, fills, borders, charts, +//! pictures, comments, conditional formats, validation, macros) is skipped with a warning. +//! Encrypted workbooks and pre-BIFF8 versions are refused. mod formula; mod ftab; @@ -16,7 +17,7 @@ use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; +use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; use crate::cfb::Compound; use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; @@ -123,6 +124,7 @@ struct Globals { sst: Vec>, links: Links, names: Vec, + precision_as_displayed: bool, } /// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. @@ -181,7 +183,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo continue; }; sheet.visibility = bs.visibility; - // Every worksheet repeats the workbook's iteration settings; Excel applies the first. + // Every worksheet repeats the workbook's calculation settings; Excel applies the first. if wb.sheets.is_empty() { wb.calc = calc; } @@ -191,6 +193,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo if wb.sheets.is_empty() { return Err(invalid("the workbook has no worksheets GridCraft can read")); } + wb.calc.precision_as_displayed = g.precision_as_displayed; defined_names(&g, &model_index, &mut wb, &mut report); wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); // The overview comes first. @@ -251,6 +254,7 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { g.sheets.push(BoundSheet { pos, name, visibility, kind }); } 0x0022 => g.date1904 = c.u16()? == 1, + 0x000E => g.precision_as_displayed = c.u16()? == 0, 0x041E => { let ifmt = c.u16()?; let code = c.string()?; @@ -626,6 +630,13 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, st.arrays.insert(at, (range, f)); } } + 0x000D => { + st.calc.mode = match c.u16()? as i16 { + 0 => CalcMode::Manual, + -1 => CalcMode::AutomaticExceptTables, + _ => CalcMode::Automatic, + } + } 0x000C => st.calc.set_max_iterations(u32::from(c.u16()?)), 0x0011 => st.calc.iterative = c.u16()? == 1, 0x0010 => st.calc.set_max_change(c.f64()?), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ad458ca1e..0bc481396 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the iterative calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -22,7 +22,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, iterative calculation settings (enabled, maximum iterations, maximum change). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -78,7 +78,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-10-12 | minor | `.xls` import reads the iterative calculation settings (CalcIter, CalcCount, CalcDelta), so circular models calculate instead of showing `#CIRC!` (#724) | +| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | From 94014c5594058eb3dad64cbc9a4745a1c56e450f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:51:40 +0200 Subject: [PATCH 40/86] Tables: structured references keep the spaces at the edges of a column name (fixes #732) --- crates/engine/src/tests.rs | 30 ++++++++++++++++++++++++++++++ crates/formula/src/parser.rs | 5 ++++- crates/formula/src/printer.rs | 7 ++++++- crates/formula/src/tests.rs | 14 ++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc113e..66ef9d77c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1018,6 +1018,36 @@ fn tables_and_structured_refs() { assert_eq!(v(&s, "B4"), Value::Number(7.0)); } +#[test] +fn structured_refs_to_columns_with_edge_spaces() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty "], ["a", 2], ["b", 3]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B3", "hasHeaders": true})).unwrap(); + s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap(); + assert_eq!(v(&s, "B4"), Value::Number(5.0)); + for (cell, input) in [ + ("F1", "=SUM(Table1[Qty ])"), + ("F2", "=Table1[[#Totals],[Qty ]]"), + ("F3", "=SUBTOTAL(109,Table1[Qty ])"), + ("F4", "=SUM(Table1[[Qty ]])"), + ("F5", "=SUM(Table1[Qty])"), + ("C2", "=Table1[@[Qty ]]*10"), + ] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let n = Value::Number; + assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3"), v(&s, "F4")], [n(5.0), n(5.0), n(5.0), n(5.0)]); + assert_eq!(v(&s, "F5"), Value::Error(gridcraft_core::CellError::Ref)); + assert_eq!(v(&s, "C2"), n(20.0)); + assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])"); + + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let b64 = r["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])"); + assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3")], [n(5.0), n(5.0), n(5.0)]); +} + #[test] fn slicer_filters_table() { let mut s = s(); diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 4fd3b7bb2..8fc4de978 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -417,6 +417,9 @@ fn combine(op: BinOp, lhs: Expr, rhs: Expr) -> Expr { pub fn parse_struct(table: &str, body: &str) -> Result { let mut specifiers = Vec::new(); let mut cols: Vec = Vec::new(); + // Spaces at the edges of a column name are part of it (`Table1[Qty ]` names `Qty `), so the + // untrimmed body is what a lone column is read from. + let raw = body; let body = body.trim(); let unescape = |s: &str| { let mut out = String::new(); @@ -468,7 +471,7 @@ pub fn parse_struct(table: &str, body: &str) -> Result { } else if let Some(i) = item(body) { specifiers.push(i); } else if !body.is_empty() { - cols.push(column(body)?); + cols.push(column(raw)?); } } else { // [[#Headers],[Col1]:[Col3]], [[#This Row],[Col 1]] or [[#This Row],Col1] diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index a63599bb4..3673036bd 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -109,7 +109,12 @@ pub fn struct_ref(s: &StructRef) -> String { let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or("")); } (Some(c), None) if s.specifiers.is_empty() => { - let _ = write!(out, "[{}]", esc(c)); + // Excel writes a name with spaces at its edges in the double-bracket form. + if c.trim() == c { + let _ = write!(out, "[{}]", esc(c)); + } else { + let _ = write!(out, "[[{}]]", esc(c)); + } } (Some(c), None) if this_row => { if simple_col(c) { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c231..aef26490c 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -248,6 +248,20 @@ fn unbracketed_this_row_column() { } } +#[test] +fn struct_column_keeps_edge_spaces() { + // The spaces are part of the column name: `Table1[Qty ]` names `Qty `, never `Qty`. + let Expr::Struct(s) = parse("Table1[Qty ]").unwrap() else { panic!() }; + assert_eq!(s.col_start.as_deref(), Some("Qty ")); + let Expr::Struct(s) = parse("Table1[ Qty]").unwrap() else { panic!() }; + assert_eq!(s.col_start.as_deref(), Some(" Qty")); + // Excel writes such a column in the double-bracket form. + assert_eq!(rt("Table1[Qty ]"), "Table1[[Qty ]]"); + for f in ["Table1[[Qty ]]", "Table1[[ Sum for Q2 ]]", "Table1[[#Totals],[Qty ]]", "Table1[@[Qty ]]", "Table1[Unit Price]"] { + assert_eq!(rt(f), f); + } +} + #[test] fn trim_references() { for f in [ From eb534b5cafb038f8b399154d5fb16a785a4d6ef1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:58:24 +0200 Subject: [PATCH 41/86] Functions: DGET skips empty result fields and "" criteria match any text (part of #737) --- crates/functions/src/database.rs | 77 +++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index 38e372975..bf0c61164 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -4,7 +4,7 @@ //! The database and the criteria are arrays whose first row holds column labels. Each criteria //! row is a set of conditions that must all hold (AND); a record matches when any criteria row //! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that -//! begin with the text, as in Excel's advanced filter. +//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. use std::borrow::Cow; @@ -49,7 +49,6 @@ fn field_index(db: &Array, a: &[Arg]) -> R> { fn cell_criterion(v: &Value) -> Option { match v { Value::Empty => None, - Value::Text(t) if t.is_empty() => None, Value::Text(t) => { let s: &str = t; let has_op = s.starts_with(['=', '<', '>']); @@ -158,9 +157,12 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { let s = select(a)?; let f = s.field.ok_or(CellError::Value)?; - match s.records.as_slice() { - [] => Err(CellError::Value), - [r] => Ok(s.db.get(*r, f).cloned().unwrap_or(Value::Empty)), + // Records whose field is blank (or "") don't count, as in Excel. + let mut found = + s.records.iter().filter_map(|&r| s.db.get(r, f)).filter(|v| !matches!(v, Value::Empty) && !matches!(v, Value::Text(t) if t.is_empty())); + match (found.next(), found.next()) { + (None, _) => Err(CellError::Value), + (Some(v), None) => Ok(v.clone()), _ => Err(CellError::Num), } } @@ -371,4 +373,69 @@ mod tests { is_err(ev("DSUM", vec![e(CellError::Ref), t("Yield"), rf(c("=Cherry"))]), CellError::Ref); close(ev("DCOUNTA", vec![rf(db()), empty(), rf(c("=Pear"))]), 2.0); } + + /// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 74-86). + #[test] + fn empty_string_criterion() { + let db = arr(vec![ + vec![tv("Index"), tv("Value")], + vec![tv("a"), tv("=")], + vec![tv("b"), Value::Empty], + vec![tv("c"), tv("")], + vec![tv("d"), tv(" ")], + vec![tv("f"), Value::Error(CellError::Value)], + vec![tv("h"), tv("abc")], + vec![tv("i"), nv(0.0)], + ]); + let get = |index: &str| { + let crit = arr(vec![vec![tv("Index"), tv("Value")], vec![tv(index), tv("")]]); + ev("DGET", vec![rf(db.clone()), t("Index"), rf(crit)]) + }; + // Like any plain text, "" is a "begins with" pattern: it matches every text value. + is_text(get("c"), "c"); + is_text(get("h"), "h"); + is_err(get("b"), CellError::Value); + is_err(get("i"), CellError::Value); + is_err(get("f"), CellError::Value); + let all = arr(vec![vec![tv("Value")], vec![tv("")]]); + close(ev("DCOUNTA", vec![rf(db.clone()), t("Index"), rf(all)]), 4.0); + + // Under a label that names no field, "" is a condition that never holds. + let db = arr(vec![vec![tv("One"), tv("Two"), tv("Three")], vec![tv("a"), tv("c"), tv("x")], vec![tv("b"), tv("d"), tv("y")]]); + let get = |label: Value, value: Value| { + let crit = arr(vec![vec![tv("One"), label, tv("Three")], vec![tv("a"), value, tv("x")]]); + ev("DGET", vec![rf(db.clone()), t("One"), rf(crit)]) + }; + is_text(get(Value::Empty, Value::Empty), "a"); + is_text(get(tv(""), Value::Empty), "a"); + is_err(get(Value::Empty, tv("")), CellError::Value); + is_err(get(tv(""), tv("")), CellError::Value); + } + + /// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 108-119). + #[test] + fn dget_skips_empty_fields() { + let db = arr(vec![ + vec![tv("One"), tv("Two")], + vec![tv("a"), Value::Empty], + vec![tv("b"), tv(" ")], + vec![tv("c"), tv("")], + vec![tv("d"), Value::Error(CellError::NA)], + vec![tv("j"), Value::Empty], + vec![tv("j"), tv("Text")], + vec![tv("j"), Value::Empty], + vec![tv("k"), Value::Empty], + vec![tv("k"), tv("")], + ]); + let get = |one: &str| { + let crit = arr(vec![vec![tv("One")], vec![tv(one)]]); + ev("DGET", vec![rf(db.clone()), t("Two"), rf(crit)]) + }; + is_err(get("a"), CellError::Value); + is_text(get("b"), " "); + is_err(get("c"), CellError::Value); + is_err(get("d"), CellError::NA); + is_text(get("j"), "Text"); + is_err(get("k"), CellError::Value); + } } From f968bb32697c5f4ac02c6ae484704b402818c379 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:05:30 +0200 Subject: [PATCH 42/86] CELL("width") returns the Column Width dialog value, without the padding (#735) --- crates/calc/src/eval.rs | 2 +- crates/calc/src/tests.rs | 15 +++++++++++++++ crates/engine/src/cmd/format.rs | 4 +--- crates/model/src/lib.rs | 2 +- crates/model/src/sheet.rs | 6 ++++++ 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39fd..6f0a44e4f 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1275,7 +1275,7 @@ impl<'h> Evaluator<'h> { _ => "v", })) } - "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| (s.col_width(c.col) as f64 / 7.0 * 100.0).round() / 100.0).round())), + "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())), "filename" => Ev::V(Value::text(String::new())), "sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())), "protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })), diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 513340983..6f1c8c71f 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -968,3 +968,18 @@ fn trim_references() { t.set("K20", "=SUM(Q1:.Q9)"); assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); } + +#[test] +fn cell_width_is_the_column_width_dialog_value_rounded() { + // #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them + // (chars * 7 + 5): 10.71 -> 80, 12 -> 89, 10.57 -> 79, 2.86 -> 25; B keeps the default 8.43 (64). + let mut t = T::new(); + let sh = t.wb.sheet_mut(0).unwrap(); + for (col, px) in [(2, 80.0), (3, 89.0), (4, 79.0), (5, 25.0)] { + sh.cols.insert(col, gridcraft_model::LineInfo { size: Some(px), ..Default::default() }); + } + for (at, col) in [("H1", "B"), ("H2", "C"), ("H3", "D"), ("H4", "E"), ("H5", "F")] { + t.set(at, &format!("=CELL(\"width\",{col}1)")); + } + assert_eq!([t.num("H1"), t.num("H2"), t.num("H3"), t.num("H4"), t.num("H5")], [8.0, 11.0, 12.0, 11.0, 3.0]); +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index dde5fa120..a07715b37 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -992,9 +992,7 @@ pub fn chars_to_points(c: f64) -> f64 { } } -pub fn points_to_chars(px: f64) -> f64 { - if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 } -} +pub use gridcraft_model::points_to_chars; /// Approximate text width in points for autofit (no font metrics in the engine: average glyph /// widths for the default font; the UI refines with real measurement when it autofits). diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 1bea84218..fa64aad37 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -19,7 +19,7 @@ pub use cell::{Cell, CellPicture, Formula}; pub use features::*; pub use password::PasswordHash; pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml}; -pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility}; +pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars}; pub use store::CellStore; pub use style::{ Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline, diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index e05d3bfb5..b404b891c 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -117,6 +117,12 @@ pub struct Sheet { pub const DEFAULT_COL_WIDTH: f32 = 64.0; pub const DEFAULT_ROW_HEIGHT: f32 = 20.0; +/// Column width (points) → characters of the default font's digit width (7 px), without the +/// cell padding: the value the Column Width dialog shows. +pub fn points_to_chars(px: f64) -> f64 { + if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 } +} + impl Sheet { pub fn new(name: impl Into) -> Sheet { Sheet { From f63a437d5b9d0d26c718c96779637762ea71c181 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:07:32 +0200 Subject: [PATCH 43/86] Formulas: keep _xludf. so user-defined calls stay user-defined (fixes #726) The parser stripped _xludf. like _xlfn., but the two mean opposite things: _xludf. marks a name Excel must not treat as a built-in. Keeping it in the call name makes the call an unknown function (#NAME?, as in Excel) and the writer emits it unchanged, so _xludf.EOMONTH() no longer saves as an EOMONTH() call Excel repairs away. --- crates/calc/src/tests.rs | 5 +++++ crates/formula/src/parser.rs | 10 +++++++--- crates/formula/src/tests.rs | 1 + crates/functions/src/lib.rs | 3 ++- crates/xlsx/src/tests/fixtures.rs | 25 +++++++++++++++++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 513340983..6214206cb 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -167,6 +167,11 @@ fn errors_and_cycles() { assert!(t.get("B1").is_error()); t.set("C1", "=NOSUCHFN(1)"); assert_eq!(t.get("C1"), Value::Error(CellError::Name)); + // `_xludf.` marks a user-defined function, never the built-in of the same name (#726). + for f in ["=_xludf.CONCAT(\"a\",\"b\")", "=_xludf.SUM(1,2)", "=_xludf.EOMONTH()"] { + t.set("C3", f); + assert_eq!(t.get("C3"), Value::Error(CellError::Name), "{f}"); + } t.set("C2", "=1+"); assert_eq!(t.get("C2"), Value::Error(CellError::Name)); } diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 4fd3b7bb2..81b8fe0da 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -88,15 +88,19 @@ fn word_is_cell(w: &str) -> Option { Some(Anchor { row, col, row_abs, col_abs }) } -/// `_xlfn.XLOOKUP` → `XLOOKUP`. +/// `_xlfn.XLOOKUP` → `XLOOKUP`. `_xludf.` is kept (`_xludf.SUM`): it marks a user-defined +/// function, never the built-in of the same name, and files need it back. pub fn normalize_function_name(name: &str) -> String { let u = name.to_ascii_uppercase(); - for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS.", "_XLUDF."] { + for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS."] { if let Some(r) = u.strip_prefix(p) { return r.to_string(); } } - u + match u.strip_prefix("_XLUDF.") { + Some(r) => format!("_xludf.{r}"), + None => u, + } } impl Parser { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c231..69491f6f9 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -48,6 +48,7 @@ fn normalises() { assert_eq!(rt("=sum(a1:b2)"), "SUM(A1:B2)"); assert_eq!(rt("= 1 + 2"), "1+2"); assert_eq!(rt("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); + assert_eq!(rt("_XLUDF.concat(\"a\",\"b\")"), "_xludf.CONCAT(\"a\",\"b\")"); assert_eq!(rt("true"), "TRUE"); assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1"); } diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 282b230ce..65612b4b8 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -197,7 +197,8 @@ fn registry() -> &'static (Vec, HashMap<&'static str, usize>) { }) } -/// Looks a function up by name (case-insensitive; `_xlfn.`, `_xlws.` and `_xludf.`-free names). +/// Looks a function up by name (case-insensitive; `_xlfn.`- and `_xlws.`-free names). A +/// `_xludf.` name is user-defined and never found. pub fn lookup(name: &str) -> Option<&'static FnSpec> { let mut n = name.trim().to_ascii_uppercase(); loop { diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 47dde5adc..99221dd19 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -495,3 +495,28 @@ fn text_box_formatting_is_read_and_written() { assert_eq!(rgb(g.text_outline.map(|o| o.0)), rgb(f.text_outline.map(|o| o.0))); assert_eq!(g.text_outline.map(|o| o.1), Some(1.5)); } + +/// `_xludf.` marks a user-defined call even when a built-in has the same name; dropping it turns +/// the call into the built-in, and `_xludf.EOMONTH()` into a call Excel repairs away (#726). +#[test] +fn user_defined_function_prefix_round_trips() { + let cases = + [("A1", "_xludf.EOMONTH()"), ("A2", "_xludf.CONCAT(\"a\",\"b\")"), ("A3", "_xludf.SUM(1,2)"), ("A4", "MYUDF(1)"), ("A5", "_xludf.MYUDF(1)")]; + let rows: String = cases + .iter() + .enumerate() + .map(|(i, (a, f))| format!(r#"{}"#, i + 1, f.replace('"', """))) + .collect(); + let (wb, _) = read_xlsx(&minimal(&format!("{rows}"), &[], "", "")).unwrap(); + let s = wb.sheet(0).unwrap(); + for (a, f) in cases { + assert_eq!(s.cell(at(a)).unwrap().formula.as_ref().unwrap().text, f, "read {a}"); + } + let bytes = crate::write_xlsx(&wb).unwrap(); + let mut pkg = crate::package::Package::open(&bytes).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + for (a, f) in cases { + let cell = sheet.child("sheetData").unwrap().kids("row").flat_map(|r| r.kids("c")).find(|c| c.attr("r") == Some(a)).unwrap(); + assert_eq!(cell.child("f").unwrap().text, f, "written {a}"); + } +} From 77a91a714a59c8d2d0869242ca385845e05a2024 Mon Sep 17 00:00:00 2001 From: msaulc <103976208+msaulc@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:07:43 -0600 Subject: [PATCH 44/86] Grid: extending the selection from the keyboard scrolls its moving end into view Ctrl+Shift+Arrow (and Shift+Arrow, Shift+PageUp/PageDown, Alt+Shift+PageUp/PageDown, Ctrl+Shift+Home/End, Shift+Home) extend the selection by moving its far corner while the active cell stays on the anchor. After the move the grid set `ensure_visible`, but the scroll-into-view step in `grid::show` always targeted `selection.active`, which is the anchor and is already on screen. So the view never followed the extension past the last visible row or column. The keyboard extension paths now also set `GridState::reveal_moving_end`, and the ensure-visible step then scrolls to `Selection::moving_end()`, the corner opposite the anchor. `selection.move` now uses that same helper (it had the logic inline) to find where an extension starts. Other ensure-visible callers (Go To, Name Box, plain moves) still reveal the active cell. Regression test: crates/ui-egui/tests/selection_scroll.rs drives Ctrl+Shift+Up/Down/ Left/Right, Shift+Down/Right and Ctrl+Shift+Home/End through real egui key events and checks that the moving end ends up on screen while the anchor and active cell stay put. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 9 +- crates/engine/src/selection.rs | 7 ++ crates/ui-egui/src/grid.rs | 9 +- crates/ui-egui/src/keymap.rs | 2 + crates/ui-egui/tests/selection_scroll.rs | 106 +++++++++++++++++++++++ 5 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/selection_scroll.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3da230b3b..2b9c2bd0f 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -420,14 +420,7 @@ fn selection_move(s: &mut Session, p: &Json) -> Result { let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; let sel = &mut d.selection; // Extension moves the far corner, not the active cell. - let from = if extend { - let r = sel.current(); - let far_row = if sel.anchor.row == r.start.row { r.end.row } else { r.start.row }; - let far_col = if sel.anchor.col == r.start.col { r.end.col } else { r.start.col }; - CellRef::new(far_row, far_col) - } else { - sel.active - }; + let from = if extend { sel.moving_end() } else { sel.active }; // Skip hidden rows/columns and step over merged areas. let mut to = if jmp { jump(sheet, from, dr.signum(), dc.signum()) diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs index 98f0c6c19..27418a21a 100644 --- a/crates/engine/src/selection.rs +++ b/crates/engine/src/selection.rs @@ -43,6 +43,13 @@ impl Selection { pub fn is_single_cell(&self) -> bool { self.ranges.len() == 1 && self.current().is_single() } + /// The corner of the current area opposite the anchor: the end that Shift+arrows move. + pub fn moving_end(&self) -> CellRef { + let r = self.current(); + let row = if self.anchor.row == r.start.row { r.end.row } else { r.start.row }; + let col = if self.anchor.col == r.start.col { r.end.col } else { r.start.col }; + CellRef::new(row, col) + } /// Extends the current area from the anchor to `to` (Shift+click / Shift+arrows). pub fn extend_to(&mut self, to: CellRef) { let r = RangeRef::new(self.anchor, to); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..4ee789598 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -68,6 +68,9 @@ pub enum Drag { pub struct GridState { pub drag: Drag, pub ensure_visible: bool, + /// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell + /// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves). + pub reveal_moving_end: bool, pub last_title: Option, pub drag_select: bool, pub rect: Option, @@ -533,10 +536,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { view.scroll.x = view.scroll.x.min(max_x); view.scroll.y = view.scroll.y.min(max_y); let mut geo = make_geo(app, view.scroll); - // Keep the active cell visible after keyboard moves. + // Keep the active cell (or the moving end of an extension) visible after keyboard moves. if app.grid.ensure_visible { app.grid.ensure_visible = false; - let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(sel.active); + let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active }; + let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); if paper { crate::paper_view::ensure_covers(app, &wb, si, target); geo = make_geo(app, view.scroll); @@ -2371,6 +2375,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let mv = |app: &mut SheetApp, dr: i64, dc: i64| { let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); app.grid.ensure_visible = true; + app.grid.reveal_moving_end = shift; }; match key { Key::ArrowUp => mv(app, -1, 0), diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 21bf41ff4..ef4b8ac88 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -212,6 +212,7 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols }; let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift})); app.grid.ensure_visible = true; + app.grid.reveal_moving_end = m.shift; } Shortcut::Edit => app.begin_edit(None, false), Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()), @@ -243,4 +244,5 @@ fn extend(app: &mut SheetApp, anchor: CellRef, active: CellRef, to: CellRef) { d.selection.anchor = anchor; } app.grid.ensure_visible = true; + app.grid.reveal_moving_end = true; } diff --git a/crates/ui-egui/tests/selection_scroll.rs b/crates/ui-egui/tests/selection_scroll.rs new file mode 100644 index 000000000..06e0d18ce --- /dev/null +++ b/crates/ui-egui/tests/selection_scroll.rs @@ -0,0 +1,106 @@ +//! Extending the selection from the keyboard scrolls its moving end into view, not the anchor. + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// Ctrl on Windows and Linux sets both `ctrl` and `command`. +const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true }; +const CTRL_SHIFT: Modifiers = Modifiers { shift: true, ..CTRL }; + +/// Column A filled down to A200 and row 1 filled across to CV1, far past one screen each way. +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + let down: Vec> = (1..=200).map(|r| vec![r]).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": down})).unwrap(); + let across: Vec = (1..=100).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": [across]})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +fn key(h: &mut Harness, k: Key, m: Modifiers) { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m }); + h.run_steps(2); +} + +/// Whether the cell's centre is on screen (a whole cell can sit a few pixels under the edge when +/// scrolling far down widens the row headings). +fn visible(h: &Harness, a1: &str) -> bool { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cells.contains(geo.cell_rect(sheet, CellRef::parse(a1).unwrap()).center()) +} + +fn assert_extended(h: &Harness, anchor: &str, moving_end: &str) { + let selection = &h.state().session.active().unwrap().selection; + let (anchor_cell, end_cell) = (CellRef::parse(anchor).unwrap(), CellRef::parse(moving_end).unwrap()); + assert_eq!(selection.current(), RangeRef::new(anchor_cell, end_cell)); + assert_eq!(selection.anchor, anchor_cell); + assert_eq!(selection.active, anchor_cell, "extending keeps the active cell on the anchor"); + assert!(visible(h, moving_end), "the moving end {moving_end} is scrolled into view"); +} + +#[test] +fn ctrl_shift_arrows_scroll_the_moving_end_into_view() { + // Down, then back Up past the top once the view has scrolled. + let mut h = harness(); + key(&mut h, Key::ArrowDown, CTRL_SHIFT); + assert_extended(&h, "A1", "A200"); + assert!(!visible(&h, "A1"), "the view follows the moving end, away from the anchor"); + let mut h = harness(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(visible(&h, "A200") && !visible(&h, "A1")); + key(&mut h, Key::ArrowUp, CTRL_SHIFT); + assert_extended(&h, "A200", "A1"); + // Right, then back Left. + let mut h = harness(); + key(&mut h, Key::ArrowRight, CTRL_SHIFT); + assert_extended(&h, "A1", "CV1"); + let mut h = harness(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(visible(&h, "CV1") && !visible(&h, "A1")); + key(&mut h, Key::ArrowLeft, CTRL_SHIFT); + assert_extended(&h, "CV1", "A1"); +} + +#[test] +fn shift_arrows_scroll_the_moving_end_into_view() { + let mut h = harness(); + for _ in 0..60 { + key(&mut h, Key::ArrowDown, Modifiers::SHIFT); + } + assert_extended(&h, "A1", "A61"); + for _ in 0..30 { + key(&mut h, Key::ArrowRight, Modifiers::SHIFT); + } + assert_extended(&h, "A1", "AE61"); +} + +#[test] +fn ctrl_shift_home_and_end_scroll_the_moving_end_into_view() { + let mut h = harness(); + key(&mut h, Key::End, CTRL_SHIFT); + assert_extended(&h, "A1", "CV200"); + let mut h = harness(); + key(&mut h, Key::End, CTRL); + assert!(!visible(&h, "A1")); + key(&mut h, Key::Home, CTRL_SHIFT); + assert_extended(&h, "CV200", "A1"); +} From 4d2e8e3455701857caae78162ad623740e53e0ee Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:16:00 +0200 Subject: [PATCH 45/86] Functions: database functions evaluate computed criteria (part of #737) A formula under a criteria label that names no field is evaluated for each record: its relative references move from the first record to that record, and the database labels name the record's fields, as in Excel. The calc evaluator passes the criteria reference to the database functions through Ctx::computed_criterion. --- crates/calc/src/eval.rs | 39 +++++++++++++-- crates/calc/src/tests.rs | 52 ++++++++++++++++++++ crates/functions/src/database.rs | 83 +++++++++++++++++--------------- crates/functions/src/lib.rs | 8 ++- 4 files changed, 137 insertions(+), 45 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39fd..98c117902 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -88,6 +88,8 @@ pub struct Evaluator<'h> { struct FnCtx<'a, 'h> { ev: &'a mut Evaluator<'h>, + /// The criteria argument of a database function, when it is a reference. + criteria: Option, } impl Ctx for FnCtx<'_, '_> { @@ -100,6 +102,24 @@ impl Ctx for FnCtx<'_, '_> { fn random(&mut self) -> f64 { self.ev.host.random() } + /// Excel's "formula as criteria": the formula is written for the first record, so its relative + /// references move down to `record`'s row, and the database's labels name its fields. + fn computed_criterion(&mut self, row: usize, col: usize, db: &Array, record: usize) -> Option { + let area = self.criteria?; + let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?; + let expr = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?.expr_arc()?; + let shifted = gridcraft_formula::adjust::shift_relative((*expr).clone(), record as i64 - 1, 0); + let env = (0..db.cols) + .filter_map(|c| match db.get(0, c) { + Some(Value::Text(t)) if !t.trim().is_empty() => { + Some((t.trim().to_string(), Ev::V(db.get(record, c).cloned().unwrap_or(Value::Empty)))) + } + _ => None, + }) + .collect(); + let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth }; + Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) + } } impl<'h> Evaluator<'h> { @@ -579,7 +599,7 @@ impl<'h> Evaluator<'h> { let value = self.value(a); evaluated.push(Arg { value, from_ref: false }); } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria: None }; return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); } let i = match index.to_number() { @@ -762,8 +782,17 @@ impl<'h> Evaluator<'h> { let mut evaluated = Vec::with_capacity(args.len()); // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. let mut criteria_shape = None; + // Database functions: the criteria range (argument 2), whose formulas are computed criteria. + let mut criteria = None; for (i, a) in args.iter().enumerate() { let mut ev = self.eval(a); + if spec.category == gridcraft_functions::Category::Database + && i == 2 + && let Ev::R(areas) = &ev + && let [area] = areas.as_slice() + { + criteria = Some(*area); + } if resizes_sum_range(name, args.len()) { if i == 0 { criteria_shape = ev_shape(&ev); @@ -791,7 +820,7 @@ impl<'h> Evaluator<'h> { }; evaluated.push(Arg { value, from_ref }); } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria }; gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) } @@ -845,13 +874,13 @@ impl<'h> Evaluator<'h> { }; let args = [Arg { value, from_ref }]; if kind != Agg::Count { - match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) { Value::Number(n) => acc.sum += n, other => return Err(Ev::V(other)), } } if kind != Agg::Sum { - match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self }) { + match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) { Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), other => return Err(Ev::V(other)), } @@ -1221,7 +1250,7 @@ impl<'h> Evaluator<'h> { } } } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria: None }; return match gridcraft_functions::call(spec, &xargs, &mut ctx) { Value::Number(p) if p >= 1.0 => { let k = p as u32 - 1; diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 513340983..3eafc4185 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -968,3 +968,55 @@ fn trim_references() { t.set("K20", "=SUM(Q1:.Q9)"); assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); } + +/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 46-49): a criteria cell holding +/// a formula under a label that names no field is evaluated for every record. +#[test] +fn database_functions_with_computed_criteria() { + let mut t = T::new(); + let db = [ + ["name", "age", "size", "headgear", "glasses"], + ["Huey", "6", "45", "cap~", "FALSE"], + ["Dewey", "7", "fourty-six", "cap?", "FALSE"], + ["Louie", "8", "47", "cap*", "FALSE"], + ["Donald", "32", "70", "sailor's hat", "FALSE"], + ["Scrooge", "62", "65", "top hat", "TRUE"], + ]; + for (r, row) in db.iter().enumerate() { + for (c, v) in row.iter().enumerate() { + t.set(&format!("{}{}", ["F", "G", "H", "I", "J"][c], r + 2), v); + } + } + t.set("G45", "7"); + let cases = [ + // Relative references are to the first record's row and move to each record's row; + // `$G$45` stays put. + ("G", "=G2<$G$45", "Dewey"), + // Column labels stand for the record's fields. + ("H", "=glasses=TRUE", "Scrooge"), + ("I", "=age<7", "Huey"), + ("J", "=size/7=10", "Donald"), + // An absolute reference to a cell that is not the first record's. + ("K", "=H3=$L$1", "Donald"), + ]; + t.set("L1", "70"); + for (i, (col, formula, _)) in cases.iter().enumerate() { + t.set(&format!("{col}46"), "Formula"); + t.set(&format!("{col}47"), formula); + t.set(&format!("B{}", 46 + i), &format!("=DGET($F$2:$J$7,\"name\",{col}46:{col}47)")); + } + for (i, (_, formula, want)) in cases.iter().enumerate() { + assert_eq!(t.get(&format!("B{}", 46 + i)), Value::text(*want), "{formula}"); + } + // Every database function takes them, ANDed with the other criteria of their row. + // Text sorts after numbers, so Dewey's "fourty-six" is > 40. + t.set("L47", "=size>40"); + t.set("M46", "age"); + t.set("M47", ">6"); + t.set("B51", "=DCOUNT($F$2:$J$7,\"age\",I46:I47)"); + t.set("B52", "=DSUM($F$2:$J$7,\"age\",G46:G47)"); + t.set("B53", "=DCOUNTA($F$2:$J$7,\"name\",L46:M47)"); + assert_eq!(t.num("B51"), 1.0); + assert_eq!(t.num("B52"), 7.0); + assert_eq!(t.num("B53"), 4.0); +} diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index bf0c61164..67132e98f 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -4,7 +4,9 @@ //! The database and the criteria are arrays whose first row holds column labels. Each criteria //! row is a set of conditions that must all hold (AND); a record matches when any criteria row //! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that -//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. +//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. A +//! formula under a label that names no field is a computed criterion, which the host evaluates +//! for each record ([`Ctx::computed_criterion`]). use std::borrow::Cow; @@ -64,9 +66,10 @@ fn cell_criterion(v: &Value) -> Option { } /// Rows (1-based within the database, excluding the header) of matching records. -fn matching_records(db: &Array, crit: &Array) -> Vec { - // Per criteria row: list of (database column or None when the label is unknown, criterion). - let mut rows: Vec, Criterion)>> = Vec::new(); +fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec { + // Per criteria row: its index and a list of (criteria column, database column or None when + // the label is unknown, criterion). + let mut rows: Vec<(usize, Vec<(usize, Option, Criterion)>)> = Vec::new(); for r in 1..crit.rows { let mut conds = Vec::new(); for c in 0..crit.cols { @@ -77,18 +80,20 @@ fn matching_records(db: &Array, crit: &Array) -> Vec { Some(Value::Number(n)) => find_column(db, &gridcraft_core::number_to_text(*n)), _ => None, }; - conds.push((col, cr)); + conds.push((c, col, cr)); } - rows.push(conds); + rows.push((r, conds)); } let all_match = rows.is_empty(); (1..db.rows) - .filter(|&r| { + .filter(|&rec| { all_match - || rows.iter().any(|conds| { - conds.iter().all(|(col, cr)| match col { - Some(c) => cr.matches(db.get(r, *c).unwrap_or(&Value::Empty)), - None => false, + || rows.iter().any(|(r, conds)| { + conds.iter().all(|(c, col, cr)| match col { + Some(f) => cr.matches(db.get(rec, *f).unwrap_or(&Value::Empty)), + // Under a label that names no field, only a formula (a computed + // criterion) can hold. + None => ctx.computed_criterion(*r, *c, db, rec).unwrap_or(false), }) }) }) @@ -101,7 +106,7 @@ struct Selection<'a> { records: Vec, } -fn select(a: &[Arg]) -> R> { +fn select<'a>(a: &'a [Arg], ctx: &mut dyn Ctx) -> R> { let dbv = &a.first().ok_or(CellError::Value)?.value; let critv = &a.get(2).ok_or(CellError::Value)?.value; if let Value::Error(e) = dbv { @@ -113,31 +118,31 @@ fn select(a: &[Arg]) -> R> { let db = as_array(dbv); let crit = as_array(critv); let field = field_index(&db, a)?; - let records = matching_records(&db, &crit); + let records = matching_records(&db, &crit, ctx); Ok(Selection { db, field, records }) } /// Numbers of the field among matching records (field required). -fn field_numbers(a: &[Arg]) -> R> { - let s = select(a)?; +fn field_numbers(a: &[Arg], c: &mut dyn Ctx) -> R> { + let s = select(a, c)?; let f = s.field.ok_or(CellError::Value)?; Ok(s.records.iter().filter_map(|&r| s.db.get(r, f).and_then(Value::as_f64)).collect()) } -fn dsum(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(field_numbers(a)?.iter().sum()) +fn dsum(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(field_numbers(a, c)?.iter().sum()) } -fn daverage(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn daverage(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; if v.is_empty() { return Err(CellError::Div0); } num_val(v.iter().sum::() / v.len() as f64) } -fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dcount(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let n = match s.field { Some(f) => s.records.iter().filter(|&&r| matches!(s.db.get(r, f), Some(Value::Number(_)))).count(), None => s.records.len(), @@ -145,8 +150,8 @@ fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(n as f64) } -fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dcounta(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let n = match s.field { Some(f) => s.records.iter().filter(|&&r| !matches!(s.db.get(r, f), None | Some(Value::Empty))).count(), None => s.records.len(), @@ -154,8 +159,8 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(n as f64) } -fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dget(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let f = s.field.ok_or(CellError::Value)?; // Records whose field is blank (or "") don't count, as in Excel. let mut found = @@ -167,18 +172,18 @@ fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { } } -fn dmax(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dmax(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0)) } -fn dmin(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dmin(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0)) } -fn dproduct(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dproduct(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; if v.is_empty() { return num_val(0.0); } @@ -196,20 +201,20 @@ fn variance(v: &[f64], sample: bool) -> R { Ok(ss / if sample { n - 1.0 } else { n }) } -fn dvar(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, true)?) +fn dvar(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, true)?) } -fn dvarp(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, false)?) +fn dvarp(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, false)?) } -fn dstdev(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, true)?.sqrt()) +fn dstdev(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, true)?.sqrt()) } -fn dstdevp(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, false)?.sqrt()) +fn dstdevp(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, false)?.sqrt()) } pub(crate) fn specs() -> Vec { diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 282b230ce..0c5ede3b9 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::sync::OnceLock; -use gridcraft_core::{CellError, DateSystem, Value}; +use gridcraft_core::{Array, CellError, DateSystem, Value}; /// Builds a [`FnSpec`] whose implementation returns `Result`. /// @@ -105,6 +105,12 @@ pub trait Ctx { fn now_serial(&self) -> f64; /// Uniform in [0, 1). fn random(&mut self) -> f64; + /// For the database functions: whether the formula in the criteria cell at (`row`, `col`) holds + /// for `record` (a row of `db` below its labels). `None` when that cell holds no formula or the + /// criteria didn't come from a reference. + fn computed_criterion(&mut self, _row: usize, _col: usize, _db: &Array, _record: usize) -> Option { + None + } } pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value; From 76e7f1de073ef993ee05dfd6e088acaf41de6b2f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 19:25:51 +0200 Subject: [PATCH 46/86] Grid: the selection outline stays visible when extended to the last row (#687) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/grid.rs | 48 +++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..1f8cede66 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..861197c7a 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1373,6 +1373,26 @@ fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool { r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0 } +/// The four sides of a `width` wide outline centred on `rect`, cut to `clip` (a little beyond it). +/// +/// A stroked rectangle that spans a whole column (20 million points tall) loses its precision in +/// the tessellator and its left and right sides vanish; sides filled from rectangles that stay +/// near the viewport do not. Sides wholly outside `clip` are left out. +pub(crate) fn outline_bands(rect: Rect, width: f32, clip: Rect) -> Vec { + if !rect.is_finite() || !clip.is_finite() { + return Vec::new(); + } + let h = width / 2.0; + let clip = clip.expand(width * 2.0); + let sides = [ + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.left() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.right() - h, rect.top() - h), pos2(rect.right() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.right() + h, rect.top() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.bottom() - h), pos2(rect.right() + h, rect.bottom() + h)), + ]; + sides.into_iter().map(|s| s.intersect(clip)).filter(|s| s.is_finite() && s.width() > 0.0 && s.height() > 0.0).collect() +} + /// `outline` is where the current area's border is drawn this frame (see `selection_glide`). #[allow(clippy::too_many_arguments)] pub(crate) fn paint_selection( @@ -1415,7 +1435,9 @@ pub(crate) fn paint_selection( } } if cur_here { - p.rect_stroke(outline.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); + for side in outline_bands(outline.expand(0.5), 2.0, p.clip_rect()) { + p.rect_filled(side, 0.0, t.sel_border); + } } if active_here && sel.ranges.len() > 1 { p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); @@ -3238,3 +3260,27 @@ mod pane_tests { assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle))); } } + +#[cfg(test)] +mod outline_band_tests { + use super::*; + + #[test] + fn whole_column_outline_keeps_left_and_right_sides_near_viewport() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + let col = Rect::from_min_max(pos2(100.0, 40.0), pos2(180.0, 20_971_540.0)); + let sides = outline_bands(col, 2.0, clip); + // Left, right and top (in view); the bottom is 20 million points away. + assert_eq!(sides.len(), 3); + assert!(sides.iter().all(|s| s.bottom() <= clip.bottom() + 4.0 + f32::EPSILON)); + assert!(sides.iter().any(|s| s.left() < 100.0 && s.right() > 100.0 && s.height() > 500.0)); + assert!(sides.iter().any(|s| s.left() < 180.0 && s.right() > 180.0 && s.height() > 500.0)); + } + + #[test] + fn outline_outside_the_view_and_non_finite_draw_nothing() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + assert!(outline_bands(Rect::from_min_max(pos2(900.0, 0.0), pos2(950.0, 20.0)), 2.0, clip).is_empty()); + assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty()); + } +} From 1767a28371232df6871e1f6bc926548759c76eac Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:43:33 +0200 Subject: [PATCH 47/86] =?UTF-8?q?Numfmt:=20scientific=20formats=20with=20a?= =?UTF-8?q?=20literal=20after=20E=C2=B1,=20a=20%=20or=20a=20comma=20match?= =?UTF-8?q?=20Excel=20(fixes=20#768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exponent sign goes right before the exponent digits, after any literal that follows the E; ? pads exponent digits with spaces; % and commas never scale a scientific section; zero shows 0 in every integer placeholder. Checked against the 248 TEXT() rows of Apache POI's NumberFormatApproxTests.xlsx, copied as data into a test. --- crates/numfmt/src/parse.rs | 5 + crates/numfmt/src/render.rs | 34 ++- .../numfmt/tests/poi_number_format_approx.rs | 272 ++++++++++++++++++ 3 files changed, 298 insertions(+), 13 deletions(-) create mode 100644 crates/numfmt/tests/poi_number_format_approx.rs diff --git a/crates/numfmt/src/parse.rs b/crates/numfmt/src/parse.rs index e54f04c9f..6df22ad79 100644 --- a/crates/numfmt/src/parse.rs +++ b/crates/numfmt/src/parse.rs @@ -513,6 +513,7 @@ fn analyze(raw: Raw) -> Section { let mut seen_exp = false; let mut seen_digit = false; let mut fraction_slash = false; + let scientific = toks.iter().skip_while(|t| !matches!(t, Tok::Digit(_))).any(|t| matches!(t, Tok::Exp { .. })); for i in 0..toks.len() { let Some(t) = toks.get(i).cloned() else { break }; let replace = match t { @@ -547,6 +548,10 @@ fn analyze(raw: Raw) -> Section { if seen_digit && next_digit && !seen_point && !seen_exp { sec.thousands = true; Some(Tok::Lit(String::new())) + } else if seen_digit && scientific { + // Observed: a scientific section never scales; any other comma after the + // first digit shows nothing (`#,e+#`, `#e+#,`, `#e+000,00`). + Some(Tok::Lit(String::new())) } else if seen_digit && !next_digit { sec.scale += 1; Some(Tok::Lit(String::new())) diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 7befe591a..6635d3ac7 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -231,7 +231,9 @@ fn digit_indices(toks: &[Tok], range: std::ops::Range) -> Vec { /// Renders a non-negative number through a number section. pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let mut x = v.abs(); - if sec.percent > 0 { + // Observed: `%` in a scientific section is only a literal (`#%e+#` shows 123456.789 as + // `1%e+5`). + if sec.percent > 0 && !sec.exp { x *= 100f64.powi(sec.percent.min(20)); } if sec.scale > 0 { @@ -264,7 +266,9 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let exp_idx = digit_indices(toks, e_at + 1..n); let step = int_idx.len().max(1) as i32; let (mut e, int_s, frac_s) = if x == 0.0 { - (0i32, String::new(), "0".repeat(frac_idx.len())) + // Observed: zero shows `0` in every integer placeholder, `#` and `?` too + // (`#.#E+#` gives `0.E+0`, `####.####E+#` gives `0000.E+0`). + (0i32, "0".repeat(int_idx.len()), "0".repeat(frac_idx.len())) } else { let (digits, xe) = decimal_digits(x); let mut e = xe - xe.rem_euclid(step); @@ -283,26 +287,30 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { if int_s.is_empty() && frac_s.chars().all(|c| c == '0') { e = 0; } - let mut head = String::from(if upper { "E" } else { "e" }); - if e < 0 { - head.push('-'); + let head = if upper { "E" } else { "e" }; + let sign = if e < 0 { + "-" } else if plus { - head.push('+'); - } - let width = exp_idx.iter().filter(|&&i| matches!(toks.get(i), Some(Tok::Digit('0')))).count().min(20); - let digits = format!("{:0w$}", e.unsigned_abs(), w = width); + "+" + } else { + "" + }; + let digits = e.unsigned_abs().to_string(); + // The sign sits right before the exponent digits, after any literal that follows the + // `E` (observed: `|0|e+|0|` shows `|1|e|+5|`); `?` pads the digits with spaces. match exp_idx.first() { Some(&first) => { if let Some(p) = pieces.get_mut(e_at) { - *p = head; + *p = head.to_string(); } - if let Some(p) = pieces.get_mut(first) { - *p = digits; + if let Some(b) = before.get_mut(first) { + *b = sign.to_string(); } + fill_int(toks, &exp_idx, &digits, false, false, &mut pieces); } None => { if let Some(p) = pieces.get_mut(e_at) { - *p = head + &digits; + *p = format!("{head}{sign}{digits}"); } } } diff --git a/crates/numfmt/tests/poi_number_format_approx.rs b/crates/numfmt/tests/poi_number_format_approx.rs new file mode 100644 index 000000000..0f9932101 --- /dev/null +++ b/crates/numfmt/tests/poi_number_format_approx.rs @@ -0,0 +1,272 @@ +//! `TEXT(value, format)` against the results Excel cached in Apache POI's +//! `test-data/spreadsheet/NumberFormatApproxTests.xlsx` (sheet `Tests`: B = format, C = value, +//! A = `=TEXT(C, B)`). The workbook is not committed; its rows are copied here as data. + +use gridcraft_core::{DateSystem, Value}; +use gridcraft_numfmt::text_function; + +/// (format, value, Excel's result). +// The last row's 3.14159 is the workbook's value, not an approximation of pi. +#[allow(clippy::approx_constant)] +const CASES: &[(&str, f64, &str)] = &[ + (r"|#,e-#|", 123456.789, r"|1e5|"), + (r"|#e-#,|", 123456.789, r"|1e5|"), + (r"|#%e-#|", 123456.789, r"|1%e5|"), + (r"|#e-#%|", 123456.789, r"|1e5%|"), + (r"|#|e-|#|", 123456.789, r"|1|e|5|"), + (r"|#.#|e-|#|", 123456.789, r"|1.2|e|5|"), + (r"|####.####|e-|#|", 123456.789, r"|12.3457|e|4|"), + (r"|#######.####|e-|#|", 123456.789, r"|123456.789|e|0|"), + (r"|#,######.####|e-|#|", 123456.789, r"|123,456.789|e|0|"), + (r"|0|e-|0|", 123456.789, r"|1|e|5|"), + (r"|0.0|e-|0|", 123456.789, r"|1.2|e|5|"), + (r"|0000.0000|e-|0|", 123456.789, r"|0012.3457|e|4|"), + (r"|0000000.0000|e-|0|", 123456.789, r"|0123456.7890|e|0|"), + (r"|0,000000.0000|e-|0|", 123456.789, r"|0,123,456.7890|e|0|"), + (r"|?|e-|?|", 123456.789, r"|1|e|5|"), + (r"|?.?|e-|?|", 123456.789, r"|1.2|e|5|"), + (r"|????.????|e-|?|", 123456.789, r"| 12.3457|e|4|"), + (r"|???????.????|e-|?|", 123456.789, r"| 123456.789 |e|0|"), + (r"|?,??????.????|e-|?|", 123456.789, r"| 123,456.789 |e|0|"), + (r"|#,e+#|", 123456.789, r"|1e+5|"), + (r"|#e+#,|", 123456.789, r"|1e+5|"), + (r"|#%e+#|", 123456.789, r"|1%e+5|"), + (r"|#e+#%|", 123456.789, r"|1e+5%|"), + (r"|#|e+|#|", 123456.789, r"|1|e|+5|"), + (r"|#.#|e+|#|", 123456.789, r"|1.2|e|+5|"), + (r"|####.####|e+|#|", 123456.789, r"|12.3457|e|+4|"), + (r"|#######.####|e+|#|", 123456.789, r"|123456.789|e|+0|"), + (r"|#,######.####|e+|#|", 123456.789, r"|123,456.789|e|+0|"), + (r"|0|e+|0|", 123456.789, r"|1|e|+5|"), + (r"|0.0|e+|0|", 123456.789, r"|1.2|e|+5|"), + (r"|0000.0000|e+|0|", 123456.789, r"|0012.3457|e|+4|"), + (r"|0000000.0000|e+|0|", 123456.789, r"|0123456.7890|e|+0|"), + (r"|0,000000.0000|e+|0|", 123456.789, r"|0,123,456.7890|e|+0|"), + (r"|?|e+|?|", 123456.789, r"|1|e|+5|"), + (r"|?.?|e+|?|", 123456.789, r"|1.2|e|+5|"), + (r"|????.????|e+|?|", 123456.789, r"| 12.3457|e|+4|"), + (r"|???????.????|e+|?|", 123456.789, r"| 123456.789 |e|+0|"), + (r"|?,??????.????|e+|?|", 123456.789, r"| 123,456.789 |e|+0|"), + (r"|#.#|E-|#|", 123456.789, r"|1.2|E|5|"), + (r"|#.#|E+|#|", 123456.789, r"|1.2|E|+5|"), + (r"|#,e-#|", 1.23456789e-05, r"|1e-5|"), + (r"|#e-#,|", 1.23456789e-05, r"|1e-5|"), + (r"|#%e-#|", 1.23456789e-05, r"|1%e-5|"), + (r"|#e-#%|", 1.23456789e-05, r"|1e-5%|"), + (r"|#|e-|#|", 1.23456789e-05, r"|1|e|-5|"), + (r"|#.#|e-|#|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|####.####|e-|#|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|#######.####|e-|#|", 1.23456789e-05, r"|123.4568|e|-7|"), + (r"|#,######.####|e-|#|", 1.23456789e-05, r"|123.4568|e|-7|"), + (r"|0|e-|0|", 1.23456789e-05, r"|1|e|-5|"), + (r"|0.0|e-|0|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|0000.0000|e-|0|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|0000000.0000|e-|0|", 1.23456789e-05, r"|0000123.4568|e|-7|"), + (r"|0,000000.0000|e-|0|", 1.23456789e-05, r"|0,000,123.4568|e|-7|"), + (r"|?|e-|?|", 1.23456789e-05, r"|1|e|-5|"), + (r"|?.?|e-|?|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|????.????|e-|?|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|???????.????|e-|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|?,??????.????|e-|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|#,e+#|", 1.23456789e-05, r"|1e-5|"), + (r"|#e+#,|", 1.23456789e-05, r"|1e-5|"), + (r"|#%e+#|", 1.23456789e-05, r"|1%e-5|"), + (r"|#e+#%|", 1.23456789e-05, r"|1e-5%|"), + (r"|#|e+|#|", 1.23456789e-05, r"|1|e|-5|"), + (r"|#.#|e+|#|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|#.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"), + (r"|##.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"), + (r"|###.####|e+|#|", 0.000123456789, r"|123.4568|e|-6|"), + (r"|####.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"), + (r"|#####.####|e+|#|", 0.000123456789, r"|12.3457|e|-5|"), + (r"|#######.####|e+|#|", 0.000123456789, r"|1234.5679|e|-7|"), + (r"|#.####|e+|#|", 1.23456789e-05, r"|1.2346|e|-5|"), + (r"|##.####|e+|#|", 1.23456789e-05, r"|12.3457|e|-6|"), + (r"|###.####|e+|#|", 1.23456789e-05, r"|12.3457|e|-6|"), + (r"|####.####|e+|#|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|#####.####|e+|#|", 1.23456789e-05, r"|1.2346|e|-5|"), + (r"|#######.####|e+|#|", 1.23456789e-05, r"|123.4568|e|-7|"), + (r"|#.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"), + (r"|##.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"), + (r"|###.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"), + (r"|####.####|e+|#|", 1.23456789e-06, r"|123.4568|e|-8|"), + (r"|#####.####|e+|#|", 1.23456789e-06, r"|12345.6789|e|-10|"), + (r"|#######.####|e+|#|", 1.23456789e-06, r"|12.3457|e|-7|"), + (r"|#,######.####|e+|#|", 1.23456789e-06, r"|12.3457|e|-7|"), + (r"|0|e+|0|", 1.23456789e-05, r"|1|e|-5|"), + (r"|0.0|e+|0|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|0000.0000|e+|0|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|0000000.0000|e+|0|", 1.23456789e-05, r"|0000123.4568|e|-7|"), + (r"|0,000000.0000|e+|0|", 1.23456789e-05, r"|0,000,123.4568|e|-7|"), + (r"|?|e+|?|", 1.23456789e-05, r"|1|e|-5|"), + (r"|?.?|e+|?|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|????.????|e+|?|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|???????.????|e+|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|?,??????.????|e+|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|#.#|E-|#|", 1.23456789e-05, r"|1.2|E|-5|"), + (r"|#.#|E+|#|", 1.23456789e-05, r"|1.2|E|-5|"), + (r"|#,e-#|", 0.0, r"|0e0|"), + (r"|#e-#,|", 0.0, r"|0e0|"), + (r"|#%e-#|", 0.0, r"|0%e0|"), + (r"|#e-#%|", 0.0, r"|0e0%|"), + (r"|#|e-|#|", 0.0, r"|0|e|0|"), + (r"|#.#|e-|#|", 0.0, r"|0.|e|0|"), + (r"|####.####|e-|#|", 0.0, r"|0000.|e|0|"), + (r"|#######.####|e-|#|", 0.0, r"|0000000.|e|0|"), + (r"|#,######.####|e-|#|", 0.0, r"|0,000,000.|e|0|"), + (r"|0|e-|0|", 0.0, r"|0|e|0|"), + (r"|0.0|e-|0|", 0.0, r"|0.0|e|0|"), + (r"|0000.0000|e-|0|", 0.0, r"|0000.0000|e|0|"), + (r"|0000000.0000|e-|0|", 0.0, r"|0000000.0000|e|0|"), + (r"|0,000000.0000|e-|0|", 0.0, r"|0,000,000.0000|e|0|"), + (r"|?|e-|?|", 0.0, r"|0|e|0|"), + (r"|?.?|e-|?|", 0.0, r"|0. |e|0|"), + (r"|????.????|e-|?|", 0.0, r"|0000. |e|0|"), + (r"|???????.????|e-|?|", 0.0, r"|0000000. |e|0|"), + (r"|?,??????.????|e-|?|", 0.0, r"|0,000,000. |e|0|"), + (r"|#,e+#|", 0.0, r"|0e+0|"), + (r"|#e+#,|", 0.0, r"|0e+0|"), + (r"|#%e+#|", 0.0, r"|0%e+0|"), + (r"|#e+#%|", 0.0, r"|0e+0%|"), + (r"|#|e+|#|", 0.0, r"|0|e|+0|"), + (r"|#.#|e+|#|", 0.0, r"|0.|e|+0|"), + (r"|####.####|e+|#|", 0.0, r"|0000.|e|+0|"), + (r"|#######.####|e+|#|", 0.0, r"|0000000.|e|+0|"), + (r"|#,######.####|e+|#|", 0.0, r"|0,000,000.|e|+0|"), + (r"|0|e+|0|", 0.0, r"|0|e|+0|"), + (r"|0.0|e+|0|", 0.0, r"|0.0|e|+0|"), + (r"|0000.0000|e+|0|", 0.0, r"|0000.0000|e|+0|"), + (r"|0000000.0000|e+|0|", 0.0, r"|0000000.0000|e|+0|"), + (r"|0,000000.0000|e+|0|", 0.0, r"|0,000,000.0000|e|+0|"), + (r"|?|e+|?|", 0.0, r"|0|e|+0|"), + (r"|?.?|e+|?|", 0.0, r"|0. |e|+0|"), + (r"|????.????|e+|?|", 0.0, r"|0000. |e|+0|"), + (r"|???????.????|e+|?|", 0.0, r"|0000000. |e|+0|"), + (r"|?,??????.????|e+|?|", 0.0, r"|0,000,000. |e|+0|"), + (r"|#.#|E-|#|", 0.0, r"|0.|E|0|"), + (r"|#.#|E+|#|", 0.0, r"|0.|E|+0|"), + (r"|#,e-#|", -123456.789, r"-|1e5|"), + (r"|#e-#,|", -123456.789, r"-|1e5|"), + (r"|#%e-#|", -123456.789, r"-|1%e5|"), + (r"|#e-#%|", -123456.789, r"-|1e5%|"), + (r"|#|e-|#|", -123456.789, r"-|1|e|5|"), + (r"|#.#|e-|#|", -123456.789, r"-|1.2|e|5|"), + (r"|####.####|e-|#|", -123456.789, r"-|12.3457|e|4|"), + (r"|#######.####|e-|#|", -123456.789, r"-|123456.789|e|0|"), + (r"|#,######.####|e-|#|", -123456.789, r"-|123,456.789|e|0|"), + (r"|0|e-|0|", -123456.789, r"-|1|e|5|"), + (r"|0.0|e-|0|", -123456.789, r"-|1.2|e|5|"), + (r"|0000.0000|e-|0|", -123456.789, r"-|0012.3457|e|4|"), + (r"|0000000.0000|e-|0|", -123456.789, r"-|0123456.7890|e|0|"), + (r"|0,000000.0000|e-|0|", -123456.789, r"-|0,123,456.7890|e|0|"), + (r"|?|e-|?|", -123456.789, r"-|1|e|5|"), + (r"|?.?|e-|?|", -123456.789, r"-|1.2|e|5|"), + (r"|????.????|e-|?|", -123456.789, r"-| 12.3457|e|4|"), + (r"|???????.????|e-|?|", -123456.789, r"-| 123456.789 |e|0|"), + (r"|?,??????.????|e-|?|", -123456.789, r"-| 123,456.789 |e|0|"), + (r"|#,e+#|", -123456.789, r"-|1e+5|"), + (r"|#e+#,|", -123456.789, r"-|1e+5|"), + (r"|#%e+#|", -123456.789, r"-|1%e+5|"), + (r"|#e+#%|", -123456.789, r"-|1e+5%|"), + (r"|#|e+|#|", -123456.789, r"-|1|e|+5|"), + (r"|#.#|e+|#|", -123456.789, r"-|1.2|e|+5|"), + (r"|####.####|e+|#|", -123456.789, r"-|12.3457|e|+4|"), + (r"|#######.####|e+|#|", -123456.789, r"-|123456.789|e|+0|"), + (r"|#,######.####|e+|#|", -123456.789, r"-|123,456.789|e|+0|"), + (r"|0|e+|0|", -123456.789, r"-|1|e|+5|"), + (r"|0.0|e+|0|", -123456.789, r"-|1.2|e|+5|"), + (r"|0000.0000|e+|0|", -123456.789, r"-|0012.3457|e|+4|"), + (r"|0000000.0000|e+|0|", -123456.789, r"-|0123456.7890|e|+0|"), + (r"|0,000000.0000|e+|0|", -123456.789, r"-|0,123,456.7890|e|+0|"), + (r"|?|e+|?|", -123456.789, r"-|1|e|+5|"), + (r"|?.?|e+|?|", -123456.789, r"-|1.2|e|+5|"), + (r"|????.????|e+|?|", -123456.789, r"-| 12.3457|e|+4|"), + (r"|???????.????|e+|?|", -123456.789, r"-| 123456.789 |e|+0|"), + (r"|?,??????.????|e+|?|", -123456.789, r"-| 123,456.789 |e|+0|"), + (r"|#.#|E-|#|", -123456.789, r"-|1.2|E|5|"), + (r"|#.#|E+|#|", -123456.789, r"-|1.2|E|+5|"), + (r"|#,e-#|", -1.23456789e-05, r"-|1e-5|"), + (r"|#e-#,|", -1.23456789e-05, r"-|1e-5|"), + (r"|#%e-#|", -1.23456789e-05, r"-|1%e-5|"), + (r"|#e-#%|", -1.23456789e-05, r"-|1e-5%|"), + (r"|#|e-|#|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|#.#|e-|#|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|####.####|e-|#|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|#######.####|e-|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|#,######.####|e-|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|0|e-|0|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|0.0|e-|0|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|0000.0000|e-|0|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|0000000.0000|e-|0|", -1.23456789e-05, r"-|0000123.4568|e|-7|"), + (r"|0,000000.0000|e-|0|", -1.23456789e-05, r"-|0,000,123.4568|e|-7|"), + (r"|?|e-|?|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|?.?|e-|?|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|????.????|e-|?|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|???????.????|e-|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|?,??????.????|e-|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|#,e+#|", -1.23456789e-05, r"-|1e-5|"), + (r"|#e+#,|", -1.23456789e-05, r"-|1e-5|"), + (r"|#%e+#|", -1.23456789e-05, r"-|1%e-5|"), + (r"|#e+#%|", -1.23456789e-05, r"-|1e-5%|"), + (r"|#|e+|#|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|#.#|e+|#|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|####.####|e+|#|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|#######.####|e+|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|#,######.####|e+|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|0|e+|0|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|0.0|e+|0|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|0000.0000|e+|0|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|0000000.0000|e+|0|", -1.23456789e-05, r"-|0000123.4568|e|-7|"), + (r"|0,000000.0000|e+|0|", -1.23456789e-05, r"-|0,000,123.4568|e|-7|"), + (r"|?|e+|?|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|?.?|e+|?|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|????.????|e+|?|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|???????.????|e+|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|?,??????.????|e+|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|#.#|E-|#|", -1.23456789e-05, r"-|1.2|E|-5|"), + (r"|#.#|E+|#|", -1.23456789e-05, r"-|1.2|E|-5|"), + (r"|#.#|e+|#|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|#####|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|0|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|000|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|0000|", 1.2345678e+142, r"|1.2|e|+0142|"), + (r"|#.#|e+|00000|", 1.2345678e+142, r"|1.2|e|+00142|"), + (r"|#.#|e+|?|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|???|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|????|", 1.2345678e+142, r"|1.2|e|+ 142|"), + (r"|#.#|e+|?????|", 1.2345678e+142, r"|1.2|e|+ 142|"), + (r"|#.#|e+|#|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|#####|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|0|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|000|", 0.0, r"|0.|e|+000|"), + (r"|#.#|e+|0000|", 0.0, r"|0.|e|+0000|"), + (r"|#.#|e+|00000|", 0.0, r"|0.|e|+00000|"), + (r"|#.#|e+|?|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|???|", 0.0, r"|0.|e|+ 0|"), + (r"|#.#|e+|????|", 0.0, r"|0.|e|+ 0|"), + (r"|#.#|e+|?????|", 0.0, r"|0.|e|+ 0|"), + (r"|#.#|e+|#|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|#####|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|0|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|000|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|0000|", 1.2345678e-142, r"|1.2|e|-0142|"), + (r"|#.#|e+|00000|", 1.2345678e-142, r"|1.2|e|-00142|"), + (r"|#.#|e+|000,00|", 1.2345678e-142, r"|1.2|e|-00142|"), + (r"|#.#|e+|?|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|???|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|????|", 1.2345678e-142, r"|1.2|e|- 142|"), + (r"|#.#|e+|?????|", 1.2345678e-142, r"|1.2|e|- 142|"), + (r"#,##0.00e+00", 3.14159, r"3.14e+00"), +]; + +#[test] +fn poi_number_format_approx_tests() { + let wrong: Vec = CASES + .iter() + .filter_map(|&(code, v, want)| { + let got = text_function(&Value::Number(v), code, DateSystem::D1900); + (got.as_deref() != Ok(want)).then(|| format!("TEXT({v}, {code:?}) = {got:?}, Excel {want:?}")) + }) + .collect(); + assert!(wrong.is_empty(), "{} of {} cases differ:\n{}", wrong.len(), CASES.len(), wrong.join("\n")); +} From 5524e2520a2f9d303834c56455add045c247c58b Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:36:37 -0400 Subject: [PATCH 48/86] Functions: COUNTIF family returns #VALUE! for criteria over 255 characters Excel rejects a text criterion longer than 255 characters in COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS with #VALUE!; GridCraft matched it anyway. Found by the calc oracle on an Excel-saved report where a 268-character concatenated criterion caches #VALUE!. Co-Authored-By: Claude Opus 5.5 --- crates/functions/src/criteria.rs | 13 +++++++++++++ crates/functions/src/math.rs | 18 +++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 20410c8a9..db010fc68 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -5,6 +5,9 @@ use std::cmp::Ordering; use gridcraft_core::parse::parse_number_text; use gridcraft_core::{CellError, Value, compare_numbers, compare_text}; +/// Longest text criterion COUNTIF and its relatives accept; a longer one is `#VALUE!`. +const MAX_CRITERION_CHARS: usize = 255; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Op { Eq, @@ -48,6 +51,16 @@ impl Criterion { } } + /// Parses the criteria argument of COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS: + /// like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + pub(crate) fn parse_arg(v: &Value) -> Result { + match v { + Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), + Value::Text(t) if t.chars().nth(MAX_CRITERION_CHARS).is_some() => Err(CellError::Value), + other => Ok(Criterion::parse(other)), + } + } + fn parse_text(t: &str) -> Criterion { let (op, rest) = if let Some(r) = t.strip_prefix(">=") { (Op::Ge, r) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index bab219ead..1233a4027 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -957,7 +957,7 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R R { fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; num_val(range.iter().filter(|v| c.matches(v)).count() as f64) } @@ -1026,7 +1026,7 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { /// the evaluator does that resizing (it has the references), so cells line up one to one here. fn if_numbers(a: &[Arg]) -> R> { let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None }; let mut out = Vec::new(); for r in 0..range.rows { @@ -1643,4 +1643,16 @@ mod tests { is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); } + + #[test] + fn criteria_longer_than_255_chars_are_value_errors() { + let ok = "x".repeat(255); + let long = "x".repeat(256); + let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]); + let qty = col(&[1.0, 2.0]); + close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("SUMIFS", vec![rf(qty), rf(cells), t(&long)]), CellError::Value); + } } From caf31932611bc2e774c294a1c9809836c5c77882 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:19:29 -0400 Subject: [PATCH 49/86] Functions: *IFS with two or more criteria pairs accept criteria over 255 characters, as in Excel --- crates/functions/src/criteria.rs | 5 +++-- crates/functions/src/math.rs | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index db010fc68..2c87a9abf 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -51,8 +51,9 @@ impl Criterion { } } - /// Parses the criteria argument of COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS: - /// like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + /// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS + /// functions: like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + /// The *IFS functions with two or more criteria pairs use `parse`: Excel doesn't limit them. pub(crate) fn parse_arg(v: &Value) -> Result { match v { Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 1233a4027..75cea937d 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -950,6 +950,10 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R R0")]), 1.0); + close(ev("COUNTIFS", vec![rf(qty.clone()), t(">0"), rf(cells.clone()), t(&long)]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty), t(">0")]), 2.0); } } From 531dc5a5aa243ad0eaba439233d39f75d0fc3fd1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 20:21:19 +0200 Subject: [PATCH 50/86] functions: MODE comments name Excel 365 as the reference, not POI's cached values (#736) --- crates/functions/src/stat.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 4afa10825..2fc11acdf 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -79,11 +79,12 @@ fn mode_sngl(v: &[f64]) -> R { } /// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers -/// count, like [`numbers`]. Scalar arguments differ from SUM: text and logical values are skipped -/// whether typed or referenced (even numeric text such as `"1"`, so `MODE(1,"1",2,3)` is `#N/A`, -/// and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference to a blank cell is -/// `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to right, so the -/// first error wins. +/// count, like [`numbers`]. Scalar arguments differ from SUM: in Excel 365, text and logical +/// values are skipped whether typed or referenced (even numeric text such as `"1"`, so +/// `MODE(1,"1",2,3)` is `#N/A`, and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference +/// to a blank cell is `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to +/// right, so the first error wins. The results cached in POI's FormulaEvalTestData come from an +/// older Excel that answered `#VALUE!` for typed text; they are not the reference here. fn mode_numbers(args: &[Arg]) -> R> { let mut out = Vec::new(); for a in args { @@ -2279,9 +2280,11 @@ mod tests { assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); } - /// MODE argument semantics from the Excel-cached results in FormulaEvalTestData (row "Mode"): + /// MODE argument semantics in Excel 365, on the inputs of FormulaEvalTestData (row "Mode"): /// text is skipped, typed or referenced, but a single-cell reference to a blank cell is - /// `#VALUE!`; the first error from the left wins. + /// `#VALUE!`; the first error from the left wins. The values cached in POI's file come from an + /// older Excel and say `#VALUE!` for `MODE(C9,E16,"1",B8)`, `MODE(B8,"1",2,3)` and + /// `MODE("1",1,#DIV/0!,2,3)`; Excel 365 gives `#N/A`, `#N/A` and `#DIV/0!`. #[test] fn mode_scalar_arguments() { let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]); From 47c0af3f1b8887c6ace710b8a1fc047bb41e6b91 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:34:07 +0200 Subject: [PATCH 51/86] Data: Forecast Sheet Forecast Start option (#785) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/forecast.rs | 110 ++++++++++++++++++++++++------ crates/ui-egui/src/dialogs.rs | 1 + docs/gaps.md | 3 +- 4 files changed, 94 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..a928c6878 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Forecast Sheet gets Forecast Start: begin the forecast inside the history to compare it with the actuals (#785) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 13c120c85..9795ea67a 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Forecast Sheet", ["Data", "Forecast"], None, - "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", + "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", has_doc, forecast_sheet )] @@ -223,13 +223,39 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) { return Err(EngineError::Other("There are no values to forecast.".into())); } - let end = match p.get("end") { - Some(Json::Number(n)) => n.as_f64(), - Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { - Value::Number(end) => Some(end), - _ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))), - }, - _ => None, + let time_param = |key: &str| -> Result> { + match p.get(key) { + Some(Json::Number(n)) => Ok(n.as_f64()), + Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { + Value::Number(x) => Ok(Some(x)), + _ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))), + }, + None | Some(Json::Null) => Ok(None), + Some(Json::String(_)) => Ok(None), + Some(_) => Err(bad(CMD, format!("`{key}` is not a number or a date"))), + } + }; + let end = time_param("end")?; + // Forecast Start: the index (into `rows`) of the first forecast point, or `rows.len()` for the + // usual forecast after the history. + let fit = match time_param("forecastStart")? { + None => rows.len(), + Some(start) => { + let last_t = times.last().copied().unwrap_or(f64::NAN); + let tol = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0); + if tol(start, last_t) || timeline.after_last(1).is_some_and(|next| tol(start, next)) { + rows.len() + } else { + let i = times + .iter() + .position(|t| tol(*t, start)) + .ok_or_else(|| bad(CMD, "`forecastStart` is not on the timeline (it is a point of the history, before its last point)"))?; + if rows.get(..i).is_none_or(|h| h.iter().filter(|r| matches!(r.1, Value::Number(_))).count() < 2) { + return Err(EngineError::Other("The forecast start needs at least two values before it to fit the forecast.".into())); + } + i + } + } }; let cap = max_periods(rows.len()); let periods = match end { @@ -252,8 +278,9 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { // Columns: A timeline, B values, C forecast, D/E bounds. let n = rows.len() as u32; let last = n + 1; // 1-based row of the last history point + let fit_last = fit as u32 + 1; // 1-based row of the last point the model is fit to let end_row = last + periods; // 1-based row of the last forecast - let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col)); + let hist = |col: u32| format!("${0}$2:${0}${fit_last}", col_letters(col)); let mut tail = format!("{},{}", opts.seasonality, opts.fill); if let Some(a) = opts.aggregate { tail.push_str(&format!(",{a}")); @@ -305,22 +332,30 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { put(&mut sheet, r, 1, v.clone(), vstyle); } } - // The last history point starts the forecast lines, so they join the values. - formula(&mut sheet, n, 2, format!("=B{last}"), vstyle); + // The last point the model is fit to starts the forecast lines, so they join the values. + let join = fit_last - 1; // 0-based row + formula(&mut sheet, join, 2, format!("=B{fit_last}"), vstyle); if opts.confidence.is_some() { - formula(&mut sheet, n, 3, format!("=C{last}"), vstyle); - formula(&mut sheet, n, 4, format!("=C{last}"), vstyle); + formula(&mut sheet, join, 3, format!("=C{fit_last}"), vstyle); + formula(&mut sheet, join, 4, format!("=C{fit_last}"), vstyle); } let (tl, vl) = (hist(0), hist(1)); + let forecast_row = |sheet: &mut Sheet, r: u32| { + formula(sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle); + if let Some(c) = opts.confidence { + let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})"); + formula(sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle); + formula(sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle); + } + }; + // Hindcast: forecasts over the actuals from the start to the last history point. + for r in (fit_last + 1)..=last { + forecast_row(&mut sheet, r); + } for (k, t) in future.iter().enumerate() { let r = last + k as u32 + 1; // 1-based put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle); - formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle); - if let Some(c) = opts.confidence { - let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})"); - formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle); - formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle); - } + forecast_row(&mut sheet, r); } if opts.statistics { put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold); @@ -407,7 +442,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { if let Ok(d) = s.doc_mut() { d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default()); } - Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods})) + Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods, "fitted": fit})) } /// Forecast rows needed to reach `end` (rounded up to whole steps). @@ -545,4 +580,39 @@ mod tests { s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap(); assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err()); } + + #[test] + fn forecast_start_hindcasts_over_the_actuals() { + let mut s = session_with_history(); // t = 1..16 in A2:A17 + let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": 13, "periods": 2, "confidenceInterval": false})).unwrap(); + assert_eq!(r["fitted"], 12); + // Fit to t = 1..12 (rows 2..13); the line joins at row 13 and forecasts rows 14..19. + assert_eq!(formula(&s, 0, "C13"), "B13"); + assert_eq!(value(&s, 0, "C13"), value(&s, 0, "B13")); + assert_eq!(formula(&s, 0, "C14"), "FORECAST.ETS(A14,$B$2:$B$13,$A$2:$A$13,1,1)"); + assert_eq!(formula(&s, 0, "C19"), "FORECAST.ETS(A19,$B$2:$B$13,$A$2:$A$13,1,1)"); + // The exact seasonal series is recovered, and the actual is still shown next to it. + let c14 = value(&s, 0, "C14").as_f64().unwrap(); + assert!((c14 - 39.0).abs() < 1e-6, "{c14}"); // t = 13: 10 + 26 + 3 + assert!(value(&s, 0, "B14").as_f64().is_some()); + // Start at the last point or one step later: as without forecastStart. + for start in [json!(16), json!(17)] { + let r = s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "forecastStart": start, "periods": 2})).unwrap(); + assert_eq!(r["fitted"], 16); + } + assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)"); + } + + #[test] + fn forecast_start_invalid_values_are_errors() { + let mut s = session_with_history(); + let sheets = s.doc().unwrap().wb.sheets.len(); + for bad in [json!(0), json!(2), json!(3.5), json!(18), json!(1000), json!("abc"), json!([1]), json!(1e308)] { + assert!(s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets); + // Blank or null behaves like no start; empty params unchanged. + let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": ""})).unwrap(); + assert_eq!(r["fitted"], 16); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c68e..1aabd9d84 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -415,6 +415,7 @@ impl Dialog { Text { key: "timeline", label: "Timeline range (blank: first column of the selection):" }, Text { key: "values", label: "Values range (blank: second column of the selection):" }, Text { key: "end", label: "Forecast end (date or number; blank: a third of the history):" }, + Text { key: "forecastStart", label: "Forecast start (a date or number in the history; blank: after the last point):" }, Check { key: "confidenceInterval", label: "Confidence interval" }, Text { key: "confidence", label: "Confidence level (%):" }, Text { key: "seasonality", label: "Seasonality (auto, 0 for none, or a season length):" }, diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b899..eefab422d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -106,7 +106,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) - `data.solver`, `data.analysisToolPak` missing. Forecast Sheet (`data.forecastSheet`) and the - FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 15–25 h. + FORECAST.ETS family are in, including Forecast Start (hindcast, #785). **Estimate:** 15–25 h. ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. @@ -160,6 +160,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | From 4c2bcd637c97a6b065404edf44b4b042d2315cd9 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:34:18 -0400 Subject: [PATCH 52/86] Functions: *IFS with two or more criteria pairs match the first 255 characters of a longer criterion, as in Excel --- crates/functions/src/criteria.rs | 16 +++++++++++++++- crates/functions/src/math.rs | 21 ++++++++++++--------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 2c87a9abf..aa571773c 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -53,7 +53,6 @@ impl Criterion { /// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS /// functions: like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. - /// The *IFS functions with two or more criteria pairs use `parse`: Excel doesn't limit them. pub(crate) fn parse_arg(v: &Value) -> Result { match v { Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), @@ -62,6 +61,21 @@ impl Criterion { } } + /// Parses a criteria argument of an *IFS function with two or more criteria pairs. Excel + /// doesn't reject a longer text criterion there: it matches its first 255 characters + /// (checked on Excel 2016 and Microsoft 365 with COUNTIFS, SUMIFS, AVERAGEIFS, MAXIFS and + /// MINIFS). + pub(crate) fn parse_arg_truncated(v: &Value) -> Criterion { + match v { + Value::Array(a) => Criterion::parse_arg_truncated(a.data.first().unwrap_or(&Value::Empty)), + Value::Text(t) => match t.char_indices().nth(MAX_CRITERION_CHARS) { + Some((end, _)) => Criterion::parse_text(&t[..end]), + None => Criterion::parse_text(t), + }, + other => Criterion::parse(other), + } + } + fn parse_text(t: &str) -> Criterion { let (op, rest) = if let Some(r) = t.strip_prefix(">=") { (Op::Ge, r) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 75cea937d..8c67aa87b 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -950,10 +950,9 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R R0")]), 1.0); - close(ev("COUNTIFS", vec![rf(qty.clone()), t(">0"), rf(cells.clone()), t(&long)]), 1.0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty), t(">0")]), 2.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0); + close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); } } From 26ed66cfe1cd8de9f936e1c4512994cf95c5b90f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Marcin=20Brzuchalski?= Date: Mon, 12 Oct 2026 20:35:10 +0200 Subject: [PATCH 53/86] i18n: look up the Format Cells dialog labels in the catalog The Format Cells dialog drew English literals in every language. Its tabs, labels, options and protection note now go through Language::tr, as do dialog titles and the shared OK/Cancel buttons. The Fill tab is keyed Format Cells|Fill because the Alignment tab's Fill is a different word in some languages. d.tab keeps the English name. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 91 +++++++++++++++++------------ crates/ui-egui/tests/interaction.rs | 19 ++++++ docs/localization-parity.md | 6 +- 3 files changed, 78 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c68e..ade096853 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -891,7 +891,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "solver" => 460.0, _ => 380.0, }; - let window = egui::Window::new(d.title.clone()) + let window = egui::Window::new(app.ui.language.tr(&d.title)) .id(egui::Id::new("dialog").with(&d.name)) .collapsible(false) .resizable(false) @@ -1223,11 +1223,15 @@ fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent)); + let lang = crate::i18n::current(ui.ctx()); + let ok = ui.add( + egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)) + .fill(crate::theme::Tokens::get(ui.ctx()).accent), + ); if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { *confirm = true; } - if ui.button(" Cancel ").clicked() { + if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { *open = false; } }); @@ -1362,9 +1366,20 @@ fn macros(ui: &mut egui::Ui, d: &mut Dialog) { } fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn + // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a + // different word in some languages. + let lang = app.ui.language; ui.horizontal(|ui| { - for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] { - if ui.selectable_label(d.tab == tab, tab).clicked() { + for (tab, key) in [ + ("Number", "Number"), + ("Alignment", "Alignment"), + ("Font", "Font"), + ("Border", "Border"), + ("Fill", "Format Cells|Fill"), + ("Protection", "Protection"), + ] { + if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() { d.tab = tab.to_string(); } } @@ -1376,20 +1391,20 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: "Number" => { let mut code = st.num_fmt.as_str().to_string(); ui.columns(2, |cols| { - cols[0].label(egui::RichText::new("Category:").strong()); + cols[0].label(egui::RichText::new(lang.tr("Category:")).strong()); for name in ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] { let c = gridcraft_engine::cmd::format::format_code_for(name).to_string(); - if cols[0].selectable_label(code == c, name).clicked() { + if cols[0].selectable_label(code == c, lang.tr(name)).clicked() { code = c; } } - cols[1].label(egui::RichText::new("Sample").strong()); + cols[1].label(egui::RichText::new(lang.tr("Sample")).strong()); let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); cols[1].add_space(8.0); - cols[1].label("Type (custom format code):"); + cols[1].label(lang.tr("Type (custom format code):")); cols[1].text_edit_singleline(&mut code); for c in [ "0", @@ -1418,7 +1433,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } "Alignment" => { use gridcraft_engine::model::{HAlign, VAlign}; - ui.label(egui::RichText::new("Horizontal").strong()); + ui.label(egui::RichText::new(lang.tr("Horizontal")).strong()); ui.horizontal_wrapped(|ui| { for (h, n) in [ (HAlign::General, "General"), @@ -1430,10 +1445,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (HAlign::CenterAcross, "Center Across Selection"), (HAlign::Distributed, "Distributed"), ] { - ui.radio_value(&mut st.align.h, h, n); + ui.radio_value(&mut st.align.h, h, lang.tr(n)); } }); - ui.label(egui::RichText::new("Vertical").strong()); + ui.label(egui::RichText::new(lang.tr("Vertical")).strong()); ui.horizontal_wrapped(|ui| { for (v, n) in [ (VAlign::Top, "Top"), @@ -1442,29 +1457,29 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (VAlign::Justify, "Justify"), (VAlign::Distributed, "Distributed"), ] { - ui.radio_value(&mut st.align.v, v, n); + ui.radio_value(&mut st.align.v, v, lang.tr(n)); } }); let mut indent = st.align.indent as f32; - ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text("Indent")); + ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent"))); st.align.indent = indent as u8; let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 }; - ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text("Orientation (degrees)")); + ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)"))); st.align.rotation = rot as i16; - ui.checkbox(&mut st.align.wrap, "Wrap text"); - ui.checkbox(&mut st.align.shrink, "Shrink to fit"); + ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); + ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); } "Font" => { - egui::ComboBox::from_label("Font").selected_text(st.font.name.clone()).show_ui(ui, |ui| { + egui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| { for f in crate::fonts::families() { ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); } }); - ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text("Size")); + ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size"))); ui.horizontal(|ui| { - ui.checkbox(&mut st.font.bold, "Bold"); - ui.checkbox(&mut st.font.italic, "Italic"); - ui.checkbox(&mut st.font.strike, "Strikethrough"); + ui.checkbox(&mut st.font.bold, lang.tr("Bold")); + ui.checkbox(&mut st.font.italic, lang.tr("Italic")); + ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough")); }); use gridcraft_engine::model::{Underline, VertAlign}; ui.horizontal(|ui| { @@ -1475,17 +1490,17 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (Underline::SingleAccounting, "Single Accounting"), (Underline::DoubleAccounting, "Double Accounting"), ] { - ui.radio_value(&mut st.font.underline, u, n); + ui.radio_value(&mut st.font.underline, u, lang.tr(n)); } }); ui.horizontal(|ui| { - ui.radio_value(&mut st.font.vert, VertAlign::Baseline, "Normal"); - ui.radio_value(&mut st.font.vert, VertAlign::Superscript, "Superscript"); - ui.radio_value(&mut st.font.vert, VertAlign::Subscript, "Subscript"); + ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); + ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); + ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); }); - ui.label("Color:"); + ui.label(lang.tr("Color:")); let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "Automatic") { + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) { st.font.color = if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() }; } @@ -1494,10 +1509,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: use gridcraft_engine::model::{BorderLine, BorderStyle}; let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto }; ui.horizontal(|ui| { - if ui.button("None").clicked() { + if ui.button(lang.tr("None")).clicked() { st.borders = Default::default(); } - if ui.button("Outline").clicked() { + if ui.button(lang.tr("Outline")).clicked() { st.borders.top = line; st.borders.bottom = line; st.borders.left = line; @@ -1512,6 +1527,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: ("Diagonal ↘", &mut st.borders.diag_down), ("Diagonal ↗", &mut st.borders.diag_up), ] { + let n = lang.tr(n); ui.horizontal(|ui| { ui.label(format!("{n}:")); for (s, label) in [ @@ -1523,6 +1539,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (BorderStyle::Dotted, "dotted"), (BorderStyle::Double, "double"), ] { + let label = lang.tr(label); let response = ui.selectable_label(slot.style == s, label); response .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}"))); @@ -1537,8 +1554,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } "Fill" => { let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - ui.label("Background Color:"); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") { + ui.label(lang.tr("Background Color:")); + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) { st.fill = if c == "none" { Default::default() } else { @@ -1547,11 +1564,13 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } } "Protection" => { - ui.checkbox(&mut st.protection.locked, "Locked"); - ui.checkbox(&mut st.protection.hidden, "Hidden"); + ui.checkbox(&mut st.protection.locked, lang.tr("Locked")); + ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); ui.label( - egui::RichText::new("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet).") - .small(), + egui::RichText::new( + lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."), + ) + .small(), ); } _ => {} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 709393389..8e8eaaac0 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -279,6 +279,25 @@ fn language_switch_translates_the_ribbon_and_persists() { assert_eq!(h.state().ui.language, Language::En); } +#[test] +fn format_cells_draws_its_labels_in_the_interface_language() { + let mut h = harness(blank()); + h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap(); + h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); + h.run_steps(2); + assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated"); + assert!(h.query_by_label("Scientific").is_none(), "no English category is left"); + h.get_by_label("Wissenschaftlich").click(); + h.run_steps(2); + let style = &h.state().dialog.as_ref().unwrap().values["style"]; + assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code"); + h.state_mut().dialog = None; + h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap(); + h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); + h.run_steps(2); + assert!(h.query_by_label("Scientific").is_some(), "English is unchanged"); +} + #[test] fn name_manager_new_and_edit_open_the_name_dialog_and_come_back() { // #238: New… in the Name Manager did nothing (the manager put itself back over the dialog it diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e7d1de9f7..6f05a6085 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German ribbon catalog added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -14,7 +14,8 @@ right-to-left sheets and full complex-script support. Part of system language (closes #20) and a View-tab switcher, with English-keyed lookup tables: Japanese 268 labels, Simplified Chinese, Korean, Russian, Brazilian Portuguese, German and Czech 266 each. The tables are used by - the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`); dialogs, menus, + the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`), the Format Cells + dialog, every dialog title and the shared OK/Cancel buttons; the other dialogs, menus, context menus, task panes, status messages, command labels and function descriptions are still English literals. Untranslated labels fall back to English. - **About 2,200 user-visible strings** in all (heuristic source scan: ~1,700 UI and command @@ -77,6 +78,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 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-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | | 2026-10-11 | minor | Sheet Right-to-Left mirrors the grid (#146) | | 2026-10-11 | minor | Czech (cs) ribbon table: 266 labels (~12%), menus only | From 339f8ed35b0eab58068ffaa6be31c37ef9a54a83 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:57:02 +0200 Subject: [PATCH 54/86] Page Layout view: drag the margins on the rulers (#792) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 + crates/ui-egui/src/grid.rs | 3 + crates/ui-egui/src/i18n.rs | 4 + crates/ui-egui/src/i18n/cs.rs | 4 + crates/ui-egui/src/i18n/de.rs | 4 + crates/ui-egui/src/i18n/es.rs | 4 + crates/ui-egui/src/i18n/fr.rs | 4 + crates/ui-egui/src/i18n/ko.rs | 4 + crates/ui-egui/src/i18n/pt.rs | 4 + crates/ui-egui/src/i18n/ru.rs | 4 + crates/ui-egui/src/i18n/zh.rs | 4 + crates/ui-egui/src/paper_view.rs | 221 +++++++++++++++++++++++ crates/ui-egui/tests/page_layout_view.rs | 54 ++++++ docs/gaps.md | 4 +- docs/ui-parity.md | 3 +- 15 files changed, 320 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..6907953f7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Page Layout view: drag the margin edges on the rulers to set the page margins (resize cursor, live preview, value tooltip, one undo step, Esc cancels) (#792) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..04c924d8a 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -100,6 +100,8 @@ pub struct GridState { pub paper: Option, /// Header or footer being edited in place (Page Layout view). pub hf_edit: Option, + /// A page margin being dragged on a ruler (Page Layout view); committed on release. + pub ruler_drag: Option, /// Insert › Header & Footer: start editing the first page's header once Page Layout view is up. pub hf_start: bool, /// The grid geometry of the last frame (hit-testing for agents and tests). @@ -493,6 +495,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if paper { let probe = Geo::new(sh, grid_rect, scroll); if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { + let g = crate::paper_view::with_dragged_margin(app, si, g); return Geo::paged(sh, grid_rect, scroll, g); } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 406cc3dd5..27230b139 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -290,6 +290,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Select\nObjects", "オブジェクトの選択"), ("Themes", "テーマ"), ("Margins", "余白"), + ("Left Margin", "左余白"), + ("Right Margin", "右余白"), + ("Top Margin", "上余白"), + ("Bottom Margin", "下余白"), ("Size", "サイズ"), ("Print\nArea", "印刷範囲"), ("Set Print Area", "印刷範囲の設定"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index ca2e55942..96a5a1114 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -116,6 +116,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Vybrat\nobjekty", "Themes" => "Motivy", "Margins" => "Okraje", + "Left Margin" => "Levý okraj", + "Right Margin" => "Pravý okraj", + "Top Margin" => "Horní okraj", + "Bottom Margin" => "Dolní okraj", "Size" => "Velikost", "Print\nArea" => "Oblast\ntisku", "Set Print Area" => "Nastavit oblast tisku", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2ad37f675..30bff11c8 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -116,6 +116,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Objekte\nauswählen", "Themes" => "Designs", "Margins" => "Seitenränder", + "Left Margin" => "Linker Seitenrand", + "Right Margin" => "Rechter Seitenrand", + "Top Margin" => "Oberer Seitenrand", + "Bottom Margin" => "Unterer Seitenrand", "Size" => "Größe", "Print\nArea" => "Druck-\nbereich", "Set Print Area" => "Druckbereich festlegen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index da41bd711..b97cce843 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -117,6 +117,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Seleccionar\nobjetos", "Themes" => "Temas", "Margins" => "Márgenes", + "Left Margin" => "Margen izquierdo", + "Right Margin" => "Margen derecho", + "Top Margin" => "Margen superior", + "Bottom Margin" => "Margen inferior", "Size" => "Tamaño", "Print\nArea" => "Área de\nimpresión", "Set Print Area" => "Establecer área de impresión", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 7bf9fd100..1ecddfa80 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -124,6 +124,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Sélection d’objets", "Themes" => "Thèmes", "Margins" => "Marges", + "Left Margin" => "Marge gauche", + "Right Margin" => "Marge droite", + "Top Margin" => "Marge haute", + "Bottom Margin" => "Marge basse", "Size" => "Taille", "Print\nArea" => "Zone d’impression", "Set Print Area" => "Définir la zone d’impression", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index b684c7886..164273988 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "개체\n선택", "Themes" => "테마", "Margins" => "여백", + "Left Margin" => "왼쪽 여백", + "Right Margin" => "오른쪽 여백", + "Top Margin" => "위쪽 여백", + "Bottom Margin" => "아래쪽 여백", "Size" => "용지 크기", "Print\nArea" => "인쇄\n영역", "Set Print Area" => "인쇄 영역 설정", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index b554ef977..9d6a12890 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -122,6 +122,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Selecionar\nObjetos", "Themes" => "Temas", "Margins" => "Margens", + "Left Margin" => "Margem esquerda", + "Right Margin" => "Margem direita", + "Top Margin" => "Margem superior", + "Bottom Margin" => "Margem inferior", "Size" => "Tamanho", "Print\nArea" => "Área de\nImpressão", "Set Print Area" => "Definir Área de Impressão", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index c8f0445d8..221b8709e 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Выбрать\nобъекты", "Themes" => "Темы", "Margins" => "Поля", + "Left Margin" => "Левое поле", + "Right Margin" => "Правое поле", + "Top Margin" => "Верхнее поле", + "Bottom Margin" => "Нижнее поле", "Size" => "Размер", "Print\nArea" => "Область\nпечати", "Set Print Area" => "Задать область печати", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index d1cf704cb..477ab82aa 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "选择\n对象", "Themes" => "主题", "Margins" => "页边距", + "Left Margin" => "左边距", + "Right Margin" => "右边距", + "Top Margin" => "上边距", + "Bottom Margin" => "下边距", "Size" => "纸张大小", "Print\nArea" => "打印\n区域", "Set Print Area" => "设置打印区域", diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 110193a64..69784de3b 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -284,6 +284,7 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo commit(app); } let Some(pg) = geo.paper.clone() else { return }; + ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet); if std::mem::take(&mut app.grid.hf_start) { begin(app, wb.active_sheet, sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 }); } @@ -594,6 +595,197 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, ); } +/// A page edge that can be dragged on a ruler. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Edge { + Left, + Right, + Top, + Bottom, +} + +impl Edge { + /// Index into `PrintSettings::margins` / `PageGrid::margins`. + fn index(self) -> usize { + match self { + Edge::Left => 0, + Edge::Right => 1, + Edge::Top => 2, + Edge::Bottom => 3, + } + } + /// The `pageLayout.pageSetup` margin key. + fn key(self) -> &'static str { + ["left", "right", "top", "bottom"][self.index()] + } + fn label(self) -> &'static str { + ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin"][self.index()] + } + fn horizontal(self) -> bool { + matches!(self, Edge::Left | Edge::Right) + } +} + +/// A margin being dragged on a ruler: the page it started on and the previewed value (inches). +#[derive(Clone, Debug)] +pub struct RulerDrag { + pub sheet: usize, + pub edge: Edge, + /// Page column (horizontal ruler) or row (vertical ruler) the drag started on. + pub page: usize, + pub start_inches: f32, + pub inches: f32, +} + +/// Content that must stay between the margins, in points. +const MIN_CONTENT_PT: f64 = 36.0; +/// Screen distance within which a ruler margin edge is grabbed. +const EDGE_GRAB: f32 = 4.0; + +/// Margin (inches, rounded to 0.01) for an edge dragged to `rel_pt` points from the page's +/// left/top edge. The margin stays on the page and leaves [`MIN_CONTENT_PT`] between the two +/// margins of its axis. `None` for NaN/infinite input or a page without a size. +pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 4]) -> Option { + let page = f64::from(if edge.horizontal() { page_pt.0 } else { page_pt.1 }); + if !rel_pt.is_finite() || !page.is_finite() || page <= 0.0 { + return None; + } + let other = f64::from(margins_in.get(edge.index() ^ 1).copied().filter(|v| v.is_finite())?).max(0.0) * 72.0; + let raw = match edge { + Edge::Left | Edge::Top => rel_pt, + Edge::Right | Edge::Bottom => page - rel_pt, + }; + let max = (page - other - MIN_CONTENT_PT).max(0.0); + let pt = raw.clamp(0.0, max); + Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) +} + +/// The grid with a margin being dragged applied, so the page content follows the pointer. +pub(crate) fn with_dragged_margin(app: &SheetApp, si: usize, pg: Arc) -> Arc { + let Some(d) = app.grid.ruler_drag.as_ref().filter(|d| d.sheet == si) else { return pg }; + // Layout units per point (the print scale and pixel/point ratio). + let per_pt = pg.page_w / f64::from(pg.paper_pt.0); + let mut g = (*pg).clone(); + let v = f64::from(d.inches) * 72.0 * per_pt; + if let Some(m) = g.margins.get_mut(d.edge.index()).filter(|_| v.is_finite() && per_pt.is_finite()) { + *m = v; + } + Arc::new(g) +} + +/// Points per layout unit of the grid (paper size over the layout page size). +fn pt_per_unit(pg: &PageGrid) -> Option { + let k = f64::from(pg.paper_pt.0) / pg.page_w; + (k.is_finite() && k > 0.0).then_some(k) +} + +/// The margin edge of a visible page under `pos` on the horizontal (`h`) or vertical ruler. +fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize)> { + let ((i0, i1), (j0, j1)) = visible_pages(geo, pg); + let [ml, mr, mt, mb, ..] = pg.margins; + let mut best: Option<(f32, Edge, usize)> = None; + let mut consider = |d: f32, e: Edge, page: usize| { + if d <= EDGE_GRAB && best.is_none_or(|b| d < b.0) { + best = Some((d, e, page)); + } + }; + if h { + for j in j0..=j1.min(j0.saturating_add(200)) { + let (x, _, w, _) = pg.page_rect(0, j); + consider((screen(geo, x + ml, 0.0).x - pos.x).abs(), Edge::Left, j); + consider((screen(geo, x + w - mr, 0.0).x - pos.x).abs(), Edge::Right, j); + } + } else { + for i in i0..=i1.min(i0.saturating_add(200)) { + let (_, y, _, hh) = pg.page_rect(i, 0); + consider((screen(geo, 0.0, y + mt).y - pos.y).abs(), Edge::Top, i); + consider((screen(geo, 0.0, y + hh - mb).y - pos.y).abs(), Edge::Bottom, i); + } + } + best.map(|(_, e, p)| (e, p)) +} + +/// Dragging the margin edges on the rulers sets the page margins: a resize cursor and a tooltip +/// on hover, a live preview while dragging, one `pageLayout.pageSetup` (one undo step) on +/// release, Esc cancels. +fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid, sh: &Sheet, si: usize) { + let Some(outer) = app.grid.rect else { return }; + if geo.rect.top() - outer.top() < RULER - 0.5 { + app.grid.ruler_drag = None; + return; + } + // Leaving the sheet or the view drops the drag. + if app.grid.ruler_drag.as_ref().is_some_and(|d| d.sheet != si) { + app.grid.ruler_drag = None; + } + let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); + let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); + let margins_in = |sh: &Sheet| -> [f32; 4] { + let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); + [m(0), m(1), m(2), m(3)] + }; + let current = margins_in(sh); + let metric = metric(app); + let tip = |ui: &egui::Ui, app: &SheetApp, edge: Edge, inches: f32| { + let value = if metric { format!("{:.2} cm", inches * 2.54) } else { format!("{inches:.2} in") }; + let text = format!("{}: {value}", app.ui.language.tr(edge.label())); + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("ruler_margin_tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(text); + }); + }; + let cursor = |ui: &egui::Ui, edge: Edge| { + ui.ctx().set_cursor_icon(if edge.horizontal() { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + }; + if let Some(d) = app.grid.ruler_drag.clone() { + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { + app.grid.ruler_drag = None; + return; + } + cursor(ui, d.edge); + let down = ui.input(|i| i.pointer.primary_down()); + let pos = ui.input(|i| i.pointer.latest_pos()); + let mut cur = d.clone(); + if let (Some(pos), Some(k)) = (pos, pt_per_unit(pg)) { + let rel = if d.edge.horizontal() { + let page_x = pg.page_x(d.page); + f64::from(geo.scroll.x) + f64::from((pos.x - geo.cells.left()) / geo.z) - page_x + } else { + let page_y = pg.page_y(d.page); + f64::from(geo.scroll.y) + f64::from((pos.y - geo.cells.top()) / geo.z) - page_y + }; + if let Some(v) = margin_from_drag(d.edge, rel * k, pg.paper_pt, current) { + cur.inches = v; + } + } + if down { + tip(ui, app, cur.edge, cur.inches); + app.grid.ruler_drag = Some(cur); + ui.ctx().request_repaint(); + } else { + app.grid.ruler_drag = None; + if (cur.inches - cur.start_inches).abs() > f32::EPSILON { + app.run_or_alert("pageLayout.pageSetup", json!({"sheet": si, "margins": {cur.edge.key(): cur.inches}})); + } + } + return; + } + for (resp, h) in [(&hresp, true), (&vresp, false)] { + let Some(pos) = resp.hover_pos().or_else(|| resp.interact_pointer_pos()) else { continue }; + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(pos) } else { pos }; + let Some((edge, page)) = edge_at(geo, pg, origin, h) else { continue }; + let value = current.get(edge.index()).copied().unwrap_or(0.5); + if resp.drag_started() { + app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value }); + cursor(ui, edge); + } else if resp.hovered() { + cursor(ui, edge); + tip(ui, app, edge, value); + } + } +} + /// The Header & Footer contextual ribbon tab. pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { use crate::widgets::big_button; @@ -667,3 +859,32 @@ fn sep(ui: &mut egui::Ui) { let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), egui::Sense::hover()); ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator)); } + +#[cfg(test)] +mod tests { + use super::*; + + const A4: (f32, f32) = (595.0, 842.0); + + #[test] + fn drag_maps_to_margins_and_clamps_inside_the_page() { + let m = [0.7, 0.7, 0.75, 0.75]; + // 72 pt from the left edge is one inch; from the right edge the margin is measured inward. + assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); + assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); + assert_eq!(margin_from_drag(Edge::Top, 108.0, A4, m), Some(1.5)); + // Dragged off the page: never negative. + assert_eq!(margin_from_drag(Edge::Left, -50.0, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.0)); + // Dragged across the opposite margin: stops a minimum content width short of it. + let max = margin_from_drag(Edge::Left, 10_000.0, A4, m).unwrap(); + assert!((f64::from(max) * 72.0 - (f64::from(A4.0) - 0.7 * 72.0 - MIN_CONTENT_PT)).abs() < 1.0, "{max}"); + // Hostile input is ignored, not clamped to something arbitrary. + assert_eq!(margin_from_drag(Edge::Left, f64::NAN, A4, m), None); + assert_eq!(margin_from_drag(Edge::Left, f64::INFINITY, A4, m), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, (0.0, 0.0), m), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7]), None); + // Oversized opposite margin leaves nothing to drag into. + assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0]), Some(0.0)); + } +} diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index f8850b3e2..632b9e222 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -162,3 +162,57 @@ fn insert_header_and_footer_switches_to_page_layout_view() { let e = h.state().grid.hf_edit.as_ref().expect("the header is being edited"); assert_eq!((e.footer, e.section, e.texts[0].as_str()), (false, 1, "Left")); } + +fn margins(h: &Harness) -> [f32; 6] { + h.state().session.doc().unwrap().wb.active().unwrap().print.margins +} + +fn mouse(h: &mut Harness, ev: Event) { + h.input_mut().events.push(ev); + h.step(); +} + +#[test] +fn dragging_a_ruler_edge_previews_commits_on_release_as_one_undo_step_and_esc_cancels() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start_margins = margins(&h); + let ruler_y = h.state().grid.rect.unwrap().top() + 9.0; + let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y); + // One inch further in = 96 layout pixels at 100% print scale; use the screen scale to be exact. + let per_inch = (72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z; + let target = pos2(edge.x + per_inch, ruler_y); + h.run_steps(40); + mouse(&mut h, Event::PointerMoved(edge)); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerMoved(pos2(edge.x + 5.0, ruler_y))); + mouse(&mut h, Event::PointerMoved(target)); + h.run_steps(2); + // Live preview: the page content moved, the document did not change yet. + let live = geo(&h).paper.unwrap().margins[0]; + assert!(live > pg.margins[0] + 1.0, "{live} vs {}", pg.margins[0]); + assert_eq!(margins(&h), start_margins); + let want = (start_margins[0] + 1.0 * 1.0).min(10.0); + mouse(&mut h, Event::PointerButton { pos: target, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let after = margins(&h); + assert!((after[0] - want).abs() < 0.03, "{after:?} want left {want}"); + assert_eq!(after[1..], start_margins[1..]); + // One undo step restores it. + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(margins(&h), start_margins); + // Esc during a drag cancels: nothing is committed. + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y); + h.run_steps(40); + mouse(&mut h, Event::PointerMoved(edge)); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerMoved(pos2(edge.x + per_inch, ruler_y))); + mouse(&mut h, Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(margins(&h), start_margins); +} diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b899..c595c1c8e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -110,8 +110,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. - Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging - margins on the ruler. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. + Margins are dragged on the rulers (#792). Remaining: pictures in headers/footers, Different First Page / Odd & Even pages. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. ### 15. Remaining functions and an oracle suite (F) - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. @@ -160,6 +159,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbccc..52dd3a344 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -28,7 +28,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | header/footer pictures, Different First Page / Odd & Even, ruler margin dragging missing (1–3 h) | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction @@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | From c4ad8e3a9bfea58156bb92df10b68c12a653dfa9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:01:09 +0200 Subject: [PATCH 55/86] Page Layout view: header/footer margins on the ruler, live re-pagination, right-to-left rulers (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/grid.rs | 2 +- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/paper_view.rs | 90 +++++++++++++------ crates/ui-egui/tests/page_layout_view.rs | 108 +++++++++++++++++++++++ docs/ui-parity.md | 2 +- 13 files changed, 191 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 04c924d8a..80af151ab 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -495,7 +495,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if paper { let probe = Geo::new(sh, grid_rect, scroll); if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { - let g = crate::paper_view::with_dragged_margin(app, si, g); + let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); return Geo::paged(sh, grid_rect, scroll, g); } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 27230b139..e2566a6d8 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -294,6 +294,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Right Margin", "右余白"), ("Top Margin", "上余白"), ("Bottom Margin", "下余白"), + ("Header Margin", "ヘッダーの余白"), + ("Footer Margin", "フッターの余白"), ("Size", "サイズ"), ("Print\nArea", "印刷範囲"), ("Set Print Area", "印刷範囲の設定"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 96a5a1114..bdcedbf4e 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -120,6 +120,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Pravý okraj", "Top Margin" => "Horní okraj", "Bottom Margin" => "Dolní okraj", + "Header Margin" => "Okraj záhlaví", + "Footer Margin" => "Okraj zápatí", "Size" => "Velikost", "Print\nArea" => "Oblast\ntisku", "Set Print Area" => "Nastavit oblast tisku", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 30bff11c8..75653e583 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -120,6 +120,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Rechter Seitenrand", "Top Margin" => "Oberer Seitenrand", "Bottom Margin" => "Unterer Seitenrand", + "Header Margin" => "Kopfzeilenabstand", + "Footer Margin" => "Fußzeilenabstand", "Size" => "Größe", "Print\nArea" => "Druck-\nbereich", "Set Print Area" => "Druckbereich festlegen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index b97cce843..0582110c7 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -121,6 +121,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Margen derecho", "Top Margin" => "Margen superior", "Bottom Margin" => "Margen inferior", + "Header Margin" => "Margen del encabezado", + "Footer Margin" => "Margen del pie de página", "Size" => "Tamaño", "Print\nArea" => "Área de\nimpresión", "Set Print Area" => "Establecer área de impresión", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 1ecddfa80..0db9df4c5 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -128,6 +128,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Marge droite", "Top Margin" => "Marge haute", "Bottom Margin" => "Marge basse", + "Header Margin" => "Marge de l'en-tête", + "Footer Margin" => "Marge du pied de page", "Size" => "Taille", "Print\nArea" => "Zone d’impression", "Set Print Area" => "Définir la zone d’impression", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 164273988..f3e1688d0 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "오른쪽 여백", "Top Margin" => "위쪽 여백", "Bottom Margin" => "아래쪽 여백", + "Header Margin" => "머리글 여백", + "Footer Margin" => "바닥글 여백", "Size" => "용지 크기", "Print\nArea" => "인쇄\n영역", "Set Print Area" => "인쇄 영역 설정", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 9d6a12890..d6185a515 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -126,6 +126,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Margem direita", "Top Margin" => "Margem superior", "Bottom Margin" => "Margem inferior", + "Header Margin" => "Margem do cabeçalho", + "Footer Margin" => "Margem do rodapé", "Size" => "Tamanho", "Print\nArea" => "Área de\nImpressão", "Set Print Area" => "Definir Área de Impressão", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 221b8709e..868f653b8 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Правое поле", "Top Margin" => "Верхнее поле", "Bottom Margin" => "Нижнее поле", + "Header Margin" => "Поле верхнего колонтитула", + "Footer Margin" => "Поле нижнего колонтитула", "Size" => "Размер", "Print\nArea" => "Область\nпечати", "Set Print Area" => "Задать область печати", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 477ab82aa..a9989f2e4 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "右边距", "Top Margin" => "上边距", "Bottom Margin" => "下边距", + "Header Margin" => "页眉边距", + "Footer Margin" => "页脚边距", "Size" => "纸张大小", "Print\nArea" => "打印\n区域", "Set Print Area" => "设置打印区域", diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 69784de3b..f560f133c 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -524,17 +524,21 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let minor = if metric { 2.0 } else { 4.0 }; let label_every = ((22.0 / unit).ceil() as usize).max(1); let font = theme::ui_font(9.5); - let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); + let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + // Right-to-left sheets: the horizontal ruler is mirrored with the pages (ticks run from the + // right margin leftwards). + let mx = |x: f32| geo.mirror_x(x); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window); let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg); - let [ml, mr, mt, mb, ..] = pg.margins; + let [ml, mr, mt, mb, mh, mf] = pg.margins; // One ruler span per page: margins shaded, the printable part light, ticks from the margin. let span = |p: &Painter, from: f32, to: f32, m0: f32, m1: f32, horizontal: bool, area: Rect| { let band = |a: f32, b: f32| { if horizontal { - Rect::from_min_max(pos2(a, area.top() + 2.0), pos2(b, area.bottom() - 2.0)) + let (a, b) = (mx(a), mx(b)); + Rect::from_min_max(pos2(a.min(b), area.top() + 2.0), pos2(a.max(b), area.bottom() - 2.0)) } else { Rect::from_min_max(pos2(area.left() + 2.0, a), pos2(area.right() - 2.0, b)) } @@ -551,6 +555,7 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, if pos > m1 + 0.5 { break; } + let pos = if horizontal { mx(pos) } else { pos }; let major = s.is_multiple_of(minor as usize); let len = if major { 6.0 @@ -587,6 +592,12 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let a = screen(geo, 0.0, y).y; let b = screen(geo, 0.0, y + h).y; span(p, a, b, screen(geo, 0.0, y + mt).y, screen(geo, 0.0, y + h - mb).y, false, vr); + // Header and footer margins: a small marker at each distance, grabbable like the margins. + let pc = p.with_clip_rect(vr); + for at in [screen(geo, 0.0, y + mh).y, screen(geo, 0.0, y + h - mf).y] { + let x = vr.left() + 2.0; + pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + 6.0, at)], t.header_text, Stroke::NONE)); + } } p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line)); p.line_segment( @@ -602,6 +613,10 @@ pub enum Edge { Right, Top, Bottom, + /// Distance of the header from the top of the page. + Header, + /// Distance of the footer from the bottom of the page. + Footer, } impl Edge { @@ -612,14 +627,16 @@ impl Edge { Edge::Right => 1, Edge::Top => 2, Edge::Bottom => 3, + Edge::Header => 4, + Edge::Footer => 5, } } /// The `pageLayout.pageSetup` margin key. fn key(self) -> &'static str { - ["left", "right", "top", "bottom"][self.index()] + ["left", "right", "top", "bottom", "header", "footer"][self.index()] } fn label(self) -> &'static str { - ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin"][self.index()] + ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin", "Header Margin", "Footer Margin"][self.index()] } fn horizontal(self) -> bool { matches!(self, Edge::Left | Edge::Right) @@ -635,6 +652,9 @@ pub struct RulerDrag { pub page: usize, pub start_inches: f32, pub inches: f32, + /// The sheet re-paginated with `inches` (page breaks and fit-to-page scale as a commit would + /// give them), rebuilt when the value changes. + preview: Option<(f32, Arc)>, } /// Content that must stay between the margins, in points. @@ -645,32 +665,40 @@ const EDGE_GRAB: f32 = 4.0; /// Margin (inches, rounded to 0.01) for an edge dragged to `rel_pt` points from the page's /// left/top edge. The margin stays on the page and leaves [`MIN_CONTENT_PT`] between the two /// margins of its axis. `None` for NaN/infinite input or a page without a size. -pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 4]) -> Option { +pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 6]) -> Option { let page = f64::from(if edge.horizontal() { page_pt.0 } else { page_pt.1 }); if !rel_pt.is_finite() || !page.is_finite() || page <= 0.0 { return None; } let other = f64::from(margins_in.get(edge.index() ^ 1).copied().filter(|v| v.is_finite())?).max(0.0) * 72.0; let raw = match edge { - Edge::Left | Edge::Top => rel_pt, - Edge::Right | Edge::Bottom => page - rel_pt, + Edge::Left | Edge::Top | Edge::Header => rel_pt, + Edge::Right | Edge::Bottom | Edge::Footer => page - rel_pt, }; let max = (page - other - MIN_CONTENT_PT).max(0.0); let pt = raw.clamp(0.0, max); Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) } -/// The grid with a margin being dragged applied, so the page content follows the pointer. -pub(crate) fn with_dragged_margin(app: &SheetApp, si: usize, pg: Arc) -> Arc { - let Some(d) = app.grid.ruler_drag.as_ref().filter(|d| d.sheet == si) else { return pg }; - // Layout units per point (the print scale and pixel/point ratio). - let per_pt = pg.page_w / f64::from(pg.paper_pt.0); - let mut g = (*pg).clone(); - let v = f64::from(d.inches) * 72.0 * per_pt; - if let Some(m) = g.margins.get_mut(d.edge.index()).filter(|_| v.is_finite() && per_pt.is_finite()) { - *m = v; +/// The grid while a margin is dragged: the sheet is re-paginated with the previewed margin, so +/// the content area, page breaks and the fit-to-page scale are those a commit would give. +pub(crate) fn with_dragged_margin(app: &mut SheetApp, wb: &Workbook, si: usize, pg: Arc) -> Arc { + let Some(d) = app.grid.ruler_drag.as_mut().filter(|d| d.sheet == si) else { return pg }; + if let Some((v, g)) = &d.preview + && (*v - d.inches).abs() < f32::EPSILON + { + return g.clone(); } - Arc::new(g) + let mut w = wb.clone(); + let Some(sheet) = w.sheets.get_mut(si) else { return pg }; + let Some(slot) = Arc::make_mut(sheet).print.margins.get_mut(d.edge.index()) else { return pg }; + *slot = d.inches; + let rows = pg.row_bands.last().map_or(0, |b| b.1); + let cols = pg.col_bands.last().map_or(0, |b| b.1); + let Some(g) = PageGrid::build(&w, si, rows, cols) else { return pg }; + let g = Arc::new(g); + d.preview = Some((d.inches, g.clone())); + g } /// Points per layout unit of the grid (paper size over the layout page size). @@ -682,7 +710,7 @@ fn pt_per_unit(pg: &PageGrid) -> Option { /// The margin edge of a visible page under `pos` on the horizontal (`h`) or vertical ruler. fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize)> { let ((i0, i1), (j0, j1)) = visible_pages(geo, pg); - let [ml, mr, mt, mb, ..] = pg.margins; + let [ml, mr, mt, mb, mh, mf] = pg.margins; let mut best: Option<(f32, Edge, usize)> = None; let mut consider = |d: f32, e: Edge, page: usize| { if d <= EDGE_GRAB && best.is_none_or(|b| d < b.0) { @@ -700,6 +728,8 @@ fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize) let (_, y, _, hh) = pg.page_rect(i, 0); consider((screen(geo, 0.0, y + mt).y - pos.y).abs(), Edge::Top, i); consider((screen(geo, 0.0, y + hh - mb).y - pos.y).abs(), Edge::Bottom, i); + consider((screen(geo, 0.0, y + mh).y - pos.y).abs(), Edge::Header, i); + consider((screen(geo, 0.0, y + hh - mf).y - pos.y).abs(), Edge::Footer, i); } } best.map(|(_, e, p)| (e, p)) @@ -720,11 +750,12 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid } let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); - let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + // Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical. + let hresp = ui.interact(geo.mirror_rect(hr), egui::Id::new("ruler_margin_h"), egui::Sense::drag()); let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); - let margins_in = |sh: &Sheet| -> [f32; 4] { + let margins_in = |sh: &Sheet| -> [f32; 6] { let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); - [m(0), m(1), m(2), m(3)] + [m(0), m(1), m(2), m(3), m(4), m(5)] }; let current = margins_in(sh); let metric = metric(app); @@ -745,7 +776,7 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid } cursor(ui, d.edge); let down = ui.input(|i| i.pointer.primary_down()); - let pos = ui.input(|i| i.pointer.latest_pos()); + let pos = ui.input(|i| i.pointer.latest_pos()).map(|p| geo.mirror_pos(p)); let mut cur = d.clone(); if let (Some(pos), Some(k)) = (pos, pt_per_unit(pg)) { let rel = if d.edge.horizontal() { @@ -774,10 +805,11 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid for (resp, h) in [(&hresp, true), (&vresp, false)] { let Some(pos) = resp.hover_pos().or_else(|| resp.interact_pointer_pos()) else { continue }; let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(pos) } else { pos }; + let origin = geo.mirror_pos(origin); let Some((edge, page)) = edge_at(geo, pg, origin, h) else { continue }; let value = current.get(edge.index()).copied().unwrap_or(0.5); if resp.drag_started() { - app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value }); + app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value, preview: None }); cursor(ui, edge); } else if resp.hovered() { cursor(ui, edge); @@ -868,7 +900,7 @@ mod tests { #[test] fn drag_maps_to_margins_and_clamps_inside_the_page() { - let m = [0.7, 0.7, 0.75, 0.75]; + let m = [0.7, 0.7, 0.75, 0.75, 0.3, 0.3]; // 72 pt from the left edge is one inch; from the right edge the margin is measured inward. assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); @@ -883,8 +915,12 @@ mod tests { assert_eq!(margin_from_drag(Edge::Left, f64::NAN, A4, m), None); assert_eq!(margin_from_drag(Edge::Left, f64::INFINITY, A4, m), None); assert_eq!(margin_from_drag(Edge::Left, 10.0, (0.0, 0.0), m), None); - assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7]), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7, 0.3, 0.3]), None); // Oversized opposite margin leaves nothing to drag into. - assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0]), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0, 0.3, 0.3]), Some(0.0)); + // Header/footer distances are measured from the top / bottom edge. + assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); + assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); + assert_eq!(margin_from_drag(Edge::Footer, -1e9, A4, m), Some(0.0)); } } diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index 632b9e222..35028a732 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -12,8 +12,20 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn fixture() -> Harness { + fixture_with(false, None) +} + +/// `rtl` mirrors the sheet; `setup` is extra `pageLayout.pageSetup` params. +fn fixture_with(rtl: bool, setup: Option) -> Harness { let mut session = Session::new(); session.new_workbook(); + if rtl { + session.execute("view.rightToLeft", json!({"on": true})).unwrap(); + } + if let Some(p) = setup { + session.execute("cell.set", json!({"cell": "Z1", "input": "wide"})).unwrap(); + session.execute("pageLayout.pageSetup", p).unwrap(); + } for r in 1..=60 { session.execute("cell.set", json!({"cell": format!("A{r}"), "input": r.to_string()})).unwrap(); } @@ -216,3 +228,99 @@ fn dragging_a_ruler_edge_previews_commits_on_release_as_one_undo_step_and_esc_ca h.run_steps(3); assert_eq!(margins(&h), start_margins); } + +fn press(h: &mut Harness, p: Pos2) { + h.run_steps(40); + mouse(h, Event::PointerMoved(p)); + mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); +} + +fn release(h: &mut Harness, p: Pos2) { + mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Screen pixels per inch of the page. +fn per_inch(g: &Geo) -> f32 { + let pg = g.paper.clone().unwrap(); + (72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z +} + +#[test] +fn header_and_footer_margins_are_dragged_on_the_vertical_ruler() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let ruler_x = h.state().grid.rect.unwrap().left() + 9.0; + // Header: distance from the top of the page, 0.2 in further down. + let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y); + let to = pos2(ruler_x, at.y + 0.2 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[4] - (start[4] + 0.2)).abs() < 0.03, "{after:?} vs {start:?}"); + assert_eq!((after[..4].to_vec(), after[5]), (start[..4].to_vec(), start[5])); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(margins(&h), start); + // Footer: distance from the bottom, dragged up by 0.2 in grows it (zoom out so the page + // bottom is on screen). + h.state_mut().run("view.zoom", json!({"percent": 50})).unwrap(); + h.run_steps(4); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let (_, py, _, ph) = pg.page_rect(0, 0); + let at = pos2(ruler_x, screen(&g, 0.0, py + ph - pg.margins[5]).y); + let to = pos2(ruler_x, at.y - 0.2 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y - 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + assert!((margins(&h)[5] - (start[5] + 0.2)).abs() < 0.03, "{:?}", margins(&h)); +} + +#[test] +fn right_to_left_sheets_mirror_the_horizontal_ruler_drag() { + let mut h = fixture_with(true, None); + let g = geo(&h); + assert!(g.rtl); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let y = h.state().grid.rect.unwrap().top() + 9.0; + // The left margin edge is drawn on the right of the mirrored page; dragging it towards the + // page centre (screen-left) grows the left margin. + let at = pos2(g.mirror_x(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x), y); + let to = pos2(at.x - per_inch(&g), y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x - 4.0, y))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[0] - (start[0] + 1.0)).abs() < 0.03, "{after:?} vs {start:?}"); + assert_eq!(after[1], start[1]); +} + +#[test] +fn preview_repaginates_so_fit_to_width_matches_the_commit() { + let mut h = fixture_with(false, Some(json!({"fitWidth": 1, "fitHeight": 0}))); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let y = h.state().grid.rect.unwrap().top() + 9.0; + let at = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, y); + let to = pos2(at.x + 2.0 * per_inch(&g), y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x + 4.0, y))); + mouse(&mut h, Event::PointerMoved(to)); + h.run_steps(2); + let live_scale = geo(&h).paper.unwrap().scale; + assert_eq!(margins(&h), start); + assert!(live_scale < pg.scale, "live scale {live_scale} should shrink below {}", pg.scale); + release(&mut h, to); + h.run_steps(2); + let committed = geo(&h).paper.unwrap().scale; + assert!((committed - live_scale).abs() < 0.01, "committed {committed} vs preview {live_scale}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 52dd3a344..04a7750e6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -28,7 +28,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction From 600c25098f4396a641b20a17f0bf02f664e96493 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:01:22 +0200 Subject: [PATCH 56/86] Fix footer clamp expectation in the drag test (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/paper_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index f560f133c..89a567e17 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -921,6 +921,6 @@ mod tests { // Header/footer distances are measured from the top / bottom edge. assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); - assert_eq!(margin_from_drag(Edge::Footer, -1e9, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Footer, 1e9, A4, m), Some(0.0)); } } From bd105a569bbee8a11b5fca9ab3c0203c66e2589f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:03:37 +0200 Subject: [PATCH 57/86] Page Layout view: vertical ruler on the right for RTL sheets, header/footer distance clamped to the margins (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/grid.rs | 7 ++- crates/ui-egui/src/paper_view.rs | 60 +++++++++++++++++++----- crates/ui-egui/tests/page_layout_view.rs | 48 +++++++++++++++++++ 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 80af151ab..10321d114 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -490,7 +490,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Page Layout view: pages on a desk, rulers above and left of the headings. let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; - let grid_rect = Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max); + // Right-to-left sheets carry the vertical ruler on the right, beside the row headings. + let grid_rect = if rtl { + Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom())) + } else { + Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) + }; let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { if paper { let probe = Geo::new(sh, grid_rect, scroll); diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 89a567e17..621a97903 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -511,6 +511,14 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { app.grid.hf_edit = Some(e); } +/// Screen rectangles of the horizontal and vertical rulers. Right-to-left sheets mirror both: +/// the vertical ruler sits on the right, beside the row headings. +fn ruler_rects(outer: Rect, geo: &Geo) -> (Rect, Rect) { + let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); + let (x0, x1) = if geo.rtl { (outer.right() - RULER, outer.right()) } else { (outer.left(), outer.left() + RULER) }; + (hr, Rect::from_min_max(pos2(x0, geo.cells.top()), pos2(x1, geo.cells.bottom()))) +} + /// Horizontal and vertical rulers (`outer` is the grid area including the rulers). pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, t: &Tokens) { let Some(pg) = geo.paper.clone() else { return }; @@ -524,13 +532,12 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let minor = if metric { 2.0 } else { 4.0 }; let label_every = ((22.0 / unit).ceil() as usize).max(1); let font = theme::ui_font(9.5); - let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); - let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let (hr, vr) = ruler_rects(outer, geo); // Right-to-left sheets: the horizontal ruler is mirrored with the pages (ticks run from the // right margin leftwards). let mx = |x: f32| geo.mirror_x(x); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window); - p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window); + p.rect_filled(Rect::from_min_max(pos2(vr.left(), outer.top()), pos2(vr.right(), outer.bottom())), 0.0, t.window); let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg); let [ml, mr, mt, mb, mh, mf] = pg.margins; // One ruler span per page: margins shaded, the printable part light, ticks from the margin. @@ -575,6 +582,8 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, } let (a, b) = if horizontal { (pos2(pos, area.bottom() - 2.0 - len), pos2(pos, area.bottom() - 2.0)) + } else if geo.rtl { + (pos2(area.left() + 2.0, pos), pos2(area.left() + 2.0 + len, pos)) } else { (pos2(area.right() - 2.0 - len, pos), pos2(area.right() - 2.0, pos)) }; @@ -595,13 +604,17 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, // Header and footer margins: a small marker at each distance, grabbable like the margins. let pc = p.with_clip_rect(vr); for at in [screen(geo, 0.0, y + mh).y, screen(geo, 0.0, y + h - mf).y] { - let x = vr.left() + 2.0; - pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + 6.0, at)], t.header_text, Stroke::NONE)); + let (x, d) = if geo.rtl { (vr.right() - 2.0, -6.0) } else { (vr.left() + 2.0, 6.0) }; + pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + d, at)], t.header_text, Stroke::NONE)); } } p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line)); p.line_segment( - [pos2(outer.left() + RULER - 0.5, outer.top()), pos2(outer.left() + RULER - 0.5, outer.bottom())], + if geo.rtl { + [pos2(vr.left() + 0.5, outer.top()), pos2(vr.left() + 0.5, outer.bottom())] + } else { + [pos2(vr.right() - 0.5, outer.top()), pos2(vr.right() - 0.5, outer.bottom())] + }, Stroke::new(1.0, t.header_line), ); } @@ -675,8 +688,19 @@ pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in Edge::Left | Edge::Top | Edge::Header => rel_pt, Edge::Right | Edge::Bottom | Edge::Footer => page - rel_pt, }; - let max = (page - other - MIN_CONTENT_PT).max(0.0); - let pt = raw.clamp(0.0, max); + let mut max = (page - other - MIN_CONTENT_PT).max(0.0); + let mut min = 0.0_f64; + let pt_of = |i: usize| f64::from(margins_in.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).max(0.0) * 72.0; + match edge { + // The header/footer sits within its page margin (conservative: never past the margin edge). + Edge::Header => max = max.min(pt_of(2)), + Edge::Footer => max = max.min(pt_of(3)), + // ... so the top/bottom margin can't be dragged inside it. + Edge::Top => min = pt_of(4), + Edge::Bottom => min = pt_of(5), + _ => {} + } + let pt = raw.clamp(min.min(max), max); Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) } @@ -748,10 +772,9 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid if app.grid.ruler_drag.as_ref().is_some_and(|d| d.sheet != si) { app.grid.ruler_drag = None; } - let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); - let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let (hr, vr) = ruler_rects(outer, geo); // Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical. - let hresp = ui.interact(geo.mirror_rect(hr), egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); let margins_in = |sh: &Sheet| -> [f32; 6] { let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); @@ -905,9 +928,9 @@ mod tests { assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Top, 108.0, A4, m), Some(1.5)); - // Dragged off the page: never negative. + // Dragged off the page: never negative (top/bottom keep the header/footer distance). assert_eq!(margin_from_drag(Edge::Left, -50.0, A4, m), Some(0.0)); - assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.3)); // Dragged across the opposite margin: stops a minimum content width short of it. let max = margin_from_drag(Edge::Left, 10_000.0, A4, m).unwrap(); assert!((f64::from(max) * 72.0 - (f64::from(A4.0) - 0.7 * 72.0 - MIN_CONTENT_PT)).abs() < 1.0, "{max}"); @@ -922,5 +945,16 @@ mod tests { assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); assert_eq!(margin_from_drag(Edge::Footer, 1e9, A4, m), Some(0.0)); + // Header stays within the top margin (0.75 in), footer within the bottom margin. + assert_eq!(margin_from_drag(Edge::Header, 500.0, A4, m), Some(0.75)); + assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 500.0, A4, m), Some(0.75)); + // The top/bottom margin can't be dragged inside the header/footer distance (0.3 in). + assert_eq!(margin_from_drag(Edge::Top, 2.0, A4, m), Some(0.3)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) - 2.0, A4, m), Some(0.3)); + // Hostile header/footer values (NaN, negative, huge) never panic or escape the page. + assert_eq!(margin_from_drag(Edge::Header, 10.0, A4, [0.7, 0.7, 0.75, 0.75, f32::NAN, 0.3]), Some(0.14)); + assert_eq!(margin_from_drag(Edge::Top, 300.0, A4, [0.7, 0.7, 0.75, 0.75, -5.0, f32::INFINITY]), Some(4.17)); + assert_eq!(margin_from_drag(Edge::Header, f64::NAN, A4, m), None); + assert!(margin_from_drag(Edge::Footer, 10.0, A4, [0.0, 0.0, 1e9, 1e9, 0.3, 1e9]).is_some_and(|v| (0.0..=11.7).contains(&v))); } } diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index 35028a732..b2e80d0d9 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -324,3 +324,51 @@ fn preview_repaginates_so_fit_to_width_matches_the_commit() { let committed = geo(&h).paper.unwrap().scale; assert!((committed - live_scale).abs() < 0.01, "committed {committed} vs preview {live_scale}"); } + +#[test] +fn right_to_left_sheets_carry_the_vertical_ruler_on_the_right() { + let mut h = fixture_with(true, None); + let g = geo(&h); + assert!(g.rtl); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let outer = h.state().grid.rect.unwrap(); + // The grid leaves the ruler's width free on the right, not on the left. + assert!((g.rect.right() - (outer.right() - gridcraft_ui_egui::paper_view::RULER)).abs() < 0.5, "{:?} {outer:?}", g.rect); + assert!((g.rect.left() - outer.left()).abs() < 0.5); + let x = outer.right() - 9.0; + let at = pos2(x, screen(&g, 0.0, pg.page_y(0) + pg.margins[2]).y); + let to = pos2(x, at.y + 0.25 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[2] - (start[2] + 0.25)).abs() < 0.03, "{after:?} vs {start:?}"); + // The left ruler strip is not a handle any more: nothing happens there. + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let at = pos2(outer.left() + 9.0, screen(&g, 0.0, pg.page_y(0) + pg.margins[3]).y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x, at.y - 30.0))); + release(&mut h, pos2(at.x, at.y - 30.0)); + assert_eq!(margins(&h)[3], start[3]); +} + +#[test] +fn header_distance_cannot_be_dragged_past_the_top_margin() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let ruler_x = h.state().grid.rect.unwrap().left() + 9.0; + let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y); + let to = pos2(ruler_x, at.y + 3.0 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[4] - start[2]).abs() < 0.011, "header {} should stop at the top margin {}", after[4], start[2]); + assert_eq!(after[2], start[2]); +} From aa2a58062664daeb94789a286ab6b35c25c43109 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:05:59 -0400 Subject: [PATCH 58/86] =?UTF-8?q?PivotTables:=20values=20on=20rows=20revie?= =?UTF-8?q?w=20fixes=20(Total=20header,=20=CE=A3=20Values=20last=20in=20Co?= =?UTF-8?q?lumns)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With values on rows and no column field, the header cell above the data column reads "Total", as Excel saves it (tabular checked against a real file). Σ Values in Columns always comes after the last column field: the column layout doesn't place it anywhere else, so `pivot.moveField` no longer stores a position there, the reader ignores where `-2` sits in colFields and the writer puts it last. `pivot.changeSource` removes dropped row and column fields one by one so Σ Values keeps its place ahead of the field it preceded. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/pivot.rs | 61 +++++++++++++++++++++++++--- crates/engine/src/pivot.rs | 7 +++- crates/model/src/features.rs | 3 +- crates/xlsx/src/pivot.rs | 11 ++--- crates/xlsx/src/tests/pivot_tests.rs | 16 ++++++-- 5 files changed, 81 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 5a35c10a6..38ea4bf09 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Move Field", [], None, - "{pivot?, field (\"Σ Values\" moves the value fields between rows and columns), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}", + "{pivot?, field (\"Σ Values\" moves the value fields between rows and columns; in columns it stays after the last field), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}", has_doc, move_field ), @@ -382,7 +382,9 @@ fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: O shift_values(pt, rows, at, true); } -/// Moves Σ Values between Rows and Columns, or within one, to `position` (default: the end). +/// Moves Σ Values between Rows and Columns, or within Rows, to `position` (default: the end). +/// In Columns it always comes after the last column field: the column layout doesn't place it +/// anywhere else yet. fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option) -> Result { let current = if pt.values_on_rows { "rows" } else { "columns" }; if from != current { @@ -392,8 +394,7 @@ fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, posit return Err(other("The field you are moving cannot be placed in that PivotTable area.")); } pt.values_on_rows = to == "rows"; - let n = if pt.values_on_rows { pt.rows.len() } else { pt.columns.len() }; - pt.values_position = position.filter(|p| *p < n); + pt.values_position = position.filter(|p| pt.values_on_rows && *p < pt.rows.len()); Ok(json!({"field": VALUES_FIELD, "to": to})) } @@ -1331,8 +1332,20 @@ fn change_source(s: &mut Session, p: &Json) -> Result { } ok }; - pt.rows.retain(|f| keep(&f.source_col, &mut dropped)); - pt.columns.retain(|f| keep(&f.source_col, &mut dropped)); + // Axis fields leave one by one so Σ Values keeps its place among the rest. + for rows in [true, false] { + let mut i = 0; + loop { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let Some(f) = list.get(i) else { break }; + if keep(&f.source_col, &mut dropped) { + i += 1; + } else { + list.remove(i); + shift_values(pt, rows, i, false); + } + } + } pt.values.retain(|f| keep(&f.source_col, &mut dropped)); pt.filters.retain(|f| keep(&f.source_col, &mut dropped)); Ok(json!({"source": source, "dropped": dropped})) @@ -1807,6 +1820,12 @@ mod tests { assert_eq!(val(&s, "B13"), Value::Number(TOTAL)); assert_eq!(text(&s, "A14"), "Total Sum of Units"); assert_eq!(val(&s, "B14"), Value::Number(72.0)); + // Tabular, no column field: "Total" heads the data column, as Excel saves it. + s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); + assert_eq!(text(&s, "A3"), "Region"); + assert_eq!(text(&s, "B3"), "Values"); + assert_eq!(text(&s, "C3"), "Total"); + s.execute("pivot.layout", json!({"layout": "compact"})).unwrap(); // Σ Values before Region: the value fields are the outer items. s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap(); assert_eq!(pt(&s).values_position, Some(0)); @@ -1841,6 +1860,36 @@ mod tests { assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err()); } + #[test] + fn values_stay_last_in_columns() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap(); + let p = pt(&s); + assert!(!p.values_on_rows); + assert_eq!(p.values_position, None); + // Σ Values to Rows and back with a position: still last in Columns. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, None); + assert_eq!(text(&s, "B5"), "Sum of Sales"); + assert_eq!(text(&s, "C5"), "Sum of Units"); + } + + #[test] + fn change_source_keeps_values_ahead_of_its_field() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap(); + // The new source has no Region: Σ Values stays ahead of Product. + s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap(); + let p = pt(&s); + assert_eq!(p.rows.len(), 1); + assert_eq!(p.values_position, Some(0)); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(text(&s, "A5"), "Apples"); + } + #[test] fn values_on_rows_between_row_fields() { let mut s = sales(); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index ed381a3c1..41b7f401e 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -1061,8 +1061,11 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None), + // Values on rows, no column field: the one data column is the grand total. + None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None), + None => {} } } } else { diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index fdebd2d73..498d513d2 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -978,7 +978,8 @@ pub struct PivotTable { /// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather /// than Columns (Excel's "values on rows"). pub values_on_rows: bool, - /// Position of Σ Values among the fields of its area (`None` = after the last). + /// Position of Σ Values among the row fields (`None` = after the last). Only used with + /// `values_on_rows`: in Columns Σ Values always comes after the last column field. pub values_position: Option, } diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 0c480ed43..609ae530e 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -830,10 +830,11 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn let loc = RangeRef::new(a, CellRef::new(end.row.max(a.row), end.col.max(a.col))); let k = vals.len(); // Σ Values (two or more value fields): its index among the written fields of its axis. - let (row_map, col_map) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len())); + let (row_map, _) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len())); let values_at = |map: &[Option], n: usize| pt.values_position.map_or(n, |p| map.iter().take(p).filter(|x| x.is_some()).count()); let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, row_idx.len())); - let col_values = (k > 1 && !pt.values_on_rows).then(|| values_at(col_map, col_idx.len())); + // In Columns Σ Values comes after the last field: the column layout doesn't place it elsewhere. + let col_values = (k > 1 && !pt.values_on_rows).then_some(col_idx.len()); let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(col_values.is_some()) }; let row_levels = row_idx.len() + usize::from(row_values.is_some()); let first_data_col = if row_levels == 0 && col_idx.is_empty() { @@ -1334,10 +1335,10 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option"#; + let (wb, _) = read_xlsx(&with_pivot(table, Some(GOOD_CACHE))).unwrap(); + let pt = &wb.sheet(0).unwrap().pivots[0]; + assert_eq!((pt.columns.len(), pt.values.len()), (1, 2)); + assert!(!pt.values_on_rows); + assert_eq!(pt.values_position, None); } #[test] From a5f531838ba2341eee61a3ab59573ef3eb032ccf Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 21:08:20 +0200 Subject: [PATCH 59/86] Functions: note the dependency gap of computed criteria (part of #737) --- crates/calc/src/eval.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 98c117902..b22c48afb 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -104,6 +104,10 @@ impl Ctx for FnCtx<'_, '_> { } /// Excel's "formula as criteria": the formula is written for the first record, so its relative /// references move down to `record`'s row, and the database's labels name its fields. + /// Known gap: the shifted references are not dependencies of the calling cell. One that lands + /// outside both the database and the criteria cell's own precedents (say `=G2 Option { let area = self.criteria?; let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?; From 2be04524b3a4fcfecfd28a274c30067c3d488aca Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:09:27 +0200 Subject: [PATCH 60/86] Calc: SUBTOTAL(10x) recalculates when Row Height, AutoFit Row Height or Ungroup reveal rows home.rowHeight, home.autofitRowHeight and data.ungroup change row hidden state without marking the edit structural, so SUBTOTAL(101-111) kept a stale value. Found by the command-sequence fuzz (seed 3). Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/data.rs | 1 + crates/engine/src/cmd/format.rs | 2 ++ crates/engine/src/tests.rs | 16 ++++++++++++++++ 4 files changed, 20 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..fafa75902 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | SUBTOTAL(109) over hidden rows recalculates after Row Height, AutoFit Row Height and Ungroup reveal rows (found by the command-sequence fuzz) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3baa6..d2b3612b7 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -1129,6 +1129,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result { { e.collapsed = false; } + cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index dde5fa120..8d4601029 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -953,6 +953,7 @@ fn row_height(s: &mut Session, p: &Json) -> Result { e.custom = true; } } + cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } @@ -1069,6 +1070,7 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result { } cx.fit_rows.extend((a..=b).map(|r| (si, r))); } + cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc113e..1e78b372e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2795,3 +2795,19 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() { assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched"); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [4], [8]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=SUBTOTAL(109,A1:A4)"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(9.0)); + // A non-zero height unhides the rows (the fuzz found the stale SUBTOTAL). + s.execute("home.rowHeight", json!({"rows": "2:3", "height": 60.0})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(15.0)); + s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(9.0)); + s.execute("home.autofitRowHeight", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(15.0)); +} From fd53de7c46bf399c4979184444b37bb1d10eec9e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:09:09 +0200 Subject: [PATCH 61/86] Numfmt: TEXT() and chart labels parse each format code once (#806) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/chart/src/axis.rs | 2 +- crates/chart/src/resolve.rs | 2 +- crates/numfmt/src/lib.rs | 76 +++++++++++++++- crates/numfmt/src/tests.rs | 171 ++++++++++++++++++++++++++++++++++++ 5 files changed, 249 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..a75127293 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | `TEXT()` over a range and chart labels parse each number format code once instead of once per value (#806) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/chart/src/axis.rs b/crates/chart/src/axis.rs index eb4f733a3..8686bf7d9 100644 --- a/crates/chart/src/axis.rs +++ b/crates/chart/src/axis.rs @@ -122,7 +122,7 @@ impl Fmt { pub fn new(code: &str) -> Fmt { let t = code.trim(); let general = t.is_empty() || t.eq_ignore_ascii_case("general"); - Fmt { general, fmt: NumberFormat::parse(if general { "General" } else { t }) } + Fmt { general, fmt: NumberFormat::parse_cached(if general { "General" } else { t }) } } /// Formats `v`; `step` (axis labels) fixes the decimals for General. pub fn format(&self, v: f64, step: Option) -> String { diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 2c17eff22..b3de1ca8f 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -145,7 +145,7 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { if let Some(c) = at { let code = &wb.styles.get(s.style_id(c)).num_fmt.0; if !code.eq_ignore_ascii_case("general") { - return format_value(v, &NumberFormat::parse(code), wb.date_system).text; + return format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text; } } } diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 674e84446..b4f259f56 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -101,6 +101,8 @@ impl Default for NumberFormat { impl NumberFormat { /// Parses a format code. Never fails: unknown or malformed parts degrade to literal text. pub fn parse(code: &str) -> NumberFormat { + #[cfg(test)] + parse_cache::count_parse(); let trimmed = code.trim(); // `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other // value displays as General. @@ -294,6 +296,78 @@ pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted } } +/// A small per-thread cache of parsed format codes for callers that format many values with the +/// same code (`TEXT` over a range, chart labels). A parsed format depends on the code text only +/// (the date system and values are applied when formatting), so the code is the whole key. +mod parse_cache { + use std::cell::RefCell; + use std::collections::HashMap; + + use super::NumberFormat; + + /// Most codes kept per thread. A full cache is emptied, so memory stays bounded. + pub(super) const CAPACITY: usize = 64; + /// Longer codes are parsed every time and never stored. + pub(super) const MAX_CODE_LEN: usize = 256; + + thread_local! { + static CACHE: RefCell> = RefCell::new(HashMap::new()); + } + + pub(super) fn get(code: &str) -> NumberFormat { + if code.len() > MAX_CODE_LEN { + return NumberFormat::parse(code); + } + // Not usable (thread shutting down, re-entered): parse without the cache. + let cached = CACHE + .try_with(|c| { + let mut c = c.try_borrow_mut().ok()?; + if let Some(f) = c.get(code) { + return Some(f.clone()); + } + let f = NumberFormat::parse(code); + if c.len() >= CAPACITY { + c.clear(); + } + c.insert(code.to_string(), f.clone()); + Some(f) + }) + .ok() + .flatten(); + cached.unwrap_or_else(|| NumberFormat::parse(code)) + } + + #[cfg(test)] + thread_local! { + static PARSES: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + #[cfg(test)] + pub(super) fn count_parse() { + let _ = PARSES.try_with(|p| p.set(p.get() + 1)); + } + + /// Parses done on this thread so far (tests only). + #[cfg(test)] + pub(super) fn parses() -> usize { + PARSES.try_with(std::cell::Cell::get).unwrap_or(0) + } + + /// Empties this thread's cache (tests only). + #[cfg(test)] + pub(super) fn clear() { + let _ = CACHE.try_with(|c| c.borrow_mut().clear()); + } +} + +impl NumberFormat { + /// Like [`NumberFormat::parse`], but remembers recently parsed codes (per thread, bounded) so + /// formatting many values with one code parses it once. Same result as `parse`. + pub fn parse_cached(code: &str) -> NumberFormat { + parse_cache::get(code) + } +} + /// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is /// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text /// section, or are returned as is. @@ -301,7 +375,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result return Err(e), Value::Empty => 0.0, diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 6c2306c4f..ca61418da 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -582,3 +582,174 @@ fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() { let (d, e) = decimal_digits(999999999999999.6); assert_eq!((d, e), ([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15)); } + +// ---- parse cache ---- + +/// `TEXT()` as it was before the cache: parse the code, then format. +fn text_uncached(v: &Value, code: &str, sys: DateSystem) -> Result { + if code.is_empty() { + return Ok(String::new()); + } + let fmt = NumberFormat::parse(code); + let n = match v.scalar() { + Value::Error(e) => return Err(e), + Value::Empty => 0.0, + Value::Number(n) => n, + Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text), + Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) { + Some(n) => n, + None => return Ok(fmt.format_text(&t).text), + }, + Value::Array(_) => return Err(CellError::Value), + }; + if !n.is_finite() { + return Err(CellError::Num); + } + let (f, overflow) = fmt.format_number(n, sys); + if overflow { Err(CellError::Value) } else { Ok(f.text) } +} + +const CACHE_CODES: &[&str] = &[ + "0", + "0.00", + "#,##0.00", + "#,##0.00;[Red](#,##0.00)", + "$#,##0.00", + "0%", + "0.0%", + "0.00E+00", + "##0.0E+0", + "General", + "general", + "checkbox", + "@", + "0;@", + "0;-0;;@", + "00000", + "# ?/?", + "# ??/16", + "?/?", + "m/d/yyyy", + "yyyy-mm-dd hh:mm:ss", + "dddd, mmmm d, yyyy", + "h:mm AM/PM", + "[h]:mm:ss", + "mm:ss.000", + "[Red]0;[Blue]-0", + "[>100]0.0;[<5]0.000;0", + "[<=9999999]###-####;(###) ###-####", + "[$-409]d mmm yy", + "\"abc\"0", + "\\x0", + "*-0", + "_(* #,##0_)", + "0.0.0", + "[", + "\"abc", + "0;0;0;0;0", + "#,##0.00;[Red", + "[$-]0", + " 0.00 ", + "[Color99]0", + "0.00_);(0.00)", + "#,,\"M\"", + "yyyy", + "", +]; + +fn cache_values() -> Vec { + vec![ + Value::Number(0.0), + Value::Number(1234.5678), + Value::Number(-42.5), + Value::Number(0.5), + Value::Number(45000.75), + Value::Number(1e15), + Value::Number(1e-7), + Value::Number(-0.0001), + Value::Number(f64::INFINITY), + Value::from("abc"), + Value::from("12.5"), + Value::Bool(true), + Value::Empty, + Value::Error(CellError::NA), + ] +} + +#[test] +fn parse_cached_gives_the_same_results_cold_and_warm() { + assert!(CACHE_CODES.len() >= 40); + parse_cache::clear(); + let values = cache_values(); + for sys in [DateSystem::D1900, DateSystem::D1904] { + // Cold on the first system, warm on the second. + for code in CACHE_CODES { + let direct = NumberFormat::parse(code); + let cached = NumberFormat::parse_cached(code); + assert_eq!(cached.code(), direct.code(), "{code:?}"); + assert_eq!( + (cached.kind(), cached.is_date(), cached.is_text(), cached.is_general()), + (direct.kind(), direct.is_date(), direct.is_text(), direct.is_general()) + ); + for v in &values { + let want = text_uncached(v, code, sys); + assert_eq!(text_function(v, code, sys), want, "TEXT({v:?}, {code:?})"); + // Again, now certainly warm. + assert_eq!(text_function(v, code, sys), want, "warm TEXT({v:?}, {code:?})"); + assert_eq!(format_value(v, &cached, sys), format_value(v, &direct, sys), "{v:?} {code:?}"); + } + } + } +} + +#[test] +fn parse_cached_parses_each_code_once() { + parse_cache::clear(); + let before = parse_cache::parses(); + for i in 0..10_000 { + assert!(text_function(&Value::Number(f64::from(i) + 0.5), "0.00", S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 1); + + let before = parse_cache::parses(); + for i in 0..10_000 { + let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" }; + assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok()); + } + // "0.00" is still cached from above: only the second code is new. + assert_eq!(parse_cache::parses() - before, 1); + + parse_cache::clear(); + let before = parse_cache::parses(); + for i in 0..10_000 { + let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" }; + assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 2); +} + +#[test] +fn parse_cache_is_bounded() { + parse_cache::clear(); + // More distinct codes than the capacity: all still format correctly... + let n = parse_cache::CAPACITY + 10; + for i in 0..n { + let c = format!("0.00\"{i}\""); + assert_eq!(text_function(&Value::Number(1.0), &c, S), Ok(format!("1.00{i}"))); + } + // ...the first ones were evicted (parsed again), the newest is still cached. + let before = parse_cache::parses(); + let newest = format!("0.00\"{}\"", n - 1); + assert!(text_function(&Value::Number(1.0), &newest, S).is_ok()); + assert_eq!(parse_cache::parses() - before, 0); + assert!(text_function(&Value::Number(1.0), "0.00\"0\"", S).is_ok()); + assert_eq!(parse_cache::parses() - before, 1); + + // A very long code is parsed on every call and not stored. + let long = format!("0\"{}\"", "x".repeat(parse_cache::MAX_CODE_LEN)); + let before = parse_cache::parses(); + for _ in 0..3 { + assert!(text_function(&Value::Number(1.0), &long, S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 3); +} From e5b12dab06b0bff899b72d900022f6012fe6c516 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:09:52 -0400 Subject: [PATCH 62/86] Functions: COUNTIF family counts the 255 criterion limit in UTF-16 units, as in Excel --- crates/functions/src/criteria.rs | 93 ++++++++++++++++++++++---------- crates/functions/src/math.rs | 52 ++++++++++++++++++ 2 files changed, 117 insertions(+), 28 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index aa571773c..2b4c48886 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -5,8 +5,9 @@ use std::cmp::Ordering; use gridcraft_core::parse::parse_number_text; use gridcraft_core::{CellError, Value, compare_numbers, compare_text}; -/// Longest text criterion COUNTIF and its relatives accept; a longer one is `#VALUE!`. -const MAX_CRITERION_CHARS: usize = 255; +/// Longest text criterion COUNTIF and its relatives accept, in UTF-16 code units as Excel +/// counts them (😀 is two units); a longer one is `#VALUE!`. +const MAX_CRITERION_UTF16_UNITS: usize = 255; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Op { @@ -25,6 +26,15 @@ enum Operand { Error(CellError), /// Text (lower-cased chars kept for wildcard matching). Text(String), + /// A criterion cut to its first 255 UTF-16 units in the middle of a surrogate pair: the + /// complete characters before the cut, followed (in Excel) by a lone high surrogate. + /// + /// No cell text holds a lone surrogate, so `=` matches nothing and `<>` everything, as + /// Excel gives 0 for SUMIFS with a criterion of 128 emoji. For `<`, `<=`, `>` and `>=` the + /// criterion sorts just above the kept text: a cell matches `>`/`>=` when it sorts above + /// that text, and `<`/`<=` otherwise. This is an approximation (Excel wasn't checked) and + /// only matters for comparison criteria over 255 units. + CutText(String), /// Nothing after the operator (`"="`, `"<>"`) or an empty criterion. Blank, } @@ -52,49 +62,51 @@ impl Criterion { } /// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS - /// functions: like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + /// functions: like `parse`, but text longer than 255 UTF-16 code units is `#VALUE!`, as in + /// Excel (checked on Excel 2016 and Microsoft 365: `"a"×254 & "😀"` is 255 characters but + /// 256 units, and is `#VALUE!`). pub(crate) fn parse_arg(v: &Value) -> Result { match v { Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), - Value::Text(t) if t.chars().nth(MAX_CRITERION_CHARS).is_some() => Err(CellError::Value), + // A UTF-8 string has at least as many bytes as UTF-16 units, so short text skips the count. + Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS && t.encode_utf16().nth(MAX_CRITERION_UTF16_UNITS).is_some() => { + Err(CellError::Value) + } other => Ok(Criterion::parse(other)), } } /// Parses a criteria argument of an *IFS function with two or more criteria pairs. Excel - /// doesn't reject a longer text criterion there: it matches its first 255 characters + /// doesn't reject a longer text criterion there: it matches its first 255 UTF-16 code units /// (checked on Excel 2016 and Microsoft 365 with COUNTIFS, SUMIFS, AVERAGEIFS, MAXIFS and - /// MINIFS). + /// MINIFS). A cut through a surrogate pair matches nothing; see `Operand::CutText`. pub(crate) fn parse_arg_truncated(v: &Value) -> Criterion { match v { Value::Array(a) => Criterion::parse_arg_truncated(a.data.first().unwrap_or(&Value::Empty)), - Value::Text(t) => match t.char_indices().nth(MAX_CRITERION_CHARS) { - Some((end, _)) => Criterion::parse_text(&t[..end]), - None => Criterion::parse_text(t), - }, + Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS => { + let mut units = 0; + for (i, c) in t.char_indices() { + if units + c.len_utf16() > MAX_CRITERION_UTF16_UNITS { + if units == MAX_CRITERION_UTF16_UNITS { + return Criterion::parse_text(&t[..i]); + } + // The cut falls between the two halves of `c`. + let (op, rest) = split_op(&t[..i]); + return Criterion { op, operand: Operand::CutText(rest.to_string()), bare_empty: false }; + } + units += c.len_utf16(); + } + Criterion::parse_text(t) + } other => Criterion::parse(other), } } fn parse_text(t: &str) -> Criterion { - let (op, rest) = if let Some(r) = t.strip_prefix(">=") { - (Op::Ge, r) - } else if let Some(r) = t.strip_prefix("<=") { - (Op::Le, r) - } else if let Some(r) = t.strip_prefix("<>") { - (Op::Ne, r) - } else if let Some(r) = t.strip_prefix('=') { - (Op::Eq, r) - } else if let Some(r) = t.strip_prefix('>') { - (Op::Gt, r) - } else if let Some(r) = t.strip_prefix('<') { - (Op::Lt, r) - } else { - if t.is_empty() { - return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true }; - } - (Op::Eq, t) - }; + if t.is_empty() { + return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true }; + } + let (op, rest) = split_op(t); let operand = if rest.is_empty() { Operand::Blank } else if let Some(n) = parse_number_text(rest) { @@ -168,10 +180,35 @@ impl Criterion { _ => false, }, }, + Operand::CutText(kept) => match self.op { + Op::Eq => false, + Op::Ne => true, + Op::Lt | Op::Le => matches!(&v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater), + Op::Gt | Op::Ge => matches!(&v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater), + }, } } } +/// Splits a leading comparison operator (`=` when there is none) off a text criterion. +fn split_op(t: &str) -> (Op, &str) { + if let Some(r) = t.strip_prefix(">=") { + (Op::Ge, r) + } else if let Some(r) = t.strip_prefix("<=") { + (Op::Le, r) + } else if let Some(r) = t.strip_prefix("<>") { + (Op::Ne, r) + } else if let Some(r) = t.strip_prefix('=') { + (Op::Eq, r) + } else if let Some(r) = t.strip_prefix('>') { + (Op::Gt, r) + } else if let Some(r) = t.strip_prefix('<') { + (Op::Lt, r) + } else { + (Op::Eq, t) + } +} + fn cmp_op(op: Op, o: Ordering) -> bool { match op { Op::Eq => o == Ordering::Equal, diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 8c67aa87b..505255c89 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -1668,4 +1668,56 @@ mod tests { close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); } + + #[test] + fn criteria_over_255_utf16_units_follow_excel() { + // The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these + // strings, B is the row number, and each row uses its own string as the criterion. + let e = "😀"; + let strings = + [e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)]; + let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect()); + let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + // COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`. + let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None]; + for (s, want) in strings.iter().zip(expected) { + let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]); + let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]); + let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]); + let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]); + match want { + Some([a, b, c, d]) => { + close(countif, a); + close(sumif, b); + close(countifs, c); + close(sumifs, d); + } + // Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which + // matches no cell (for emoji it splits a surrogate pair). + None => { + is_err(countif, CellError::Value); + is_err(sumif, CellError::Value); + is_err(countifs, CellError::Value); + close(sumifs, 0.0); + } + } + } + } + + #[test] + fn criteria_cut_inside_a_surrogate_pair() { + let e = "😀"; + let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]); + let rows = col(&[1.0, 2.0, 3.0, 4.0]); + let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]); + // "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji. + close(sumifs(format!("={}", e.repeat(128))), 1.0); + // With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion + // equals no cell text: "=" matches nothing and "<>" everything, blanks included. + close(sumifs(format!("=a{}", e.repeat(128))), 0.0); + close(sumifs(format!("<>{}", e.repeat(128))), 10.0); + // Comparisons treat it as sorting just above the 126 kept emoji. + close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0); + close(sumifs(format!("<={}", e.repeat(128))), 3.0); + } } From 0042240c0582da1213c1c68653343648af045532 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:18:41 +0200 Subject: [PATCH 63/86] PDF: title and author are written as UTF-16BE when they are not ASCII (#813) Info strings were WinAnsi; PDF text strings are PDFDocEncoding or UTF-16BE. Printable ASCII stays a literal string; anything else is a hex string. Info strings are capped at 1024 characters. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/pdf/src/lib.rs | 19 +++++++++++++++++-- crates/pdf/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..43805ad88 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | PDF export writes the document title and author as UTF-16BE when they are not ASCII, so viewers show accents, currency signs and non-Latin text correctly (#813) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 51c2df115..5cc4a8386 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -516,8 +516,23 @@ fn run_length(data: &[u8]) -> Vec { out } -fn pdf_text_string(s: &str) -> String { - pdf_string(s) +/// Longest Info string written, in characters; longer values are cut on a character boundary. +const MAX_INFO_CHARS: usize = 1024; + +/// A PDF text string (document Info entries) for `text`: a literal `( … )` for printable ASCII, +/// otherwise UTF-16BE with a byte-order mark as a hex string (ISO 32000-1 7.9.2.2). +fn pdf_text_string(text: &str) -> String { + let text: String = text.chars().take(MAX_INFO_CHARS).collect(); + if text.chars().all(|c| matches!(c, ' '..='~')) { + return pdf_string(&text); + } + let mut s = String::with_capacity(text.len() * 4 + 6); + s.push_str("'); + s } impl PdfDoc { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e7b488a20..4ea9170e7 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -157,3 +157,43 @@ fn hostile_inputs_never_panic() { let pdf = d.finish(); check_xref(&pdf); } + +/// Decodes the `` hex string following `key` in the file bytes. +fn utf16_entry(pdf: &[u8], key: &str) -> String { + let at = find(pdf, key.as_bytes()).unwrap() + key.len(); + let rest = std::str::from_utf8(&pdf[at..at + 9000.min(pdf.len() - at)]).unwrap_or(""); + let hex = rest.trim_start().strip_prefix("').unwrap()]; + let units: Vec = (0..hex.len()).step_by(4).map(|i| u16::from_str_radix(&hex[i..i + 4], 16).unwrap()).collect(); + String::from_utf16(&units).unwrap() +} + +#[test] +fn info_strings_with_non_ascii_are_utf16be() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = "Übersicht €5 – “Q1” 日本 😀".into(); + doc.info.author = "Müller & Søn".into(); + let pdf = doc.finish(); + assert_eq!(utf16_entry(&pdf, "/Title"), "Übersicht €5 – “Q1” 日本 😀"); + assert_eq!(utf16_entry(&pdf, "/Author"), "Müller & Søn"); +} + +#[test] +fn ascii_info_strings_stay_literal_and_escaped() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = r"Q1 (draft) a\b".into(); + let pdf = doc.finish(); + assert!(find(&pdf, br"/Title (Q1 \(draft\) a\\b)").is_some()); + assert!(find(&pdf, b"/Producer (GridCraft PDF)").is_some()); +} + +#[test] +fn info_strings_are_capped() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = "日".repeat(100_000); + let pdf = doc.finish(); + assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS); +} From a540a7138a87e7db2faa19ccc91cef9bb585fd44 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:18:58 +0200 Subject: [PATCH 64/86] PDF: text width cannot overflow; dash, stroke width and NaN sizes are sanitised (#812) text_width sums in u64; an all-zero or non-finite dash array is written as solid ([] 0 d); stroke widths are clamped to >= 0 (NaN -> 0); drawing calls with non-finite or non-positive geometry return before storing anything. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/pdf/src/lib.rs | 69 +++++++++++++++++++++++++++-------- crates/pdf/src/tests.rs | 80 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..ac5d3150c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | PDF writer hardening: text width cannot overflow, all-zero dash arrays and negative or NaN stroke widths are sanitised, non-finite sizes and positions draw nothing (#812) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 51c2df115..828f8fb2f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -145,7 +145,8 @@ impl Font { /// Width of `text` in points when set in `font` at `size` points. pub fn text_width(font: Font, size: f32, text: &str) -> f32 { - let units: u32 = text.chars().map(|c| font.char_width(c) as u32).sum(); + // u64: a u32 sum overflows for ~8 million characters. + let units: u64 = text.chars().map(|c| u64::from(font.char_width(c))).sum(); units as f32 * size / 1000.0 } @@ -221,6 +222,32 @@ fn n(v: f32) -> String { } } +/// True when every value is finite (NaN and infinities are rejected). +fn finite(v: &[f32]) -> bool { + v.iter().all(|x| x.is_finite()) +} + +/// A positive, finite extent (a width, height or radius). +fn extent(v: f32) -> bool { + v > 0.0 && v.is_finite() +} + +/// A stroke width: negative, NaN and infinite become 0 (the thinnest line). +fn stroke_width(w: f32) -> f32 { + if w.is_finite() { w.max(0.0) } else { 0.0 } +} + +/// The entries of a dash array (`[on, off, …]`, at most 8). A pattern the PDF specification +/// forbids (any non-finite entry, or no entry that survives rounding to a positive number) is +/// replaced by an empty array, i.e. a solid line. +fn dash_values(dash: &[f32]) -> String { + let d: Vec = dash.iter().take(8).map(|v| if v.is_finite() { v.max(0.0) } else { f32::NAN }).collect(); + if !finite(&d) || !d.iter().any(|v| (v.min(1.0e7) * 1000.0).round() > 0.0) { + return String::new(); + } + d.iter().map(|v| n(*v)).collect::>().join(" ") +} + fn rgb_op(c: Rgb, stroke: bool) -> String { let f = |v: u8| n(v as f32 / 255.0); format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" }) @@ -288,7 +315,7 @@ impl Page { } pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) { - if w <= 0.0 || h <= 0.0 { + if !(extent(w) && extent(h) && finite(&[x, y])) { return; } let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h)); @@ -296,7 +323,10 @@ impl Page { } pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) { - let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h)); + if !finite(&[x, y, w, h]) { + return; + } + let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(stroke_width(width)), n(x), n(self.y(y + h)), n(w), n(h)); self.op(&s); } @@ -306,19 +336,28 @@ impl Page { /// A line with a dash pattern (`[on, off, …]` in points; empty = solid). pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) { - let d: Vec = dash.iter().take(8).map(|v| n(v.max(0.0))).collect(); - let s = - format!("{}\n{} w [{}] 0 d\n{} {} m {} {} l S", rgb_op(color, true), n(width), d.join(" "), n(x0), n(self.y(y0)), n(x1), n(self.y(y1))); + if !finite(&[x0, y0, x1, y1]) { + return; + } + let s = format!( + "{}\n{} w [{}] 0 d\n{} {} m {} {} l S", + rgb_op(color, true), + n(stroke_width(width)), + dash_values(dash), + n(x0), + n(self.y(y0)), + n(x1), + n(self.y(y1)) + ); self.op(&s); } /// An open polyline. pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) { - if pts.len() < 2 { + if pts.len() < 2 || !pts.iter().all(|p| finite(p)) { return; } - let d: Vec = dash.iter().take(8).map(|v| n(v.max(0.0))).collect(); - let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(width), d.join(" ")); + let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash)); self.path(&mut s, pts, false); s.push_str(" S"); self.op(&s); @@ -335,7 +374,7 @@ impl Page { /// A closed polygon, filled and/or stroked. pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option, stroke: Option<(Rgb, f32)>) { - if pts.len() < 2 || (fill.is_none() && stroke.is_none()) { + if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) { return; } let mut s = String::new(); @@ -344,7 +383,7 @@ impl Page { s.push('\n'); } if let Some((c, w)) = stroke { - let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(w)); + let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w))); } self.path(&mut s, pts, true); s.push_str(match (fill.is_some(), stroke.is_some()) { @@ -357,7 +396,7 @@ impl Page { /// A circle approximated by four cubic Béziers. pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option, stroke: Option<(Rgb, f32)>) { - if r <= 0.0 || (fill.is_none() && stroke.is_none()) { + if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) { return; } const K: f32 = 0.552_284_8; @@ -369,7 +408,7 @@ impl Page { s.push('\n'); } if let Some((c, w)) = stroke { - let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(w)); + let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w))); } let _ = write!( s, @@ -417,7 +456,7 @@ impl Page { /// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate /// system) about its baseline origin. pub fn text_rotated(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str, angle: f32) { - if text.is_empty() || !(size.is_finite() && size > 0.0) { + if text.is_empty() || !extent(size) || !finite(&[x, y]) { return; } let fi = font.index(); @@ -446,7 +485,7 @@ impl Page { /// `(x, y, w, h)`. Malformed or oversized buffers are ignored. pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) { let px = pw as u64 * ph as u64; - if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || w <= 0.0 || h <= 0.0 { + if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) { return; } let px = px as usize; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e7b488a20..176176b8a 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -157,3 +157,83 @@ fn hostile_inputs_never_panic() { let pdf = d.finish(); check_xref(&pdf); } + +fn page_content(d: &PdfDoc) -> &str { + &d.pages[0].content +} + +#[test] +fn text_width_does_not_overflow() { + // 8,000,000 x 556 units exceeds u32::MAX: a u32 sum panics in debug builds. + let long = "a".repeat(8_000_000); + let w = text_width(Font::Helvetica, 10.0, &long); + assert!(w.is_finite() && w > 0.0); + assert!(w > text_width(Font::Helvetica, 10.0, &"a".repeat(7_000_000))); +} + +#[test] +fn all_zero_dash_is_drawn_solid() { + let mut d = PdfDoc::new(); + let p = d.add_page(100.0, 100.0); + p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[0.0, 0.0]); + p.polyline(&[[0.0, 0.0], [5.0, 5.0]], 1.0, [0, 0, 0], &[0.0, 0.0001, f32::NAN]); + p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[3.0, 2.0]); + let c = page_content(&d); + assert_eq!(c.matches("[] 0 d").count(), 2); + assert!(!c.contains("[0 0]")); + assert!(c.contains("[3 2] 0 d")); +} + +#[test] +fn negative_and_nan_stroke_widths_become_zero() { + let mut d = PdfDoc::new(); + let p = d.add_page(100.0, 100.0); + p.rect_stroke(1.0, 1.0, 10.0, 10.0, -3.0, [0, 0, 0]); + p.stroke_line(0.0, 0.0, 5.0, 5.0, f32::NAN, [0, 0, 0]); + p.polygon(&[[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]], None, Some(([0, 0, 0], -1.0))); + p.circle(20.0, 20.0, 5.0, None, Some(([0, 0, 0], f32::NEG_INFINITY))); + let c = page_content(&d); + assert_eq!(c.matches("\n0 w ").count(), 4); + assert!(!c.contains("-1 w") && !c.contains("-3 w")); +} + +#[test] +fn non_finite_image_size_stores_nothing() { + let mut d = PdfDoc::new(); + let p = d.add_page(200.0, 200.0); + let px = [255u8; 16]; + for (w, h) in [(f32::NAN, 10.0), (10.0, f32::NAN), (f32::INFINITY, 10.0), (-1.0, 10.0)] { + p.image_rgba(0.0, 0.0, w, h, &px, 2, 2); + } + p.image_rgba(f32::NAN, 0.0, 10.0, 10.0, &px, 2, 2); + assert!(p.images.is_empty()); + assert!(p.content.is_empty()); +} + +#[test] +fn every_draw_method_survives_hostile_numbers() { + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -7.0, 0.0, 1.0e30, -1.0e30]; + let px = [200u8; 16]; + let mut d = PdfDoc::new(); + let p = d.add_page(300.0, 300.0); + for &a in &bad { + for &b in &bad { + p.clip_rect(a, b, a, b); + p.fill_rect(a, b, b, a, [1, 2, 3]); + p.rect_stroke(a, b, a, b, a, [1, 2, 3]); + p.stroke_line(a, b, b, a, a, [1, 2, 3]); + p.stroke_line_dashed(a, b, b, a, b, [1, 2, 3], &[a, b, 0.0]); + p.polyline(&[[a, b], [b, a], [1.0, 1.0]], a, [1, 2, 3], &[b, a]); + p.polygon(&[[a, b], [b, a], [1.0, 1.0]], Some([1, 2, 3]), Some(([4, 5, 6], a))); + p.circle(a, b, b, Some([1, 2, 3]), Some(([4, 5, 6], a))); + p.text(a, b, a, Font::Helvetica, [0, 0, 0], "x"); + p.text_rotated(a, b, 10.0, Font::TimesRoman, [0, 0, 0], "y", b); + p.image_rgba(a, b, b, a, &px, 2, 2); + } + } + p.stroke_line_dashed(0.0, 0.0, 1.0, 1.0, 1.0, [0, 0, 0], &[0.0; 100]); + let c = page_content(&d); + assert!(!c.contains("NaN") && !c.contains("inf")); + let pdf = d.finish(); + check_xref(&pdf); +} From ef1cd55d24af4e714f32bf9919c938b7d8f3b2c5 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:29:02 -0400 Subject: [PATCH 65/86] PivotTables: a collapsed item puts its value captions in the Values column (outline, tabular) --- crates/engine/src/cmd/pivot.rs | 18 ++++++++++++++++++ crates/engine/src/pivot.rs | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 38ea4bf09..7026486e3 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -1860,6 +1860,24 @@ mod tests { assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err()); } + #[test] + fn collapsed_item_puts_values_in_the_values_column() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap(); + for layout in ["tabular", "outline"] { + s.execute("pivot.layout", json!({"layout": layout})).unwrap(); + assert_eq!(text(&s, "C3"), "Values", "{layout}"); + let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap(); + let first = if layout == "tabular" { east } else { east + 1 }; + assert_eq!(text(&s, &format!("B{first}")), "", "{layout}"); + assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}"); + assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}"); + assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}"); + } + } + #[test] fn values_stay_last_in_columns() { let mut s = sales(); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 41b7f401e..a0fb268f0 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -664,7 +664,10 @@ impl Ctx<'_> { labels: vec![(col, v, indent, fmt)], vi, }); - self.flatten_values(&[], depth + 1, prefix, out); + // Outline gives each level its own column, so the captions go in the Values + // column even when the levels between are collapsed away. + let level = if self.pt.layout == PivotLayout::Outline { self.vpos.unwrap_or(depth + 1) } else { depth + 1 }; + self.flatten_values(&[], level, prefix, out); } else if values_below { // Subtotals per value field, below the group (one line can't hold them all). out.push(Line { @@ -749,7 +752,7 @@ impl Ctx<'_> { let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); if depth + 1 >= n || collapsed || node.children.is_empty() { if values_below { - self.flatten_tabular_values(&[], depth + 1, prefix, &labels, out); + self.flatten_tabular_values(&[], self.vpos.unwrap_or(depth + 1), prefix, &labels, out); } else { out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi }); } From 9a58f2d086ae56d4b254bf101e48388815cb2b46 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:40:01 +0200 Subject: [PATCH 66/86] XLS: a formula kept as its saved value keeps a text result (fixes #802) --- crates/xlsx/src/tests/xls_tests.rs | 15 +++++++++++++++ crates/xlsx/src/xls/mod.rs | 10 +++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795ec..f008883e3 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -358,6 +358,21 @@ fn reads_values_formulas_formats_and_layout() { assert_eq!(wb.date_system, DateSystem::D1904); } +#[test] +fn text_result_of_a_formula_kept_as_its_value() { + // A what-if data table cell ({=TABLE(B1,)}) can't be imported as a formula, so its saved result + // is kept. With the default XF the cell is blank until the String record brings the text. + let table = cat(&[&[0x02], &u16s(&[0, 0])]); + let sheet = cat(&[&bof(0x0010), &formula(0, 0, special(0, 0), &table), &rec(0x0207, &xlstr("103a")), &eof()]); + let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])); + let len = cat(&[&bof(0x0005), &boundsheet(0), &eof()]).len() as u32; + let stream = cat(&[&bof(0x0005), &boundsheet(len), &eof(), &sheet]); + let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("what-if data tables")), "{:?}", report.warnings); + assert_eq!(ftext(&wb, "A1"), None); + assert_eq!(v(&wb, 0, "A1"), Value::from("103a")); +} + #[test] fn refuses_encrypted_old_and_foreign_files() { // FILEPASS in the globals: an encrypted workbook. diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66a3..0e9fb64ec 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -390,7 +390,9 @@ struct SheetState<'g> { shared: HashMap, arrays: HashMap, last_formula: Option, - pending_string: Option, + /// The cell waiting for its String record, with its style: a blank cell isn't stored, so a + /// formula kept as its value may have no cell to receive the text. + pending_string: Option<(CellRef, StyleId)>, dropped: usize, unsupported: Vec<&'static str>, } @@ -594,11 +596,13 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, } 0x0006 => formula_cell(&mut c, st, budget)?, 0x0207 => { - if let Some(pos) = st.pending_string.take() { + if let Some((pos, style)) = st.pending_string.take() { let s = c.string()?; budget.text(s.len())?; if let Some(cell) = st.sheet.cells.get_mut(pos) { cell.value = Value::from(s); + } else { + st.sheet.cells.set(pos, Cell { value: Value::from(s), formula: None, style }); } } } @@ -736,7 +740,7 @@ fn formula_cell(c: &mut Cursor<'_, '_>, st: &mut SheetState<'_>, budget: &mut Bu st.sheet.cells.set(pos, Cell { value, formula: None, style }); st.last_formula = Some(pos); if string_result { - st.pending_string = Some(pos); + st.pending_string = Some((pos, style)); } // PtgExp: part of a shared or array formula anchored at another cell. if f.rgce.len() == 5 && f.rgce.first() == Some(&0x01) { From 930fa1cada654420afdf8c64af73a87aa0e506e3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:49:34 +0200 Subject: [PATCH 67/86] XLSX: keep fileVersion and calcFeatures in workbook.xml on save (fixes #799) Excel uses the cached cell values of a saved file only when workbook.xml has both and the calcFeatures extension; without them it recalculates everything on open, and lookups into closed workbooks come back as #CALC!. Both are now read into the workbook's kept content and written back in schema order: fileVersion first, the calcFeatures ext in last. A workbook that had neither gets neither. --- crates/model/src/preserved.rs | 5 +-- crates/xlsx/src/passthrough.rs | 38 +++++++++++++++++++++- crates/xlsx/src/tests/passthrough_tests.rs | 38 ++++++++++++++++++++++ crates/xlsx/src/write.rs | 5 +++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/model/src/preserved.rs b/crates/model/src/preserved.rs index 6d0b35d5c..eb8e2bb91 100644 --- a/crates/model/src/preserved.rs +++ b/crates/model/src/preserved.rs @@ -33,10 +33,11 @@ pub struct PreservedPart { } /// A raw XML element of an owning part that refers to kept parts, re-emitted on save with its -/// relationship ids rewritten. +/// relationship ids rewritten, or that Excel needs back unchanged (the workbook's `fileVersion`). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PreservedXml { - /// Which slot of the owner it belongs in (`externalReferences`, `oleObjects`, `controls`). + /// Which slot of the owner it belongs in (`externalReferences`, `fileVersion`, `ext`, + /// `oleObjects`, `controls`). pub slot: String, /// The element, namespace declarations it needs included. pub xml: String, diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index f121977e2..7db21fdfc 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -6,7 +6,8 @@ //! their ``, data connections) and each sheet (query tables, form controls, //! ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's //! ``, `` and `` XML, the `r:id` of `` and the legacy -//! VML shapes those controls need). +//! VML shapes those controls need). The workbook also keeps its `` and the +//! calcFeatures extension, which Excel needs to trust the saved values. //! //! On write, kept parts get fresh part names where needed and new relationship ids; the `r:id`s in //! kept XML are rewritten to match. Kept content is conservative: @@ -83,6 +84,11 @@ const WORKBOOK_ROOTS: &[&str] = &["customXml", "connections"]; const SHEET_ROOTS: &[&str] = &["queryTable"]; /// Sheet relationship kinds that mean the sheet XML may hold elements worth keeping. const SHEET_ELEMENT_KINDS: &[&str] = &["oleObject", "ctrlProp", "control", "image", "package", "printerSettings"]; +/// Attributes of `` (CT_FileVersion). +const FILE_VERSION_ATTRS: &[&str] = &["appName", "lastEdited", "lowestEdited", "rupBuild", "codeName"]; +/// The workbook extension listing the calculation features the saved values were computed with. +const CALC_FEATURES_URI: &str = "{B58B0392-4F1F-4190-BB64-5DF3571DCE5F}"; +const NS_CALC_FEATURES: &str = "http://schemas.microsoft.com/office/spreadsheetml/2018/calcfeatures"; /// Kept sheet elements, in schema order (all after ``, before ``). const SHEET_SLOTS: &[&str] = &["picture", "oleObjects", "controls"]; @@ -369,6 +375,30 @@ impl KeptReader { Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")), } } + // Excel trusts the saved cell values only when both are present; without them it + // recalculates everything on open, and links into closed workbooks come back as errors. + if let Some(v) = wb_xml.child("fileVersion") { + let mut x = String::from(""); + p.elements.push(PreservedXml { slot: "fileVersion".into(), xml: x }); + } + let calc_features = wb_xml + .child("extLst") + .and_then(|l| l.kids("ext").find(|e| e.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(CALC_FEATURES_URI)))) + .and_then(|e| e.child("calcFeatures")); + if let Some(c) = calc_features { + let mut x = format!(""); + for name in c.kids("feature").filter_map(|f| f.attr("name")) { + let _ = write!(x, "", esc_attr(name)); + } + x.push_str(""); + p.elements.push(PreservedXml { slot: "ext".into(), xml: x }); + } (!p.is_empty()).then(|| Arc::new(p)) } @@ -853,6 +883,12 @@ pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> Strin p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect() } +/// The workbook's kept elements of `slot` that refer to no part (`fileVersion`, `ext`). +pub fn workbook_elements(wb: &Workbook, slot: &str) -> String { + let Some(p) = wb.preserved.as_deref() else { return String::new() }; + p.elements.iter().filter(|e| e.slot == slot).map(|e| e.xml.as_str()).collect() +} + /// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`. pub fn write_package_rels(root: &mut Rels, out: &mut Out) { for (ty, target, external) in std::mem::take(&mut out.kept.root) { diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 4cde9d69e..aac9b6b86 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -483,3 +483,41 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() { let (wb, _) = read_xlsx(&rich_package_with(CONTENT_TYPES, true)).unwrap(); assert!(wb.sheets.iter().all(|s| s.preserved.as_ref().is_some_and(|p| p.vml.is_some()))); } + +#[test] +fn file_version_and_calc_features_survive_a_save() { + // Excel trusts the saved cell values only when workbook.xml carries both and the + // calcFeatures extension; without them it recalculates everything on open (#799). + let calc_features = r#""#; + let workbook = format!( + r#"{calc_features}"# + ); + let sheet = format!( + r#"1A1*22"# + ); + let bytes = make_zip(&[ + ("[Content_Types].xml", super::CT.as_bytes()), + ("_rels/.rels", super::ROOT_RELS.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", rels(&[("rId1", "worksheet", "worksheets/sheet1.xml")]).as_bytes()), + ("xl/worksheets/sheet1.xml", sheet.as_bytes()), + ]); + let (wb, _) = read_xlsx(&bytes).unwrap(); + // Twice: what was kept on the first save is read back and kept again. + let (again, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + for saved in [write_xlsx(&wb).unwrap(), write_xlsx(&again).unwrap()] { + let u = Unpacked::new(&saved); + u.assert_valid(); + let text = u.text("xl/workbook.xml"); + let root = xml::parse(text.as_bytes()).unwrap(); + // Schema order: fileVersion is the first child of , extLst the last. + assert_eq!(root.children.first().map(|e| e.name.as_str()), Some("fileVersion"), "{text}"); + assert_eq!(root.children.last().map(|e| e.name.as_str()), Some("extLst"), "{text}"); + assert!(text.contains(r#""#), "{text}"); + assert!(text.contains(&format!("{calc_features}")), "{text}"); + } + + // A workbook that had neither gets neither. + let plain = Unpacked::new(&write_xlsx(&gridcraft_model::Workbook::new()).unwrap()).text("xl/workbook.xml"); + assert!(!plain.contains("fileVersion") && !plain.contains("extLst"), "{plain}"); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 9c117f4d7..9ff7000a9 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -270,6 +270,7 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { } let mut w = format!("{XML_DECL}"); + w.push_str(&crate::passthrough::workbook_elements(wb, "fileVersion")); w.push_str(if wb.date_system == gridcraft_core::DateSystem::D1904 { "" } else { "" }); if wb.protected_structure { let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default(); @@ -306,6 +307,10 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { } w.push_str("/>"); w.push_str(&pivots.workbook_xml(&mut wb_rels)); + let exts = crate::passthrough::workbook_elements(wb, "ext"); + if !exts.is_empty() { + let _ = write!(w, "{exts}"); + } w.push_str(""); // Workbook-level parts. From 75305dfedf73e3227304108c75358d07bff5ccc4 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:51:41 +0200 Subject: [PATCH 68/86] XLSX: keep the workbook's default font on save (fixes #805) Cell format 0 always mapped to the model's built-in Calibri 11 default, so the file's Normal font was replaced on save and every default-formatted cell changed size. The Normal style now takes the font of cell format 0, and a Normal font in the theme's body face is written with scheme="minor" again. --- crates/model/src/style.rs | 6 ++++ crates/xlsx/src/read.rs | 4 +++ crates/xlsx/src/styles.rs | 13 ++++++--- crates/xlsx/src/tests/roundtrip.rs | 47 ++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index 126727eda..d3976876e 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -377,6 +377,12 @@ impl Default for StyleTable { } impl StyleTable { + /// A table whose default (Normal) style is `normal`, such as a file's own Normal font. + pub fn with_default(normal: Style) -> Self { + let mut index = HashMap::new(); + index.insert(normal.clone(), StyleId::DEFAULT); + StyleTable { styles: vec![normal], index } + } pub fn get(&self, id: StyleId) -> &Style { self.styles.get(id.0 as usize).or_else(|| self.styles.first()).unwrap_or(&DEFAULT_STYLE) } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index a25444d88..c4a1d2754 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -120,6 +120,10 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if let Some(x) = cx.optional_xml(&r.target)? { let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x, &checkboxes); cx.num_fmts = num_fmts; + // Cell format 0 is the Normal style: its font is what every unformatted cell shows. + if let Some(normal) = xfs.first() { + cx.styles = StyleTable::with_default(Style { font: normal.font.clone(), ..Style::default() }); + } cx.xf_map = xfs.into_iter().enumerate().map(|(i, s)| if i == 0 { StyleId::DEFAULT } else { cx.styles.intern(s) }).collect(); cx.dxfs = dxfs; cx.palette = palette; diff --git a/crates/xlsx/src/styles.rs b/crates/xlsx/src/styles.rs index 1c98a544c..01f1fa632 100644 --- a/crates/xlsx/src/styles.rs +++ b/crates/xlsx/src/styles.rs @@ -396,7 +396,8 @@ pub fn color_attrs(c: &Color) -> String { } } -fn font_xml(f: &Font, dxf: bool) -> String { +/// `minor`: the font follows the theme's body font (``). +fn font_xml(f: &Font, dxf: bool, minor: bool) -> String { let mut s = String::from(""); if f.bold { s.push_str(""); @@ -429,6 +430,9 @@ fn font_xml(f: &Font, dxf: bool) -> String { } if !dxf { let _ = write!(s, "", esc_attr(&f.name)); + if minor { + s.push_str(""); + } } s.push_str(""); s @@ -667,7 +671,7 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St || d.font.color != Color::Auto || d.font.vert != VertAlign::Baseline { - dx.push_str(&font_xml(&d.font, true)); + dx.push_str(&font_xml(&d.font, true, false)); } if d.num_fmt != def.num_fmt { let id = nf.id(d.num_fmt.as_str()); @@ -699,8 +703,9 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St s.push_str(""); } let _ = write!(s, "", fonts.len()); - for f in &fonts { - s.push_str(&font_xml(f, false)); + for (i, f) in fonts.iter().enumerate() { + // A Normal font in the theme's body face follows the theme, as Excel writes it (#805). + s.push_str(&font_xml(f, false, i == 0 && f.name == wb.theme.minor_font)); } let _ = write!(s, "", fills.len()); for f in &fills { diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 960157254..a1457fd8d 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1014,3 +1014,50 @@ fn package_kind_sets_workbook_content_type() { } assert_eq!(crate::XlsxKind::from_path("noext"), crate::XlsxKind::Workbook); } + +/// The workbook's default font (`fonts[0]`, the Normal style's) is what every default-formatted +/// cell shows, typed or empty; saving keeps it instead of resetting it to Calibri 11 (#805). +#[test] +fn default_font_survives_a_round_trip() { + let styles = |font0: &str| { + format!( + r#"{font0} + + + + "# + ) + }; + let rels = r#""#; + let body = r#"typed text1"#; + let font0 = |bytes: &[u8]| { + let mut pkg = Package::open(bytes).unwrap(); + let st = pkg.read_xml("xl/styles.xml").unwrap().unwrap(); + let f = st.child("fonts").unwrap().kids("font").next().unwrap().clone(); + let val = |n: &str| f.child(n).and_then(|e| e.attr("val")).map(str::to_string); + (val("sz"), val("name"), val("family"), val("scheme")) + }; + + let file = + super::minimal(body, &[("xl/styles.xml", &styles(r#""#))], rels, ""); + let (wb, _) = read_xlsx(&file).unwrap(); + let saved = write_xlsx(&wb).unwrap(); + assert_eq!(font0(&saved), (Some("10".into()), Some("Arial".into()), Some("2".into()), None)); + let (back, _) = read_xlsx(&saved).unwrap(); + let normal = &back.styles.get(StyleId::DEFAULT).font; + assert_eq!((normal.name.as_str(), normal.size), ("Arial", 10.0), "empty cells and new input take the Normal font"); + let s = back.sheet(0).unwrap(); + assert_eq!(s.cell(at("A1")).unwrap().style, StyleId::DEFAULT); + let b1 = &back.styles.get(s.cell(at("B1")).unwrap().style).font; + assert!(b1.bold && b1.size == 14.0, "an explicit font is still its own: {b1:?}"); + + // Excel's own Normal font follows the theme's body font; the scheme link survives too. + let file = super::minimal( + body, + &[("xl/styles.xml", &styles(r#""#))], + rels, + "", + ); + let saved = write_xlsx(&read_xlsx(&file).unwrap().0).unwrap(); + assert_eq!(font0(&saved), (Some("12".into()), Some("Calibri".into()), Some("2".into()), Some("minor".into()))); +} From 6939d95caee24b5298cff9a6cede4adabf7b43e1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:53:10 +0200 Subject: [PATCH 69/86] Formula bar: hide _xludf. like Excel, keep it when the edit is confirmed unchanged (#726) --- crates/ui-egui/src/formula_locale.rs | 44 +++++++++++++++++++++++--- crates/ui-egui/tests/formula_locale.rs | 20 +++++++++++- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index ad0725e54..6e833b95d 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -9,6 +9,14 @@ pub(crate) fn canonical( sheet: usize, cell: gridcraft_engine::core::CellRef, ) -> Result { + // The editor hid the `_xludf.` prefixes of this cell's formula; confirming it unchanged keeps + // the user-defined calls instead of binding them to the built-ins of the same name. + if let Some(stored) = wb.sheet(sheet).and_then(|sh| sh.cell(cell)).filter(|c| c.formula.is_some()).map(|c| c.input_text()) + && has_udf_prefix(&stored) + && text == display(&stored, locale, wb, sheet) + { + return Ok(stored); + } if locale == FormulaLocale::En || wb.sheet(sheet).is_some_and(|sh| wb.styles.get(sh.style_id(cell)).num_fmt.as_str() == "@") { return Ok(text.to_string()); } @@ -40,16 +48,42 @@ pub(crate) fn canonical( } pub(crate) fn display(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> String { - // English (and every language without a formula table) shows the stored text untouched, - // and skips a parse per frame. + // English (and every language without a formula table) shows the stored text, `_xludf.` + // prefixes aside, and skips a parse per frame. if locale == FormulaLocale::En { - return text.to_string(); + return hide_udf_prefix(text, locale); } let Some(body) = text.strip_prefix('=') else { return text.to_string() }; - formula::parse(body) + let shown = formula::parse(body) .and_then(|expr| formula::print_input(&expr, locale, |name| wb.name(name, sheet).is_some() || wb.table(name).is_some())) .map(|body| format!("={body}")) - .unwrap_or_else(|_| text.to_string()) + .unwrap_or_else(|_| text.to_string()); + hide_udf_prefix(&shown, locale) +} + +const UDF_PREFIX: &str = "_xludf."; + +fn has_udf_prefix(text: &str) -> bool { + text.to_ascii_lowercase().contains(UDF_PREFIX) +} + +/// Excel shows a user-defined call such as `_xludf.EOMONTH()` as `=EOMONTH()`. Only function +/// names lose the prefix: text literals and names keep it. +fn hide_udf_prefix(text: &str, locale: FormulaLocale) -> String { + let Some(body) = text.strip_prefix('=').filter(|b| has_udf_prefix(b)) else { return text.to_string() }; + let Ok(tokens) = formula::lexer::tokenize_locale(body, locale) else { return text.to_string() }; + let mut out = String::from("="); + let mut from = 0; + for t in tokens { + if matches!(t.tok, formula::lexer::Tok::Func(_)) + && body.get(t.start..t.start + UDF_PREFIX.len()).is_some_and(|p| p.eq_ignore_ascii_case(UDF_PREFIX)) + { + out.push_str(body.get(from..t.start).unwrap_or("")); + from = t.start + UDF_PREFIX.len(); + } + } + out.push_str(body.get(from..).unwrap_or("")); + out } // Descriptions/signatures for the supported Spanish function table; untranslated functions diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/formula_locale.rs index fe21218b3..855869377 100644 --- a/crates/ui-egui/tests/formula_locale.rs +++ b/crates/ui-egui/tests/formula_locale.rs @@ -1,7 +1,7 @@ //! Language selection must affect UI entry, never the canonical engine/file boundary. use gridcraft_engine::{ Session, - core::{CellRef, Value}, + core::{CellError, CellRef, Value}, }; use gridcraft_ui_egui::{SheetApp, UiState, i18n::Language}; use serde_json::json; @@ -201,3 +201,21 @@ fn czech_interface_retains_its_canonical_formula_fallback() { let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); assert_eq!(restored.language, Language::Cs); } + +#[test] +fn user_defined_prefix_is_hidden_while_editing_and_kept_on_commit() { + // Excel shows `_xludf.EOMONTH()` as `=EOMONTH()`; the cell still holds the user-defined call + // (#726), and committing the edit unchanged must not turn it into the built-in. + let mut app = app(); + app.run("cell.set", json!({"cell":"A1","input":"=_xludf.EOMONTH()&_xludf.CONCAT(\"_xludf.x\")"})).unwrap(); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, "=EOMONTH()&CONCAT(\"_xludf.x\")"); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(formula(&app), "_xludf.EOMONTH()&_xludf.CONCAT(\"_xludf.x\")"); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Error(CellError::Name)); + app.run("app.language.set", json!({"language":"es"})).unwrap(); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, "=EOMONTH()&CONCAT(\"_xludf.x\")"); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(formula(&app), "_xludf.EOMONTH()&_xludf.CONCAT(\"_xludf.x\")"); +} From 484612f638c2089ba458a851d4e96cf92027bd1e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:56:40 +0200 Subject: [PATCH 70/86] XLS: formulas that use a skipped defined name keep their saved results (fixes #803) --- crates/xlsx/src/tests/xls_tests.rs | 44 ++++++++++++++++++++++++++++++ crates/xlsx/src/xls/formula.rs | 17 +++++++----- crates/xlsx/src/xls/mod.rs | 3 ++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795ec..816c96478 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -476,3 +476,47 @@ fn sheets_share_one_record_budget() { let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); assert!(err.contains("same substream"), "{err}"); } + +#[test] +fn formulas_using_a_skipped_name_keep_their_saved_results() { + // Lbl "ExtRange" = [Source.xlsx]Sheet1!$A$1:$A$3 (PtgArea3d through an external SupBook), + // "Local" = Sheet1!$A$1 and "Twice" = ExtRange*2. Only the first is skipped on import; a + // name that uses it is still read as it was. + let ext = cat(&[&[0x3B], &1u16.to_le_bytes(), &u16s(&[0, 2, 0, 0])]); + let local = cat(&[&[0x3A], &0u16.to_le_bytes(), &u16s(&[0, 0])]); + let twice = cat(&[&[0x23], &1u32.to_le_bytes(), &ptg_int(2), &[0x05]]); + let lbl = |name: &str, rgce: &[u8]| { + rec(0x0018, &cat(&[&u16s(&[0]), &[0, name.len() as u8], &u16s(&[rgce.len() as u16, 0, 0]), &[0; 4], &[0], name.as_bytes(), rgce])) + }; + let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])); + let globals = |pos: u32| { + cat(&[ + &bof(0x0005), + &boundsheet(pos), + &rec(0x01AE, &u16s(&[1, 0x0401])), + &rec(0x01AE, &cat(&[&u16s(&[1]), &xlstr("Source.xlsx"), &xlstr("Sheet1")])), + &rec(0x0017, &cat(&[&u16s(&[2]), &u16s(&[0, 0, 0]), &u16s(&[1, 0, 0])])), + &lbl("ExtRange", &ext), + &lbl("Local", &local), + &lbl("Twice", &twice), + &eof(), + ]) + }; + let sum_ext = cat(&[&[0x43], &1u32.to_le_bytes(), &[0x22, 1], &4u16.to_le_bytes()]); + let local_x2 = cat(&[&[0x43], &2u32.to_le_bytes(), &ptg_int(2), &[0x05]]); + let sheet = cat(&[ + &bof(0x0010), + &rec(0x0203, &cat(&[&cell(0, 0, 0), &10.0f64.to_le_bytes()])), + &formula(5, 1, num(60.0), &sum_ext), + &formula(6, 1, num(20.0), &local_x2), + &eof(), + ]); + let pos = globals(0).len() as u32; + let (wb, report) = read_xls(&compound("Workbook", &cat(&[&globals(pos), &sheet]))).unwrap(); + let names: Vec<_> = wb.names.iter().map(|n| (n.name.as_str(), n.formula.as_str())).collect(); + assert_eq!(names, [("Local", "Sheet1!$A$1"), ("Twice", "ExtRange*2")]); + assert_eq!(ftext(&wb, "B6"), None); + assert_eq!(v(&wb, 0, "B6"), Value::Number(60.0)); + assert_eq!(ftext(&wb, "B7").as_deref(), Some("Local*2")); + assert!(report.warnings.iter().any(|w| w.contains("defined names") && w.contains("saved results")), "{:?}", report.warnings); +} diff --git a/crates/xlsx/src/xls/formula.rs b/crates/xlsx/src/xls/formula.rs index 87a512c59..3de860707 100644 --- a/crates/xlsx/src/xls/formula.rs +++ b/crates/xlsx/src/xls/formula.rs @@ -51,6 +51,8 @@ pub(super) struct Links { pub sheets: Vec, /// Defined names (Lbl records), in order; `None` for names that can't be referenced. pub names: Vec, + /// Per defined name: whether the import skips it (its formula can't be decoded). + pub skipped: Vec, pub books: Vec, pub xti: Vec, } @@ -330,13 +332,13 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } } 0x23 => { - let i = t.u32()?; - let name = usize::try_from(i) - .ok() - .and_then(|i| i.checked_sub(1)) - .and_then(|i| links.names.get(i)) - .filter(|n| !n.is_empty()) - .ok_or(Unsupported("reference to a missing defined name"))?; + let i = usize::try_from(t.u32()?).ok().and_then(|i| i.checked_sub(1)); + let name = i.and_then(|i| links.names.get(i)).filter(|n| !n.is_empty()).ok_or(Unsupported("reference to a missing defined name"))?; + // A cell using a name the import drops would calculate to #NAME?: keep its + // saved result instead, as for references to other workbooks. + if mode != Mode::Name && i.and_then(|i| links.skipped.get(i)) == Some(&true) { + return Err(Unsupported("defined names GridCraft skipped")); + } push(&mut stack, kids, Expr::Name(name.clone()))?; } 0x24 | 0x2C => { @@ -479,6 +481,7 @@ mod tests { Links { sheets: vec!["Sheet1".into(), "My Data".into()], names: vec!["Rate".into()], + skipped: vec![false], books: vec![ SupBook { internal: true, addin: false, names: vec![] }, SupBook { internal: false, addin: true, names: vec!["_xlfn.IFERROR".into()] }, diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66a3..3c6c2f5a1 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -297,6 +297,9 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { _ => {} } } + // The same test `defined_names` applies, before any cell formula is decoded. + let skipped = g.names.iter().map(|l| !l.rgce.is_empty() && decode(&l.rgce, &l.rgcb, CellRef::new(0, 0), Mode::Name, &g.links).is_err()).collect(); + g.links.skipped = skipped; Ok(g) } From 52b4f873c76ba8e535623a57229e16997f078bad Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:41:01 +0200 Subject: [PATCH 71/86] XLSX: a hyperlink to another file is saved as an external relationship (fixes #800) --- crates/engine/src/cmd/insert.rs | 23 ++++++++++-- crates/engine/src/tests.rs | 50 ++++++++++++++++++++++++++ crates/model/src/cellmap.rs | 2 +- crates/model/src/features.rs | 7 +++- crates/xlsx/src/sheet_read.rs | 4 ++- crates/xlsx/src/sheet_write.rs | 5 ++- crates/xlsx/src/tests/fixtures.rs | 57 ++++++++++++++++++++++++++++++ crates/xlsx/src/tests/roundtrip.rs | 6 ++-- 8 files changed, 145 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 392c5eebf..6b0b3ca2a 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -153,7 +153,7 @@ pub fn specs() -> Vec { "Link", ["Insert", "Links"], Some("Cmd+K"), - "{cell?, target: \"https://…\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}", + "{cell?, target: \"https://…\" | \"Book.xlsx\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}", has_doc, insert_link ), @@ -1156,6 +1156,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { }; let text = str_param(p, "text").map(str::to_string); let tooltip = str_param(p, "tooltip").map(str::to_string); + let external = links_outside(&s.doc()?.wb, sheet, &target); edit(s, |cx| { let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| { st.font.color = Color::Theme(10, 0); @@ -1167,12 +1168,30 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { } else { sh.set_style(at, link_style); } - sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone() }); + sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external }); cx.touch(sheet, at); Ok(Json::Null) }) } +/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This +/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web +/// Page") is outside the workbook and saved as a relationship. +fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool { + let t = target.trim(); + if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() { + return false; + } + let (on, body) = split_sheet(t); + // Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference, + // and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name. + let on_sheet = on.is_none_or(|name| { + wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name)) + || (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#'])) + }); + !(on_sheet && RangeRef::parse(body).is_some()) +} + fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result { let sheet = target_sheet(s, p)?; let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc113e..4fdc87aa4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2549,6 +2549,56 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.cells, before.cells); } +/// Insert › Link sorts a target as Excel's dialog does: a sheet reference, a defined name or `#…` is +/// a place in this document, anything else an existing file or web page. A file saved as a +/// `location` opens nothing in Excel (#800). +#[test] +fn links_to_files_are_saved_as_external_relationships() { + use std::io::Read; + let mut s = s(); + s.execute("formulas.defineName", json!({"name": "Price", "refersTo": "=Sheet1!$A$1"})).unwrap(); + s.execute("insert.table", json!({"range": "F1:G3", "name": "Sales"})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Q#1"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let links = [ + ("A1", "Source.xlsx", true), + ("A2", r"C:\Temp\Book.xlsx", true), + ("A3", r"..\Reports\Q1.xlsx", true), + ("A4", "Other.xlsx#Sheet1!A2", true), + ("A5", "https://example.com/a.xlsx", true), + ("A6", "Sheet1!B2", false), + ("A7", "#Sheet1!B3", false), + ("A8", "Price", false), + ("A9", "'Sheet1'!C4:D5", false), + ("A10", "Sales", false), + ("A11", "'Q#1'!B2", false), + ]; + for (cell, target, _) in links { + s.execute("insert.link", json!({"cell": cell, "target": target})).unwrap(); + } + let bytes = gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap(); + let mut part = |name: &str| { + let mut text = String::new(); + zip.by_name(name).unwrap().read_to_string(&mut text).unwrap(); + text + }; + let (sheet, rels) = (part("xl/worksheets/sheet1.xml"), part("xl/worksheets/_rels/sheet1.xml.rels")); + for (cell, target, external) in links { + let file = target.split_once('#').map_or(target, |(f, _)| f); + let as_rel = format!("Target=\"{file}\" TargetMode=\"External\""); + let as_location = format!("location=\"{}\"", target.trim_start_matches('#')); + assert_eq!(rels.contains(&as_rel), external, "{cell} {target}: {rels}"); + assert_eq!(sheet.contains(&as_location), !external, "{cell} {target}: {sheet}"); + } + assert!(sheet.contains("location=\"Sheet1!A2\""), "{sheet}"); + let (back, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap(); + for (cell, target, external) in links { + let h = &back.sheet(0).unwrap().hyperlinks[&CellRef::parse(cell).unwrap()]; + assert_eq!((h.target.as_str(), h.external), (target.trim_start_matches('#'), external), "{cell}"); + } +} + #[test] fn vertical_alignment_commands_and_roundtrip() { let mut s = s(); diff --git a/crates/model/src/cellmap.rs b/crates/model/src/cellmap.rs index 0f2c685a7..dbbe202f5 100644 --- a/crates/model/src/cellmap.rs +++ b/crates/model/src/cellmap.rs @@ -43,7 +43,7 @@ mod tests { dates: vec![], }, ); - s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()) }); + s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()), external: false }); let json = serde_json::to_string(&s).expect("serialize"); let back: Sheet = serde_json::from_str(&json).expect("deserialize"); assert_eq!(back.comments, s.comments); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index e04db25cd..105ee1730 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -304,9 +304,14 @@ pub struct Comment { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Hyperlink { - /// URL, `mailto:`, or an in-workbook location like `Sheet2!A1`. + /// URL, `mailto:`, a file path (`Book.xlsx`, `Book.xlsx#Sheet1!A2`), or an in-workbook location + /// like `Sheet2!A1`. pub target: String, pub tooltip: Option, + /// The target is outside this workbook (Excel's "Existing File or Web Page"): a relationship in + /// the file, not a `location`. A bare file name can't be told from a defined name by its text. + #[serde(default)] + pub external: bool, } // ---------------------------------------------------------------- drawing objects diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 77b46d95b..a29175ba6 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -714,6 +714,8 @@ fn read_hyperlinks(e: &El, rels: &[Rel], sheet: &mut Sheet) { for h in e.kids("hyperlink") { let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue }; let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default(); + // A relationship is a file or web page; a bare `location` is a place in this workbook. + let external = !target.is_empty(); if let Some(loc) = h.attr("location").filter(|l| !l.is_empty()) { if target.is_empty() { target = loc.trim_start_matches('#').to_string(); @@ -724,7 +726,7 @@ fn read_hyperlinks(e: &El, rels: &[Rel], sheet: &mut Sheet) { if target.is_empty() { continue; } - let link = Hyperlink { target, tooltip: h.attr("tooltip").map(str::to_string) }; + let link = Hyperlink { target, tooltip: h.attr("tooltip").map(str::to_string), external }; for c in range.iter() { if cells >= MAX_HYPERLINK_CELLS { return; diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 0bfcb7d85..f53c5544f 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -388,7 +388,10 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if t.is_empty() { continue; } - let external = t.contains("://") + // `external` is what the file said, or what Insert › Link chose; the scheme test covers + // links made before the flag existed. + let external = h.external + || t.contains("://") || t.to_ascii_lowercase().starts_with("mailto:") || t.starts_with("\\\\") || t.to_ascii_lowercase().starts_with("file:"); diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 47dde5adc..5dc45db2e 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -278,6 +278,63 @@ Check this"#; assert_eq!(s.sparklines[0].kind, gridcraft_model::SparklineKind::Column); } +/// The sheet's hyperlinks as written: cell, relationship target (external only), location. +fn written_links(bytes: &[u8]) -> Vec<(String, Option, Option)> { + let mut pkg = crate::package::Package::open(bytes).unwrap(); + let rels = pkg.rels("xl/worksheets/sheet1.xml").unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + let Some(links) = sheet.child("hyperlinks") else { return vec![] }; + links + .kids("hyperlink") + .map(|h| { + let rel = h.attr("id").map(|id| rels.iter().find(|r| r.id == id).unwrap()); + let target = rel.map(|r| { + assert!(r.external && r.kind == "hyperlink", "{r:?}"); + r.target.clone() + }); + (h.attr("ref").unwrap().to_string(), target, h.attr("location").map(str::to_string)) + }) + .collect() +} + +/// A link to another file is a relationship (`TargetMode="External"`) in the file, with the place in +/// it as `location`. Saved as a bare `location`, Excel takes it for a place in this workbook and the +/// link no longer opens the file (#800). C16 is Apache POI's `WithMoreVariousData.xlsx`. +#[test] +fn hyperlink_to_another_file_stays_external() { + let body = r#" + "#; + let rel = |id: &str, target: &str| { + format!( + r#""# + ) + }; + let sheet_rels = format!( + r#"{}{}{}{}"#, + rel("rId2", "WithVariousData.xlsx"), + rel("rId3", "Source.xlsx"), + rel("rId4", r"..\Reports\Q1.xlsx"), + rel("rId5", "https://example.com/a.xlsx"), + ); + let bytes = minimal(body, &[("xl/worksheets/_rels/sheet1.xml.rels", &sheet_rels)], "", ""); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let s = wb.sheet(0).unwrap(); + assert!(s.hyperlinks[&at("C16")].external && !s.hyperlinks[&at("C19")].external); + + let some = |s: &str| Some(s.to_string()); + let links = written_links(&crate::write_xlsx(&wb).unwrap()); + assert_eq!( + links, + vec![ + ("C16".into(), some("WithVariousData.xlsx"), None), + ("C17".into(), some("Source.xlsx"), some("Sheet1!A2")), + ("C18".into(), some(r"..\Reports\Q1.xlsx"), None), + ("C19".into(), None, some("Data!B2")), + ("C20".into(), some("https://example.com/a.xlsx"), None), + ] + ); +} + #[test] fn drawing_with_picture_and_chart() { let png: &[u8] = b"\x89PNG\r\n\x1a\nfakeimagedata"; diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 960157254..f3bcb65af 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -204,9 +204,9 @@ fn sample() -> Workbook { security_descriptor: None, }, ]; - s.hyperlinks.insert(at("A2"), Hyperlink { target: "https://example.com/?a=1&b=2".into(), tooltip: Some("go".into()) }); - s.hyperlinks.insert(at("A3"), Hyperlink { target: "'Data & Stuff'!B2".into(), tooltip: None }); - s.hyperlinks.insert(at("A4"), Hyperlink { target: "mailto:me@example.com".into(), tooltip: None }); + s.hyperlinks.insert(at("A2"), Hyperlink { target: "https://example.com/?a=1&b=2".into(), tooltip: Some("go".into()), external: true }); + s.hyperlinks.insert(at("A3"), Hyperlink { target: "'Data & Stuff'!B2".into(), tooltip: None, external: false }); + s.hyperlinks.insert(at("A4"), Hyperlink { target: "mailto:me@example.com".into(), tooltip: None, external: true }); s.comments.insert( at("B2"), Comment { From 1f19bf4a27e1f4934522e94a5875a62a266bfb53 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 23:28:26 +0200 Subject: [PATCH 72/86] Grid: Shift+arrows in point mode extend from the pointed cell, also up and left (fixes #835) --- crates/ui-egui/src/grid.rs | 12 +++++++++++- crates/ui-egui/tests/interaction.rs | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..b1e3b778d 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2674,7 +2674,17 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte let from = ed.point_cell.unwrap_or(ed.cell); let to = from.offset_clamped(dr, dc); let text = if extend { - let anchor = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(|t| CellRef::parse(t.split(':').next().unwrap_or(""))).unwrap_or(from); + // The anchor is the corner opposite the moving end: the written range is normalized, so + // after extending up or left its first cell is the moving end, not the anchor. + let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse); + let anchor = pointed + .map(|r| { + CellRef::new( + if from.row == r.start.row { r.end.row } else { r.start.row }, + if from.col == r.start.col { r.end.col } else { r.start.col }, + ) + }) + .unwrap_or(from); RangeRef::new(anchor, to).a1() } else { to.a1() diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 709393389..529cbfeef 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -191,6 +191,25 @@ fn arrows_escape_and_undo() { assert_eq!(value(&h, "B2"), Value::Empty); } +#[test] +fn shift_arrows_in_point_mode_extend_from_the_pointed_cell() { + let mut s = blank(); + s.execute("selection.set", json!({"cell": "A11"})).unwrap(); + let mut h = harness(s); + text(&mut h, "=SUM("); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + let edited = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().editor.as_ref().map(|e| e.text.clone()); + assert_eq!(edited(&h).as_deref(), Some("=SUM(A10")); + // Shift is held down, as on a real keyboard: the backend reports it before the arrow keys. + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT)); + for expected in ["=SUM(A9:A10", "=SUM(A8:A10", "=SUM(A7:A10"] { + key(&mut h, Key::ArrowUp, Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some(expected)); + } + key(&mut h, Key::ArrowDown, Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some("=SUM(A8:A10"), "Shift+Down shrinks back towards the anchor"); +} + #[test] fn shortcuts_format_and_extend() { let mut s = blank(); From 443301f9eea66b69b1328b1faa831008ea6b7799 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:21:25 +0200 Subject: [PATCH 73/86] UI: Enter pastes the copied range and ends copy mode (fixes #845) --- crates/ui-egui/src/grid.rs | 6 ++++++ crates/ui-egui/tests/clipboard.rs | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..7673debfb 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2398,6 +2398,12 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()})); app.grid.ensure_visible = true; } + // Excel: Enter while a copied range is marked pastes it and ends copy mode. + Key::Enter if !m.alt && !m.ctrl && !shift && !cmd && app.session.clipboard.is_some() => { + app.run_or_alert("edit.paste", json!({})); + app.session.clipboard = None; + app.grid.ensure_visible = true; + } Key::Enter => { if m.alt || m.ctrl { app.begin_edit(None, false); diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs index 71fe3364e..e2ac4fb2c 100644 --- a/crates/ui-egui/tests/clipboard.rs +++ b/crates/ui-egui/tests/clipboard.rs @@ -116,3 +116,24 @@ fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() { assert_eq!(h.state().message.as_ref().map(|(_, text)| text.as_str()), Some("This action won't work on multiple selections.")); assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2), "the earlier copy stays"); } + +#[test] +fn enter_pastes_the_copied_cells_and_ends_copy_mode() { + // #845: with the marching ants showing, Enter pastes into the selection and cancels the copy. + let mut h = harness(Services::default()); + h.input_mut().events.push(Event::Copy); + h.step(); + h.state_mut().run("selection.set", json!({"cell": "D5"})).unwrap(); + h.key_press(egui::Key::Enter); + h.run_steps(2); + let value = |r, c| h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(gridcraft_engine::core::CellRef::new(r, c)))); + assert_eq!(value(4, 3), Some(gridcraft_engine::core::Value::text("Name"))); + assert_eq!(value(5, 4), Some(gridcraft_engine::core::Value::number(42.0))); + assert!(h.state().session.clipboard.is_none(), "the marching ants are gone"); + assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()), Some("D5".to_string()), "Enter does not also move down"); + + // Copy mode is over: the next Enter just moves. + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()), Some("D6".to_string())); +} From 68a3eabe2090684ee92ed489590861bd5b4c9d85 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:25:32 +0200 Subject: [PATCH 74/86] Sheet tabs: All Sheets navigator with a filter field (fixes #839) --- crates/ui-egui/src/icons.rs | 8 ++++ crates/ui-egui/src/ribbon.rs | 4 +- crates/ui-egui/src/tabs.rs | 57 ++++++++++++++++++++----- crates/ui-egui/tests/sheet_navigator.rs | 44 +++++++++++++++++++ 4 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/tests/sheet_navigator.rs diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fe9f7bf51..3e5cdf379 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -113,6 +113,8 @@ pub enum Icon { /// Light/dark appearance: a circle, its right half filled. Appearance, Sheet, + /// All Sheets navigator next to the sheet tabs: a list of rows with a bullet each. + SheetList, Book, Folder, /// Header & Footer: a page with its top / bottom band marked. @@ -419,6 +421,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.line(&[(2.0, 8.0), (8.0, 2.5), (14.0, 8.0)], c, 1.2); g.line(&[(4.0, 6.5), (4.0, 13.5), (12.0, 13.5), (12.0, 6.5)], c, 1.2); } + Icon::SheetList => { + for y in [4.0, 8.0, 12.0] { + g.rect(2.5, y - 1.0, 2.0, 2.0, Some(c), None); + g.stroke(&[(6.5, y), (13.5, y)], c); + } + } Icon::More => { for x in [4.0, 8.0, 12.0] { g.circle(x, 8.0, 1.0, Some(c), None); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291accd..a4f0c6be0 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -367,7 +367,7 @@ struct ComboState { } /// What the user did with an [`editable_combo`]. -enum ComboResult { +pub(crate) enum ComboResult { None, /// A row was applied: clicked or Enter on the highlighted row. Picked(String), @@ -378,7 +378,7 @@ enum ComboResult { /// Excel-style dropdown: a filter field (type to narrow the list) plus arrow/Enter selection. /// `items` are `(value, display)`; the value is what comes back. When `forced` (keytip invoke) /// or clicked, the popup opens, focuses the filter field, and selects its text. -fn editable_combo( +pub(crate) fn editable_combo( ui: &mut Ui, anchor: &egui::Response, salt: &'static str, diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index e55f575c6..17b7f1c58 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -1,7 +1,9 @@ //! Sheet tabs and the status bar. +use std::sync::Arc; + use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use gridcraft_engine::model::Visibility; +use gridcraft_engine::model::{Sheet, Visibility}; use serde_json::json; use crate::SheetApp; @@ -14,6 +16,25 @@ fn rename_needs_focus(has_focus: bool, lost_focus: bool) -> bool { !has_focus && !lost_focus } +/// Rows of the All Sheets navigator as `(workbook index, name)`. Hidden sheets stay out, like on the tab strip: +/// Unhide is the way to reach them. +fn navigator_items(sheets: &[Arc]) -> Vec<(String, String)> { + sheets.iter().enumerate().filter(|(_, s)| s.visibility == Visibility::Visible).map(|(i, s)| (i.to_string(), s.name.clone())).collect() +} + +/// Switch to sheet `i` from the tab strip or the navigator. In point mode the formula being typed keeps going on the +/// other sheet; otherwise the edit is committed first. +fn activate_sheet(app: &mut SheetApp, i: usize) { + if app.editor.as_ref().is_some_and(|e| e.can_point()) { + let _ = app.session.run("sheet.activate", json!({"sheet": i})); + } else { + if app.editor.is_some() { + app.commit_edit(0, 0, false, false); + } + app.run_or_alert("sheet.activate", json!({"sheet": i})); + } +} + pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { @@ -28,6 +49,16 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, "Next sheet", vec2(24.0, 24.0)).clicked() { app.run_or_alert("sheet.next", json!({})); } + let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, "All sheets", vec2(24.0, 24.0)); + all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "All sheets")); + let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); + let active = app.session.active().map(|d| d.wb.active_sheet.to_string()).unwrap_or_default(); + let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); + if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false) + && let Ok(i) = i.parse::() + { + activate_sheet(app, i); + } ui.add_space(8.0); let Some(d) = app.session.active() else { return }; let sheets: Vec<(usize, String, Option<[u8; 3]>, bool)> = @@ -106,15 +137,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); } if resp.clicked() { - if app.editor.as_ref().is_some_and(|e| e.can_point()) { - // Point mode across sheets: switch without committing. - let _ = app.session.run("sheet.activate", json!({"sheet": i})); - } else { - if app.editor.is_some() { - app.commit_edit(0, 0, false, false); - } - app.run_or_alert("sheet.activate", json!({"sheet": i})); - } + activate_sheet(app, i); } if resp.double_clicked() { app.grid.renaming_tab = Some(i); @@ -332,7 +355,9 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { #[cfg(test)] mod tests { - use super::rename_needs_focus; + use super::{navigator_items, rename_needs_focus}; + use gridcraft_engine::model::{Sheet, Visibility}; + use std::sync::Arc; #[test] fn rename_field_takes_focus_once_and_lets_go_on_commit() { @@ -340,4 +365,14 @@ mod tests { assert!(!rename_needs_focus(true, false), "already focused: do not ask again"); assert!(!rename_needs_focus(false, true), "Enter or click-away: must not take focus back"); } + + #[test] + fn navigator_lists_visible_sheets_by_workbook_index() { + let mut hidden = Sheet::new("Hidden"); + hidden.visibility = Visibility::Hidden; + let mut very_hidden = Sheet::new("VeryHidden"); + very_hidden.visibility = Visibility::VeryHidden; + let sheets = [Sheet::new("Data"), hidden, very_hidden, Sheet::new("Summary")].map(Arc::new); + assert_eq!(navigator_items(&sheets), vec![("0".to_string(), "Data".to_string()), ("3".to_string(), "Summary".to_string())]); + } } diff --git a/crates/ui-egui/tests/sheet_navigator.rs b/crates/ui-egui/tests/sheet_navigator.rs new file mode 100644 index 000000000..a16e306ed --- /dev/null +++ b/crates/ui-egui/tests/sheet_navigator.rs @@ -0,0 +1,44 @@ +//! The All Sheets navigator next to the sheet tabs: type part of a name, Enter, and that sheet is active (#839). + +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut s = Session::new(); + s.new_workbook(); + for _ in 0..3 { + s.run("home.insertSheet", json!({})).unwrap(); + } + for (i, name) in ["Data", "Hidden", "Notes", "Summary"].iter().enumerate() { + s.run("sheet.rename", json!({"sheet": i, "name": name})).unwrap(); + } + s.run("sheet.hide", json!({"sheet": 1})).unwrap(); + s.run("sheet.activate", json!({"sheet": 0})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +#[test] +fn typing_a_name_and_enter_activates_that_sheet() { + let mut h = harness(); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 0); + h.get_by_label("All sheets").click(); + h.run_steps(4); + h.input_mut().events.push(Event::Text("summ".into())); + h.run_steps(3); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 3, "Enter on the filtered row must activate Summary"); +} From 39b3d55f8a0185827067f068da9b396baf1fbbf7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:16:05 +0200 Subject: [PATCH 75/86] CSV export: refuse a file over 512 MiB instead of expanding the whole used range (#528) write_csv visited every position of the used range and appended to one growing String with no limit, so two cells far apart (A1 and XFD1048576) asked for about 16 GiB of commas. The output is now capped at 512 MiB: a lower bound (delimiters and line ends of every row) is checked before anything is allocated, and the text written so far is checked after each row. Over the cap, write_csv returns IoError::TooLarge naming the rows, columns and limit, and Save As / Export CSV and TSV report it without writing a file. Accepted exports are unchanged. write_csv now returns a Result. --- crates/engine/src/io.rs | 7 +-- crates/engine/tests/csv_export_budget.rs | 45 +++++++++++++++++++ crates/xlsx/src/csv.rs | 33 ++++++++++++-- crates/xlsx/src/lib.rs | 2 +- crates/xlsx/src/tests/csv_tests.rs | 56 ++++++++++++++++++++++-- 5 files changed, 132 insertions(+), 11 deletions(-) create mode 100644 crates/engine/tests/csv_export_budget.rs diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index bc7011dec..5453be859 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -164,8 +164,8 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), // A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`). - FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()), - FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()), + FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default), + FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default), FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), FileKind::Html => Ok(to_html(wb, sheet).into_bytes()), } @@ -174,7 +174,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { /// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all /// characters in each cell" (Microsoft Support, *Excel formatting and features that are not /// transferred to other file formats*): number formats applied, never column-width `#####`. -fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec { +fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result> { gridcraft_xlsx::write_csv(sh, delimiter, |c| { // General's grid-width formatting rounds large identifiers to scientific notation. // Delimited files have no column width, so preserve the whole numeric value instead. @@ -187,6 +187,7 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec { crate::display::value_text(wb, sh, c) } }) + .map_err(|e| EngineError::Other(e.to_string())) } fn esc(s: &str) -> String { diff --git a/crates/engine/tests/csv_export_budget.rs b/crates/engine/tests/csv_export_budget.rs new file mode 100644 index 000000000..22068d1c9 --- /dev/null +++ b/crates/engine/tests/csv_export_budget.rs @@ -0,0 +1,45 @@ +//! CSV and TSV export is capped: two cells far apart in a sheet would otherwise make a file as +//! big as the whole rectangle between them. A refused export is an error and writes nothing. + +use gridcraft_engine::Session; +use serde_json::json; + +fn two_far_cells() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap(); + s.execute("cell.set", json!({"cell": "XFD1048576", "input": "b"})).unwrap(); + s +} + +#[test] +fn a_sparse_sheet_spanning_the_grid_is_refused_not_expanded() { + let mut s = two_far_cells(); + for format in ["csv", "tsv"] { + let err = s.execute("file.saveBytes", json!({"format": format})).unwrap_err().to_string(); + assert!(err.contains("1048576 rows by 16384 columns") && err.contains("MiB"), "{format}: {err}"); + } + // Save As and Export write no file, and the document stays as it was. + let dir = std::env::temp_dir().join(format!("gridcraft-csv-budget-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("big.csv"); + let err = s.execute("file.exportCsv", json!({"path": path.to_str().unwrap()})).unwrap_err().to_string(); + assert!(err.contains("over the limit"), "{err}"); + assert!(!path.exists(), "nothing is written for a refused export"); + std::fs::remove_dir_all(&dir).unwrap(); + // Other formats are unaffected, and so is a small export from the same session. + assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok()); + s.execute("cell.set", json!({"cell": "XFD1048576", "input": ""})).unwrap(); + assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], 3); +} + +#[test] +fn exports_below_the_cap_keep_their_positions() { + for (corner, bytes) in [("C3", 14), ("Z10", 272), ("XFD8", 131_082)] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap(); + s.execute("cell.set", json!({"cell": corner, "input": "b"})).unwrap(); + assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], bytes, "{corner}"); + } +} diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index 2becc0e8f..47486bfa2 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -8,6 +8,9 @@ use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook}; use crate::{CsvOptions, IoError}; +/// The largest CSV or tab-separated file written (bytes). +pub const MAX_CSV_BYTES: usize = 512 << 20; + /// Windows-1252 code points for bytes 0x80–0x9F (others map to Latin-1). const CP1252: [u32; 32] = [ 0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019, @@ -187,10 +190,31 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { /// Writes the sheet's used range as CSV (CRLF line ends, UTF-8 without BOM). `cell_text` gives /// each cell's field text: the caller decides how values look (Save As CSV writes them as /// displayed, number formats applied); quoting is done here. -pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef) -> String) -> Vec { +/// +/// The output is capped at [`MAX_CSV_BYTES`]: two cells far apart make the used range, and so the +/// file, as big as the whole rectangle between them (`A1` and `XFD1048576` are 16 GiB of commas). +/// An export that would pass the cap is refused before anything is allocated, or as soon as the +/// text written so far passes it, with [`IoError::TooLarge`]; accepted exports keep every field in +/// its place. +pub fn write_csv(sheet: &Sheet, delimiter: u8, cell_text: impl FnMut(CellRef) -> String) -> Result, IoError> { + write_csv_limited(sheet, delimiter, MAX_CSV_BYTES, cell_text) +} + +/// [`write_csv`] with the output cap `limit` (bytes) given. +pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_text: impl FnMut(CellRef) -> String) -> Result, IoError> { let delim = if delimiter == 0 { ',' } else { delimiter as char }; let mut out = String::new(); - let Some(r) = sheet.used_range() else { return Vec::new() }; + let Some(r) = sheet.used_range() else { return Ok(Vec::new()) }; + let too_large = + |what: String| IoError::TooLarge(format!("the CSV would be {what}, over the limit of {} MiB; export a smaller range", limit >> 20)); + // Every row has a delimiter between its fields and a CRLF, whatever the cells hold: a lower + // bound on the output that needs no cell text. + let (rows, cols) = (u64::from(r.end.row - r.start.row) + 1, u64::from(r.end.col - r.start.col) + 1); + let per_row = (cols - 1).saturating_mul(delim.len_utf8() as u64).saturating_add(2); + let least = rows.saturating_mul(per_row); + if least > limit as u64 { + return Err(too_large(format!("at least {} MiB ({rows} rows by {cols} columns)", least >> 20))); + } for row in r.start.row..=r.end.row { for col in r.start.col..=r.end.col { if col > r.start.col { @@ -206,6 +230,9 @@ pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef } } out.push_str("\r\n"); + if out.len() > limit { + return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1))); + } } - out.into_bytes() + Ok(out.into_bytes()) } diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index b5e4bb66e..6fd80d9c2 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -45,7 +45,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use crypto::{DecryptError, check_encryption, decrypt}; -pub use csv::{read_csv, write_csv}; +pub use csv::{MAX_CSV_BYTES, read_csv, write_csv, write_csv_limited}; pub use ods::read_ods; pub use xls::read_xls; pub use xlsb::read_xlsb; diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index c5951555e..cc52072e7 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -2,7 +2,7 @@ use gridcraft_core::{CellRef, DateSystem, Value}; use gridcraft_model::{Sheet, Workbook}; use crate::csv::{decode_text, sniff_delimiter}; -use crate::{CsvOptions, Format, read_csv, sniff, write_csv}; +use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited}; fn v(wb: &Workbook, r: u32, c: u32) -> Value { wb.sheet(0).unwrap().value(CellRef::new(r, c)) @@ -109,11 +109,11 @@ fn writing() { Value::Error(e) => e.as_str().into(), Value::Array(_) => String::new(), }; - let out = String::from_utf8(write_csv(sh, b',', raw)).unwrap(); + let out = String::from_utf8(write_csv(sh, b',', raw).unwrap()).unwrap(); assert_eq!(out, "\"a,b\",\"q\"\"t\",\r\n0.1,FALSE,\r\n#DIV/0!,,\"x\ny\"\r\n"); - let semi = String::from_utf8(write_csv(sh, b';', raw)).unwrap(); + let semi = String::from_utf8(write_csv(sh, b';', raw).unwrap()).unwrap(); assert!(semi.starts_with("a,b;")); - assert!(write_csv(&Sheet::new("E"), b',', raw).is_empty()); + assert!(write_csv(&Sheet::new("E"), b',', raw).unwrap().is_empty()); // Round trip. let back = read_csv(out.as_bytes(), &CsvOptions::default()).unwrap(); assert_eq!(v(&back, 0, 0), Value::text("a,b")); @@ -142,3 +142,51 @@ fn hostile_csv() { let wb = read_csv(format!("{wide}x").as_bytes(), &CsvOptions::default()).unwrap(); assert!(wb.sheet(0).unwrap().cells.is_empty(), "fields past column XFD are dropped"); } + +/// A sheet with `a` in A1 and `b` in `corner`. +fn two_cells(corner: (u32, u32)) -> Sheet { + let mut s = Sheet::new("S"); + s.set_value(CellRef::new(0, 0), Value::text("a")); + s.set_value(CellRef::new(corner.0, corner.1), Value::text("b")); + s +} + +fn text_of(sh: &Sheet) -> impl FnMut(CellRef) -> String + '_ { + |c| match sh.value(c) { + Value::Text(t) => t.to_string(), + _ => String::new(), + } +} + +#[test] +fn two_far_apart_cells_are_refused_before_anything_is_allocated() { + // A1 and XFD1048576 span the whole grid: 16 GiB of commas. Refused at once, with the numbers. + let s = two_cells((gridcraft_core::MAX_ROWS - 1, gridcraft_core::MAX_COLS - 1)); + let err = write_csv(&s, b',', text_of(&s)).unwrap_err(); + assert!(matches!(err, IoError::TooLarge(_)), "{err}"); + let msg = err.to_string(); + assert!(msg.contains("1048576 rows by 16384 columns") && msg.contains("512 MiB"), "{msg}"); + // A single row or column across the grid is refused too when it is over the cap. + let wide = two_cells((gridcraft_core::MAX_ROWS - 1, 0)); + assert!(write_csv_limited(&wide, b',', 1 << 20, text_of(&wide)).is_err()); +} + +#[test] +fn the_cap_counts_the_bytes_the_cells_hold_and_accepted_exports_keep_every_position() { + // 3 rows x 3 columns, 14 bytes: accepted at the cap, refused one byte under. + let s = two_cells((2, 2)); + let ok = write_csv_limited(&s, b',', 14, text_of(&s)).unwrap(); + assert_eq!(String::from_utf8(ok).unwrap(), "a,,\r\n,,\r\n,,b\r\n"); + assert!(write_csv_limited(&s, b',', 13, text_of(&s)).is_err()); + // Rows that pass the lower bound but hold long text stop as soon as the cap is passed. + let mut long = Sheet::new("L"); + for r in 0..50u32 { + long.set_value(CellRef::new(r, 0), Value::text("x".repeat(100))); + } + let err = write_csv_limited(&long, b',', 1000, text_of(&long)).unwrap_err(); + assert!(err.to_string().contains("stopped at row"), "{err}"); + assert!(write_csv_limited(&long, b',', 50 * 102, text_of(&long)).is_ok()); + // A tab is one byte, like a comma. + let tabs = write_csv_limited(&s, b'\t', 14, text_of(&s)).unwrap(); + assert_eq!(tabs.len(), 14); +} From d1260e1878c41894ad6171404ee51b8f0e9396ed Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:17:16 +0200 Subject: [PATCH 76/86] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ docs/file-format-parity.md | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..94ae973a0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | CSV and tab-separated export is capped at 512 MiB: a sheet whose used range would expand to more (two cells far apart, such as A1 and XFD1048576, are 16 GiB of commas) is refused with the rows, columns and limit, before anything is allocated or written (#528) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: CSV export size cap (#528) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 851065956..5cf441cfc 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -34,9 +34,9 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | Format | Excel | GridCraft read | GridCraft write | Notes | |---|---|---|---|---| -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes | Text Import Wizard options partly via Text to Columns | -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | @@ -78,6 +78,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | From daeda9db472e89a845ece49b320135c01fa80c1f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:17:16 +0200 Subject: [PATCH 77/86] Credit Silvertree2010 in the About window --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 5d343e9c3..d522b84e0 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -61,3 +61,7 @@ sync_github_name = true real_name = "James Symes" sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false From 317ec4388c5149db79984e7fe791b90c1e5ae4df Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:12:40 -0400 Subject: [PATCH 78/86] XLS: array formulas fill their range on recalculation A legacy array formula in a .xls file ({=FREQUENCY(...)}, {=TREND(...)}) is stored as one ARRAY record plus a FORMULA record per cell of its range, each with the cached result. The importer attached the formula to the anchor cell but kept the cached values in the other cells as plain constants. On recalculation the formula then found its range occupied and returned #SPILL!. The .xlsx reader already drops those cached results; the .xls reader now does the same once the formula is attached, and keeps the values when the formula could not be imported. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/tests/xls_tests.rs | 5 +++-- crates/xlsx/src/xls/mod.rs | 14 ++++++++++++-- docs/file-format-parity.md | 3 ++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795ec..72829ce1c 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -330,11 +330,12 @@ fn reads_values_formulas_formats_and_layout() { // Shared formula: relative offsets resolve per cell. assert_eq!(ftext(&wb, "B5").as_deref(), Some("A5*10")); assert_eq!(ftext(&wb, "B6").as_deref(), Some("A6*10")); - // Array formula: the anchor holds it, the other cell keeps its value. + // Array formula: the anchor holds it; the other cell's cached result is dropped so that + // the formula fills the range on recalculation (as when an .xlsx is read, #156). let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap(); assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap()))); assert_eq!(ftext(&wb, "C6"), None); - assert_eq!(v(&wb, 0, "C6"), Value::Number(4.0)); + assert_eq!(v(&wb, 0, "C6"), Value::Empty); // Number formats, merges, sizes, names, print area; embedded chart records ignored. let sh = &wb.sheets[0]; assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd"); diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66a3..e0c8cf355 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -507,9 +507,9 @@ fn worksheet( } // Shared and array formulas: their definitions follow the first cell that uses them. let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared)); - for p in std::mem::take(&mut st.pending) { + let pending = std::mem::take(&mut st.pending); + for p in &pending { if let Some((range, f)) = arrays.get(&p.anchor) { - // The other cells of an array formula keep only their values. if p.pos == p.anchor { st.attach(p.pos, f, Mode::Cell, Some(*range)); } @@ -519,6 +519,16 @@ fn worksheet( st.unsupported("a shared formula that is missing from the file"); } } + // The other cells of a legacy array formula hold only cached results: dropped, as when an + // .xlsx is read, so that the formula fills its range on recalculation. If the formula itself + // couldn't be imported, they keep their values. + for p in &pending { + let filled = st.sheet.cell(p.anchor).and_then(|c| c.formula.as_ref()).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos)); + if p.pos != p.anchor && filled { + let style = st.sheet.style_id(p.pos); + st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style }); + } + } if st.dropped > 0 { report.warn(format!("{} cells outside the grid were skipped.", st.dropped)); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 851065956..c32fa47ca 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` array formulas fill their range on recalculation) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -78,6 +78,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | `.xls` import: the other cells of an array formula ({=FREQUENCY(...)}, {=TREND(...)}) drop their cached results, as `.xlsx` import does, so the formula fills its range on recalculation instead of showing `#SPILL!` against its own old values; 79 fewer mismatching formulas and 16 fewer round-trip differences over 4,224 real-world files | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | From dab4aa5e79a6eccaf45aa696175ddc332f59e672 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 02:58:56 +0200 Subject: [PATCH 79/86] XLSX: write this-row structured references as [[#This Row],[Col]] (fixes #864) [@Col] is only Excel's display shorthand. Written verbatim into cell formulas and calculatedColumnFormula, it made Excel repair the file and drop the tables. --- crates/formula/src/printer.rs | 12 ++++++++- crates/xlsx/src/fmla.rs | 17 ++++++++++++ crates/xlsx/src/tests/array_export.rs | 37 +++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index a63599bb4..84b16b8ac 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -91,6 +91,16 @@ fn range_r1c1(r: &Reference, at: CellRef, sep: &str) -> String { } pub fn struct_ref(s: &StructRef) -> String { + write_struct_ref(s, true) +} + +/// Structured reference as files spell it: `[@Col]` is only Excel's display shorthand, files +/// hold `[[#This Row],[Col]]` (and `[#This Row]` for `[@]`). +pub fn struct_ref_file(s: &StructRef) -> String { + write_struct_ref(s, false) +} + +fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#"); let item = |i: &StructItem| match i { StructItem::All => "#All", @@ -99,7 +109,7 @@ pub fn struct_ref(s: &StructRef) -> String { StructItem::Totals => "#Totals", StructItem::ThisRow => "#This Row", }; - let this_row = s.specifiers == [StructItem::ThisRow]; + let this_row = at_shorthand && s.specifiers == [StructItem::ThisRow]; let mut out = s.table.clone(); let simple_col = |c: &str| !c.chars().any(|ch| matches!(ch, ' ' | '[' | ']' | '#' | '\'' | ',' | ':')); match (&s.col_start, &s.col_end) { diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index 459364e09..ef263873e 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -82,6 +82,9 @@ fn expr_to_file(e: Expr) -> String { Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]), // Nor an `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)`. Expr::Unary(gridcraft_formula::UnOp::At, x) => Expr::Call("_xlfn.SINGLE".into(), vec![*x]), + // Nor the `[@Col]` shorthand: files spell it `[[#This Row],[Col]]`, or Excel repairs + // the file and drops the table. Carried as a name, which prints verbatim. + Expr::Struct(st) => Expr::Name(gridcraft_formula::printer::struct_ref_file(&st)), // Nor trim-reference operators: written as the equivalent `TRIMRANGE(range,n,n)`, which // every Excel with trim references reads. (Excel's own file spelling of `A1:.E10` is // not publicly documented; the `_xlfn._TRO_*` call forms are accepted on read.) @@ -152,6 +155,20 @@ mod tests { assert_eq!(from_file("Sales[[#This Row],Units]*[[#This Row],Price]"), "Sales[@Units]*[@Price]"); } + #[test] + fn this_row_long_form() { + // `[@Col]` is only Excel's display shorthand; a file holding it is repaired by Excel, + // which drops the table. + assert_eq!(to_file(&Formula::new("Sales[@Units]*Sales[@[Unit Price]]")), "Sales[[#This Row],[Units]]*Sales[[#This Row],[Unit Price]]"); + assert_eq!(text_to_file("=[@Units]&COUNTA(Sales[@])"), "[[#This Row],[Units]]&COUNTA(Sales[#This Row])"); + assert_eq!(text_to_file("=SUM(Sales[Units])+[#Totals]"), "SUM(Sales[Units])+[#Totals]"); + for f in ["Sales[@Units]*[@[Unit Price]]", "Sales[@]", "[@[Unit Price]]+1"] { + let file = to_file(&Formula::new(f)); + assert!(!file.contains("[@"), "{file}"); + assert_eq!(from_file(&file), f); + } + } + #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)"); diff --git a/crates/xlsx/src/tests/array_export.rs b/crates/xlsx/src/tests/array_export.rs index 0491efd34..45764e428 100644 --- a/crates/xlsx/src/tests/array_export.rs +++ b/crates/xlsx/src/tests/array_export.rs @@ -200,6 +200,43 @@ fn calculated_table_column_keeps_formula_and_cell_value() { assert_eq!(sh.value(at("B2")), Value::Number(6.0)); } +#[test] +fn this_row_references_are_written_in_long_form() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.cells.set(at("A2"), Cell::value(Value::Number(3.0))); + sh.cells.set(at("B2"), formula("Data[@Input]*2", 6.0)); + sh.tables.push(Table { + id: 1, + name: "Data".into(), + range: range("A1:B2"), + header_row: true, + totals_row: false, + columns: vec![ + TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None }, + TableColumn { name: "Output".into(), totals: TotalsFn::None, totals_label: None, formula: Some("[@Input]*2".into()) }, + ], + style: "TableStyleLight1".into(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + }); + let bytes = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&bytes).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + assert_eq!(cell(&sheet, "B2").child("f").unwrap().text, "Data[[#This Row],[Input]]*2"); + let table = pkg.read_xml("xl/tables/table1.xml").unwrap().unwrap(); + let column = table.child("tableColumns").unwrap().kids("tableColumn").nth(1).unwrap(); + assert_eq!(column.child("calculatedColumnFormula").unwrap().text, "[[#This Row],[Input]]*2"); + let (back, _) = read_xlsx(&bytes).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(sh.tables[0].columns[1].formula.as_deref(), Some("[@Input]*2")); + assert_eq!(sh.cell(at("B2")).unwrap().formula.as_ref().unwrap().text, "Data[@Input]*2"); +} + /// Compare both cell and formula metadata, collecting all boundaries in the group on failure. fn check_formula_metadata(mut wb: Workbook, sheet_index: usize, cases: &[(&str, bool)]) { for (i, (text, _)) in cases.iter().enumerate() { From bf2923c4bad89ed58d63c874a31a6ee63ebae636 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 03:04:37 +0200 Subject: [PATCH 80/86] check: the package round trip reports relationship kinds the save lost (part of #860) --- apps/gridcraft-cli/src/check.rs | 79 ++++++++++++++++++++++++++++++--- docs/cli.md | 3 +- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index 9fc81e30a..347cf6e80 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -8,8 +8,9 @@ //! them are skipped. //! //! **Round trip.** The recalculated workbook is written with GridCraft's XLSX writer, the package is -//! validated (content types cover every part, every internal relationship target exists) and read -//! back, and the two workbooks are compared feature by feature. +//! validated (content types cover every part, every internal relationship target exists, no kind of +//! relationship the source had is missing) and read back, and the two workbooks are compared feature +//! by feature. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::io::{Cursor, Read as _}; @@ -38,6 +39,9 @@ const MAX_FILES: usize = 100_000; const MAX_XML_PART: u64 = 64 * 1024 * 1024; /// Most zip entries looked at while validating a written package. const MAX_ENTRIES: usize = 200_000; +/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is +/// a cache Excel rebuilds, and GridCraft never writes it. +const REBUILT_KINDS: [&str; 1] = ["calcchain"]; #[derive(Clone, Copy, Debug)] pub struct Options { @@ -286,7 +290,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport { calc.recalc_all(&mut fresh); let trust_cache = !full_calc_on_load(bytes); report.calc = oracle(&cached, &fresh, &calc, trust_cache, opts); - report.round_trip = round_trip(&fresh, opts); + report.round_trip = round_trip(&fresh, bytes, opts); report } @@ -514,8 +518,9 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo // ---------------------------------------------------------------- round trip -/// Writes `wb` with GridCraft's XLSX writer, validates the package, reads it back and compares. -pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip { +/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against +/// `source` (the file `wb` was read from), reads it back and compares. +pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip { let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") { Ok(b) => b, Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] }, @@ -525,6 +530,13 @@ pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip { Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)), Err(e) => package.push(|| e), } + if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) { + for (key, kind) in &before { + if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) { + package.push(|| format!("lost relationship {kind}")); + } + } + } let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) { Ok((b, _)) => b, Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] }, @@ -888,6 +900,22 @@ pub fn validate_package(bytes: &[u8]) -> Result, String> { Ok(issues) } +/// Every relationship kind (last segment of the type, the same in the transitional and strict +/// namespaces) in the package's relationship parts, keyed in lower case. +fn relationship_kinds(bytes: &[u8]) -> Result, String> { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; + let mut kinds = BTreeMap::new(); + for n in zip_names(&mut zip).into_iter().filter(|n| part_key(n).ends_with(".rels")) { + let Ok(els) = read_zip_part(&mut zip, &n).and_then(|xml| xml_elements(&xml)) else { continue }; + for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") { + if let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) { + kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string()); + } + } + } + Ok(kinds) +} + // ---------------------------------------------------------------- output #[derive(Default)] @@ -1277,7 +1305,8 @@ mod tests { fn round_trip_of_a_simple_book_is_clean() { let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]); wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); - let rt = round_trip(&wb, &Options::default()); + let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); + let rt = round_trip(&wb, &source, &Options::default()); assert_eq!(rt.error, None); assert!(rt.is_clean(), "{rt:?}"); assert!(rt.categories.iter().any(|c| c.name == "package")); @@ -1342,6 +1371,44 @@ mod tests { assert_eq!(validate_package(&own).unwrap(), Vec::::new()); } + /// `bytes` with `extra` relationships added to the workbook's rels part and `parts` added. + fn with_workbook_rels(bytes: &[u8], extra: &str, parts: &[(&str, &str)]) -> Vec { + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..src.len() { + let mut f = src.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut body = String::new(); + f.read_to_string(&mut body).unwrap(); + if name == "xl/_rels/workbook.xml.rels" { + body = body.replace("", &format!("{extra}")); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + for (name, body) in parts { + out.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + out.finish().unwrap().into_inner() + } + + #[test] + fn package_reports_relationship_kinds_the_output_lost() { + let plain = gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap(); + // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written + // (Excel rebuilds it), so its absence isn't a loss. + let source = with_workbook_rels( + &plain, + r#""#, + &[("xl/madeUp.xml", ""), ("xl/calcChain.xml", "")], + ); + let r = check_bytes("t.xlsx", &source, &Options::default()); + let package = r.round_trip.categories.iter().find(|c| c.name == "package").unwrap(); + assert_eq!(package.examples, ["lost relationship madeUp"]); + assert!(check_bytes("t.xlsx", &plain, &Options::default()).round_trip.is_clean()); + } + #[test] fn unopenable_files_are_reported() { let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default()); diff --git a/docs/cli.md b/docs/cli.md index b3ee318a8..0a1cad2a0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -85,7 +85,8 @@ up by *signature*, `expected type → actual type` (e.g. `number → #SPILL!`), **Round trip.** The recalculated workbook is written with GridCraft's XLSX writer, the package is validated (every part has a content type, every content-type override and internal relationship target exists, relationship ids are -unique: what Excel would "repair") and read back. Categories compared: `package`, `sheets` (names and order), +unique: what Excel would "repair"), compared with the source (no kind of relationship it had, such as `xmlMaps`, is +missing; a calculation chain doesn't count, Excel rebuilds it) and read back. Categories compared: `package`, `sheets` (names and order), `values`, `formulas`, `numFmts` (per cell), `merges`, `names`, `tables` (name, range, columns), `condFormats` (rules per sheet), `validations`, `charts` (kinds and series counts), `comments`, `hyperlinks`, `pivots`. `#SPILL!`/`#CALC!` compare equal to `#VALUE!` and `#CIRC!` to 0, as that's what the file format stores. From 6dc9138a6426be4005af81f3e4a64018a2095b46 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 03:52:00 +0200 Subject: [PATCH 81/86] check: internal links and empty parts aren't package losses (part of #860) --- apps/gridcraft-cli/src/check.rs | 149 +++++++++++++++++++++++++++----- docs/cli.md | 3 +- 2 files changed, 131 insertions(+), 21 deletions(-) diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index 347cf6e80..13a2e5690 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -901,21 +901,76 @@ pub fn validate_package(bytes: &[u8]) -> Result, String> { } /// Every relationship kind (last segment of the type, the same in the transitional and strict -/// namespaces) in the package's relationship parts, keyed in lower case. +/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry +/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook +/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it. fn relationship_kinds(bytes: &[u8]) -> Result, String> { let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; + let names = zip_names(&mut zip); + let by_key: HashMap = names.iter().map(|n| (part_key(n), n)).collect(); let mut kinds = BTreeMap::new(); - for n in zip_names(&mut zip).into_iter().filter(|n| part_key(n).ends_with(".rels")) { - let Ok(els) = read_zip_part(&mut zip, &n).and_then(|xml| xml_elements(&xml)) else { continue }; + for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) { + let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue }; + let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue }; for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") { - if let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) { - kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string()); + let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue }; + let target = attr(attrs, "Target").unwrap_or(""); + if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') { + continue; } + let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); + if !external + && let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase()) + && !holds_something(&mut zip, part, kind) + { + continue; + } + kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string()); } } Ok(kinds) } +/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the +/// empty string for the package's `_rels/.rels`. +fn rels_source_dir(key: &str) -> Option { + let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?; + Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_string()) +} + +/// Whether a part has content: an element under its root, or for comments (whose authors list +/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts. +fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool { + use quick_xml::events::Event; + let Ok(f) = zip.by_name(name) else { return true }; + let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART))); + let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() { + "comments" => Some(b"comment"), + "vmldrawing" => Some(b"shape"), + _ => None, + }; + let mut buf = Vec::new(); + let mut in_root = false; + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let found = match wanted { + Some(w) => local_name(e.name().as_ref()) == w, + None => in_root, + }; + if found { + return true; + } + in_root = true; + } + Ok(Event::Eof) => return false, + Ok(_) => {} + Err(_) => return true, + } + buf.clear(); + } +} + // ---------------------------------------------------------------- output #[derive(Default)] @@ -1371,42 +1426,96 @@ mod tests { assert_eq!(validate_package(&own).unwrap(), Vec::::new()); } - /// `bytes` with `extra` relationships added to the workbook's rels part and `parts` added. - fn with_workbook_rels(bytes: &[u8], extra: &str, parts: &[(&str, &str)]) -> Vec { + const WB_RELS: &str = "xl/_rels/workbook.xml.rels"; + const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels"; + const MADE_UP: &str = r#""#; + /// Not empty: an empty part isn't a loss. + const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", ""); + + /// `bytes` with relationships added to rels parts (created when missing) and `parts` added. + fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec { let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let mut pending: Vec<(&str, &str)> = rels.to_vec(); for i in 0..src.len() { let mut f = src.by_index(i).unwrap(); let name = f.name().to_string(); let mut body = String::new(); f.read_to_string(&mut body).unwrap(); - if name == "xl/_rels/workbook.xml.rels" { - body = body.replace("", &format!("{extra}")); + if let Some(at) = pending.iter().position(|(r, _)| *r == name) { + body = body.replace("", &format!("{}", pending.remove(at).1)); } out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); out.write_all(body.as_bytes()).unwrap(); } - for (name, body) in parts { - out.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + let created = pending.iter().map(|(r, extra)| { + (*r, format!(r#"{extra}"#)) + }); + for (name, body) in created.collect::>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) { + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); out.write_all(body.as_bytes()).unwrap(); } out.finish().unwrap().into_inner() } + /// The `package` examples of checking `source`. + fn package_of(source: &[u8]) -> Vec { + let r = check_bytes("t.xlsx", source, &Options::default()); + r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone() + } + + fn plain() -> Vec { + gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap() + } + #[test] fn package_reports_relationship_kinds_the_output_lost() { - let plain = gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap(); // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written // (Excel rebuilds it), so its absence isn't a loss. - let source = with_workbook_rels( - &plain, - r#""#, - &[("xl/madeUp.xml", ""), ("xl/calcChain.xml", "")], + let calc_chain = r#""#; + let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean()); + } + + // In the three tests below the made-up kind stays lost, so the comparison is known to run. + + #[test] + fn an_internal_link_written_as_a_relationship_is_not_a_loss() { + // openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`. + let link = r##""##; + let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + } + + #[test] + fn comments_without_a_comment_are_not_a_loss() { + let rels = r#""#; + let comments = r#"A"#; + let vml = r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, MADE_UP), (SHEET_RELS, rels)], + &[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)], ); - let r = check_bytes("t.xlsx", &source, &Options::default()); - let package = r.round_trip.categories.iter().find(|c| c.name == "package").unwrap(); - assert_eq!(package.examples, ["lost relationship madeUp"]); - assert!(check_bytes("t.xlsx", &plain, &Options::default()).round_trip.is_clean()); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + } + + #[test] + fn empty_drawings_and_person_lists_are_not_a_loss() { + let drawing = r#""#; + let person = + r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)], + &[ + MADE_UP_PART, + ("xl/drawings/drawing1.xml", r#""#), + ("xl/persons/person.xml", r#""#), + ], + ); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); } #[test] diff --git a/docs/cli.md b/docs/cli.md index 0a1cad2a0..d80412909 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -86,7 +86,8 @@ up by *signature*, `expected type → actual type` (e.g. `number → #SPILL!`), **Round trip.** The recalculated workbook is written with GridCraft's XLSX writer, the package is validated (every part has a content type, every content-type override and internal relationship target exists, relationship ids are unique: what Excel would "repair"), compared with the source (no kind of relationship it had, such as `xmlMaps`, is -missing; a calculation chain doesn't count, Excel rebuilds it) and read back. Categories compared: `package`, `sheets` (names and order), +missing; a calculation chain doesn't count, Excel rebuilds it, nor do a link to a place in the workbook and a +relationship to an empty part, such as a comments part without comments) and read back. Categories compared: `package`, `sheets` (names and order), `values`, `formulas`, `numFmts` (per cell), `merges`, `names`, `tables` (name, range, columns), `condFormats` (rules per sheet), `validations`, `charts` (kinds and series counts), `comments`, `hyperlinks`, `pivots`. `#SPILL!`/`#CALC!` compare equal to `#VALUE!` and `#CIRC!` to 0, as that's what the file format stores. From 9bb85ab9af88d86c6c33feb41132ab84d11591c9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 04:34:03 +0200 Subject: [PATCH 82/86] Refuse to save to an unknown file extension instead of writing XLSX under it (fixes #878) --- crates/engine/src/cmd/file.rs | 3 +-- crates/engine/src/io.rs | 8 +++++++- crates/engine/src/tests.rs | 25 +++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3c4a1012b..84ca32b6c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -107,9 +107,8 @@ fn save(s: &mut Session, p: &Json) -> Result { let d = s.doc_mut()?; let bytes = crate::io::save_bytes(&d.wb, &path)?; crate::io::write_file(&path, &bytes)?; - let kind = crate::io::FileKind::from_path(&path).unwrap_or(crate::io::FileKind::Xlsx); // Only a full-fidelity format clears the dirty flag and becomes the document's file. - if matches!(kind, crate::io::FileKind::Xlsx | crate::io::FileKind::Json) { + if matches!(crate::io::FileKind::from_path(&path), Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) { d.saved = d.wb.clone(); d.path = Some(path.clone()); } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index bc7011dec..34adece2a 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -154,7 +154,13 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) { /// Encodes a workbook in the format chosen by `path`'s extension. pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { let sheet = wb.active_sheet; - match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) { + // An unknown or missing extension is refused rather than written as an XLSX under that name: + // a typo like `.cvs` would otherwise produce a file no other app recognises. + let Some(kind) = FileKind::from_path(path) else { + let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path); + return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html."))); + }; + match kind { // The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry // the VBA project, so any other name saves without it, as Excel does. FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc113e..99a8d8cc8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1519,6 +1519,31 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() { std::fs::remove_dir_all(dir).unwrap(); } +/// A typo like `report.cvs` must not quietly write an XLSX under that name and adopt it as the +/// document's file. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn saving_to_an_unknown_extension_is_refused() { + #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("gridcraft-unknown-ext-{}-{nonce}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + for name in ["report.cvs", "report"] { + let path = dir.join(name); + let e = s.execute("file.save", json!({"path": path})).unwrap_err().to_string(); + assert!(e.contains(".xlsx"), "{name}: {e}"); + assert!(!path.exists(), "{name} was written"); + } + let d = s.doc().unwrap(); + assert!(d.path.is_none()); + assert!(d.is_dirty()); + assert!(s.execute("file.saveBytes", json!({"format": "foo"})).is_err()); + s.execute("file.save", json!({"path": dir.join("report.csv")})).unwrap(); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn validation_formulas_are_relative_and_see_the_new_value() { let mut s = s(); From c186781d8bc5f178f2f6c8700916756e970b2662 Mon Sep 17 00:00:00 2001 From: CouchRiv <270190108+CouchRiv@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:36:20 -0400 Subject: [PATCH 83/86] check: a boolean cached as 1/0 without t="b" matches and is counted apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Business Central's "Open in Excel" exports write booleans as TRUE()1 with no t="b", so the cached value reads back as the number 1 and the calc oracle reported every such cell as a `number → bool` mismatch. A cached 1 or 0 now matches GridCraft's TRUE or FALSE, and the cells are counted apart (`boolAsNumber` in --json, a note in the text report) so a real number/logical disagreement stays visible. Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft-cli/src/check.rs | 69 +++++++++++++++++++++++++++++++-- docs/cli.md | 8 +++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index 9fc81e30a..c6f11b1e4 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -97,6 +97,9 @@ pub struct CalcReport { /// aren't results, so every formula counts as no oracle. pub stale_cache: bool, pub volatile: usize, + /// Matched cells whose producer saved GridCraft's TRUE or FALSE as the number 1 or 0 (no + /// `t="b"`, as Business Central's Excel exports do); counted in `matched` too. + pub bool_as_number: usize, /// The first `max_report` mismatches, primary ones first. pub mismatches: Vec, pub functions: BTreeMap, @@ -347,6 +350,13 @@ fn cell_name(wb: &Workbook, (sheet, c): Key) -> String { format!("{}!{}", quote_sheet(name), c.a1()) } +/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a +/// logical result `t="b"`; a writer that leaves the type out (`TRUE()1`, as +/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number. +pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool { + matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 }) +} + /// Whether GridCraft's `actual` agrees with the producer's `expected`. pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool { match (expected, actual) { @@ -458,7 +468,11 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo } None => vec!["(unparsed)".to_string()], }; - let ok = values_match(&cell.value, &actual, opts.tolerance); + let bool_as_number = bool_cached_as_number(&cell.value, &actual); + let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance); + if bool_as_number { + r.bool_as_number += 1; + } for n in &funcs { let st = r.functions.entry(n.clone()).or_default(); st.checked += 1; @@ -901,6 +915,7 @@ struct Totals { mismatched: usize, no_oracle: usize, volatile: usize, + bool_as_number: usize, round_trip_failed: usize, round_trip_differing: usize, categories: BTreeMap<&'static str, usize>, @@ -922,6 +937,7 @@ fn totals(reports: &[FileReport]) -> Totals { t.mismatched += r.calc.mismatched; t.no_oracle += r.calc.no_oracle; t.volatile += r.calc.volatile; + t.bool_as_number += r.calc.bool_as_number; if r.round_trip.error.is_some() { t.round_trip_failed += 1; } @@ -982,7 +998,8 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { "warnings": r.warnings, "calc": { "formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched, - "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "mismatches": mismatches, "functions": functions_json(&c.functions), + "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number, + "mismatches": mismatches, "functions": functions_json(&c.functions), "signatures": c.signatures, }, "roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories}, @@ -996,7 +1013,7 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { "files": files, "totals": { "files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked, - "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, + "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number, "roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories, }, "functions": functions_json(&t.functions), @@ -1035,6 +1052,9 @@ fn print_human(reports: &[FileReport], opts: &Options) { if c.stale_cache { outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)"); } + if c.bool_as_number > 0 { + outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number); + } for m in &c.mismatches { let tag = if m.primary { "mismatch" } else { " (downstream)" }; outln!( @@ -1131,6 +1151,9 @@ fn print_human(reports: &[FileReport], opts: &Options) { t.no_oracle, t.volatile ); + if t.bool_as_number > 0 { + outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number); + } let cats: Vec = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect(); outln!( "round trip: {} of {} files differ{}{}", @@ -1235,6 +1258,31 @@ mod tests { assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1); } + /// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`: + /// the cache holds the right value under a number type, so it matches and is counted apart. + #[test] + fn booleans_cached_as_numbers_match_and_are_counted_apart() { + let wb = book(&[ + ("A1", "=TRUE()", n(1.0)), + ("B1", "=FALSE()", n(0.0)), + ("C1", "=TRUE()", Value::Bool(true)), + ("D1", "=1=1", n(0.0)), + ("E1", "=TRUE()+0", n(1.0)), + ]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1)); + // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number. + assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1"); + assert_eq!(r.calc.signatures.get("number → bool"), Some(&1)); + assert_eq!(r.calc.bool_as_number, 2); + let v = report_json(std::slice::from_ref(&r), &Options::default()); + assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2))); + assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false))); + assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0))); + } + #[test] fn missing_cached_values_and_volatile_formulas_are_skipped() { let wb = @@ -1362,7 +1410,19 @@ mod tests { assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"])); assert_eq!( keys(&f["calc"]), - set(&["formulaCells", "checked", "matched", "mismatched", "noOracle", "staleCache", "volatile", "mismatches", "functions", "signatures"]) + set(&[ + "formulaCells", + "checked", + "matched", + "mismatched", + "noOracle", + "staleCache", + "volatile", + "boolAsNumber", + "mismatches", + "functions", + "signatures" + ]) ); assert_eq!(f["calc"]["signatures"], json!({"number → number": 1})); assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"])); @@ -1401,6 +1461,7 @@ mod tests { "mismatched", "noOracle", "volatile", + "boolAsNumber", "roundTripFailed", "roundTripDiffering", "categoriesDiffering" diff --git a/docs/cli.md b/docs/cli.md index b3ee318a8..90a1d9517 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -78,6 +78,11 @@ exactly. Skipped, and counted separately: - *volatile*: `RAND`, `RANDARRAY`, `RANDBETWEEN`, `NOW`, `TODAY`, `INFO`, `CELL("filename")`, names that use them, and every formula that reads such a cell (through the dependency graph). +A logical result the producer cached as the number 1 or 0, without `t="b"` (Business Central's "Open in Excel" +exports write `TRUE()1`), holds the same value: it matches GridCraft's TRUE or FALSE and is also +counted as *booleans saved as 1/0* (`boolAsNumber`). Excel types logical results, so on files it saved this count +should be 0; a non-zero count there would point at a formula where GridCraft returns a logical and Excel a number. + Each mismatch lists the cell, formula, expected (cached) and actual (GridCraft) values and the functions the formula calls. Mismatches roll up by function: *checked* (formula cells calling it), *mismatched*, and *primary* (mismatched while none of the cell's precedents mismatch — the likely culprit, as opposed to a downstream effect). They also roll @@ -110,6 +115,7 @@ added, a changed meaning bumps the version): "calc": { "formulaCells": 3, "checked": 2, "matched": 1, "mismatched": 1, "noOracle": 1, "volatile": 0, "staleCache": false, // fullCalcOnLoad: nothing compared + "boolAsNumber": 0, // matched TRUE/FALSE results the producer saved as 1/0 (no t="b") "mismatches": [{"cell": "Sheet1!B1", "formula": "=SUM(A1:A2)", "expected": 7, "actual": 6, "kind": "numeric", // numeric | text | bool | error | blank (GridCraft empty) | type "functions": ["SUM"], "primary": true}], @@ -123,7 +129,7 @@ added, a changed meaning bumps the version): } }], "totals": {"files": 1, "opened": 1, "failedToOpen": 0, "formulaCells": 3, "checked": 2, "matched": 1, - "mismatched": 1, "noOracle": 1, "volatile": 0, "roundTripFailed": 0, "roundTripDiffering": 1, + "mismatched": 1, "noOracle": 1, "volatile": 0, "boolAsNumber": 0, "roundTripFailed": 0, "roundTripDiffering": 1, "categoriesDiffering": {"values": 1}}, "functions": [{"name": "SUM", "checked": 1, "mismatched": 1, "primary": 1}], // all files, most mismatched first "signatures": {"number → number": 1} From 522ede0fca8f40144a6b92aca3518ec1568931f9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 05:14:30 +0200 Subject: [PATCH 84/86] Save an XLSX opened under an unknown extension back to its own file as XLSX (#878) --- crates/engine/src/cmd/file.rs | 16 +++++++++++++--- crates/engine/src/io.rs | 7 ++++++- crates/engine/src/lib.rs | 4 ++++ crates/engine/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 84ca32b6c..2a8b43537 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -75,7 +75,11 @@ fn open(s: &mut Session, p: &Json) -> Result { { s.close_document(0); } - let i = s.add_document(DocState::new(wb, path, title)); + let mut doc = DocState::new(wb, path, title); + if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx { + doc.content_kind = Some(crate::io::FileKind::Xlsx); + } + let i = s.add_document(doc); // The decrypted-workbook notice is for the info bar, not a message box. let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect(); if !shown.is_empty() { @@ -105,10 +109,16 @@ fn save(s: &mut Session, p: &Json) -> Result { return super::print::export_pdf(s, &json!({"path": path})); } let d = s.doc_mut()?; - let bytes = crate::io::save_bytes(&d.wb, &path)?; + // The document's own file keeps the format its content had when opened; any other path + // goes by its extension. + let kind = crate::io::FileKind::from_path(&path).or(d.content_kind.filter(|_| d.path.as_deref() == Some(path.as_str()))); + let bytes = match kind { + Some(kind) => crate::io::save_bytes_as(&d.wb, &path, kind)?, + None => crate::io::save_bytes(&d.wb, &path)?, + }; crate::io::write_file(&path, &bytes)?; // Only a full-fidelity format clears the dirty flag and becomes the document's file. - if matches!(crate::io::FileKind::from_path(&path), Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) { + if matches!(kind, Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) { d.saved = d.wb.clone(); d.path = Some(path.clone()); } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 34adece2a..8ed9f372f 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -153,13 +153,18 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) { /// Encodes a workbook in the format chosen by `path`'s extension. pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { - let sheet = wb.active_sheet; // An unknown or missing extension is refused rather than written as an XLSX under that name: // a typo like `.cvs` would otherwise produce a file no other app recognises. let Some(kind) = FileKind::from_path(path) else { let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path); return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html."))); }; + save_bytes_as(wb, path, kind) +} + +/// Encodes a workbook as `kind`, whatever `path`'s extension says. +pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result> { + let sheet = wb.active_sheet; match kind { // The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry // the VBA project, so any other name saves without it, as Excel does. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3aceab093..5a2e3df98 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -108,6 +108,9 @@ pub struct DocState { pub sheet_selections: Vec, /// Allow Edit Ranges unlocked with their password in this session: (sheet name, title). pub unlocked_ranges: Vec<(String, String)>, + /// The format `path`'s content was recognised as when its extension doesn't name one (an + /// XLSX called `budget.dat`): Save writes it back in that format. + pub content_kind: Option, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -132,6 +135,7 @@ impl DocState { title, sheet_selections: vec![], unlocked_ranges: vec![], + content_kind: None, } } pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 99a8d8cc8..3b963e89e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1544,6 +1544,40 @@ fn saving_to_an_unknown_extension_is_refused() { std::fs::remove_dir_all(dir).unwrap(); } +/// An XLSX opened by its content under an unknown extension (`budget.dat`) saves back to its own +/// file as XLSX; a Save As to another unknown extension is still refused. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn an_xlsx_opened_under_an_unknown_extension_saves_back_as_xlsx() { + #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("gridcraft-dat-{}-{nonce}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("budget.dat"); + let mut src = s(); + src.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + std::fs::write(&path, crate::io::save_bytes(&src.doc().unwrap().wb, "book.xlsx").unwrap()).unwrap(); + let mut s = s(); + s.execute("file.open", json!({"path": path})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + s.execute("file.save", json!({})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xlsx); + let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap(); + assert_eq!(wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::Number(2.0)); + assert!(s.execute("file.saveAs", json!({"path": dir.join("copy.foo")})).is_err()); + assert!(!dir.join("copy.foo").exists()); + // A text file under an unknown extension opens as CSV: Save doesn't turn it into an XLSX. + let notes = dir.join("notes.dat"); + std::fs::write(¬es, "a,b\r\n").unwrap(); + s.execute("file.open", json!({"path": notes})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "c"})).unwrap(); + assert!(s.execute("file.save", json!({})).is_err()); + assert_eq!(std::fs::read_to_string(¬es).unwrap(), "a,b\r\n"); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn validation_formulas_are_relative_and_see_the_new_value() { let mut s = s(); From 45221f524370c216babb328e690cf7814ac728c6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 23:51:11 -0400 Subject: [PATCH 85/86] Fix-up: rustfmt extra.rs after merging Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/extra.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 473896902..62bc51584 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -704,11 +704,7 @@ fn stash_get(wb: &Workbook, key: &str) -> Json { /// Like `stash_get`, for the name-keyed lists (views, scripts): a stored value that is not a JSON object is treated as empty. fn stash_map(wb: &Workbook, key: &str) -> Json { let v = stash_get(wb, key); - if v.is_object() { - v - } else { - json!({}) - } + if v.is_object() { v } else { json!({}) } } fn stash_set(wb: &mut Workbook, key: &str, v: &Json) { From 6405b00cf70545eb133f9bbf06a2a99c6cef3923 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 00:34:01 -0400 Subject: [PATCH 86/86] Fix-up: repair test files after merging the conflicting PRs - restore closing braces the line-level merges of side-by-side tests dropped - new ChartData literals carry the palette field - the filter-menu shortcut passes the row with the column - #688's tab tests account for #851's All Sheets button - Serbian margins for the ruler-margin labels Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 7 +++++++ crates/chart/src/tests.rs | 21 +++++++++++++++------ crates/engine/src/tests.rs | 6 ++++++ crates/formula/src/tests.rs | 3 +++ crates/pdf/src/tests.rs | 2 ++ crates/ui-egui/src/i18n/sr.rs | 6 ++++++ crates/ui-egui/src/keymap.rs | 2 +- crates/ui-egui/tests/clipboard.rs | 3 +++ crates/ui-egui/tests/keyboard_shortcuts.rs | 4 ++-- crates/ui-egui/tests/sheet_tabs.rs | 6 ++++-- crates/xlsx/src/sheet_read.rs | 7 ++----- crates/xlsx/src/tests/roundtrip.rs | 14 +++++++------- crates/xlsx/src/tests/xls_tests.rs | 6 ++++++ 13 files changed, 64 insertions(+), 23 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 8b29f631b..a0c9fe26d 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -571,6 +571,9 @@ fn functions_given_by_name() { // A LET variable of that name is still the variable. t.set("J1", "=LET(ABS,LAMBDA(x,x*10),SUM(MAP(B1:B3,ABS)))"); assert_eq!(t.num("J1"), 20.0); +} + +#[test] fn lambda_argument_count() { let mut t = T::new(); t.wb.names.push(gridcraft_model::DefinedName { @@ -1134,6 +1137,8 @@ fn cell_width_is_the_column_width_dialog_value_rounded() { t.set(at, &format!("=CELL(\"width\",{col}1)")); } assert_eq!([t.num("H1"), t.num("H2"), t.num("H3"), t.num("H4"), t.num("H5")], [8.0, 11.0, 12.0, 11.0, 3.0]); +} + /// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 46-49): a criteria cell holding /// a formula under a label that names no field is evaluated for every record. #[test] @@ -1184,6 +1189,8 @@ fn database_functions_with_computed_criteria() { assert_eq!(t.num("B51"), 1.0); assert_eq!(t.num("B52"), 7.0); assert_eq!(t.num("B53"), 4.0); +} + #[test] fn offset_extreme_arguments_return_ref() { let wb = Workbook::new(); diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 1093e9209..01f1aac49 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -456,11 +456,8 @@ fn pie_starts_at_twelve_clockwise() { #[test] fn negative_values_put_axis_below_zero() { let c = chart(ChartKind::ColumnClustered); - let d = ChartData { - palette: vec![], - categories: vec![], - series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)], - }; + let d = + ChartData { palette: vec![], categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] }; let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); let t = texts(&prims); assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}"); @@ -901,7 +898,7 @@ fn treemap_and_histogram_shapes() { #[test] fn histogram_boundary_values_match_interval_labels() { let heights = |vals: &[f64]| -> Vec { - let d = ChartData { categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] }; let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); prims.iter().filter_map(|p| if let Prim::Rect { h, fill: Some([10, 20, 30, 255]), .. } = p { Some(*h) } else { None }).collect() }; @@ -964,8 +961,13 @@ fn box_and_stock_charts_honour_the_gridlines_flag() { let off = render(&c, &d, 500.0, 350.0, &ApproxMeasure); assert!(grids(&on) > 0, "{kind:?}: gridlines on must draw lines"); assert_eq!(grids(&off), 0, "{kind:?}: gridlines off must hide them"); + } +} + +#[test] fn funnel_value_labels_follow_the_data_labels_flag() { let data = ChartData { + palette: vec![], categories: vec!["a".into(), "b".into(), "c".into()], series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)], }; @@ -982,6 +984,10 @@ fn funnel_value_labels_follow_the_data_labels_flag() { for cat in ["a", "b", "c"] { assert!(texts(&off).contains(&cat)); assert!(texts(&on).contains(&cat)); + } +} + +#[test] fn pie_points_follow_workbook_theme() { let mut wb = Workbook::new(); wb.theme.colors[4] = 0x4F6D8A; @@ -1024,6 +1030,9 @@ fn pie_points_follow_workbook_theme() { assert_eq!(legend.len(), 40); assert_eq!(legend.get(37), Some(&series_color(&wb.theme, 37))); assert_ne!(series_color(&wb.theme, 37), series_color(&gridcraft_model::Theme::default(), 37)); +} + +#[test] fn area_charts_draw_source_value_labels_only_when_enabled() { for kind in [ChartKind::Area, ChartKind::AreaStacked] { let d = sample(kind); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index de592f5c0..0225b838c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1086,6 +1086,9 @@ fn structured_refs_to_columns_with_edge_spaces() { s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])"); assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3")], [n(5.0), n(5.0), n(5.0)]); +} + +#[test] fn table_rename_keeps_a_defined_name_with_the_same_name() { let mut s = s(); s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap(); @@ -3054,6 +3057,9 @@ fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() { assert_eq!(v(&s, "C1"), Value::Number(9.0)); s.execute("home.autofitRowHeight", json!({"rows": "2:3"})).unwrap(); assert_eq!(v(&s, "C1"), Value::Number(15.0)); +} + +#[test] fn paste_special_validation_copies_the_rule() { let mut s = s(); let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index c8359b19d..103a26bd1 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -10,6 +10,9 @@ fn quoted_sheet_qualified_name_keeps_quotes() { assert_eq!(rt("'My Sheet'!Total"), "'My Sheet'!Total"); assert_eq!(rt("'Data'!Total"), "Data!Total"); assert_eq!(rt("'It''s'!Total+1"), "'It''s'!Total+1"); +} + +#[test] fn intersection_with_deleted_operand_reparses() { // Deleting the columns of an intersection operand prints `A1:B2 #REF!`; that text must parse back. assert_eq!(rt("A1:B2 #REF!"), "A1:B2 #REF!"); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 6483cc737..7d6a61a45 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -236,6 +236,8 @@ fn every_draw_method_survives_hostile_numbers() { assert!(!c.contains("NaN") && !c.contains("inf")); let pdf = d.finish(); check_xref(&pdf); +} + /// Decodes the `` hex string following `key` in the file bytes. fn utf16_entry(pdf: &[u8], key: &str) -> String { let at = find(pdf, key.as_bytes()).unwrap() + key.len(); diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index bc6a8ee02..152bddee8 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -391,6 +391,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Izračunavanje radne sveske", "Enable iterative calculation" => "Omogući iterativno izračunavanje", "Calculation settings are saved with the workbook." => "Podešavanja izračunavanja se čuvaju sa radnom sveskom.", + "Left Margin" => "Leva margina", + "Right Margin" => "Desna margina", + "Top Margin" => "Gornja margina", + "Bottom Margin" => "Donja margina", + "Header Margin" => "Margina zaglavlja", + "Footer Margin" => "Margina podnožja", _ => return None, }) } diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index cb4f7c6fe..032db1a2d 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -223,7 +223,7 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic app.grid.list_picker = Some(active); } else if sh.has_filter_button(active) { let at = geo.mirror_pos(crate::grid::filter_button_rect(geo, sh, active).center()); - app.grid.filter_menu = Some((active.col, at)); + app.grid.filter_menu = Some((active.col, active.row, at)); } } Shortcut::Zoom(step) => { diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs index bf835d893..08f83b53c 100644 --- a/crates/ui-egui/tests/clipboard.rs +++ b/crates/ui-egui/tests/clipboard.rs @@ -139,6 +139,9 @@ fn enter_pastes_the_copied_cells_and_ends_copy_mode() { h.key_press(egui::Key::Enter); h.run_steps(2); assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()), Some("D6".to_string())); +} + +#[test] fn clicking_the_ribbon_paste_button_or_its_menu_does_not_freeze() { // #273: a click on Paste deadlocked the UI thread (a click-state query inside the egui context // lock), so the frame never returned. Run the clicks on a worker and fail on a timeout instead of diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index ae0ce5c66..c0a1307b3 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -160,7 +160,7 @@ fn alt_down_opens_autofilter_headers_without_changing_the_workbook() { let mut h = harness(session); let before = h.state().session.active().unwrap().wb.clone(); key(&mut h, Key::ArrowDown, Modifiers::ALT); - let (column, at) = h.state().grid.filter_menu.expect("Alt+Down must open the filter header menu"); + let (column, _row, at) = h.state().grid.filter_menu.expect("Alt+Down must open the filter header menu"); let d = h.state().session.active().unwrap(); let active = CellRef::parse(cell).unwrap(); assert_eq!(column, active.col); @@ -210,7 +210,7 @@ fn alt_down_only_opens_enabled_table_filter_buttons() { session.execute("selection.set", json!({"cell": "B1"})).unwrap(); let mut h = harness(session); key(&mut h, Key::ArrowDown, Modifiers::ALT); - assert_eq!(h.state().grid.filter_menu.map(|(column, _)| column), enabled.then_some(1)); + assert_eq!(h.state().grid.filter_menu.map(|(column, _, _)| column), enabled.then_some(1)); assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1"); } } diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs index 966ea60f0..e799f7a89 100644 --- a/crates/ui-egui/tests/sheet_tabs.rs +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -71,7 +71,8 @@ fn opening_and_activating_late_sheets_reveals_their_tabs() { fn navigation_arrows_reveal_the_selected_tab() { let mut h = harness(700.0, 0); let (_, clip) = tab(&h, "Tab00").unwrap(); - let next = pos2(clip.left() - 20.0, clip.center().y); + // The Next arrow sits left of the All Sheets button (24 px) and the 8 px gap before the tabs. + let next = pos2(clip.left() - 44.0, clip.center().y); for _ in 0..29 { click(&mut h, next); } @@ -129,7 +130,8 @@ fn hidden_sheets_long_names_and_window_resize_keep_active_tab_visible() { h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap(); h.run_steps(20); assert_visible(&h, name); - h.set_size(vec2(500.0, 120.0)); + // Narrow, but still wide enough for the long tab next to the arrows and the All Sheets button. + h.set_size(vec2(560.0, 120.0)); h.run_steps(20); assert_visible(&h, name); h.set_size(vec2(1000.0, 120.0)); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 61857a7ef..1aa9b407b 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -598,11 +598,8 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { let f0 = formulas.first().cloned().unwrap_or_default(); let text = r.attr("text").unwrap_or("").to_string(); // The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there). - let ext_id = r - .child("extLst") - .and_then(|l| l.kids("ext").find_map(|x| x.child("id"))) - .map(|i| i.text.trim().to_string()) - .filter(|i| !i.is_empty()); + let ext_id = + r.child("extLst").and_then(|l| l.kids("ext").find_map(|x| x.child("id"))).map(|i| i.text.trim().to_string()).filter(|i| !i.is_empty()); let rule = match r.attr("type").unwrap_or("") { "cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style }, "expression" => CfRule::Expression { formula: f0, style }, diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index d214fd214..e9d72d5ad 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1096,18 +1096,15 @@ fn default_font_survives_a_round_trip() { ); let saved = write_xlsx(&read_xlsx(&file).unwrap().0).unwrap(); assert_eq!(font0(&saved), (Some("12".into()), Some("Calibri".into()), Some("2".into()), Some("minor".into()))); +} + #[test] fn solid_data_bar_survives_with_sparklines_and_gradient_neighbours() { let mut wb = Workbook::new(); let s = wb.sheet_mut(0).unwrap(); s.set_value(at("A1"), Value::Number(1.0)); - let bar = |gradient: bool, max: CfValueKind| CfRule::DataBar { - min: CfValueKind::Min, - max, - color: Color::Rgb(0x638EC6), - gradient, - show_value: true, - }; + let bar = + |gradient: bool, max: CfValueKind| CfRule::DataBar { min: CfValueKind::Min, max, color: Color::Rgb(0x638EC6), gradient, show_value: true }; let rules = [(true, "A1", CfValueKind::Max), (false, "B1", CfValueKind::Formula("$C$1".into())), (false, "D1:D3", CfValueKind::Number(9.0))]; for (i, (gradient, range, max)) in rules.into_iter().enumerate() { s.cond_formats.push(CondFormat { ranges: vec![rr(range)], rule: bar(gradient, max), priority: i as u32 + 1, stop_if_true: false }); @@ -1122,6 +1119,9 @@ fn solid_data_bar_survives_with_sparklines_and_gradient_neighbours() { let b = &back.sheets[0]; assert_eq!(b.cond_formats, wb.sheets[0].cond_formats); assert_eq!(b.sparklines.len(), 1); +} + +#[test] fn doc_props_keep_boundary_whitespace() { let mut wb = Workbook::new(); wb.props = DocProps { diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index 38820a114..91e63c18f 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -535,6 +535,9 @@ fn formulas_using_a_skipped_name_keep_their_saved_results() { assert_eq!(v(&wb, 0, "B6"), Value::Number(60.0)); assert_eq!(ftext(&wb, "B7").as_deref(), Some("Local*2")); assert!(report.warnings.iter().any(|w| w.contains("defined names") && w.contains("saved results")), "{:?}", report.warnings); +} + +#[test] fn reads_iterative_calculation_settings() { // CalcMode, CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only // the first sheet carries them, so the sheet after it must not reset them to the defaults. @@ -554,6 +557,9 @@ fn reads_iterative_calculation_settings() { // Without the records: Excel's defaults, iteration off. let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap(); assert_eq!(wb.calc, gridcraft_model::CalcSettings::default()); +} + +#[test] fn a_format_shorter_than_its_count_keeps_its_characters() { // A FORMAT whose cch is one more than the characters in the record (seen in real files): // Excel opens them, the record's characters are the format code.