From f69861f7e3a983aa76e792e63a836f5b2d421c66 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen <7456949+mikl0s@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:39:57 +0200 Subject: [PATCH 001/102] Cell: cell.set without an input is an error cell.set treated a missing input as an empty string, so a call that forgot the input (or sent it under the wrong key) cleared the cell and reported success. It now returns an error when neither `input` nor `value` is given. An explicit empty string still clears the cell. --- crates/engine/src/cmd/edit.rs | 7 +++++-- crates/mcp/src/tests.rs | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb54fe03c..a812c6541 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -196,8 +196,11 @@ 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)).unwrap_or_default(); + 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`)"))?; let array = bool_param(p, "array").unwrap_or(false); let protected = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked)); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index cf590bedb..1d4abb40c 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -339,6 +339,18 @@ fn serve_loop_over_buffers() { assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("42")); } +#[test] +fn cell_set_without_input_is_error() { + let mut s = server(); + ok(&mut s, "set_cell", json!({"cell": "B2", "input": "keep"})); + fails(&mut s, "execute_command", json!({"command": "cell.set", "params": {"cell": "B2"}})); + fails(&mut s, "execute_command", json!({"command": "cell.set", "params": {}})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], "keep"); + // An explicit empty string still clears. + ok(&mut s, "execute_command", json!({"command": "cell.set", "params": {"cell": "B2", "input": ""}})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], ""); +} + #[test] fn remote_backend_over_tcp() { // A fake app: answers engine.execute by running a headless session; closes after the first From 271119d9e32d8f74ac088a7a204638fa04a1e8b4 Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sun, 11 Oct 2026 00:27:43 +0300 Subject: [PATCH 002/102] Build: satisfy Rust 1.95 clippy --- crates/ui-egui/src/grid.rs | 19 ++++++------------ crates/xlsx/src/sheet_read.rs | 36 +++++++++++++++++------------------ 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..1b4a0a094 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1657,14 +1657,12 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: app.copy_to_clipboard(ctx, "edit.cut"); } egui::Event::Paste(text) => app.run_or_alert("edit.paste", json!({"text": text})), - egui::Event::Text(text) => { - if text.chars().all(|c| !c.is_control()) && !text.is_empty() { - app.begin_edit(Some(text), false); - if let Some(ed) = app.editor.as_mut() { - ed.caret = ed.text.chars().count(); - } - return; + egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => { + app.begin_edit(Some(text), false); + if let Some(ed) = app.editor.as_mut() { + ed.caret = ed.text.chars().count(); } + return; } egui::Event::Key { key, pressed: true, modifiers: m, .. } => { use egui::Key; @@ -1777,12 +1775,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) 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 = sh.tables.first().filter(|t| col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range); if sh.autofilter.is_none() && let Some(r) = table_range { diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 065558e6b..8cdd2cab8 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -142,26 +142,24 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result sheet.print.row_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&r| r > 0 && r < MAX_ROWS).collect(), "colBreaks" => sheet.print.col_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&c| c > 0 && c < MAX_COLS).collect(), - "sheetProtection" => { - if e.flag("sheet", false) { - if e.attr("password").is_some() || e.attr("hashValue").is_some() { - cx.warn(format!("sheet \"{name}\": the protection password was not imported")); - } - sheet.protection = Some(SheetProtection { - password_hash: None, - select_locked: !e.flag("selectLockedCells", false), - select_unlocked: !e.flag("selectUnlockedCells", false), - format_cells: !e.flag("formatCells", true), - format_columns: !e.flag("formatColumns", true), - format_rows: !e.flag("formatRows", true), - insert_columns: !e.flag("insertColumns", true), - insert_rows: !e.flag("insertRows", true), - delete_columns: !e.flag("deleteColumns", true), - delete_rows: !e.flag("deleteRows", true), - sort: !e.flag("sort", true), - autofilter: !e.flag("autoFilter", true), - }); + "sheetProtection" if e.flag("sheet", false) => { + if e.attr("password").is_some() || e.attr("hashValue").is_some() { + cx.warn(format!("sheet \"{name}\": the protection password was not imported")); } + sheet.protection = Some(SheetProtection { + password_hash: None, + select_locked: !e.flag("selectLockedCells", false), + select_unlocked: !e.flag("selectUnlockedCells", false), + format_cells: !e.flag("formatCells", true), + format_columns: !e.flag("formatColumns", true), + format_rows: !e.flag("formatRows", true), + insert_columns: !e.flag("insertColumns", true), + insert_rows: !e.flag("insertRows", true), + delete_columns: !e.flag("deleteColumns", true), + delete_rows: !e.flag("deleteRows", true), + sort: !e.flag("sort", true), + autofilter: !e.flag("autoFilter", true), + }); } "drawing" => { if let Some(rel) = e.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)) { From 27be1753c2d8c1284498a46b4547ccc4d7d7a83c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:43:32 +0200 Subject: [PATCH 003/102] Read the browser clock for NOW(), TODAY() and year-less dates on wasm (#202) --- Cargo.lock | 1 + crates/calc/src/recalc.rs | 14 ++++---------- crates/core/Cargo.toml | 3 +++ crates/core/src/date.rs | 28 ++++++++++++++++++++++++++++ crates/core/src/parse.rs | 14 +++++--------- 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5dce47768..e1d8d3e5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1469,6 +1469,7 @@ dependencies = [ name = "gridcraft-core" version = "0.4.0" dependencies = [ + "js-sys", "serde", "serde_json", "thiserror 2.0.21", diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 4cf0c6a21..9df724573 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -919,17 +919,11 @@ impl Calc { } } -/// Local date-time as a serial (1900 system). Wasm without a clock returns a fixed date. -#[allow(clippy::disallowed_methods)] // the clock is read only off wasm +/// Current date-time as a serial (1900 system), from the system clock (browser clock on wasm). +/// UTC; the local offset isn't available without platform calls. Falls back to a fixed date only +/// when no clock is available. pub fn now_serial() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { - // UTC; local offset isn't available without platform calls. - return 25569.0 + d.as_secs_f64() / 86400.0; - } - } - 46302.5 + gridcraft_core::date::unix_now_ms().map_or(46302.5, gridcraft_core::date::serial_from_unix_ms) } /// Evaluates a formula in the context of a cell without storing it (conditional formats, data diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 26cd19f08..1decf6257 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -12,5 +12,8 @@ workspace = true serde = { workspace = true } thiserror = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" + [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 81fa6c55c..3b126b7fd 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -39,6 +39,26 @@ pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { era * 146097 + doe - 719468 } +/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable. +/// On wasm32 `std::time::SystemTime::now` traps, so the browser clock is read through `Date.now()`. +#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm +pub fn unix_now_ms() -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?; + Some(d.as_secs_f64() * 1000.0) + } + #[cfg(target_arch = "wasm32")] + { + Some(js_sys::Date::now()) + } +} + +/// Date serial (1900 system, with fraction) for a Unix time in milliseconds. +pub fn serial_from_unix_ms(ms: f64) -> f64 { + 25569.0 + ms / 86_400_000.0 +} + pub fn civil_from_days(z: i64) -> (i32, u32, u32) { let z = z + 719468; let era = if z >= 0 { z } else { z - 146096 } / 146097; @@ -160,6 +180,14 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th mod tests { use super::*; + #[test] + fn serial_from_unix_time() { + assert_eq!(serial_from_unix_ms(0.0), 25569.0); + // 2026-10-10T12:00:00Z + assert_eq!(serial_from_unix_ms(1_791_633_600_000.0), 46305.5); + assert!(unix_now_ms().is_some_and(|ms| serial_from_unix_ms(ms) > 46305.0)); + } + #[test] fn known_serials() { let s = DateSystem::D1900; diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index 7169ae545..b0ed80e07 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -273,16 +273,12 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option { serial_from_ymd(sys, y, m, d) } -/// The year used for dates typed without one. Fixed per process from the system clock when -/// available (wasm without clock falls back to 2026). -#[allow(clippy::disallowed_methods)] // the clock is read only off wasm +/// The year used for dates typed without one, from the system clock (browser clock on wasm). +/// Falls back to 2026 only when no clock is available. pub fn current_year() -> i64 { - #[cfg(not(target_arch = "wasm32"))] - { - if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { - let days = (d.as_secs() / 86400) as i64; - return crate::date::civil_from_days(days).0 as i64; - } + if let Some(ms) = crate::date::unix_now_ms() { + let days = (ms / 86_400_000.0).floor() as i64; + return crate::date::civil_from_days(days).0 as i64; } 2026 } From 5722dc5355a6e19e3b24889eeffc7a37522d5a35 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:45:21 +0200 Subject: [PATCH 004/102] Do not draw the selection over the frozen pane when it lies only in the scrolling pane (#198) --- crates/ui-egui/src/chartview.rs | 5 +- crates/ui-egui/src/grid.rs | 173 ++++++++++++++++++++++++++++---- 2 files changed, 155 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index adeeddbc5..bb40a4a2d 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -303,7 +303,10 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: && rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name)) { let rr = geo.range_rect(sh, rf.range()); - p.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside); + for pane in geo.range_panes(rf.range()) { + let pp = p.with_clip_rect(pane.intersect(p.clip_rect())); + pp.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside); + } } } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..93a6709d3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -186,6 +186,33 @@ impl Geo { let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1); (c0, c1) } + /// Screen areas of the panes (frozen rows/columns or the scrolling part) that hold part of `r`. + /// The panes share one coordinate system, so a range that is not clipped to these is also drawn + /// over the frozen rows/columns when it is scrolled under them. + pub fn range_panes(&self, r: RangeRef) -> Vec { + let split = pos2(self.cells.left() + self.frozen_w, self.cells.top() + self.frozen_h); + let mut xs = Vec::new(); + if r.start.col < self.fc { + xs.push((self.cells.left(), split.x)); + } + if r.end.col >= self.fc { + xs.push((split.x, self.cells.right())); + } + let mut ys = Vec::new(); + if r.start.row < self.fr { + ys.push((self.cells.top(), split.y)); + } + if r.end.row >= self.fr { + ys.push((split.y, self.cells.bottom())); + } + ys.iter().flat_map(|&(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)))).collect() + } + /// True when cell `c` is in the scrolling pane but scrolled entirely under the frozen rows or columns. + pub fn hidden_under_frozen(&self, sh: &Sheet, c: CellRef) -> bool { + let r = self.cell_rect(sh, c); + (self.fc > 0 && c.col >= self.fc && r.right() <= self.cells.left() + self.frozen_w) + || (self.fr > 0 && c.row >= self.fr && r.bottom() <= self.cells.top() + self.frozen_h) + } } /// Resolved look of a cell for painting. @@ -396,7 +423,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } let p = painter.with_clip_rect(*clip); paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t); - paint_selection(&p, &geo, sh, &sel, &t, app); + paint_selection(&p, &geo, sh, &sel, &t, app, *rows, *cols); } // Freeze lines. if geo.fr > 0 { @@ -901,21 +928,40 @@ fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { } } -fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) { +/// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive). +/// The panes of a frozen sheet share one coordinate system, so a range that lives only in the +/// scrolling pane would otherwise be drawn over the frozen pane as well. +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 +} + +#[allow(clippy::too_many_arguments)] +fn paint_selection( + p: &Painter, + geo: &Geo, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + t: &Tokens, + app: &SheetApp, + rows: (u32, u32), + cols: (u32, u32), +) { // Fill all areas except the active cell. for r in &sel.ranges { + if !range_in_pane(*r, rows, cols) { + continue; + } if r.is_single() && sel.ranges.len() == 1 { continue; } let rect = geo.range_rect(sh, *r); p.rect_filled(rect, 0.0, t.sel_fill); } - let active = geo.cell_rect(sh, sh.merge_at(sel.active).map(|m| m.start).unwrap_or(sel.active)); - let active = match sh.merge_at(sel.active) { - Some(m) => geo.range_rect(sh, m), - None => active, - }; - if !(sel.ranges.len() == 1 && sel.current().is_single()) { + let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active)); + let active = geo.range_rect(sh, active_range); + let active_here = range_in_pane(active_range, rows, cols); + let cur_here = range_in_pane(sel.current(), rows, cols); + if active_here && !(sel.ranges.len() == 1 && sel.current().is_single()) { p.rect_filled(active.shrink(1.0), 0.0, t.grid_bg.gamma_multiply(0.0)); } let cur = geo.range_rect(sh, sel.current()); @@ -927,6 +973,9 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S if sheet.as_deref().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) { continue; } + if !range_in_pane(r, rows, cols) { + continue; + } let rr = geo.range_rect(sh, r); let col = REF_COLORS[idx]; p.rect_filled(rr, 0.0, col.gamma_multiply(0.12)); @@ -936,18 +985,22 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S } } } - p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); - if sel.ranges.len() > 1 { + if cur_here { + p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); + } + if active_here && sel.ranges.len() > 1 { p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); } // Fill handle (bottom-right of the current area). - if app.editor.is_none() { + if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) { let h = Rect::from_center_size(cur.right_bottom(), vec2(6.0, 6.0)); p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE); p.rect_filled(h, 0.0, t.sel_border); } // Fill-handle drag preview. - if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag { + if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag + && range_in_pane(*tr, rows, cols) + { let rr = geo.range_rect(sh, *tr); p.extend(egui::Shape::dashed_line( &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], @@ -959,12 +1012,15 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag { let r = sel.current(); let moved = RangeRef::new(*to, to.offset_clamped(r.height() as i64 - 1, r.width() as i64 - 1)); - let rr = geo.range_rect(sh, moved); - p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); + if range_in_pane(moved, rows, cols) { + let rr = geo.range_rect(sh, moved); + p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); + } } // Marching ants around the copied range. if let Some(clip) = &app.session.clipboard && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet) + && range_in_pane(clip.range, rows, cols) { let rr = geo.range_rect(sh, clip.range).expand(0.5); let phase = app.grid.marching_phase; @@ -1065,6 +1121,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); let cur_rect = geo.range_rect(sh, sel.current()); let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); + // The fill handle and the move border only react in the panes where they are painted, not + // over the frozen rows/columns that a scrolled selection lies under. + let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); + let border_panes = geo.range_panes(sel.current()); + let on_handle = |p: Pos2| handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); + let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); // Hover cursors. if resp.hovered() @@ -1076,12 +1138,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & ctx.set_cursor_icon(CursorIcon::ResizeColumn); } else if near_row_edge { ctx.set_cursor_icon(CursorIcon::ResizeRow); - } else if handle.contains(p) && app.editor.is_none() { + } else if on_handle(p) && app.editor.is_none() { ctx.set_cursor_icon(CursorIcon::Crosshair); if app.grid.drag == Drag::None && !pointer.any_down() { resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); } - } else if in_cells && app.editor.is_none() && on_border(cur_rect, p) { + } else if in_cells && app.editor.is_none() && on_cur_border(p) { ctx.set_cursor_icon(CursorIcon::Move); } else if in_cells { ctx.set_cursor_icon(CursorIcon::Cell); @@ -1258,11 +1320,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { return; } - if handle.contains(p) { + if on_handle(p) { app.grid.drag = Drag::Fill { target: None }; return; } - if on_border(cur_rect, p) && resp.drag_started() { + if on_cur_border(p) && resp.drag_started() { app.grid.drag = Drag::Move { grab: c, to: None }; return; } @@ -1442,7 +1504,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // Double-click the fill handle. if resp.double_clicked() && let Some(p) = pos - && handle.contains(p) + && on_handle(p) { // Fill down as far as the neighbouring column goes. let src = sel.current(); @@ -1954,7 +2016,7 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte /// The dropdown arrow beside a cell with an in-cell list validation. pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option { let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?; - if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown { + if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown || geo.hidden_under_frozen(sh, c) { return None; } let r = geo.cell_rect(sh, c); @@ -1986,6 +2048,7 @@ fn paint_overlays( && 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()) + && !geo.hidden_under_frozen(sh, sel.active) { let r = geo.cell_rect(sh, sel.active); let at = pos2(r.left() + 8.0, r.bottom() + 6.0); @@ -2007,7 +2070,7 @@ fn paint_overlays( continue; } let r = geo.cell_rect(sh, *c); - if !p.clip_rect().intersects(r) { + if !p.clip_rect().intersects(r) || geo.hidden_under_frozen(sh, *c) { continue; } let at = pos2(r.right() + 10.0, r.top()); @@ -2047,7 +2110,9 @@ fn paint_overlays( let Some(t) = target else { continue }; let tr = geo.range_rect(sh, t); if !t.is_single() { - p.rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside); + for pane in geo.range_panes(t) { + p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside); + } } let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) }; p.line_segment([from, to], Stroke::new(1.5, blue)); @@ -2223,3 +2288,67 @@ fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { } p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); } + +#[cfg(test)] +mod pane_tests { + use super::*; + + fn rng(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef { + RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) + } + + #[test] + fn selection_in_scrolling_pane_is_not_drawn_in_frozen_pane() { + // Frozen: 2 rows and 2 columns; the scrolling pane shows rows 2..=20 and columns 2..=10. + let frozen_cols = ((2, 20), (0, 1)); + let frozen_rows = ((0, 1), (2, 10)); + let corner = ((0, 1), (0, 1)); + let scrolled = ((2, 20), (2, 10)); + let sel = rng(5, 4, 7, 6); + assert!(range_in_pane(sel, scrolled.0, scrolled.1)); + assert!(!range_in_pane(sel, frozen_cols.0, frozen_cols.1)); + assert!(!range_in_pane(sel, frozen_rows.0, frozen_rows.1)); + assert!(!range_in_pane(sel, corner.0, corner.1)); + } + + #[test] + fn selection_across_the_freeze_line_is_drawn_in_both_panes() { + let sel = rng(5, 0, 7, 6); + assert!(range_in_pane(sel, (2, 20), (0, 1))); + assert!(range_in_pane(sel, (2, 20), (2, 10))); + } + + /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. + fn frozen_geo(sh: &mut Sheet) -> Geo { + sh.freeze = Some((2, 2)); + let scroll = vec2(3.0 * sh.default_col_width + 8.0, 0.0); + Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll) + } + + #[test] + fn overlays_skip_cells_scrolled_under_the_frozen_columns() { + let mut sh = Sheet::new("S"); + let geo = frozen_geo(&mut sh); + assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 2))); + assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 4))); + assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 5))); + assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 0))); + let unscrolled = Geo::new(&sh, geo.rect, vec2(0.0, 0.0)); + assert!(!unscrolled.hidden_under_frozen(&sh, CellRef::new(5, 2))); + } + + #[test] + fn ranges_are_clipped_to_the_panes_that_hold_them() { + let mut sh = Sheet::new("S"); + let geo = frozen_geo(&mut sh); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + assert_eq!(geo.range_panes(rng(5, 3, 7, 6)), [Rect::from_min_max(split, geo.cells.max)]); + assert_eq!(geo.range_panes(rng(5, 0, 7, 6)).len(), 2); + assert_eq!(geo.range_panes(rng(0, 0, 7, 6)).len(), 4); + assert_eq!(geo.range_panes(rng(0, 0, 1, 1)), [Rect::from_min_max(geo.cells.min, split)]); + // The fill handle of D6:E7 sits over the frozen columns, where nothing of it is shown. + let handle = geo.range_rect(&sh, rng(5, 3, 6, 4)).right_bottom(); + assert!(handle.x < split.x); + assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle))); + } +} From 36c591883b88d1b8fda497719b6a7f51f81ce4bd Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:44:01 +0200 Subject: [PATCH 005/102] Resize charts from the corner handle and the Size fields, keep typed chart and table names (#190) The corner drag now grabs the chart where the press began and the selected object's corner shows a resize cursor. The Format Chart Area Size fields, chart and axis title fields and the Table Name field reloaded their value from the workbook every frame, so typed or dragged edits were thrown away; they now keep a draft until the field loses focus (Escape cancels, like sheet tab rename). --- crates/ui-egui/src/grid.rs | 16 ++- crates/ui-egui/src/panes.rs | 26 ++-- crates/ui-egui/src/ribbon.rs | 7 +- crates/ui-egui/src/widgets.rs | 19 +++ crates/ui-egui/tests/chart_resize.rs | 119 ++++++++++++++++++ crates/ui-egui/tests/committed_text_fields.rs | 81 ++++++++++++ docs/chart-parity.md | 2 +- 7 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/tests/chart_resize.rs create mode 100644 crates/ui-egui/tests/committed_text_fields.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..d890f17d2 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1047,6 +1047,9 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel // ------------------------------------------------------------------ interaction +/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. +const OBJECT_RESIZE_RADIUS: f32 = 10.0; + fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { // Text selection and editing must not also move the object or edit worksheet cells. if app.text_box_editor.is_some() { @@ -1072,7 +1075,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & { let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); let near_row_edge = in_row_header && row_edge(geo, sh, p.y).is_some(); - if near_col_edge { + let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); + let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); + let near_object_corner = app.grid.drag == Drag::None && near_corner; + if near_object_corner || matches!(app.grid.drag, Drag::Object { resize: true, .. }) { + ctx.set_cursor_icon(CursorIcon::ResizeNwSe); + } else if near_col_edge { ctx.set_cursor_icon(CursorIcon::ResizeColumn); } else if near_row_edge { ctx.set_cursor_icon(CursorIcon::ResizeRow); @@ -1135,7 +1143,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } // Objects (charts, pictures, shapes) take clicks first. if resp.drag_started() || primary_clicked { - if let Some(p) = pos + // A drag is recognised only after the pointer has moved, so grab the object where the press began. + let at = if resp.drag_started() { pointer.press_origin().or(pos) } else { pos }; + if let Some(p) = at && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) { app.selected_chart = Some(id); @@ -1151,7 +1161,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } if resp.drag_started() { let a = object_anchor(sh, kind, id); - let resize = (p - rect.right_bottom()).length() < 10.0; + let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; if let Some(a) = a { app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize }; } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fb8a9d7f7..47da52d1f 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -152,8 +152,8 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { }; let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default(); ui.collapsing("Chart Title", |ui| { - let mut title = chart.title.clone().unwrap_or_default(); - if ui.text_edit_singleline(&mut title).lost_focus() { + let field = egui::Id::new(("gridcraft.chart_title", id)); + if let Some(title) = crate::widgets::committed_text(ui, field, chart.title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "title": title})); } }); @@ -195,17 +195,17 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { if ui.checkbox(&mut gl, "Gridlines").changed() { app.run_or_alert("chart.set", json!({"chart": id, "gridlines": gl})); } - let mut xt = chart.x_title.clone().unwrap_or_default(); ui.horizontal(|ui| { ui.label("Horizontal axis title:"); - if ui.text_edit_singleline(&mut xt).lost_focus() { + let field = egui::Id::new(("gridcraft.chart_x_title", id)); + if let Some(xt) = crate::widgets::committed_text(ui, field, chart.x_title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "xTitle": if xt.is_empty() { Json::Null } else { json!(xt) }})); } }); - let mut yt = chart.y_title.clone().unwrap_or_default(); ui.horizontal(|ui| { ui.label("Vertical axis title:"); - if ui.text_edit_singleline(&mut yt).lost_focus() { + let field = egui::Id::new(("gridcraft.chart_y_title", id)); + if let Some(yt) = crate::widgets::committed_text(ui, field, chart.y_title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "yTitle": if yt.is_empty() { Json::Null } else { json!(yt) }})); } }); @@ -250,12 +250,20 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { }); }); ui.collapsing("Size", |ui| { - let mut w = chart.anchor.width; - let mut h = chart.anchor.height; + // Keep the value being typed or dragged between frames; reloading it from the chart every frame threw the edit away. + let draft = egui::Id::new(("gridcraft.chart_size_draft", id)); + let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or((chart.anchor.width, chart.anchor.height)); let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(40.0..=4000.0)); let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(40.0..=4000.0)); if a.drag_stopped() || a.lost_focus() || b.drag_stopped() || b.lost_focus() { - app.run_or_alert("chart.set", json!({"chart": id, "width": w, "height": h})); + ui.data_mut(|d| d.remove::<(f32, f32)>(draft)); + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.run_or_alert("chart.set", json!({"chart": id, "width": w, "height": h})); + } + } else if a.dragged() || a.has_focus() || b.dragged() || b.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, (w, h))); + } else { + ui.data_mut(|d| d.remove::<(f32, f32)>(draft)); } }); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..ede1accc3 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1733,9 +1733,10 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) { }; ui.vertical(|ui| { ui.label(egui::RichText::new("Table Name:").small()); - let mut name = tname.clone(); - let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(120.0)); - if r.lost_focus() && name != tname { + let field = egui::Id::new(("gridcraft.table_name", &tname)); + if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0)) + && name != tname + { act(app, "table.rename", json!({"table": tname, "name": name})); } }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 18649e66a..736989228 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -125,6 +125,25 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option, tip: &str) (m.on_hover_text(tr(ui, tip)).clicked(), a) } +/// A one-line text field for a value stored in the workbook, which callers reload every frame. The text being typed is +/// kept in egui memory until the field loses focus, then returned to commit (`None` when Escape cancels the edit). +pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option) -> Option { + let width = width.unwrap_or(ui.spacing().text_edit_width); + let draft = id.with("draft"); + let mut text = ui.data_mut(|d| d.get_temp::(draft)).unwrap_or_else(|| current.to_string()); + let r = ui.add(egui::TextEdit::singleline(&mut text).id(id).desired_width(width)); + if r.lost_focus() { + ui.data_mut(|d| d.remove::(draft)); + return (!ui.input(|i| i.key_pressed(egui::Key::Escape))).then_some(text); + } + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, text)); + } else { + ui.data_mut(|d| d.remove::(draft)); + } + None +} + /// Our colour palette (theme row + tints + standard colours), returns a picked hex colour, /// `Some("none")` for No Fill/Automatic. pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option { diff --git a/crates/ui-egui/tests/chart_resize.rs b/crates/ui-egui/tests/chart_resize.rs new file mode 100644 index 000000000..5aa07e080 --- /dev/null +++ b/crates/ui-egui/tests/chart_resize.rs @@ -0,0 +1,119 @@ +//! Resizing a chart by dragging its bottom-right corner or with the Format Chart Area Size fields, through real input. + +use egui::{Event, Key, Modifiers, PointerButton, accesskit::Role, pos2, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn fixture() -> (Harness, u32) { + let mut session = Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + session.execute("cell.set", json!({"cell": "A2", "input": "2"})).unwrap(); + session.execute("selection.set", json!({"range": "A1:A2"})).unwrap(); + let chart = session.execute("insert.chart", json!({"type": "column", "at": "D3", "width": 300, "height": 200})).unwrap(); + let id = chart["chart"].as_u64().unwrap() as u32; + let mut h = egui_kittest::Harness::builder().with_size(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(session, Default::default()), + ); + h.run_steps(4); + (h, id) +} + +fn size(h: &Harness, id: u32) -> (f32, f32) { + let a = h.state().session.doc().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().anchor; + (a.width, a.height) +} + +fn corner(h: &Harness, id: u32) -> egui::Pos2 { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + let a = sh.charts.iter().find(|c| c.id == id).unwrap().anchor; + let min = geo.cell_rect(sh, CellRef::parse("D3").unwrap()).min + vec2(a.dx, a.dy) * geo.z; + min + vec2(a.width, a.height) * geo.z +} + +fn button(h: &mut Harness, pos: egui::Pos2, pressed: bool) { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn dragging_the_corner_handle_resizes_the_chart() { + let (mut h, id) = fixture(); + let (w0, h0) = size(&h, id); + let start = corner(&h, id); + // Select the chart first, as the user does before grabbing a handle. + let inside = start - vec2(40.0, 40.0); + h.input_mut().events.push(Event::PointerMoved(inside)); + h.run_steps(2); + button(&mut h, inside, true); + button(&mut h, inside, false); + assert_eq!(h.state().selected_chart, Some(id)); + + h.input_mut().events.push(Event::PointerMoved(start)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe, "the corner shows a resize cursor"); + button(&mut h, start, true); + // The pointer moves well past the drag threshold in one step, like a quick drag. + let end = start + vec2(60.0, 30.0); + h.input_mut().events.push(Event::PointerMoved(pos2(end.x, end.y))); + h.run_steps(3); + button(&mut h, end, false); + let (w1, h1) = size(&h, id); + assert!(w1 > w0 + 40.0 && h1 > h0 + 20.0, "chart grew from {w0}x{h0} to {w1}x{h1}"); +} + +/// Opens Format Chart Area for the chart and expands its Size section; returns the Width field's rect. +fn open_size_fields(h: &mut Harness, id: u32) -> egui::Rect { + h.state_mut().selected_chart = Some(id); + h.state_mut().grid.pane = Some("formatChart".into()); + h.run_steps(2); + h.get_by_role_and_label(Role::Button, "Size").click(); + h.run_steps(2); + h.get_all_by_role(Role::SpinButton).next().unwrap().rect() +} + +#[test] +fn typing_a_width_in_the_size_field_resizes_the_chart() { + let (mut h, id) = fixture(); + let (_, h0) = size(&h, id); + let field = open_size_fields(&mut h, id); + h.input_mut().events.push(Event::PointerMoved(field.center())); + button(&mut h, field.center(), true); + button(&mut h, field.center(), false); + // Clicking the field selects its text; each keystroke must survive the next frame. + for digit in ["6", "4", "0"] { + h.input_mut().events.push(Event::Text(digit.into())); + h.run_steps(2); + } + h.key_press(Key::Enter); + h.run_steps(2); + assert_eq!(size(&h, id), (640.0, h0)); +} + +#[test] +fn dragging_the_size_field_resizes_the_chart() { + let (mut h, id) = fixture(); + let (w0, _) = size(&h, id); + let field = open_size_fields(&mut h, id); + h.input_mut().events.push(Event::PointerMoved(field.center())); + button(&mut h, field.center(), true); + for step in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(field.center() + vec2(10.0 * step as f32, 0.0))); + h.run_steps(1); + } + button(&mut h, field.center() + vec2(60.0, 0.0), false); + let (w1, _) = size(&h, id); + assert!(w1 > w0 + 20.0, "width went from {w0} to {w1}"); +} diff --git a/crates/ui-egui/tests/committed_text_fields.rs b/crates/ui-egui/tests/committed_text_fields.rs new file mode 100644 index 000000000..565a3d15e --- /dev/null +++ b/crates/ui-egui/tests/committed_text_fields.rs @@ -0,0 +1,81 @@ +//! Text fields over workbook values (chart titles, table name) keep what the user types until they commit it. + +use egui::{Event, Key, Modifiers, accesskit::Role, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(session: Session) -> Harness { + let mut h = egui_kittest::Harness::builder().with_size(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(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn session() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); + session +} + +/// Clicks the field, replaces its text with `text` and presses Enter. +fn retype(h: &mut Harness, field: egui::Rect, text: &str) { + h.input_mut().events.push(Event::PointerMoved(field.center())); + h.run_steps(1); + h.get_all_by_role(Role::TextInput).find(|n| n.rect() == field).unwrap().click(); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + // One keystroke per frame, as a person types: each must survive the next frame. + for c in text.chars() { + h.input_mut().events.push(Event::Text(c.to_string())); + h.run_steps(2); + } + h.key_press(Key::Enter); + h.run_steps(2); +} + +#[test] +fn typing_a_chart_title_in_format_chart_area_sets_it() { + let mut session = session(); + session.execute("selection.set", json!({"range": "A1:B3"})).unwrap(); + let chart = session.execute("insert.chart", json!({"type": "column", "at": "D3"})).unwrap(); + let id = chart["chart"].as_u64().unwrap() as u32; + let mut h = harness(session); + h.state_mut().selected_chart = Some(id); + h.state_mut().grid.pane = Some("formatChart".into()); + h.run_steps(2); + h.get_by_role_and_label(Role::Button, "Chart Title").click(); + h.run_steps(2); + // The pane sits on the right; the formula bar and name box are further left. + let field = h.get_all_by_role(Role::TextInput).map(|n| n.rect()).find(|r| r.left() > 850.0).unwrap(); + retype(&mut h, field, "Sales"); + let title = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().title.clone(); + assert_eq!(title.as_deref(), Some("Sales")); +} + +#[test] +fn typing_a_table_name_on_the_table_design_tab_renames_the_table() { + let mut session = session(); + session.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap(); + session.execute("selection.set", json!({"range": "A2"})).unwrap(); + let mut h = harness(session); + h.state_mut().ui.ribbon_tab = "Table Design".into(); + h.run_steps(2); + let label = h.get_by_label("Table Name:").rect(); + let below_label = |r: &egui::Rect| r.top() >= label.bottom() - 2.0 && (r.left() - label.left()).abs() < 20.0; + let field = h.get_all_by_role(Role::TextInput).map(|n| n.rect()).find(below_label).unwrap(); + retype(&mut h, field, "Sales"); + let name = h.state().session.active().unwrap().wb.active().unwrap().tables[0].name.clone(); + assert_eq!(name, "Sales"); +} diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 05779299e..edfc95e8b 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -35,7 +35,7 @@ open PRs #158, #161, and issue #190. | Element | Excel | GridCraft | |---|---|---| -| Move and resize the chart object | yes | move yes; **resize broken** (#190) | +| Move and resize the chart object | yes | yes (corner handle, Format Chart Area Size fields) | | Chart title, axis titles | text, linked to cells, full formatting | text only | | Legend | position, overlay, formatting, per-entry delete | position only | | Data labels | value, category, series, percentage, from cells, callouts, position | one on/off flag | From ac87c269ffdfe6d6a0f104e0d7468936270842c7 Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sun, 11 Oct 2026 01:02:18 +0300 Subject: [PATCH 006/102] Fix #213: delete selected objects from keyboard --- ROADMAP.md | 2 ++ crates/ui-egui/src/grid.rs | 18 ++++++++++++++++ crates/ui-egui/tests/interaction.rs | 33 +++++++++++++++++++++++++++++ docs/ui-parity.md | 4 +++- 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..1bbae9aae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Selected charts, pictures and shapes can be deleted from the keyboard (#213) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | @@ -142,6 +143,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Selected objects can be deleted from the keyboard (#213) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..6b7530d06 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1608,6 +1608,22 @@ fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option bool { + let Some(id) = app.selected_chart else { return false }; + let kind = if sh.charts.iter().any(|object| object.id == id) { + "chart" + } else if sh.images.iter().any(|object| object.id == id) { + "image" + } else if sh.shapes.iter().any(|object| object.id == id) { + "shape" + } else { + return false; + }; + app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); + app.selected_chart = None; + true +} + pub fn follow_link(app: &mut SheetApp, target: &str) { if target.contains("://") || target.starts_with("mailto:") { if let Some(f) = &app.services.open_url { @@ -1708,7 +1724,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: } Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }), Key::F2 => app.begin_edit(None, false), + Key::Delete if delete_selected_object(app, sh) => {} Key::Delete => app.run_or_alert("edit.clearContents", json!({})), + Key::Backspace if delete_selected_object(app, sh) => {} Key::Backspace => { // Excel for Mac: Delete clears the cell and starts editing it. app.run_or_alert("edit.clearContents", json!({})); diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 84f20a675..75587717e 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -85,6 +85,21 @@ fn click_cell(h: &mut egui_kittest::Harness<'static, SheetApp>, cell: &str) { h.run_steps(2); } +fn click_chart(h: &mut egui_kittest::Harness<'static, SheetApp>, id: u32) { + 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); + let anchor = sheet.charts.iter().find(|chart| chart.id == id).unwrap().anchor; + let pos = egui::pos2(geo.x(sheet, anchor.cell.col) + anchor.dx * geo.z, geo.y(sheet, anchor.cell.row) + anchor.dy * geo.z) + + egui::vec2(anchor.width, anchor.height) * geo.z / 2.0; + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); +} + #[test] fn keyboard_navigation_does_not_replay_the_last_pointer_click() { let mut h = harness(blank()); @@ -170,6 +185,24 @@ fn shortcuts_format_and_extend() { assert_eq!(value(&h, "A1"), Value::Empty); } +#[test] +fn delete_key_removes_the_selected_chart() { + let mut session = blank(); + session.execute("range.setValues", json!({"range": "A1:B2", "values": [[1, 2], [3, 4]]})).unwrap(); + let id = session.execute("insert.chart", json!({"range": "A1:B2", "at": "D2"})).unwrap()["chart"].as_u64().unwrap() as u32; + let mut h = harness(session); + + click_cell(&mut h, "C10"); + click_chart(&mut h, id); + assert_eq!(h.state().selected_chart, Some(id)); + + key(&mut h, Key::Delete, Modifiers::NONE); + + assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty()); + assert_eq!(h.state().selected_chart, None); + assert_eq!(value(&h, "A1"), Value::Number(1.0)); +} + #[test] fn renders_every_ribbon_tab_without_panicking() { let mut s = Session::new(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..6c341def8 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-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selected objects can be deleted from the keyboard) · **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 @@ -56,6 +56,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T, Ctrl+K, Ctrl+Shift+L | yes | most done; audit pending | | F2, F4, F5, F9 family, Shift+F3, F11 | yes | most; F11 chart sheet missing (no chart sheets) | | Mac-specific (Cmd variants, Fn keys) | yes | Cmd mapping done | +| Delete selected charts, pictures and shapes | yes | done (#213) | | Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) | Work: a shortcut table generated from the command catalog checked against Microsoft's published @@ -91,5 +92,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Delete and Backspace now remove the selected chart, picture or shape (#213) | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From 706fe7b10c4fa2ab4a168cfd9a11646fb03d1bcf Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sun, 11 Oct 2026 01:20:03 +0300 Subject: [PATCH 007/102] macOS: use the padded app icon in the Dock --- apps/gridcraft/src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3c73a9541..dc70a092a 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -165,6 +165,9 @@ fn services() -> Services { } fn icon() -> Option { + #[cfg(target_os = "macos")] + let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png"); + #[cfg(not(target_os = "macos"))] let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png"); let img = image::load_from_memory(bytes).ok()?.to_rgba8(); Some(egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() }) From a0dc1abce1f5e8fab69430ddbf727ba4d1bd9f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dejan=20Koci=C4=87?= Date: Sun, 11 Oct 2026 00:35:45 +0200 Subject: [PATCH 008/102] fix: Save toolbar button and saveChanges dialog discard an in-progress cell edit Clicking the Save icon in the toolbar, or the Save button in the "save changes?" dialog, called file.save directly without first committing a cell edit still in progress (e.g. double-click a cell, type a new value, then click Save with the mouse instead of pressing Enter). The pending text in EditState was never flushed into the document, so the saved file still held the pre-edit value, silently. The File > Save menu item already guards against this (app.commit_edit(...) before running the command); this applies the same guard to the toolbar icon and the saveChanges dialog's Save button. --- crates/ui-egui/src/dialogs.rs | 3 ++- crates/ui-egui/src/ribbon.rs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb10..8d6c8ada7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -810,7 +810,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { if ui.button("Cancel").clicked() { open = false; } - if ui.button("Save").clicked() { + // File operations must include the cell the user is still editing (as the File > Save menu item already does). + if ui.button("Save").clicked() && app.commit_edit(0, 0, false, false) { app.run_or_alert("file.save", json!({})); if app.session.active().is_some_and(|x| !x.is_dirty()) { app.run_or_alert("file.close", json!({"force": true})); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..7838316f5 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -44,7 +44,10 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, 26.0)).clicked() { app.open_dialog("start", json!({})); } - if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, 26.0)).clicked() { + if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, 26.0)).clicked() + // File operations must include the cell the user is still editing (as the File > Save menu item already does). + && app.commit_edit(0, 0, false, false) + { app.run_or_alert("file.save", json!({})); } let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty()); From 9f896c1249f40d18bf182199dc03042959be5f3f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 19:10:53 -0400 Subject: [PATCH 009/102] Bump version to 0.5.0 for release (#227) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 32 +++--- Cargo.toml | 2 +- contributors/contributors.json | 200 +++++++++++++++++++++++++++++++-- 3 files changed, 206 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5dce47768..f772f83d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1416,7 +1416,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.4.0" +version = "0.5.0" dependencies = [ "arboard", "eframe", @@ -1434,7 +1434,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.4.0" +version = "0.5.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1446,7 +1446,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.4.0" +version = "0.5.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1457,7 +1457,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1467,7 +1467,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "serde", "serde_json", @@ -1476,7 +1476,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.4.0" +version = "0.5.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1497,7 +1497,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.4.0" +version = "0.5.0" dependencies = [ "gridcraft-core", "serde", @@ -1506,7 +1506,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.4.0" +version = "0.5.0" dependencies = [ "gridcraft-core", "regex", @@ -1514,7 +1514,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.4.0" +version = "0.5.0" dependencies = [ "gridcraft-engine", "log", @@ -1523,7 +1523,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.4.0" +version = "0.5.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1534,18 +1534,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.4.0" +version = "0.5.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.4.0" +version = "0.5.0" [[package]] name = "gridcraft-ui-egui" -version = "0.4.0" +version = "0.5.0" dependencies = [ "egui", "egui_kittest", @@ -1562,7 +1562,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.4.0" +version = "0.5.0" dependencies = [ "eframe", "egui", @@ -1579,7 +1579,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.4.0" +version = "0.5.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -4393,7 +4393,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.4.0" +version = "0.5.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2e0e895a7..d7fad0f75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/contributors/contributors.json b/contributors/contributors.json index d338d1656..60da0f92d 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -2,23 +2,36 @@ "schema": 1, "repo": "storytold/gridcraft", "branch": "main", - "head": "51b531be3e8b3f5a19d2b7714a4780a79569ca92", - "head_date": "2026-10-08T13:12:32Z", - "commits": 24, + "head": "90b3c41eec691797129303603d4073269aebab73", + "head_date": "2026-10-10T19:59:42Z", + "commits": 148, "unattributed_commits": 0, "contributors": [ { "login": "echelon", "people_entry": true, "github_name": null, - "prs": 4, - "commits": 21, - "lines_added": 79088, - "lines_deleted": 3997, + "prs": 5, + "commits": 46, + "lines_added": 81717, + "lines_deleted": 4837, "binary_added": 35, "binary_deleted": 12, "first_commit": "2026-10-06T02:09:42Z", - "last_commit": "2026-10-08T13:50:17Z" + "last_commit": "2026-10-10T19:59:42Z" + }, + { + "login": "switch87", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 646, + "lines_deleted": 8, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T10:06:39Z", + "last_commit": "2026-10-08T11:01:16Z" }, { "login": "h-a-n-a-s-h-i", @@ -33,6 +46,19 @@ "first_commit": "2026-10-08T12:13:30Z", "last_commit": "2026-10-08T14:02:29Z" }, + { + "login": "justinjohn0306", + "people_entry": false, + "github_name": null, + "prs": 6, + "commits": 7, + "lines_added": 1408, + "lines_deleted": 116, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T12:28:53Z", + "last_commit": "2026-10-09T18:39:12Z" + }, { "login": "XusBadia", "people_entry": false, @@ -45,6 +71,149 @@ "binary_deleted": 0, "first_commit": "2026-10-08T13:12:32Z", "last_commit": "2026-10-08T13:12:32Z" + }, + { + "login": "DWAA1660", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 4, + "lines_added": 244, + "lines_deleted": 24, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T01:40:12Z", + "last_commit": "2026-10-09T20:46:29Z" + }, + { + "login": "MarekSpodBiedry", + "people_entry": false, + "github_name": null, + "prs": 14, + "commits": 14, + "lines_added": 586, + "lines_deleted": 52, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T08:45:50Z", + "last_commit": "2026-10-10T17:06:52Z" + }, + { + "login": "harrisonoconnorhover", + "people_entry": false, + "github_name": null, + "prs": 20, + "commits": 22, + "lines_added": 9266, + "lines_deleted": 371, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T09:28:48Z", + "last_commit": "2026-10-10T17:48:20Z" + }, + { + "login": "LloydNicholson", + "people_entry": false, + "github_name": null, + "prs": 7, + "commits": 2, + "lines_added": 130, + "lines_deleted": 25, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T15:13:40Z", + "last_commit": "2026-10-09T15:23:15Z" + }, + { + "login": "lloyd-nich", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 5, + "lines_added": 854, + "lines_deleted": 88, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T18:36:08Z", + "last_commit": "2026-10-10T12:17:14Z" + }, + { + "login": "dylanmtaylor", + "people_entry": false, + "github_name": null, + "prs": 3, + "commits": 3, + "lines_added": 281, + "lines_deleted": 45, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:14:43Z", + "last_commit": "2026-10-10T03:14:29Z" + }, + { + "login": "edurbs", + "people_entry": true, + "github_name": "Eduardo Soares", + "prs": 1, + "commits": 1, + "lines_added": 311, + "lines_deleted": 11, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:18:57Z", + "last_commit": "2026-10-09T21:18:57Z" + }, + { + "login": "nihilist-narwhal", + "people_entry": true, + "github_name": null, + "prs": 19, + "commits": 35, + "lines_added": 2261, + "lines_deleted": 145, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T22:14:59Z", + "last_commit": "2026-10-10T10:40:00Z" + }, + { + "login": "neko233-com", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 1004, + "lines_deleted": 81, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T02:20:59Z", + "last_commit": "2026-10-10T02:20:59Z" + }, + { + "login": "agammann", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 34, + "lines_deleted": 2, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T05:47:38Z", + "last_commit": "2026-10-10T05:47:38Z" + }, + { + "login": "itrycodinganditsbad", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 2, + "lines_added": 68, + "lines_deleted": 7, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T11:25:38Z", + "last_commit": "2026-10-10T11:28:07Z" } ], "models": [ @@ -53,9 +222,18 @@ "company": "Anthropic", "model": "Claude Opus", "version": "5.5", - "commits": 23, - "lines_added": 79359, - "lines_deleted": 4032 + "commits": 58, + "lines_added": 83111, + "lines_deleted": 4992 + }, + { + "id": "openai/codex", + "company": "OpenAI", + "model": "Codex", + "version": "", + "commits": 3, + "lines_added": 173, + "lines_deleted": 24 } ] } From 83c7a3c2f5b582388b7436b8c48be76e5790a019 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:56:15 +0000 Subject: [PATCH 010/102] Contributors: add Jason Brashear display name --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 78683ae55..9b0d8aa1d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -28,3 +28,7 @@ sync_github_name = false [people.edurbs] sync_github_name = true + +[people.webdevtodayjason] +display_name = "Jason Brashear" +sync_github_name = false From a788c41faa5b94ab280cfa40c9e1e43d4b731ce5 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:56:15 +0000 Subject: [PATCH 011/102] #200: keep formula helpers inside the worksheet Fixes #200 --- crates/ui-egui/src/formula_bar.rs | 6 +- crates/ui-egui/src/grid.rs | 12 ++- crates/ui-egui/tests/formula_popup.rs | 144 ++++++++++++++++++++++++++ docs/ui-parity.md | 5 +- 4 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/formula_popup.rs diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 6cca1103c..63abc6f57 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -336,9 +336,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ui.painter().galley(pos, g, Color32::WHITE); } // Autocomplete list and argument hint under the editor. - if mine && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { + if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { let below = resp.rect.left_bottom() + vec2(0.0, 4.0); - egui::Area::new(id.with("ac")).fixed_pos(below).order(egui::Order::Tooltip).show(ui.ctx(), |ui| { + // Areas have their own clip rect: keep cell helpers inside the worksheet. + let bounds = if ed.from_formula_bar { ui.ctx().content_rect() } else { ui.clip_rect() }; + egui::Area::new(id.with("ac")).fixed_pos(below).constrain_to(bounds).order(egui::Order::Tooltip).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(4.0).show(ui, |ui| { if !ed.autocomplete.is_empty() { let mut pick = None; diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..8cad6c6d3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1756,10 +1756,18 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE), _ => Color32::WHITE, }; - ui.painter().rect_filled(erect, 0.0, fill); - ui.painter().rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + let quadrant = Rect::from_min_max( + pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }), + pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }), + ); + let clip = ui.clip_rect().intersect(quadrant); + let painter = ui.painter().with_clip_rect(clip); + painter.rect_filled(erect, 0.0, fill); + painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); let id = egui::Id::new("gridcraft.cell_editor"); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); + child.set_clip_rect(clip); crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0); } diff --git a/crates/ui-egui/tests/formula_popup.rs b/crates/ui-egui/tests/formula_popup.rs new file mode 100644 index 000000000..6734d30dc --- /dev/null +++ b/crates/ui-egui/tests/formula_popup.rs @@ -0,0 +1,144 @@ +//! Formula helpers must stay in the worksheet while its editor scrolls. +use egui::{Context, Id, LayerId, Order, RawInput, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; + +fn frame(ctx: &Context, app: &mut SheetApp) -> egui::FullOutput { + let mut output = + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() }, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }); + output.textures_delta.clear(); + output +} + +fn frames(ctx: &Context, app: &mut SheetApp) { + for _ in 0..4 { + frame(ctx, app); + } +} + +fn visible(ctx: &Context, editor: &str) -> bool { + ctx.memory(|m| m.areas().is_visible(&LayerId::new(Order::Tooltip, Id::new(editor).with("ac")))) +} + +#[test] +fn cell_formula_helpers_hide_when_scrolled_out_and_return_with_the_editor() { + for text in ["=SU", "=SUM("] { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some(text.into()), false); + if text == "=SU" { + app.editor.as_mut().unwrap().update_autocomplete(&["SUM".into(), "SUMIF".into()]); + } + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.cell_editor"), "visible cell shows {text} help"); + app.view_mut().unwrap().scroll.y = 400.0; + frames(&ctx, &mut app); + assert!(!visible(&ctx, "gridcraft.cell_editor"), "scrolled-out cell must hide {text} help"); + assert_eq!(app.editor.as_ref().unwrap().text, text, "scrolling preserves the edit"); + app.view_mut().unwrap().scroll.y = 0.0; + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores {text} help"); + } +} + +#[test] +fn formula_bar_hint_remains_visible_when_the_worksheet_scrolls() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some("=SUM(".into()), true); + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.formula_bar")); + app.view_mut().unwrap().scroll.y = 400.0; + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.formula_bar")); +} + +#[test] +fn cell_argument_hint_is_clipped_to_the_worksheet() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some("=SUM(".into()), false); + frames(&ctx, &mut app); + app.view_mut().unwrap().scroll.y = 10.0; + frames(&ctx, &mut app); + let output = frame(&ctx, &mut app); + let cells = app.grid.cells_rect.unwrap(); + let hint = output.shapes.iter().find(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "SUM(")).expect("argument hint painted"); + assert!(cells.contains_rect(hint.clip_rect), "helper must not paint over the headers: {:?}", hint.clip_rect); +} + +#[test] +fn scrolled_out_cell_editor_still_handles_escape() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some("=SUM(".into()), false); + frames(&ctx, &mut app); + app.view_mut().unwrap().scroll.y = 400.0; + frames(&ctx, &mut app); + let mut output = ctx.run_ui( + RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), + events: vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| app.ui(ui), + ); + output.textures_delta.clear(); + assert!(app.editor.is_none(), "scrolling must not disable editor keys"); +} + +#[test] +fn cell_editor_and_helper_stay_below_the_frozen_top_row() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.run("view.freezeTopRow", serde_json::json!({})).unwrap(); + session.run("selection.set", serde_json::json!({"range": "B5"})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let frozen_h = sh.row_height(0); + let scroll = sh.row_top(4) as f32; + app.begin_edit(Some("=SUM(".into()), false); + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.cell_editor")); + app.view_mut().unwrap().scroll.y = scroll - frozen_h / 2.0; + frames(&ctx, &mut app); + let output = frame(&ctx, &mut app); + let frozen_bottom = app.grid.cells_rect.unwrap().top() + frozen_h * gridcraft_ui_egui::grid::DISPLAY_SCALE; + let editor = output + .shapes + .iter() + .find(|s| s.clip_rect.bottom() > frozen_bottom && matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "=SUM(")) + .expect("editor painted"); + assert!(editor.clip_rect.top() >= frozen_bottom, "editor must be clipped below the frozen row: {:?}", editor.clip_rect); + app.view_mut().unwrap().scroll.y = scroll; + frames(&ctx, &mut app); + assert!(!visible(&ctx, "gridcraft.cell_editor"), "cell hidden under the frozen row must hide its helper"); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUM("); + app.view_mut().unwrap().scroll.y = 0.0; + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores the helper"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..689a58084 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-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (formula helpers stay inside the worksheet, #200) · **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 @@ -36,7 +36,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done | -| AutoComplete in columns, function autocomplete and argument tips | done | +| AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | | Freeze panes, split panes | freeze done; split to verify | | Copy as HTML table for other apps | done (#36, 10-10) | @@ -91,5 +91,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | Keep formula helpers inside the worksheet when scrolling (#200) | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From 2f078962a0aa2d4692d04f6485d4a1aa6327c85e Mon Sep 17 00:00:00 2001 From: pkibiego Date: Sun, 11 Oct 2026 03:39:32 +0300 Subject: [PATCH 012/102] xlsx: bump quick-xml to 0.41 for the namespace-declaration DoS (RUSTSEC-2026-0195) The ODS and XLSB readers parse untrusted package metadata and content through `quick_xml::NsReader`. In 0.38.4 that is RUSTSEC-2026-0195 (CVSS 7.5): the namespace resolver pushes a binding for every `xmlns` / `xmlns:*` declaration on a start tag, with no cap on how many there are, and `NsReader::read_event` reaches that path - not only `read_resolved_event`. None of our caps bound it. MAX_PART (512 MiB), MAX_TOTAL (1 GiB), MAX_ENTRIES, MAX_CELLS, MAX_TEXT and MAX_DEPTH bound part size, entry count, cells, text and nesting, never declarations per tag. Measured with `gridcraft-cli info` on crafted .ods files, before this change: 754 B, 0 declarations -> 8.6 MB, exit 0 24 KB, 1,000,000 -> 52.6 MB, exit 0 187 KB, 8,000,000 -> 367.8 MB, exit 0 770 KB, 33,000,000 -> 1.46 GB, exit 0 About 45 bytes of resident memory per declaration, roughly 1,900x the file size, and no error at all. At MAX_PART a ~1 MB file reaches ~1.9 GB, and every part is held in memory while the package is read. Both readers share the path, so a downloaded .ods or .xlsb was enough. Nothing panicked, so `Session::execute` never saw it. 0.41.0 caps declarations per start tag at 256 (DEFAULT_MAX_DECLARATIONS_PER_ELEMENT, raisable through NamespaceResolver::set_max_declarations_per_element) and returns `NamespaceError::TooManyDeclarations` beyond it. The same fixtures after: 754 B -> 8.6 MB, ok 24 KB -> 19 MB, error 187 KB -> 101.8 MB, error 770 KB -> 397 MB, error with "start tag declares more than 256 namespace bindings". The residual peak is the part itself: MAX_PART still lets one part decompress to 256 MiB, the existing intended bound. API changes for 0.41 (0.x bumps may break; all four are in the xlsx crate): - `resolve_element` and `resolve_attribute` moved off `NsReader` on to the resolver: `reader.resolver_mut().resolve_element(..)`. `attr` and `tag` in ods.rs take `&mut NsReader` for that. - `Attribute::unescape_value` is deprecated in favour of `normalized_value`, which also applies the attribute-value normalization the XML specification requires. - `BytesText::xml_content` now takes the document's XML version. This also clears RUSTSEC-2026-0194, the quadratic duplicate-attribute check. xml.rs already passed `with_checks(false)`, but ods.rs and xlsb.rs used the checked iterator, so they were on the quadratic path as well. Regression test: `ods_tests::ods_namespace_declaration_bomb_is_rejected` asserts that a tag with thousands of declarations is an error and that a document with a handful still reads. It fails on 0.38.4 (the tag is accepted) and passes on 0.41.0. Verified: `cargo xtask ci`; the full xlsx suite (80 tests) plus the malformed-input tests; and the four measurements above, before and after, with no panics either way. --- Cargo.lock | 13 ++----------- Cargo.toml | 2 +- crates/xlsx/src/ods.rs | 23 ++++++++++++----------- crates/xlsx/src/tests/ods_tests.rs | 16 ++++++++++++++++ crates/xlsx/src/xlsb.rs | 7 ++++--- crates/xlsx/src/xml.rs | 5 +++-- 6 files changed, 38 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f772f83d2..16e222181 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1584,7 +1584,7 @@ dependencies = [ "gridcraft-core", "gridcraft-formula", "gridcraft-model", - "quick-xml 0.38.4", + "quick-xml", "thiserror 2.0.21", "zip", ] @@ -2739,15 +2739,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -3725,7 +3716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.41.0", + "quick-xml", "quote", ] diff --git a/Cargo.toml b/Cargo.toml index d7fad0f75..d8263903f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } -quick-xml = "0.38" +quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs index 4f63fd1f0..8415ce395 100644 --- a/crates/xlsx/src/ods.rs +++ b/crates/xlsx/src/ods.rs @@ -10,6 +10,7 @@ use gridcraft_core::date::{days_in_month, serial_from_ymd}; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook}; use quick_xml::NsReader; +use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; use quick_xml::name::ResolveResult; @@ -67,13 +68,13 @@ fn is_ns(ns: ResolveResult<'_>, expected: &[u8]) -> bool { matches!(ns, ResolveResult::Bound(n) if n.as_ref() == expected) } -fn attr(reader: &NsReader<&[u8]>, e: &BytesStart<'_>, ns: &[u8], name: &[u8]) -> Result, IoError> { +fn attr(reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>, ns: &[u8], name: &[u8]) -> Result, IoError> { let mut found = None; for a in e.attributes() { let a = a.map_err(|e| IoError::Xml(e.to_string()))?; - let (resolved, local) = reader.resolve_attribute(a.key); + let (resolved, local) = reader.resolver_mut().resolve_attribute(a.key); if is_ns(resolved, ns) && local.as_ref() == name { - let value = a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?; + let value = a.normalized_value(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?; if value.len() > MAX_CELL_TEXT { return Err(IoError::TooLarge("ODS attribute exceeds 1 MiB".into())); } @@ -106,7 +107,7 @@ fn check_manifest(bytes: &[u8]) -> Result<(), IoError> { let event = reader.read_event().map_err(xml_error)?; match &event { Event::Start(e) | Event::Empty(e) => { - let (ns, name) = reader.resolve_element(e.name()); + let (ns, name) = reader.resolver_mut().resolve_element(e.name()); if is_ns(ns.clone(), MANIFEST) && name.as_ref() == b"encryption-data" { return Err(IoError::Format("encrypted ODS files are not supported".into())); } @@ -153,8 +154,8 @@ enum Tag { Other, } -fn tag(reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Tag { - let (ns, name) = reader.resolve_element(e.name()); +fn tag(reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>) -> Tag { + let (ns, name) = reader.resolver_mut().resolve_element(e.name()); let local = name.as_ref(); if is_ns(ns.clone(), OFFICE) { return match local { @@ -253,23 +254,23 @@ impl Import { return Err(xml_error("XML nested too deeply")); } if self.stack.is_empty() { - if root_seen || tag(&reader, &e) != Tag::Document { + if root_seen || tag(&mut reader, &e) != Tag::Document { return Err(xml_error("expected one office:document-content root")); } root_seen = true; } - self.start(&reader, &e)?; + self.start(&mut reader, &e)?; } Event::Empty(e) => { if self.stack.is_empty() { return Err(xml_error("expected office:document-content with a spreadsheet")); } - self.start(&reader, &e)?; + self.start(&mut reader, &e)?; self.end()?; } Event::End(_) => self.end()?, Event::Text(t) => { - let text = t.xml_content().map_err(xml_error)?; + let text = t.xml_content(XmlVersion::Explicit1_0).map_err(xml_error)?; if self.stack.is_empty() && !text.trim().is_empty() { return Err(xml_error("text outside the document")); } @@ -301,7 +302,7 @@ impl Import { Ok(()) } - fn start(&mut self, reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Result<(), IoError> { + fn start(&mut self, reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>) -> Result<(), IoError> { let t = tag(reader, e); let depth = self.stack.len(); match t { diff --git a/crates/xlsx/src/tests/ods_tests.rs b/crates/xlsx/src/tests/ods_tests.rs index c0f2e0e21..47c258657 100644 --- a/crates/xlsx/src/tests/ods_tests.rs +++ b/crates/xlsx/src/tests/ods_tests.rs @@ -23,6 +23,22 @@ fn text(s: &str) -> Value { Value::Text(s.into()) } +/// A start tag with thousands of `xmlns:` declarations is refused instead of allocated +/// (RUSTSEC-2026-0195: the resolver kept a binding per declaration, with no cap). +#[test] +fn ods_namespace_declaration_bomb_is_rejected() { + let bomb = |n: usize| -> String { (0..n).map(|i| format!(" xmlns:p{i}=\"urn:p{i}\"")).collect() }; + let doc = |decls: &str| { + format!( + r#"1"# + ) + }; + let build = |decls: &str| make_zip(&[("mimetype", MIME), ("META-INF/manifest.xml", MANIFEST), ("content.xml", doc(decls).as_bytes())]); + let ok = read_ods(&build(&bomb(8))); + assert!(ok.is_ok(), "a handful of namespaces still reads: {:?}", ok.err()); + assert!(read_ods(&build(&bomb(10_000))).is_err(), "a tag with thousands of declarations is refused"); +} + #[test] fn ods_typed_values_and_formula_caches_are_constants_and_survive_xlsx() { let bytes = row(r#" diff --git a/crates/xlsx/src/xlsb.rs b/crates/xlsx/src/xlsb.rs index 897a40be9..3c8454749 100644 --- a/crates/xlsx/src/xlsb.rs +++ b/crates/xlsx/src/xlsb.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{Cell, Sheet, Visibility, Workbook}; use quick_xml::NsReader; +use quick_xml::XmlVersion; use quick_xml::events::Event; use quick_xml::name::ResolveResult; @@ -160,7 +161,7 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result, IoE let event = reader.read_event().map_err(|e| IoError::Xml(e.to_string()))?; match &event { Event::Start(e) | Event::Empty(e) => { - let (namespace, local) = reader.resolve_element(e.name()); + let (namespace, local) = reader.resolver_mut().resolve_element(e.name()); let valid_ns = matches!(namespace, ResolveResult::Bound(n) if n.as_ref() == ns); if depth == 0 { if seen || !valid_ns || local.as_ref() != root_name { @@ -174,7 +175,7 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result, IoE if !a.key.as_ref().contains(&b':') { attrs.push(( String::from_utf8_lossy(a.key.as_ref()).into_owned(), - a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?.into_owned(), + a.normalized_value(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?.into_owned(), )); } } @@ -188,7 +189,7 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result, IoE } } Event::End(_) => depth = depth.checked_sub(1).ok_or_else(|| invalid("unexpected metadata end"))?, - Event::Text(t) if depth == 0 && !t.xml_content().map_err(|e| IoError::Xml(e.to_string()))?.trim().is_empty() => { + Event::Text(t) if depth == 0 && !t.xml_content(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?.trim().is_empty() => { return Err(invalid("text outside package metadata")); } Event::DocType(_) => return Err(invalid("DOCTYPE in package metadata is unsupported")), diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index efa5cdcb1..9e03f5ff0 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -2,6 +2,7 @@ //! shared strings) so large parts never need a full tree, plus escaping helpers for writing. use quick_xml::Reader; +use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; use crate::IoError; @@ -124,7 +125,7 @@ fn start_el(e: &BytesStart<'_>) -> El { if key == b"xmlns" || key.starts_with(b"xmlns:") { continue; } - let v = match a.unescape_value() { + let v = match a.normalized_value(XmlVersion::Explicit1_0) { Ok(v) => v.into_owned(), Err(_) => String::from_utf8_lossy(&a.value).into_owned(), }; @@ -203,7 +204,7 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res } Event::Text(t) => { if let Some(top) = stack.last_mut() { - let s = t.xml_content().map_err(|e| IoError::Xml(e.to_string()))?; + let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?; top.text.push_str(&s); } } From fae90c8e4aece9de93547c22c2a8364a7b6e11eb Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:37:50 +0000 Subject: [PATCH 013/102] selection.move: clamp extreme dr/dc to the sheet edge instead of overflowing --- crates/core/src/addr.rs | 23 +++++++++-- crates/engine/src/cmd/edit.rs | 6 +-- .../engine/tests/extreme_selection_moves.rs | 40 +++++++++++++++++++ 3 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 crates/engine/tests/extreme_selection_moves.rs diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 8a649105e..2bdfe797a 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -30,14 +30,14 @@ impl CellRef { } /// Moved by `(dr, dc)`, `None` when the result leaves the sheet. pub fn offset(&self, dr: i64, dc: i64) -> Option { - let r = self.row as i64 + dr; - let c = self.col as i64 + dc; + let r = (self.row as i64).checked_add(dr)?; + let c = (self.col as i64).checked_add(dc)?; if (0..MAX_ROWS as i64).contains(&r) && (0..MAX_COLS as i64).contains(&c) { Some(CellRef::new(r as u32, c as u32)) } else { None } } /// Moved by `(dr, dc)` and clamped to the sheet. pub fn offset_clamped(&self, dr: i64, dc: i64) -> CellRef { - let r = (self.row as i64 + dr).clamp(0, MAX_ROWS as i64 - 1); - let c = (self.col as i64 + dc).clamp(0, MAX_COLS as i64 - 1); + let r = (self.row as i64).saturating_add(dr).clamp(0, MAX_ROWS as i64 - 1); + let c = (self.col as i64).saturating_add(dc).clamp(0, MAX_COLS as i64 - 1); CellRef::new(r as u32, c as u32) } /// Parses `A1`, `$A$1`, `xfd1048576` (case-insensitive). `None` if malformed or out of range. @@ -272,6 +272,21 @@ mod tests { assert_eq!(a.iter().count(), 9); } + #[test] + fn extreme_offsets() { + let cell = CellRef::new(1, 1); + for delta in [i64::MIN, i64::MAX] { + assert_eq!(cell.offset(delta, 0), None); + assert_eq!(cell.offset(0, delta), None); + assert_eq!(cell.offset(delta, delta), None); + } + assert_eq!(cell.offset_clamped(i64::MAX, i64::MAX), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1)); + assert_eq!(cell.offset_clamped(i64::MIN, i64::MIN), CellRef::new(0, 0)); + assert_eq!(cell.offset_clamped(i64::MAX, i64::MIN), CellRef::new(MAX_ROWS - 1, 0)); + assert_eq!(cell.offset_clamped(i64::MIN, i64::MAX), CellRef::new(0, MAX_COLS - 1)); + assert_eq!(cell.offset(2, -1), Some(CellRef::new(3, 0))); + } + #[test] fn offsets() { assert_eq!(CellRef::new(0, 0).offset(-1, 0), None); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb54fe03c..2dba66fa0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,6 +1,6 @@ //! Cell entry, selection, undo/redo, clipboard, clear, fill, find & replace. -use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; +use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{Cell, Formula, StyleId}; use serde_json::{Value as Json, json}; @@ -408,9 +408,9 @@ fn selection_move(s: &mut Session, p: &Json) -> Result { if !extend && let Some(m) = sheet.merge_at(from) { // Leaving a merged cell moves from its edge. if dr > 0 { - t = CellRef::new((m.end.row + dr as u32).min(MAX_ROWS - 1), from.col); + t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col); } else if dc > 0 { - t = CellRef::new(from.row, (m.end.col + dc as u32).min(MAX_COLS - 1)); + t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col); } } t diff --git a/crates/engine/tests/extreme_selection_moves.rs b/crates/engine/tests/extreme_selection_moves.rs new file mode 100644 index 000000000..f6f5737b5 --- /dev/null +++ b/crates/engine/tests/extreme_selection_moves.rs @@ -0,0 +1,40 @@ +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS}; +use gridcraft_engine::Session; +use serde_json::json; + +#[test] +fn selection_move_clamps_extreme_displacements() { + let mut session = Session::new(); + session.new_workbook(); + for (dr, dc, expected) in [ + (i64::MAX, 0, CellRef::new(MAX_ROWS - 1, 1)), + (0, i64::MAX, CellRef::new(1, MAX_COLS - 1)), + (i64::MAX, i64::MAX, CellRef::new(MAX_ROWS - 1, MAX_COLS - 1)), + (i64::MIN, i64::MIN, CellRef::new(0, 0)), + (i64::MAX, i64::MIN, CellRef::new(MAX_ROWS - 1, 0)), + (i64::MIN, i64::MAX, CellRef::new(0, MAX_COLS - 1)), + (2, -1, CellRef::new(3, 0)), + ] { + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + session.execute("selection.move", json!({"dr": dr, "dc": dc})).unwrap(); + assert_eq!(session.doc().unwrap().selection.active, expected); + } +} + +#[test] +fn selection_move_from_a_merged_cell_clamps_extreme_displacements() { + let mut session = Session::new(); + session.new_workbook(); + session.execute("selection.set", json!({"range": "B2:C3"})).unwrap(); + session.execute("home.mergeCells", json!({})).unwrap(); + for (dr, dc, expected) in [ + (i64::MAX, 0, CellRef::new(MAX_ROWS - 1, 1)), + (0, i64::MAX, CellRef::new(1, MAX_COLS - 1)), + (4_294_967_297, 0, CellRef::new(MAX_ROWS - 1, 1)), + (1, 0, CellRef::new(3, 1)), + ] { + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + session.execute("selection.move", json!({"dr": dr, "dc": dc})).unwrap(); + assert_eq!(session.doc().unwrap().selection.active, expected); + } +} From aee898595f49426ffdcae86708171a3dfbc4a7e8 Mon Sep 17 00:00:00 2001 From: Sebastian Whincop Date: Sun, 11 Oct 2026 16:22:36 +1300 Subject: [PATCH 014/102] Accuracy: round an exact tie at the 16th significant digit toward zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel shows 15 significant digits. When a number is an exact tie at the 16th (its exact decimal expansion is 15 digits, a 5, then only zeros), Excel rounds it toward zero: TEXT(1234567890123455,"0") is 1234567890123450. GridCraft took Rust's `{:.14e}`, which rounds that tie to even, so any tie with an odd 15th digit came out one unit too high (…460). decimal_digits now formats 16 digits (correct unless the 16th is a 5), and only on a 5 checks the exact expansion (a 30-digit screen, then the full one) and truncates a true tie. Non-ties, including values just above or below a tie, round as before. One format serves almost every number. Tests: ties of every 15th-digit parity, negatives, fractional ties, the neighbours just above and below a tie, the carry after fifteen 9s (999999999999999.5 shows 999999999999999), and unchanged ordinary cases. Evidence: Windows Excel 16.0.20430 TEXT() over a 174-row probe and a 24-row carry probe agree on every row except those under a percent format and General over negatives (the two bugs named below). The suite's number-format golden goes from 183,843 to 184,900 of 187,810 passing with no row that passed before failing now. modified: crates/numfmt/src/decimal.rs modified: crates/numfmt/src/tests.rs --- crates/numfmt/src/decimal.rs | 63 +++++++++++++++++++++++++++++--- crates/numfmt/src/tests.rs | 70 ++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/crates/numfmt/src/decimal.rs b/crates/numfmt/src/decimal.rs index 15cfcd35e..67071d4e0 100644 --- a/crates/numfmt/src/decimal.rs +++ b/crates/numfmt/src/decimal.rs @@ -9,17 +9,70 @@ pub(crate) const MAX_DECIMALS: usize = 400; /// The 15 significant digits of a positive finite number and the power of ten of the first /// digit: `a ≈ d0.d1d2… × 10^exp`. +/// +/// The value is rounded to the nearest 15-digit decimal. An exact tie at the 16th digit (the exact +/// decimal expansion is 15 digits, a 5, then only zeros, as for 1234567890123455 or +/// 123456789012345.5) goes toward zero, as Excel does: `1234567890123455` shows `1234567890123450`, +/// not `…460`. Excel observed (Windows 16.0.20430, TEXT()). pub(crate) fn decimal_digits(a: f64) -> ([u8; 15], i32) { let mut digits = [0u8; 15]; if !a.is_finite() || a <= 0.0 { return (digits, 0); } - let s = format!("{:.14e}", a); - let (mant, exp) = s.split_once('e').unwrap_or((s.as_str(), "0")); - for (slot, b) in digits.iter_mut().zip(mant.bytes().filter(u8::is_ascii_digit)) { - *slot = b - b'0'; + // 16 digits, rounded. Unless the 16th is a 5 it decides the 15th exactly (4 or less rounds + // down, 6 or more up), so one format serves almost every number. + let (d16, exp) = significant_digits(a, 16); + for (slot, d) in digits.iter_mut().zip(&d16) { + *slot = *d; } - (digits, exp.parse().unwrap_or(0)) + match d16.get(15).copied().unwrap_or(0) { + 0..=4 => (digits, exp), + 5 if is_exact_tie(a) => (digits, exp), + 5 => { + // Not a tie: round the exact value (the 16-digit rounding above may be a double rounding). + let (d15, exp15) = significant_digits(a, 15); + let mut out = [0u8; 15]; + for (slot, d) in out.iter_mut().zip(&d15) { + *slot = *d; + } + (out, exp15) + } + _ => { + let mut exp = exp; + let mut carry = true; + for d in digits.iter_mut().rev() { + if *d == 9 { + *d = 0; + } else { + *d += 1; + carry = false; + break; + } + } + if carry { + digits = [0; 15]; + digits[0] = 1; + exp += 1; + } + (digits, exp) + } + } +} + +/// `n` significant digits of `a` (rounded half to even on the exact binary value) and the power of +/// ten of the first. +fn significant_digits(a: f64, n: usize) -> (Vec, i32) { + let s = format!("{:.*e}", n.saturating_sub(1), a); + let (mant, exp) = s.split_once('e').unwrap_or((s.as_str(), "0")); + (mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(), exp.parse().unwrap_or(0)) +} + +/// True when `a`'s exact decimal expansion has a 5 as its 16th significant digit and only zeros after. +fn is_exact_tie(a: f64) -> bool { + let is_tie = |ds: &[u8]| ds.get(15) == Some(&5) && ds.get(16..).is_some_and(|t| t.iter().all(|&d| d == 0)); + // A cheap screen first (30 digits), then the exact expansion: a double has at most 767 + // significant digits and Rust prints every one of them. + is_tie(&significant_digits(a, 30).0) && is_tie(&significant_digits(a, 800).0) } /// Rounds the number `digits × 10^exp` (see [`decimal_digits`]) to `decimals` places, half away diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index ea40753a5..0742b2649 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -421,3 +421,73 @@ fn fuzz_no_panic() { } } } + +// ---- 15 significant digits: an exact tie at the 16th digit goes toward zero ---- +// Windows Excel 16.0.20430 TEXT() over the exact doubles below. An odd 15th digit is where +// round-half-even and toward-zero differ; an even one is where they agree. + +#[test] +fn an_exact_tie_at_the_sixteenth_digit_goes_toward_zero() { + for (v, whole) in [ + (1234567890123405.0, "1234567890123400"), + (1234567890123415.0, "1234567890123410"), + (1234567890123425.0, "1234567890123420"), + (1234567890123435.0, "1234567890123430"), + (1234567890123455.0, "1234567890123450"), + (1234567890123475.0, "1234567890123470"), + (1234567890123495.0, "1234567890123490"), + (6631679258820775.0, "6631679258820770"), + (8052991635850695.0, "8052991635850690"), + ] { + assert_eq!(f("0", v), whole, "0 over {v}"); + assert_eq!(f("0.00", v), format!("{whole}.00"), "0.00 over {v}"); + assert_eq!(f("0", -v), format!("-{whole}"), "0 over -{v}"); + } + assert_eq!(f("#,##0", 1234567890123455.0), "1,234,567,890,123,450"); + assert_eq!(f("#,##0", -3267527853107975.0), "-3,267,527,853,107,970"); + // A fraction is a tie as well, when it is exact in binary. + assert_eq!(f("0", 123456789012345.5), "123456789012345"); + assert_eq!(f("0.00", 123456789012345.5), "123456789012345.00"); + // Excel observed: no carry after fifteen 9s. + assert_eq!(f("0", 999999999999999.5), "999999999999999"); + assert_eq!(f("0", -999999999999999.5), "-999999999999999"); + assert_eq!(f("0", 4999999999999995.0), "4999999999999990"); + assert_eq!(f("#,##0", -4999999999999995.0), "-4,999,999,999,999,990"); +} + +#[test] +fn only_exact_ties_go_toward_zero() { + // Just above and just below a tie round to the nearer 15-digit value (Excel observed). + assert_eq!(f("0", 1234567890123455.0 + 0.25), "1234567890123460"); + assert_eq!(f("0", 1234567890123455.5), "1234567890123460"); + assert_eq!(f("0", 1234567890123455.0 - 0.25), "1234567890123450"); + // 16 digits that are not a tie, and the ordinary decimal cases, are untouched. + assert_eq!(f("0", 1234567890123456.0), "1234567890123460"); + assert_eq!(f("0", 1234567890123454.0), "1234567890123450"); + assert_eq!(f("0.00", 2.675), "2.68"); + assert_eq!(f("0.00", 1.005), "1.01"); + assert_eq!(f("0.0", 0.285), "0.3"); + assert_eq!(f("0.0", 99999999999999.95), "100000000000000.0"); + // Ten digits and a half are not a 16th-digit tie. + assert_eq!(f("0", 0.5), "1"); + assert_eq!(f("0", 2.5), "3"); +} + +#[test] +fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() { + use crate::decimal::decimal_digits; + let (d, e) = decimal_digits(1234567890123455.0); + assert_eq!((d, e), ([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5], 15)); + let (d, e) = decimal_digits(1234567890123456.0); + assert_eq!((d, e), ([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 6], 15)); + let (d, e) = decimal_digits(123456789012345.5); + assert_eq!((d, e), ([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5], 14)); + // Excel observed: a tie after fifteen 9s does not carry (999999999999999.5 shows 999999999999999). + let (d, e) = decimal_digits(999999999999999.5); + assert_eq!((d, e), ([9; 15], 14)); + let (d, e) = decimal_digits(4999999999999995.0); + assert_eq!((d, e), ([4, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9], 15)); + // A 16th digit above 5 carries as usual. + 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)); +} From cbf56f0707f99ae3928be6abd8ed76c760adb567 Mon Sep 17 00:00:00 2001 From: Diego Ospina Date: Sat, 10 Oct 2026 22:36:49 -0500 Subject: [PATCH 015/102] i18n: Spanish interface translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Language::Es was registered and switches formulas to Spanish (SUMA, decimal comma, semicolon arguments), but every interface label still fell back to English. This adds es.rs with all 266 labels in Excel's Spanish wording (Combinar y centrar, Inmovilizar paneles, Análisis de hipótesis, Buscar objetivo, Rastrear precedentes…) and covers Spanish in the i18n tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012JC2QZ1Qc5KQMBswS38GKy --- crates/ui-egui/src/i18n.rs | 8 +- crates/ui-egui/src/i18n/es.rs | 275 +++++++++++++++++++++++++ crates/ui-egui/tests/formula_locale.rs | 2 +- 3 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/i18n/es.rs diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..e44a03295 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; +mod es; mod ko; mod pt; mod ru; @@ -97,7 +98,8 @@ impl Language { /// "Orientation") is keyed `context|label`: English shows only the part after the `|`. pub fn tr(self, text: &str) -> &str { let translated = match self { - Self::En | Self::Es => None, + Self::En => None, + Self::Es => es::translate(text), Self::Ja => JAPANESE.iter().find(|(english, _)| *english == text).map(|(_, translated)| *translated), Self::Zh => zh::translate(text), Self::Ko => ko::translate(text), @@ -434,12 +436,16 @@ mod tests { assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}"); assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}"); assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}"); + assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}"); } assert_eq!(Language::Ja.tr("Data"), "データ"); assert_eq!(Language::Zh.tr("Data"), "数据"); assert_eq!(Language::Ko.tr("Data"), "데이터"); assert_eq!(Language::Ru.tr("Data"), "Данные"); assert_eq!(Language::PtBr.tr("Data"), "Dados"); + assert_eq!(Language::Es.tr("Data"), "Datos"); + assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles"); + assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::PtBr.tr("Date"), "Data"); assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1"); diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs new file mode 100644 index 000000000..fe201cc17 --- /dev/null +++ b/crates/ui-egui/src/i18n/es.rs @@ -0,0 +1,275 @@ +//! Spanish translations for the interface strings in i18n.rs. Labels follow Excel's Spanish +//! wording (sentence case, «Inmovilizar paneles», «Combinar y centrar»); anything absent falls back +//! to English. Formula names and separators are localized separately (`formula_locale`). + +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Pegar", + "Paste Values" => "Pegar valores", + "Paste Formulas" => "Pegar fórmulas", + "Paste Formatting" => "Pegar formato", + "Transpose" => "Transponer", + "Paste Link" => "Pegar vínculo", + "Paste Special…" => "Pegado especial…", + "Cut (⌘X)" => "Cortar (⌘X)", + "Copy (⌘C)" => "Copiar (⌘C)", + "Format Painter (double-click to keep it on)" => "Copiar formato (doble clic para mantenerlo activo)", + "Bold (⌘B)" => "Negrita (⌘B)", + "Italic (⌘I)" => "Cursiva (⌘I)", + "Underline (⌘U)" => "Subrayado (⌘U)", + "Double Underline" => "Subrayado doble", + "Strikethrough" => "Tachado", + "Underline" => "Subrayado", + "Increase Font Size" => "Aumentar tamaño de fuente", + "Decrease Font Size" => "Disminuir tamaño de fuente", + "Wrap Text" => "Ajustar texto", + "Merge & Center" => "Combinar y centrar", + "Merge Cells" => "Combinar celdas", + "Merge Across" => "Combinar horizontalmente", + "Unmerge Cells" => "Separar celdas", + "Fill Color" => "Color de relleno", + "Font Color" => "Color de fuente", + "Borders" => "Bordes", + "Top\nAlign" => "Alinear en la\nparte superior", + "Middle\nAlign" => "Alinear en\nel medio", + "Bottom\nAlign" => "Alinear en la\nparte inferior", + "Left" => "Izquierda", + "Right" => "Derecha", + "Center" => "Centro", + "Horizontal" => "Horizontal", + "Vertical Text" => "Texto vertical", + "Decrease Indent" => "Disminuir sangría", + "Increase Indent" => "Aumentar sangría", + "Orientation" => "Orientación", + "Angle Counterclockwise" => "Ángulo ascendente", + "Angle Clockwise" => "Ángulo descendente", + "Rotate Text Up" => "Girar texto hacia arriba", + "Rotate Text Down" => "Girar texto hacia abajo", + "Accounting Number Format" => "Formato de número de contabilidad", + "Percent Style" => "Estilo porcentual", + "Comma Style" => "Estilo de millares", + "Increase Decimal" => "Aumentar decimales", + "Decrease Decimal" => "Disminuir decimales", + "More Accounting Formats…" => "Más formatos de contabilidad…", + "Conditional\nFormatting" => "Formato\ncondicional", + "Conditional Formatting" => "Formato condicional", + "Format\nas Table" => "Dar formato\ncomo tabla", + "Cell\nStyles" => "Estilos\nde celda", + "Format Cells…" => "Formato de celdas…", + "Format Cell Alignment…" => "Alineación de celdas…", + "Format\nPane" => "Panel\nde formato", + "Format" => "Formato", + "Insert" => "Insertar", + "Insert Cells…" => "Insertar celdas…", + "Delete" => "Eliminar", + "Delete Cells…" => "Eliminar celdas…", + "Insert Sheet Rows" => "Insertar filas de hoja", + "Insert Sheet Columns" => "Insertar columnas de hoja", + "Delete Sheet Rows" => "Eliminar filas de hoja", + "Delete Sheet Columns" => "Eliminar columnas de hoja", + "Row Height…" => "Alto de fila…", + "AutoFit Row Height" => "Autoajustar alto de fila", + "Column Width…" => "Ancho de columna…", + "AutoFit Column Width" => "Autoajustar ancho de columna", + "Default Width…" => "Ancho predeterminado…", + "Hide Rows" => "Ocultar filas", + "Unhide Rows" => "Mostrar filas", + "Hide Columns" => "Ocultar columnas", + "Unhide Columns" => "Mostrar columnas", + "Lock Cell" => "Bloquear celda", + "Clear" => "Borrar", + "Clear All" => "Borrar todo", + "Clear Formats" => "Borrar formatos", + "Clear Contents" => "Borrar contenido", + "Clear Comments and Notes" => "Borrar comentarios y notas", + "Clear Hyperlinks" => "Borrar hipervínculos", + "AutoSum" => "Autosuma", + "AutoSum (⌘⇧T)" => "Autosuma (⌘⇧T)", + "Flash\nFill" => "Relleno\nrápido", + "Flash Fill" => "Relleno rápido", + "Fill" => "Rellenar", + "Clear Print Area" => "Borrar área de impresión", + "Find &\nSelect" => "Buscar y\nseleccionar", + "Find…" => "Buscar…", + "Replace…" => "Reemplazar…", + "Go To…" => "Ir a…", + "Go To Special…" => "Ir a Especial…", + "PivotTable" => "Tabla dinámica", + "Recommended\nCharts" => "Gráficos\nrecomendados", + "Table" => "Tabla", + "Pictures" => "Imágenes", + "Shapes" => "Formas", + "Icons" => "Iconos", + "Text\nBox" => "Cuadro\nde texto", + "Comment" => "Comentario", + "New\nComment" => "Nuevo\ncomentario", + "New Note" => "Nueva nota", + "Show/Hide Note" => "Mostrar u ocultar nota", + "Header &\nFooter" => "Encabezado y\npie de página", + "Text to\nColumns" => "Texto en\ncolumnas", + "Link" => "Vínculo", + "Symbol" => "Símbolo", + "Sparklines" => "Minigráficos", + "Header & Footer" => "Encabezado y pie de página", + "Draw with ink" => "Dibujar con lápiz", + "Eraser" => "Borrador", + "Ink to\nShape" => "Entrada de lápiz\na forma", + "Select\nObjects" => "Seleccionar\nobjetos", + "Themes" => "Temas", + "Margins" => "Márgenes", + "Size" => "Tamaño", + "Print\nArea" => "Área de\nimpresión", + "Set Print Area" => "Establecer área de impresión", + "Breaks" => "Saltos", + "Insert Page Break" => "Insertar salto de página", + "Remove Page Break" => "Quitar salto de página", + "Reset All Page Breaks" => "Restablecer todos los saltos de página", + "Print\nTitles" => "Imprimir\ntítulos", + "Narrow" => "Estrecho", + "Wide" => "Ancho", + "Normal" => "Normal", + "Portrait" => "Vertical", + "Landscape" => "Horizontal", + "Custom Margins…" => "Márgenes personalizados…", + "Insert\nFunction" => "Insertar\nfunción", + "Name\nManager" => "Administrador\nde nombres", + "Define Name" => "Definir nombre", + "Use in Formula" => "Utilizar en la fórmula", + "Create from Selection" => "Crear desde la selección", + "Trace Precedents" => "Rastrear precedentes", + "Trace Dependents" => "Rastrear dependientes", + "Remove Arrows" => "Quitar flechas", + "Show Formulas" => "Mostrar fórmulas", + "Error Checking" => "Comprobación de errores", + "Evaluate Formula" => "Evaluar fórmula", + "Watch Window" => "Ventana Inspección", + "Calculation\nOptions" => "Opciones\nde cálculo", + "Calculate Now" => "Calcular ahora", + "Calculate Sheet" => "Calcular hoja", + "Automatic" => "Automático", + "Automatic Except for Data Tables" => "Automático excepto en las tablas de datos", + "Manual" => "Manual", + "Sort &\nFilter" => "Ordenar\ny filtrar", + "Sort" => "Ordenar", + "Sort A to Z" => "Ordenar de A a Z", + "Sort Z to A" => "Ordenar de Z a A", + "Custom Sort…" => "Orden personalizado…", + "Filter" => "Filtro", + "Reapply" => "Volver a aplicar", + "Get Data\n(Text/CSV)" => "Obtener datos\n(texto/CSV)", + "Remove\nDuplicates" => "Quitar\nduplicados", + "Data\nValidation" => "Validación\nde datos", + "Data Validation" => "Validación de datos", + "Data Validation…" => "Validación de datos…", + "Circle Invalid Data" => "Rodear con un círculo datos no válidos", + "Clear Validation" => "Borrar círculos de validación", + "Group" => "Agrupar", + "Ungroup" => "Desagrupar", + "Subtotal" => "Subtotal", + "What-If\nAnalysis" => "Análisis de\nhipótesis", + "Data Table…" => "Tabla de datos…", + "Scenario Manager…" => "Administrador de escenarios…", + "Goal Seek…" => "Buscar objetivo…", + "Spelling" => "Ortografía", + "Check\nAccessibility" => "Comprobar\naccesibilidad", + "Threaded Comments" => "Comentarios en conversación", + "Show\nComments" => "Mostrar\ncomentarios", + "Protect\nWorkbook" => "Proteger\nlibro", + "Unprotect\nSheet" => "Desproteger\nhoja", + "Protect Sheet…" => "Proteger hoja…", + "Comments" => "Comentarios", + "Notes" => "Notas", + "Page Break\nPreview" => "Vista previa de\nsalto de página", + "Page\nLayout" => "Diseño\nde página", + "Formula Bar" => "Barra de fórmulas", + "Gridlines" => "Líneas de cuadrícula", + "Headings" => "Encabezados", + "Zoom" => "Zoom", + "100%" => "100%", + "Zoom to\nSelection" => "Ampliar\nselección", + "Freeze\nPanes" => "Inmovilizar\npaneles", + "Freeze Panes" => "Inmovilizar paneles", + "Freeze Top Row" => "Inmovilizar fila superior", + "Freeze First Column" => "Inmovilizar primera columna", + "Unfreeze Panes" => "Movilizar paneles", + "Dark Mode" => "Modo oscuro", + "Interface language" => "Idioma de la interfaz", + "Command\nPalette" => "Paleta de\ncomandos", + "Agent\nControl" => "Control\ndel agente", + "Action\nJournal" => "Registro\nde acciones", + "About\nGridCraft" => "Acerca de\nGridCraft", + "Share" => "Compartir", + "Add Chart\nElement" => "Agregar elemento\nde gráfico", + "Change\nChart Type" => "Cambiar tipo\nde gráfico", + "Switch\nRow/Column" => "Cambiar entre\nfilas y columnas", + "Data Labels: None" => "Etiquetas de datos: ninguna", + "Data Labels: Show" => "Etiquetas de datos: mostrar", + "Legend: None" => "Leyenda: ninguna", + "Legend: Bottom" => "Leyenda: abajo", + "Legend: Top" => "Leyenda: arriba", + "Legend: Right" => "Leyenda: derecha", + "Gridlines: None" => "Líneas de cuadrícula: ninguna", + "Gridlines: Show" => "Líneas de cuadrícula: mostrar", + "Chart\nTitle" => "Título\ndel gráfico", + "Delete\nChart" => "Eliminar\ngráfico", + "Convert\nto Range" => "Convertir\nen rango", + "Table\nStyles" => "Estilos\nde tabla", + "Top 10 Items" => "10 superiores", + "Top 10%" => "10% superior", + "Bottom 10 Items" => "10 inferiores", + "Bottom 10%" => "10% inferior", + "Above Average" => "Por encima del promedio", + "Below Average" => "Por debajo del promedio", + "Clear Rules from Entire Sheet" => "Borrar reglas de toda la hoja", + "Clear Rules from Selected Cells" => "Borrar reglas de las celdas seleccionadas", + "Constants" => "Constantes", + "Data Bars" => "Barras de datos", + "Color Scales" => "Escalas de color", + "Icon Sets" => "Conjuntos de iconos", + "Home" => "Inicio", + "Draw" => "Dibujar", + "Page Layout" => "Disposición de página", + "Formulas" => "Fórmulas", + "Data" => "Datos", + "Review" => "Revisar", + "View" => "Vista", + "Automate" => "Automatizar", + "Table Design" => "Diseño de tabla", + "Chart Design" => "Diseño de gráfico", + "Ready" => "Listo", + "Enter" => "Introducir", + "Edit" => "Modificar", + "Point" => "Señalar", + "Calculate" => "Calcular", + "Select destination and press Enter or choose Paste" => "Seleccione el destino y presione Entrar o elija Pegar", + "General" => "General", + "Number" => "Número", + "Currency" => "Moneda", + "Accounting" => "Contabilidad", + "Date" => "Fecha", + "Time" => "Hora", + "Percentage" => "Porcentaje", + "Fraction" => "Fracción", + "Scientific" => "Científica", + "Text" => "Texto", + "Special" => "Especial", + "Custom" => "Personalizada", + "Short Date" => "Fecha corta", + "Long Date" => "Fecha larga", + "More Number Formats…" => "Más formatos de número…", + "Financial" => "Financieras", + "Logical" => "Lógicas", + "Date &\nTime" => "Fecha\ny hora", + "Lookup &\nReference" => "Búsqueda y\nreferencia", + "Math &\nTrig" => "Matemáticas y\ntrigonométricas", + "More\nFunctions" => "Más\nfunciones", + "Page Layout|Orientation" => "Orientación", + "Display theme" => "Tema de la interfaz", + "System" => "Sistema", + "Light" => "Claro", + "Dark" => "Oscuro", + "Sheet Options|View" => "Ver", + "Print" => "Imprimir", + _ => return None, + }) +} diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/formula_locale.rs index a92ab1370..9dcb65d75 100644 --- a/crates/ui-egui/tests/formula_locale.rs +++ b/crates/ui-egui/tests/formula_locale.rs @@ -183,5 +183,5 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() { assert!(!enter(&mut app, "=SUMA(1;2)")); let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); assert_eq!(restored.language, Language::PtBr); - assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientation"); + assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación"); } From 2b1f851d0ae97d12f355a35732e7f6369c5a537a Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:07:16 -0500 Subject: [PATCH 016/102] Conditional formatting: apply every matching rule's format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the first matching rule's format was used, so with a light red fill for >300 and a red border for >500, 510 showed only the border. Excel applies every matching rule; where two rules set the same part (fill, font colour, a border edge…), the rule with the higher priority wins, and Stop If True still ends the list. --- crates/engine/src/cf.rs | 70 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index a1028632e..f7d3623df 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -281,10 +281,12 @@ impl CfCache { }; if let Some(st) = matched_style { any = true; - out.style = Some(match out.style.take() { - None => st.clone(), - Some(prev) => prev, - }); + // Every matching rule applies; where two set the same part, the rule with the + // higher priority (seen first) wins. + match &mut out.style { + None => out.style = Some(st.clone()), + Some(prev) => layer_under(prev, st), + } if cf.stop_if_true { break; } @@ -294,6 +296,39 @@ impl CfCache { } } +/// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts +/// are unset). +fn layer_under(style: &mut Style, lower: &Style) { + if style.fill.pattern == PatternType::None { + style.fill = lower.fill; + } + if style.font.color == Color::Auto { + style.font.color = lower.font.color; + } + style.font.bold |= lower.font.bold; + style.font.italic |= lower.font.italic; + style.font.strike |= lower.font.strike; + if style.font.underline == Underline::None { + style.font.underline = lower.font.underline; + } + let b = &mut style.borders; + for (dst, src) in [ + (&mut b.left, lower.borders.left), + (&mut b.right, lower.borders.right), + (&mut b.top, lower.borders.top), + (&mut b.bottom, lower.borders.bottom), + (&mut b.diag_down, lower.borders.diag_down), + (&mut b.diag_up, lower.borders.diag_up), + ] { + if dst.is_none() { + *dst = src; + } + } + if style.num_fmt.as_str() == "General" { + style.num_fmt = lower.num_fmt.clone(); + } +} + /// Shifts a rule formula written for the top-left cell of the range to `c`. fn relative(f: &str, first: CellRef, c: CellRef) -> String { if first == c { @@ -306,3 +341,30 @@ fn relative(f: &str, first: CellRef, c: CellRef) -> String { Err(_) => f.to_string(), } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn every_matching_rule_applies() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[200], [400], [510]]})).unwrap(); + let rule = |op: &str, value: &str, preset: &str| json!({"range": "A1:A3", "rule": {"type": "cellIs", "operator": op, "value": value, "preset": preset}}); + s.execute("home.conditionalFormat", rule("greaterThan", "300", "lightRedFill")).unwrap(); + s.execute("home.conditionalFormat", rule("greaterThan", "500", "redBorder")).unwrap(); + let d = s.doc().unwrap(); + let mut cache = CfCache::new(); + let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style); + assert!(look("A1").is_none()); + let only_fill = look("A2").unwrap(); + assert_eq!(only_fill.fill.pattern, PatternType::Solid); + assert!(only_fill.borders.top.is_none()); + let both = look("A3").unwrap(); + assert_eq!(both.fill, only_fill.fill); + assert!(!both.borders.top.is_none()); + } +} From 4e7379bd6801b935448183f052109993bc6cea06 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:55:17 -0500 Subject: [PATCH 017/102] Conditional formatting: cell value rules on blank and error cells Excel compares a blank cell as 0 (or empty text) in Cell Value rules, so "less than 5" or "equal to 0" also formats blank cells, and a cell holding an error never matches (the comparison is an error). GridCraft skipped blank cells and matched errors as if they sorted above every number ("greater than 5" formatted #N/A). The No Blanks rule also counted a cell holding only spaces as not blank, while the Blanks rule (like Excel's LEN(TRIM())) counted it as blank, so such a cell matched both. --- crates/engine/src/cf.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index f7d3623df..6640c7ae8 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -144,7 +144,8 @@ impl CfCache { CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less, CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater, }; - (ok && !v.is_empty()).then_some(style.as_ref()) + // As in Excel, a blank cell compares as 0 (or ""), and an error never matches. + (ok && !v.is_error()).then_some(style.as_ref()) } CfRule::Expression { formula, style } => { gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref()) @@ -154,7 +155,7 @@ impl CfCache { CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()), CfRule::EndsWith { text: t, style } => text.to_lowercase().ends_with(&t.to_lowercase()).then_some(style.as_ref()), CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()), - CfRule::NoBlanks { style } => (!v.is_empty()).then_some(style.as_ref()), + CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()), CfRule::Errors { style } => v.is_error().then_some(style.as_ref()), CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()), CfRule::Duplicate { style } => { @@ -367,4 +368,28 @@ mod tests { assert_eq!(both.fill, only_fill.fill); assert!(!both.borders.top.is_none()); } + + #[test] + fn cell_value_rules_on_blanks_and_errors() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[3, "=NA()"], [null, 7], ["=NA()", null], [" ", null], [7, null]]})).unwrap(); + let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r}); + s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "cellIs", "operator": "less", "value": "5", "preset": "lightRedFill"}))) + .unwrap(); + s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "noBlanks", "preset": "redBorder"}))).unwrap(); + s.execute("home.conditionalFormat", rule("B1:B2", json!({"type": "cellIs", "operator": "greater", "value": "5", "preset": "lightRedFill"}))) + .unwrap(); + let d = s.doc().unwrap(); + let mut cache = CfCache::new(); + let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style); + let fill = |st: Option + +
Rp10,000.00Name
2.50 & upx
"#; + let rows = table_cells(excel); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }); + assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None }); + assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column"); + assert_eq!(rows[1][0].text, "2.50 & up"); + assert_eq!(rows[1][2].text, "x"); + + let calc = r#" +
Rp10,000.00
"#; + assert_eq!(table_cells(calc), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]); + + let quoted_gt = r#"
1500
"#; + assert_eq!(table_cells(quoted_gt), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]); + } + + #[test] + fn hostile_markup_does_not_panic() { + for html in [ + "", + "<", + "", + "
a", + "�&", + "", + " + + + From 46873b2826b502d1619b82be49efdb2c3eeddf81 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:47:10 +0200 Subject: [PATCH 068/102] macOS: centre the window buttons in the title bar (#170) AppKit draws the traffic lights centred in its standard 28 pt title-bar band, but GridCraft's title bar was 38 pt tall, so the lights sat above its centre line. On macOS the title bar is now 28 pt with 22 pt controls, and content starts after the lights with the same gap they keep from the top. Other platforms are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/ribbon.rs | 56 ++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..314544830 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -11,12 +11,33 @@ use crate::widgets::{big_button, color_palette, icon_button, small_button, split pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; +/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the +/// traffic lights itself, centred in its standard 28 pt title-bar band. Moving them would take +/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is that band: the lights sit +/// vertically centred in it, with the controls beside them (#170). +const MAC: bool = cfg!(target_os = "macos"); +pub const MAC_TITLE_BAR: f32 = 28.0; +/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the +/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the +/// gap after them from shrinking). +const MAC_LIGHT: f32 = 12.0; +const MAC_LIGHT_PITCH: f32 = 20.0; +const MAC_LIGHTS_LEFT: f32 = 10.0; +/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights +/// keep from the top of the window. +pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + /// Leave room for the macOS traffic lights when the content extends into the title bar. -pub const TITLE_LEFT_PAD: f32 = if cfg!(target_os = "macos") { 76.0 } else { 8.0 }; +pub const TITLE_LEFT_PAD: f32 = if MAC { MAC_CONTENT_LEFT } else { 8.0 }; +const TITLE_BAR_H: f32 = if MAC { MAC_TITLE_BAR } else { 38.0 }; +/// The tallest title-bar controls, smaller on macOS to keep a margin in the shorter bar. +const TITLE_CONTROL_H: f32 = if MAC { 22.0 } else { 26.0 }; +const _: () = assert!(TITLE_CONTROL_H < TITLE_BAR_H, "title-bar controls keep a margin above and below"); pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::top("title_bar").exact_size(38.0).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| { + let ch = TITLE_CONTROL_H; + egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| { let rect = ui.max_rect(); // Dragging the empty title area moves the window. let bg = ui.interact(rect, ui.id().with("drag_title"), Sense::click_and_drag()); @@ -41,19 +62,18 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { app.grid.toggle_autosave(); } ui.add_space(6.0); - if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, 26.0)).clicked() { + if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)).clicked() { app.open_dialog("start", json!({})); } - if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, 26.0)).clicked() { + if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, ch)).clicked() { app.run_or_alert("file.save", json!({})); } let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty()); let can_redo = app.session.active().is_some_and(|d| !d.redo.is_empty()); - if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo (⌘Z)", vec2(26.0, 26.0)).clicked() && can_undo - { + if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo (⌘Z)", vec2(26.0, ch)).clicked() && can_undo { app.run_or_alert("edit.undo", json!({})); } - let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, 26.0)); + let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch)); egui::Popup::menu(&undo_list).show(|ui| { let labels: Vec = app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default(); @@ -66,11 +86,10 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } } }); - if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo (⌘Y)", vec2(26.0, 26.0)).clicked() && can_redo - { + if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo (⌘Y)", vec2(26.0, ch)).clicked() && can_redo { app.run_or_alert("edit.redo", json!({})); } - let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, 26.0)); + let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch)); egui::Popup::menu(&more).show(|ui| { for (label, id) in [ ("New Workbook", "file.new"), @@ -97,7 +116,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); - if icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, 26.0)).clicked() { + if icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, ch)).clicked() { app.open_dialog("commandSearch", json!({})); } let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true); @@ -110,7 +129,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } } }); - if icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, 26.0)).clicked() { + if icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, ch)).clicked() { app.open_dialog("comments", json!({})); } }); @@ -1910,3 +1929,16 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { fn unused(_: &Tokens, _: Icon) { let _ = icons::GREEN; } + +#[cfg(test)] +mod title_bar_tests { + use super::*; + + #[test] + fn mac_title_bar_centres_and_clears_the_traffic_lights() { + // The lights are centred in the bar, and content keeps the same gap after them. + let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; + assert_eq!(MAC_CONTENT_LEFT - lights_end, margin); + } +} From c2d2f22e6a0443aaab53c6c4a812f416acae7243 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:54:30 +0200 Subject: [PATCH 069/102] Data: Forecast Sheet (data.forecastSheet) (#322) New sheet before the active one with the sorted history, a FORECAST.ETS forecast column, FORECAST.ETS.CONFINT lower/upper bounds, an optional FORECAST.ETS.STAT table, an Excel table over the data and a line or column chart. Parameters: timeline/values (default: the selection's first two columns), end or periods, confidence interval, seasonality, missing-point fill, duplicate aggregation, statistics, chart kind. Data ribbon button opens a form dialog; programmatic calls never do. gridcraft-functions exports Timeline to continue a timeline (numeric step or calendar months). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 6 +- crates/engine/src/cmd/forecast.rs | 494 ++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/functions/src/ets.rs | 73 ++++- crates/functions/src/lib.rs | 1 + crates/ui-egui/src/dialogs.rs | 34 ++ crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ko.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/zh.rs | 1 + crates/ui-egui/src/ribbon.rs | 3 + docs/gaps.md | 7 +- docs/parity-checklist.md | 8 +- docs/ui-parity.md | 9 +- 15 files changed, 628 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/cmd/forecast.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6608f1d66..e636acc0a 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-11 · **Change:** minor (worksheet functions 505 / 523) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (worksheet functions 505 / 523; catalog 259 / 290) · **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. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 505 / 523 (96.6%; 496 working, 94.8%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 259 / 290 (89.3%), worksheet functions 505 / 523 (96.6%; 496 working, 94.8%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | | 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | @@ -143,6 +144,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Catalog 259 / 290 (Forecast Sheet); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (FORECAST.ETS family); progress log row | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs new file mode 100644 index 000000000..55ed453c7 --- /dev/null +++ b/crates/engine/src/cmd/forecast.rs @@ -0,0 +1,494 @@ +//! Data › Forecast › Forecast Sheet: a new sheet with the history, a FORECAST.ETS column, the +//! FORECAST.ETS.CONFINT confidence bounds, optional FORECAST.ETS.STAT statistics and a chart. +//! +//! Like Excel, the sheet holds a copy of the timeline and values (sorted by time) and live +//! formulas over that copy, so the forecast follows edits to the copied history. + +use std::sync::Arc; + +use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_functions::Timeline; +use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn}; +use serde_json::{Value as Json, json}; + +use super::*; +use crate::selection::current_region; + +const CMD: &str = "data.forecastSheet"; +/// Most history rows a forecast sheet copies. +const MAX_HISTORY: u64 = 100_000; +/// Most forecast rows. +const MAX_PERIODS: u32 = 10_000; + +pub fn specs() -> Vec { + vec![cmd!( + "data.forecastSheet", + "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), 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 + )] +} + +/// The cells of a one-row or one-column range, at most `MAX_HISTORY + 1`. +fn line_cells(r: RangeRef) -> Result> { + if r.count() > MAX_HISTORY + 1 { + return Err(bad(CMD, format!("at most {MAX_HISTORY} rows of history"))); + } + if r.width() == 1 { + Ok((r.start.row..=r.end.row).map(|row| CellRef::new(row, r.start.col)).collect()) + } else if r.height() == 1 { + Ok((r.start.col..=r.end.col).map(|col| CellRef::new(r.start.row, col)).collect()) + } else { + Err(bad(CMD, "the timeline and the values are each one row or one column")) + } +} + +/// A range parameter on any sheet (whole columns trimmed to the used range). +fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> { + let d = s.doc()?; + let (sh, body) = split_sheet(t); + let si = match sh { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?, + None => d.wb.active_sheet, + }; + let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?; + let r = if r.is_full_cols() || r.is_full_rows() { + match d.wb.sheet(si).and_then(|s| s.used_range()) { + Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)), + None => RangeRef::cell(r.start), + } + } else { + r + }; + Ok((si, r)) +} + +/// (sheet, timeline cells, value cells). +fn source(s: &Session, p: &Json) -> Result<(usize, Vec, Vec)> { + let text = |k: &str| str_param(p, k).map(str::trim).filter(|t| !t.is_empty()); + match (text("timeline"), text("values")) { + (Some(t), Some(v)) => { + let (ts, tr) = range_on(s, t)?; + let (vs, vr) = range_on(s, v)?; + if ts != vs { + return Err(bad(CMD, "the timeline and the values must be on one sheet")); + } + Ok((ts, line_cells(tr)?, line_cells(vr)?)) + } + (None, None) => { + let d = s.doc()?; + let (si, r) = match text("range") { + Some(t) => range_on(s, t)?, + None => { + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let sel = d.selection.current(); + (d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel }) + } + }; + if r.width() < 2 { + return Err(EngineError::Other("Select a timeline column and a values column for the forecast.".into())); + } + let col = |c: u32| RangeRef::new(CellRef::new(r.start.row, c), CellRef::new(r.end.row, c)); + Ok((si, line_cells(col(r.start.col))?, line_cells(col(r.start.col + 1))?)) + } + _ => Err(bad(CMD, "give both `timeline` and `values`, or neither")), + } +} + +/// A number parameter given as a number or as text. +fn number(p: &Json, key: &str) -> Result> { + match p.get(key) { + None | Some(Json::Null) => Ok(None), + Some(Json::Number(n)) => Ok(n.as_f64()), + Some(Json::String(t)) if t.trim().is_empty() => Ok(None), + Some(Json::String(t)) => { + t.trim().trim_end_matches('%').trim().parse::().map(Some).map_err(|_| bad(CMD, format!("`{key}` is not a number"))) + } + Some(_) => Err(bad(CMD, format!("`{key}` is not a number"))), + } +} + +struct Options { + seasonality: u32, + fill: u8, + aggregate: Option, + confidence: Option, + statistics: bool, + column_chart: bool, +} + +fn options(p: &Json) -> Result { + let seasonality = match p.get("seasonality") { + Some(Json::String(t)) if t.trim().is_empty() || t.trim().eq_ignore_ascii_case("auto") => 1, + None | Some(Json::Null) => 1, + _ => match number(p, "seasonality")? { + Some(n) if n == n.trunc() && (0.0..=8760.0).contains(&n) => n as u32, + _ => return Err(bad(CMD, "`seasonality` is \"auto\", 0 (none) or a season length from 2 to 8,760")), + }, + }; + let fill = match str_param(p, "fillMissing").map(|t| t.trim().to_ascii_lowercase()) { + None => 1, + Some(t) if t.is_empty() || t == "interpolate" || t == "interpolation" => 1, + Some(t) if t == "zeros" || t == "zero" => 0, + Some(t) => return Err(bad(CMD, format!("`fillMissing` is interpolate or zeros, not `{t}`"))), + }; + let aggregate = match str_param(p, "aggregate").map(|t| t.trim().to_ascii_lowercase()) { + None => None, + Some(t) => match t.as_str() { + "" | "average" => None, + "count" => Some(2), + "counta" => Some(3), + "max" => Some(4), + "median" => Some(5), + "min" => Some(6), + "sum" => Some(7), + _ => return Err(bad(CMD, format!("unknown `aggregate`: `{t}`"))), + }, + }; + let confidence = if bool_param(p, "confidenceInterval").unwrap_or(true) { + let c = number(p, "confidence")?.unwrap_or(0.95); + let c = if c >= 1.0 { c / 100.0 } else { c }; + if !(c > 0.0 && c < 1.0) { + return Err(bad(CMD, "`confidence` is between 0 and 1 (or 0 and 100%)")); + } + Some(c) + } else { + None + }; + let column_chart = match str_param(p, "chart").map(|t| t.trim().to_ascii_lowercase()) { + None => false, + Some(t) if t.is_empty() || t == "line" => false, + Some(t) if t == "column" => true, + Some(t) => return Err(bad(CMD, format!("`chart` is line or column, not `{t}`"))), + }; + Ok(Options { seasonality, fill, aggregate, confidence, statistics: bool_param(p, "statistics").unwrap_or(false), column_chart }) +} + +fn col_letters(c: u32) -> String { + gridcraft_core::col_to_letters(c) +} + +fn forecast_sheet(s: &mut Session, p: &Json) -> Result { + let opts = options(p)?; + let (si, tcells, vcells) = source(s, p)?; + if tcells.len() != vcells.len() { + return Err(bad(CMD, "the timeline and the values need the same number of cells")); + } + let d = s.doc()?; + if d.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } + let sys = d.wb.date_system; + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + // A text first cell is the header. + let header = tcells.first().is_some_and(|c| sh.value(*c).is_text()) || vcells.first().is_some_and(|c| sh.value(*c).is_text()); + let name_of = |c: Option<&CellRef>, fallback: &str| match c.map(|c| sh.value(*c)) { + Some(v) if header && !v.is_empty() => v.display(), + _ => fallback.to_string(), + }; + let (tname, vname) = (name_of(tcells.first(), "Timeline"), name_of(vcells.first(), "Values")); + let skip = usize::from(header); + let mut rows: Vec<(f64, Value)> = Vec::new(); + for (t, v) in tcells.iter().zip(&vcells).skip(skip) { + let (tv, vv) = (sh.value(*t), sh.value(*v)); + match tv { + Value::Number(x) => rows.push((x, if matches!(vv, Value::Number(_)) { vv } else { Value::Empty })), + Value::Empty if vv.is_empty() => {} + _ => return Err(EngineError::Other(format!("The timeline must hold numbers or dates ({} isn't one).", t.a1()))), + } + } + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + let times: Vec = rows.iter().map(|r| r.0).collect(); + let timeline = + Timeline::detect(×, sys).ok_or_else(|| EngineError::Other("The timeline needs at least two points a constant step apart.".into()))?; + 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 periods = match end { + Some(end) => steps_to(&timeline, end)?, + None => match number(p, "periods")? { + Some(n) if n >= 1.0 && n <= f64::from(MAX_PERIODS) => n.ceil() as u32, + Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {MAX_PERIODS}"))), + None => (rows.len() as u32).div_ceil(3).max(1), + }, + }; + let future: Vec = + (1..=periods).map(|k| timeline.after_last(k)).collect::>().ok_or_else(|| bad(CMD, "the forecast runs past the last date"))?; + let fmt_of = |c: Option<&CellRef>| c.map(|c| d.wb.styles.get(sh.style_id(*c)).num_fmt.clone()).unwrap_or_default(); + let (tfmt, vfmt) = (fmt_of(tcells.get(skip)), fmt_of(vcells.get(skip))); + let sheet_name = d.wb.next_sheet_name(); + let table_name = (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()); + let chart_id = d.wb.next_object_id(); + let table_id = chart_id.saturating_add(1); + + // 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 end_row = last + periods; // 1-based row of the last forecast + let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col)); + let mut tail = format!("{},{}", opts.seasonality, opts.fill); + if let Some(a) = opts.aggregate { + tail.push_str(&format!(",{a}")); + } + let mut headers = vec![tname, vname.clone(), format!("Forecast({vname})")]; + if opts.confidence.is_some() { + headers.push(format!("Lower Confidence Bound({vname})")); + headers.push(format!("Upper Confidence Bound({vname})")); + } + // Table column names are unique. + for i in 1..headers.len() { + let base = headers.get(i).cloned().unwrap_or_default(); + let mut name = base.clone(); + let mut k = 2; + while headers.iter().take(i).any(|h| h.eq_ignore_ascii_case(&name)) { + name = format!("{base}{k}"); + k += 1; + } + if let Some(h) = headers.get_mut(i) { + *h = name; + } + } + let width = headers.len() as u32; + let stats_col = width + 1; + let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 }; + + let at = s.doc()?.wb.active_sheet; + let index = edit(s, |cx| { + let at = at.min(cx.wb.sheets.len()); + let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true); + let tstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = tfmt.clone()); + let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone()); + let mut sheet = Sheet::new(sheet_name.clone()); + let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| { + sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style }); + }; + let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| { + let mut cell = Cell::formula(Formula::new(&text)); + cell.style = style; + sheet.set_cell(CellRef::new(row, col), cell); + }; + for (c, h) in headers.iter().enumerate() { + put(&mut sheet, 0, c as u32, Value::text(h.as_str()), StyleId::DEFAULT); + } + for (i, (t, v)) in rows.iter().enumerate() { + let r = i as u32 + 1; + put(&mut sheet, r, 0, Value::Number(*t), tstyle); + if !v.is_empty() { + 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); + if opts.confidence.is_some() { + formula(&mut sheet, n, 3, format!("=C{last}"), vstyle); + formula(&mut sheet, n, 4, format!("=C{last}"), vstyle); + } + let (tl, vl) = (hist(0), hist(1)); + 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); + } + } + if opts.statistics { + put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold); + put(&mut sheet, 0, stats_col + 1, Value::text("Value"), bold); + for (k, label) in ["Alpha", "Beta", "Gamma", "MASE", "SMAPE", "MAE", "RMSE"].iter().enumerate() { + let r = k as u32 + 1; + put(&mut sheet, r, stats_col, Value::text(*label), StyleId::DEFAULT); + formula(&mut sheet, r, stats_col + 1, format!("=FORECAST.ETS.STAT({vl},{tl},{},{tail})", k + 1), StyleId::DEFAULT); + } + } + for c in 0..width { + sheet.cols.insert( + c, + gridcraft_model::LineInfo { + size: Some(match c { + 0 | 1 => 90.0, + 2 => 130.0, + _ => 230.0, + }), + ..Default::default() + }, + ); + } + let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(end_row - 1, width - 1)); + sheet.tables.push(Table { + id: table_id, + name: table_name.clone(), + range, + header_row: true, + totals_row: false, + columns: headers.iter().map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None }).collect(), + style: "TableStyleMedium2".into(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + }); + let q = gridcraft_formula::quote_sheet(&sheet_name); + let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c)); + let series = (1..width) + .map(|c| Series { + name: Some(format!("{q}!${}$1", col_letters(c))), + categories: Some(col_ref(0)), + values: col_ref(c), + bubble_sizes: None, + color: None, + secondary: false, + kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line), + smooth: false, + }) + .collect(); + sheet.charts.push(Chart { + id: chart_id, + kind: if opts.column_chart { ChartKind::ColumnClustered } else { ChartKind::Line }, + anchor: Anchor { cell: CellRef::new(1, chart_col), dx: 0.0, dy: 0.0, width: 480.0, height: 288.0, mode: Default::default() }, + title: None, + series, + legend: LegendPos::Bottom, + data_labels: false, + gridlines: true, + style: 1, + x_title: None, + y_title: None, + source: None, + by_rows: false, + }); + cx.wb.sheets.insert(at, Arc::new(sheet)); + for nm in cx.wb.names.iter_mut() { + if let Some(sc) = nm.scope.as_mut() + && *sc >= at + { + *sc += 1; + } + } + cx.wb.active_sheet = at; + *cx.sel = crate::Selection::at(CellRef::new(0, 0)); + cx.structural = true; + Ok(at) + })?; + 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})) +} + +/// Forecast rows needed to reach `end` (rounded up to whole steps). +fn steps_to(timeline: &Timeline, end: f64) -> Result { + let steps = timeline.steps_after_last(end).ok_or_else(|| bad(CMD, "`end` is not on the timeline"))?; + if steps < 1e-9 { + return Err(EngineError::Other("The forecast end must be after the last point of the timeline.".into())); + } + let steps = steps.ceil(); + if steps > f64::from(MAX_PERIODS) { + return Err(bad(CMD, format!("at most {MAX_PERIODS} forecast steps"))); + } + Ok(steps as u32) +} + +#[cfg(test)] +mod tests { + use gridcraft_core::{CellRef, Value}; + use serde_json::json; + + use crate::Session; + + fn session_with_history() -> Session { + let mut s = Session::new(); + s.new_workbook(); + let mut rows = vec![json!(["Month", "Sales"])]; + // y = 10 + 2t with a four-step season. + let season = [3.0, -1.0, 4.0, -6.0]; + for t in 1..=16 { + rows.push(json!([t, 10.0 + 2.0 * t as f64 + season[(t - 1) % 4]])); + } + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + s + } + + fn value(s: &Session, sheet: usize, a1: &str) -> Value { + s.doc().unwrap().wb.sheet(sheet).unwrap().value(CellRef::parse(a1).unwrap()) + } + + fn formula(s: &Session, sheet: usize, a1: &str) -> String { + let wb = &s.doc().unwrap().wb; + wb.sheet(sheet).unwrap().cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()).unwrap_or_default() + } + + #[test] + fn forecast_sheet_builds_table_formulas_and_chart() { + let mut s = session_with_history(); + s.execute("selection.set", json!({"range": "A1:B17"})).unwrap(); + let r = s.execute("data.forecastSheet", json!({"periods": 4, "statistics": true})).unwrap(); + assert_eq!(r["history"], 16); + assert_eq!(r["periods"], 4); + let wb = &s.doc().unwrap().wb; + // Inserted before the data sheet and activated. + assert_eq!(wb.active_sheet, 0); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.name, r["sheet"].as_str().unwrap()); + assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)")); + assert_eq!(value(&s, 0, "D1"), Value::text("Lower Confidence Bound(Sales)")); + assert_eq!(value(&s, 0, "E1"), Value::text("Upper Confidence Bound(Sales)")); + assert_eq!(formula(&s, 0, "C17"), "B17"); + assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)"); + assert_eq!(formula(&s, 0, "D18"), "C18-FORECAST.ETS.CONFINT(A18,$B$2:$B$17,$A$2:$A$17,0.95,1,1)"); + assert_eq!(formula(&s, 0, "H2"), "FORECAST.ETS.STAT($B$2:$B$17,$A$2:$A$17,1,1,1)"); + // Future timeline and the exact continuation of the seasonal series (t = 17 → 10 + 34 + 3). + assert_eq!(value(&s, 0, "A18"), Value::Number(17.0)); + assert_eq!(value(&s, 0, "A21"), Value::Number(20.0)); + let c18 = value(&s, 0, "C18").as_f64().unwrap(); + assert!((c18 - 47.0).abs() < 1e-6, "{c18}"); + let (lo, hi) = (value(&s, 0, "D19").as_f64().unwrap(), value(&s, 0, "E19").as_f64().unwrap()); + assert!(lo <= hi); + assert_eq!(sh.tables.first().map(|t| t.range.a1()), Some("A1:E21".into())); + let chart = sh.charts.first().unwrap(); + assert_eq!(chart.series.len(), 4); + assert_eq!(chart.series[1].values, format!("{}!$C$2:$C$21", r["sheet"].as_str().unwrap())); + // One undo step removes the sheet again. + let sheets = s.doc().unwrap().wb.sheets.len(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1); + } + + #[test] + fn forecast_sheet_parameters_and_errors() { + let mut s = session_with_history(); + let r = s + .execute( + "data.forecastSheet", + json!({"timeline": "A2:A17", "values": "B2:B17", "end": "22", "confidenceInterval": false, "seasonality": 4, "fillMissing": "zeros", "aggregate": "sum", "chart": "column"}), + ) + .unwrap(); + assert_eq!(r["periods"], 6); + assert_eq!(value(&s, 0, "B1"), Value::text("Values")); + assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,4,0,7)"); + assert_eq!(value(&s, 0, "D1"), Value::Empty); + let sh = s.doc().unwrap().wb.sheet(0).unwrap(); + assert_eq!(sh.charts.first().map(|c| c.kind), Some(gridcraft_model::ChartKind::ColumnClustered)); + // Errors: end before the data, bad seasonality, uneven timeline, one column only. + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "end": 3})).is_err()); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "seasonality": 9000})).is_err()); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!B1:B17"})).is_err()); + s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap(); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c4a290e47..d411f0b16 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -6,6 +6,7 @@ pub mod draw; pub mod edit; pub mod extra; pub mod file; +pub mod forecast; pub mod format; pub mod formulas; pub mod insert; @@ -120,6 +121,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(spelling::specs()); v.extend(pivot::specs()); v.extend(whatif::specs()); + v.extend(forecast::specs()); v.extend(print::specs()); v }) diff --git a/crates/functions/src/ets.rs b/crates/functions/src/ets.rs index 4649f4144..617b171ac 100644 --- a/crates/functions/src/ets.rs +++ b/crates/functions/src/ets.rs @@ -25,7 +25,7 @@ //! Prediction intervals use the model's h-step forecast variance (the book's Table 6.1, class 1): //! `σ²·(1 + Σ_{j=1}^{h−1} (α + αβ·j + γ·[j mod m = 0])²)`, σ² = SSE / n, with the normal quantile. -use gridcraft_core::date::{datetime_from_serial, days_in_month_in}; +use gridcraft_core::date::{datetime_from_serial, days_in_month_in, serial_from_ymd}; use gridcraft_core::{CellError, DateSystem, Value}; use crate::special::norm_inv; @@ -391,6 +391,62 @@ fn interpolate(slots: &[Option]) -> Vec { out } +/// A timeline's step, for continuing it past its last point (the Forecast Sheet's future rows). +#[derive(Clone, Copy, Debug)] +pub struct Timeline { + axis: Axis, + /// Position of the last time stamp. + last: i64, +} + +impl Timeline { + /// The step of a set of time stamps (any order, duplicates allowed): a constant numeric step + /// (missing points allowed) or, for whole-day dates on one day of the month, calendar months. + /// `None` without at least two distinct, finite time stamps or without a constant step. + pub fn detect(times: &[f64], sys: DateSystem) -> Option { + if times.iter().any(|t| !t.is_finite()) { + return None; + } + let mut sorted = times.to_vec(); + sorted.sort_by(|a, b| a.total_cmp(b)); + sorted.dedup(); + let axis = detect_axis(&sorted, sys).ok()?; + let last = axis.position(*sorted.last()?)?.round(); + // Like the functions, at most 30% of the points may be missing. + if !(0.0..MAX_POINTS as f64).contains(&last) || (last + 1.0 - sorted.len() as f64) > MAX_MISSING * (last + 1.0) { + return None; + } + Some(Timeline { axis, last: last as i64 }) + } + + /// Steps from the last time stamp to `t` (fractional between steps, negative before it). + pub fn steps_after_last(&self, t: f64) -> Option { + let p = self.axis.position(t)? - self.last as f64; + p.is_finite().then_some(p) + } + + /// The time stamp `k` steps after the last one. + pub fn after_last(&self, k: u32) -> Option { + let pos = self.last.checked_add(i64::from(k))?; + match self.axis { + Axis::Linear { t0, step } => { + let t = t0 + pos as f64 * step; + t.is_finite().then_some(t) + } + Axis::Months { sys, month0, step, day } => { + let month = month0.checked_add(pos.checked_mul(step)?)?; + let (y, m) = (month.div_euclid(12), month.rem_euclid(12) + 1); + let dim = i64::from(days_in_month_in(sys, i32::try_from(y).ok()?, u32::try_from(m).ok()?)); + let d = day.map_or(dim, |d| i64::from(d).min(dim)); + if y < 1900 { + return None; + } + serial_from_ymd(sys, y, m, d) + } + } + } +} + // --------------------------------------------------------------------------------------------- // Seasonality detection // --------------------------------------------------------------------------------------------- @@ -1002,4 +1058,19 @@ mod tests { is_err(ets(vec![n(1e15), range(&[1.0, 2.0, 3.0]), range(&[0.0, 1.0, 1e12])]), CellError::Num); is_err(ets(vec![n(1e308), range(&ys), range(&ts)]), CellError::Num); } + + #[test] + fn timelines_continue_past_their_last_point() { + let tl = Timeline::detect(&[3.0, 1.0, 2.0, 2.0, 5.0], DateSystem::D1900).expect("numeric step"); + assert_eq!(tl.after_last(1), Some(6.0)); + assert_eq!(tl.steps_after_last(8.5), Some(3.5)); + // Month ends: 2024-01-31 (45322), 2024-02-29 (45351), 2024-03-31 (45382). + let tl = Timeline::detect(&[45322.0, 45351.0, 45382.0], DateSystem::D1900).expect("months"); + assert_eq!(tl.after_last(1), Some(45412.0)); // 2024-04-30 + assert_eq!(tl.steps_after_last(45412.0), Some(1.0)); + assert!(Timeline::detect(&[1.0, 2.0, 4.5], DateSystem::D1900).is_none()); + assert!(Timeline::detect(&[1.0, 2.0, 9.0], DateSystem::D1900).is_none(), "too many missing points"); + assert!(Timeline::detect(&[1.0], DateSystem::D1900).is_none()); + assert!(Timeline::detect(&[1.0, f64::NAN], DateSystem::D1900).is_none()); + } } diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index c2b904bb2..0d74e6a49 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -76,6 +76,7 @@ pub(crate) mod stat; pub(crate) mod text; pub(crate) mod util; +pub use ets::Timeline; pub use stat::{aggregate_values, aggregate_values_k}; /// An evaluated argument. Ranges arrive as `Value::Array` (blank cells = `Value::Empty`) with diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb10..ee5c44b4b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -317,6 +317,40 @@ impl Dialog { json!({"header": true}), ), "goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})), + "forecastSheet" => Dialog::form( + "forecastSheet", + "Create Forecast Worksheet", + "data.forecastSheet", + vec![ + 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):" }, + 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):" }, + Choice { + key: "fillMissing", + label: "Fill missing points using", + options: vec![("interpolate", "Interpolation"), ("zeros", "Zeros")], + }, + Choice { + key: "aggregate", + label: "Aggregate duplicates using", + options: vec![ + ("average", "Average"), + ("count", "Count"), + ("counta", "CountA"), + ("max", "Max"), + ("median", "Median"), + ("min", "Min"), + ("sum", "Sum"), + ], + }, + Check { key: "statistics", label: "Include forecast statistics" }, + Choice { key: "chart", label: "Chart", options: vec![("line", "Line chart"), ("column", "Column chart")] }, + ], + json!({"confidenceInterval": true, "confidence": "95", "seasonality": "auto", "fillMissing": "interpolate", "aggregate": "average", "chart": "line"}), + ), "subtotal" => Dialog::form( "subtotal", "Subtotal", diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..7574d3dee 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -317,6 +317,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Data Table…", "データ テーブル…"), ("Scenario Manager…", "シナリオ マネージャー…"), ("Goal Seek…", "ゴール シーク…"), + ("Forecast\nSheet", "予測シート"), ("Spelling", "スペルチェック"), ("Check\nAccessibility", "アクセシビリティ チェック"), ("Threaded Comments", "スレッド化されたコメント"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3cab..c81c964ee 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -168,6 +168,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "데이터 표…", "Scenario Manager…" => "시나리오 관리자…", "Goal Seek…" => "목표값 찾기…", + "Forecast\nSheet" => "예측 시트", "Spelling" => "맞춤법 검사", "Check\nAccessibility" => "접근성\n검사", "Threaded Comments" => "스레드 댓글", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3f2..41b0cf116 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -169,6 +169,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "Tabela de Dados…", "Scenario Manager…" => "Gerenciador de Cenários…", "Goal Seek…" => "Buscar Objetivo…", + "Forecast\nSheet" => "Planilha de\nPrevisão", "Spelling" => "Ortografia", "Check\nAccessibility" => "Verificar\nAcessibilidade", "Threaded Comments" => "Comentários Encadeados", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d5485658..855c84b62 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -168,6 +168,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "Таблица данных…", "Scenario Manager…" => "Диспетчер сценариев…", "Goal Seek…" => "Подбор параметра…", + "Forecast\nSheet" => "Лист\nпрогноза", "Spelling" => "Орфография", "Check\nAccessibility" => "Проверка\nдоступности", "Threaded Comments" => "Комментарии с ветками", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada73..8cd112e5d 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -168,6 +168,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "数据表…", "Scenario Manager…" => "方案管理器…", "Goal Seek…" => "单变量求解…", + "Forecast\nSheet" => "预测工作表", "Spelling" => "拼写检查", "Check\nAccessibility" => "检查\n辅助功能", "Threaded Comments" => "线程式批注", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..2eb947785 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1568,6 +1568,9 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { ], ); }); + if big_button(ui, Icon::ChartLine, "Forecast\nSheet", "Forecast Sheet", false).clicked() { + app.open_dialog("forecastSheet", json!({})); + } sep(ui); ui.vertical(|ui| { if small_button(ui, Icon::Group, "Group", "Group", false).clicked() { diff --git a/docs/gaps.md b/docs/gaps.md index a033d0bb1..2265ded1b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§13, §15: FORECAST.ETS family landed) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§13: FORECAST.ETS family and Forecast Sheet landed; §15) · **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 @@ -98,8 +98,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h. ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) -- `data.solver`, `data.analysisToolPak`, `data.forecastSheet` missing (the FORECAST.ETS family the - Forecast Sheet builds on is in). **Estimate:** 18–30 h. +- `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. ### 14. Page Layout view and print polish (U, F) - No paper view with rulers and in-place header/footer editing; web print exports only (#141, PR @@ -149,6 +149,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §13: Forecast Sheet landed (#322) | | 2026-10-11 | minor | §13, §15: FORECAST.ETS family landed (#314) | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e98835d31..9819de910 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 258 / 290 commands implemented (89.0%).** +**Overall: 259 / 290 commands implemented (89.3%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -14,7 +14,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | -| Data | 20 | 28 | 71% | +| Data | 21 | 28 | 75% | | Review | 9 | 18 | 50% | | View | 18 | 20 | 90% | | Automate | 3 | 3 | 100% | @@ -240,7 +240,7 @@ Implemented: - `formulas.calculateNow` Calculate Now (Calculation) - `formulas.calculateSheet` Calculate Sheet (Calculation) -## Data (20 / 28, 71%) +## Data (21 / 28, 75%) Implemented: @@ -259,6 +259,7 @@ Implemented: - `data.scenarioManager` Scenario Manager (Forecast) - `data.goalSeek` Goal Seek (Forecast) - `data.dataTable` Data Table (Forecast) +- `data.forecastSheet` Forecast Sheet (Forecast) - `data.group` Group (Outline) - `data.ungroup` Ungroup (Outline) - `data.subtotal` Subtotal (Outline) @@ -271,7 +272,6 @@ Missing: - `data.stocks` Stocks (Data Types) - `data.geography` Geography (Data Types) - `data.relationships` Relationships (Data Tools) -- `data.forecastSheet` Forecast Sheet (Forecast) - `data.analyzeData` Analyze Data (Analysis) - `data.solver` Solver (Analysis) - `data.analysisToolPak` Data Analysis (Analysis) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..03a75cd8a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,10 +1,10 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Forecast Sheet dialog) · **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 -separately in [parity-checklist.md](parity-checklist.md) (258 / 290). +separately in [parity-checklist.md](parity-checklist.md) (259 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes @@ -65,8 +65,8 @@ shortcut lists for Windows and Mac, then fill the gaps: 6–10 h. Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal -Seek, Scenario Manager and others exist. Missing or partial: Options, Solver, Data Analysis, -Forecast Sheet, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, +Seek, Scenario Manager, Forecast Sheet and others exist. Missing or partial: Options, Solver, Data Analysis, +Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. ## Accessibility @@ -91,5 +91,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Forecast Sheet dialog (#322); catalog 259 / 290 | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From dc75b2cf5ef72b7dbd409188c6545a73ac41b737 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:56:36 +0200 Subject: [PATCH 070/102] Data: Solver with Simplex LP, integer branch and bound and a nonlinear method (#326) data.solver maximises, minimises or targets a value in an objective cell by changing variable cells under <=, >=, =, int and bin constraints. Simplex LP linearises the model through recalculation, checks linearity and solves it with a two-phase simplex; integers use branch and bound. The GRG Nonlinear choice runs an augmented-Lagrangian projected-gradient method. The model is saved as hidden solver_* names; Solver Parameters and Solver Results dialogs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/solver.rs | 1487 +++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 165 ++- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ko.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/zh.rs | 1 + crates/ui-egui/src/ribbon.rs | 4 + crates/ui-egui/tests/interaction.rs | 32 + docs/gaps.md | 6 +- docs/parity-checklist.md | 8 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 14 files changed, 1704 insertions(+), 9 deletions(-) create mode 100644 crates/engine/src/cmd/solver.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c4a290e47..60d192a94 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ pub mod pivot; pub mod print; pub mod review; pub mod sheet; +pub mod solver; pub mod spelling; pub mod view; pub mod whatif; @@ -118,6 +119,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(extra::specs()); v.extend(draw::specs()); v.extend(spelling::specs()); + v.extend(solver::specs()); v.extend(pivot::specs()); v.extend(whatif::specs()); v.extend(print::specs()); diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs new file mode 100644 index 000000000..5759f5421 --- /dev/null +++ b/crates/engine/src/cmd/solver.rs @@ -0,0 +1,1487 @@ +//! Solver (Data › Analysis › Solver): finds values for variable cells that maximise, minimise +//! or hit a value in an objective cell, subject to constraints. +//! +//! The workbook is the model: every evaluation sets the variable cells on a copy of the +//! workbook and recalculates their dependents ([`Trial`]), so any formula works. Two methods: +//! +//! - **Simplex LP**: the model is linearised from `n + 1` evaluations and checked for linearity +//! at one more point; then a dense two-phase simplex (Dantzig's rule, Bland's rule when it +//! stalls) solves it exactly. Integer and binary constraints use depth-first branch and bound. +//! - **GRG Nonlinear** (the name Excel's dialog uses): GridCraft's method is *not* a generalised +//! reduced gradient. It is an augmented-Lagrangian method (Powell–Hestenes–Rockafellar) whose +//! inner problems are solved by projected gradient descent with Barzilai–Borwein steps and an +//! Armijo line search, on central-difference gradients. Variable bounds are kept by +//! projection. It finds a local optimum of a smooth model; integer constraints use a small +//! branch and bound on top. +//! +//! Work is bounded by an evaluation budget (no clock: `Instant` traps on wasm). The model is +//! saved as hidden sheet-scoped names (`solver_opt`, `solver_adj`, `solver_lhs1`…), so it +//! travels with the workbook through XLSX. + +use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_model::{Cell, DefinedName, Workbook}; +use serde_json::{Value as Json, json}; + +use super::whatif::Trial; +use super::*; + +const CMD: &str = "data.solver"; +/// Excel's limit on decision variables. +const MAX_VARS: usize = 200; +/// Most scalar constraints (after expanding ranges). +const MAX_CONSTRAINTS: usize = 1000; +const DEFAULT_EVALS: u64 = 20_000; +const MAX_EVALS: u64 = 200_000; +/// Most simplex pivots × tableau cells per solve. +const LP_WORK: u64 = 2_000_000_000; +const MAX_NODES_LP: usize = 10_000; +const MAX_NODES_NLP: usize = 200; +/// Constraint precision (Excel's default 0.000001). +const PRECISION: f64 = 1e-6; +const EPS: f64 = 1e-9; + +pub fn specs() -> Vec { + vec![cmd!( + "data.solver", + "Solver…", + ["Data", "Analysis"], + None, + "{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex (default grg), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden solver_* names), maxEvaluations?: 20000, integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override)} → {status: optimal|converged|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, evaluations, changed}. No `objective` → {model: the saved model} (the menu opens the dialog).", + has_doc, + run + )] +} + +// ---------------------------------------------------------------- model + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Rel { + Le, + Eq, + Ge, + Int, + Bin, +} + +impl Rel { + fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "<=" | "≤" | "=<" | "le" => Some(Rel::Le), + ">=" | "≥" | "=>" | "ge" => Some(Rel::Ge), + "=" | "==" | "eq" => Some(Rel::Eq), + "int" | "integer" => Some(Rel::Int), + "bin" | "binary" => Some(Rel::Bin), + _ => None, + } + } + fn text(self) -> &'static str { + match self { + Rel::Le => "<=", + Rel::Eq => "=", + Rel::Ge => ">=", + Rel::Int => "int", + Rel::Bin => "bin", + } + } + /// The relation code of the saved model (`solver_relN`), as in Solver's VBA `SolverAdd`. + fn code(self) -> u8 { + match self { + Rel::Le => 1, + Rel::Eq => 2, + Rel::Ge => 3, + Rel::Int => 4, + Rel::Bin => 5, + } + } + fn from_code(c: &str) -> Option { + match c.trim() { + "1" => Some(Rel::Le), + "2" => Some(Rel::Eq), + "3" => Some(Rel::Ge), + "4" => Some(Rel::Int), + "5" => Some(Rel::Bin), + _ => None, + } + } +} + +#[derive(Clone, Debug)] +enum Rhs { + None, + Const(f64), + Cells(Vec), +} + +#[derive(Clone, Debug)] +struct Constraint { + lhs: Vec, + lhs_text: String, + rel: Rel, + rhs: Rhs, + rhs_text: String, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Goal { + Max, + Min, + Value(f64), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Method { + Simplex, + Grg, +} + +#[derive(Clone, Debug)] +struct Model { + sheet: usize, + objective: CellRef, + goal: Goal, + vars: Vec, + vars_text: String, + cons: Vec, + non_negative: bool, + method: Method, +} + +/// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`. +fn cells_of(wb: &Workbook, t: &str, default_sheet: usize, cap: usize) -> Result<(usize, Vec)> { + let mut sheet = None; + let mut out = Vec::new(); + for part in t.split(',').map(str::trim).filter(|x| !x.is_empty()) { + let (sh, body) = split_sheet(part); + let si = match sh { + Some(n) => wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?, + None => default_sheet, + }; + if *sheet.get_or_insert(si) != si { + return Err(bad(CMD, "all cells of a reference must be on one sheet")); + } + let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a reference: `{part}`")))?; + if r.count() > cap as u64 || out.len() + r.count() as usize > cap { + return Err(bad(CMD, format!("too many cells (at most {cap})"))); + } + out.extend(r.iter()); + } + if out.is_empty() { + return Err(bad(CMD, format!("not a reference: `{t}`"))); + } + Ok((sheet.unwrap_or(default_sheet), out)) +} + +fn num_text(t: &str) -> Option { + gridcraft_core::parse::parse_number_text(t.trim()).filter(|v| v.is_finite()) +} + +/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs). +fn split_constraint(t: &str) -> Option<(String, Rel, String)> { + for op in ["<=", ">=", "=<", "=>", "≤", "≥", "=="] { + if let Some((a, b)) = t.split_once(op) { + return Some((a.trim().into(), Rel::parse(op)?, b.trim().into())); + } + } + if let Some((a, b)) = t.split_once('=') { + return Some((a.trim().into(), Rel::Eq, b.trim().into())); + } + let lower = t.trim().to_ascii_lowercase(); + for (suffix, rel) in [("integer", Rel::Int), ("binary", Rel::Bin), ("int", Rel::Int), ("bin", Rel::Bin)] { + if let Some(a) = lower.strip_suffix(suffix) { + let a = a.trim(); + return Some((t.trim().get(..a.len())?.trim().to_string(), rel, String::new())); + } + } + None +} + +fn parse_constraint(wb: &Workbook, sheet: usize, c: &Json) -> Result { + let (lhs_text, rel, rhs_json) = match c { + Json::String(t) => { + let (l, r, rhs) = split_constraint(t).ok_or_else(|| bad(CMD, format!("not a constraint: `{t}`")))?; + (l, r, Json::String(rhs)) + } + Json::Object(_) => { + let lhs = str_param(c, "lhs").or_else(|| str_param(c, "cell")).or_else(|| str_param(c, "range")).unwrap_or("").to_string(); + let op = str_param(c, "op").or_else(|| str_param(c, "relation")).unwrap_or(""); + let rel = Rel::parse(op).ok_or_else(|| bad(CMD, format!("unknown constraint relation `{op}`")))?; + (lhs, rel, c.get("rhs").or_else(|| c.get("value")).cloned().unwrap_or(Json::Null)) + } + _ => return Err(bad(CMD, "a constraint is a string like \"B5 <= 10\" or {lhs, op, rhs}")), + }; + let (lsi, lhs) = cells_of(wb, &lhs_text, sheet, MAX_CONSTRAINTS)?; + if lsi != sheet { + return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into())); + } + let (rhs, rhs_text) = match rel { + Rel::Int | Rel::Bin => (Rhs::None, String::new()), + _ => match &rhs_json { + Json::Number(n) => { + let v = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "the constraint's right side must be a number"))?; + (Rhs::Const(v), v.to_string()) + } + Json::String(t) => { + let t = t.trim().trim_start_matches('=').trim(); + if let Some(v) = num_text(t) { + (Rhs::Const(v), t.to_string()) + } else { + let (rsi, cells) = cells_of(wb, t, sheet, MAX_CONSTRAINTS)?; + if rsi != sheet { + return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into())); + } + if cells.len() != 1 && cells.len() != lhs.len() { + return Err(EngineError::Other("The constraint's two sides must be the same size (or the right side one cell).".into())); + } + (Rhs::Cells(cells), t.to_string()) + } + } + _ => return Err(bad(CMD, "missing the constraint's right side")), + }, + }; + Ok(Constraint { lhs, lhs_text, rel, rhs, rhs_text }) +} + +fn parse_model(s: &Session, p: &Json) -> Result { + let d = s.doc()?; + let wb = &d.wb; + let obj_text = str_param(p, "objective").or_else(|| str_param(p, "setObjective")).or_else(|| str_param(p, "set")).unwrap_or(""); + let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet, 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?; + let objective = *obj.first().ok_or_else(|| EngineError::Other("The objective must be a single cell.".into()))?; + let sh = wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + if sh.cell(objective).is_none_or(|c| c.formula.is_none()) { + return Err(EngineError::Other("The objective cell must contain a formula.".into())); + } + let goal = match str_param(p, "goal").or_else(|| str_param(p, "to")).unwrap_or("max").to_ascii_lowercase().as_str() { + "max" | "maximize" | "maximum" => Goal::Max, + "min" | "minimize" | "minimum" => Goal::Min, + "value" | "valueof" | "value of" => { + let v = f64_param(p, "value") + .or_else(|| str_param(p, "value").and_then(num_text)) + .filter(|v| v.is_finite()) + .ok_or_else(|| bad(CMD, "goal `value` needs a number in `value`"))?; + Goal::Value(v) + } + g => return Err(bad(CMD, format!("unknown goal `{g}` (max, min or value)"))), + }; + let vars_text = str_param(p, "variables").or_else(|| str_param(p, "changing")).or_else(|| str_param(p, "byChanging")).unwrap_or("").to_string(); + let (vsi, mut vars) = cells_of(wb, &vars_text, sheet, MAX_VARS).map_err(|e| match e { + EngineError::BadParams { msg, .. } if msg.starts_with("too many") => { + EngineError::Other(format!("Too many variable cells (at most {MAX_VARS}).")) + } + _ => EngineError::Other("The variable cells must be a reference like B2:B4.".into()), + })?; + if vsi != sheet { + return Err(EngineError::Other("The variable cells must be on the objective cell's sheet.".into())); + } + let mut seen = std::collections::HashSet::new(); + vars.retain(|c| seen.insert(*c)); + if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some())) { + return Err(EngineError::Other("Variable cells must hold constants, not formulas.".into())); + } + let mut cons = Vec::new(); + let mut scalar = 0usize; + if let Some(list) = p.get("constraints") { + let list = list.as_array().ok_or_else(|| bad(CMD, "`constraints` must be an array"))?; + if list.len() > MAX_CONSTRAINTS { + return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS})."))); + } + for c in list { + let c = parse_constraint(wb, sheet, c)?; + scalar = scalar.saturating_add(c.lhs.len()); + if scalar > MAX_CONSTRAINTS { + return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS} cells)."))); + } + if matches!(c.rel, Rel::Int | Rel::Bin) && c.lhs.iter().any(|x| !vars.contains(x)) { + return Err(EngineError::Other("Integer and binary constraints must be on variable cells.".into())); + } + cons.push(c); + } + } + let non_negative = bool_param(p, "nonNegative").unwrap_or(true); + let method = match str_param(p, "method").unwrap_or("grg").to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "grg" | "grgnonlinear" | "nonlinear" => Method::Grg, + "simplex" | "simplexlp" | "lp" | "linear" => Method::Simplex, + "evolutionary" => return Err(EngineError::Other("The Evolutionary method isn't available; use GRG Nonlinear or Simplex LP.".into())), + m => return Err(bad(CMD, format!("unknown method `{m}` (grg or simplex)"))), + }; + Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method }) +} + +// ---------------------------------------------------------------- saved model + +fn ref_text(sheet_name: &str, cells: &str) -> String { + let q = crate::pivot::quote_sheet(sheet_name); + cells + .split(',') + .map(str::trim) + .filter(|x| !x.is_empty()) + .filter_map(|part| { + let r = RangeRef::parse(split_sheet(part).1.trim())?; + let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1); + Some(if r.is_single() { format!("{q}!{}", abs(r.start)) } else { format!("{q}!{}:{}", abs(r.start), abs(r.end)) }) + }) + .collect::>() + .join(",") +} + +/// The model as hidden names scoped to its sheet (replacing any saved before). +fn save_model(wb: &mut Workbook, m: &Model) { + let Some(sheet_name) = wb.sheet(m.sheet).map(|s| s.name.clone()) else { return }; + wb.names.retain(|n| !(n.scope == Some(m.sheet) && n.name.to_ascii_lowercase().starts_with("solver_"))); + let mut put = |name: String, formula: String| { + wb.names.push(DefinedName { name, scope: Some(m.sheet), formula, comment: String::new(), hidden: true }); + }; + put("solver_opt".into(), ref_text(&sheet_name, &m.objective.a1())); + let (typ, val) = match m.goal { + Goal::Max => (1, 0.0), + Goal::Min => (2, 0.0), + Goal::Value(v) => (3, v), + }; + put("solver_typ".into(), typ.to_string()); + put("solver_val".into(), val.to_string()); + put("solver_adj".into(), ref_text(&sheet_name, &m.vars_text)); + for (i, c) in m.cons.iter().enumerate() { + let k = i + 1; + put(format!("solver_lhs{k}"), ref_text(&sheet_name, &c.lhs_text)); + put(format!("solver_rel{k}"), c.rel.code().to_string()); + let rhs = match &c.rhs { + Rhs::None => { + if c.rel == Rel::Bin { + "\"binary\"".to_string() + } else { + "\"integer\"".to_string() + } + } + Rhs::Const(v) => v.to_string(), + Rhs::Cells(_) => ref_text(&sheet_name, &c.rhs_text), + }; + put(format!("solver_rhs{k}"), rhs); + } + put("solver_num".into(), m.cons.len().to_string()); + put("solver_neg".into(), if m.non_negative { "1" } else { "2" }.into()); + put("solver_eng".into(), if m.method == Method::Simplex { "2" } else { "1" }.into()); + put("solver_ver".into(), "3".into()); +} + +/// The saved model of a sheet as `data.solver` params, if any. +pub fn saved_model(wb: &Workbook, sheet: usize) -> Option { + let get = |n: &str| { + wb.names + .iter() + .find(|d| d.name.eq_ignore_ascii_case(n) && (d.scope == Some(sheet) || d.scope.is_none())) + .map(|d| d.formula.trim().trim_start_matches('=').to_string()) + }; + let local = |t: String| -> String { t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::>().join(",") }; + let objective = local(get("solver_opt")?); + if objective.is_empty() { + return None; + } + let goal = match get("solver_typ").as_deref().map(str::trim) { + Some("2") => "min", + Some("3") => "value", + _ => "max", + }; + let value = get("solver_val").and_then(|v| num_text(&v)).unwrap_or(0.0); + let count = get("solver_num").and_then(|v| num_text(&v)).unwrap_or(0.0).clamp(0.0, MAX_CONSTRAINTS as f64) as usize; + let mut constraints = Vec::new(); + for k in 1..=count { + let (Some(lhs), Some(rel)) = (get(&format!("solver_lhs{k}")), get(&format!("solver_rel{k}")).and_then(|r| Rel::from_code(&r))) else { + continue; + }; + let lhs = local(lhs); + constraints.push(match rel { + Rel::Int | Rel::Bin => json!(format!("{lhs} {}", rel.text())), + _ => { + let rhs = get(&format!("solver_rhs{k}")).unwrap_or_default(); + let rhs = if num_text(&rhs).is_some() { rhs } else { local(rhs) }; + json!(format!("{lhs} {} {rhs}", rel.text())) + } + }); + } + Some(json!({ + "objective": objective, + "goal": goal, + "value": value, + "variables": get("solver_adj").map(local).unwrap_or_default(), + "constraints": constraints, + "nonNegative": get("solver_neg").as_deref().map(str::trim) != Some("2"), + "method": if get("solver_eng").as_deref().map(str::trim) == Some("2") { "simplex" } else { "grg" }, + })) +} + +// ---------------------------------------------------------------- evaluation + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Sense { + Le, + Ge, + Eq, +} + +/// A scalar constraint `lhs (sense) rhs`. +#[derive(Clone, Debug)] +struct Row { + lhs: CellRef, + sense: Sense, + rhs: Option, + rhs_const: f64, +} + +/// The values the solvers see at a point: the objective and `lhs − rhs` per row. +#[derive(Clone, Debug)] +struct Point { + f: f64, + g: Vec, +} + +struct Evaluator { + trial: Trial, + sheet: usize, + vars: Vec, + objective: CellRef, + rows: Vec, + evals: u64, + budget: u64, +} + +fn as_num(v: &Value) -> f64 { + match v { + Value::Number(n) => *n, + Value::Empty => 0.0, + Value::Bool(b) => f64::from(u8::from(*b)), + _ => f64::NAN, + } +} + +impl Evaluator { + fn exhausted(&self) -> bool { + self.evals >= self.budget + } + + /// Evaluates the model at `x`; `None` once the budget is spent. + fn eval(&mut self, x: &[f64]) -> Option { + if self.exhausted() { + return None; + } + self.evals += 1; + let sets: Vec<(usize, CellRef, Value)> = self.vars.iter().zip(x).map(|(c, v)| (self.sheet, *c, Value::number(*v))).collect(); + let mut reads = Vec::with_capacity(1 + 2 * self.rows.len()); + reads.push((self.sheet, self.objective)); + for r in &self.rows { + reads.push((self.sheet, r.lhs)); + if let Some(c) = r.rhs { + reads.push((self.sheet, c)); + } + } + let vals = self.trial.run(&sets, &reads); + let mut it = vals.iter().map(as_num); + let f = it.next().unwrap_or(f64::NAN); + let mut g = Vec::with_capacity(self.rows.len()); + for r in &self.rows { + let l = it.next().unwrap_or(f64::NAN); + let rv = if r.rhs.is_some() { it.next().unwrap_or(f64::NAN) } else { r.rhs_const }; + g.push(l - rv); + } + Some(Point { f, g }) + } +} + +// ---------------------------------------------------------------- problem + +struct Problem { + lo: Vec, + hi: Vec, + integer: Vec, + rows: Vec, +} + +/// Splits the constraints into variable bounds and general rows. +fn build_problem(m: &Model) -> Problem { + let n = m.vars.len(); + let mut lo = vec![f64::NEG_INFINITY; n]; + let mut hi = vec![f64::INFINITY; n]; + let mut has_lower = vec![false; n]; + let mut integer = vec![false; n]; + let mut rows = Vec::new(); + let var_index = |c: &CellRef| m.vars.iter().position(|v| v == c); + for c in &m.cons { + for (k, cell) in c.lhs.iter().enumerate() { + let j = var_index(cell); + match c.rel { + Rel::Int | Rel::Bin => { + if let Some(j) = j { + if let Some(b) = integer.get_mut(j) { + *b = true; + } + if c.rel == Rel::Bin + && let (Some(l), Some(h), Some(hl)) = (lo.get_mut(j), hi.get_mut(j), has_lower.get_mut(j)) + { + *l = l.max(0.0); + *h = h.min(1.0); + *hl = true; + } + } + } + rel => { + let sense = match rel { + Rel::Le => Sense::Le, + Rel::Ge => Sense::Ge, + _ => Sense::Eq, + }; + let (rhs, rhs_const) = match &c.rhs { + Rhs::Const(v) => (None, *v), + Rhs::Cells(cells) => (cells.get(k).or_else(|| cells.first()).copied(), 0.0), + Rhs::None => (None, 0.0), + }; + match (j, rhs) { + // A constant bound on a variable cell is a box bound. + (Some(j), None) => { + if let (Some(l), Some(h), Some(hl)) = (lo.get_mut(j), hi.get_mut(j), has_lower.get_mut(j)) { + if sense != Sense::Ge { + *h = h.min(rhs_const); + } + if sense != Sense::Le { + *l = l.max(rhs_const); + *hl = true; + } + } + } + _ => rows.push(Row { lhs: *cell, sense, rhs, rhs_const }), + } + } + } + } + } + if m.non_negative { + for (l, hl) in lo.iter_mut().zip(&has_lower) { + if !hl { + *l = l.max(0.0); + } + } + } + Problem { lo, hi, integer, rows } +} + +/// What a relaxation (or the whole solve) ended with. +#[derive(Clone, Debug)] +enum Outcome { + /// x, objective to minimise, and whether the solver converged (vs. ran out of budget). + Solved(Vec, f64, bool), + Infeasible, + Unbounded, + Limit, +} + +/// Depth-first branch and bound over a relaxation solver; returns the best integer point. +fn branch_and_bound( + lo: &[f64], + hi: &[f64], + integer: &[bool], + max_nodes: usize, + gap: f64, + relax: &mut dyn FnMut(&[f64], &[f64]) -> Outcome, +) -> (Outcome, usize) { + let mut stack: Vec<(Vec, Vec)> = vec![(lo.to_vec(), hi.to_vec())]; + let mut best: Option<(Vec, f64)> = None; + let mut nodes = 0usize; + let mut limited = false; + let mut root = true; + while let Some((l, h)) = stack.pop() { + if nodes >= max_nodes { + limited = true; + break; + } + nodes += 1; + let out = relax(&l, &h); + let was_root = std::mem::replace(&mut root, false); + let (x, obj, converged) = match out { + Outcome::Solved(x, obj, c) => (x, obj, c), + Outcome::Unbounded if was_root => return (Outcome::Unbounded, nodes), + Outcome::Limit => { + limited = true; + break; + } + _ => continue, + }; + if !converged { + limited = true; + } + if let Some((_, b)) = &best + && obj >= b - gap * b.abs().max(1e-12) - 1e-9 * (1.0 + b.abs()) + { + continue; + } + // The most fractional integer variable. + let frac = x + .iter() + .zip(integer) + .enumerate() + .filter(|(_, (_, int))| **int) + .map(|(j, (v, _))| (j, (v - v.round()).abs())) + .filter(|(_, f)| *f > 1e-6) + .max_by(|a, b| a.1.total_cmp(&b.1)); + match frac { + None => { + let snapped: Vec = x.iter().zip(integer).map(|(v, int)| if *int { v.round() } else { *v }).collect(); + best = Some((snapped, obj)); + } + Some((j, _)) => { + let v = x.get(j).copied().unwrap_or(0.0); + let (mut down_h, mut up_l) = (h.clone(), l.clone()); + if let Some(e) = down_h.get_mut(j) { + *e = e.min(v.floor()); + } + if let Some(e) = up_l.get_mut(j) { + *e = e.max(v.ceil()); + } + // Explore the nearer side first (pushed last). + if v - v.floor() < 0.5 { + stack.push((up_l, h)); + stack.push((l, down_h)); + } else { + stack.push((l, down_h)); + stack.push((up_l, h)); + } + } + } + } + match best { + Some((x, obj)) => (Outcome::Solved(x, obj, !limited), nodes), + None if limited => (Outcome::Limit, nodes), + None => (Outcome::Infeasible, nodes), + } +} + +// ---------------------------------------------------------------- simplex + +/// Pivots the tableau on (r, j), updating the objective row. +fn pivot(t: &mut [Vec], obj: &mut [f64], r: usize, j: usize) { + let Some(prow) = t.get_mut(r) else { return }; + let pv = prow.get(j).copied().unwrap_or(0.0); + if pv == 0.0 { + return; + } + for v in prow.iter_mut() { + *v /= pv; + } + let prow = prow.clone(); + for (i, row) in t.iter_mut().enumerate() { + if i == r { + continue; + } + let f = row.get(j).copied().unwrap_or(0.0); + if f != 0.0 { + for (v, p) in row.iter_mut().zip(&prow) { + *v -= f * p; + } + } + } + let f = obj.get(j).copied().unwrap_or(0.0); + if f != 0.0 { + for (v, p) in obj.iter_mut().zip(&prow) { + *v -= f * p; + } + } +} + +enum Step { + Optimal, + Unbounded, + Limit, +} + +/// Minimises with the reduced costs in `obj` (last entry: −objective). Columns not `allowed` +/// never enter. +fn simplex(t: &mut [Vec], basis: &mut [usize], obj: &mut [f64], allowed: &[bool], budget: &mut u64) -> Step { + let n = obj.len().saturating_sub(1); + let cost = (t.len() as u64 + 1).saturating_mul(n as u64 + 1).max(1); + let mut stalled = 0u32; + loop { + if *budget < cost { + return Step::Limit; + } + *budget -= cost; + let bland = stalled > 50; + let mut enter = None; + let mut best = -EPS; + for (j, d) in obj.iter().take(n).enumerate() { + if !allowed.get(j).copied().unwrap_or(false) || *d >= -EPS { + continue; + } + if bland { + enter = Some(j); + break; + } + if *d < best { + best = *d; + enter = Some(j); + } + } + let Some(j) = enter else { return Step::Optimal }; + let mut leave: Option<(usize, f64)> = None; + for (i, row) in t.iter().enumerate() { + let a = row.get(j).copied().unwrap_or(0.0); + if a <= EPS { + continue; + } + let ratio = row.get(n).copied().unwrap_or(0.0) / a; + let better = match leave { + None => true, + Some((li, lr)) => ratio < lr - 1e-12 || (ratio <= lr + 1e-12 && basis.get(i) < basis.get(li)), + }; + if better { + leave = Some((i, ratio)); + } + } + let Some((r, ratio)) = leave else { return Step::Unbounded }; + stalled = if ratio <= EPS { stalled + 1 } else { 0 }; + pivot(t, obj, r, j); + if let Some(b) = basis.get_mut(r) { + *b = j; + } + } +} + +/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`. +fn solve_lp(c: &[f64], rows: &[(Vec, Sense, f64)], lo: &[f64], hi: &[f64], budget: &mut u64) -> Outcome { + let n = c.len(); + if lo.iter().zip(hi).any(|(l, h)| l > h) { + return Outcome::Infeasible; + } + // x_j = offset + Σ sign·y_k with y ≥ 0. + struct Map { + var: usize, + sign: f64, + } + let mut cols: Vec = Vec::new(); + let mut offset = vec![0.0; n]; + let mut extra: Vec<(Vec<(usize, f64)>, Sense, f64)> = Vec::new(); + for j in 0..n { + let (l, h) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY)); + if l.is_finite() { + if let Some(o) = offset.get_mut(j) { + *o = l; + } + cols.push(Map { var: j, sign: 1.0 }); + if h.is_finite() { + extra.push((vec![(cols.len() - 1, 1.0)], Sense::Le, h - l)); + } + } else if h.is_finite() { + if let Some(o) = offset.get_mut(j) { + *o = h; + } + cols.push(Map { var: j, sign: -1.0 }); + } else { + cols.push(Map { var: j, sign: 1.0 }); + cols.push(Map { var: j, sign: -1.0 }); + } + } + let ny = cols.len(); + // Rows over y: (coefficients, sense, rhs). + let mut yrows: Vec<(Vec, Sense, f64)> = Vec::with_capacity(rows.len() + extra.len()); + for (a, sense, b) in rows { + let mut coef = vec![0.0; ny]; + for (k, m) in cols.iter().enumerate() { + if let Some(v) = coef.get_mut(k) { + *v = a.get(m.var).copied().unwrap_or(0.0) * m.sign; + } + } + let shift: f64 = a.iter().zip(&offset).map(|(x, o)| x * o).sum(); + yrows.push((coef, *sense, b - shift)); + } + for (entries, sense, b) in extra { + let mut coef = vec![0.0; ny]; + for (k, v) in entries { + if let Some(e) = coef.get_mut(k) { + *e = v; + } + } + yrows.push((coef, sense, b)); + } + let m = yrows.len(); + let n_slack = yrows.iter().filter(|r| r.1 != Sense::Eq).count(); + // Rows with b < 0 are negated so every right side is ≥ 0. + let normalized: Vec<(Vec, Sense, f64)> = yrows + .into_iter() + .map(|(a, s, b)| { + if b < 0.0 { + let s = match s { + Sense::Le => Sense::Ge, + Sense::Ge => Sense::Le, + Sense::Eq => Sense::Eq, + }; + (a.iter().map(|v| -v).collect(), s, -b) + } else { + (a, s, b) + } + }) + .collect(); + let n_art = normalized.iter().filter(|r| r.1 != Sense::Le).count(); + let width = ny + n_slack + n_art; + let mut t: Vec> = Vec::with_capacity(m); + let mut basis = vec![0usize; m]; + let (mut si, mut ai) = (ny, ny + n_slack); + for (i, (a, s, b)) in normalized.iter().enumerate() { + let mut row = vec![0.0; width + 1]; + for (v, x) in row.iter_mut().zip(a) { + *v = *x; + } + match s { + Sense::Le => { + if let Some(v) = row.get_mut(si) { + *v = 1.0; + } + if let Some(bi) = basis.get_mut(i) { + *bi = si; + } + si += 1; + } + Sense::Ge => { + if let Some(v) = row.get_mut(si) { + *v = -1.0; + } + si += 1; + if let Some(v) = row.get_mut(ai) { + *v = 1.0; + } + if let Some(bi) = basis.get_mut(i) { + *bi = ai; + } + ai += 1; + } + Sense::Eq => { + if let Some(v) = row.get_mut(ai) { + *v = 1.0; + } + if let Some(bi) = basis.get_mut(i) { + *bi = ai; + } + ai += 1; + } + } + if let Some(v) = row.get_mut(width) { + *v = *b; + } + t.push(row); + } + let is_art = |j: usize| j >= ny + n_slack && j < width; + // Phase 1: minimise the sum of the artificials. + let mut obj = vec![0.0; width + 1]; + for j in (ny + n_slack)..width { + if let Some(v) = obj.get_mut(j) { + *v = 1.0; + } + } + for (i, row) in t.iter().enumerate() { + if basis.get(i).is_some_and(|b| is_art(*b)) { + for (v, x) in obj.iter_mut().zip(row) { + *v -= x; + } + } + } + let all: Vec = vec![true; width]; + if n_art > 0 { + match simplex(&mut t, &mut basis, &mut obj, &all, budget) { + Step::Limit => return Outcome::Limit, + Step::Unbounded | Step::Optimal => {} + } + let scale = normalized.iter().map(|r| r.2.abs()).fold(1.0f64, f64::max); + if -obj.get(width).copied().unwrap_or(0.0) > 1e-7 * scale { + return Outcome::Infeasible; + } + // Drive zero-valued artificials out of the basis where possible. + for i in 0..m { + if !basis.get(i).is_some_and(|b| is_art(*b)) { + continue; + } + let j = t.get(i).and_then(|row| (0..ny + n_slack).find(|&j| row.get(j).is_some_and(|v| v.abs() > EPS))); + if let Some(j) = j { + pivot(&mut t, &mut obj, i, j); + if let Some(b) = basis.get_mut(i) { + *b = j; + } + } + } + } + // Phase 2: the real costs; artificials may not re-enter. + let mut cost = vec![0.0; width + 1]; + for (k, mp) in cols.iter().enumerate() { + if let Some(v) = cost.get_mut(k) { + *v = c.get(mp.var).copied().unwrap_or(0.0) * mp.sign; + } + } + let mut obj = cost.clone(); + for (i, row) in t.iter().enumerate() { + let cb = basis.get(i).and_then(|b| cost.get(*b)).copied().unwrap_or(0.0); + if cb != 0.0 { + for (v, x) in obj.iter_mut().zip(row) { + *v -= cb * x; + } + } + } + let allowed: Vec = (0..width).map(|j| !is_art(j)).collect(); + match simplex(&mut t, &mut basis, &mut obj, &allowed, budget) { + Step::Limit => return Outcome::Limit, + Step::Unbounded => return Outcome::Unbounded, + Step::Optimal => {} + } + let mut y = vec![0.0; width]; + for (i, row) in t.iter().enumerate() { + if let (Some(b), Some(v)) = (basis.get(i), row.get(width)) + && let Some(e) = y.get_mut(*b) + { + *e = *v; + } + } + let mut x = offset; + for (k, mp) in cols.iter().enumerate() { + if let Some(e) = x.get_mut(mp.var) { + *e += mp.sign * y.get(k).copied().unwrap_or(0.0); + } + } + let val: f64 = c.iter().zip(&x).map(|(a, b)| a * b).sum(); + Outcome::Solved(x, val, true) +} + +/// Linearises the model at `x0` (n + 1 evaluations) and checks it at one more point. Returns the +/// objective gradient and constant, and per row the gradient and constant of `lhs − rhs`. +#[allow(clippy::type_complexity)] +fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<((Vec, f64), Vec<(Vec, f64)>), &'static str> { + let n = x0.len(); + let p0 = ev.eval(x0).ok_or("budget")?; + if !p0.f.is_finite() || p0.g.iter().any(|v| !v.is_finite()) { + return Err("error"); + } + let mut cf = vec![0.0; n]; + let mut ga = vec![vec![0.0; n]; p0.g.len()]; + for j in 0..n { + let mut x = x0.to_vec(); + if let Some(v) = x.get_mut(j) { + *v += 1.0; + } + let p = ev.eval(&x).ok_or("budget")?; + if let Some(v) = cf.get_mut(j) { + *v = p.f - p0.f; + } + for (row, (gi, g0)) in ga.iter_mut().zip(p.g.iter().zip(&p0.g)) { + if let Some(v) = row.get_mut(j) { + *v = gi - g0; + } + } + } + let dot = |a: &[f64], x: &[f64]| a.iter().zip(x).map(|(p, q)| p * q).sum::(); + let f_const = p0.f - dot(&cf, x0); + let rows: Vec<(Vec, f64)> = ga.into_iter().zip(&p0.g).map(|(a, g0)| (a.clone(), g0 - dot(&a, x0))).collect(); + // Linearity check at a spread-out point. + let xt: Vec = + x0.iter().enumerate().map(|(j, v)| v + if j % 2 == 0 { 1.7 + 0.3 * (j % 5) as f64 } else { -0.6 - 0.2 * (j % 3) as f64 }).collect(); + let pt = ev.eval(&xt).ok_or("budget")?; + let close = |actual: f64, pred: f64, terms: f64| actual.is_finite() && (actual - pred).abs() <= 1e-6 * (1.0 + actual.abs() + terms); + let terms = |a: &[f64]| a.iter().zip(&xt).map(|(p, q)| (p * q).abs()).sum::(); + if !close(pt.f, f_const + dot(&cf, &xt), terms(&cf)) { + return Err("nonlinear"); + } + for ((a, k), g) in rows.iter().zip(&pt.g) { + if !close(*g, k + dot(a, &xt), terms(a)) { + return Err("nonlinear"); + } + } + Ok(((cf, f_const), rows)) +} + +// ---------------------------------------------------------------- nonlinear + +/// Augmented-Lagrangian value of a point: objective `phi` plus the constraint terms. +fn aug(phi: f64, cons: &[f64], senses: &[Sense], lam: &[f64], mu: f64) -> f64 { + let mut v = phi; + for ((c, s), l) in cons.iter().zip(senses).zip(lam) { + v += match s { + Sense::Eq => l * c + 0.5 * mu * c * c, + _ => { + let t = (l + mu * c).max(0.0); + (t * t - l * l) / (2.0 * mu) + } + }; + } + v +} + +/// Constraint values in the `≤ 0` / `= 0` form, and the objective to minimise. +fn shape(p: &Point, rows: &[Row], goal: Goal) -> (f64, Vec) { + let phi = match goal { + Goal::Max => -p.f, + Goal::Min => p.f, + Goal::Value(v) => (p.f - v) * (p.f - v), + }; + let cons = p.g.iter().zip(rows).map(|(g, r)| if r.sense == Sense::Ge { -g } else { *g }).collect(); + (phi, cons) +} + +fn violation(cons: &[f64], senses: &[Sense]) -> f64 { + cons.iter().zip(senses).map(|(c, s)| if *s == Sense::Eq { c.abs() } else { c.max(0.0) }).fold(0.0, f64::max) +} + +fn project(x: &mut [f64], lo: &[f64], hi: &[f64]) { + for ((v, l), h) in x.iter_mut().zip(lo).zip(hi) { + if *v < *l { + *v = *l; + } + if *v > *h { + *v = *h; + } + } +} + +/// Local minimisation of the model within bounds `lo..hi` from `x0`. +fn nlp(ev: &mut Evaluator, x0: &[f64], lo: &[f64], hi: &[f64], goal: Goal) -> Outcome { + let n = x0.len(); + let rows = ev.rows.clone(); + let senses: Vec = rows.iter().map(|r| r.sense).collect(); + let mut x = x0.to_vec(); + project(&mut x, lo, hi); + let mut lam = vec![0.0; rows.len()]; + let mut mu = 10.0; + let Some(p) = ev.eval(&x) else { return Outcome::Limit }; + let (mut phi, mut cons) = shape(&p, &rows, goal); + if !phi.is_finite() { + return Outcome::Infeasible; + } + let phi0 = phi; + let mut prev_viol = violation(&cons, &senses); + let mut converged = false; + let mut out_of_budget = false; + let mut carried: Option = None; + for _outer in 0..40 { + // Inner: projected gradient on the augmented Lagrangian. + let lval = |phi: f64, cons: &[f64], lam: &[f64], mu: f64| aug(phi, cons, &senses, lam, mu); + let grad = |ev: &mut Evaluator, x: &[f64], lam: &[f64], mu: f64| -> Option> { + let mut gr = vec![0.0; n]; + for j in 0..n { + let xj = x.get(j).copied().unwrap_or(0.0); + let h = 1e-6 * xj.abs().max(1.0); + let (l, u) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY)); + let (a, b) = (if xj - h >= l { xj - h } else { xj }, if xj + h <= u { xj + h } else { xj }); + if b <= a { + continue; + } + let mut xa = x.to_vec(); + let mut xb = x.to_vec(); + if let Some(v) = xa.get_mut(j) { + *v = a; + } + if let Some(v) = xb.get_mut(j) { + *v = b; + } + let (pa, ca) = shape(&ev.eval(&xa)?, &rows, goal); + let (pb, cb) = shape(&ev.eval(&xb)?, &rows, goal); + let d = (lval(pb, &cb, lam, mu) - lval(pa, &ca, lam, mu)) / (b - a); + if let Some(v) = gr.get_mut(j) { + *v = if d.is_finite() { d } else { 0.0 }; + } + } + Some(gr) + }; + let mut l_cur = lval(phi, &cons, &lam, mu); + let Some(mut g) = grad(ev, &x, &lam, mu) else { + out_of_budget = true; + break; + }; + // The step length carries over between outer iterations once known. + let gnorm = g.iter().map(|v| v.abs()).fold(0.0, f64::max); + let mut alpha = carried.unwrap_or(if gnorm > 0.0 { 1.0 / gnorm } else { 1.0 }); + let mut inner_done = false; + let mut quiet = 0; + for _inner in 0..500 { + let mut accepted = None; + let mut a = alpha; + for _ in 0..40 { + let mut xn: Vec = x.iter().zip(&g).map(|(v, d)| v - a * d).collect(); + project(&mut xn, lo, hi); + let Some(pn) = ev.eval(&xn) else { break }; + let (phn, cn) = shape(&pn, &rows, goal); + let ln = lval(phn, &cn, &lam, mu); + let decrease: f64 = g.iter().zip(x.iter().zip(&xn)).map(|(d, (u, v))| d * (u - v)).sum(); + if ln.is_finite() && ln <= l_cur - 1e-4 * decrease { + accepted = Some((xn, phn, cn, ln)); + break; + } + a *= 0.25; + } + if ev.exhausted() { + out_of_budget = true; + } + let Some((xn, phn, cn, ln)) = accepted else { + inner_done = true; + break; + }; + // Variables or objective running off towards infinity: unbounded. + if phn.abs() > 1e15 * (1.0 + phi0.abs()) || xn.iter().zip(x0).any(|(v, s)| v.abs() > 1e15 * (1.0 + s.abs())) { + return Outcome::Unbounded; + } + let s: Vec = xn.iter().zip(&x).map(|(a, b)| a - b).collect(); + let step = s.iter().map(|v| v.abs()).fold(0.0, f64::max); + let xmax = xn.iter().map(|v| v.abs()).fold(1.0, f64::max); + let small = step <= 1e-10 * xmax || (l_cur - ln).abs() <= 1e-13 * (1.0 + l_cur.abs()); + quiet = if small { quiet + 1 } else { 0 }; + x = xn; + phi = phn; + cons = cn; + l_cur = ln; + if quiet >= 3 { + inner_done = true; + break; + } + let Some(gn) = grad(ev, &x, &lam, mu) else { + out_of_budget = true; + break; + }; + // Barzilai–Borwein step. + let yv: Vec = gn.iter().zip(&g).map(|(a, b)| a - b).collect(); + let sy: f64 = s.iter().zip(&yv).map(|(a, b)| a * b).sum(); + let ss: f64 = s.iter().map(|v| v * v).sum(); + alpha = if sy > 0.0 { (ss / sy).clamp(1e-12, 1e12) } else { (a * 4.0).min(1e12) }; + g = gn; + if g.iter().all(|v| *v == 0.0) { + inner_done = true; + break; + } + } + carried = Some(alpha); + if out_of_budget { + break; + } + let viol = violation(&cons, &senses); + let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max); + if viol <= PRECISION * scale && inner_done { + converged = true; + if rows.is_empty() { + break; + } + } + // Multiplier and penalty updates. + for ((l, c), s) in lam.iter_mut().zip(&cons).zip(&senses) { + *l = match s { + Sense::Eq => *l + mu * c, + _ => (*l + mu * c).max(0.0), + }; + } + if viol > 0.25 * prev_viol { + mu = (mu * 10.0).min(1e10); + } + if converged && viol <= PRECISION * scale * 1e-2 { + break; + } + prev_viol = viol; + converged = false; + } + let viol = violation(&cons, &senses); + let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max); + if viol > PRECISION * scale * 10.0 { + return if out_of_budget { Outcome::Limit } else { Outcome::Infeasible }; + } + Outcome::Solved(x, phi, (converged || viol <= PRECISION * scale) && !out_of_budget) +} + +// ---------------------------------------------------------------- command + +fn run(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let active = d.wb.active_sheet; + let mut params = p.clone(); + if bool_param(p, "saved").unwrap_or(false) { + let saved = saved_model(&d.wb, active).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))?; + if let (Some(base), Some(over)) = (saved.as_object(), p.as_object()) { + let mut merged = base.clone(); + for (k, v) in over { + merged.insert(k.clone(), v.clone()); + } + params = Json::Object(merged); + } + } + let has_objective = ["objective", "setObjective", "set"].iter().any(|k| str_param(¶ms, k).is_some_and(|t| !t.trim().is_empty())); + if !has_objective { + let model = saved_model(&d.wb, active); + s.ui_requests.push(crate::UiRequest::Dialog("solver".into(), json!({}))); + return Ok(json!({"model": model})); + } + let model = parse_model(s, ¶ms)?; + let budget = params + .get("maxEvaluations") + .and_then(Json::as_f64) + .filter(|v| v.is_finite() && *v >= 1.0) + .map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS)); + let gap = params.get("integerOptimality").and_then(Json::as_f64).filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0); + let keep = bool_param(¶ms, "keep").unwrap_or(true); + let save = bool_param(¶ms, "saveModel").unwrap_or(true); + let prob = build_problem(&model); + let d = s.doc()?; + let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?; + let x0: Vec = model.vars.iter().map(|c| as_num(&sh.value(*c))).map(|v| if v.is_finite() { v } else { 0.0 }).collect(); + let mut ev = Evaluator { + trial: Trial::new(d), + sheet: model.sheet, + vars: model.vars.clone(), + objective: model.objective, + rows: prob.rows.clone(), + evals: 0, + budget, + }; + let any_int = prob.integer.iter().any(|b| *b); + let mut not_linear = false; + let outcome = if prob.lo.iter().zip(&prob.hi).any(|(l, h)| l > h) { + Outcome::Infeasible + } else { + match model.method { + Method::Simplex => match linearize(&mut ev, &x0) { + Err("nonlinear") | Err("error") => { + not_linear = true; + Outcome::Infeasible + } + Err(_) => Outcome::Limit, + Ok(((cf, f_const), lin)) => { + let mut rows: Vec<(Vec, Sense, f64)> = lin.iter().zip(&prob.rows).map(|((a, k), r)| (a.clone(), r.sense, -k)).collect(); + let c: Vec = match model.goal { + Goal::Max => cf.iter().map(|v| -v).collect(), + Goal::Min => cf.clone(), + Goal::Value(v) => { + rows.push((cf.clone(), Sense::Eq, v - f_const)); + vec![0.0; cf.len()] + } + }; + let size = (rows.len() as u64 + 2 * model.vars.len() as u64 + 1) * (4 * model.vars.len() as u64 + 2 * rows.len() as u64 + 1); + let mut work = LP_WORK.max(size); + let mut relax = |l: &[f64], h: &[f64]| solve_lp(&c, &rows, l, h, &mut work); + if any_int { + branch_and_bound(&prob.lo, &prob.hi, &prob.integer, MAX_NODES_LP, gap, &mut relax).0 + } else { + relax(&prob.lo, &prob.hi) + } + } + }, + Method::Grg => { + let goal = model.goal; + if any_int { + let mut relax = |l: &[f64], h: &[f64]| { + let mut start = x0.clone(); + project(&mut start, l, h); + nlp(&mut ev, &start, l, h, goal) + }; + branch_and_bound(&prob.lo, &prob.hi, &prob.integer, MAX_NODES_NLP, gap, &mut relax).0 + } else { + nlp(&mut ev, &x0, &prob.lo, &prob.hi, goal) + } + } + } + }; + let (status, message, solution) = match outcome { + _ if not_linear => ("notLinear", "The model isn't linear in the variable cells, so Simplex LP can't solve it. Try GRG Nonlinear.", None), + Outcome::Solved(x, _, true) => ( + if model.method == Method::Simplex { "optimal" } else { "converged" }, + if model.method == Method::Simplex { + "Solver found an optimal solution. All constraints are satisfied." + } else { + "Solver converged to a locally optimal solution. All constraints are satisfied." + }, + Some(x), + ), + Outcome::Solved(x, _, false) => { + ("iterationLimit", "Solver stopped at its evaluation limit; the best solution found satisfies the constraints.", Some(x)) + } + Outcome::Limit => ("iterationLimit", "Solver stopped at its evaluation limit without finding a feasible solution.", None), + Outcome::Infeasible => ("infeasible", "Solver could not find a solution that satisfies all constraints.", None), + Outcome::Unbounded => ("unbounded", "The objective can grow without limit: the problem is unbounded.", None), + }; + let evaluations = ev.evals; + // The objective's value at the solution. + let objective = solution.as_ref().and_then(|x| { + ev.budget = ev.budget.saturating_add(1); + ev.eval(x).map(|p| p.f) + }); + let mut values = serde_json::Map::new(); + if let Some(x) = &solution { + for (c, v) in model.vars.iter().zip(x) { + values.insert(c.a1(), json!(v)); + } + } + let changed = keep && solution.is_some(); + if changed || save { + let sheet = model.sheet; + let vars = model.vars.clone(); + let m = model.clone(); + edit(s, |cx| { + if changed && let Some(x) = &solution { + let sh = cx.sheet_mut(sheet)?; + for (c, v) in vars.iter().zip(x) { + let style = sh.style_id(*c); + sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style }); + } + for c in &vars { + cx.touch(sheet, *c); + } + } + if save { + save_model(&mut cx.wb, &m); + } + Ok(()) + })?; + } + Ok(json!({ + "status": status, + "message": message, + "objective": objective, + "values": values, + "evaluations": evaluations, + "changed": changed, + })) +} + +#[cfg(test)] +mod tests { + use gridcraft_core::CellRef; + use serde_json::json; + + use crate::Session; + + fn s() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s + } + fn n(s: &Session, a: &str) -> f64 { + let d = s.doc().unwrap(); + d.wb.active().unwrap().value(CellRef::parse(a).unwrap()).as_f64().unwrap_or(f64::NAN) + } + fn set(s: &mut Session, cells: &[(&str, &str)]) { + for (c, v) in cells { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + } + + #[test] + fn simplex_solves_the_wyndor_glass_lp() { + // Hillier & Lieberman's Wyndor Glass problem: max 3x + 5y, x ≤ 4, 2y ≤ 12, 3x + 2y ≤ 18 + // → x = 2, y = 6, Z = 36. + let mut s = s(); + set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")]); + let r = s + .execute( + "data.solver", + json!({"objective": "B1", "goal": "max", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", {"lhs": "B3", "op": "<=", "rhs": 18}], "method": "simplex"}), + ) + .unwrap(); + assert_eq!(r["status"], "optimal", "{r}"); + assert!((n(&s, "A1") - 2.0).abs() < 1e-9 && (n(&s, "A2") - 6.0).abs() < 1e-9); + assert!((n(&s, "B1") - 36.0).abs() < 1e-9); + // The model is saved as hidden names, survives XLSX and solves again from them. + let names: Vec = s.doc().unwrap().wb.names.iter().map(|n| n.name.clone()).collect(); + assert!(names.iter().any(|n| n == "solver_opt") && names.iter().any(|n| n == "solver_lhs3")); + let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "m.xlsx", "base64": b64})).unwrap(); + set(&mut s, &[("A1", "0"), ("A2", "0")]); + let m = s.execute("data.solver", json!({})).unwrap(); + assert_eq!(m["model"]["objective"], "B1"); + assert_eq!(m["model"]["method"], "simplex"); + let r = s.execute("data.solver", json!({"saved": true})).unwrap(); + assert_eq!(r["status"], "optimal"); + assert!((n(&s, "B1") - 36.0).abs() < 1e-9); + // Minimise with keep: false leaves the cells alone. + set(&mut s, &[("A1", "1"), ("A2", "1")]); + let r = s.execute("data.solver", json!({"saved": true, "goal": "min", "keep": false})).unwrap(); + assert_eq!(r["objective"], 0.0); + assert_eq!((n(&s, "A1"), n(&s, "A2")), (1.0, 1.0)); + } + + #[test] + fn integer_and_binary_constraints_use_branch_and_bound() { + // max 5x + 4y, 6x + 4y ≤ 24, x + 2y ≤ 6: the LP optimum (3, 1.5) = 21; integers give + // (4, 0) = 20. + let mut s = s(); + set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2")]); + let base = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6"], "method": "simplex"}); + let r = s.execute("data.solver", base.clone()).unwrap(); + assert!((r["objective"].as_f64().unwrap() - 21.0).abs() < 1e-9); + let mut p = base.clone(); + p["constraints"].as_array_mut().unwrap().push(json!("A1:A2 int")); + let r = s.execute("data.solver", p).unwrap(); + assert_eq!(r["status"], "optimal"); + assert_eq!((n(&s, "A1"), n(&s, "A2"), n(&s, "B1")), (4.0, 0.0, 20.0)); + // 0/1 knapsack: values 10, 13, 7, weights 5, 6, 4, capacity 10 → items 2 and 3 (20). + set(&mut s, &[("D1", "=10*C1+13*C2+7*C3"), ("D2", "=5*C1+6*C2+4*C3")]); + let r = s + .execute("data.solver", json!({"objective": "D1", "variables": "C1:C3", "constraints": ["D2 <= 10", "C1:C3 bin"], "method": "simplex"})) + .unwrap(); + assert_eq!(r["status"], "optimal"); + assert_eq!((n(&s, "C1"), n(&s, "C2"), n(&s, "C3"), n(&s, "D1")), (0.0, 1.0, 1.0, 20.0)); + } + + #[test] + fn grg_finds_smooth_optima() { + // Largest rectangle with perimeter at most 20: a 5 × 5 square, area 25. + let mut s = s(); + set(&mut s, &[("A1", "1"), ("A2", "1"), ("B1", "=A1*A2"), ("B2", "=2*A1+2*A2")]); + let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"]})).unwrap(); + assert_eq!(r["status"], "converged", "{r}"); + assert!((n(&s, "A1") - 5.0).abs() < 1e-3 && (n(&s, "A2") - 5.0).abs() < 1e-3, "{r}"); + // Unconstrained minimum of (x − 3)² + (y + 1)² at (3, −1) once negatives are allowed. + set(&mut s, &[("C1", "0"), ("C2", "0"), ("D1", "=(C1-3)^2+(C2+1)^2")]); + s.execute("data.solver", json!({"objective": "D1", "goal": "min", "variables": "C1:C2", "nonNegative": false})).unwrap(); + assert!((n(&s, "C1") - 3.0).abs() < 1e-4 && (n(&s, "C2") + 1.0).abs() < 1e-4); + // Value Of: x³ = 8 → x = 2. + set(&mut s, &[("E1", "1"), ("F1", "=E1^3")]); + s.execute("data.solver", json!({"objective": "F1", "goal": "value", "value": 8, "variables": "E1"})).unwrap(); + assert!((n(&s, "E1") - 2.0).abs() < 1e-4); + } + + #[test] + fn infeasible_unbounded_and_nonlinear_models_are_reported() { + let mut s = s(); + set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); + for method in ["simplex", "grg"] { + let r = s + .execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 >= 5", "B1 <= 6"], "method": method})) + .unwrap(); + assert_eq!(r["status"], "infeasible", "{method}: {r}"); + assert_eq!(r["changed"], false); + assert_eq!(n(&s, "A1"), 1.0, "an infeasible model leaves the cells alone"); + } + let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "method": "simplex"})).unwrap(); + assert_eq!(r["status"], "unbounded"); + let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "method": "grg", "maxEvaluations": 5000})).unwrap(); + assert!(r["status"] == "unbounded" || r["status"] == "iterationLimit", "{r}"); + set(&mut s, &[("A1", "1"), ("C1", "=A1^2")]); + let r = s.execute("data.solver", json!({"objective": "C1", "goal": "min", "variables": "A1", "method": "simplex"})).unwrap(); + assert_eq!(r["status"], "notLinear"); + } + + #[test] + fn hostile_models_are_refused_without_panicking() { + let mut s = s(); + let r = s.execute("data.solver", json!({})).unwrap(); + assert!(r["model"].is_null()); + assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(n, _) if n == "solver"))); + set(&mut s, &[("B1", "=SUM(A1:A1000)"), ("C1", "5")]); + for p in [ + json!({"objective": "B1", "variables": "A1:A1000"}), + json!({"objective": "B1", "variables": "A1:XFD1048576"}), + json!({"objective": "C1", "variables": "A1"}), + json!({"objective": "B1:B2", "variables": "A1"}), + json!({"objective": "B1", "variables": "B1"}), + json!({"objective": "B1", "variables": "A1", "goal": "sideways"}), + json!({"objective": "B1", "variables": "A1", "goal": "value"}), + json!({"objective": "B1", "variables": "A1", "method": "evolutionary"}), + json!({"objective": "B1", "variables": "A1", "constraints": ["A1 ?? 3"]}), + json!({"objective": "B1", "variables": "A1", "constraints": "A1 <= 3"}), + json!({"objective": "B1", "variables": "A1", "constraints": ["A1:A3 <= C1:C2"]}), + json!({"objective": "B1", "variables": "A1", "constraints": ["C1 int"]}), + json!({"objective": "B1", "variables": "A1", "constraints": ["A1:A1048576 <= 1"]}), + json!({"objective": "B1", "variables": "A1", "constraints": vec![json!("A1 <= 1"); 1001]}), + json!({"objective": "Nope!B1", "variables": "A1"}), + ] { + assert!(s.execute("data.solver", p.clone()).is_err(), "{p}"); + } + // Huge evaluation requests are capped, and a model erroring everywhere doesn't panic. + set(&mut s, &[("D1", "=1/0+A1")]); + let r = s.execute("data.solver", json!({"objective": "D1", "variables": "A1", "maxEvaluations": 1e300})).unwrap(); + assert_ne!(r["status"], "converged"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb10..d67bc1ac0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -317,6 +317,23 @@ impl Dialog { json!({"header": true}), ), "goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})), + "solver" => { + // Starts from the sheet's saved model, else the active cell as objective. + let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet)); + let mut values = json!({"objective": active, "goal": "max", "value": "0", "variables": "", "constraints": [], "nonNegative": true, "method": "grg"}); + if let (Some(m), Some(v)) = (saved.as_ref().and_then(Json::as_object), values.as_object_mut()) { + for (k, x) in m { + v.insert(k.clone(), if k == "value" { json!(x.to_string()) } else { x.clone() }); + } + } + if let Some(v) = values.as_object_mut() { + v.insert("newOp".into(), json!("<=")); + } + let mut d = Dialog::custom("solver", "Solver Parameters", values); + d.command = Some("data.solver"); + d + } + "solverResults" => Dialog::custom("solverResults", "Solver Results", params.clone()), "subtotal" => Dialog::form( "subtotal", "Subtotal", @@ -659,6 +676,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "formatCells" => 560.0, "about" => 660.0, "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0, + "solver" => 460.0, _ => 380.0, }; let window = egui::Window::new(d.title.clone()) @@ -681,6 +699,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "manageRules" => manage_rules(app, ui), "commandSearch" => command_search(app, ui, &mut d, &mut confirm), "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), + "solver" => solver(ui, &mut d, &mut confirm, &mut open), + "solverResults" => solver_results(app, ui, &mut d, &mut open), "spelling" => spelling(app, ui, &mut d, &mut open), "agents" => { ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:"); @@ -877,7 +897,12 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { if confirm && let Some(cmd) = d.command { let params = build_params(&d); match app.run(cmd, params) { - Ok(_) => open = false, + Ok(r) => { + open = false; + if d.name == "solver" { + app.open_dialog("solverResults", r); + } + } Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), } } else if confirm { @@ -1607,6 +1632,144 @@ fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mu } } +/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's +/// command; the result opens Solver Results. +fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default(); + let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32| { + let mut t = text(d, k); + if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).changed() { + d.values.insert(k.into(), json!(t)); + } + }; + ui.horizontal(|ui| { + ui.label("Set Objective:"); + edit(ui, d, "objective", 200.0); + }); + let goal = text(d, "goal"); + ui.horizontal(|ui| { + ui.label("To:"); + for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] { + if ui.radio(goal == g, label).clicked() { + d.values.insert("goal".into(), json!(g)); + } + } + edit(ui, d, "value", 80.0); + }); + ui.label("By Changing Variable Cells:"); + edit(ui, d, "variables", 420.0); + ui.label("Subject to the Constraints:"); + let list: Vec = d + .values + .get("constraints") + .and_then(Json::as_array) + .map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect()) + .unwrap_or_default(); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_min_size(vec2(420.0, 90.0)); + egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { + for (i, c) in list.iter().enumerate() { + if ui.selectable_label(d.list_index == i, c).clicked() { + d.list_index = i; + } + } + }); + }); + ui.horizontal(|ui| { + edit(ui, d, "newLhs", 110.0); + let op = text(d, "newOp"); + egui::ComboBox::from_id_salt("solverOp").selected_text(op.clone()).width(60.0).show_ui(ui, |ui| { + for o in ["<=", "=", ">=", "int", "bin"] { + if ui.selectable_label(op == o, o).clicked() { + d.values.insert("newOp".into(), json!(o)); + } + } + }); + if !matches!(op.as_str(), "int" | "bin") { + edit(ui, d, "newRhs", 110.0); + } + if ui.button("Add").clicked() { + let lhs = text(d, "newLhs"); + if !lhs.trim().is_empty() { + let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) }; + let mut list = list.clone(); + list.push(format!("{} {op}{rhs}", lhs.trim())); + d.values.insert("constraints".into(), json!(list)); + d.values.insert("newLhs".into(), json!("")); + d.values.insert("newRhs".into(), json!("")); + } + } + if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() { + let mut list = list.clone(); + if d.list_index < list.len() { + list.remove(d.list_index); + } + d.values.insert("constraints".into(), json!(list)); + } + }); + let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true); + if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() { + d.values.insert("nonNegative".into(), json!(nonneg)); + } + let method = text(d, "method"); + ui.horizontal(|ui| { + ui.label("Select a Solving Method:"); + let label = |m: &str| if m == "simplex" { "Simplex LP" } else { "GRG Nonlinear" }; + egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| { + for m in ["grg", "simplex"] { + if ui.selectable_label(method == m, label(m)).clicked() { + d.values.insert("method".into(), json!(m)); + } + } + }); + }); + ui.label( + egui::RichText::new(if method == "simplex" { + "Simplex LP: for linear models; integer and binary constraints by branch and bound." + } else { + "GRG Nonlinear: a local optimum of a smooth model, from the current values." + }) + .small(), + ); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let solve = + ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent)); + if solve.clicked() && !text(d, "objective").trim().is_empty() { + *confirm = true; + } + if ui.button(" Close ").clicked() { + *open = false; + } + }); +} + +/// Solver Results: the outcome, and keeping or restoring the variable cells. +fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { + let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string(); + ui.label(message); + let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false); + let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false); + if changed { + ui.add_space(4.0); + if ui.radio(!restore, "Keep Solver Solution").clicked() { + d.values.insert("restore".into(), json!(false)); + } + if ui.radio(restore, "Restore Original Values").clicked() { + d.values.insert("restore".into(), json!(true)); + } + } + let mut confirm = false; + ok_cancel(ui, &mut confirm, open); + if confirm { + // The solution (and the saved model) is one undo step. + if changed && restore { + app.run_or_alert("edit.undo", json!({})); + } + *open = false; + } +} + fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { let r = d.result.clone().unwrap_or(Json::Null); if let Some(e) = r.get("error").and_then(Json::as_str) { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..36a20e25b 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -313,6 +313,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Group", "グループ化"), ("Ungroup", "グループ解除"), ("Subtotal", "小計"), + ("Solver", "ソルバー"), ("What-If\nAnalysis", "What-If 分析"), ("Data Table…", "データ テーブル…"), ("Scenario Manager…", "シナリオ マネージャー…"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3cab..13fdc2f13 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -164,6 +164,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Group" => "그룹", "Ungroup" => "그룹 해제", "Subtotal" => "부분합", + "Solver" => "해 찾기", "What-If\nAnalysis" => "가상 분석", "Data Table…" => "데이터 표…", "Scenario Manager…" => "시나리오 관리자…", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3f2..b0b30e01c 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -165,6 +165,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Group" => "Agrupar", "Ungroup" => "Desagrupar", "Subtotal" => "Subtotal", + "Solver" => "Solver", "What-If\nAnalysis" => "Análise de\nHipóteses", "Data Table…" => "Tabela de Dados…", "Scenario Manager…" => "Gerenciador de Cenários…", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d5485658..aaaadbb74 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -164,6 +164,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Group" => "Группировать", "Ungroup" => "Разгруппировать", "Subtotal" => "Промежуточный итог", + "Solver" => "Поиск решения", "What-If\nAnalysis" => "Анализ «что\nесли»", "Data Table…" => "Таблица данных…", "Scenario Manager…" => "Диспетчер сценариев…", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada73..462462cc0 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -164,6 +164,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Group" => "组合", "Ungroup" => "取消组合", "Subtotal" => "分类汇总", + "Solver" => "规划求解", "What-If\nAnalysis" => "假设分析", "Data Table…" => "数据表…", "Scenario Manager…" => "方案管理器…", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..70b24a352 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1580,6 +1580,10 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { app.open_dialog("subtotal", json!({})); } }); + sep(ui); + if big_button(ui, Icon::Function, "Solver", "Solver", false).clicked() { + app.open_dialog("solver", json!({})); + } } fn review(app: &mut SheetApp, ui: &mut Ui) { diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 84f20a675..21f4f0469 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -228,6 +228,7 @@ fn dialogs_open_and_close() { "spelling", "goTo", "about", + "solver", ] { h.state_mut().open_dialog(name, json!({})); h.run_steps(2); @@ -237,6 +238,37 @@ fn dialogs_open_and_close() { } } +#[test] +fn solver_dialog_loads_the_saved_model_and_results_can_restore() { + let mut s = blank(); + for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let r = s + .execute( + "data.solver", + json!({"objective": "B1", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", "B3 <= 18"], "method": "simplex"}), + ) + .unwrap(); + assert_eq!(s.active().unwrap().wb.active().unwrap().value(CellRef::parse("B1").unwrap()), Value::Number(36.0)); + let mut h = harness(s); + h.state_mut().open_dialog("solver", json!({})); + h.run_steps(2); + let d = h.state().dialog.as_ref().unwrap(); + assert_eq!(d.values.get("objective"), Some(&json!("B1"))); + assert_eq!(d.values.get("method"), Some(&json!("simplex"))); + assert_eq!(d.values.get("constraints").and_then(|c| c.as_array()).map(Vec::len), Some(3)); + h.state_mut().dialog = None; + // Solver Results › Restore Original Values undoes the solution. + h.state_mut().open_dialog("solverResults", r); + h.state_mut().dialog.as_mut().unwrap().values.insert("restore".into(), json!(true)); + h.run_steps(2); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + assert_eq!(value(&h, "A1"), Value::Number(0.0)); + assert_eq!(value(&h, "B1"), Value::Number(0.0)); +} + #[test] fn about_renders_every_tab() { let mut h = harness(blank()); diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8d8..ef1e08da8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -98,8 +98,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h. ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) -- `data.solver`, `data.analysisToolPak`, `data.forecastSheet` missing; FORECAST.ETS family missing. - **Estimate:** 22–35 h. +- `data.analysisToolPak`, `data.forecastSheet` missing; FORECAST.ETS family missing. Solver + (`data.solver`) has Simplex LP with integer/binary branch and bound and a local nonlinear method + (augmented Lagrangian, not GRG); Evolutionary, AllDifferent constraints, Solver options and + reports remain. **Estimate:** 16–28 h. ### 14. Page Layout view and print polish (U, F) - No paper view with rulers and in-place header/footer editing; web print exports only (#141, PR diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e98835d31..623b2b605 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 258 / 290 commands implemented (89.0%).** +**Overall: 259 / 290 commands implemented (89.3%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -14,7 +14,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | -| Data | 20 | 28 | 71% | +| Data | 21 | 28 | 75% | | Review | 9 | 18 | 50% | | View | 18 | 20 | 90% | | Automate | 3 | 3 | 100% | @@ -240,7 +240,7 @@ Implemented: - `formulas.calculateNow` Calculate Now (Calculation) - `formulas.calculateSheet` Calculate Sheet (Calculation) -## Data (20 / 28, 71%) +## Data (21 / 28, 75%) Implemented: @@ -264,6 +264,7 @@ Implemented: - `data.subtotal` Subtotal (Outline) - `data.showDetail` Show Detail (Outline) - `data.hideDetail` Hide Detail (Outline) +- `data.solver` Solver (Analysis) Missing: @@ -273,7 +274,6 @@ Missing: - `data.relationships` Relationships (Data Tools) - `data.forecastSheet` Forecast Sheet (Forecast) - `data.analyzeData` Analyze Data (Analysis) -- `data.solver` Solver (Analysis) - `data.analysisToolPak` Data Analysis (Analysis) ## Review (9 / 18, 50%) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2e5ed01cd..b3ec3a6e5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -87,7 +87,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, XLSX round-trip. Missing: slicers, timelines, calculated fields/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. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data | +| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Solver (Simplex LP and a local nonlinear method; no Evolutionary or reports). Missing: Analysis ToolPak, Forecast Sheet, Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection. Missing: comment navigation, Show Changes, Allow Edit Ranges, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..71b660269 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -65,7 +65,7 @@ shortcut lists for Windows and Mac, then fill the gaps: 6–10 h. Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal -Seek, Scenario Manager and others exist. Missing or partial: Options, Solver, Data Analysis, +Seek, Scenario Manager, Solver and others exist. Missing or partial: Options, Data Analysis, Forecast Sheet, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. From b4d823902aa3630d64b590aecb23dc1a9f75ab49 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:03:11 +0200 Subject: [PATCH 071/102] UI: File tab Backstage view (#174) Clicking the File tab now opens a full-window Backstage view in place of the ribbon, formula bar, grid and sheet tabs, like Excel's. Esc or the back arrow return to the sheet. Pages: Home (new + recent), New (blank + samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, scope, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (theme, language, formula bar, AutoSave, calculation). Every action runs an existing command or dialog. Adds `file.options` (reports the workbook's calculation settings and asks the interface for the Options page) and the UI command `ui.backstage {page}`. Keyboard shortcuts and the macOS native menu are unchanged. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/file.rs | 27 +- crates/engine/src/tests.rs | 16 + crates/ui-egui/src/backstage.rs | 828 ++++++++++++++++++++++++++++++ crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/i18n.rs | 61 +++ crates/ui-egui/src/i18n/ko.rs | 59 +++ crates/ui-egui/src/i18n/pt.rs | 59 +++ crates/ui-egui/src/i18n/ru.rs | 59 +++ crates/ui-egui/src/i18n/zh.rs | 55 ++ crates/ui-egui/src/icons.rs | 29 ++ crates/ui-egui/src/lib.rs | 81 ++- crates/ui-egui/src/ribbon.rs | 29 +- crates/ui-egui/tests/file_menu.rs | 87 +++- docs/control-protocol.md | 3 +- docs/parity-checklist.md | 9 +- docs/ui-parity.md | 9 +- 17 files changed, 1348 insertions(+), 66 deletions(-) create mode 100644 crates/ui-egui/src/backstage.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..9c53c82db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d52418b4c..16e714aa4 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1,4 +1,4 @@ -//! File menu: new, open, save, close, export, properties. +//! File menu: new, open, save, close, export, properties, options. use serde_json::{Value as Json, json}; @@ -16,6 +16,15 @@ pub fn specs() -> Vec { cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")), cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")), cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties), + cmd!( + query "file.options", + "Options", + ["File"], + None, + "{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)", + always, + options + ), cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window), ] } @@ -156,6 +165,22 @@ fn properties(s: &mut Session, p: &Json) -> Result { }) } +/// File › Options: reports the workbook-level settings and asks the interface to show its Options +/// page. Nothing changes here; every setting has its own command. +fn options(s: &mut Session, _: &Json) -> Result { + s.ui_requests.push(crate::UiRequest::Dialog("options".into(), json!({}))); + let calculation = s.active().map(|d| { + let c = &d.wb.calc; + let mode = match c.mode { + gridcraft_model::CalcMode::Automatic => "automatic", + gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables", + gridcraft_model::CalcMode::Manual => "manual", + }; + json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}) + }); + Ok(json!({"calculation": calculation})) +} + fn activate_window(s: &mut Session, p: &Json) -> Result { let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize; if i >= s.documents().len() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55e..4bc7262cd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1036,6 +1036,22 @@ fn every_command_survives_empty_params() { } } +#[test] +fn file_options_reports_settings_and_asks_for_the_options_page() { + let mut s = Session::new(); + // No workbook: nothing to report, but still no error. + assert_eq!(s.execute("file.options", json!({})).unwrap(), json!({"calculation": null})); + s.take_ui_requests(); + s.new_workbook(); + s.execute("formulas.calculationOptions", json!({"mode": "manual", "iterative": true})).unwrap(); + let before = s.active().unwrap().wb.clone(); + let r = s.execute("file.options", json!({})).unwrap(); + assert_eq!(r["calculation"]["mode"], "manual"); + assert_eq!(r["calculation"]["iterative"], true); + assert!(matches!(s.take_ui_requests().as_slice(), [crate::UiRequest::Dialog(name, _)] if name == "options")); + assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook"); +} + fn reopen(s: &mut Session) { let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); let b64 = r["base64"].as_str().unwrap().to_string(); diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs new file mode 100644 index 000000000..c29174017 --- /dev/null +++ b/crates/ui-egui/src/backstage.rs @@ -0,0 +1,828 @@ +//! File tab Backstage view (#174): a full-window page that replaces the ribbon, formula bar, grid +//! and sheet tabs until Esc or the back arrow returns to the sheet. +//! +//! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export, +//! Options); Save and Close act at once. Every action runs an existing command through +//! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is +//! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched +//! until the user picks one. + +use egui::{Align2, Color32, Key, Modifiers, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value as Json, json}; + +use crate::SheetApp; +use crate::i18n::{self, Language}; +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; + +/// Width of the navigation column. +const NAV_W: f32 = 196.0; +/// Width of the content column (pages are left-aligned like a document, not stretched). +const CONTENT_W: f32 = 760.0; + +/// A Backstage page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Page { + Home, + New, + Open, + Info, + SaveAs, + Print, + Export, + Options, +} + +impl Page { + pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options]; + + /// The id `ui.backstage` and `ui.dialog {"name": "backstage", "page": …}` take. + pub fn id(self) -> &'static str { + match self { + Page::Home => "home", + Page::New => "new", + Page::Open => "open", + Page::Info => "info", + Page::SaveAs => "saveAs", + Page::Print => "print", + Page::Export => "export", + Page::Options => "options", + } + } + + pub fn parse(id: &str) -> Option { + let id = id.trim(); + Page::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id)) + } + + /// English label (navigation entry and page heading); translated when drawn. + pub fn label(self) -> &'static str { + match self { + Page::Home => "Home", + Page::New => "New", + Page::Open => "Open", + Page::Info => "Info", + Page::SaveAs => "Save As", + Page::Print => "Print", + Page::Export => "Export", + Page::Options => "Options", + } + } + + fn icon(self) -> Icon { + match self { + Page::Home => Icon::Home, + Page::New => Icon::NewFile, + Page::Open => Icon::OpenFolder, + Page::Info => Icon::Info, + Page::SaveAs => Icon::Save, + Page::Print => Icon::Print, + Page::Export => Icon::Share, + Page::Options => Icon::Settings, + } + } +} + +/// Document properties shown on the Info page: (`file.properties` key, label). +const PROPERTIES: [(&str, &str); 6] = [ + ("title", "Title"), + ("subject", "Subject"), + ("author", "Author"), + ("company", "Company"), + ("keywords", "Keywords"), + ("description", "Description"), +]; + +/// The open Backstage view. +#[derive(Clone, Debug)] +pub struct Backstage { + pub page: Page, + /// Info: property drafts while a field is being edited (committed when it loses focus). + props: [String; 6], + /// Print: the whole workbook rather than the active sheet. + pub print_all: bool, + /// Print: fit each sheet on one page. + pub fit_to_page: bool, + /// Print: the previewed page (0-based). + preview_page: usize, + /// Print: the preview for (document, revision, whole workbook, fit to page). + preview: Option<((u64, u64, bool, bool), Json)>, +} + +impl Backstage { + pub fn new(page: Page) -> Backstage { + Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None } + } +} + +/// What a click asked for, applied after drawing. +#[derive(Default)] +struct Outcome { + close: bool, + page: Option, +} + +/// Draws the Backstage view in place of the ribbon, formula bar, grid and sheet tabs. +pub fn show(app: &mut SheetApp, ui: &mut Ui) { + let Some(mut bs) = app.backstage.take() else { return }; + let ctx = ui.ctx().clone(); + // Esc inside a text field leaves the field; it closes the view only from outside one. + let typing = ctx.text_edit_focused(); + let t = Tokens::get(&ctx); + let lang = app.ui.language; + let mut out = Outcome::default(); + let (nav_bg, nav_text, nav_sel) = + if t.dark { (t.accent_soft, t.text, Color32::from_white_alpha(30)) } else { (t.accent, Color32::WHITE, Color32::from_black_alpha(46)) }; + egui::Panel::left("backstage_nav") + .exact_size(NAV_W) + .resizable(false) + .frame(egui::Frame::NONE.fill(nav_bg).inner_margin(egui::Margin { left: 0, right: 0, top: 10, bottom: 10 })) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + if nav_item(ui, Icon::Back, lang.tr("Back"), false, nav_text, nav_sel).clicked() { + out.close = true; + } + ui.add_space(10.0); + for page in [Page::Home, Page::New, Page::Open] { + if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() { + out.page = Some(page); + } + } + nav_separator(ui, nav_text); + if nav_item(ui, Page::Info.icon(), lang.tr("Info"), bs.page == Page::Info, nav_text, nav_sel).clicked() { + out.page = Some(Page::Info); + } + if nav_item(ui, Icon::Save, lang.tr("Save"), false, nav_text, nav_sel).clicked() { + save(app, &mut out); + } + for page in [Page::SaveAs, Page::Print, Page::Export] { + if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() { + out.page = Some(page); + } + } + if nav_item(ui, Icon::Close, lang.tr("Close"), false, nav_text, nav_sel).clicked() { + // A cell still being edited is part of the workbook; Close keeps the unsaved prompt. + if app.commit_edit(0, 0, false, false) { + app.run_or_alert("file.close", json!({})); + out.close = true; + } + } + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + if nav_item(ui, Page::Options.icon(), lang.tr("Options"), bs.page == Page::Options, nav_text, nav_sel).clicked() { + out.page = Some(Page::Options); + } + }); + }); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon)).show(ui, |ui| { + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + egui::Frame::NONE.inner_margin(egui::Margin { left: 44, right: 32, top: 26, bottom: 32 }).show(ui, |ui| { + ui.set_max_width(CONTENT_W); + ui.label(RichText::new(lang.tr(bs.page.label())).font(theme::ui_font(28.0)).color(t.text)); + ui.add_space(18.0); + match bs.page { + Page::Home => home(app, ui, &mut out), + Page::New => new_page(app, ui, &mut out), + Page::Open => open_page(app, ui, &mut out), + Page::Info => info(app, ui, &mut bs), + Page::SaveAs => save_as(app, ui, &mut out), + Page::Print => print(app, ui, &mut bs, &mut out), + Page::Export => export(app, ui, &mut out), + Page::Options => options(app, ui), + } + }); + }); + }); + if !typing + && app.dialog.is_none() + && app.message.is_none() + && !egui::Popup::is_any_open(&ctx) + && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) + { + out.close = true; + } + if let Some(page) = out.page { + bs.page = page; + } + // A command run from a page may itself have opened (or replaced) the view; keep that. + if out.close { + app.backstage = None; + } else if app.backstage.is_none() { + app.backstage = Some(bs); + } +} + +/// Save: saves a workbook that has a file and returns to the sheet; a new workbook goes to the +/// Save As page first, like Excel's. +fn save(app: &mut SheetApp, out: &mut Outcome) { + if !app.commit_edit(0, 0, false, false) { + return; + } + if app.session.active().is_some_and(|d| d.path.is_some()) { + app.run_or_alert("file.save", json!({})); + out.close = true; + } else { + out.page = Some(Page::SaveAs); + } +} + +// ---------------------------------------------------------------- navigation + +fn nav_item(ui: &mut Ui, icon: Icon, label: &str, selected: bool, text: Color32, sel: Color32) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, 38.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label)); + let p = ui.painter(); + if selected { + p.rect_filled(rect, 0.0, sel); + p.rect_filled(Rect::from_min_size(rect.min, vec2(4.0, rect.height())), 0.0, text); + } else if resp.hovered() { + p.rect_filled(rect, 0.0, sel.gamma_multiply(0.55)); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 30.0, rect.center().y), vec2(17.0, 17.0)), icon, text); + p.text(pos2(rect.left() + 50.0, rect.center().y), Align2::LEFT_CENTER, label, theme::ui_font(14.0), text); + resp +} + +fn nav_separator(ui: &mut Ui, text: Color32) { + let (rect, _) = ui.allocate_exact_size(vec2(NAV_W, 17.0), Sense::hover()); + let y = rect.center().y; + ui.painter().line_segment([pos2(rect.left() + 20.0, y), pos2(rect.right() - 20.0, y)], Stroke::new(1.0, text.gamma_multiply(0.35))); +} + +// ---------------------------------------------------------------- shared pieces + +fn section(ui: &mut Ui, lang: Language, title: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(6.0); + ui.label(RichText::new(lang.tr(title)).font(theme::ui_bold(15.0)).color(t.text)); + ui.add_space(8.0); +} + +fn dim(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(RichText::new(text).font(theme::ui_font(12.5)).color(t.text_dim)); +} + +/// A new-workbook tile: a small sheet drawn in code over its name. +fn tile(ui: &mut Ui, label: &str, sample: Option) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(150.0, 140.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + let p = ui.painter(); + let thumb = Rect::from_min_size(rect.min, vec2(rect.width(), 104.0)); + p.rect_filled(thumb, 4.0, t.grid_bg); + let edge = if resp.hovered() { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.ribbon_border) }; + let inner = thumb.shrink(10.0); + let (rows, cols) = (7, 4); + let (rh, cw) = (inner.height() / rows as f32, inner.width() / cols as f32); + if let Some(i) = sample { + // A header band and a few "values" so each sample reads differently from a blank book. + p.rect_filled(Rect::from_min_size(inner.min, vec2(inner.width(), rh)), 0.0, t.accent_soft); + for r in 1..rows { + let len = ((r * (i + 2) * 7 + i * 3) % 10) as f32 / 10.0; + let bar = Rect::from_min_size( + pos2(inner.left() + cw + 3.0, inner.top() + r as f32 * rh + 3.0), + vec2((cw * 3.0 - 6.0) * (0.25 + 0.75 * len), rh - 6.0), + ); + p.rect_filled(bar, 1.0, t.accent.gamma_multiply(0.75)); + } + } + for r in 0..=rows { + let y = inner.top() + r as f32 * rh; + p.line_segment([pos2(inner.left(), y), pos2(inner.right(), y)], Stroke::new(1.0, t.gridline)); + } + for c in 0..=cols { + let x = inner.left() + c as f32 * cw; + p.line_segment([pos2(x, inner.top()), pos2(x, inner.bottom())], Stroke::new(1.0, t.gridline)); + } + p.rect_stroke(thumb, 4.0, edge, StrokeKind::Inside); + let galley = p.layout(label.to_string(), theme::ui_font(12.5), t.text, rect.width()); + p.galley(pos2(rect.left(), thumb.bottom() + 6.0), galley, t.text); + resp +} + +/// Blank workbook and the built-in samples. +fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(18.0, 14.0); + if tile(ui, lang.tr("Blank workbook"), None).clicked() { + app.run_or_alert("file.new", json!({})); + out.close = true; + } + for (i, (name, title)) in gridcraft_engine::sample::SAMPLES.iter().enumerate() { + if tile(ui, title, Some(i)).clicked() { + app.run_or_alert("file.new", json!({"sample": name})); + out.close = true; + } + } + }); +} + +/// File name and folder of a recent path, split as text (nothing on disk is read). +fn split_path(path: &str) -> (String, String) { + let p = std::path::Path::new(path); + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string(); + let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string(); + (name, folder) +} + +/// Recent workbooks, newest first. Opening one is the user's explicit choice. +fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + if app.ui.recent.is_empty() { + dim(ui, lang.tr("No recent workbooks yet.")); + return; + } + let width = ui.available_width().min(CONTENT_W); + for path in app.ui.recent.iter().take(limit).cloned().collect::>() { + let (name, folder) = split_path(&path); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, 4.0, t.hover); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(20.0, 20.0)), Icon::Book, t.text); + p.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text); + p.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim); + let resp = resp.on_hover_text(&path); + if resp.clicked() { + app.open_path(&path); + if app.session.active().and_then(|d| d.path.as_deref()) == Some(path.as_str()) { + out.close = true; + } + } + } +} + +/// A large clickable row (icon, title, one-line description). +fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let width = ui.available_width().min(CONTENT_W); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 56.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, 4.0, t.hover); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 24.0, rect.center().y), vec2(24.0, 24.0)), icon, t.text); + p.text(pos2(rect.left() + 52.0, rect.center().y - 9.0), Align2::LEFT_CENTER, title, theme::ui_bold(13.5), t.text); + p.text(pos2(rect.left() + 52.0, rect.center().y + 10.0), Align2::LEFT_CENTER, detail, theme::ui_font(12.0), t.text_dim); + resp +} + +/// A bordered square command button (Browse…, Print, Protect Workbook). +fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 88.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + let p = ui.painter(); + p.rect_filled(rect, 6.0, if resp.hovered() { t.hover } else { t.ribbon }); + p.rect_stroke(rect, 6.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.ribbon_border }), StrokeKind::Inside); + icons::paint(p, Rect::from_center_size(pos2(rect.center().x, rect.top() + 32.0), vec2(30.0, 30.0)), icon, t.text); + let galley = p.layout(label.to_string(), theme::ui_font(12.0), t.text, rect.width() - 8.0); + p.galley(pos2(rect.center().x - galley.size().x / 2.0, rect.top() + 56.0), galley, t.text); + resp +} + +// ---------------------------------------------------------------- pages + +fn home(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + section(ui, lang, "New"); + new_tiles(app, ui, out); + ui.add_space(22.0); + section(ui, lang, "Recent"); + recent_list(app, ui, out, 8); + if app.ui.recent.len() > 8 { + ui.add_space(6.0); + if ui.link(lang.tr("More workbooks")).clicked() { + out.page = Some(Page::Open); + } + } +} + +fn new_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + new_tiles(app, ui, out); +} + +fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + if square_button(ui, Icon::OpenFolder, lang.tr("Browse…")).clicked() { + let before = app.session.active().map(|d| d.uid); + app.open_dialog("open", json!({})); + // Desktop pickers return at once; the web picker delivers later (see `SheetApp::logic`). + if app.session.active().map(|d| d.uid) != before { + out.close = true; + } + } + ui.add_space(22.0); + section(ui, lang, "Recent"); + recent_list(app, ui, out, usize::MAX); +} + +fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + let Some(d) = app.session.active() else { + dim(ui, lang.tr("No workbook is open.")); + return; + }; + let title = d.display_title(); + let path = d.path.clone(); + let sheets = d.wb.sheets.len(); + let protected = d.wb.protected_structure; + let current: Vec = { + let pr = &d.wb.props; + vec![pr.title.clone(), pr.subject.clone(), pr.author.clone(), pr.company.clone(), pr.keywords.clone(), pr.description.clone()] + }; + ui.horizontal(|ui| { + icons::paint(ui.painter(), Rect::from_min_size(ui.cursor().min, vec2(28.0, 28.0)), Icon::Book, t.text); + ui.add_space(38.0); + ui.vertical(|ui| { + ui.label(RichText::new(&title).font(theme::ui_bold(16.0)).color(t.text)); + match &path { + Some(p) => dim(ui, p), + None => dim(ui, lang.tr("Not saved yet")), + } + }); + }); + ui.add_space(18.0); + ui.horizontal(|ui| { + if square_button(ui, Icon::Lock, lang.tr("Protect Workbook")).clicked() { + app.run_or_alert("review.protectWorkbook", json!({"on": !protected})); + } + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(14.0); + ui.label(RichText::new(lang.tr("Protect Workbook")).font(theme::ui_bold(13.5)).color(t.text)); + let text = if protected { + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." + } else { + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." + }; + dim(ui, lang.tr(text)); + }); + }); + ui.add_space(22.0); + section(ui, lang, "Properties"); + egui::Grid::new("backstage_props").num_columns(2).spacing(vec2(18.0, 8.0)).show(ui, |ui| { + ui.label(RichText::new(lang.tr("Location")).color(t.text_dim)); + ui.label(path.as_deref().unwrap_or(lang.tr("Not saved yet"))); + ui.end_row(); + ui.label(RichText::new(lang.tr("Sheets")).color(t.text_dim)); + ui.label(sheets.to_string()); + ui.end_row(); + for (i, (key, label)) in PROPERTIES.iter().enumerate() { + let (Some(draft), Some(saved)) = (bs.props.get_mut(i), current.get(i)) else { continue }; + ui.label(RichText::new(lang.tr(label)).color(t.text_dim)); + let id = ui.make_persistent_id(("backstage_prop", *key)); + // Show the workbook's value unless the field is being edited (undo, another window). + if !ui.ctx().memory(|m| m.has_focus(id)) { + draft.clone_from(saved); + } + let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0)); + if resp.lost_focus() && draft != saved { + let mut params = serde_json::Map::new(); + params.insert((*key).to_string(), json!(draft.clone())); + app.run_or_alert("file.properties", Json::Object(params)); + } + ui.end_row(); + } + }); +} + +fn save_as(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + dim(ui, lang.tr("Save the workbook under another name or in another folder.")); + ui.add_space(14.0); + // Desktop: the system Save dialog. Web: the workbook downloads as .xlsx. + let label = if app.services.pick_save.is_some() || app.services.download.is_none() { "Browse…" } else { "Download (.xlsx)" }; + if square_button(ui, Icon::OpenFolder, lang.tr(label)).clicked() && app.commit_edit(0, 0, false, false) { + let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); + app.open_dialog("saveAs", json!({})); + let after = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); + if after != before || app.services.pick_save.is_none() { + out.close = true; + } + } + ui.add_space(18.0); + dim(ui, lang.tr("PDF, CSV and web pages are on the Export page.")); +} + +fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + let Some((uid, revision)) = app.session.active().map(|d| (d.uid, d.revision)) else { + dim(ui, lang.tr("No workbook is open.")); + return; + }; + let mut params = json!({"sheets": if bs.print_all { "all" } else { "active" }}); + if bs.fit_to_page { + params["fitToPage"] = json!(true); + } + let key = (uid, revision, bs.print_all, bs.fit_to_page); + if bs.preview.as_ref().is_none_or(|(k, _)| *k != key) { + let preview = app.session.run("file.printPreview", params.clone()).unwrap_or(Json::Null); + bs.preview = Some((key, preview)); + } + let pages: Vec = bs.preview.as_ref().and_then(|(_, p)| p.get("pageList")).and_then(Json::as_array).cloned().unwrap_or_default(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) { + app.run_or_alert("file.print", params.clone()); + out.close = true; + } + ui.add_space(16.0); + let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" }; + egui::ComboBox::from_id_salt("backstage_print_scope").width(230.0).selected_text(lang.tr(scope)).show_ui(ui, |ui| { + for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] { + if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() { + bs.print_all = all; + bs.preview_page = 0; + } + } + }); + ui.add_space(8.0); + if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() { + bs.preview_page = 0; + } + ui.add_space(8.0); + if ui.link(lang.tr("Page Setup…")).clicked() { + app.open_dialog("pageSetup", json!({})); + } + }); + ui.add_space(24.0); + ui.vertical(|ui| { + if pages.is_empty() { + dim(ui, lang.tr("Nothing to print.")); + return; + } + let last = pages.len().saturating_sub(1); + bs.preview_page = bs.preview_page.min(last); + let Some(page) = pages.get(bs.preview_page) else { return }; + // Paper proportions from the preview (points); hostile sizes fall back to Letter. + let w = page.get("width").and_then(Json::as_f64).filter(|w| w.is_finite() && *w > 1.0).unwrap_or(612.0) as f32; + let h = page.get("height").and_then(Json::as_f64).filter(|h| h.is_finite() && *h > 1.0).unwrap_or(792.0) as f32; + let scale = (520.0 / h).min(440.0 / w); + let (rect, _) = ui.allocate_exact_size(vec2(w * scale, h * scale), Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect.translate(vec2(2.0, 3.0)), 2.0, t.shadow); + p.rect_filled(rect, 2.0, t.grid_bg); + p.rect_stroke(rect, 2.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside); + if let Some(d) = app.session.active() { + paint_page(p, rect, w, page, &d.wb, &t); + } + let sheet = page.get("sheet").and_then(Json::as_str).unwrap_or(""); + let range = page.get("range").and_then(Json::as_str).unwrap_or(""); + ui.add_space(4.0); + dim(ui, &format!("{sheet} · {range}")); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.add_enabled(bs.preview_page > 0, egui::Button::new("◀")).on_hover_text(lang.tr("Previous page")).clicked() { + bs.preview_page = bs.preview_page.saturating_sub(1); + } + let text = lang + .tr("Page {page} of {pages}") + .replace("{page}", &(bs.preview_page + 1).to_string()) + .replace("{pages}", &pages.len().to_string()); + ui.label(text); + if ui.add_enabled(bs.preview_page < last, egui::Button::new("▶")).on_hover_text(lang.tr("Next page")).clicked() { + bs.preview_page = (bs.preview_page + 1).min(last); + } + }); + }); + }); +} + +/// Most rows and columns one preview page draws (a page holds far fewer at any readable scale). +const PREVIEW_ROWS: u32 = 200; +const PREVIEW_COLS: u32 = 60; + +/// Draws one printed page in miniature: the cells' displayed text at the page's print scale, +/// inside the sheet's margins. `paper_w` is the page width in points. +fn paint_page(p: &egui::Painter, rect: Rect, paper_w: f32, page: &Json, wb: &gridcraft_engine::model::Workbook, t: &Tokens) { + let Some(range) = page.get("range").and_then(Json::as_str).and_then(gridcraft_engine::core::RangeRef::parse) else { return }; + let Some(sh) = page.get("sheet").and_then(Json::as_str).and_then(|n| wb.sheet_index(n)).and_then(|i| wb.sheets.get(i)) else { return }; + // Preview pixels per point, and per sheet pixel (sheet geometry is 96 dpi: 0.75 pt per pixel). + let per_pt = rect.width() / paper_w; + let print_scale = + page.get("scale").and_then(Json::as_f64).filter(|s| s.is_finite() && *s > 0.0).map_or(1.0, |s| (s / 100.0) as f32).clamp(0.1, 4.0); + let per_px = per_pt * 0.75 * print_scale; + let margin = + |i: usize, default: f32| sh.print.margins.get(i).copied().filter(|m| m.is_finite()).unwrap_or(default).clamp(0.0, 3.0) * 72.0 * per_pt; + let body = Rect::from_min_max( + pos2(rect.left() + margin(0, 0.7), rect.top() + margin(2, 0.75)), + pos2(rect.right() - margin(1, 0.7), rect.bottom() - margin(3, 0.75)), + ); + if !body.is_positive() { + return; + } + let font = theme::ui_font((11.0 * per_pt * print_scale).clamp(3.0, 14.0)); + let last_row = range.end.row.min(range.start.row.saturating_add(PREVIEW_ROWS)); + let last_col = range.end.col.min(range.start.col.saturating_add(PREVIEW_COLS)); + let mut y = body.top(); + for row in range.start.row..=last_row { + let h = sh.row_height(row) * per_px; + if h <= 0.0 { + continue; + } + if y >= body.bottom() { + break; + } + let mut x = body.left(); + for col in range.start.col..=last_col { + let w = sh.col_width(col) * per_px; + if w <= 0.0 { + continue; + } + if x >= body.right() { + break; + } + let cell = gridcraft_engine::core::CellRef::new(row, col); + let text = gridcraft_engine::display::cell_text(wb, sh, cell); + if !text.is_empty() { + let numeric = sh.value(cell).as_f64().is_some(); + // Text runs on over empty neighbours, as on the sheet; numbers stay in their cell. + let mut span = w; + if !numeric { + let mut next = col.saturating_add(1); + while next <= last_col && x + span < body.right() && sh.value(gridcraft_engine::core::CellRef::new(row, next)).is_empty() { + span += sh.col_width(next) * per_px; + next = next.saturating_add(1); + } + } + let clip = Rect::from_min_size(pos2(x, y), vec2(span, h)).intersect(body); + let (anchor, at) = + if numeric { (Align2::RIGHT_CENTER, pos2(x + w - 1.0, y + h / 2.0)) } else { (Align2::LEFT_CENTER, pos2(x + 1.0, y + h / 2.0)) }; + p.with_clip_rect(clip).text(at, anchor, text, font.clone(), t.cell_text); + } + x += w; + } + y += h; + } +} + +fn export(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + if action_row(ui, Icon::Paper, lang.tr("Create PDF"), lang.tr("Save the sheet as a PDF document that keeps its page layout.")).clicked() + && export_as(app, "pdf") + { + out.close = true; + } + ui.add_space(14.0); + section(ui, lang, "Change File Type"); + for (fmt, icon, title, detail) in [ + ("csv", Icon::TextColumns, "CSV (Comma delimited)", "Save the active sheet as comma-separated text."), + ("html", Icon::Link, "Web Page (.html)", "Save the workbook as a web page."), + ] { + if action_row(ui, icon, lang.tr(title), lang.tr(detail)).clicked() && export_as(app, fmt) { + out.close = true; + } + } +} + +/// Exports the active workbook as `pdf`, `csv` or `html`: through the save picker on the desktop, +/// as a browser download on the web. Returns whether a file was written or handed over. +pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool { + if !app.commit_edit(0, 0, false, false) { + return false; + } + let id = match fmt { + "pdf" => "file.exportPdf", + "csv" => "file.exportCsv", + "html" => "file.exportHtml", + _ => return false, + }; + let title = app.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(); + if let Some(pick) = &app.services.pick_save { + let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false }; + // The picker may suggest a workbook extension; the export writes the chosen format. + let path = std::path::Path::new(&path).with_extension(fmt).to_string_lossy().into_owned(); + return match app.run(id, json!({"path": path})) { + Ok(_) => true, + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + false + } + }; + } + if app.services.download.is_none() { + app.message = Some(("GridCraft".into(), "Exporting needs a file picker, which this window doesn't have.".into())); + return false; + } + if fmt == "pdf" { + // Without a path the PDF comes back as bytes, which `SheetApp::run` hands to the browser. + return match app.run(id, json!({})) { + Ok(_) => true, + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + false + } + }; + } + match app.session.run("file.saveBytes", json!({"format": fmt})) { + Ok(r) => { + let bytes = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode); + let name = r.get("name").and_then(Json::as_str).map_or_else(|| format!("{stem}.{fmt}"), str::to_string); + match (bytes, &app.services.download) { + (Some(b), Some(dl)) => { + dl(&name, &b); + true + } + _ => false, + } + } + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + false + } + } +} + +fn options(app: &mut SheetApp, ui: &mut Ui) { + let lang = app.ui.language; + section(ui, lang, "Options|General"); + egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| { + ui.label(lang.tr("Display theme")); + let mode = app.ui.theme_mode(); + let current = match mode { + "system" => "System", + "dark" => "Dark", + _ => "Light", + }; + egui::ComboBox::from_id_salt("backstage_theme").width(160.0).selected_text(lang.tr(current)).show_ui(ui, |ui| { + for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + if ui.selectable_label(mode == value, lang.tr(label)).clicked() { + app.run_or_alert("view.theme", json!({"mode": value})); + } + } + }); + ui.end_row(); + ui.label(lang.tr("Interface language")); + egui::ComboBox::from_id_salt("backstage_language").width(160.0).selected_text(lang.name()).show_ui(ui, |ui| { + for l in i18n::Language::ALL { + if ui.selectable_label(l == lang, l.name()).clicked() { + app.run_or_alert("app.language.set", json!({"language": l.code()})); + } + } + }); + ui.end_row(); + }); + ui.add_space(6.0); + let mut formula_bar = app.ui.formula_bar; + if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { + app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); + } + ui.add_space(18.0); + section(ui, lang, "Options|Save"); + let mut autosave = app.grid.autosave(); + if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { + app.grid.toggle_autosave(); + } + let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; + ui.add_space(18.0); + section(ui, lang, "Formulas"); + ui.label(lang.tr("Workbook calculation")); + use gridcraft_engine::model::CalcMode; + for (mode, id, label) in [ + (CalcMode::Automatic, "automatic", "Automatic"), + (CalcMode::AutomaticExceptTables, "automaticExceptTables", "Automatic Except for Data Tables"), + (CalcMode::Manual, "manual", "Manual"), + ] { + if ui.radio(calc.mode == mode, lang.tr(label)).clicked() && calc.mode != mode { + app.run_or_alert("formulas.calculationOptions", json!({"mode": id})); + } + } + ui.add_space(4.0); + let mut iterative = calc.iterative; + if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() { + app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative})); + } + ui.add_space(6.0); + dim(ui, lang.tr("Calculation settings are saved with the workbook.")); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_ids_round_trip_and_unknown_ids_are_refused() { + for page in Page::ALL { + assert_eq!(Page::parse(page.id()), Some(page)); + } + assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs)); + assert_eq!(Page::parse("account"), None); + assert_eq!(Page::parse(""), None); + } + + #[test] + fn recent_paths_split_without_touching_the_disk() { + assert_eq!(split_path("/home/me/Budget.xlsx"), ("Budget.xlsx".into(), "/home/me".into())); + assert_eq!(split_path("Budget.xlsx"), ("Budget.xlsx".into(), "".into())); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e6da0f00b..38bfcce6e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -73,6 +73,7 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { "scroll": [v.scroll.x, v.scroll.y], "editor": app.editor.as_ref().map(|e| json!({"cell": e.cell.a1(), "text": e.text, "formulaBar": e.from_formula_bar, "point": e.can_point()})), "dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})), + "backstage": app.backstage.as_ref().map(|b| b.page.id()), "message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})), "selectedChart": app.selected_chart, "mode": format!("{:?}", app.session.mode), @@ -174,6 +175,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.theme", "Display Theme", ""), ("view.zoom100", "100%", ""), ("ui.dialog", "Open Dialog", ""), + ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"), ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..420911a33 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -416,6 +416,67 @@ const JAPANESE: &[(&str, &str)] = &[ ("More\nFunctions", "その他の関数"), ("Sheet Options|View", "画面"), ("Print", "印刷"), + // File › Backstage view. + ("File", "ファイル"), + ("Back", "戻る"), + ("New", "新規"), + ("Open", "開く"), + ("Info", "情報"), + ("Save", "上書き保存"), + ("Save As", "名前を付けて保存"), + ("Export", "エクスポート"), + ("Close", "閉じる"), + ("Options", "オプション"), + ("Blank workbook", "空白のブック"), + ("Recent", "最近使ったアイテム"), + ("No recent workbooks yet.", "最近使ったブックはまだありません。"), + ("More workbooks", "その他のブック"), + ("Browse…", "参照…"), + ("Not saved yet", "まだ保存されていません"), + ("Location", "場所"), + ("Sheets", "シート"), + ("Protect Workbook", "ブックの保護"), + ( + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted.", + "シートの構成はロックされています。シートの追加、移動、名前の変更、削除はできません。", + ), + ( + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted.", + "シートの構成をロックして、シートの追加、移動、名前の変更、削除をできないようにします。", + ), + ("Properties", "プロパティ"), + ("Title", "タイトル"), + ("Subject", "件名"), + ("Author", "作成者"), + ("Company", "会社名"), + ("Keywords", "キーワード"), + ("Description", "説明"), + ("No workbook is open.", "ブックが開かれていません。"), + ("Save the workbook under another name or in another folder.", "ブックを別の名前または別のフォルダーに保存します。"), + ("Download (.xlsx)", "ダウンロード (.xlsx)"), + ("PDF, CSV and web pages are on the Export page.", "PDF、CSV、Web ページは [エクスポート] ページにあります。"), + ("Print Active Sheets", "作業中のシートを印刷"), + ("Print Entire Workbook", "ブック全体を印刷"), + ("Fit Sheet on One Page", "シートを 1 ページに印刷"), + ("Page Setup…", "ページ設定…"), + ("Page {page} of {pages}", "{page} / {pages} ページ"), + ("Nothing to print.", "印刷する内容がありません。"), + ("Previous page", "前のページ"), + ("Next page", "次のページ"), + ("Create PDF", "PDF の作成"), + ("Save the sheet as a PDF document that keeps its page layout.", "ページ レイアウトを保ったまま、シートを PDF ドキュメントとして保存します。"), + ("Change File Type", "ファイルの種類の変更"), + ("CSV (Comma delimited)", "CSV (コンマ区切り)"), + ("Save the active sheet as comma-separated text.", "作業中のシートをコンマ区切りのテキストとして保存します。"), + ("Web Page (.html)", "Web ページ (.html)"), + ("Save the workbook as a web page.", "ブックを Web ページとして保存します。"), + ("Options|General", "全般"), + ("Options|Save", "保存"), + ("Show formula bar", "数式バーを表示する"), + ("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"), + ("Workbook calculation", "ブックの計算"), + ("Enable iterative calculation", "反復計算を行う"), + ("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"), ]; #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3cab..c0d7aa751 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -268,6 +268,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "어둡게", "Sheet Options|View" => "화면", "Print" => "인쇄", + // File › Backstage view. + "File" => "파일", + "Back" => "뒤로", + "New" => "새로 만들기", + "Open" => "열기", + "Info" => "정보", + "Save" => "저장", + "Save As" => "다른 이름으로 저장", + "Export" => "내보내기", + "Close" => "닫기", + "Options" => "옵션", + "Blank workbook" => "새 통합 문서", + "Recent" => "최근 항목", + "No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.", + "More workbooks" => "더 많은 통합 문서", + "Browse…" => "찾아보기…", + "Not saved yet" => "아직 저장되지 않음", + "Location" => "위치", + "Sheets" => "시트", + "Protect Workbook" => "통합 문서 보호", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "시트 구조가 잠겨 있습니다. 시트를 추가, 이동, 이름 바꾸기 또는 삭제할 수 없습니다." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "시트 구조를 잠가 시트를 추가, 이동, 이름 바꾸기 또는 삭제할 수 없게 합니다." + } + "Properties" => "속성", + "Title" => "제목", + "Subject" => "주제", + "Author" => "만든 이", + "Company" => "회사", + "Keywords" => "키워드", + "Description" => "설명", + "No workbook is open." => "열려 있는 통합 문서가 없습니다.", + "Save the workbook under another name or in another folder." => "통합 문서를 다른 이름 또는 다른 폴더에 저장합니다.", + "Download (.xlsx)" => "다운로드(.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV 및 웹 페이지는 내보내기 페이지에 있습니다.", + "Print Active Sheets" => "활성 시트 인쇄", + "Print Entire Workbook" => "전체 통합 문서 인쇄", + "Fit Sheet on One Page" => "한 페이지에 시트 맞추기", + "Page Setup…" => "페이지 설정…", + "Page {page} of {pages}" => "{page}/{pages} 페이지", + "Nothing to print." => "인쇄할 내용이 없습니다.", + "Previous page" => "이전 페이지", + "Next page" => "다음 페이지", + "Create PDF" => "PDF 만들기", + "Save the sheet as a PDF document that keeps its page layout." => "페이지 레이아웃을 유지한 채 시트를 PDF 문서로 저장합니다.", + "Change File Type" => "파일 형식 변경", + "CSV (Comma delimited)" => "CSV(쉼표로 분리)", + "Save the active sheet as comma-separated text." => "활성 시트를 쉼표로 구분된 텍스트로 저장합니다.", + "Web Page (.html)" => "웹 페이지(.html)", + "Save the workbook as a web page." => "통합 문서를 웹 페이지로 저장합니다.", + "Options|General" => "일반", + "Options|Save" => "저장", + "Show formula bar" => "수식 입력줄 표시", + "Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장", + "Workbook calculation" => "통합 문서 계산", + "Enable iterative calculation" => "반복 계산 사용", + "Calculation settings are saved with the workbook." => "계산 설정은 통합 문서와 함께 저장됩니다.", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3f2..a5d00a072 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -269,6 +269,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Escuro", "Sheet Options|View" => "Exibir", "Print" => "Imprimir", + // File › Backstage view. + "File" => "Arquivo", + "Back" => "Voltar", + "New" => "Novo", + "Open" => "Abrir", + "Info" => "Informações", + "Save" => "Salvar", + "Save As" => "Salvar como", + "Export" => "Exportar", + "Close" => "Fechar", + "Options" => "Opções", + "Blank workbook" => "Pasta de trabalho em branco", + "Recent" => "Recentes", + "No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.", + "More workbooks" => "Mais pastas de trabalho", + "Browse…" => "Procurar…", + "Not saved yet" => "Ainda não salvo", + "Location" => "Local", + "Sheets" => "Planilhas", + "Protect Workbook" => "Proteger pasta de trabalho", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "A estrutura das planilhas está bloqueada: não é possível adicionar, mover, renomear nem excluir planilhas." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Bloqueie a estrutura das planilhas para que não possam ser adicionadas, movidas, renomeadas nem excluídas." + } + "Properties" => "Propriedades", + "Title" => "Título", + "Subject" => "Assunto", + "Author" => "Autor", + "Company" => "Empresa", + "Keywords" => "Palavras-chave", + "Description" => "Descrição", + "No workbook is open." => "Nenhuma pasta de trabalho está aberta.", + "Save the workbook under another name or in another folder." => "Salve a pasta de trabalho com outro nome ou em outra pasta.", + "Download (.xlsx)" => "Baixar (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV e páginas da Web estão na página Exportar.", + "Print Active Sheets" => "Imprimir planilhas ativas", + "Print Entire Workbook" => "Imprimir toda a pasta de trabalho", + "Fit Sheet on One Page" => "Ajustar planilha em uma página", + "Page Setup…" => "Configurar página…", + "Page {page} of {pages}" => "Página {page} de {pages}", + "Nothing to print." => "Nada para imprimir.", + "Previous page" => "Página anterior", + "Next page" => "Próxima página", + "Create PDF" => "Criar PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Salve a planilha como um documento PDF que mantém o layout da página.", + "Change File Type" => "Alterar tipo de arquivo", + "CSV (Comma delimited)" => "CSV (separado por vírgulas)", + "Save the active sheet as comma-separated text." => "Salve a planilha ativa como texto separado por vírgulas.", + "Web Page (.html)" => "Página da Web (.html)", + "Save the workbook as a web page." => "Salve a pasta de trabalho como uma página da Web.", + "Options|General" => "Geral", + "Options|Save" => "Salvar", + "Show formula bar" => "Mostrar barra de fórmulas", + "Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração", + "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.", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d5485658..cbe2aa3df 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -268,6 +268,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Тёмная", "Sheet Options|View" => "На экране", "Print" => "Печать", + // File › Backstage view. + "File" => "Файл", + "Back" => "Назад", + "New" => "Создать", + "Open" => "Открыть", + "Info" => "Сведения", + "Save" => "Сохранить", + "Save As" => "Сохранить как", + "Export" => "Экспорт", + "Close" => "Закрыть", + "Options" => "Параметры", + "Blank workbook" => "Пустая книга", + "Recent" => "Последние", + "No recent workbooks yet." => "Недавних книг пока нет.", + "More workbooks" => "Другие книги", + "Browse…" => "Обзор…", + "Not saved yet" => "Ещё не сохранено", + "Location" => "Расположение", + "Sheets" => "Листы", + "Protect Workbook" => "Защитить книгу", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "Структура листов заблокирована: листы нельзя добавлять, перемещать, переименовывать или удалять." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Заблокировать структуру листов, чтобы их нельзя было добавлять, перемещать, переименовывать или удалять." + } + "Properties" => "Свойства", + "Title" => "Название", + "Subject" => "Тема", + "Author" => "Автор", + "Company" => "Организация", + "Keywords" => "Ключевые слова", + "Description" => "Описание", + "No workbook is open." => "Нет открытых книг.", + "Save the workbook under another name or in another folder." => "Сохранить книгу под другим именем или в другой папке.", + "Download (.xlsx)" => "Скачать (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV и веб-страницы — на странице «Экспорт».", + "Print Active Sheets" => "Напечатать активные листы", + "Print Entire Workbook" => "Напечатать всю книгу", + "Fit Sheet on One Page" => "Вписать лист на одну страницу", + "Page Setup…" => "Параметры страницы…", + "Page {page} of {pages}" => "Страница {page} из {pages}", + "Nothing to print." => "Нечего печатать.", + "Previous page" => "Предыдущая страница", + "Next page" => "Следующая страница", + "Create PDF" => "Создать PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Сохранить лист как PDF-документ с сохранением разметки страниц.", + "Change File Type" => "Изменить тип файла", + "CSV (Comma delimited)" => "CSV (разделители — запятые)", + "Save the active sheet as comma-separated text." => "Сохранить активный лист как текст с разделителями-запятыми.", + "Web Page (.html)" => "Веб-страница (.html)", + "Save the workbook as a web page." => "Сохранить книгу как веб-страницу.", + "Options|General" => "Общие", + "Options|Save" => "Сохранение", + "Show formula bar" => "Показывать строку формул", + "Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения", + "Workbook calculation" => "Вычисления в книге", + "Enable iterative calculation" => "Включить итеративные вычисления", + "Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada73..b1bebfd85 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -268,6 +268,61 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "深色", "Sheet Options|View" => "屏幕", "Print" => "打印", + // File › Backstage view. + "File" => "文件", + "Back" => "返回", + "New" => "新建", + "Open" => "打开", + "Info" => "信息", + "Save" => "保存", + "Save As" => "另存为", + "Export" => "导出", + "Close" => "关闭", + "Options" => "选项", + "Blank workbook" => "空白工作簿", + "Recent" => "最近", + "No recent workbooks yet." => "还没有最近使用的工作簿。", + "More workbooks" => "更多工作簿", + "Browse…" => "浏览…", + "Not saved yet" => "尚未保存", + "Location" => "位置", + "Sheets" => "工作表", + "Protect Workbook" => "保护工作簿", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => "工作表结构已锁定:无法添加、移动、重命名或删除工作表。", + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => "锁定工作表结构,使工作表无法被添加、移动、重命名或删除。", + "Properties" => "属性", + "Title" => "标题", + "Subject" => "主题", + "Author" => "作者", + "Company" => "公司", + "Keywords" => "关键词", + "Description" => "说明", + "No workbook is open." => "没有打开的工作簿。", + "Save the workbook under another name or in another folder." => "以其他名称或在其他文件夹中保存工作簿。", + "Download (.xlsx)" => "下载 (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF、CSV 和网页位于“导出”页。", + "Print Active Sheets" => "打印活动工作表", + "Print Entire Workbook" => "打印整个工作簿", + "Fit Sheet on One Page" => "将工作表调整为一页", + "Page Setup…" => "页面设置…", + "Page {page} of {pages}" => "第 {page} 页,共 {pages} 页", + "Nothing to print." => "没有可打印的内容。", + "Previous page" => "上一页", + "Next page" => "下一页", + "Create PDF" => "创建 PDF", + "Save the sheet as a PDF document that keeps its page layout." => "将工作表另存为保留页面布局的 PDF 文档。", + "Change File Type" => "更改文件类型", + "CSV (Comma delimited)" => "CSV (逗号分隔)", + "Save the active sheet as comma-separated text." => "将活动工作表另存为逗号分隔的文本。", + "Web Page (.html)" => "网页 (.html)", + "Save the workbook as a web page." => "将工作簿另存为网页。", + "Options|General" => "常规", + "Options|Save" => "保存", + "Show formula bar" => "显示编辑栏", + "Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿", + "Workbook calculation" => "工作簿计算", + "Enable iterative calculation" => "启用迭代计算", + "Calculation settings are saved with the workbook." => "计算设置随工作簿一起保存。", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b2079c821..fcd2d8726 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -111,6 +111,12 @@ pub enum Icon { Sheet, Book, Folder, + /// File › Backstage navigation (line art, drawn in the navigation colour). + Back, + NewFile, + OpenFolder, + Info, + Print, } struct G<'a> { @@ -605,6 +611,29 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { Icon::Folder => { g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], YELLOW); } + Icon::Back => { + g.circle(8.0, 8.0, 6.5, None, Some(c)); + g.line(&[(11.5, 8.0), (4.8, 8.0)], c, 1.2); + g.line(&[(7.5, 5.3), (4.8, 8.0), (7.5, 10.7)], c, 1.2); + } + Icon::NewFile => { + g.line(&[(9.5, 1.5), (3.5, 1.5), (3.5, 14.5), (12.5, 14.5), (12.5, 4.5), (9.5, 1.5), (9.5, 4.5), (12.5, 4.5)], c, 1.1); + } + Icon::OpenFolder => { + g.line(&[(13.0, 6.5), (13.0, 4.5), (7.5, 4.5), (6.0, 3.0), (1.5, 3.0), (1.5, 13.0)], c, 1.1); + g.line(&[(1.5, 13.0), (4.0, 7.0), (15.0, 7.0), (12.5, 13.0), (1.5, 13.0)], c, 1.1); + } + Icon::Info => { + g.circle(8.0, 8.0, 6.5, None, Some(c)); + g.line(&[(8.0, 7.0), (8.0, 11.8)], c, 1.3); + g.circle(8.0, 4.8, 0.8, Some(c), None); + } + Icon::Print => { + g.line(&[(4.5, 5.5), (4.5, 1.5), (11.5, 1.5), (11.5, 5.5)], c, 1.1); + g.line(&[(4.0, 11.5), (1.5, 11.5), (1.5, 5.5), (14.5, 5.5), (14.5, 11.5), (12.0, 11.5)], c, 1.1); + g.line(&[(4.5, 9.0), (4.5, 14.5), (11.5, 14.5), (11.5, 9.0), (4.5, 9.0)], c, 1.1); + g.circle(12.3, 7.6, 0.6, Some(c), None); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eecdf..32b63fa63 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod backstage; mod border_preview; mod cell_pictures; pub mod chartview; @@ -141,6 +142,8 @@ pub struct SheetApp { pub editor: Option, pub text_box_editor: Option, pub dialog: Option, + /// The File tab's Backstage view, when open (UI-only; replaces the ribbon, grid and tabs). + pub backstage: Option, pub message: Option<(String, String)>, pub toast: Option<(String, f64)>, pub services: Services, @@ -173,6 +176,7 @@ impl SheetApp { editor: None, text_box_editor: None, dialog: None, + backstage: None, message: None, toast: None, services, @@ -339,6 +343,22 @@ impl SheetApp { self.ui.language = i18n::Language::Ja; Ok(json!({"language": i18n::Language::Ja})) } + "ui.backstage" => { + // `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet. + match p.get("page").and_then(Json::as_str).map(str::trim) { + Some("close" | "none" | "") => self.backstage = None, + Some(id) => match backstage::Page::parse(id) { + Some(page) => self.open_backstage(page), + None => { + return Some(Err(format!( + "unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)" + ))); + } + }, + None => self.open_backstage(backstage::Page::Home), + } + Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})) + } "ui.dialog" => { let name = p.get("name").and_then(Json::as_str).unwrap_or(""); self.open_dialog(name, p.clone()); @@ -407,10 +427,28 @@ impl SheetApp { dl(&name, &b); } } + // File › Options (`file.options`) and the File tab itself open the Backstage view. + "options" => self.open_backstage(backstage::Page::Options), + "backstage" => { + let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home); + self.open_backstage(page); + } other => self.dialog = dialogs::Dialog::open(self, other, params), } } + /// Opens the File tab's Backstage view on `page`, committing a cell still being edited first + /// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet. + pub fn open_backstage(&mut self, page: backstage::Page) { + if !self.commit_edit(0, 0, false, false) { + return; + } + match &mut self.backstage { + Some(b) => b.page = page, + None => self.backstage = Some(backstage::Backstage::new(page)), + } + } + pub fn open_path(&mut self, path: &str) { match self.run("file.open", json!({"path": path})) { Ok(_) => { @@ -577,8 +615,10 @@ impl SheetApp { .unwrap_or_default(); for (name, bytes) in arrived { let b64 = gridcraft_engine::io::base64_encode(&bytes); - if let Err(e) = self.run("file.open", json!({"name": name, "base64": b64})) { - self.message = Some(("GridCraft".into(), clean_error(&e))); + match self.run("file.open", json!({"name": name, "base64": b64})) { + // A file picked from File › Open (web) arrives here: show it, not the Backstage. + Ok(_) => self.backstage = None, + Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); } @@ -605,23 +645,30 @@ impl SheetApp { // The language the widgets translate with this frame (see `i18n::current`). i18n::set_current(&ctx, self.ui.language); let t = theme::Tokens::get(&ctx); - self.ribbon_keys(&ctx); + if self.backstage.is_none() { + self.ribbon_keys(&ctx); + } ribbon::title_bar(self, ui); - ribbon::show(self, ui); - if self.ui.formula_bar { - formula_bar::show(self, ui); + if self.backstage.is_some() { + // File tab: the Backstage view replaces everything below the title bar. + backstage::show(self, ui); + } else { + ribbon::show(self, ui); + if self.ui.formula_bar { + formula_bar::show(self, ui); + } + if self.ui.status_bar { + tabs::status_bar(self, ui); + } + tabs::sheet_tabs(self, ui); + pivot_pane::show(self, ui); + panes::show(self, ui); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { + let g0 = now_ms(); + grid::show(self, ui); + self.perf.grid_ms = now_ms() - g0; + }); } - if self.ui.status_bar { - tabs::status_bar(self, ui); - } - tabs::sheet_tabs(self, ui); - pivot_pane::show(self, ui); - panes::show(self, ui); - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { - let g0 = now_ms(); - grid::show(self, ui); - self.perf.grid_ms = now_ms() - g0; - }); dialogs::show(self, &ctx); widgets::message_box(self, &ctx); widgets::toast(self, &ctx); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..06dee5bd2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -207,30 +207,15 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }); } +/// The File tab opens the Backstage view (New, Open, Info, Save As, Print, Export, Options…), like +/// Excel's; the ribbon's tab and collapsed state are left as they were for when it closes. fn file_menu(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let label = egui::RichText::new("File").font(theme::ui_font(13.5)).color(t.accent); - let file = ui.add_sized(vec2(46.0, 30.0), egui::Button::new(label).frame(false)); - egui::Popup::menu(&file).show(|ui| { - for (label, id, needs_document) in [ - ("New Workbook", "file.new", false), - ("Open…", "file.open", false), - ("Save", "file.save", true), - ("Save As…", "file.saveAs", true), - ("Close", "file.close", true), - ] { - if id == "file.save" || id == "file.close" { - ui.separator(); - } - if ui.add_enabled(!needs_document || app.session.active().is_some(), egui::Button::new(label)).clicked() { - // File operations must include the cell the user is still editing. - if app.commit_edit(0, 0, false, false) { - app.run_or_alert(id, json!({})); - } - ui.close(); - } - } - }); + let label = egui::RichText::new(app.ui.language.tr("File")).font(theme::ui_font(13.5)).color(t.accent); + let file = ui.add(egui::Button::new(label).frame(false).min_size(vec2(46.0, 30.0))); + if file.clicked() { + app.open_backstage(crate::backstage::Page::Home); + } } fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index f3b07f4c8..d47bf5a7f 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -1,4 +1,4 @@ -//! The shared ribbon exposes basic file operations without requiring a native menu. +//! The File tab opens the Backstage view, which exposes the file operations without a native menu. use std::sync::{Arc, Mutex}; @@ -21,15 +21,26 @@ fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { h } +/// Opens File (the Backstage view) and clicks a navigation entry or page button. fn choose(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { - h.get_by_label("File").click(); - h.run_steps(3); - h.get_by_label(label).click(); + if h.state().backstage.is_none() { + h.get_by_label("File").click(); + h.run_steps(3); + } + click(h, label); +} + +fn click(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); h.run_steps(3); } +fn page(h: &egui_kittest::Harness<'static, SheetApp>) -> Option<&'static str> { + h.state().backstage.as_ref().map(|b| b.page.id()) +} + #[test] -fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() { +fn new_and_open_from_the_file_tab_keep_the_ribbon_as_it_was() { let opened = Arc::new(Mutex::new(0)); let capture = opened.clone(); let mut h = harness(Services { @@ -42,39 +53,85 @@ fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() { h.state_mut().ui.ribbon_tab = "Data".into(); h.state_mut().ui.ribbon_collapsed = true; h.run_steps(2); - choose(&mut h, "New Workbook"); + h.get_by_label("File").click(); + h.run_steps(3); + assert_eq!(page(&h), Some("home"), "the File tab opens the Backstage view on Home"); + choose(&mut h, "New"); + assert_eq!(page(&h), Some("new")); + click(&mut h, "Blank workbook"); assert_eq!(h.state().session.documents().len(), 2); - choose(&mut h, "Open…"); - assert_eq!(*opened.lock().unwrap(), 1, "Open invokes the platform file picker"); + assert!(h.state().backstage.is_none(), "creating a workbook returns to the sheet"); + choose(&mut h, "Open"); + click(&mut h, "Browse…"); + assert_eq!(*opened.lock().unwrap(), 1, "Browse invokes the platform file picker"); + assert_eq!(page(&h), Some("open"), "a cancelled pick stays on File › Open"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().backstage.is_none(), "Esc returns to the sheet"); assert_eq!(h.state().ui.ribbon_tab, "Data"); assert!(h.state().ui.ribbon_collapsed); - assert!(h.query_by_label("Open…").is_none(), "choosing an action dismisses File"); } #[test] fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() { - let downloads = Arc::new(Mutex::new(Vec::>::new())); + let downloads = Arc::new(Mutex::new(Vec::<(String, Vec)>::new())); let capture = downloads.clone(); - let mut h = harness(Services { download: Some(Box::new(move |_, bytes| capture.lock().unwrap().push(bytes.to_vec()))), ..Default::default() }); + let mut h = harness(Services { + download: Some(Box::new(move |name, bytes| capture.lock().unwrap().push((name.to_string(), bytes.to_vec())))), + ..Default::default() + }); h.input_mut().events.push(egui::Event::Text("123".into())); h.run_steps(2); assert!(h.state().editor.is_some()); choose(&mut h, "Save"); - choose(&mut h, "Save As…"); + assert!(h.state().editor.is_none(), "opening File commits the cell draft"); + assert_eq!(page(&h), Some("saveAs"), "a workbook without a file saves through Save As"); + click(&mut h, "Download (.xlsx)"); + assert!(h.state().backstage.is_none()); + choose(&mut h, "Save As"); + click(&mut h, "Download (.xlsx)"); + choose(&mut h, "Export"); + click(&mut h, "CSV (Comma delimited)"); let saved = downloads.lock().unwrap(); - assert_eq!(saved.len(), 2); - for bytes in saved.iter() { + assert_eq!(saved.len(), 3); + for (_, bytes) in saved.iter().take(2) { let (wb, _) = gridcraft_engine::io::open_bytes("saved.xlsx", bytes).unwrap(); assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0)); } + assert!(saved[2].0.ends_with(".csv")); + assert!(String::from_utf8_lossy(&saved[2].1).contains("123")); drop(saved); - assert!(h.state().editor.is_none()); choose(&mut h, "Close"); + assert!(h.state().backstage.is_none()); assert_eq!(h.state().session.documents().len(), 1); assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0)); } +#[test] +fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { + let mut h = harness(Services::default()); + let r = h.state_mut().run("file.options", serde_json::json!({})).unwrap(); + assert_eq!(r["calculation"]["mode"], "automatic"); + h.run_steps(3); + assert_eq!(page(&h), Some("options")); + click(&mut h, "Info"); + assert_eq!(page(&h), Some("info")); + for id in ["print", "export", "saveAs", "new", "open", "home"] { + h.state_mut().run("ui.backstage", serde_json::json!({"page": id})).unwrap(); + h.run_steps(2); + assert_eq!(page(&h), Some(id)); + } + assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "account"})).is_err()); + h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap(); + assert!(h.state().backstage.is_none()); + // The back arrow returns to the sheet too. + h.state_mut().run("ui.backstage", serde_json::json!({})).unwrap(); + h.run_steps(2); + click(&mut h, "Back"); + assert!(h.state().backstage.is_none()); +} + #[test] fn export_as_pdf_is_delivered_as_a_download() { let files = Arc::new(Mutex::new(Vec::<(String, Vec)>::new())); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aeb540d82..09cb59069 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -25,13 +25,14 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels | | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | -| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, message, grid rect, scroll, perf | +| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf | | `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points | | `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing) | | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing) | | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `ui.ribbon` | `{tab}` | Switch ribbon tab | | `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs | +| `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | | `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path) | | `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?}` | UI preferences | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e98835d31..d039e3d2d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,11 +4,11 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 258 / 290 commands implemented (89.0%).** +**Overall: 259 / 290 commands implemented (89.3%).** | Tab | Implemented | Total | Parity | |---|---|---|---| -| File | 10 | 11 | 91% | +| File | 11 | 11 | 100% | | Home | 94 | 94 | 100% | | Insert | 15 | 25 | 60% | | Draw | 4 | 4 | 100% | @@ -24,7 +24,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | PivotTable Analyze | 12 | 13 | 92% | | PivotTable Design | 4 | 4 | 100% | -## File (10 / 11, 91%) +## File (11 / 11, 100%) Implemented: @@ -38,9 +38,6 @@ Implemented: - `file.exportCsv` CSV (Export) - `file.exportHtml` Web Page (Export) - `file.properties` Properties (Info) - -Missing: - - `file.options` Options (Options) ## Home (94 / 94, 100%) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..9dd4a1de4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,10 +1,10 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (File tab Backstage view, #174) · **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 -separately in [parity-checklist.md](parity-checklist.md) (258 / 290). +separately in [parity-checklist.md](parity-checklist.md) (259 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes @@ -16,13 +16,13 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes |---|---|---|---| | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | -| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | +| 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) | | 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 | +| 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 | **no** (Page Break Preview only) | 8–12 h | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | @@ -91,5 +91,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | File tab Backstage view and File › Options (#174) | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From bd5c5c1d261e14f82ac6aecc6a657774774d96fe Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:09:27 +0200 Subject: [PATCH 072/102] XLSX/IO: read legacy .xls (BIFF8) workbooks (#121) Clean-room import of Excel 97-2003 workbooks from the public [MS-CFB] and [MS-XLS] specifications: compound-file streams (FAT, mini FAT, directory tree), the BIFF8 Workbook stream with Continue records, shared strings, all cell records, formulas decoded from Ptg tokens (shared and array formulas, 3-D refs, names, add-in/newer functions by name) with cached results, number formats, the 1904 date system, merges, column widths, row heights, defined names and print areas/titles. Encrypted (FILEPASS) and BIFF5 files are refused with a clear message; everything else is skipped with a warning. .xls opens as a new unsaved workbook (Save As .xlsx). Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + ROADMAP.md | 1 + apps/gridcraft-web/src/web.rs | 2 +- apps/gridcraft/src/main.rs | 3 +- crates/engine/src/cmd/file.rs | 7 +- crates/engine/src/io.rs | 20 +- crates/engine/src/tests.rs | 85 ++++ crates/mcp/src/tools.rs | 2 +- crates/xlsx/src/cfb.rs | 216 ++++++++ crates/xlsx/src/lib.rs | 20 +- crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/xls_tests.rs | 439 +++++++++++++++++ crates/xlsx/src/xls/formula.rs | 496 +++++++++++++++++++ crates/xlsx/src/xls/ftab.rs | 376 ++++++++++++++ crates/xlsx/src/xls/mod.rs | 765 +++++++++++++++++++++++++++++ crates/xlsx/src/xls/records.rs | 198 ++++++++ docs/cli.md | 5 + docs/file-format-parity.md | 11 +- docs/gaps.md | 10 +- 19 files changed, 2635 insertions(+), 23 deletions(-) create mode 100644 crates/xlsx/src/tests/xls_tests.rs create mode 100644 crates/xlsx/src/xls/formula.rs create mode 100644 crates/xlsx/src/xls/ftab.rs create mode 100644 crates/xlsx/src/xls/mod.rs create mode 100644 crates/xlsx/src/xls/records.rs diff --git a/README.md b/README.md index 770b3fe61..db6a5bf6d 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ - **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays), tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines, PivotTables and print settings round-trip. CSV and TSV too. + Excel 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet). [XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas. [ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas. [Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data. diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..f961ed450 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index f5744bd19..f4f4e3fb1 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -109,7 +109,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let Some(file) = rfd::AsyncFileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) .pick_file() .await else { diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3c73a9541..14953509a 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -126,8 +126,9 @@ fn services() -> Services { })), pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) .add_filter("Excel Workbook", &["xlsx", "xlsm"]) + .add_filter("Excel 97-2003 Workbook (import)", &["xls"]) .add_filter("Excel Binary Workbook (data import)", &["xlsb"]) .add_filter("OpenDocument Spreadsheet (data import)", &["ods"]) .add_filter("CSV", &["csv"]) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d52418b4c..8837ea6d1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -8,7 +8,7 @@ use crate::DocState; pub fn specs() -> Vec { vec![ cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xlsb and ods data import, csv, tsv, txt, json)", always, open), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), @@ -47,8 +47,9 @@ fn open(s: &mut Session, p: &Json) -> Result { return ok(); }; let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?; - let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb)) - || matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb); + let imported = + matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls)) + || matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls); let title = if imported { format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book")) } else { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 25dec989b..52ee2d1c9 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,4 +1,4 @@ -//! Opening and saving files: XLSX, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export. +//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export. use std::fmt::Write as _; @@ -11,6 +11,8 @@ use crate::{EngineError, Result}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FileKind { Xlsx, + /// Excel 97-2003 workbook (import only). + Xls, Xlsb, Ods, Csv, @@ -24,6 +26,7 @@ impl FileKind { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); Some(match ext.as_str() { "xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx, + "xls" | "xlt" => FileKind::Xls, "xlsb" => FileKind::Xlsb, "ods" => FileKind::Ods, "csv" => FileKind::Csv, @@ -45,9 +48,13 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { "'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again." ))); } + if sniffed == gridcraft_xlsx::Format::Xls { + let (wb, report) = gridcraft_xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?; + return Ok((wb, report.warnings)); + } if sniffed == gridcraft_xlsx::Format::LegacyBinary { return Err(EngineError::Other(format!( - "'{name}' is an Excel 97-2003 (.xls) workbook or another legacy binary file. GridCraft doesn't open these yet; save it as .xlsx in Excel and try again." + "'{name}' is a legacy binary file but not an Excel workbook (no workbook stream). GridCraft can't open it." ))); } // Content wins over the extension: an ODS or XLSB package is imported as what it is, @@ -107,6 +114,9 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { let sheet = wb.active_sheet; match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) { FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())), + FileKind::Xls => { + Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into())) + } 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())), FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()), @@ -388,10 +398,10 @@ mod tests { } #[test] - fn legacy_xls_is_not_called_encrypted() { - let err = open_bytes("old.xls", &cfb(&["Workbook", "\u{5}SummaryInformation"])).unwrap_err(); + fn other_compound_files_are_not_called_encrypted() { + let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("97-2003") && msg.contains(".xlsx"), "got: {msg}"); + assert!(msg.contains("not an Excel workbook"), "got: {msg}"); assert!(!msg.contains("password"), "got: {msg}"); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55e..df8b14269 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1242,6 +1242,91 @@ fn xlsb_import_never_reuses_source_as_save_target() { std::fs::remove_dir_all(dir).unwrap(); } +/// A one-sheet Excel 97-2003 workbook ([MS-CFB] container, [MS-XLS] BIFF8 stream) built in code: +/// A1 = 42 and A2 = `A1*2` with its cached result. +fn xls_fixture() -> Vec { + fn rec(id: u16, payload: &[u8]) -> Vec { + [&id.to_le_bytes()[..], &(payload.len() as u16).to_le_bytes(), payload].concat() + } + let bof = |dt: u16| rec(0x0809, &[&0x0600u16.to_le_bytes()[..], &dt.to_le_bytes(), &[0; 12]].concat()); + let sheet = [ + bof(0x0010), + rec(0x0203, &[&[0u8, 0, 0, 0, 0, 0][..], &42f64.to_le_bytes()].concat()), + rec(0x0006, &[&[1u8, 0, 0, 0, 0, 0][..], &84f64.to_le_bytes(), &[0; 6], &[9, 0, 0x44, 0, 0, 0, 0xC0, 0x1E, 2, 0, 0x05]].concat()), + rec(0x000A, &[]), + ] + .concat(); + let globals = |pos: u32| [bof(0x0005), rec(0x0085, &[&pos.to_le_bytes()[..], &[0, 0, 3, 0], b"Old"].concat()), rec(0x000A, &[])].concat(); + let stream = [globals(globals(0).len() as u32), sheet].concat(); + // Sectors: 0 FAT, 1 directory, 2 mini FAT, 3 mini stream container. + let mut b = vec![0u8; 512 * 5]; + let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); + put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + put(26, &3u16.to_le_bytes()); + put(30, &9u16.to_le_bytes()); + put(32, &6u16.to_le_bytes()); + put(44, &1u32.to_le_bytes()); + put(48, &1u32.to_le_bytes()); + put(56, &4096u32.to_le_bytes()); + put(60, &2u32.to_le_bytes()); + put(64, &1u32.to_le_bytes()); + put(68, &0xFFFF_FFFEu32.to_le_bytes()); + for i in 1..109 { + put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); + } + for (i, v) in [0xFFFF_FFFDu32, 0xFFFF_FFFE, 0xFFFF_FFFE, 0xFFFF_FFFE].iter().enumerate() { + put(512 + i * 4, &v.to_le_bytes()); + } + for i in 4..128 { + put(512 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); + } + let minis = stream.len().div_ceil(64); + for i in 0..128 { + let next = if i + 1 < minis { + i as u32 + 1 + } else if i + 1 == minis { + 0xFFFF_FFFE + } else { + 0xFFFF_FFFF + }; + put(512 * 3 + i * 4, &next.to_le_bytes()); + } + let entry = |k: usize, name: &str, kind: u8, child: u32, start: u32, size: u32, put: &mut dyn FnMut(usize, &[u8])| { + let at = 512 * 2 + k * 128; + let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); + put(at, &units); + put(at + 64, &(units.len() as u16 + 2).to_le_bytes()); + put(at + 66, &[kind, 1]); + put(at + 68, &[0xFF; 8]); + put(at + 76, &child.to_le_bytes()); + put(at + 116, &start.to_le_bytes()); + put(at + 120, &size.to_le_bytes()); + }; + entry(0, "Root Entry", 5, 1, 3, 512, &mut put); + entry(1, "Workbook", 2, 0xFFFF_FFFF, 0, stream.len() as u32, &mut put); + put(512 * 4, &stream); + b +} + +#[test] +fn xls_import_keeps_formulas_and_never_saves_back_to_xls() { + let bytes = xls_fixture(); + assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xls); + let mut s = s(); + let r = s.execute("file.open", json!({"name": "legacy.xls", "base64": crate::io::base64_encode(&bytes)})).unwrap(); + assert!(r["warnings"][0].as_str().unwrap().contains("97-2003")); + assert_eq!(r["sheets"], json!(["Old"])); + let d = s.doc().unwrap(); + assert!(d.path.is_none()); + assert_eq!(d.display_title(), "legacy (imported).xlsx"); + assert_eq!(v(&s, "A2"), Value::Number(84.0)); + // The formula is live: editing its input recalculates it. + s.execute("cell.set", json!({"cell": "A1", "input": "50"})).unwrap(); + assert_eq!(v(&s, "A2"), Value::Number(100.0)); + assert!(s.execute("file.saveBytes", json!({"format": "xls"})).is_err()); + assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok()); +} + #[test] fn malformed_xlsb_keeps_the_current_workbook() { let mut s = s(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d5d5c3601..eb741b8bc 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec { tool( "open_workbook", "Open workbook", - "Open a workbook file (.xlsx, .xlsm, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.", + "Open a workbook file (.xlsx, .xlsm, .xls, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. Excel 97-2003 .xls imports values, formulas and number formats; XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.", obj(json!({"path": string("File path")}), &["path"]), false, ), diff --git a/crates/xlsx/src/cfb.rs b/crates/xlsx/src/cfb.rs index 252096dfa..3a7471852 100644 --- a/crates/xlsx/src/cfb.rs +++ b/crates/xlsx/src/cfb.rs @@ -97,6 +97,222 @@ fn directory_names(b: &[u8]) -> Option> { Some(names) } +/// Directory entry type of a stream object ([MS-CFB] 2.6.1). +const STREAM_OBJECT: u8 = 2; +/// "No entry" in the directory tree (NOSTREAM). +const NO_STREAM: u32 = 0xFFFF_FFFF; +/// Streams larger than this are refused (the format allows more, a workbook never needs it). +const MAX_STREAM: u64 = 1 << 30; + +/// One directory entry ([MS-CFB] 2.6). +struct DirEntry { + name: String, + kind: u8, + left: u32, + right: u32, + child: u32, + start: u32, + size: u64, +} + +/// A parsed compound file: the FAT, the mini FAT and the directory, all bounds-checked. +/// Implemented from Microsoft's public [MS-CFB] specification. +pub(crate) struct Compound<'a> { + bytes: &'a [u8], + sector_size: usize, + mini_sector_size: usize, + mini_cutoff: u64, + fat: Vec, + mini_fat: Vec, + entries: Vec, +} + +impl<'a> Compound<'a> { + /// Parses the header, FAT, mini FAT and directory. Errors on anything that can't be followed. + pub(crate) fn open(b: &'a [u8]) -> Result, String> { + let bad = |m: &str| format!("damaged compound file: {m}"); + if !b.starts_with(&SIGNATURE) { + return Err(bad("missing signature")); + } + let major = u16_at(b, 26).ok_or_else(|| bad("truncated header"))?; + let shift = u16_at(b, 30).ok_or_else(|| bad("truncated header"))?; + if shift != 9 && shift != 12 { + return Err(bad("unsupported sector size")); + } + let mini_shift = u16_at(b, 32).ok_or_else(|| bad("truncated header"))?; + if mini_shift != 6 { + return Err(bad("unsupported mini sector size")); + } + let sector_size = 1usize << shift; + let per = sector_size / 4; + // Sectors that physically exist after the header sector: every chain is bounded by it. + let sectors = (b.len() / sector_size).saturating_sub(1); + let sector = |id: u32| -> Option<&[u8]> { + let start = (id as usize).checked_add(1)?.checked_mul(sector_size)?; + b.get(start..start.checked_add(sector_size)?) + }; + // DIFAT: 109 FAT sector ids in the header, then chained DIFAT sectors. + let mut fat_sectors: Vec = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect(); + let mut next = u32_at(b, 68).ok_or_else(|| bad("truncated header"))?; + let mut hops = 0usize; + while next < MAX_REG_SECT { + hops += 1; + if hops > sectors.min(MAX_DIFAT_SECTORS * 64) { + return Err(bad("DIFAT chain loops")); + } + let s = sector(next).ok_or_else(|| bad("DIFAT sector out of range"))?; + fat_sectors.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT)); + next = u32_at(s, (per - 1) * 4).ok_or_else(|| bad("truncated DIFAT sector"))?; + } + if fat_sectors.len() > sectors { + return Err(bad("more FAT sectors than the file holds")); + } + let mut fat = Vec::with_capacity(fat_sectors.len().saturating_mul(per)); + for id in fat_sectors { + let s = sector(id).ok_or_else(|| bad("FAT sector out of range"))?; + fat.extend((0..per).filter_map(|i| u32_at(s, i * 4))); + } + let mut cf = Compound { bytes: b, sector_size, mini_sector_size: 64, mini_cutoff: 4096, fat, mini_fat: vec![], entries: vec![] }; + if let Some(c) = u32_at(b, 56) { + cf.mini_cutoff = u64::from(c); + } + // Directory. + let dir_start = u32_at(b, 48).ok_or_else(|| bad("truncated header"))?; + let dir = cf.read_chain(dir_start, u64::MAX)?; + for e in dir.as_chunks::<128>().0 { + let len = usize::from(u16_at(e, 64).unwrap_or(0)).min(64); + let units: Vec = (0..len.saturating_sub(2) / 2).filter_map(|i| u16_at(e, i * 2)).collect(); + let mut size = u32_at(e, 120).map(u64::from).unwrap_or(0); + if major >= 4 { + size |= u64::from(u32_at(e, 124).unwrap_or(0)) << 32; + } + cf.entries.push(DirEntry { + name: String::from_utf16_lossy(&units), + kind: e.get(66).copied().unwrap_or(0), + left: u32_at(e, 68).unwrap_or(NO_STREAM), + right: u32_at(e, 72).unwrap_or(NO_STREAM), + child: u32_at(e, 76).unwrap_or(NO_STREAM), + start: u32_at(e, 116).unwrap_or(END_OF_CHAIN), + size, + }); + } + if cf.entries.first().is_none_or(|r| r.kind != 5) { + return Err(bad("missing root entry")); + } + // Mini FAT. + let mini_start = u32_at(b, 60).ok_or_else(|| bad("truncated header"))?; + if mini_start < MAX_REG_SECT { + let raw = cf.read_chain(mini_start, u64::MAX)?; + cf.mini_fat = (0..raw.len() / 4).filter_map(|i| u32_at(&raw, i * 4)).collect(); + } + Ok(cf) + } + + /// 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. + 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()]; + while id != END_OF_CHAIN && out.len() < limit { + if id >= MAX_REG_SECT { + return Err("damaged compound file: broken sector chain".into()); + } + match seen.get_mut(id as usize) { + Some(v) if !*v => *v = true, + Some(_) => return Err("damaged compound file: sector chain loops".into()), + None => return Err("damaged compound file: sector missing from the FAT".into()), + } + 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)?)) + .ok_or("damaged compound file: sector out of range (the file may be truncated)")?; + 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. + 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()]; + while id != END_OF_CHAIN && out.len() < limit { + if id >= MAX_REG_SECT { + return Err("damaged compound file: broken mini sector chain".into()); + } + match seen.get_mut(id as usize) { + Some(v) if !*v => *v = true, + Some(_) => return Err("damaged compound file: mini sector chain loops".into()), + 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")?; + 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")?; + } + Ok(out) + } + + /// The contents of the stream called `name` (case-insensitive) directly under the root + /// storage, or `None` when there is no such stream. + /// The directory entry of the stream called `name` (case-insensitive) directly under the + /// root storage. + fn root_entry(&self, name: &str) -> Result, String> { + let Some(root) = self.entries.first() else { return Ok(None) }; + // Walk the root's children (a red-black tree through left/right siblings), bounded. + let mut stack = vec![root.child]; + let mut seen = 0usize; + while let Some(i) = stack.pop() { + if i == NO_STREAM { + continue; + } + seen += 1; + if seen > self.entries.len() { + return Err("damaged compound file: directory tree loops".into()); + } + let Some(e) = self.entries.get(i as usize) else { continue }; + if e.kind == STREAM_OBJECT && e.name.eq_ignore_ascii_case(name) { + return Ok(Some(e)); + } + stack.push(e.left); + stack.push(e.right); + } + Ok(None) + } + + /// Whether the root storage holds a stream called `name`, without reading it. + pub(crate) fn has_root_stream(&self, name: &str) -> bool { + matches!(self.root_entry(name), Ok(Some(_))) + } + + /// The contents of the stream called `name` (case-insensitive) directly under the root + /// storage, or `None` when there is no such stream. + pub(crate) fn root_stream(&self, name: &str) -> Result>, String> { + let Some(root) = self.entries.first() else { return Ok(None) }; + let found = self.root_entry(name)?; + let Some(e) = found else { return Ok(None) }; + if e.size > MAX_STREAM { + return Err("the workbook stream is larger than 1 GiB".into()); + } + let size = usize::try_from(e.size).map_err(|_| "stream too large".to_string())?; + let mut data = if e.size < self.mini_cutoff { + let container = self.read_chain(root.start, root.size)?; + self.read_mini_chain(&container, e.start, size)? + } else { + self.read_chain(e.start, e.size)? + }; + if data.len() < size { + return Err("damaged compound file: a stream is shorter than its declared size (the file may be truncated)".into()); + } + data.truncate(size); + Ok(Some(data)) + } +} + #[cfg(test)] pub(crate) mod tests { use super::*; diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 05d1afc77..ce3353a3e 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -1,4 +1,5 @@ -//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import. +//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import +//! and legacy Excel 97-2003 `.xls` (BIFF8) import. //! //! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry //! points work on byte slices (no file system access), so the crate builds for WebAssembly. @@ -28,6 +29,7 @@ mod styles; mod tables; mod theme; mod write; +mod xls; mod xlsb; mod xml; @@ -39,6 +41,7 @@ use gridcraft_model::Workbook; pub use csv::{read_csv, write_csv}; pub use ods::read_ods; +pub use xls::read_xls; pub use xlsb::read_xlsb; /// Errors from reading or writing files. @@ -105,7 +108,9 @@ pub enum Format { Csv, /// A password-protected (encrypted) workbook: a compound file with an `EncryptedPackage`. Encrypted, - /// Another compound (CFB/OLE2) file: an Excel 97-2003 `.xls` or other legacy Office binary. + /// An Excel 97-2003 `.xls` workbook: a compound file with a `Workbook` (or BIFF5 `Book`) stream. + Xls, + /// Another compound (CFB/OLE2) file: a legacy Office binary that isn't an Excel workbook. LegacyBinary, Unknown, } @@ -122,9 +127,16 @@ pub fn sniff(bytes: &[u8]) -> Format { }; } // CFB/OLE2 compound file: a password-protected workbook when it holds an encryption - // stream, otherwise a legacy binary (.xls, .doc…). + // stream, an Excel 97-2003 workbook when it holds a workbook stream, otherwise another + // legacy binary (.doc…). if bytes.starts_with(&cfb::SIGNATURE) { - return if cfb::is_encrypted(bytes) { Format::Encrypted } else { Format::LegacyBinary }; + return if cfb::is_encrypted(bytes) { + Format::Encrypted + } else if xls::is_xls(bytes) { + Format::Xls + } else { + Format::LegacyBinary + }; } if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { return Format::Csv; diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 43639fee4..e311a507d 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -9,6 +9,7 @@ mod pivot_tests; mod richdata_tests; mod roundtrip; mod smooth_roundtrip; +mod xls_tests; mod xlsb_tests; use std::io::Write; diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs new file mode 100644 index 000000000..a150dc0b3 --- /dev/null +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -0,0 +1,439 @@ +//! Legacy `.xls` (BIFF8) import. Every test file is assembled here from the [MS-CFB] and +//! [MS-XLS] structures; no Excel-produced file is used. + +use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value}; + +use crate::{Format, IoError, read_xls, read_xlsx, sniff, write_xlsx}; + +const END: u32 = 0xFFFF_FFFE; +const FREE: u32 = 0xFFFF_FFFF; + +fn put32(b: &mut [u8], at: usize, v: u32) { + b[at..at + 4].copy_from_slice(&v.to_le_bytes()); +} + +fn dir_entry(name: &str, kind: u8, child: u32, start: u32, size: u32) -> [u8; 128] { + let mut e = [0u8; 128]; + let units: Vec = name.encode_utf16().collect(); + for (i, u) in units.iter().enumerate() { + e[i * 2..i * 2 + 2].copy_from_slice(&u.to_le_bytes()); + } + if !name.is_empty() { + e[64..66].copy_from_slice(&((units.len() as u16 + 1) * 2).to_le_bytes()); + } + e[66] = kind; + e[67] = 1; // black + put32(&mut e, 68, FREE); + put32(&mut e, 72, FREE); + put32(&mut e, 76, child); + put32(&mut e, 116, start); + put32(&mut e, 120, size); + e +} + +/// A version 3 compound file (512-byte sectors) whose root holds one stream. Streams under +/// 4096 bytes go to the mini stream, like real files. +pub(crate) fn compound(name: &str, data: &[u8]) -> Vec { + let mini = data.len() < 4096; + let pad = |n: usize, to: usize| n.div_ceil(to) * to; + // Sector payloads after the FAT: directory, mini FAT (if any), then data. + let mut payload: Vec> = vec![]; + let mut chains: Vec<(usize, usize)> = vec![]; // (first payload index, sector count) + let (dir_at, minifat_at, data_at); + let mut dir = vec![]; + let mut minifat = vec![]; + let stream_sectors; + if mini { + let m = data.len().div_ceil(64).max(1); + for i in 0..m { + minifat.extend_from_slice(&(if i + 1 == m { END } else { i as u32 + 1 }).to_le_bytes()); + } + minifat.resize(512, 0xFF); + let mut container = data.to_vec(); + container.resize(pad(data.len().max(1), 64), 0); + stream_sectors = container.len().div_ceil(512); + container.resize(stream_sectors * 512, 0); + dir_at = 0; + minifat_at = Some(1); + data_at = 2; + payload.push(vec![]); + payload.push(minifat); + payload.extend(container.chunks(512).map(<[u8]>::to_vec)); + chains.push((data_at, stream_sectors)); + dir.extend_from_slice(&dir_entry("Root Entry", 5, 1, 0, (stream_sectors * 512) as u32)); + dir.extend_from_slice(&dir_entry(name, 2, FREE, 0, data.len() as u32)); + } else { + let mut body = data.to_vec(); + stream_sectors = body.len().div_ceil(512); + body.resize(stream_sectors * 512, 0); + dir_at = 0; + minifat_at = None; + data_at = 1; + payload.push(vec![]); + payload.extend(body.chunks(512).map(<[u8]>::to_vec)); + chains.push((data_at, stream_sectors)); + dir.extend_from_slice(&dir_entry("Root Entry", 5, 1, END, 0)); + dir.extend_from_slice(&dir_entry(name, 2, FREE, 0, data.len() as u32)); + } + dir.extend_from_slice(&dir_entry("", 0, FREE, FREE, 0)); + dir.extend_from_slice(&dir_entry("", 0, FREE, FREE, 0)); + // FAT sectors come first; their count depends on the total. + let mut fat_n = 1; + while (fat_n + payload.len()).div_ceil(128) > fat_n { + fat_n += 1; + } + let sec = |i: usize| (fat_n + i) as u32; + // Fix the directory's start sectors now that the layout is known. + let first_data = sec(data_at); + if mini { + put32(&mut dir, 116, first_data); // root: mini stream container + } else { + put32(&mut dir, 128 + 116, first_data); + } + payload[dir_at] = dir; + let total = fat_n + payload.len(); + let mut fat = vec![FREE; fat_n * 128]; + for f in fat.iter_mut().take(fat_n) { + *f = 0xFFFF_FFFD; + } + fat[sec(dir_at) as usize] = END; + if let Some(m) = minifat_at { + fat[sec(m) as usize] = END; + } + for (first, n) in chains { + for k in 0..n { + fat[sec(first + k) as usize] = if k + 1 == n { END } else { sec(first + k + 1) }; + } + } + let mut out = vec![0u8; 512]; + out[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + out[24..26].copy_from_slice(&0x3Eu16.to_le_bytes()); + out[26..28].copy_from_slice(&3u16.to_le_bytes()); + out[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes()); + out[30..32].copy_from_slice(&9u16.to_le_bytes()); + out[32..34].copy_from_slice(&6u16.to_le_bytes()); + put32(&mut out, 44, fat_n as u32); + put32(&mut out, 48, sec(dir_at)); + put32(&mut out, 56, 4096); + put32(&mut out, 60, minifat_at.map_or(END, sec)); + put32(&mut out, 64, u32::from(minifat_at.is_some())); + put32(&mut out, 68, END); + for i in 0..109 { + put32(&mut out, 76 + i * 4, if i < fat_n { i as u32 } else { FREE }); + } + for f in &fat { + out.extend_from_slice(&f.to_le_bytes()); + } + for p in payload { + let mut p = p; + p.resize(512, 0); + out.extend_from_slice(&p); + } + assert_eq!(out.len(), 512 * (1 + total)); + out +} + +fn rec(id: u16, payload: &[u8]) -> Vec { + let mut v = id.to_le_bytes().to_vec(); + v.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + v.extend_from_slice(payload); + v +} + +fn cat(parts: &[&[u8]]) -> Vec { + parts.concat() +} + +fn u16s(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// Cell header: row, column, XF index. +fn cell(r: u16, c: u16, xf: u16) -> Vec { + u16s(&[r, c, xf]) +} + +fn bof(dt: u16) -> Vec { + rec(0x0809, &cat(&[&u16s(&[0x0600, dt, 0x0DBB, 0x07CC]), &[0; 8]])) +} + +fn eof() -> Vec { + rec(0x000A, &[]) +} + +/// XLUnicodeString with compressed characters. +fn xlstr(s: &str) -> Vec { + cat(&[&(s.len() as u16).to_le_bytes(), &[0], s.as_bytes()]) +} + +/// A FORMULA record with a numeric cached result. +fn formula(r: u16, c: u16, cached: [u8; 8], rgce: &[u8]) -> Vec { + let mut p = cell(r, c, 0); + p.extend_from_slice(&cached); + p.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // flags, chn + p.extend_from_slice(&(rgce.len() as u16).to_le_bytes()); + p.extend_from_slice(rgce); + rec(0x0006, &p) +} + +fn num(n: f64) -> [u8; 8] { + n.to_le_bytes() +} + +fn special(kind: u8, v: u8) -> [u8; 8] { + [kind, 0, v, 0, 0, 0, 0xFF, 0xFF] +} + +/// PtgRef (value class) with relative row and column. +fn ptg_ref(r: u16, c: u16) -> Vec { + cat(&[&[0x44], &u16s(&[r, c | 0xC000])]) +} + +fn ptg_int(n: u16) -> Vec { + cat(&[&[0x1E], &n.to_le_bytes()]) +} + +fn exp(r: u16, c: u16) -> Vec { + cat(&[&[0x01], &u16s(&[r, c])]) +} + +/// A workbook with two worksheets and a chart sheet covering the stage-1 features. +fn sample(date1904: bool) -> Vec { + // SST: "Hello", then "Wörld" as UTF-16 split across a Continue record. + let mut sst = cat(&[&2u32.to_le_bytes(), &2u32.to_le_bytes(), &xlstr("Hello")]); + sst.extend_from_slice(&[5, 0, 0, b'W']); + let sst_rec = cat(&[&rec(0x00FC, &sst), &rec(0x003C, &cat(&[&[1], &u16s(&[0xF6, 'r' as u16, 'l' as u16, 'd' as u16])]))]); + let xf = |ifmt: u16| rec(0x00E0, &cat(&[&u16s(&[0, ifmt, 0]), &[0; 14]])); + // Lbl "Rate" = Data!$A$1 (PtgRef3d, absolute). + let rate = cat(&[&[0x3A], &1u16.to_le_bytes(), &u16s(&[0, 0])]); + let lbl = rec(0x0018, &cat(&[&u16s(&[0]), &[0, 4], &u16s(&[rate.len() as u16, 0, 0]), &[0; 4], &[0], b"Rate", &rate])); + // Print_Area on Sheet1 (built-in name 6, itab 1) = Sheet1!$A$1:$B$2. + let pa = cat(&[&[0x3B], &0u16.to_le_bytes(), &u16s(&[0, 1, 0, 1])]); + let print_area = rec(0x0018, &cat(&[&u16s(&[0x0020]), &[0, 1], &u16s(&[pa.len() as u16, 0, 1]), &[0; 4], &[0, 6], &pa])); + let boundsheet = |pos: u32, kind: u8, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, kind, name.len() as u8, 0], name.as_bytes()])); + let globals = |positions: [u32; 3]| { + cat(&[ + &bof(0x0005), + &rec(0x0022, &u16s(&[u16::from(date1904)])), + &rec(0x041E, &cat(&[&164u16.to_le_bytes(), &xlstr("yyyy-mm-dd")])), + &xf(0), + &xf(164), + &xf(10), // built-in 0.00% + &boundsheet(positions[0], 0, "Sheet1"), + &boundsheet(positions[1], 0, "Data"), + &boundsheet(positions[2], 2, "Chart1"), + &rec(0x01AE, &u16s(&[3, 0x0401])), + &rec(0x0017, &cat(&[&u16s(&[2]), &u16s(&[0, 0, 0]), &u16s(&[0, 1, 1])])), + &lbl, + &print_area, + &sst_rec, + &eof(), + ]) + }; + let mulrk = cat(&[&u16s(&[1, 2]), &u16s(&[0]), &((7u32 << 2) | 2).to_le_bytes(), &u16s(&[2]), &((150u32 << 2) | 3).to_le_bytes(), &u16s(&[3])]); + let shared = cat(&[&[0x4C], &u16s(&[0, 0xFFFF]), &ptg_int(10), &[0x05]]); + let array = cat(&[&[0x65], &u16s(&[4, 5, 0xC000, 0xC000]), &ptg_int(2), &[0x05]]); + let sum = cat(&[&[0x25], &u16s(&[0, 0, 1 | 0xC000, 2 | 0xC000]), &[0x22, 1], &4u16.to_le_bytes()]); + let concat = cat(&[&ptg_ref(0, 0), &[0x17, 1, 0, b'!'], &[0x08]]); + let round = cat(&[&ptg_ref(0, 1), &ptg_int(0), &[0x41], &27u16.to_le_bytes()]); + let name_ref = cat(&[&[0x43], &1u32.to_le_bytes(), &ptg_int(2), &[0x05]]); + let ref3d = cat(&[&[0x5A], &1u16.to_le_bytes(), &u16s(&[0, 0xC000])]); + let sheet1 = cat(&[ + &bof(0x0010), + &rec(0x0225, &u16s(&[0, 255])), + &rec(0x007D, &u16s(&[0, 0, 20 * 256, 0, 0x0002, 0])), + &rec(0x0208, &cat(&[&u16s(&[0, 0, 4, 600, 0, 0]), &(0x0140u32).to_le_bytes()])), + &rec(0x00FD, &cat(&[&cell(0, 0, 0), &0u32.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(0, 1, 0), &1.5f64.to_le_bytes()])), + &rec(0x027E, &cat(&[&cell(0, 2, 0), &((42u32 << 2) | 2).to_le_bytes()])), + &rec(0x00FD, &cat(&[&cell(0, 3, 0), &1u32.to_le_bytes()])), + &rec(0x0205, &cat(&[&cell(1, 0, 0), &[1, 0]])), + &rec(0x0205, &cat(&[&cell(1, 1, 0), &[0x07, 1]])), + &rec(0x00BD, &mulrk), + &formula(2, 0, num(3.0), &cat(&[&ptg_ref(0, 1), &ptg_int(2), &[0x05]])), + &formula(2, 1, num(43.5), &sum), + &formula(2, 2, special(0, 0), &concat), + &rec(0x0207, &xlstr("Hello!")), + &formula(2, 3, special(1, 1), &cat(&[&ptg_ref(0, 1), &ptg_int(1), &[0x0D]])), + &formula(2, 4, num(14.0), &name_ref), + &formula(2, 5, num(2.0), &round), + &formula(2, 6, num(7.0), &ref3d), + &formula(2, 7, special(2, 0x2A), &[0x1C, 0x2A]), + &rec(0x0203, &cat(&[&cell(3, 0, 1), &0.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(3, 1, 2), &0.25f64.to_le_bytes()])), + &rec(0x027E, &cat(&[&cell(4, 0, 0), &((1u32 << 2) | 2).to_le_bytes()])), + &rec(0x027E, &cat(&[&cell(5, 0, 0), &((2u32 << 2) | 2).to_le_bytes()])), + &formula(4, 1, num(10.0), &exp(4, 1)), + &rec(0x04BC, &cat(&[&u16s(&[4, 5]), &[1, 1, 0, 2], &(shared.len() as u16).to_le_bytes(), &shared])), + &formula(5, 1, num(20.0), &exp(4, 1)), + &formula(4, 2, num(2.0), &exp(4, 2)), + &rec(0x0221, &cat(&[&u16s(&[4, 5]), &[2, 2], &[0; 6], &(array.len() as u16).to_le_bytes(), &array])), + &formula(5, 2, num(4.0), &exp(4, 2)), + &rec(0x00E5, &cat(&[&u16s(&[1]), &u16s(&[6, 6, 0, 1])])), + // An embedded chart substream: its records are not cells. + &bof(0x0020), + &rec(0x0203, &cat(&[&cell(9, 9, 0), &9.0f64.to_le_bytes()])), + &eof(), + &eof(), + ]); + let data = cat(&[&bof(0x0010), &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]); + let chart = cat(&[&bof(0x0020), &eof()]); + let len = globals([0; 3]).len() as u32; + let p1 = len; + let p2 = p1 + sheet1.len() as u32; + let p3 = p2 + data.len() as u32; + cat(&[&globals([p1, p2, p3]), &sheet1, &data, &chart]) +} + +fn v(wb: &gridcraft_model::Workbook, sheet: usize, a1: &str) -> Value { + wb.sheets[sheet].value(CellRef::parse(a1).unwrap()) +} + +fn ftext(wb: &gridcraft_model::Workbook, a1: &str) -> Option { + wb.sheets[0].cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()) +} + +#[test] +fn reads_values_formulas_formats_and_layout() { + for big in [false, true] { + let mut stream = sample(false); + if big { + // Past the mini stream cutoff: the stream lives in regular sectors. + stream.extend(std::iter::repeat_n(0u8, 5000)); + } + let bytes = compound("Workbook", &stream); + assert_eq!(sniff(&bytes), Format::Xls); + let (wb, report) = read_xls(&bytes).unwrap(); + assert!(report.warnings[0].contains("Excel 97-2003"), "{:?}", report.warnings); + assert!(report.warnings.iter().any(|w| w.contains("Chart sheets"))); + assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), ["Sheet1", "Data"]); + assert_eq!(v(&wb, 0, "A1"), Value::from("Hello")); + assert_eq!(v(&wb, 0, "B1"), Value::Number(1.5)); + assert_eq!(v(&wb, 0, "C1"), Value::Number(42.0)); + assert_eq!(v(&wb, 0, "D1"), Value::from("Wörld")); + assert_eq!(v(&wb, 0, "A2"), Value::Bool(true)); + assert_eq!(v(&wb, 0, "B2"), Value::Error(CellError::Div0)); + assert_eq!(v(&wb, 0, "C2"), Value::Number(7.0)); + assert_eq!(v(&wb, 0, "D2"), Value::Number(1.5)); + // Formulas: text plus the cached result. + assert_eq!(ftext(&wb, "A3").as_deref(), Some("B1*2")); + assert_eq!(v(&wb, 0, "A3"), Value::Number(3.0)); + assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)")); + assert_eq!(ftext(&wb, "C3").as_deref(), Some("A1&\"!\"")); + assert_eq!(v(&wb, 0, "C3"), Value::from("Hello!")); + assert_eq!(ftext(&wb, "D3").as_deref(), Some("B1>1")); + assert_eq!(v(&wb, 0, "D3"), Value::Bool(true)); + assert_eq!(ftext(&wb, "E3").as_deref(), Some("Rate*2")); + assert_eq!(ftext(&wb, "F3").as_deref(), Some("ROUND(B1,0)")); + assert_eq!(ftext(&wb, "G3").as_deref(), Some("Data!A1")); + assert_eq!(v(&wb, 0, "H3"), Value::Error(CellError::NA)); + // 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. + 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)); + // 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"); + assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%"); + assert_eq!(sh.merges, vec![RangeRef::parse("A7:B7").unwrap()]); + assert!(sh.cols.get(&0).unwrap().size.unwrap() > sh.default_col_width); + assert_eq!(sh.rows.get(&0).unwrap().size, Some(40.0)); + assert!(!sh.rows.get(&0).unwrap().hidden && sh.rows.get(&0).unwrap().custom); + assert_eq!(sh.print.print_area, RangeRef::parse("A1:B2")); + assert_eq!(v(&wb, 0, "J10"), Value::Empty); + assert_eq!(wb.names.len(), 1); + assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1")); + assert_eq!(v(&wb, 1, "A1"), Value::Number(7.0)); + assert_eq!(wb.date_system, DateSystem::D1900); + // The import saves as .xlsx and reads back. + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().text, "B1*2"); + assert_eq!(back.sheets[0].value(CellRef::parse("C3").unwrap()), Value::from("Hello!")); + } + let (wb, _) = read_xls(&compound("Workbook", &sample(true))).unwrap(); + assert_eq!(wb.date_system, DateSystem::D1904); +} + +#[test] +fn refuses_encrypted_old_and_foreign_files() { + // FILEPASS in the globals: an encrypted workbook. + let mut stream = bof(0x0005); + stream.extend(rec(0x002F, &[1, 0, 1, 0])); + stream.extend(eof()); + let err = read_xls(&compound("Workbook", &stream)).unwrap_err().to_string(); + assert!(err.contains("password-protected"), "{err}"); + // A BIFF5 (Excel 5.0/95) workbook keeps its data in a `Book` stream. + let book = compound("Book", &stream); + assert_eq!(sniff(&book), Format::Xls); + assert!(read_xls(&book).unwrap_err().to_string().contains("BIFF5")); + // Another compound file (a word processor document): not a workbook. + let doc = compound("WordDocument", &[0u8; 100]); + assert_eq!(sniff(&doc), Format::LegacyBinary); + assert!(read_xls(&doc).is_err()); +} + +#[test] +fn hostile_xls_input_never_panics_or_hangs() { + let stream = sample(false); + let good = compound("Workbook", &stream); + // Truncation anywhere. + for cut in (0..good.len()).step_by(37) { + let _ = read_xls(&good[..cut]); + let _ = sniff(&good[..cut]); + } + // Byte mutations in the container and the stream. + for at in (0..good.len()).step_by(7) { + for x in [0x00u8, 0x01, 0x7F, 0xFF] { + let mut b = good.clone(); + b[at] = x; + let _ = read_xls(&b); + } + } + for at in 0..stream.len() { + let mut s = stream.clone(); + s[at] ^= 0xA5; + let _ = read_xls(&compound("Workbook", &s)); + } + // A FAT chain that loops on itself (big stream in regular sectors). + let mut big = stream.clone(); + big.extend(std::iter::repeat_n(0u8, 5000)); + let mut b = compound("Workbook", &big); + let fat_at = 512; + let first_data = 2usize; // FAT, directory, then data + put32(&mut b, fat_at + (first_data + 3) * 4, first_data as u32); + assert!(read_xls(&b).is_err()); + // Huge declared counts: shared strings, merges, external sheets. + let mut huge = bof(0x0005); + huge.extend(rec(0x00FC, &cat(&[&u32::MAX.to_le_bytes(), &u32::MAX.to_le_bytes()]))); + huge.extend(eof()); + assert!(matches!(read_xls(&compound("Workbook", &huge)), Err(IoError::TooLarge(_)))); + let mut huge = bof(0x0005); + huge.extend(rec(0x00FC, &cat(&[&9u32.to_le_bytes(), &1_000_000u32.to_le_bytes(), &xlstr("x")]))); + huge.extend(rec(0x0017, &u16s(&[0xFFFF]))); + huge.extend(eof()); + assert!(read_xls(&compound("Workbook", &huge)).is_err()); // no worksheets + // A sheet pointing outside the stream and a stream size larger than the file. + let mut bad = bof(0x0005); + bad.extend(rec(0x0085, &cat(&[&u32::MAX.to_le_bytes(), &[0, 0, 1, 0], b"S"]))); + bad.extend(eof()); + assert!(read_xls(&compound("Workbook", &bad)).is_err()); + let mut b = compound("Workbook", &big); + put32(&mut b, 512 * 2 + 128 + 120, 0x3FFF_FFFF); + assert!(read_xls(&b).is_err()); + // Random bytes behind a compound-file signature. + let mut seed = 0x2545_F491_4F6C_DD1Du64; + for _ in 0..200 { + let mut b = good[..512].to_vec(); + for _ in 0..2048 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + b.push(seed as u8); + } + let _ = read_xls(&b); + } +} diff --git a/crates/xlsx/src/xls/formula.rs b/crates/xlsx/src/xls/formula.rs new file mode 100644 index 000000000..3fc3fc40c --- /dev/null +++ b/crates/xlsx/src/xls/formula.rs @@ -0,0 +1,496 @@ +//! BIFF8 parsed formulas ([MS-XLS] 2.2.2, 2.5.198): reverse-Polish `Ptg` tokens back to an +//! expression tree. Anything outside the common token set is reported as unsupported, and the +//! caller keeps the cell's cached value instead. + +use std::sync::Arc; + +use gridcraft_core::{CellError, CellRef}; +use gridcraft_formula::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, UnOp}; + +use super::ftab::FTAB; + +/// Formula token bytes decoded at most (Excel's limit is 1,800 per formula). +const MAX_RGCE: usize = 16 * 1024; +/// Values in one array constant at most. +const MAX_ARRAY: usize = 65_536; + +/// Why a formula could not be turned back into text. +#[derive(Debug, PartialEq)] +pub(super) struct Unsupported(pub &'static str); + +/// Where the formula lives: refs in cell formulas store coordinates; shared formulas store +/// row/column offsets for relative parts; names store offsets for 3-D refs too. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Mode { + Cell, + Shared, + Name, +} + +/// A supporting link ([MS-XLS] 2.4.271): only links to this workbook (and add-in function +/// names) are resolved; external workbooks are never followed. +#[derive(Default)] +pub(super) struct SupBook { + pub internal: bool, + pub addin: bool, + pub names: Vec, +} + +/// XTI ([MS-XLS] 2.5.344): a supporting link and a range of its sheets. +#[derive(Clone, Copy)] +pub(super) struct Xti { + pub book: u16, + pub first: i16, + pub last: i16, +} + +/// Workbook-level tables formulas refer to. +#[derive(Default)] +pub(super) struct Links { + /// Every sheet's name in BoundSheet8 order (including skipped chart/macro sheets). + pub sheets: Vec, + /// Defined names (Lbl records), in order; `None` for names that can't be referenced. + pub names: Vec, + pub books: Vec, + pub xti: Vec, +} + +pub(super) fn function(iftab: u16) -> Option<(&'static str, i8)> { + FTAB.binary_search_by_key(&iftab, |(i, _, _)| *i).ok().and_then(|i| FTAB.get(i)).map(|(_, n, a)| (*n, *a)) +} + +fn error_code(code: u8) -> Option { + Some(match code { + 0x00 => CellError::Null, + 0x07 => CellError::Div0, + 0x0F => CellError::Value, + 0x17 => CellError::Ref, + 0x1D => CellError::Name, + 0x24 => CellError::Num, + 0x2A => CellError::NA, + 0x2B => CellError::GettingData, + _ => return None, + }) +} + +pub(super) fn cell_error(code: u8) -> CellError { + error_code(code).unwrap_or(CellError::NA) +} + +struct Bytes<'a> { + b: &'a [u8], + pos: usize, +} + +const TRUNC: Unsupported = Unsupported("truncated formula"); + +impl Bytes<'_> { + fn take(&mut self, n: usize) -> Result<&[u8], Unsupported> { + let s = self.b.get(self.pos..self.pos.checked_add(n).ok_or(TRUNC)?).ok_or(TRUNC)?; + self.pos += n; + Ok(s) + } + fn u8(&mut self) -> Result { + self.take(1)?.first().copied().ok_or(TRUNC) + } + fn u16(&mut self) -> Result { + let s = self.take(2)?; + Ok(u16::from_le_bytes([*s.first().ok_or(TRUNC)?, *s.get(1).ok_or(TRUNC)?])) + } + fn u32(&mut self) -> Result { + Ok(u32::from(self.u16()?) | (u32::from(self.u16()?) << 16)) + } + fn f64(&mut self) -> Result { + let a: [u8; 8] = self.take(8)?.try_into().map_err(|_| TRUNC)?; + Ok(f64::from_le_bytes(a)) + } + fn chars(&mut self, cch: usize, high: bool) -> Result { + let units: Vec = if high { + self.take(cch.checked_mul(2).ok_or(TRUNC)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect() + } else { + self.take(cch)?.iter().map(|&b| u16::from(b)).collect() + }; + Ok(String::from_utf16_lossy(&units)) + } +} + +/// A column field (ColRelU / ColRelNegU): the 14-bit column and the two relative flags. +fn col_field(v: u16) -> (u16, bool, bool) { + (v & 0x3FFF, v & 0x4000 != 0, v & 0x8000 != 0) +} + +/// One end of a reference. In `relative` (RgceLocRel) form the relative parts are offsets +/// from `base`, wrapped into the BIFF8 grid (65,536 × 256). +fn anchor(row: u16, col: u16, base: CellRef, relative: bool) -> Anchor { + let (c, col_rel, row_rel) = col_field(col); + let (r, c) = if relative { + let r = if row_rel { (u32::from(row).wrapping_add(base.row)) & 0xFFFF } else { u32::from(row) }; + // A 14-bit signed offset; adding modulo 256 gives the same column either way. + let c = if col_rel { (u32::from(c).wrapping_add(base.col)) & 0xFF } else { u32::from(c) & 0xFF }; + (r, c) + } else { + (u32::from(row), u32::from(c) & 0xFF) + }; + Anchor { row: r, col: c, row_abs: !row_rel, col_abs: !col_rel } +} + +/// An area; a range spanning every BIFF8 row (or column) becomes a whole-column (row) ref. +fn area(r0: u16, r1: u16, c0: u16, c1: u16, base: CellRef, relative: bool) -> RefKind { + let a = anchor(r0, c0, base, relative); + let b = anchor(r1, c1, base, relative); + if a.row == 0 && b.row == 0xFFFF { + return RefKind::Cols(a.col, a.col_abs, b.col, b.col_abs); + } + if a.col == 0 && b.col == 0xFF { + return RefKind::Rows(a.row, a.row_abs, b.row, b.row_abs); + } + RefKind::Range(a, b) +} + +impl Links { + fn sheet_sel(&self, ixti: u16) -> Result, Unsupported> { + let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing sheet link"))?; + let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?; + if !book.internal { + return Err(Unsupported("references to other workbooks (external links)")); + } + let name = |i: i16| usize::try_from(i).ok().and_then(|i| self.sheets.get(i)).cloned(); + Ok(match (name(x.first), name(x.last)) { + (Some(a), Some(_)) if x.first == x.last => Some(SheetSel::Named(a)), + (Some(a), Some(b)) => Some(SheetSel::Span(a, b)), + // A deleted sheet: the reference is #REF!. + _ => None, + }) + } + + fn extern_name(&self, ixti: u16, index: u32) -> Result { + let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing name link"))?; + let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?; + if !book.internal && !book.addin { + return Err(Unsupported("names in other workbooks (external links)")); + } + let i = usize::try_from(index).ok().and_then(|i| i.checked_sub(1)).ok_or(Unsupported("invalid name index"))?; + book.names.get(i).cloned().ok_or(Unsupported("reference to a missing name")) + } +} + +/// Strips the prefixes newer functions carry in files (`_xlfn.IFERROR`). +fn function_name(name: &str) -> String { + let n = name.strip_prefix("_xlfn.").or_else(|| name.strip_prefix("_xlws.")).unwrap_or(name); + n.to_ascii_uppercase() +} + +fn pop(stack: &mut Vec) -> Result { + stack.pop().ok_or(Unsupported("malformed formula (operand missing)")) +} + +fn pop_n(stack: &mut Vec, n: usize) -> Result, Unsupported> { + let at = stack.len().checked_sub(n).ok_or(Unsupported("malformed formula (argument missing)"))?; + Ok(stack.split_off(at)) +} + +/// Decodes `rgce` (tokens) with `rgcb` (the extra data for array constants) evaluated at +/// `base`. Returns the expression or why it can't be represented. +pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: &Links) -> Result { + if rgce.len() > MAX_RGCE { + return Err(Unsupported("formula too long")); + } + let mut t = Bytes { b: rgce, pos: 0 }; + let mut extra = Bytes { b: rgcb, pos: 0 }; + let mut stack: Vec = Vec::new(); + while t.pos < rgce.len() { + let ptg = t.u8()?; + // Operand classes (reference / value / array) share one token number. + let id = if ptg & 0x60 != 0 { 0x20 | (ptg & 0x1F) } else { ptg }; + match id { + 0x03..=0x11 => { + let op = match id { + 0x03 => BinOp::Add, + 0x04 => BinOp::Sub, + 0x05 => BinOp::Mul, + 0x06 => BinOp::Div, + 0x07 => BinOp::Pow, + 0x08 => BinOp::Concat, + 0x09 => BinOp::Lt, + 0x0A => BinOp::Le, + 0x0B => BinOp::Eq, + 0x0C => BinOp::Ge, + 0x0D => BinOp::Gt, + 0x0E => BinOp::Ne, + 0x0F => BinOp::Intersect, + 0x10 => BinOp::Union, + _ => BinOp::Range, + }; + let b = pop(&mut stack)?; + let a = pop(&mut stack)?; + stack.push(Expr::Binary(op, Box::new(a), Box::new(b))); + } + 0x12..=0x14 => { + let op = match id { + 0x12 => UnOp::Plus, + 0x13 => UnOp::Neg, + _ => UnOp::Percent, + }; + let x = pop(&mut stack)?; + stack.push(Expr::Unary(op, Box::new(x))); + } + 0x15 => { + let x = pop(&mut stack)?; + stack.push(Expr::Paren(Box::new(x))); + } + 0x16 => stack.push(Expr::Missing), + 0x17 => { + let cch = usize::from(t.u8()?); + let high = t.u8()? & 1 != 0; + stack.push(Expr::Text(Arc::from(t.chars(cch, high)?))); + } + 0x19 => { + let kind = t.u8()?; + if kind & 0x04 != 0 { + // PtgAttrChoose: jump table of cOffset + 1 entries. + let n = usize::from(t.u16()?) + 1; + t.take(n * 2)?; + } else { + t.take(2)?; + } + if kind & 0x10 != 0 { + // PtgAttrSum: SUM of the single operand. + let x = pop(&mut stack)?; + stack.push(Expr::Call("SUM".into(), vec![x])); + } + // Semi (volatile), If, Goto, Space, Baxcel: no effect on the expression. + } + 0x1C => { + let e = t.u8()?; + stack.push(Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?)); + } + 0x1D => stack.push(Expr::Bool(t.u8()? != 0)), + 0x1E => stack.push(Expr::Number(f64::from(t.u16()?))), + 0x1F => { + let n = t.f64()?; + if !n.is_finite() { + return Err(Unsupported("invalid number constant")); + } + stack.push(Expr::Number(n)); + } + 0x20 => { + t.take(7)?; + stack.push(array_constant(&mut extra)?); + } + 0x21 => { + let iftab = t.u16()?; + let (name, args) = function(iftab).ok_or(Unsupported("unknown function"))?; + let n = usize::try_from(args).map_err(|_| Unsupported("unknown function argument count"))?; + let args = pop_n(&mut stack, n)?; + stack.push(Expr::Call(name.to_string(), args)); + } + 0x22 => { + let n = usize::from(t.u8()?); + let tab = t.u16()?; + if tab & 0x8000 != 0 { + return Err(Unsupported("macro sheet commands")); + } + let mut args = pop_n(&mut stack, n)?; + if tab & 0x7FFF == 0x00FF { + // A function called by name (add-in or newer function): the name comes first. + if args.is_empty() { + return Err(Unsupported("malformed function call")); + } + let name = match args.remove(0) { + Expr::Name(n) => function_name(&n), + _ => return Err(Unsupported("function call through an expression")), + }; + stack.push(Expr::Call(name, args)); + } else { + let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?; + stack.push(Expr::Call(name.to_string(), args)); + } + } + 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"))?; + stack.push(Expr::Name(name.clone())); + } + 0x24 | 0x2C => { + let (r, c) = (t.u16()?, t.u16()?); + let a = anchor(r, c, base, id == 0x2C); + stack.push(Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) })); + } + 0x25 | 0x2D => { + let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); + let kind = area(r0, r1, c0, c1, base, id == 0x2D); + stack.push(Expr::Ref(Reference { sheet: SheetSel::Current, kind })); + } + 0x26 => { + // PtgMemArea: a cache of the sub-expression that follows; skip its extra data. + t.take(6)?; + let n = usize::from(extra.u16()?); + extra.take(n.checked_mul(8).ok_or(TRUNC)?)?; + } + 0x27 | 0x28 => { + t.take(6)?; + } + 0x29 => { + t.take(2)?; + } + 0x2A => { + t.take(4)?; + stack.push(Expr::Error(CellError::Ref)); + } + 0x2B => { + t.take(8)?; + stack.push(Expr::Error(CellError::Ref)); + } + 0x39 => { + let ixti = t.u16()?; + let i = t.u32()?; + stack.push(Expr::Name(links.extern_name(ixti, i)?)); + } + 0x3A => { + let ixti = t.u16()?; + let (r, c) = (t.u16()?, t.u16()?); + let a = anchor(r, c, base, mode == Mode::Name); + stack.push(match links.sheet_sel(ixti)? { + Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }), + None => Expr::Error(CellError::Ref), + }); + } + 0x3B => { + let ixti = t.u16()?; + let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); + let kind = area(r0, r1, c0, c1, base, mode == Mode::Name); + stack.push(match links.sheet_sel(ixti)? { + Some(sheet) => Expr::Ref(Reference { sheet, kind }), + None => Expr::Error(CellError::Ref), + }); + } + 0x3C => { + t.take(6)?; + stack.push(Expr::Error(CellError::Ref)); + } + 0x3D => { + t.take(10)?; + stack.push(Expr::Error(CellError::Ref)); + } + 0x01 => return Err(Unsupported("nested shared or array formula reference")), + 0x02 => return Err(Unsupported("what-if data tables")), + 0x18 => return Err(Unsupported("natural-language formulas")), + _ => return Err(Unsupported("unknown formula token")), + } + if stack.len() > MAX_RGCE { + return Err(Unsupported("formula too complex")); + } + } + let e = pop(&mut stack)?; + if !stack.is_empty() { + return Err(Unsupported("malformed formula (unused operands)")); + } + Ok(e) +} + +/// PtgExtraArray ([MS-XLS] 2.5.198.59): an array constant's values in row-major order. +fn array_constant(b: &mut Bytes<'_>) -> Result { + let cols = usize::from(b.u8()?) + 1; + let rows = usize::from(b.u16()?) + 1; + if cols.saturating_mul(rows) > MAX_ARRAY { + return Err(Unsupported("array constant too large")); + } + let mut out = Vec::with_capacity(rows); + for _ in 0..rows { + let mut row = Vec::with_capacity(cols); + for _ in 0..cols { + let kind = b.u8()?; + row.push(match kind { + 0x00 => { + b.take(8)?; + Expr::Missing + } + 0x01 => { + let n = b.f64()?; + if !n.is_finite() { + return Err(Unsupported("invalid number constant")); + } + Expr::Number(n) + } + 0x02 => { + let cch = usize::from(b.u16()?); + let high = b.u8()? & 1 != 0; + Expr::Text(Arc::from(b.chars(cch, high)?)) + } + 0x04 => { + let v = b.u8()?; + b.take(7)?; + Expr::Bool(v != 0) + } + 0x10 => { + let e = b.u8()?; + b.take(7)?; + Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?) + } + _ => return Err(Unsupported("unknown array constant value")), + }); + } + out.push(row); + } + Ok(Expr::Array(out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn links() -> Links { + Links { + sheets: vec!["Sheet1".into(), "My Data".into()], + names: vec!["Rate".into()], + books: vec![ + SupBook { internal: true, addin: false, names: vec![] }, + SupBook { internal: false, addin: true, names: vec!["_xlfn.IFERROR".into()] }, + SupBook::default(), + ], + xti: vec![ + Xti { book: 0, first: 0, last: 1 }, + Xti { book: 1, first: -2, last: -2 }, + Xti { book: 2, first: 0, last: 0 }, + Xti { book: 0, first: -1, last: -1 }, + ], + } + } + + fn text(rgce: &[u8], rgcb: &[u8]) -> Result { + decode(rgce, rgcb, CellRef::new(0, 0), Mode::Cell, &links()).map(|e| gridcraft_formula::print(&e)) + } + + #[test] + fn decodes_common_tokens() { + // {1,"a";TRUE,#N/A} via PtgArray + PtgExtraArray. + let extra = + [&[1u8, 1, 0][..], &[1], &1f64.to_le_bytes(), &[2, 1, 0, 0, b'a'], &[4, 1, 0, 0, 0, 0, 0, 0, 0], &[0x10, 0x2A, 0, 0, 0, 0, 0, 0, 0]] + .concat(); + assert_eq!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &extra).unwrap(), "{1,\"a\";TRUE,#N/A}"); + // SUM via PtgAttrSum over a 3-D span, whole column. + let area3d = [&[0x3Bu8][..], &0u16.to_le_bytes(), &[0, 0, 0xFF, 0xFF, 1, 0, 1, 0]].concat(); + assert_eq!(text(&[area3d.as_slice(), &[0x19, 0x10, 0, 0]].concat(), &[]).unwrap(), "SUM('Sheet1:My Data'!$B:$B)"); + // IFERROR (a newer function) called by name through PtgNameX, with a missing argument. + let namex = [&[0x39u8][..], &1u16.to_le_bytes(), &1u32.to_le_bytes()].concat(); + let call = [namex.as_slice(), &[0x1E, 5, 0, 0x16, 0x22, 3, 0xFF, 0]].concat(); + assert_eq!(text(&call, &[]).unwrap(), "IFERROR(5,)"); + // A deleted sheet becomes #REF!; the CHOOSE jump table is skipped. + let ref_deleted = [&[0x3Au8][..], &3u16.to_le_bytes(), &[0, 0, 0, 0]].concat(); + let choose = [&[0x1Eu8, 1, 0][..], &[0x19, 0x04, 1, 0, 0, 0, 0, 0], ref_deleted.as_slice(), &[0x1E, 2, 0, 0x22, 3, 100, 0]].concat(); + assert_eq!(text(&choose, &[]).unwrap(), "CHOOSE(1,#REF!,2)"); + // External links, data tables and unbalanced stacks are refused, never guessed. + let external = [&[0x3Au8][..], &2u16.to_le_bytes(), &[0, 0, 0, 0]].concat(); + assert!(text(&external, &[]).is_err()); + assert!(text(&[0x02, 0, 0, 0, 0], &[]).is_err()); + assert!(text(&[0x1E, 1, 0, 0x1E, 2, 0], &[]).is_err()); + assert!(text(&[0x03], &[]).is_err()); + assert!(text(&[0x21, 0xFF, 0x7F], &[]).is_err()); + assert!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &[0xFF, 0xFF, 0xFF]).is_err()); + } +} diff --git a/crates/xlsx/src/xls/ftab.rs b/crates/xlsx/src/xls/ftab.rs new file mode 100644 index 000000000..d796419f6 --- /dev/null +++ b/crates/xlsx/src/xls/ftab.rs @@ -0,0 +1,376 @@ +// Generated from the Ftab table of [MS-XLS] section 2.5.198.17: (iftab, name, fixed argument +// count or -1 when the function takes a variable number of arguments). +pub(super) const FTAB: &[(u16, &str, i8)] = &[ + (0, "COUNT", -1), + (1, "IF", -1), + (2, "ISNA", 1), + (3, "ISERROR", 1), + (4, "SUM", -1), + (5, "AVERAGE", -1), + (6, "MIN", -1), + (7, "MAX", -1), + (8, "ROW", -1), + (9, "COLUMN", -1), + (10, "NA", 0), + (11, "NPV", -1), + (12, "STDEV", -1), + (13, "DOLLAR", -1), + (14, "FIXED", -1), + (15, "SIN", 1), + (16, "COS", 1), + (17, "TAN", 1), + (18, "ATAN", 1), + (19, "PI", 0), + (20, "SQRT", 1), + (21, "EXP", 1), + (22, "LN", 1), + (23, "LOG10", 1), + (24, "ABS", 1), + (25, "INT", 1), + (26, "SIGN", 1), + (27, "ROUND", 2), + (28, "LOOKUP", -1), + (29, "INDEX", -1), + (30, "REPT", 2), + (31, "MID", 3), + (32, "LEN", 1), + (33, "VALUE", 1), + (34, "TRUE", 0), + (35, "FALSE", 0), + (36, "AND", -1), + (37, "OR", -1), + (38, "NOT", 1), + (39, "MOD", 2), + (40, "DCOUNT", 3), + (41, "DSUM", 3), + (42, "DAVERAGE", 3), + (43, "DMIN", 3), + (44, "DMAX", 3), + (45, "DSTDEV", 3), + (46, "VAR", -1), + (47, "DVAR", 3), + (48, "TEXT", 2), + (49, "LINEST", -1), + (50, "TREND", -1), + (51, "LOGEST", -1), + (52, "GROWTH", -1), + (53, "GOTO", 1), + (54, "HALT", -1), + (55, "RETURN", -1), + (56, "PV", -1), + (57, "FV", -1), + (58, "NPER", -1), + (59, "PMT", -1), + (60, "RATE", -1), + (61, "MIRR", 3), + (62, "IRR", -1), + (63, "RAND", 0), + (64, "MATCH", -1), + (65, "DATE", 3), + (66, "TIME", 3), + (67, "DAY", 1), + (68, "MONTH", 1), + (69, "YEAR", 1), + (70, "WEEKDAY", -1), + (71, "HOUR", 1), + (72, "MINUTE", 1), + (73, "SECOND", 1), + (74, "NOW", 0), + (75, "AREAS", 1), + (76, "ROWS", 1), + (77, "COLUMNS", 1), + (78, "OFFSET", -1), + (79, "ABSREF", 2), + (80, "RELREF", 2), + (81, "ARGUMENT", -1), + (82, "SEARCH", -1), + (83, "TRANSPOSE", 1), + (84, "ERROR", -1), + (85, "STEP", 0), + (86, "TYPE", 1), + (87, "ECHO", -1), + (88, "SET.NAME", -1), + (89, "CALLER", 0), + (90, "DEREF", 1), + (91, "WINDOWS", -1), + (92, "SERIES", -1), + (93, "DOCUMENTS", -1), + (94, "ACTIVE.CELL", 0), + (95, "SELECTION", 0), + (96, "RESULT", -1), + (97, "ATAN2", 2), + (98, "ASIN", 1), + (99, "ACOS", 1), + (100, "CHOOSE", -1), + (101, "HLOOKUP", -1), + (102, "VLOOKUP", -1), + (103, "LINKS", -1), + (104, "INPUT", -1), + (105, "ISREF", 1), + (106, "GET.FORMULA", 1), + (107, "GET.NAME", -1), + (108, "SET.VALUE", 2), + (109, "LOG", -1), + (110, "EXEC", -1), + (111, "CHAR", 1), + (112, "LOWER", 1), + (113, "UPPER", 1), + (114, "PROPER", 1), + (115, "LEFT", -1), + (116, "RIGHT", -1), + (117, "EXACT", 2), + (118, "TRIM", 1), + (119, "REPLACE", 4), + (120, "SUBSTITUTE", -1), + (121, "CODE", 1), + (122, "NAMES", -1), + (123, "DIRECTORY", -1), + (124, "FIND", -1), + (125, "CELL", -1), + (126, "ISERR", 1), + (127, "ISTEXT", 1), + (128, "ISNUMBER", 1), + (129, "ISBLANK", 1), + (130, "T", 1), + (131, "N", 1), + (132, "FOPEN", -1), + (133, "FCLOSE", 1), + (134, "FSIZE", 1), + (135, "FREADLN", 1), + (136, "FREAD", 2), + (137, "FWRITELN", 2), + (138, "FWRITE", 2), + (139, "FPOS", -1), + (140, "DATEVALUE", 1), + (141, "TIMEVALUE", 1), + (142, "SLN", 3), + (143, "SYD", 4), + (144, "DDB", -1), + (145, "GET.DEF", -1), + (146, "REFTEXT", -1), + (147, "TEXTREF", -1), + (148, "INDIRECT", -1), + (149, "REGISTER", -1), + (150, "CALL", -1), + (151, "ADD.BAR", -1), + (152, "ADD.MENU", -1), + (153, "ADD.COMMAND", -1), + (154, "ENABLE.COMMAND", -1), + (155, "CHECK.COMMAND", -1), + (156, "RENAME.COMMAND", -1), + (157, "SHOW.BAR", -1), + (158, "DELETE.MENU", -1), + (159, "DELETE.COMMAND", -1), + (160, "GET.CHART.ITEM", -1), + (161, "DIALOG.BOX", 1), + (162, "CLEAN", 1), + (163, "MDETERM", 1), + (164, "MINVERSE", 1), + (165, "MMULT", 2), + (166, "FILES", -1), + (167, "IPMT", -1), + (168, "PPMT", -1), + (169, "COUNTA", -1), + (170, "CANCEL.KEY", -1), + (171, "FOR", -1), + (172, "WHILE", 1), + (173, "BREAK", 0), + (174, "NEXT", 0), + (175, "INITIATE", 2), + (176, "REQUEST", 2), + (177, "POKE", 3), + (178, "EXECUTE", 2), + (179, "TERMINATE", 1), + (180, "RESTART", -1), + (181, "HELP", -1), + (182, "GET.BAR", -1), + (183, "PRODUCT", -1), + (184, "FACT", 1), + (185, "GET.CELL", -1), + (186, "GET.WORKSPACE", 1), + (187, "GET.WINDOW", -1), + (188, "GET.DOCUMENT", -1), + (189, "DPRODUCT", 3), + (190, "ISNONTEXT", 1), + (191, "GET.NOTE", -1), + (192, "NOTE", -1), + (193, "STDEVP", -1), + (194, "VARP", -1), + (195, "DSTDEVP", 3), + (196, "DVARP", 3), + (197, "TRUNC", -1), + (198, "ISLOGICAL", 1), + (199, "DCOUNTA", 3), + (200, "DELETE.BAR", 1), + (201, "UNREGISTER", 1), + (204, "USDOLLAR", -1), + (205, "FINDB", -1), + (206, "SEARCHB", -1), + (207, "REPLACEB", 4), + (208, "LEFTB", -1), + (209, "RIGHTB", -1), + (210, "MIDB", 3), + (211, "LENB", 1), + (212, "ROUNDUP", 2), + (213, "ROUNDDOWN", 2), + (214, "ASC", 1), + (215, "DBCS", 1), + (216, "RANK", -1), + (219, "ADDRESS", -1), + (220, "DAYS360", -1), + (221, "TODAY", 0), + (222, "VDB", -1), + (223, "ELSE", 0), + (224, "ELSE.IF", 1), + (225, "END.IF", 0), + (226, "FOR.CELL", -1), + (227, "MEDIAN", -1), + (228, "SUMPRODUCT", -1), + (229, "SINH", 1), + (230, "COSH", 1), + (231, "TANH", 1), + (232, "ASINH", 1), + (233, "ACOSH", 1), + (234, "ATANH", 1), + (235, "DGET", 3), + (236, "CREATE.OBJECT", -1), + (237, "VOLATILE", -1), + (238, "LAST.ERROR", 0), + (239, "CUSTOM.UNDO", -1), + (240, "CUSTOM.REPEAT", -1), + (241, "FORMULA.CONVERT", -1), + (242, "GET.LINK.INFO", -1), + (243, "TEXT.BOX", -1), + (244, "INFO", 1), + (245, "GROUP", 0), + (246, "GET.OBJECT", -1), + (247, "DB", -1), + (248, "PAUSE", -1), + (251, "RESUME", -1), + (252, "FREQUENCY", 2), + (253, "ADD.TOOLBAR", -1), + (254, "DELETE.TOOLBAR", 1), + (256, "RESET.TOOLBAR", 1), + (257, "EVALUATE", 1), + (258, "GET.TOOLBAR", -1), + (259, "GET.TOOL", -1), + (260, "SPELLING.CHECK", -1), + (261, "ERROR.TYPE", 1), + (262, "APP.TITLE", -1), + (263, "WINDOW.TITLE", -1), + (264, "SAVE.TOOLBAR", -1), + (265, "ENABLE.TOOL", 3), + (266, "PRESS.TOOL", 3), + (267, "REGISTER.ID", -1), + (268, "GET.WORKBOOK", -1), + (269, "AVEDEV", -1), + (270, "BETADIST", -1), + (271, "GAMMALN", 1), + (272, "BETAINV", -1), + (273, "BINOMDIST", 4), + (274, "CHIDIST", 2), + (275, "CHIINV", 2), + (276, "COMBIN", 2), + (277, "CONFIDENCE", 3), + (278, "CRITBINOM", 3), + (279, "EVEN", 1), + (280, "EXPONDIST", 3), + (281, "FDIST", 3), + (282, "FINV", 3), + (283, "FISHER", 1), + (284, "FISHERINV", 1), + (285, "FLOOR", 2), + (286, "GAMMADIST", 4), + (287, "GAMMAINV", 3), + (288, "CEILING", 2), + (289, "HYPGEOMDIST", 4), + (290, "LOGNORMDIST", 3), + (291, "LOGINV", 3), + (292, "NEGBINOMDIST", 3), + (293, "NORMDIST", 4), + (294, "NORMSDIST", 1), + (295, "NORMINV", 3), + (296, "NORMSINV", 1), + (297, "STANDARDIZE", 3), + (298, "ODD", 1), + (299, "PERMUT", 2), + (300, "POISSON", 3), + (301, "TDIST", 3), + (302, "WEIBULL", 4), + (303, "SUMXMY2", 2), + (304, "SUMX2MY2", 2), + (305, "SUMX2PY2", 2), + (306, "CHITEST", 2), + (307, "CORREL", 2), + (308, "COVAR", 2), + (309, "FORECAST", 3), + (310, "FTEST", 2), + (311, "INTERCEPT", 2), + (312, "PEARSON", 2), + (313, "RSQ", 2), + (314, "STEYX", 2), + (315, "SLOPE", 2), + (316, "TTEST", 4), + (317, "PROB", -1), + (318, "DEVSQ", -1), + (319, "GEOMEAN", -1), + (320, "HARMEAN", -1), + (321, "SUMSQ", -1), + (322, "KURT", -1), + (323, "SKEW", -1), + (324, "ZTEST", -1), + (325, "LARGE", 2), + (326, "SMALL", 2), + (327, "QUARTILE", 2), + (328, "PERCENTILE", 2), + (329, "PERCENTRANK", -1), + (330, "MODE", -1), + (331, "TRIMMEAN", 2), + (332, "TINV", 2), + (334, "MOVIE.COMMAND", -1), + (335, "GET.MOVIE", -1), + (336, "CONCATENATE", -1), + (337, "POWER", 2), + (338, "PIVOT.ADD.DATA", -1), + (339, "GET.PIVOT.TABLE", -1), + (340, "GET.PIVOT.FIELD", -1), + (341, "GET.PIVOT.ITEM", -1), + (342, "RADIANS", 1), + (343, "DEGREES", 1), + (344, "SUBTOTAL", -1), + (345, "SUMIF", -1), + (346, "COUNTIF", 2), + (347, "COUNTBLANK", 1), + (348, "SCENARIO.GET", -1), + (349, "OPTIONS.LISTS.GET", 1), + (350, "ISPMT", 4), + (351, "DATEDIF", 3), + (352, "DATESTRING", 1), + (353, "NUMBERSTRING", 2), + (354, "ROMAN", -1), + (355, "OPEN.DIALOG", -1), + (356, "SAVE.DIALOG", -1), + (357, "VIEW.GET", -1), + (358, "GETPIVOTDATA", -1), + (359, "HYPERLINK", -1), + (360, "PHONETIC", 1), + (361, "AVERAGEA", -1), + (362, "MAXA", -1), + (363, "MINA", -1), + (364, "STDEVPA", -1), + (365, "VARPA", -1), + (366, "STDEVA", -1), + (367, "VARA", -1), + (368, "BAHTTEXT", 1), + (369, "THAIDAYOFWEEK", 1), + (370, "THAIDIGIT", 1), + (371, "THAIMONTHOFYEAR", 1), + (372, "THAINUMSOUND", 1), + (373, "THAINUMSTRING", 1), + (374, "THAISTRINGLENGTH", 1), + (375, "ISTHAIDIGIT", 1), + (376, "ROUNDBAHTDOWN", 1), + (377, "ROUNDBAHTUP", 1), + (378, "THAIYEAR", 1), + (379, "RTD", -1), +]; diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs new file mode 100644 index 000000000..b070478d5 --- /dev/null +++ b/crates/xlsx/src/xls/mod.rs @@ -0,0 +1,765 @@ +//! Legacy Excel 97-2003 `.xls` (BIFF8) import, implemented from Microsoft's public +//! specifications [MS-CFB] (the compound file container) and [MS-XLS] (the Workbook stream): +//! . +//! +//! 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. + +mod formula; +mod ftab; +mod records; + +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 crate::cfb::Compound; +use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; +use crate::{IoError, ReadReport}; +use formula::{Links, Mode, SupBook, Unsupported, Xti, cell_error, decode}; +use records::{Cursor, Record, Records}; + +const MAX_CELLS: usize = 4_000_000; +const MAX_STRINGS: usize = 4_000_000; +const MAX_TEXT: usize = 256 * 1024 * 1024; +const MAX_SHEETS: usize = 4096; +const MAX_NAMES: usize = 65_536; +const MAX_XFS: usize = 65_536; +const MAX_MERGES: usize = 100_000; +/// Records read from one substream at most (a 65,536 × 256 sheet has fewer). +const MAX_RECORDS: usize = 40_000_000; + +const BOF: u16 = 0x0809; +const EOF: u16 = 0x000A; +const BIFF8: u16 = 0x0600; + +pub(crate) fn invalid(msg: impl Into) -> IoError { + IoError::Format(format!("XLS: {}", msg.into())) +} + +/// The message for an encrypted workbook (a FilePass record). +const ENCRYPTED: &str = + "this .xls workbook is password-protected (encrypted). GridCraft can't open encrypted .xls files; remove the password in Excel and try again."; + +/// Whether the bytes are a compound file holding an Excel workbook stream (BIFF8 `Workbook` +/// or BIFF5 `Book`). +pub(crate) fn is_xls(bytes: &[u8]) -> bool { + let Ok(cf) = Compound::open(bytes) else { return false }; + cf.has_root_stream("Workbook") || cf.has_root_stream("Book") +} + +#[derive(Default)] +struct Budget { + cells: usize, + text: usize, +} + +impl Budget { + fn cell(&mut self) -> Result<(), IoError> { + self.cells += 1; + if self.cells > MAX_CELLS { + return Err(IoError::TooLarge("XLS: more than 4 million cells".into())); + } + Ok(()) + } + fn text(&mut self, len: usize) -> Result<(), IoError> { + self.text = self.text.saturating_add(len); + if self.text > MAX_TEXT { + return Err(IoError::TooLarge("XLS: more than 256 MB of text".into())); + } + Ok(()) + } +} + +struct BoundSheet { + pos: usize, + name: String, + visibility: Visibility, + kind: u8, +} + +struct Lbl { + name: String, + builtin: Option, + hidden: bool, + macro_name: bool, + itab: u16, + rgce: Vec, + rgcb: Vec, +} + +#[derive(Default)] +struct Globals { + sheets: Vec, + date1904: bool, + formats: HashMap, + xf_formats: Vec, + sst: Vec>, + links: Links, + names: Vec, +} + +/// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. +pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + let cf = Compound::open(bytes).map_err(invalid)?; + let stream = match cf.root_stream("Workbook").map_err(invalid)? { + Some(s) => s, + None => { + if cf.has_root_stream("Book") { + return Err(invalid("Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again.")); + } + return Err(invalid("the file has no Workbook stream, so it is not an Excel workbook")); + } + }; + let mut report = ReadReport::default(); + let mut budget = Budget::default(); + let g = globals(&stream, &mut budget)?; + + let mut wb = Workbook::new(); + wb.sheets.clear(); + wb.date_system = if g.date1904 { DateSystem::D1904 } else { DateSystem::D1900 }; + // One model style per XF: only the number format is imported. + let styles: Vec = g + .xf_formats + .iter() + .map(|ifmt| { + let code = g.formats.get(ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(*ifmt))).unwrap_or("General"); + if code.eq_ignore_ascii_case("General") { + StyleId::DEFAULT + } else { + wb.styles.intern(Style { num_fmt: NumFmt::new(code), ..Style::default() }) + } + }) + .collect(); + + // BoundSheet8 index → model sheet index. + let mut model_index: Vec> = Vec::with_capacity(g.sheets.len()); + for bs in &g.sheets { + if bs.kind != 0 { + report.warn(match bs.kind { + 2 => "Chart sheets in .xls files are not imported yet and were skipped.", + 1 => "Excel 4.0 macro sheets were skipped.", + _ => "VBA modules and other non-worksheet sheets were skipped.", + }); + model_index.push(None); + 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 { + report.warn("Dialog sheets and unreadable sheets were skipped."); + model_index.push(None); + continue; + }; + sheet.visibility = bs.visibility; + model_index.push(Some(wb.sheets.len())); + wb.sheets.push(Arc::new(sheet)); + } + if wb.sheets.is_empty() { + return Err(invalid("the workbook has no worksheets GridCraft can read")); + } + 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. + report.warnings.insert( + 0, + "Imported from an Excel 97-2003 (.xls) workbook: values, formulas, number formats, merged cells, column widths, row heights and defined names were read. Fonts, fills, borders, charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(), + ); + Ok((wb, report)) +} + +fn bof(rec: &Record<'_>) -> Result<(u16, u16), IoError> { + if rec.id != BOF { + return Err(invalid("a substream does not start with a BOF record")); + } + let mut c = Cursor::new(&rec.parts); + Ok((c.u16()?, c.u16()?)) +} + +fn globals(stream: &[u8], budget: &mut Budget) -> 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)?; + if vers != BIFF8 { + return Err(invalid(if vers == 0x0500 { + "Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again." + } else { + "unsupported BIFF version (only Excel 97-2003 BIFF8 files are read)" + })); + } + if dt != 0x0005 { + return Err(invalid("the Workbook stream does not start with the workbook globals")); + } + let mut g = Globals::default(); + let mut count = 0usize; + while let Some(rec) = recs.next_record()? { + count += 1; + if count > MAX_RECORDS { + return Err(IoError::TooLarge("XLS: too many records".into())); + } + let mut c = Cursor::new(&rec.parts); + match rec.id { + EOF => break, + 0x002F => return Err(invalid(ENCRYPTED)), + 0x0085 => { + if g.sheets.len() >= MAX_SHEETS { + return Err(IoError::TooLarge("XLS: too many sheets".into())); + } + let pos = c.u32()? as usize; + let state = c.u8()?; + let kind = c.u8()?; + let name = c.short_string()?; + let visibility = match state & 3 { + 0 => Visibility::Visible, + 1 => Visibility::Hidden, + _ => Visibility::VeryHidden, + }; + g.links.sheets.push(name.clone()); + g.sheets.push(BoundSheet { pos, name, visibility, kind }); + } + 0x0022 => g.date1904 = c.u16()? == 1, + 0x041E => { + let ifmt = c.u16()?; + let code = c.string()?; + budget.text(code.len())?; + g.formats.insert(ifmt, code); + } + 0x00E0 => { + if g.xf_formats.len() < MAX_XFS { + c.u16()?; + g.xf_formats.push(c.u16()?); + } + } + 0x00FC => g.sst = shared_strings(&mut c, budget)?, + 0x01AE => { + let _ctab = c.u16()?; + let cch = c.u16()?; + g.links.books.push(SupBook { internal: cch == 0x0401, addin: cch == 0x3A01, names: vec![] }); + } + 0x0023 => { + // ExternName: flags, then an AddinUdf or ExternDocName body; both start with + // four bytes before the name. + c.u16()?; + c.skip(4)?; + let name = c.short_string()?; + if let Some(book) = g.links.books.last_mut() + && book.names.len() < MAX_NAMES + { + book.names.push(name); + } + } + 0x0017 => { + let n = usize::from(c.u16()?); + for _ in 0..n.min(c.remaining() / 6) { + let book = c.u16()?; + let first = c.u16()? as i16; + let last = c.u16()? as i16; + g.links.xti.push(Xti { book, first, last }); + } + } + 0x0018 => { + if g.names.len() >= MAX_NAMES { + continue; + } + let lbl = label(&mut c)?; + g.links.names.push(lbl.name.clone()); + g.names.push(lbl); + } + _ => {} + } + } + Ok(g) +} + +/// Built-in defined names ([MS-XLS] 2.4.150, Lbl.Name). +const BUILTIN_NAMES: [&str; 14] = [ + "Consolidate_Area", + "Auto_Open", + "Auto_Close", + "Extract", + "Database", + "Criteria", + "Print_Area", + "Print_Titles", + "Recorder", + "Data_Form", + "Auto_Activate", + "Auto_Deactivate", + "Sheet_Title", + "_FilterDatabase", +]; + +fn label(c: &mut Cursor<'_, '_>) -> Result { + let flags = c.u16()?; + let _key = c.u8()?; + let cch = usize::from(c.u8()?); + let cce = usize::from(c.u16()?); + c.skip(2)?; + let itab = c.u16()?; + c.skip(4)?; + let high = c.u8()? & 1 != 0; + let raw = c.chars(cch, high)?; + let builtin = flags & 0x0020 != 0; + let (name, builtin) = if builtin { + let code = raw.chars().next().map_or(0, |ch| ch as u32); + let code = u8::try_from(code).unwrap_or(u8::MAX); + (BUILTIN_NAMES.get(usize::from(code)).map_or_else(|| raw.clone(), |n| (*n).to_string()), Some(code)) + } else { + (raw, None) + }; + let rest = c.rest(); + let rgce = rest.get(..cce).unwrap_or(&rest).to_vec(); + let rgcb = rest.get(cce..).unwrap_or(&[]).to_vec(); + Ok(Lbl { name, builtin, hidden: flags & 1 != 0, macro_name: flags & 0x000A != 0, itab, rgce, rgcb }) +} + +fn shared_strings(c: &mut Cursor<'_, '_>, budget: &mut Budget) -> Result>, IoError> { + let _total = c.u32()?; + let unique = c.u32()? as usize; + if unique > MAX_STRINGS { + return Err(IoError::TooLarge("XLS: too many shared strings".into())); + } + // Each string takes at least three bytes: the declared count can't exceed what's there. + let mut out = Vec::with_capacity(unique.min(c.remaining() / 3)); + for _ in 0..unique { + if c.remaining() == 0 { + break; + } + let s = c.rich_string()?; + budget.text(s.len())?; + out.push(Arc::from(s)); + } + Ok(out) +} + +/// A formula cell waiting for its shared or array formula (which follows it in the file). +struct Pending { + pos: CellRef, + anchor: CellRef, +} + +/// A parsed formula: tokens and extra data. +struct Parsed { + rgce: Vec, + rgcb: Vec, +} + +fn parsed(c: &mut Cursor<'_, '_>) -> Result { + let cce = usize::from(c.u16()?); + let rest = c.rest(); + if cce > rest.len() { + return Err(invalid("formula runs past the end of its record")); + } + Ok(Parsed { rgce: rest.get(..cce).unwrap_or(&[]).to_vec(), rgcb: rest.get(cce..).unwrap_or(&[]).to_vec() }) +} + +struct SheetState<'g> { + g: &'g Globals, + styles: &'g [StyleId], + sheet: Sheet, + pending: Vec, + shared: HashMap, + arrays: HashMap, + last_formula: Option, + pending_string: Option, + dropped: usize, + unsupported: Vec<&'static str>, +} + +impl SheetState<'_> { + /// Records why a formula was kept as its value (each reason once). + fn unsupported(&mut self, why: &'static str) { + if !self.unsupported.contains(&why) { + self.unsupported.push(why); + } + } + + fn style(&self, ixfe: u16) -> StyleId { + self.styles.get(usize::from(ixfe)).copied().unwrap_or(StyleId::DEFAULT) + } + + fn pos(&mut self, row: u16, col: u16) -> Option { + let (r, c) = (u32::from(row), u32::from(col)); + if r >= MAX_ROWS || c >= MAX_COLS.min(256) { + self.dropped += 1; + return None; + } + Some(CellRef::new(r, c)) + } + + fn put(&mut self, budget: &mut Budget, row: u16, col: u16, ixfe: u16, value: Value) -> Result, IoError> { + let Some(pos) = self.pos(row, col) else { return Ok(None) }; + let style = self.style(ixfe); + if value.is_empty() && style == StyleId::DEFAULT { + return Ok(Some(pos)); + } + budget.cell()?; + self.sheet.cells.set(pos, Cell { value, formula: None, style }); + Ok(Some(pos)) + } + + /// Decodes a formula and attaches it to the cell at `pos`, or records why it can't be. + fn attach(&mut self, pos: CellRef, p: &Parsed, mode: Mode, array: Option) { + match decode(&p.rgce, &p.rgcb, pos, mode, &self.g.links) { + Ok(e) => { + let text = gridcraft_formula::print(&e); + let mut f = Formula::new(&text); + if f.expr.is_none() { + self.unsupported("syntax GridCraft can't parse yet"); + return; + } + f.array = array.filter(|r| !r.is_single()); + if let Some(cell) = self.sheet.cells.get_mut(pos) { + cell.formula = Some(Arc::new(f)); + } else { + self.sheet.cells.set(pos, Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT }); + } + } + Err(Unsupported(why)) => self.unsupported(why), + } + } +} + +/// RK number ([MS-XLS] 2.5.217): a 30-bit integer or the top of an IEEE double, maybe ×100. +fn rk(v: u32) -> Value { + let n = if v & 2 != 0 { f64::from((v as i32) >> 2) } else { f64::from_bits(u64::from(v & !3) << 32) }; + Value::number(if v & 1 != 0 { n / 100.0 } else { n }) +} + +fn worksheet( + stream: &[u8], + bs: &BoundSheet, + g: &Globals, + styles: &[StyleId], + budget: &mut Budget, + report: &mut ReadReport, +) -> Result, IoError> { + if bs.pos >= stream.len() { + return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name))); + } + let mut recs = Records::new(stream, bs.pos); + let first = recs.next_record()?.ok_or_else(|| invalid("missing sheet substream"))?; + let (vers, dt) = bof(&first)?; + if vers != BIFF8 || dt != 0x0010 { + return Ok(None); + } + let mut st = SheetState { + g, + styles, + sheet: Sheet::new(&bs.name), + pending: vec![], + shared: HashMap::new(), + arrays: HashMap::new(), + last_formula: None, + pending_string: None, + dropped: 0, + unsupported: vec![], + }; + let mut depth = 1usize; + let mut count = 0usize; + while let Some(rec) = recs.next_record()? { + count += 1; + if count > MAX_RECORDS { + return Err(IoError::TooLarge("XLS: too many records in a sheet".into())); + } + match rec.id { + BOF => { + // An embedded chart's substream. + depth += 1; + continue; + } + EOF => { + depth -= 1; + if depth == 0 { + break; + } + continue; + } + _ if depth > 1 => continue, + _ => {} + } + sheet_record(&rec, &mut st, budget, report)?; + } + // 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) { + 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)); + } + } else if let Some(f) = shared.get(&p.anchor) { + st.attach(p.pos, f, Mode::Shared, None); + } else { + st.unsupported("a shared formula that is missing from the file"); + } + } + if st.dropped > 0 { + report.warn(format!("{} cells outside the grid were skipped.", st.dropped)); + } + for why in &st.unsupported { + report.warn(format!("Some formulas use {why}; their saved results were kept as values.")); + } + Ok(Some(st.sheet)) +} + +fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> { + let mut c = Cursor::new(&rec.parts); + // A formula's string result is the String record right after it (shared/array records may sit between). + if !matches!(rec.id, 0x0207 | 0x04BC | 0x0221 | 0x0236) { + st.pending_string = None; + } + match rec.id { + 0x0203 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let v = Value::number(c.f64()?); + st.put(budget, r, col, x, v)?; + } + 0x027E => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let v = rk(c.u32()?); + st.put(budget, r, col, x, v)?; + } + 0x00BD => { + let r = c.u16()?; + let first = c.u16()?; + let n = c.remaining().saturating_sub(2) / 6; + for i in 0..n { + let x = c.u16()?; + let v = rk(c.u32()?); + let col = u16::try_from(usize::from(first) + i).unwrap_or(u16::MAX); + st.put(budget, r, col, x, v)?; + } + } + 0x00FD => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let i = c.u32()? as usize; + match st.g.sst.get(i) { + Some(s) => { + st.put(budget, r, col, x, Value::Text(s.clone()))?; + } + None => report.warn("Some cells refer to missing shared strings and were left empty."), + } + } + 0x0204 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let s = c.string()?; + budget.text(s.len())?; + st.put(budget, r, col, x, Value::from(s))?; + } + 0x0205 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let (v, is_err) = (c.u8()?, c.u8()?); + let v = if is_err != 0 { Value::Error(cell_error(v)) } else { Value::Bool(v != 0) }; + st.put(budget, r, col, x, v)?; + } + 0x0201 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + st.put(budget, r, col, x, Value::Empty)?; + } + 0x00BE => { + let r = c.u16()?; + let first = c.u16()?; + let n = c.remaining().saturating_sub(2) / 2; + for i in 0..n { + let x = c.u16()?; + let col = u16::try_from(usize::from(first) + i).unwrap_or(u16::MAX); + st.put(budget, r, col, x, Value::Empty)?; + } + } + 0x0006 => formula_cell(&mut c, st, budget)?, + 0x0207 => { + if let Some(pos) = 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); + } + } + } + 0x04BC => { + // RefU (the cells that may use it), reserved, cUse. + c.skip(8)?; + let f = parsed(&mut c)?; + if let Some(at) = st.last_formula { + st.shared.insert(at, f); + } + } + 0x0221 => { + let (r0, r1) = (c.u16()?, c.u16()?); + let (c0, c1) = (c.u8()?, c.u8()?); + c.skip(6)?; + let f = parsed(&mut c)?; + let range = RangeRef::new(CellRef::new(u32::from(r0), u32::from(c0)), CellRef::new(u32::from(r1.max(r0)), u32::from(c1.max(c0)))); + if let Some(at) = st.last_formula { + st.arrays.insert(at, (range, f)); + } + } + 0x0236 => report.warn("What-if data tables are not supported; their values were kept."), + 0x00E5 => { + let n = usize::from(c.u16()?); + for _ in 0..n.min(c.remaining() / 8) { + let (r0, r1, c0, c1) = (c.u16()?, c.u16()?, c.u16()?, c.u16()?); + if r1 < r0 || c1 < c0 || u32::from(c1) >= 256 { + continue; + } + if st.sheet.merges.len() >= MAX_MERGES { + return Err(IoError::TooLarge("XLS: too many merged ranges".into())); + } + let range = RangeRef::new(CellRef::new(u32::from(r0), u32::from(c0)), CellRef::new(u32::from(r1), u32::from(c1))); + if !range.is_single() { + st.sheet.merges.push(range); + } + } + } + 0x007D => { + let (first, last) = (c.u16()?, c.u16()?); + let width = c.u16()?; + let x = c.u16()?; + let flags = c.u16()?; + let last = last.min(255); + if first > last { + return Ok(()); + } + let mut info = LineInfo::default(); + let px = col_width_to_px(f64::from(width) / 256.0); + if flags & 0x0002 != 0 || px != st.sheet.default_col_width { + info.size = Some(px); + } + info.hidden = flags & 0x0001 != 0; + info.outline = ((flags >> 8) & 7) as u8; + info.collapsed = flags & 0x1000 != 0; + let style = st.style(x); + if style != StyleId::DEFAULT { + info.style = Some(style); + } + if info != LineInfo::default() { + for col in first..=last { + st.sheet.cols.insert(u32::from(col), info); + } + } + } + 0x0208 => { + let r = c.u16()?; + c.skip(4)?; + let height = c.u16()?; + c.skip(4)?; + let flags = c.u32()?; + let mut info = LineInfo { custom: flags & 0x40 != 0, hidden: flags & 0x20 != 0, ..LineInfo::default() }; + let px = pt_to_px(f64::from(height & 0x7FFF) / 20.0); + if info.custom || (px - st.sheet.default_row_height).abs() > 0.5 { + info.size = Some(px); + } + info.outline = (flags & 7) as u8; + info.collapsed = flags & 0x10 != 0; + if flags & 0x80 != 0 { + let style = st.style(((flags >> 16) & 0x0FFF) as u16); + if style != StyleId::DEFAULT { + info.style = Some(style); + } + } + if info != LineInfo::default() && u32::from(r) < MAX_ROWS { + st.sheet.rows.insert(u32::from(r), info); + } + } + 0x0225 => { + let flags = c.u16()?; + let height = c.u16()?; + if flags & 0x0002 == 0 && height > 0 { + st.sheet.default_row_height = pt_to_px(f64::from(height) / 20.0); + } + } + 0x001C => report.warn("Cell comments in .xls files are not imported yet."), + 0x00EC => report.warn("Pictures, charts and shapes in .xls files are not imported yet."), + 0x01B0 | 0x0879 => report.warn("Conditional formatting in .xls files is not imported yet."), + 0x01B2 => report.warn("Data validation in .xls files is not imported yet."), + 0x01B8 => report.warn("Hyperlinks in .xls files are not imported yet."), + 0x00B0 => report.warn("PivotTables in .xls files are not imported yet; their values were kept."), + _ => {} + } + Ok(()) +} + +fn formula_cell(c: &mut Cursor<'_, '_>, st: &mut SheetState<'_>, budget: &mut Budget) -> Result<(), IoError> { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let mut val = [0u8; 8]; + for b in val.iter_mut() { + *b = c.u8()?; + } + let _flags = c.u16()?; + c.skip(4)?; + let f = parsed(c)?; + let [b0, _, b2, _, _, _, e0, e1] = val; + let mut string_result = false; + let value = if [e0, e1] == [0xFF, 0xFF] { + match b0 { + 0x00 => { + string_result = true; + Value::Empty + } + 0x01 => Value::Bool(b2 != 0), + 0x02 => Value::Error(cell_error(b2)), + 0x03 => Value::from(""), + _ => Value::Empty, + } + } else { + Value::number(f64::from_le_bytes(val)) + }; + let Some(pos) = st.pos(r, col) else { return Ok(()) }; + budget.cell()?; + let style = st.style(x); + st.sheet.cells.set(pos, Cell { value, formula: None, style }); + st.last_formula = Some(pos); + if string_result { + st.pending_string = Some(pos); + } + // PtgExp: part of a shared or array formula anchored at another cell. + if f.rgce.len() == 5 && f.rgce.first() == Some(&0x01) { + let get = |i: usize| f.rgce.get(i).copied().unwrap_or(0); + let anchor = CellRef::new(u32::from(u16::from_le_bytes([get(1), get(2)])), u32::from(u16::from_le_bytes([get(3), get(4)]))); + st.pending.push(Pending { pos, anchor }); + return Ok(()); + } + st.attach(pos, &f, Mode::Cell, None); + Ok(()) +} + +fn defined_names(g: &Globals, model_index: &[Option], wb: &mut Workbook, report: &mut ReadReport) { + let scope_of = |itab: u16| -> Option> { + if itab == 0 { + return Some(None); + } + model_index.get(usize::from(itab) - 1).copied().flatten().map(Some) + }; + for lbl in &g.names { + let Some(scope) = scope_of(lbl.itab) else { continue }; + let text = match decode(&lbl.rgce, &lbl.rgcb, CellRef::new(0, 0), Mode::Name, &g.links) { + Ok(e) => gridcraft_formula::print(&e), + Err(_) if lbl.rgce.is_empty() => continue, + Err(Unsupported(why)) => { + report.warn(format!("Some defined names use {why} and were skipped.")); + continue; + } + }; + match lbl.builtin { + Some(6 | 7) => { + let Some(si) = scope else { continue }; + let Some(sheet) = wb.sheet_mut(si) else { continue }; + if lbl.builtin == Some(6) { + sheet.print.print_area = crate::read::parse_print_area(&text); + } else { + let (rows, cols) = crate::read::parse_print_titles(&text); + sheet.print.title_rows = rows; + sheet.print.title_cols = cols; + } + } + Some(_) => {} + None if lbl.macro_name => report.warn("Macro names were skipped."), + None => wb.names.push(DefinedName { name: lbl.name.clone(), scope, formula: text, comment: String::new(), hidden: lbl.hidden }), + } + } +} diff --git a/crates/xlsx/src/xls/records.rs b/crates/xlsx/src/xls/records.rs new file mode 100644 index 000000000..fac7847dd --- /dev/null +++ b/crates/xlsx/src/xls/records.rs @@ -0,0 +1,198 @@ +//! BIFF8 record framing ([MS-XLS] 2.1.4) and the string structures ([MS-XLS] 2.5.240, +//! 2.5.293, 2.5.294) that may continue across `Continue` records. + +use super::invalid; +use crate::IoError; + +/// `Continue` record type. +pub(super) const CONTINUE: u16 = 0x003C; +/// Characters in one string at most (Excel's cell limit is 32,767; the SST may hold longer). +const MAX_CHARS: usize = 1 << 20; +/// `Continue` records gathered for one record at most. +const MAX_PARTS: usize = 1 << 16; + +/// One record and the payloads of the `Continue` records that follow it. +pub(super) struct Record<'a> { + pub id: u16, + pub parts: Vec<&'a [u8]>, +} + +/// Iterates over the records of a stream starting at `pos`. +pub(super) struct Records<'a> { + stream: &'a [u8], + pos: usize, +} + +impl<'a> Records<'a> { + pub fn new(stream: &'a [u8], pos: usize) -> Self { + Records { stream, pos } + } + + fn raw(&mut self) -> Result, IoError> { + if self.pos >= self.stream.len() { + return Ok(None); + } + let head = self.stream.get(self.pos..self.pos.saturating_add(4)).ok_or_else(|| invalid("truncated record header"))?; + let id = u16::from_le_bytes([head.first().copied().unwrap_or(0), head.get(1).copied().unwrap_or(0)]); + let len = usize::from(u16::from_le_bytes([head.get(2).copied().unwrap_or(0), head.get(3).copied().unwrap_or(0)])); + let start = self.pos + 4; + let data = self.stream.get(start..start.saturating_add(len)).ok_or_else(|| invalid("truncated record"))?; + self.pos = start + len; + Ok(Some((id, data))) + } + + fn peek_id(&self) -> Option { + let h = self.stream.get(self.pos..self.pos.checked_add(2)?)?; + Some(u16::from_le_bytes([*h.first()?, *h.get(1)?])) + } + + /// The next record with its `Continue` records, or `None` at the end of the stream. + pub fn next_record(&mut self) -> Result>, IoError> { + let Some((id, data)) = self.raw()? else { return Ok(None) }; + let mut parts = vec![data]; + while self.peek_id() == Some(CONTINUE) && parts.len() < MAX_PARTS { + if let Some((_, more)) = self.raw()? { + parts.push(more); + } + } + Ok(Some(Record { id, parts })) + } +} + +/// Reads a record's payload, crossing into its `Continue` records when needed. +pub(super) struct Cursor<'a, 'b> { + parts: &'b [&'a [u8]], + part: usize, + pos: usize, +} + +impl<'a, 'b> Cursor<'a, 'b> { + pub fn new(parts: &'b [&'a [u8]]) -> Self { + Cursor { parts, part: 0, pos: 0 } + } + + /// Skips to the next part when the current one is used up. Returns whether a new part began. + fn settle(&mut self) -> bool { + let mut moved = false; + while self.parts.get(self.part).is_some_and(|p| self.pos >= p.len()) && self.part + 1 < self.parts.len() { + self.part += 1; + self.pos = 0; + moved = true; + } + moved + } + + pub fn u8(&mut self) -> Result { + self.settle(); + let b = self.parts.get(self.part).and_then(|p| p.get(self.pos)).copied().ok_or_else(|| invalid("record is shorter than its contents"))?; + self.pos += 1; + Ok(b) + } + + pub fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes([self.u8()?, self.u8()?])) + } + + pub fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes([self.u8()?, self.u8()?, self.u8()?, self.u8()?])) + } + + pub fn f64(&mut self) -> Result { + let mut b = [0u8; 8]; + for x in b.iter_mut() { + *x = self.u8()?; + } + Ok(f64::from_le_bytes(b)) + } + + pub fn skip(&mut self, n: usize) -> Result<(), IoError> { + for _ in 0..n { + self.u8()?; + } + Ok(()) + } + + /// Bytes left in the record (all parts). + pub fn remaining(&self) -> usize { + let here = self.parts.get(self.part).map_or(0, |p| p.len().saturating_sub(self.pos)); + here + self.parts.iter().skip(self.part + 1).map(|p| p.len()).sum::() + } + + /// The rest of the record as one buffer. + pub fn rest(&mut self) -> Vec { + let mut out = Vec::with_capacity(self.remaining()); + if let Some(p) = self.parts.get(self.part) { + out.extend_from_slice(p.get(self.pos..).unwrap_or(&[])); + } + for p in self.parts.iter().skip(self.part + 1) { + out.extend_from_slice(p); + } + self.part = self.parts.len(); + self.pos = 0; + out + } + + /// `cch` characters, compressed (one byte each) or UTF-16. When the characters run into + /// a `Continue` record, that record starts with a fresh compression flag ([MS-XLS] 2.5.293). + pub fn chars(&mut self, cch: usize, mut high: bool) -> Result { + if cch > MAX_CHARS { + return Err(invalid("string too long")); + } + let mut units: Vec = Vec::with_capacity(cch.min(self.remaining())); + while units.len() < cch { + let at_end = self.parts.get(self.part).is_none_or(|p| self.pos >= p.len()); + if at_end && self.settle() { + high = self.u8()? & 1 != 0; + } + units.push(if high { self.u16()? } else { u16::from(self.u8()?) }); + } + Ok(String::from_utf16_lossy(&units)) + } + + /// ShortXLUnicodeString: 1-byte count, flags, characters. + pub fn short_string(&mut self) -> Result { + let cch = usize::from(self.u8()?); + let high = self.u8()? & 1 != 0; + self.chars(cch, high) + } + + /// XLUnicodeString: 2-byte count, flags, characters. + pub fn string(&mut self) -> Result { + let cch = usize::from(self.u16()?); + let high = self.u8()? & 1 != 0; + self.chars(cch, high) + } + + /// XLUnicodeRichExtendedString: the text; formatting runs and phonetic data are skipped. + pub fn rich_string(&mut self) -> Result { + let cch = usize::from(self.u16()?); + let flags = self.u8()?; + let runs = if flags & 0x08 != 0 { usize::from(self.u16()?) } else { 0 }; + let ext = if flags & 0x04 != 0 { self.u32()? as usize } else { 0 }; + let text = self.chars(cch, flags & 1 != 0)?; + let extra = runs.saturating_mul(4).saturating_add(ext); + if extra > self.remaining() { + return Err(invalid("string formatting runs past the end of the record")); + } + self.skip(extra)?; + Ok(text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strings_continue_with_a_new_compression_flag() { + // "ab" compressed, then "ç" and "d" as UTF-16 in a Continue part. + let first: &[u8] = &[4, 0, 0, b'a', b'b']; + let second: &[u8] = &[1, 0xE7, 0, b'd', 0]; + let parts = [first, second]; + let mut c = Cursor::new(&parts); + assert_eq!(c.rich_string().unwrap(), "abçd"); + assert_eq!(c.remaining(), 0); + let mut c = Cursor::new(&parts); + assert!(c.chars(MAX_CHARS + 1, false).is_err()); + } +} diff --git a/docs/cli.md b/docs/cli.md index 44e69e5ee..a4606fe54 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,6 +3,11 @@ Headless GridCraft: inspect, convert, evaluate, script and serve MCP without a window. Errors go to stderr with a non-zero exit code. +`.xls` (Excel 97-2003, BIFF8) files are imported with values, formulas, number formats, merges, +column widths, row heights and defined names; fonts, fills, borders, charts and other objects are +not imported yet. Import warnings go to stderr. Convert to `.xlsx` to save; `.xls` output is not +supported. + `.xlsb` files support [worksheet data import](xlsb-import.md). Formula cells use their last saved values; source formatting and other workbook features are omitted. Import warnings go to stderr. Convert to `.xlsx` to save the imported workbook; XLSB output is not supported. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index eeb2dd9b4..7477f020f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; every format Excel reads or writes, against the code on main) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.xls` BIFF8 import, #121) · **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** @@ -9,7 +9,7 @@ Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready Excel's list comes from Microsoft's public "File formats that are supported in Excel" documentation (the Office bundle is off limits under this repo's clean-room rule). GridCraft's support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and the tests in -`crates/xlsx/src/tests/` (round-trip, fixtures, malformed input, CSV, ODS, XLSB, PivotTables). +`crates/xlsx/src/tests/` (round-trip, fixtures, malformed input, CSV, ODS, XLSB, XLS, PivotTables). ## Workbook formats @@ -20,8 +20,8 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, **without macros** | VBA project dropped with a warning; saving as `.xlsm` loses the macros (#6) | — | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the workbook content type, not the template one; "new from template" behaviour missing | — | | `.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 | **no** (#121); recognised and refused with a message since #17 | no | Still common in finance, government and legacy systems | — | -| `.xlt` Excel 97-2003 template | R/W | no | no | | — | +| `.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` | +| `.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 | — | | `.ods` OpenDocument Spreadsheet | R/W | **values only** (#100) | no | Sheets, values, dates, merges, repeated cells, cached formula results; no formulas, formatting, charts; encrypted ODS refused | `ods_tests.rs` | @@ -64,7 +64,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | | Preserve unmodelled XLSX parts and extensions on round trip (pass-through with relationship fix-up) | 10–15 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | -| Legacy `.xls` (BIFF8) import: cells, formulas, styles, names; then write | 20–30 | +| Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | | Encrypted workbooks: open with password prompt, save with password (#11) | 8–12 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | | CSV/text: displayed-value export, encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 5–8 | @@ -74,5 +74,6 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | `.xls` (BIFF8) import: values, formulas, number formats, merges, sizes, names (#121) | | 2026-10-10 | minor | Encrypted and `.xls` files are now recognised and refused with a message (#17) | | 2026-10-10 | major | Created: every Excel format with our read/write support; XLSB and ODS values-only imports recorded; `.xls`, encryption and large files flagged as beta gates | diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8d8..1da410495 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -64,11 +64,14 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Impact:** wrong numbers are worse than missing features. - **Estimate:** 10–15 h. [function-parity.md](function-parity.md#formula-language) -### 7. No legacy `.xls`; no password-protected workbooks (FF) -- **Evidence:** #121. Since #17 (closed #11) both are recognised through a CFB sniff and refused with a clear message, but neither opens. +### 7. Legacy `.xls` only partly read; no password-protected workbooks (FF) +- **Evidence:** #121: Excel 97-2003 (BIFF8) `.xls` files now open (values, formulas, number + formats, merges, sizes, names); fonts, fills, borders, charts, pictures, comments, conditional + formats, validation and BIFF5 files are not read, and `.xls` is never written. Encrypted + workbooks (OOXML and `.xls`) are recognised and refused with a clear message since #17 (closed #11). - **Impact:** common in finance, government and anything older than 2007; encrypted files are routine in business. -- **Estimate:** 28–42 h (`.xls` read 20–30, encryption 8–12). [file-format-parity.md](file-format-parity.md) +- **Estimate:** 18–27 h (`.xls` styles and objects 10–15, encryption 8–12). [file-format-parity.md](file-format-parity.md) ### 8. PivotTables: no slicers, calculated fields, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, @@ -149,6 +152,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | | 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues | From ed623902442f2853d758f87906e8140bc880508d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:12:56 +0200 Subject: [PATCH 073/102] XLSX: keep unmodelled parts on save (#330) Pass-through of package content GridCraft does not model: on read the raw parts are kept with their content types and relationship graph per owner (package root, workbook, sheet); on write they get fresh part names where needed and new relationship ids, and the r:ids in kept XML are rewritten. Kept: custom document properties, ribbon customisation, add-in task panes, custom XML, links to external workbooks with (never followed), connections, sheet query tables, printer settings with the (#324), form and ActiveX controls, OLE objects and sheet backgrounds with their VML shapes (merged with GridCraft's note shapes). Content whose graph reaches parts GridCraft rewrites is dropped with a warning; sheet content goes with its sheet and is not copied by Copy Sheet. Fixes #324 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/sheet.rs | 1 + crates/model/src/lib.rs | 2 + crates/model/src/preserved.rs | 70 ++ crates/model/src/sheet.rs | 5 + crates/model/src/workbook.rs | 4 + crates/xlsx/src/lib.rs | 1 + crates/xlsx/src/passthrough.rs | 898 +++++++++++++++++++++ crates/xlsx/src/read.rs | 6 +- crates/xlsx/src/sheet_read.rs | 1 - crates/xlsx/src/sheet_write.rs | 15 +- crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/passthrough_tests.rs | 430 ++++++++++ crates/xlsx/src/write.rs | 11 + crates/xlsx/src/xml.rs | 2 +- docs/file-format-parity.md | 7 +- docs/gaps.md | 17 +- 17 files changed, 1455 insertions(+), 17 deletions(-) create mode 100644 crates/model/src/preserved.rs create mode 100644 crates/xlsx/src/passthrough.rs create mode 100644 crates/xlsx/src/tests/passthrough_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..f2c255a45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 24e626828..bee10b113 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -563,6 +563,7 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result { let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone()); sh.name = name.chars().take(31).collect(); sh.tables.clear(); // table names must be unique + sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original let at = to.min(n); // The copy gets its own copies of the sheet's names, referring to the copy. let copies: Vec = cx diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index e3b8b6386..7dd43dfac 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -8,6 +8,7 @@ mod b64; pub mod cell; mod cellmap; pub mod features; +pub mod preserved; pub mod sheet; pub mod store; pub mod style; @@ -15,6 +16,7 @@ pub mod workbook; pub use cell::{Cell, CellPicture, Formula}; pub use features::*; +pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml}; pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility}; pub use store::CellStore; pub use style::{ diff --git a/crates/model/src/preserved.rs b/crates/model/src/preserved.rs new file mode 100644 index 000000000..6d0b35d5c --- /dev/null +++ b/crates/model/src/preserved.rs @@ -0,0 +1,70 @@ +//! File content GridCraft does not model, carried through unchanged so that saving a workbook +//! never silently drops it (XLSX pass-through: custom XML, external links, connections, query +//! tables, form controls, OLE objects…). +//! +//! The data is opaque to everything except the file-format crate: the model only holds it, so a +//! sheet's kept content disappears with the sheet. It is not part of the JSON format. + +use std::sync::Arc; + +/// A relationship from an owning part (the package root, the workbook or a sheet, or a kept part) +/// to a kept part or to an external target. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PreservedRel { + /// The id in the source file (`rId7`). Ids of an owner GridCraft writes are reassigned on save. + pub id: String, + /// The full relationship type URI. + pub rel_type: String, + /// The original part name of the target (no leading `/`), or the raw target of an external + /// relationship. External targets are never opened. + pub target: String, + pub external: bool, +} + +/// A kept package part. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PreservedPart { + /// Part name in the source file (no leading `/`). + pub name: String, + pub content_type: String, + pub data: Arc<[u8]>, + /// The part's own relationships, ids unchanged (the part's bytes refer to them). + pub rels: Vec, +} + +/// A raw XML element of an owning part that refers to kept parts, re-emitted on save with its +/// relationship ids rewritten. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PreservedXml { + /// Which slot of the owner it belongs in (`externalReferences`, `oleObjects`, `controls`). + pub slot: String, + /// The element, namespace declarations it needs included. + pub xml: String, +} + +/// Unmodelled content kept for one owner (the workbook, which also holds package-root content, +/// or one sheet). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Preserved { + /// Relationships of the package root (`_rels/.rels`) to kept parts (workbook owner only). + pub package_rels: Vec, + /// Relationships of the owning workbook or sheet part to kept parts. + pub rels: Vec, + /// Every kept part reachable from `package_rels` and `rels`. + pub parts: Vec, + /// Owner XML elements that refer to kept parts. + pub elements: Vec, + /// The sheet's legacy VML drawing when kept elements need its shapes (form controls, OLE + /// objects). Its own relationships point into `parts`. + pub vml: Option, +} + +impl Preserved { + pub fn is_empty(&self) -> bool { + self.package_rels.is_empty() && self.rels.is_empty() && self.parts.is_empty() && self.elements.is_empty() && self.vml.is_none() + } + /// Total bytes held. + pub fn byte_len(&self) -> usize { + self.parts.iter().chain(self.vml.iter()).fold(0usize, |n, p| n.saturating_add(p.data.len())) + } +} diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index d2d82ed94..79fd3fdf3 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -68,6 +68,10 @@ pub struct Sheet { /// Last selection and scroll position, saved with the file. pub view_active: CellRef, pub view_top_left: CellRef, + /// Unmodelled file content of this sheet kept for saving (form controls, OLE objects, query + /// tables). Goes away with the sheet; not in JSON. + #[serde(skip)] + pub preserved: Option>, } /// Default width of a column (points) and height of a row (points) for the default font. @@ -111,6 +115,7 @@ impl Sheet { print: PrintSettings::default(), view_active: CellRef::default(), view_top_left: CellRef::default(), + preserved: None, } } diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 6ccef9e9c..7e03cb31b 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -71,6 +71,9 @@ pub struct Workbook { pub custom_lists: Vec>, /// Named cell styles (Cell Styles gallery): name → style. pub cell_styles: Vec<(String, crate::style::Style)>, + /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON. + #[serde(skip)] + pub preserved: Option>, } impl Default for Workbook { @@ -94,6 +97,7 @@ impl Workbook { protected_structure: false, custom_lists: vec![], cell_styles: vec![], + preserved: None, } } pub fn sheet(&self, i: usize) -> Option<&Sheet> { diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 05d1afc77..e5d1bc42a 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -19,6 +19,7 @@ mod drawing; mod fmla; mod ods; mod package; +mod passthrough; mod pivot; mod read; mod richdata; diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs new file mode 100644 index 000000000..187009bca --- /dev/null +++ b/crates/xlsx/src/passthrough.rs @@ -0,0 +1,898 @@ +//! Pass-through of package content GridCraft does not model, so that open → edit → save keeps it. +//! +//! On read, the raw bytes of unmodelled parts are kept with their content type and their +//! relationship graph, per owner: the package root (custom document properties, ribbon +//! customisation, add-in task panes), the workbook (custom XML, links to external workbooks with +//! 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). +//! +//! 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: +//! - Anything whose relationship graph reaches a part GridCraft reads and rewrites (worksheets, +//! drawings, charts, tables, PivotTables, comments, slicers…) is not kept: it would point at +//! content that no longer exists in the same form. +//! - External relationship targets (the path of a linked workbook) are kept as text and never +//! opened, followed or refreshed. +//! - Sheet content lives on the sheet: deleting the sheet drops it, Copy Sheet doesn't copy it. +//! Cell references inside kept parts (a control's linked cell, a query table's range) are kept +//! as they were, not adjusted to inserted or deleted rows. +//! - Strict Open XML files are not passed through (their namespaces differ from what we write). +//! - Sizes are bounded: per part and per workbook. + +use std::borrow::Cow; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt::Write as _; +use std::sync::Arc; + +use gridcraft_model::{Preserved, PreservedPart, PreservedRel, PreservedXml, Sheet, Workbook}; +use quick_xml::events::attributes::Attribute; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::name::QName; +use quick_xml::{Reader, Writer}; + +use crate::package::{self, normalize}; +use crate::read::Ctx; +use crate::write::{NS_REL, Out, Rels, XML_DECL}; +use crate::xml::{self, esc_attr}; + +/// Most bytes kept for one workbook. +pub const MAX_KEPT_TOTAL: usize = 256 * 1024 * 1024; +/// Largest single kept part. +pub const MAX_KEPT_PART: u64 = 64 * 1024 * 1024; +/// Most parts kept for one workbook. +const MAX_KEPT_PARTS: usize = 4096; +/// Most relationships read from one rels part. +const MAX_RELS: usize = 10_000; +/// Largest relationships, content-types or VML part read here. +const MAX_META: u64 = 16 * 1024 * 1024; +/// Longest chain of relationships followed from an owner. +const MAX_CHAIN: usize = 16; +/// Largest kept XML fragment of a sheet. +const MAX_FRAGMENT: usize = 4 * 1024 * 1024; +/// Deepest element nesting accepted in a kept fragment. +const MAX_FRAGMENT_DEPTH: usize = 256; + +const NS_REL_STRICT: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships"; +const CT_VML: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing"; + +/// Relationship kinds (last segment of the type) kept from the package root. +const PACKAGE_ROOTS: &[&str] = &["custom-properties", "extensibility", "webextensiontaskpanes"]; +/// Relationship kinds kept from the workbook without a workbook element. +const WORKBOOK_ROOTS: &[&str] = &["customXml", "connections"]; +/// Relationship kinds kept from a sheet without a sheet element. +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"]; +/// Kept sheet elements, in schema order (all after ``, before ``). +const SHEET_SLOTS: &[&str] = &["picture", "oleObjects", "controls"]; + +/// Relationship kinds GridCraft reads and rewrites, or that depend on content it rewrites: +/// kept content whose graph reaches one of them is dropped. +const REWRITTEN: &[&str] = &[ + "officedocument", + "core-properties", + "extended-properties", + "worksheet", + "chartsheet", + "dialogsheet", + "xlmacrosheet", + "drawing", + "chart", + "chartex", + "chartstyle", + "chartcolorstyle", + "chartusershapes", + "table", + "pivottable", + "pivotcachedefinition", + "pivotcacherecords", + "comments", + "vmldrawing", + "threadedcomment", + "person", + "sharedstrings", + "styles", + "theme", + "sheetmetadata", + "calcchain", + "vbaproject", + "rdrichvalue", + "rdrichvaluestructure", + "rdrichvaluetypes", + "rdrichvaluewebimage", + "rdarray", + "rdsupportingpropertybag", + "rdsupportingpropertybagstructure", + "richvaluerel", + "slicer", + "slicercache", + "timeline", + "timelinecache", + "powerpivotdata", +]; + +/// Part-name prefixes (lower case) GridCraft's writer never produces: kept parts there keep their +/// original names. Anything else is renamed so it can't collide with written parts. +const OWN_DIRS: &[&str] = &[ + "customxml/", + "docprops/custom.xml", + "customui/", + "xl/externallinks/", + "xl/connections.xml", + "xl/querytables/", + "xl/ctrlprops/", + "xl/embeddings/", + "xl/activex/", + "xl/webextensions/", + "xl/printersettings/", +]; + +fn kind(rel_type: &str) -> &str { + rel_type.rsplit('/').next().unwrap_or("") +} + +fn is_kind(r: &PreservedRel, kinds: &[&str]) -> bool { + let k = kind(&r.rel_type); + kinds.iter().any(|x| x.eq_ignore_ascii_case(k)) +} + +fn rewritten(r: &PreservedRel) -> bool { + !r.external && REWRITTEN.contains(&kind(&r.rel_type).to_ascii_lowercase().as_str()) +} + +fn rels_part_name(part: &str) -> String { + let (dir, file) = package::split_dir(part); + if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") } +} + +/// A part's relationships with full type URIs. `Ok(vec![])` when it has none; an error when the +/// rels part is damaged or too large (its part then can't be kept faithfully). +fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result, String> { + let name = rels_part_name(part); + let bytes = match cx.pkg.read_limited(&name, MAX_META) { + Ok(Some(b)) => b, + Ok(None) => return Ok(vec![]), + Err(e) => return Err(e.to_string()), + }; + let root = xml::parse(&bytes).map_err(|e| format!("{name}: {e}"))?; + let (dir, _) = package::split_dir(part); + let mut out = vec![]; + for r in root.kids("Relationship") { + if out.len() >= MAX_RELS { + return Err(format!("{name}: too many relationships")); + } + let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue }; + let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); + out.push(PreservedRel { + id: id.to_string(), + rel_type: r.attr("Type").unwrap_or("").to_string(), + target: if external { target.to_string() } else { package::resolve(dir, target) }, + external, + }); + } + Ok(out) +} + +// ---------------------------------------------------------------- reading + +/// Collects kept content while a package is read. +pub struct KeptReader { + on: bool, + strict: bool, + defaults: HashMap, + overrides: HashMap, + budget: usize, + parts_left: usize, +} + +impl KeptReader { + pub fn new(cx: &mut Ctx<'_>) -> KeptReader { + let mut k = KeptReader { + on: true, + strict: false, + defaults: HashMap::new(), + overrides: HashMap::new(), + budget: MAX_KEPT_TOTAL, + parts_left: MAX_KEPT_PARTS, + }; + if let Ok(root) = raw_rels(cx, "") + && root.iter().any(|r| r.rel_type.starts_with("http://purl.oclc.org/")) + { + k.on = false; + k.strict = true; + } + match cx.pkg.read_limited("[Content_Types].xml", MAX_META).ok().flatten().and_then(|b| xml::parse(&b).ok()) { + Some(types) => { + for d in types.kids("Default") { + if let (Some(e), Some(t)) = (d.attr("Extension"), d.attr("ContentType")) { + k.defaults.insert(e.to_ascii_lowercase(), t.to_string()); + } + } + for o in types.kids("Override") { + if let (Some(p), Some(t)) = (o.attr("PartName"), o.attr("ContentType")) { + k.overrides.insert(normalize(p), t.to_string()); + } + } + } + None => k.on = false, + } + k + } + + fn content_type(&self, part: &str) -> Option { + let key = normalize(part); + if let Some(t) = self.overrides.get(&key) { + return Some(t.clone()); + } + let (_, file) = package::split_dir(&key); + let ext = file.rsplit_once('.').map(|(_, e)| e)?; + self.defaults.get(ext).cloned() + } + + /// The parts reachable from `root` that aren't in `seen` yet. Fails (and keeps nothing) when the + /// graph reaches rewritten content, a missing or damaged part, or a size limit. + fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet) -> Result, String> { + if root.external { + return Ok(vec![]); + } + if rewritten(root) { + return Err(format!("it refers to {}", kind(&root.rel_type))); + } + let mut parts = vec![]; + let mut bytes = 0usize; + let mut local: HashSet = HashSet::new(); + let mut queue = VecDeque::from([(root.target.clone(), 0usize)]); + while let Some((name, depth)) = queue.pop_front() { + let key = normalize(&name); + if seen.contains(&key) || !local.insert(key) { + continue; + } + if depth > MAX_CHAIN { + return Err("its relationships are nested too deeply".into()); + } + if parts.len() >= self.parts_left { + return Err("it has too many parts".into()); + } + let content_type = self.content_type(&name).ok_or_else(|| format!("{name} has no content type"))?; + let data = match cx.pkg.read_limited(&name, MAX_KEPT_PART) { + Ok(Some(d)) => d, + Ok(None) => return Err(format!("{name} is missing")), + Err(e) => return Err(e.to_string()), + }; + bytes = bytes.saturating_add(data.len()); + if bytes > self.budget { + return Err("it is too large".into()); + } + let rels = raw_rels(cx, &name)?; + for r in rels.iter().filter(|r| !r.external) { + if rewritten(r) { + return Err(format!("it refers to {}", kind(&r.rel_type))); + } + queue.push_back((r.target.clone(), depth + 1)); + } + parts.push(PreservedPart { name, content_type, data: Arc::from(data), rels }); + } + self.budget = self.budget.saturating_sub(bytes); + self.parts_left = self.parts_left.saturating_sub(parts.len()); + Ok(parts) + } + + /// Keeps a set of relationships (all or nothing) and the parts behind them. + fn keep(&mut self, cx: &mut Ctx<'_>, p: &mut Preserved, roots: &[PreservedRel], seen: &mut HashSet) -> Result<(), String> { + let mut new_parts: Vec = vec![]; + let mut scratch = seen.clone(); + for r in roots { + let parts = self.closure(cx, r, &scratch)?; + scratch.extend(parts.iter().map(|q| normalize(&q.name))); + new_parts.extend(parts); + } + *seen = scratch; + p.parts.extend(new_parts); + for r in roots { + if !p.rels.iter().any(|x| x.id == r.id) { + p.rels.push(r.clone()); + } + } + Ok(()) + } + + /// Package-root and workbook-level content. + pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El) -> Option> { + if !self.on { + if self.strict { + cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save"); + } + return None; + } + let mut p = Preserved::default(); + let mut seen = HashSet::new(); + // Package root: custom document properties, ribbon customisation, task panes. + if let Ok(root) = raw_rels(cx, "") { + for r in root.iter().filter(|r| is_kind(r, PACKAGE_ROOTS)) { + let mut q = Preserved::default(); + match self.keep(cx, &mut q, std::slice::from_ref(r), &mut seen) { + Ok(()) => { + p.parts.extend(q.parts); + p.package_rels.extend(q.rels); + } + Err(why) => cx.warn(format!("{} was dropped: {why}", kind(&r.rel_type))), + } + } + } + let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); + for r in wb_rels.iter().filter(|r| is_kind(r, WORKBOOK_ROOTS)) { + if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { + cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target)); + } + } + // Links to external workbooks: `[1]` in formulas is the position in , + // so they are kept all together or not at all. + let ids: Vec<&str> = + wb_xml.child("externalReferences").map(|e| e.kids("externalReference").filter_map(|x| x.attr("id")).collect()).unwrap_or_default(); + if !ids.is_empty() { + let roots: Option> = + ids.iter().map(|id| wb_rels.iter().find(|r| r.id == *id && is_kind(r, &["externalLink"])).cloned()).collect(); + let kept = match roots { + Some(roots) => self.keep(cx, &mut p, &roots, &mut seen), + None => Err("a link has no part".into()), + }; + match kept { + Ok(()) => { + let mut x = format!(""); + for id in &ids { + let _ = write!(x, "", esc_attr(id)); + } + x.push_str(""); + p.elements.push(PreservedXml { slot: "externalReferences".into(), xml: x }); + cx.warn("links to external workbooks were kept; they are not followed or refreshed"); + } + Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")), + } + } + (!p.is_empty()).then(|| Arc::new(p)) + } + + /// Sheet-level content of the sheet part `part`. + pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option> { + if !self.on { + return None; + } + let rels = match raw_rels(cx, part) { + Ok(r) => r, + Err(e) => { + cx.warn(format!("sheet \"{name}\": relationships could not be read ({e}); unmodelled content was dropped")); + return None; + } + }; + let mut p = Preserved::default(); + let mut seen = HashSet::new(); + for r in rels.iter().filter(|r| is_kind(r, SHEET_ROOTS)) { + if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { + cx.warn(format!("sheet \"{name}\": {} was dropped: {why}", kind(&r.rel_type))); + } + } + if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) { + self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen); + } + (!p.is_empty()).then(|| Arc::new(p)) + } + + fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet) { + let scan = match cx.pkg.read(part) { + Ok(Some(bytes)) => scan_sheet(&bytes), + _ => None, + }; + let Some(scan) = scan else { + cx.warn(format!("sheet \"{name}\": controls and embedded objects could not be read and were dropped")); + return; + }; + // Printer settings (a binary part per sheet) hang off . + if let Some(id) = scan.page_setup.as_deref() + && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["printerSettings"])) + { + match self.keep(cx, p, std::slice::from_ref(r), seen) { + Ok(()) => p.elements.push(PreservedXml { slot: "pageSetup".into(), xml: r.id.clone() }), + Err(why) => cx.warn(format!("sheet \"{name}\": printer settings were dropped: {why}")), + } + } + let mut frags = scan.fragments; + // Controls and OLE objects are drawn from shapes in the legacy VML drawing. + if frags.iter().any(|(slot, _)| slot != "picture") { + match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) { + Ok((vml, parts)) => { + p.parts.extend(parts); + p.vml = Some(vml); + } + Err(why) => { + cx.warn(format!("sheet \"{name}\": controls and embedded objects were dropped: {why}")); + frags.retain(|(slot, _)| slot == "picture"); + } + } + } + for (slot, x) in frags { + let mut ids = vec![]; + let ok = map_rel_ids(&x, &mut |id| { + ids.push(id.to_string()); + Some(id.to_string()) + }) + .is_some(); + let roots: Option> = ids.iter().map(|id| rels.iter().find(|r| r.id == *id).cloned()).collect(); + let kept = match (ok, roots) { + (true, Some(roots)) => self.keep(cx, p, &roots, seen), + (false, _) => Err("its XML is damaged".into()), + (_, None) => Err("it refers to a missing relationship".into()), + }; + match kept { + Ok(()) => p.elements.push(PreservedXml { slot, xml: x }), + Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")), + } + } + if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") { + cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving")); + } else { + p.vml = None; + } + } + + /// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its + /// shapes refer to (preview images). + fn vml( + &mut self, + cx: &mut Ctx<'_>, + rels: &[PreservedRel], + rid: Option<&str>, + seen: &mut HashSet, + ) -> Result<(PreservedPart, Vec), String> { + let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?; + let data = match cx.pkg.read_limited(&rel.target, MAX_META) { + Ok(Some(d)) => d, + Ok(None) => return Err(format!("{} is missing", rel.target)), + Err(e) => return Err(e.to_string()), + }; + let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?; + let text = strip_notes(&text); + let vrels = raw_rels(cx, &rel.target)?; + let mut q = Preserved::default(); + let roots: Vec = vrels.iter().filter(|r| !r.external).cloned().collect(); + self.keep(cx, &mut q, &roots, seen)?; + let vml = PreservedPart { name: rel.target.clone(), content_type: CT_VML.into(), data: Arc::from(text.into_bytes()), rels: vrels }; + Ok((vml, q.parts)) + } +} + +/// What a sheet part holds for pass-through. +struct SheetScan { + /// (slot, XML with the namespace declarations it needs). + fragments: Vec<(String, String)>, + legacy_drawing: Option, + /// The relationship id on `` (printer settings). + page_setup: Option, +} + +/// Finds the sheet's ``, ``, `` (also inside +/// ``) and the `` id. `None` when the XML is damaged. +fn scan_sheet(bytes: &[u8]) -> Option { + let data = xml::to_utf8(bytes); + let text = std::str::from_utf8(&data).ok()?; + let mut reader = Reader::from_str(text); + let mut decls: Vec<(String, String)> = vec![]; + let mut out = SheetScan { fragments: vec![], legacy_drawing: None, page_setup: None }; + let mut in_root = false; + loop { + let start = usize::try_from(reader.buffer_position()).ok()?; + match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) if !in_root => { + in_root = true; + for a in e.attributes().with_checks(false).flatten() { + if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") + && let (Ok(p), Ok(v)) = (std::str::from_utf8(p), std::str::from_utf8(&a.value)) + && !v.contains('"') + { + decls.push((p.to_string(), v.to_string())); + } + } + } + Event::Start(e) => { + let local = e.local_name().as_ref().to_vec(); + match local.as_slice() { + b"pageSetup" => out.page_setup = rel_id_attr(&e), + b"legacyDrawing" => out.legacy_drawing = rel_id_attr(&e), + _ => {} + } + reader.read_to_end(e.name()).ok()?; + let end = usize::try_from(reader.buffer_position()).ok()?; + let wanted = matches!(local.as_slice(), b"picture" | b"oleObjects" | b"controls" | b"AlternateContent"); + if wanted && end.saturating_sub(start) <= MAX_FRAGMENT { + let frag = text.get(start..end)?; + push_fragment(&mut out, frag, &local, &decls); + } + } + Event::Empty(e) if in_root => { + let local = e.local_name().as_ref().to_vec(); + if local == b"legacyDrawing" || local == b"pageSetup" { + let id = rel_id_attr(&e); + if local == b"pageSetup" { + out.page_setup = id; + } else { + out.legacy_drawing = id; + } + } else if local == b"picture" + && let Some(frag) = text.get(start..usize::try_from(reader.buffer_position()).ok()?) + { + push_fragment(&mut out, frag, &local, &decls); + } + } + Event::End(_) => break, + _ => {} + } + } + Some(out) +} + +/// The `r:id` of an element (an `id` attribute with a namespace prefix). +fn rel_id_attr(e: &BytesStart<'_>) -> Option { + e.attributes() + .with_checks(false) + .flatten() + .find(|a| a.key.local_name().as_ref() == b"id" && a.key.prefix().is_some()) + .and_then(|a| String::from_utf8(a.value.to_vec()).ok()) +} + +fn push_fragment(out: &mut SheetScan, frag: &str, local: &[u8], decls: &[(String, String)]) { + let frag = with_decls(frag, decls); + let slot = if local == b"AlternateContent" { + // The kept element is the one the first choice holds. + let Ok(el) = xml::parse(frag.as_bytes()) else { return }; + match el.child("Choice").and_then(|c| c.children.first()) { + Some(c) => c.name.clone(), + None => return, + } + } else { + String::from_utf8_lossy(local).into_owned() + }; + if SHEET_SLOTS.contains(&slot.as_str()) && !out.fragments.iter().any(|(s, _)| *s == slot) { + out.fragments.push((slot, frag)); + } +} + +/// Adds the root's namespace declarations a fragment doesn't declare itself. +fn with_decls(frag: &str, decls: &[(String, String)]) -> String { + let Some(name_end) = frag.char_indices().skip(1).find(|(_, c)| c.is_whitespace() || *c == '/' || *c == '>').map(|(i, _)| i) else { + return frag.to_string(); + }; + let head = frag.get(..frag.find('>').unwrap_or(frag.len())).unwrap_or(""); + let mut add = String::new(); + for (p, v) in decls { + if !head.contains(&format!("xmlns:{p}=")) { + let _ = write!(add, " xmlns:{p}=\"{v}\""); + } + } + format!("{}{add}{}", frag.get(..name_end).unwrap_or(""), frag.get(name_end..).unwrap_or("")) +} + +/// Byte ranges of the `…` (or self-closed) elements in VML text. +fn shape_spans(vml: &str) -> Vec<(usize, usize)> { + let mut spans = vec![]; + let mut from = 0; + while let Some(i) = vml.get(from..).and_then(|s| s.find("' || c == '/'); + if !is_shape { + from = after; + continue; + } + let Some(tag_end) = vml.get(i..).and_then(|s| s.find('>')).map(|j| i + j + 1) else { break }; + let end = if vml.get(..tag_end).is_some_and(|t| t.ends_with("/>")) { + tag_end + } else { + match vml.get(tag_end..).and_then(|s| s.find("")) { + Some(j) => tag_end + j + "".len(), + None => break, + } + }; + spans.push((i, end)); + from = end; + } + spans +} + +/// VML without its note shapes (`ObjectType="Note"`). +fn strip_notes(vml: &str) -> String { + let mut out = String::with_capacity(vml.len()); + let mut last = 0; + for (a, b) in shape_spans(vml) { + let shape = vml.get(a..b).unwrap_or(""); + if shape.contains("ObjectType=\"Note\"") { + out.push_str(vml.get(last..a).unwrap_or("")); + last = b; + } + } + out.push_str(vml.get(last..).unwrap_or("")); + out +} + +/// Shape numbers (`_x0000_s1025` → 1025) used in VML text. +fn shape_numbers(vml: &str) -> Vec { + vml.match_indices("_x0000_s") + .filter_map(|(i, m)| { + let digits: String = vml.get(i + m.len()..)?.chars().take_while(char::is_ascii_digit).take(18).collect(); + digits.parse().ok() + }) + .collect() +} + +/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh +/// shape block so they can't collide with the kept shapes the sheet XML refers to. +fn merge_vml(kept: &str, ours: &str) -> String { + let Some(close) = kept.rfind("") else { return ours.to_string() }; + let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1; + let mut add = String::new(); + if !kept.contains("id=\"_x0000_t202\"") + && let Some(i) = ours.find("")) + { + add.push_str(ours.get(i..i + j + "".len()).unwrap_or("")); + } + for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() { + let shape = ours.get(a..b).unwrap_or(""); + let id = block.saturating_mul(1024).saturating_add(1 + k as u64); + match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) { + (Some(i), Some(j)) => { + let _ = write!(add, "{}id=\"_x0000_s{id}\"{}", shape.get(..i).unwrap_or(""), shape.get(j + 1..).unwrap_or("")); + } + _ => add.push_str(shape), + } + } + let mut head = kept.get(..close).unwrap_or("").to_string(); + // Claim the block in the drawing's id map. + if let Some(i) = head.find(" Option) -> Option { + let mut reader = Reader::from_str(x); + let mut writer = Writer::new(Vec::with_capacity(x.len())); + let mut prefixes: HashSet> = HashSet::new(); + let mut depth = 0usize; + loop { + match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) => { + depth += 1; + if depth > MAX_FRAGMENT_DEPTH { + return None; + } + let e = map_start(&e, &mut prefixes, map)?; + writer.write_event(Event::Start(e)).ok()?; + } + Event::Empty(e) => { + let e = map_start(&e, &mut prefixes, map)?; + writer.write_event(Event::Empty(e)).ok()?; + } + Event::End(e) => { + depth = depth.saturating_sub(1); + writer.write_event(Event::End(e)).ok()?; + } + ev => writer.write_event(ev).ok()?, + } + } + String::from_utf8(writer.into_inner()).ok() +} + +fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet>, map: &mut dyn FnMut(&str) -> Option) -> Option> { + for a in e.attributes().with_checks(false) { + let a = a.ok()?; + if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") + && (a.value.as_ref() == NS_REL.as_bytes() || a.value.as_ref() == NS_REL_STRICT.as_bytes()) + { + prefixes.insert(p.to_vec()); + } + } + let name = String::from_utf8(e.name().as_ref().to_vec()).ok()?; + let mut out = BytesStart::new(name); + for a in e.attributes().with_checks(false) { + let a = a.ok()?; + let key = a.key.as_ref(); + let is_rel = !key.starts_with(b"xmlns") && a.key.prefix().is_some_and(|p| prefixes.contains(p.as_ref())); + if is_rel { + let old = std::str::from_utf8(&a.value).ok()?; + let new = map(old)?; + out.push_attribute(Attribute { key: QName(key), value: Cow::Owned(esc_attr(&new).into_bytes()) }); + } else { + out.push_attribute(Attribute { key: QName(key), value: a.value.clone() }); + } + } + Some(out) +} + +// ---------------------------------------------------------------- writing + +/// Names given to kept parts while a package is written. +#[derive(Default)] +pub struct KeptOut { + used: HashSet, + n: u32, + /// Package-root relationships (type, target, external), added when `_rels/.rels` is written. + root: Vec<(String, String, bool)>, +} + +impl KeptOut { + /// A part name for a kept part: its original name when that can't collide with anything + /// written, otherwise a fresh one in the same folder. + fn claim(&mut self, original: &str) -> String { + let lower = original.to_ascii_lowercase(); + let own = OWN_DIRS.iter().any(|d| lower.starts_with(d)); + if own && self.used.insert(lower) { + return original.to_string(); + } + let (dir, file) = package::split_dir(original); + let (stem, ext) = match file.rsplit_once('.') { + Some((s, e)) => (s, format!(".{e}")), + None => (file, String::new()), + }; + let stem = if own { format!("{stem}_") } else { "kept".to_string() }; + loop { + self.n = self.n.saturating_add(1); + let file = format!("{stem}{}{ext}", self.n); + let cand = if dir.is_empty() { file } else { format!("{dir}/{file}") }; + if self.used.insert(cand.to_ascii_lowercase()) || self.n == u32::MAX { + return cand; + } + } + } +} + +/// Original part name (normalized) → written part name. +type NameMap = HashMap; + +fn rel_target(r: &PreservedRel, names: &NameMap) -> Option { + if r.external { Some(r.target.clone()) } else { names.get(&normalize(&r.target)).map(|n| format!("/{n}")) } +} + +/// A kept part's relationships, ids unchanged, targets renamed. +fn part_rels_xml(rels: &[PreservedRel], names: &NameMap) -> String { + let mut s = format!("{XML_DECL}"); + for r in rels { + let Some(target) = rel_target(r, names) else { continue }; + let _ = write!( + s, + "", + esc_attr(&r.id), + esc_attr(&r.rel_type), + esc_attr(&target), + if r.external { " TargetMode=\"External\"" } else { "" } + ); + } + s.push_str(""); + s +} + +fn write_parts(out: &mut Out, parts: &[PreservedPart]) -> NameMap { + let mut names = NameMap::new(); + for p in parts { + names.entry(normalize(&p.name)).or_insert_with(|| out.kept.claim(&p.name)); + } + let mut written = HashSet::new(); + for p in parts { + let Some(new) = names.get(&normalize(&p.name)).cloned() else { continue }; + if !written.insert(new.clone()) { + continue; + } + out.part(&new, Some(&p.content_type), p.data.to_vec()); + if !p.rels.is_empty() { + out.part(&rels_part_name(&new), None, part_rels_xml(&p.rels, &names).into_bytes()); + } + } + names +} + +/// Adds the owner's relationships; returns old id → new id. +fn attach(rels: &mut Rels, list: &[PreservedRel], names: &NameMap) -> HashMap { + let mut ids = HashMap::new(); + for r in list { + if let Some(target) = rel_target(r, names) { + let id = rels.add_raw(&r.rel_type, &target, r.external); + ids.insert(r.id.clone(), id); + } + } + ids +} + +fn remap(x: &str, ids: &HashMap) -> Option { + map_rel_ids(x, &mut |id| ids.get(id).cloned()) +} + +/// Writes the workbook's kept parts and relationships; returns the workbook XML elements that +/// go right after `` (``). +pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> String { + let Some(p) = wb.preserved.as_deref() else { return String::new() }; + let names = write_parts(out, &p.parts); + let ids = attach(wb_rels, &p.rels, &names); + for r in &p.package_rels { + if let Some(target) = rel_target(r, &names) { + out.kept.root.push((r.rel_type.clone(), target, r.external)); + } + } + p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).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) { + root.add_raw(&ty, &target, external); + } +} + +/// A sheet's kept content being written. +pub struct SheetKept<'a> { + p: Option<&'a Preserved>, + names: NameMap, + ids: HashMap, +} + +impl<'a> SheetKept<'a> { + /// Writes the sheet's kept parts and adds their relationships to the sheet's. + pub fn write(sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> SheetKept<'a> { + let Some(p) = sheet.preserved.as_deref() else { return SheetKept { p: None, names: NameMap::new(), ids: HashMap::new() } }; + let names = write_parts(out, &p.parts); + let ids = attach(rels, &p.rels, &names); + SheetKept { p: Some(p), names, ids } + } + + fn vml(&self) -> Option<&'a PreservedPart> { + self.p?.vml.as_ref() + } + + fn write_vml_rels(&self, vml: &PreservedPart, name: &str, out: &mut Out) { + if !vml.rels.is_empty() { + out.part(&rels_part_name(name), None, part_rels_xml(&vml.rels, &self.names).into_bytes()); + } + } + + /// GridCraft's notes VML for part `name`, merged with the kept control and object shapes. + pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String { + let Some(vml) = self.vml() else { return ours }; + self.write_vml_rels(vml, name, out); + merge_vml(&String::from_utf8_lossy(&vml.data), &ours) + } + + /// The kept VML drawing of a sheet without notes; its new relationship id. + pub fn lone_vml(&self, rels: &mut Rels, out: &mut Out) -> Option { + let vml = self.vml()?; + let name = out.kept.claim(&vml.name); + out.part(&name, Some(CT_VML), vml.data.to_vec()); + self.write_vml_rels(vml, &name, out); + Some(rels.add("vmlDrawing", &format!("/{name}"))) + } + + /// The new relationship id for `` (printer settings). + pub fn page_setup_id(&self) -> Option<&str> { + let old = &self.p?.elements.iter().find(|e| e.slot == "pageSetup")?.xml; + self.ids.get(old).map(String::as_str) + } + + /// The kept sheet elements, in schema order. + pub fn elements(&self) -> String { + let Some(p) = self.p else { return String::new() }; + let mut s = String::new(); + for slot in SHEET_SLOTS { + for e in p.elements.iter().filter(|e| e.slot == *slot) { + if let Some(x) = remap(&e.xml, &self.ids) { + s.push_str(&x); + } + } + } + s + } +} diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 050a9f3c6..50b568e5d 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -98,6 +98,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { return Err(IoError::Format(format!("unexpected root element <{}> in workbook part", wb_xml.name))); } let wb_rels = cx.pkg.rels(&wb_part)?; + let mut kept = crate::passthrough::KeptReader::new(&mut cx); cx.cell_pictures = match crate::richdata::read(&mut cx, &wb_rels) { Ok(pictures) => pictures, Err(error) => { @@ -212,6 +213,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)), Err(e) => cx.warn(format!("PivotTables on sheet \"{name}\" could not be read: {e}")), } + sheet.preserved = kept.sheet(&mut cx, &rel.target, &name); sheet.visibility = match se.attr("state") { Some("hidden") => Visibility::Hidden, Some("veryHidden") => Visibility::VeryHidden, @@ -308,9 +310,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if wb_rels.iter().any(|r| r.kind == "vbaProject") { cx.warn("macros (VBA project) are not supported and were dropped"); } - if wb_rels.iter().any(|r| r.kind == "externalLink") { - cx.warn("links to external workbooks are not supported"); - } + wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml); // Calls of LAMBDA names are spelled like the names (the parser upper-cases them). wb.apply_name_call_case(); wb.styles = cx.styles; diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 065558e6b..8f51f3320 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -183,7 +183,6 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_ext(cx, e, &mut sheet), - "oleObjects" | "controls" => cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not supported")), _ => {} } } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index b1e3fca2c..cfd1b6bf4 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -414,6 +414,8 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( } s.push_str("/>"); } + // Unmodelled content kept from the source file (printer settings, controls, OLE objects…). + let kept = crate::passthrough::SheetKept::write(sheet, &mut rels, out); let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64); let _ = write!(s, "", m(0), m(1), m(2), m(3), m(4), m(5)); @@ -429,7 +431,11 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if let Some((w, h)) = p.fit_to { let _ = write!(s, " fitToWidth=\"{w}\" fitToHeight=\"{h}\""); } - let _ = write!(s, " orientation=\"{}\"/>", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" }); + let _ = write!(s, " orientation=\"{}\"", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" }); + if let Some(id) = kept.page_setup_id() { + let _ = write!(s, " r:id=\"{id}\""); + } + s.push_str("/>"); if !p.header.is_empty() || !p.footer.is_empty() { s.push_str(""); if !p.header.is_empty() { @@ -506,11 +512,16 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let n = out.comments; out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet).into_bytes()); out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing"); - out.part(&format!("xl/drawings/vmlDrawing{n}.vml"), None, vml_xml(sheet, n).into_bytes()); + let vml_name = format!("xl/drawings/vmlDrawing{n}.vml"); + let vml = kept.notes_vml(vml_xml(sheet, n), &vml_name, out); + out.part(&vml_name, None, vml.into_bytes()); rels.add("comments", &format!("../comments{n}.xml")); let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml")); let _ = write!(s, ""); + } else if let Some(rid) = kept.lone_vml(&mut rels, out) { + let _ = write!(s, ""); } + s.push_str(&kept.elements()); // Tables. if !tables.is_empty() { let mut parts = String::new(); diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 43639fee4..cd5dacac3 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -5,6 +5,7 @@ mod csv_tests; mod fixtures; mod malformed; mod ods_tests; +mod passthrough_tests; mod pivot_tests; mod richdata_tests; mod roundtrip; diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs new file mode 100644 index 000000000..10b2681d7 --- /dev/null +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -0,0 +1,430 @@ +//! Pass-through of unmodelled package content (custom XML, external links, connections, query +//! tables, printer settings, form controls, OLE objects). Packages are built in code. + +use std::collections::HashMap; +use std::io::Read; +use std::sync::Arc; + +use gridcraft_model::Sheet; + +use super::make_zip; +use crate::package::{normalize, resolve, split_dir}; +use crate::xml; +use crate::{read_xlsx, write_xlsx}; + +const NS_R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const NS_MAIN: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; +const PRINTER: &[u8] = &[0, 1, 2, 3, 250, 251, 252, 253, 9, 9, 9]; +const OLE: &[u8] = b"\xD0\xCF\x11\xE0 not really a compound file"; +const EMF: &[u8] = b"\x01\x00\x00\x00 preview"; +const CUSTOM_XML: &str = r#"42"#; +const CUSTOM_PROPS: &str = r#""#; + +fn rels(items: &[(&str, &str, &str)]) -> String { + let mut s = String::from(r#""#); + for (id, ty, target) in items { + let ty = if ty.starts_with("http") { ty.to_string() } else { format!("{NS_R}/{ty}") }; + let ext = if target.contains("://") { r#" TargetMode="External""# } else { "" }; + s.push_str(&format!(r#""#)); + } + s.push_str(""); + s +} + +const CONTENT_TYPES: &str = r#""#; + +const EXTERNAL_LINK: &str = r#"7"#; +const CONNECTIONS: &str = r#""#; +const QUERY_TABLE: &str = r#""#; +const CTRL_PROP: &str = r#""#; +const COMMENTS: &str = r#"Anote"#; +const VML: &str = r##"1, 0, 1, 0, 3, 0, 5, 0Pict4, 0, 1, 0, 6, 0, 2, 0$C$1
old note
00
"##; + +/// A workbook with every kind of kept content: package root custom properties, custom XML, an +/// external link (with formulas using it), a connection, and on sheet "Data" a query table, +/// printer settings, an OLE object (in `mc:AlternateContent`), a form control, a note and the VML +/// drawing all of them share. +fn rich_package() -> Vec { + let workbook = format!( + r#""# + ); + let sheet1 = format!( + r#"[1]Sheet1!$A$17SUM([1]Sheet1!A1:A3)*21410103050"# + ); + let sheet2 = + format!(r#"1"#); + let root_rels = rels(&[("rId1", "officeDocument", "xl/workbook.xml"), ("rId2", "custom-properties", "docProps/custom.xml")]); + let wb_rels = rels(&[ + ("rId1", "worksheet", "worksheets/sheet1.xml"), + ("rId2", "worksheet", "worksheets/sheet2.xml"), + ("rId3", "externalLink", "externalLinks/externalLink1.xml"), + ("rId4", "customXml", "../customXml/item1.xml"), + ("rId5", "connections", "connections.xml"), + ]); + let sheet1_rels = rels(&[ + ("rId1", "printerSettings", "../printerSettings/printerSettings1.bin"), + ("rId2", "vmlDrawing", "../drawings/vmlDrawing1.vml"), + ("rId3", "oleObject", "../embeddings/oleObject1.bin"), + ("rId4", "image", "../media/image1.emf"), + ("rId5", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp", "../ctrlProps/ctrlProp1.xml"), + ("rId6", "queryTable", "../queryTables/queryTable1.xml"), + ("rId7", "comments", "../comments1.xml"), + ]); + let custom_rels = rels(&[("rId1", "customXmlProps", "itemProps1.xml")]); + let link_rels = rels(&[("rId1", "externalLinkPath", "file:///C:/never/opened/Book2.xlsx")]); + let vml_rels = rels(&[("rId1", "image", "../media/image1.emf")]); + let item_props = r#""#; + make_zip(&[ + ("[Content_Types].xml", CONTENT_TYPES.as_bytes()), + ("_rels/.rels", root_rels.as_bytes()), + ("docProps/custom.xml", CUSTOM_PROPS.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()), + ("xl/worksheets/sheet1.xml", sheet1.as_bytes()), + ("xl/worksheets/sheet2.xml", sheet2.as_bytes()), + ("xl/worksheets/_rels/sheet1.xml.rels", sheet1_rels.as_bytes()), + ("xl/printerSettings/printerSettings1.bin", PRINTER), + ("xl/drawings/vmlDrawing1.vml", VML.as_bytes()), + ("xl/drawings/_rels/vmlDrawing1.vml.rels", vml_rels.as_bytes()), + ("xl/embeddings/oleObject1.bin", OLE), + ("xl/media/image1.emf", EMF), + ("xl/ctrlProps/ctrlProp1.xml", CTRL_PROP.as_bytes()), + ("xl/queryTables/queryTable1.xml", QUERY_TABLE.as_bytes()), + ("xl/comments1.xml", COMMENTS.as_bytes()), + ("customXml/item1.xml", CUSTOM_XML.as_bytes()), + ("customXml/_rels/item1.xml.rels", custom_rels.as_bytes()), + ("customXml/itemProps1.xml", item_props.as_bytes()), + ("xl/externalLinks/externalLink1.xml", EXTERNAL_LINK.as_bytes()), + ("xl/externalLinks/_rels/externalLink1.xml.rels", link_rels.as_bytes()), + ("xl/connections.xml", CONNECTIONS.as_bytes()), + ]) +} + +/// An unpacked package: parts by name, content types, resolved relationships. +struct Unpacked { + parts: HashMap>, +} + +/// (id, type, resolved target, external) +type RelList = Vec<(String, String, String, bool)>; + +impl Unpacked { + fn new(bytes: &[u8]) -> Unpacked { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut parts = HashMap::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let mut v = vec![]; + f.read_to_end(&mut v).unwrap(); + parts.insert(f.name().to_string(), v); + } + Unpacked { parts } + } + fn text(&self, name: &str) -> String { + String::from_utf8(self.parts.get(name).unwrap_or_else(|| panic!("no part {name}")).clone()).unwrap() + } + fn rels(&self, part: &str) -> RelList { + let (dir, file) = split_dir(part); + let name = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") }; + let Some(b) = self.parts.get(&name) else { return vec![] }; + let root = xml::parse(b).unwrap(); + root.kids("Relationship") + .map(|r| { + let ext = r.attr("TargetMode") == Some("External"); + let t = r.attr("Target").unwrap(); + (r.attr("Id").unwrap().into(), r.attr("Type").unwrap().into(), if ext { t.into() } else { resolve(dir, t) }, ext) + }) + .collect() + } + fn target(&self, part: &str, id: &str) -> (String, String) { + let r = self.rels(part).into_iter().find(|r| r.0 == id).unwrap_or_else(|| panic!("{part}: no relationship {id}")); + (r.1, r.2) + } + /// Every part has a content type, every internal relationship target exists, rel ids are + /// unique per part. + fn assert_valid(&self) { + let types = xml::parse(self.parts.get("[Content_Types].xml").unwrap()).unwrap(); + let defaults: Vec = types.kids("Default").map(|d| d.attr("Extension").unwrap().to_ascii_lowercase()).collect(); + let overrides: Vec = types.kids("Override").map(|d| normalize(d.attr("PartName").unwrap())).collect(); + let mut seen_override = std::collections::HashSet::new(); + for o in &overrides { + assert!(seen_override.insert(o.clone()), "duplicate override {o}"); + assert!(self.parts.keys().any(|p| normalize(p) == *o), "override for a missing part {o}"); + } + for name in self.parts.keys().filter(|n| *n != "[Content_Types].xml") { + let ext = name.rsplit('.').next().unwrap().to_ascii_lowercase(); + assert!(overrides.contains(&normalize(name)) || defaults.contains(&ext), "{name} has no content type"); + if name.ends_with(".rels") { + let source = name.replace("_rels/", "").trim_end_matches(".rels").to_string(); + let rels = self.rels(&source); + let mut ids = std::collections::HashSet::new(); + for (id, _, target, ext) in rels { + assert!(ids.insert(id.clone()), "{name}: duplicate id {id}"); + if !ext { + assert!(self.parts.keys().any(|p| normalize(p) == normalize(&target)), "{name}: {id} → missing {target}"); + } + } + } + } + } + /// The part holding exactly `data`. + fn find(&self, data: &[u8]) -> Vec<&String> { + self.parts.iter().filter(|(_, v)| v.as_slice() == data).map(|(k, _)| k).collect() + } +} + +/// The value of attribute `attr` of the first `` in `x` (`r:id` of ` String { + let i = x.find(&format!("<{tag} ")).unwrap_or_else(|| panic!("no <{tag}> in {x}")); + let head = &x[i..i + x[i..].find('>').unwrap()]; + let j = head.find(&format!(" {attr}=\"")).unwrap_or_else(|| panic!("no {attr} in {head}")) + attr.len() + 3; + head[j..j + head[j..].find('"').unwrap()].to_string() +} + +fn sheet_part(u: &Unpacked, sheet: &str) -> String { + let wb = u.text("xl/workbook.xml"); + let i = wb.find(&format!(", its path kept as an external target. + let wbx = u.text("xl/workbook.xml"); + let link_id = attr_of(&wbx, "externalReference", "r:id"); + let (ty, link) = u.target("xl/workbook.xml", &link_id); + assert!(ty.ends_with("/externalLink")); + assert_eq!(u.parts.get(&link).unwrap(), EXTERNAL_LINK.as_bytes()); + let path = u.rels(&link); + assert_eq!(path.len(), 1); + assert_eq!((path[0].0.as_str(), path[0].2.as_str(), path[0].3), ("rId1", "file:///C:/never/opened/Book2.xlsx", true)); + assert!(wbx.find("
").unwrap() < wbx.find("[1]Sheet1!$A$1"), "{sx}"); + assert!(sx.contains("SUM([1]Sheet1!A1:A3)*2"), "{sx}"); + // Printer settings (#324). + let ps = attr_of(&sx, "pageSetup", "r:id"); + let (ty, target) = u.target(&sheet, &ps); + assert!(ty.ends_with("/printerSettings")); + assert_eq!(u.parts.get(&target).unwrap(), PRINTER); + // The OLE object and its preview image, re-linked. + let ole = attr_of(&sx, "oleObject", "r:id"); + assert_eq!(u.parts.get(&u.target(&sheet, &ole).1).unwrap(), OLE); + let preview = attr_of(&sx, "objectPr", "r:id"); + assert_eq!(u.parts.get(&u.target(&sheet, &preview).1).unwrap(), EMF); + // Namespace declarations travel with the fragment. + assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:mc"), "http://schemas.openxmlformats.org/markup-compatibility/2006"); + assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:x14"), "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"); + // The form control. + let ctrl = attr_of(&sx, "control", "r:id"); + assert_eq!(u.parts.get(&u.target(&sheet, &ctrl).1).unwrap(), CTRL_PROP.as_bytes()); + // Schema order: pageSetup < legacyDrawing < oleObjects < controls. + let pos = |t: &str| sx.find(t).unwrap_or_else(|| panic!("no {t}")); + assert!(pos(" = a.parts.iter().map(|p| &*p.data).collect(); + let mut db: Vec<&[u8]> = b.parts.iter().map(|p| &*p.data).collect(); + da.sort(); + db.sort(); + assert_eq!(da, db); + assert_eq!(a.elements.len(), b.elements.len()); + assert_eq!(wb.preserved.as_deref().unwrap().parts.len(), wb2.preserved.as_deref().unwrap().parts.len()); + u.assert_valid(); +} + +#[test] +fn edited_workbooks_keep_consistent_relationships() { + let (mut wb, _) = read_xlsx(&rich_package()).unwrap(); + // Insert a sheet before, rename the sheet with controls, drop its note, edit a cell. + wb.sheets.insert(0, Arc::new(Sheet::new("New"))); + let s = wb.sheet_mut(1).unwrap(); + s.name = "Renamed".into(); + s.comments.clear(); + s.set_value(gridcraft_core::CellRef::new(5, 5), gridcraft_core::Value::Number(3.0)); + let u = Unpacked::new(&write_xlsx(&wb).unwrap()); + u.assert_valid(); + let sheet = sheet_part(&u, "Renamed"); + assert_eq!(sheet, "xl/worksheets/sheet2.xml"); + let sx = u.text(&sheet); + assert_eq!(u.parts.get(&u.target(&sheet, &attr_of(&sx, "pageSetup", "r:id")).1).unwrap(), PRINTER); + assert_eq!(u.parts.get(&u.target(&sheet, &attr_of(&sx, "control", "r:id")).1).unwrap(), CTRL_PROP.as_bytes()); + // Without notes the kept VML is written on its own, still without the old note. + let vml = u.text(&u.target(&sheet, &attr_of(&sx, "legacyDrawing", "r:id")).1); + assert!(vml.contains("_x0000_s1026") && !vml.contains("ObjectType=\"Note\"")); + assert!(!u.text("xl/worksheets/sheet1.xml").contains("pageSetup orientation=\"landscape\" r:id")); + + // Deleting the sheet drops its content; workbook-level content stays. + wb.sheets.remove(1); + wb.names.clear(); + let u = Unpacked::new(&write_xlsx(&wb).unwrap()); + u.assert_valid(); + for d in [PRINTER, OLE, EMF, QUERY_TABLE.as_bytes(), CTRL_PROP.as_bytes()] { + assert!(u.find(d).is_empty()); + } + for d in [CUSTOM_XML.as_bytes(), EXTERNAL_LINK.as_bytes(), CONNECTIONS.as_bytes(), CUSTOM_PROPS.as_bytes()] { + assert_eq!(u.find(d).len(), 1); + } +} + +/// #324: an openpyxl-style workbook whose sheet carries printer settings. +#[test] +fn printer_settings_and_page_setup_id_survive_save() { + let ct = r#""#; + let bin: Vec = (0..=255u8).cycle().take(1024).collect(); + let pkg = super::minimal( + r#"42.1"#, + &[("xl/worksheets/_rels/sheet1.xml.rels", &rels(&[("rIdPs1", "printerSettings", "../printerSettings/printerSettings1.bin")]))], + "", + "", + ); + // Swap in content types that know `.bin`, and add the binary part. + let mut u = Unpacked::new(&pkg); + u.parts.insert("[Content_Types].xml".into(), ct.as_bytes().to_vec()); + u.parts.insert("xl/printerSettings/printerSettings1.bin".into(), bin.clone()); + let list: Vec<(&str, &[u8])> = u.parts.iter().map(|(k, v)| (k.as_str(), v.as_slice())).collect(); + let (wb, rep) = read_xlsx(&make_zip(&list)).unwrap(); + assert!(rep.warnings.iter().all(|w| !w.contains("printer")), "{:?}", rep.warnings); + let out = Unpacked::new(&write_xlsx(&wb).unwrap()); + out.assert_valid(); + let sx = out.text("xl/worksheets/sheet1.xml"); + assert!(sx.contains("orientation=\"landscape\"")); + let (ty, target) = out.target("xl/worksheets/sheet1.xml", &attr_of(&sx, "pageSetup", "r:id")); + assert!(ty.ends_with("/printerSettings")); + assert_eq!(target, "xl/printerSettings/printerSettings1.bin"); + assert_eq!(out.parts.get(&target).unwrap(), &bin); +} + +#[test] +fn damaged_kept_content_is_dropped_without_failing_the_read() { + let ct = CONTENT_TYPES.replace("", r#""#); + let workbook = format!( + r#""# + ); + let sheet = format!( + r#""# + ); + let wb_rels = rels(&[ + ("rId1", "worksheet", "worksheets/sheet1.xml"), + ("rId3", "externalLink", "externalLinks/externalLink1.xml"), + // A missing part. + ("rId4", "customXml", "../customXml/missing.xml"), + // A cycle: item1 → itemProps1 → item1. + ("rId5", "customXml", "../customXml/item1.xml"), + // Kept content that points into a part GridCraft rewrites. + ("rId6", "customXml", "../customXml/item2.xml"), + // Damaged rels behind a kept part. + ("rId7", "connections", "connections.xml"), + ]); + let cyclic = rels(&[("rId1", "customXmlProps", "itemProps1.xml")]); + let back = rels(&[("rId1", "customXml", "item1.xml")]); + let into_sheet = rels(&[("rId1", "worksheet", "../xl/worksheets/sheet1.xml")]); + let sheet_rels = rels(&[("rId1", "printerSettings", "../printerSettings/missing.bin")]); + let bytes = make_zip(&[ + ("[Content_Types].xml", ct.as_bytes()), + ("_rels/.rels", super::ROOT_RELS.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()), + ("xl/worksheets/sheet1.xml", sheet.as_bytes()), + ("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels.as_bytes()), + ("xl/externalLinks/externalLink1.xml", EXTERNAL_LINK.as_bytes()), + ("customXml/item1.xml", CUSTOM_XML.as_bytes()), + ("customXml/_rels/item1.xml.rels", cyclic.as_bytes()), + ("customXml/itemProps1.xml", b"

"), + ("customXml/_rels/itemProps1.xml.rels", back.as_bytes()), + ("customXml/item2.xml", b""), + ("customXml/_rels/item2.xml.rels", into_sheet.as_bytes()), + ("xl/connections.xml", CONNECTIONS.as_bytes()), + ("xl/_rels/connections.xml.rels", b" = p.parts.iter().map(|q| q.name.as_str()).collect(); + names.sort(); + assert_eq!(names, ["customXml/item1.xml", "customXml/itemProps1.xml"]); + assert!(p.elements.is_empty()); + assert!(wb.sheet(0).unwrap().preserved.is_none()); + let u = Unpacked::new(&write_xlsx(&wb).unwrap()); + u.assert_valid(); + assert!(!u.text("xl/workbook.xml").contains("externalReferences")); +} + +#[test] +fn oversized_kept_parts_are_dropped() { + let big = vec![0u8; crate::passthrough::MAX_KEPT_PART as usize + 1]; + let ct = CONTENT_TYPES; + let wb_rels = rels(&[("rId1", "worksheet", "worksheets/sheet1.xml"), ("rId2", "customXml", "../customXml/item1.xml")]); + let workbook = format!( + r#""# + ); + let sheet = format!(r#""#); + let bytes = make_zip(&[ + ("[Content_Types].xml", ct.as_bytes()), + ("_rels/.rels", super::ROOT_RELS.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()), + ("xl/worksheets/sheet1.xml", sheet.as_bytes()), + ("customXml/item1.xml", &big), + ]); + drop(big); + let (wb, rep) = read_xlsx(&bytes).unwrap(); + assert!(wb.preserved.is_none()); + assert!(rep.warnings.iter().any(|w| w.contains("customXml") && w.contains("dropped")), "{:?}", rep.warnings); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 8af3df1e8..96a803a39 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -49,6 +49,12 @@ impl Rels { self.items.push((id.clone(), ty.to_string(), target.to_string(), false)); id } + /// Adds a relationship with its complete type URI, internal or external (kept content). + pub fn add_raw(&mut self, ty: &str, target: &str, external: bool) -> String { + let id = format!("rId{}", self.items.len() + 1); + self.items.push((id.clone(), crate::xml::esc_attr(ty), target.to_string(), external)); + id + } pub fn is_empty(&self) -> bool { self.items.is_empty() } @@ -106,6 +112,8 @@ pub struct Out { pub dynamic_arrays: bool, pub needs_calc: bool, pub cell_pictures: crate::richdata::PicturesOut, + /// Kept (pass-through) parts written so far. + pub kept: crate::passthrough::KeptOut, } impl Out { @@ -158,6 +166,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { dynamic_arrays: false, needs_calc: false, cell_pictures: Default::default(), + kept: Default::default(), }; out.default_type("rels", "application/vnd.openxmlformats-package.relationships+xml"); out.default_type("xml", "application/xml"); @@ -247,6 +256,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { w.push_str(""); } let _ = write!(w, "{sheets_xml}"); + w.push_str(&crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out)); if !names.is_empty() { let _ = write!(w, "{names}"); } @@ -329,6 +339,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { root.add("officeDocument", "xl/workbook.xml"); root.add("core-properties", "docProps/core.xml"); root.add("extended-properties", "docProps/app.xml"); + crate::passthrough::write_package_rels(&mut root, &mut out); out.part("_rels/.rels", None, root.xml().into_bytes()); // Content types first, then the parts in creation order. diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index efa5cdcb1..3a4760e27 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -82,7 +82,7 @@ pub fn rich_text(e: &El) -> String { } /// Converts input bytes to UTF-8 XML text (handles BOMs and UTF-16). -fn to_utf8(xml: &[u8]) -> std::borrow::Cow<'_, [u8]> { +pub(crate) fn to_utf8(xml: &[u8]) -> std::borrow::Cow<'_, [u8]> { if let Some(rest) = xml.strip_prefix(&[0xEF, 0xBB, 0xBF]) { return std::borrow::Cow::Borrowed(rest); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index eeb2dd9b4..8959cc023 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; every format Excel reads or writes, against the code on main) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (XLSX pass-through of unmodelled parts, #330) · **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** @@ -15,7 +15,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | 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. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures | | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, **without macros** | VBA project dropped with a warning; saving as `.xlsm` loses the macros (#6) | — | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the workbook content type, not the template one; "new from template" behaviour missing | — | @@ -62,7 +62,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Work | Hours | |---|---| | Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | -| Preserve unmodelled XLSX parts and extensions on round trip (pass-through with relationship fix-up) | 10–15 | +| Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | | Legacy `.xls` (BIFF8) import: cells, formulas, styles, names; then write | 20–30 | | Encrypted workbooks: open with password prompt, save with password (#11) | 8–12 | @@ -74,5 +74,6 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls, OLE objects, sheet backgrounds kept on save (#330, #324) | | 2026-10-10 | minor | Encrypted and `.xls` files are now recognised and refused with a message (#17) | | 2026-10-10 | major | Created: every Excel format with our read/write support; XLSB and ODS values-only imports recorded; `.xls`, encryption and large files flagged as beta gates | diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8d8..de3faba9d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (§3 narrowed by XLSX pass-through, #330) · **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 @@ -33,12 +33,14 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Impact:** finance and data users routinely have big workbooks. - **Estimate:** 25–40 h. [target-app-parity.md](target-app-parity.md#performance), [hardware-parity.md](hardware-parity.md) -### 3. Unmodelled XLSX content is dropped on save (FF) -- **Missing:** pass-through of parts and extensions we don't model (form controls, slicers, - timelines, external links, OLE objects, custom XML, query tables, data model, chart details). -- **Evidence:** reader warns and skips (`crates/xlsx/src/read.rs`); external links "not supported". -- **Impact:** open-edit-save silently simplifies colleagues' workbooks. -- **Estimate:** 10–15 h. [file-format-parity.md](file-format-parity.md) +### 3. Some unmodelled XLSX content is dropped on save (FF) +- **Missing:** slicers and their caches, timelines, the data model, query tables attached to + tables, unmodelled `extLst` entries, chart details. Pass-through (#330) keeps custom XML, + external links, connections, sheet query tables, printer settings, form and ActiveX controls, + OLE objects and sheet backgrounds; never checked in Excel (repair prompts unknown). +- **Evidence:** `crates/xlsx/src/passthrough.rs` (what is kept, and why the rest isn't). +- **Impact:** open-edit-save still simplifies workbooks with slicers, timelines or Power Pivot. +- **Estimate:** 6–10 h. [file-format-parity.md](file-format-parity.md) ### 4. Charts lose type and options on round trip; shallow chart model (F, FF) - **Missing:** kinds that survive XLSX (stock, combo, histogram, box, waterfall, funnel, treemap, @@ -149,6 +151,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §3: XLSX pass-through keeps custom XML, external links, connections, query tables, printer settings, controls and OLE objects (#330, #324) | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | | 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues | From 001f02511964ff0ddec099e114c0a7e065c2c3a9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:24:06 +0200 Subject: [PATCH 074/102] View: Page Layout view with rulers and in-place headers and footers (#329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View › Page Layout (and the status-bar button) now shows the sheet as printed pages on a desk instead of overlaying page outlines on the grid. - Model/XLSX: per-sheet view mode (normal / pageLayout / pageBreakPreview), saved as and read back. view.* commands set it per sheet ({sheet?}) without an undo step or dirtying a clean workbook. - Engine: paper::PageGrid paginates the sheet from A1 with the print pagination (print::split_bands, shared with PDF export and printing) and maps cells to pages and back; header/footer section helpers (split/join, &[Page] field names) shared with the PDF header code. pageLayout.headerFooter takes {part, section, text} for one section and a sheet; header/footer strings are capped at 255 characters. view.ruler toggles the rulers. - UI: Geo carries the page grid, so painting, hit-testing, selection, editing, objects and scrolling work through the page transform; frozen panes are ignored in this view. Pages with margins and gaps, "Click to add data" pages, rulers in inches (English) or centimetres, headers/footers drawn on printed pages, click a margin section to edit it in place; a click elsewhere commits (one undo step), Escape cancels. Header & Footer contextual tab: presets, Page Number, Number of Pages, Current Date/Time, File Path, File Name, Sheet Name, Go to Header/Footer. View › Ruler. - New strings in every catalog; new code-drawn icons; desk colour token. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/extra.rs | 51 +- crates/engine/src/cmd/print.rs | 249 +++++++++- crates/engine/src/cmd/view.rs | 41 +- crates/engine/src/lib.rs | 5 +- crates/engine/src/paper.rs | 322 ++++++++++++ crates/model/src/lib.rs | 2 +- crates/model/src/sheet.rs | 34 ++ crates/ui-egui/examples/snapshot.rs | 13 +- crates/ui-egui/src/grid.rs | 220 +++++++-- crates/ui-egui/src/i18n.rs | 26 + crates/ui-egui/src/i18n/ko.rs | 26 + crates/ui-egui/src/i18n/pt.rs | 26 + crates/ui-egui/src/i18n/ru.rs | 26 + crates/ui-egui/src/i18n/zh.rs | 26 + crates/ui-egui/src/icons.rs | 46 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/paper_view.rs | 605 +++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 11 + crates/ui-egui/src/tabs.rs | 2 +- crates/ui-egui/src/theme.rs | 4 + crates/ui-egui/tests/page_layout_view.rs | 126 +++++ crates/xlsx/src/sheet_read.rs | 1 + crates/xlsx/src/sheet_write.rs | 3 + crates/xlsx/src/tests/roundtrip.rs | 2 + docs/gaps.md | 7 +- docs/target-app-parity.md | 7 +- docs/ui-parity.md | 9 +- 28 files changed, 1789 insertions(+), 103 deletions(-) create mode 100644 crates/engine/src/paper.rs create mode 100644 crates/ui-egui/src/paper_view.rs create mode 100644 crates/ui-egui/tests/page_layout_view.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..2d7661a7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Page Layout view: pages with margins and rulers, headers and footers edited in place, Header & Footer tab, view saved per sheet in XLSX (#329) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index b6a362ba7..ef25d6500 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -116,9 +116,29 @@ pub fn specs() -> Vec { rotate ), // Views - cmd!(noundo "view.normal", "Normal", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "normal")), - cmd!(noundo "view.pageLayout", "Page Layout", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "pageLayout")), - cmd!(noundo "view.pageBreakPreview", "Page Break Preview", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "pageBreakPreview")), + cmd!(noundo "view.normal", "Normal", ["View", "Workbook Views"], None, "{sheet?} → {view} (per sheet, saved with the file)", has_doc, |s, p| set_view( + s, + p, + SheetViewMode::Normal + )), + cmd!( + noundo "view.pageLayout", + "Page Layout", + ["View", "Workbook Views"], + None, + "{sheet?} → {view}: pages on paper with rulers; headers and footers are edited in place (per sheet, saved with the file)", + has_doc, + |s, p| set_view(s, p, SheetViewMode::PageLayout) + ), + cmd!( + noundo "view.pageBreakPreview", + "Page Break Preview", + ["View", "Workbook Views"], + None, + "{sheet?} → {view} (per sheet, saved with the file)", + has_doc, + |s, p| set_view(s, p, SheetViewMode::PageBreakPreview) + ), cmd!(noundo "view.customViews", "Custom Views…", ["View", "Workbook Views"], None, "{save?: name, show?: name, delete?: name} → list", has_doc, custom_views), cmd!(noundo "view.split", "Split", ["View", "Window"], None, "{} (toggles a split at the active cell; shown as frozen panes in this version)", has_doc, split), cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} opens a second window on the same workbook (a linked copy)", has_doc, new_window), @@ -126,7 +146,7 @@ pub fn specs() -> Vec { cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{}", has_doc, hide_window), cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{index?}", has_doc, unhide_window), cmd!(query "view.switchWindows", "Switch Windows", ["View", "Window"], None, "{} → open windows", always, switch_windows), - cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?}", has_doc, |_, _| ok()), + cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?} → {on}: rulers in Page Layout view (an application option)", has_doc, ruler), cmd!(query "view.macros", "Macros", ["View", "Macros"], None, "{} → scripts", has_doc, all_scripts), // Table tools cmd!("table.resize", "Resize Table", ["Table Design", "Properties"], None, "{table?, range: \"A1:F20\"}", has_doc, table_resize), @@ -540,9 +560,26 @@ fn rotate(s: &mut Session, p: &Json) -> Result { with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height)) } -fn set_view(s: &mut Session, mode: &str) -> Result { - s.view_mode = mode.to_string(); - Ok(json!({"view": mode})) +/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it +/// neither records an undo step nor marks a clean workbook as changed. +fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result { + let si = target_sheet(s, p)?; + let d = s.doc_mut()?; + let dirty = d.is_dirty(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + if let Some(sh) = wb.sheet_mut(si) { + sh.view = mode; + } + if !dirty { + d.saved = d.wb.clone(); + } + Ok(json!({"view": mode.as_str()})) +} + +fn ruler(s: &mut Session, p: &Json) -> Result { + let on = bool_param(p, "on").unwrap_or(s.hide_ruler); + s.hide_ruler = !on; + Ok(json!({"on": on})) } const VIEWS_NAME: &str = "_sc_custom_views"; diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 033132124..aead37908 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -58,7 +58,7 @@ pub fn specs() -> Vec { } /// Sheet geometry is in pixels at 96 dpi; PDF units are points. -const PX_TO_PT: f32 = 0.75; +pub(crate) const PX_TO_PT: f32 = 0.75; /// Most pages one export produces. pub const MAX_PAGES: usize = 2000; /// Cells in a page block above which only stored cells are visited (not every grid position). @@ -110,13 +110,15 @@ pub struct SheetLayout { /// Heading sizes in sheet pixels (0 when headings are off). pub heading_w: f32, pub heading_h: f32, + /// Manual page breaks apply (they don't when the sheet is fitted to pages). + pub manual_breaks: bool, } impl SheetLayout { fn k(&self) -> f32 { PX_TO_PT * self.scale } - fn avail(&self) -> (f32, f32) { + pub(crate) fn avail(&self) -> (f32, f32) { let [l, r, t, b, ..] = self.margins; ((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0)) } @@ -271,30 +273,11 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid title_cols, heading_w, heading_h, + manual_breaks: fit.is_none(), }; let paginate = |lay: &mut SheetLayout, manual: bool| { - let k = lay.k(); - let (aw, ah) = lay.avail(); - let (aw, ah) = (aw / k, ah / k); - let rb: Vec = if manual { st.row_breaks.clone() } else { vec![] }; - let cb: Vec = if manual { st.col_breaks.clone() } else { vec![] }; - let (mut rb, mut cb) = (rb, cb); - rb.sort_unstable(); - cb.sort_unstable(); - let title_h = |start: u32| -> f32 { - match title_rows { - Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(), - _ => 0.0, - } - }; - let title_w = |start: u32| -> f32 { - match title_cols { - Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(), - _ => 0.0, - } - }; - lay.row_bands = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - heading_h - title_h(start)).max(1.0), &rb, MAX_PAGES); - lay.col_bands = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - heading_w - title_w(start)).max(1.0), &cb, MAX_PAGES); + lay.manual_breaks = manual; + (lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES); lay.pages.clear(); 'outer: for ci in 0..lay.col_bands.len() { for ri in 0..lay.row_bands.len() { @@ -332,6 +315,37 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid Some(lay) } +/// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each), +/// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The +/// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view. +pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) { + let k = lay.k(); + let (aw, ah) = lay.avail(); + let (aw, ah) = (aw / k, ah / k); + let st = &lay.settings; + let mut rb: Vec = if lay.manual_breaks { st.row_breaks.clone() } else { vec![] }; + let mut cb: Vec = if lay.manual_breaks { st.col_breaks.clone() } else { vec![] }; + rb.sort_unstable(); + cb.sort_unstable(); + let (title_rows, title_cols) = (lay.title_rows, lay.title_cols); + let title_h = |start: u32| -> f32 { + match title_rows { + Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(), + _ => 0.0, + } + }; + let title_w = |start: u32| -> f32 { + match title_cols { + Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(), + _ => 0.0, + } + }; + let area = lay.area; + let rows = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - lay.heading_h - title_h(start)).max(1.0), &rb, cap); + let cols = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - lay.heading_w - title_w(start)).max(1.0), &cb, cap); + (rows, cols) +} + /// Lays out every sheet a print command covers. pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec { let mut out = Vec::new(); @@ -375,6 +389,132 @@ pub struct HfContext { pub time: String, } +/// Today's date and the current time as headers and footers show them (`&D`, `&T`). +pub fn hf_date_time(wb: &Workbook) -> (String, String) { + let now = gridcraft_calc::now_serial(); + let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text; + let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text; + (date, time) +} + +/// Header/footer codes and the field names the Page Layout view shows for them while editing. +pub const HF_FIELDS: [(char, &str); 8] = [ + ('P', "&[Page]"), + ('N', "&[Pages]"), + ('D', "&[Date]"), + ('T', "&[Time]"), + ('Z', "&[Path]"), + ('F', "&[File]"), + ('A', "&[Tab]"), + ('G', "&[Picture]"), +]; + +/// Splits a header/footer string into its raw left, centre and right sections (codes kept). +/// Text before the first `&L`/`&C`/`&R` belongs to the centre, as when printing. +pub fn split_header_footer(spec: &str) -> [String; 3] { + let mut out: [String; 3] = Default::default(); + let mut cur = 1usize; + let chars: Vec = spec.chars().take(10_000).collect(); + let mut i = 0; + while let Some(&c) = chars.get(i) { + let next = chars.get(i + 1).copied(); + let sec = cur.min(2); + match (c, next) { + ('&', Some('L' | 'l')) => cur = 0, + ('&', Some('C' | 'c')) => cur = 1, + ('&', Some('R' | 'r')) => cur = 2, + ('&', Some('"')) => { + // &"Font,Style" is kept whole. + let end = chars[i + 2..].iter().position(|ch| *ch == '"').map_or(chars.len(), |p| i + 3 + p); + out[sec].extend(&chars[i..end.min(chars.len())]); + i = end; + continue; + } + ('&', Some(n)) => { + out[sec].push('&'); + out[sec].push(n); + } + ('&', None) => {} + (ch, _) => { + out[sec].push(ch); + i += 1; + continue; + } + } + i += 2; + } + out +} + +/// Joins raw left, centre and right sections into one header/footer string. +pub fn join_header_footer(sections: &[String; 3]) -> String { + let mut s = String::new(); + for (code, text) in ["&L", "&C", "&R"].iter().zip(sections) { + if !text.is_empty() { + s.push_str(code); + s.push_str(text); + } + } + s +} + +/// A raw section as the user edits it: `&P` shows as `&[Page]`, `&N` as `&[Pages]`, and so on. +pub fn codes_to_fields(raw: &str) -> String { + let mut out = String::new(); + let mut it = raw.chars().peekable(); + while let Some(c) = it.next() { + if c != '&' { + out.push(c); + continue; + } + // A dangling `&` at the end is dropped (printing ignores it too). + if let Some(n) = it.next() { + match HF_FIELDS.iter().find(|(code, _)| *code == n.to_ascii_uppercase()) { + Some((_, field)) => out.push_str(field), + None => { + out.push('&'); + out.push(n); + } + } + } + } + out +} + +/// The inverse of [`codes_to_fields`]: field names become codes again; a dangling `&` at the end +/// becomes a literal ampersand so it can't swallow the next section's code. +pub fn fields_to_codes(text: &str) -> String { + let chars: Vec = text.chars().take(10_000).collect(); + let mut out = String::new(); + let mut i = 0; + while let Some(&c) = chars.get(i) { + if c != '&' { + out.push(c); + i += 1; + continue; + } + if chars.get(i + 1) == Some(&'&') { + out.push_str("&&"); + i += 2; + continue; + } + let rest: String = chars[i..].iter().take(12).collect(); + if let Some((code, field)) = HF_FIELDS.iter().find(|(_, f)| rest.starts_with(f)) { + out.push('&'); + out.push(*code); + i += field.chars().count(); + continue; + } + if i + 1 == chars.len() { + out.push_str("&&"); + } else { + out.push('&'); + } + i += 1; + } + out +} + /// One header/footer section: its text and whether `&B` made it bold. #[derive(Clone, Debug, Default, PartialEq)] pub struct HfSection { @@ -1286,9 +1426,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str doc.info.title = if wb.props.title.is_empty() { file.to_string() } else { wb.props.title.clone() }; doc.info.author = wb.props.author.clone(); let total: usize = layouts.iter().map(|l| l.pages.len()).sum(); - let now = gridcraft_calc::now_serial(); - let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text; - let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text; + let (date, time) = hf_date_time(wb); let mut page_no = 0usize; for lay in layouts { let Some(sh) = wb.sheet(lay.sheet) else { continue }; @@ -1587,6 +1725,63 @@ mod tests { hay.windows(needle.len()).any(|w| w == needle.as_bytes()) } + #[test] + fn header_sections_split_join_and_field_names() { + let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D"; + let parts = split_header_footer(spec); + assert_eq!(parts, ["&\"Arial,Bold\"Sales &&Co".to_string(), "Page &P of &N".into(), "&D".into()]); + assert_eq!(join_header_footer(&parts), spec); + // Text before any section code is the centre section, as when printing. + assert_eq!(split_header_footer("Report"), [String::new(), "Report".into(), String::new()]); + assert_eq!(codes_to_fields("Page &P of &N, &A (&F) &&"), "Page &[Page] of &[Pages], &[Tab] (&[File]) &&"); + assert_eq!(fields_to_codes("Page &[Page] of &[Pages], &[Tab] (&[File]) &&"), "Page &P of &N, &A (&F) &&"); + // A dangling ampersand stays literal instead of eating the next section's code. + assert_eq!(fields_to_codes("R&"), "R&&"); + let joined = join_header_footer(&[fields_to_codes("R&"), "x".into(), String::new()]); + assert_eq!(split_header_footer(&joined)[0], "R&&"); + let cx = HfContext { page: 2, pages: 5, sheet: "Data".into(), ..Default::default() }; + let out = expand_header_footer(&join_header_footer(&[String::new(), fields_to_codes("&[Page] / &[Pages]"), fields_to_codes("&[Tab]")]), &cx); + assert_eq!((out[1].text.as_str(), out[2].text.as_str()), ("2 / 5", "Data")); + // Hostile input never panics. + for t in ["&", "&\"", "&\"unterminated", "&&&", "&[Pag", "\u{1F600}&[Page]"] { + let _ = join_header_footer(&split_header_footer(t)); + let _ = fields_to_codes(&codes_to_fields(t)); + } + } + + #[test] + fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() { + let mut s = s(); + s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&RRight"})).unwrap(); + let r = s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "center", "text": "Page &[Page] of &[Pages]"})).unwrap(); + assert_eq!(r["header"], "&LLeft&CPage &P of &N&RRight"); + s.execute("pageLayout.headerFooter", json!({"part": "footer", "section": "right", "text": "&[Date]"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.footer, "&R&D"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.header, "&LLeft&RRight"); + assert!(s.execute("pageLayout.headerFooter", json!({"part": "middle", "text": "x"})).is_err()); + assert!(s.execute("pageLayout.headerFooter", json!({"section": "top"})).is_err()); + let long = "x".repeat(1000); + s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "left", "text": long})).unwrap(); + assert!(s.doc().unwrap().wb.sheet(0).unwrap().print.header.chars().count() <= 255); + // The view is per sheet and doesn't dirty a clean workbook. + let mut fresh = Session::new(); + fresh.new_workbook(); + fresh.execute("home.insertSheet", json!({})).unwrap(); + fresh.doc_mut().unwrap().saved = fresh.doc().unwrap().wb.clone(); + fresh.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); + let wb = &fresh.doc().unwrap().wb; + assert_eq!((wb.sheet(0).unwrap().view, wb.sheet(1).unwrap().view), (SheetViewMode::PageLayout, SheetViewMode::Normal)); + assert!(!fresh.doc().unwrap().is_dirty(), "switching views is not an edit"); + s.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); + s.execute("view.normal", json!({"sheet": 0})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().view, SheetViewMode::Normal); + assert_eq!(s.execute("view.ruler", json!({"on": false})).unwrap()["on"], false); + assert!(s.hide_ruler); + assert_eq!(s.execute("view.ruler", json!({})).unwrap()["on"], true); + } + #[test] fn page_count_300_rows_letter_portrait() { let mut s = s(); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index d8450cdbf..1de5877e1 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -91,7 +91,7 @@ pub fn specs() -> Vec { "Header & Footer", ["Insert", "Text"], None, - "{header?: \"&C&P\", footer?: \"&CPage &P of &N\"}", + "{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\"} or one section: {sheet?, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer}", has_doc, header_footer ), @@ -281,17 +281,48 @@ fn breaks(s: &mut Session, p: &Json) -> Result { }) } +/// Excel keeps at most 255 characters per header or footer. +const MAX_HEADER_FOOTER: usize = 255; + fn header_footer(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "pageLayout.headerFooter"; + let si = target_sheet(s, p)?; + let cap = |t: &str| t.chars().take(MAX_HEADER_FOOTER).collect::(); + // One section edited in place (Page Layout view). + let section = match (str_param(p, "part"), str_param(p, "section")) { + (None, None) => None, + (part, sec) => { + let footer = match part.unwrap_or("header") { + "header" => false, + "footer" => true, + other => return Err(bad(CMD, format!("`part` must be header or footer, not `{other}`"))), + }; + let idx = match sec.unwrap_or("center") { + "left" => 0usize, + "center" | "centre" => 1, + "right" => 2, + other => return Err(bad(CMD, format!("`section` must be left, center or right, not `{other}`"))), + }; + Some((footer, idx, super::print::fields_to_codes(str_param(p, "text").unwrap_or("")))) + } + }; edit(s, |cx| { - let si = cx.sheet_index(); let pr = &mut cx.sheet_mut(si)?.print; if let Some(h) = str_param(p, "header") { - pr.header = h.to_string(); + pr.header = cap(h); } if let Some(f) = str_param(p, "footer") { - pr.footer = f.to_string(); + pr.footer = cap(f); } - Ok(Json::Null) + if let Some((footer, idx, text)) = §ion { + let spec = if *footer { &mut pr.footer } else { &mut pr.header }; + let mut parts = super::print::split_header_footer(spec); + if let Some(slot) = parts.get_mut(*idx) { + *slot = text.clone(); + } + *spec = cap(&super::print::join_header_footer(&parts)); + } + Ok(json!({"header": pr.header, "footer": pr.footer})) }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de786a1f4..1030709e4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod cmd; pub mod display; pub mod fill; pub mod io; +pub mod paper; pub mod pivot; pub mod sample; pub mod selection; @@ -190,8 +191,8 @@ pub struct Session { pub format_painter: Option<(Arc, usize, RangeRef, bool)>, /// Find state (last search). pub last_find: Option, - /// Workbook view: normal, pageLayout or pageBreakPreview. - pub view_mode: String, + /// View › Show › Ruler is off (rulers in Page Layout view; an application option). + pub hide_ruler: bool, /// Journal position where action recording started. pub recording: Option, /// Windows hidden with View › Hide. diff --git a/crates/engine/src/paper.rs b/crates/engine/src/paper.rs new file mode 100644 index 000000000..01065c09e --- /dev/null +++ b/crates/engine/src/paper.rs @@ -0,0 +1,322 @@ +//! Page Layout view geometry: a sheet shown as printed pages side by side. +//! +//! The view uses the print pagination ([`crate::cmd::print::split_bands`]) over the sheet from +//! A1, so its page edges are the printed ones whenever the print area starts at A1. Pages keep +//! going right and down past the data ("Click to add data" pages), as far as the view needs. +//! +//! Coordinates are *layout units*: sheet pixels (96 dpi) at the print scale, so a cell keeps its +//! sheet size and a page is `paper / scale` big. The UI multiplies by its zoom. Pages sit in a +//! grid with a gap around each: page `(row band, column band)` starts at +//! `(gap + j·(page_w + gap), gap + i·(page_h + gap))`, and its cells start at its top-left margin. + +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; +use gridcraft_model::{LineIndex, Sheet, Workbook}; + +use crate::cmd::print::{self, PX_TO_PT, PrintOptions}; + +/// Gap between pages on screen, in pixels at 100% zoom. +pub const PAGE_GAP: f32 = 14.0; + +/// One sheet paginated for the Page Layout view. +#[derive(Clone, Debug)] +pub struct PageGrid { + /// Page size in layout units. + pub page_w: f64, + pub page_h: f64, + /// Left, right, top, bottom, header and footer margins in layout units. + pub margins: [f64; 6], + /// Gap between pages in layout units. + pub gap: f64, + /// Print scale (1.0 = 100%): screen sizes of cells are `zoom × scale`. + pub scale: f32, + /// Paper size in points (after orientation), for the rulers. + pub paper_pt: (f32, f32), + pub row_bands: Vec<(u32, u32)>, + pub col_bands: Vec<(u32, u32)>, + /// The bands reach the sheet's last row / column. + pub rows_complete: bool, + pub cols_complete: bool, + /// Bands holding printed data: `[first, last]` row bands and column bands. + pub data_rows: (usize, usize), + pub data_cols: (usize, usize), + ri: LineIndex, + ci: LineIndex, +} + +/// A header or footer zone of one page. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HfZone { + pub footer: bool, + /// 0 left, 1 centre, 2 right. + pub section: usize, +} + +impl PageGrid { + /// Paginates sheet `si` far enough to cover row `need_row` and column `need_col` (and at + /// least the printed area). `None` when the sheet doesn't exist. + pub fn build(wb: &Workbook, si: usize, need_row: u32, need_col: u32) -> Option { + let sh = wb.sheet(si)?; + let printed = print::layout_sheet(wb, si, &PrintOptions::default(), None)?; + let mut lay = printed.clone(); + let end_row = need_row.max(printed.area.end.row).min(MAX_ROWS - 1); + let end_col = need_col.max(printed.area.end.col).min(MAX_COLS - 1); + lay.area = RangeRef::new(CellRef::new(0, 0), CellRef::new(end_row, end_col)); + let (row_bands, col_bands) = print::split_bands(sh, &lay, MAX_ROWS as usize); + let s = f64::from(lay.scale.clamp(0.1, 4.0)); + let to_layout = |pt: f32| f64::from(pt) / f64::from(PX_TO_PT) / s; + let margins = lay.margins.map(to_layout); + let band_range = |bands: &[(u32, u32)], lo: u32, hi: u32| { + let first = bands.iter().position(|b| b.1 >= lo).unwrap_or(0); + let last = bands.iter().rposition(|b| b.0 <= hi).unwrap_or(first).max(first); + (first, last) + }; + let area = printed.area; + let data_rows = band_range(&row_bands, area.start.row, area.end.row); + let data_cols = band_range(&col_bands, area.start.col, area.end.col); + Some(PageGrid { + page_w: to_layout(lay.page_w), + page_h: to_layout(lay.page_h), + margins, + gap: f64::from(PAGE_GAP) / s, + scale: lay.scale, + paper_pt: (lay.page_w, lay.page_h), + rows_complete: row_bands.last().is_some_and(|b| b.1 >= MAX_ROWS - 1), + cols_complete: col_bands.last().is_some_and(|b| b.1 >= MAX_COLS - 1), + row_bands, + col_bands, + data_rows, + data_cols, + ri: LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS), + ci: LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS), + }) + } + + /// The grid covers row `r` and column `c` (else it needs a rebuild with more pages). + pub fn covers(&self, r: u32, c: u32) -> bool { + (self.rows_complete || self.row_bands.last().is_some_and(|b| b.1 >= r)) + && (self.cols_complete || self.col_bands.last().is_some_and(|b| b.1 >= c)) + } + + fn band_of(bands: &[(u32, u32)], line: u32) -> usize { + bands.partition_point(|b| b.1 < line).min(bands.len().saturating_sub(1)) + } + + /// Left edge of page column `j` / top edge of page row `i`. + pub fn page_x(&self, j: usize) -> f64 { + self.gap + j as f64 * (self.page_w + self.gap) + } + pub fn page_y(&self, i: usize) -> f64 { + self.gap + i as f64 * (self.page_h + self.gap) + } + /// The page rectangle `(x, y, w, h)` of page `(i, j)`. + pub fn page_rect(&self, i: usize, j: usize) -> (f64, f64, f64, f64) { + (self.page_x(j), self.page_y(i), self.page_w, self.page_h) + } + /// Where the cells of page `(i, j)` go: inside the margins. + pub fn content_rect(&self, i: usize, j: usize) -> (f64, f64, f64, f64) { + let [l, r, t, b, ..] = self.margins; + (self.page_x(j) + l, self.page_y(i) + t, (self.page_w - l - r).max(1.0), (self.page_h - t - b).max(1.0)) + } + + /// Left edge of column `col`. + pub fn col_x(&self, col: u32) -> f64 { + let j = Self::band_of(&self.col_bands, col); + let start = self.col_bands.get(j).map_or(0, |b| b.0); + self.page_x(j) + self.margins[0] + (self.ci.start(col) - self.ci.start(start)) + } + /// Top edge of row `row`. + pub fn row_y(&self, row: u32) -> f64 { + let i = Self::band_of(&self.row_bands, row); + let start = self.row_bands.get(i).map_or(0, |b| b.0); + self.page_y(i) + self.margins[2] + (self.ri.start(row) - self.ri.start(start)) + } + + /// Page column under `x` (the nearest one in gaps). + pub fn page_col_at(&self, x: f64) -> usize { + let j = ((x - self.gap / 2.0) / (self.page_w + self.gap)).floor(); + (j.max(0.0) as usize).min(self.col_bands.len().saturating_sub(1)) + } + pub fn page_row_at(&self, y: f64) -> usize { + let i = ((y - self.gap / 2.0) / (self.page_h + self.gap)).floor(); + (i.max(0.0) as usize).min(self.row_bands.len().saturating_sub(1)) + } + + /// Column under `x`, clamped to the page's columns (margins and gaps hit the nearest one). + pub fn col_at(&self, x: f64) -> u32 { + let j = self.page_col_at(x); + let Some(&(a, b)) = self.col_bands.get(j) else { return 0 }; + let local = x - self.page_x(j) - self.margins[0]; + if local <= 0.0 { a } else { self.ci.at(self.ci.start(a) + local).clamp(a, b) } + } + pub fn row_at(&self, y: f64) -> u32 { + let i = self.page_row_at(y); + let Some(&(a, b)) = self.row_bands.get(i) else { return 0 }; + let local = y - self.page_y(i) - self.margins[2]; + if local <= 0.0 { a } else { self.ri.at(self.ri.start(a) + local).clamp(a, b) } + } + + /// The page `(i, j)` whose paper contains the point, if any. + pub fn page_at(&self, x: f64, y: f64) -> Option<(usize, usize)> { + let (i, j) = (self.page_row_at(y), self.page_col_at(x)); + let (px, py, w, h) = self.page_rect(i, j); + (x >= px && x < px + w && y >= py && y < py + h).then_some((i, j)) + } + + /// The point lies on cells (inside a page's margins, on a cell of that page). + pub fn on_cells(&self, x: f64, y: f64) -> bool { + let Some((i, j)) = self.page_at(x, y) else { return false }; + let (cx, cy, w, h) = self.content_rect(i, j); + let (Some(&(_, c1)), Some(&(_, r1))) = (self.col_bands.get(j), self.row_bands.get(i)) else { return false }; + // Cells end where the band ends (the rest of the printable area stays blank). + let right = (self.col_x(c1) + self.ci.start(c1 + 1) - self.ci.start(c1)).min(cx + w); + let bottom = (self.row_y(r1) + self.ri.start(r1 + 1) - self.ri.start(r1)).min(cy + h); + x >= cx && x < right && y >= cy && y < bottom + } + + /// The header or footer section under the point: the top or bottom margin of a page, split + /// into thirds between the side margins. + pub fn hf_zone_at(&self, x: f64, y: f64) -> Option<((usize, usize), HfZone)> { + let (i, j) = self.page_at(x, y)?; + let (px, py, _, _) = self.page_rect(i, j); + let [l, r, t, b, ..] = self.margins; + let footer = if y < py + t { + false + } else if y >= py + self.page_h - b { + true + } else { + return None; + }; + let inner = (self.page_w - l - r).max(3.0); + let section = (((x - px - l) / (inner / 3.0)).floor().max(0.0) as usize).min(2); + Some(((i, j), HfZone { footer, section })) + } + + /// Page `(i, j)` holds printed data (others are "Click to add data" pages). + pub fn is_data_page(&self, i: usize, j: usize) -> bool { + (self.data_rows.0..=self.data_rows.1).contains(&i) && (self.data_cols.0..=self.data_cols.1).contains(&j) + } + /// Printed page number of page `(i, j)` (down, then over) and the printed page count. + pub fn page_number(&self, i: usize, j: usize) -> Option<(usize, usize)> { + if !self.is_data_page(i, j) { + return None; + } + let rows = self.data_rows.1 - self.data_rows.0 + 1; + let cols = self.data_cols.1 - self.data_cols.0 + 1; + Some(((j - self.data_cols.0) * rows + (i - self.data_rows.0) + 1, rows * cols)) + } + + /// Page rows and columns overlapping the layout rectangle `[x0, x1] × [y0, y1]`. + pub fn pages_in(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> ((usize, usize), (usize, usize)) { + ((self.page_row_at(y0), self.page_row_at(y1)), (self.page_col_at(x0), self.page_col_at(x1))) + } + + /// Width / height of everything laid out so far. + pub fn extent(&self) -> (f64, f64) { + (self.page_x(self.col_bands.len()), self.page_y(self.row_bands.len())) + } + + /// Rows `(first, last)` of a page row and columns of a page column. + pub fn row_band(&self, i: usize) -> Option<(u32, u32)> { + self.row_bands.get(i).copied() + } + pub fn col_band(&self, j: usize) -> Option<(u32, u32)> { + self.col_bands.get(j).copied() + } +} + +/// Sheet-wide helper: the bottom-right cell a page grid for this sheet must reach so that the +/// layout rectangle up to `(x, y)` is covered. Layout coordinates never run ahead of sheet +/// coordinates (pages only add margins and gaps), so the sheet position is a safe bound. +pub fn need_for(sh: &Sheet, x: f64, y: f64) -> (u32, u32) { + (sh.row_at(y.max(0.0)).saturating_add(1).min(MAX_ROWS - 1), sh.col_at(x.max(0.0)).saturating_add(1).min(MAX_COLS - 1)) +} + +#[cfg(test)] +mod tests { + use gridcraft_model::{Orientation, Sheet}; + + use super::*; + + fn wb_with(rows: u32, cols: u32) -> Workbook { + let mut wb = Workbook::new(); + if let Some(sh) = wb.sheet_mut(0) { + for r in 0..rows { + for c in 0..cols { + sh.set_value(CellRef::new(r, c), gridcraft_core::Value::Number(1.0)); + } + } + } + wb + } + + #[test] + fn pages_match_print_pagination_and_cells_map_both_ways() { + let wb = wb_with(120, 14); + let g = PageGrid::build(&wb, 0, 400, 40).unwrap(); + let printed = print::layout_sheet(&wb, 0, &PrintOptions::default(), None).unwrap(); + // The printed area starts at A1, so the view's page edges are the printed ones (the + // last printed page simply ends with the data). + let starts = |b: &[(u32, u32)], n: usize| b.iter().take(n).map(|x| x.0).collect::>(); + assert!(printed.row_bands.len() > 1 && printed.col_bands.len() > 1); + assert_eq!(starts(&g.row_bands, printed.row_bands.len()), starts(&printed.row_bands, usize::MAX)); + assert_eq!(starts(&g.col_bands, printed.col_bands.len()), starts(&printed.col_bands, usize::MAX)); + assert_eq!(g.data_rows, (0, printed.row_bands.len() - 1)); + assert!(g.covers(400, 40)); + // Every cell maps to a point inside its own page's margins and back. + for (r, c) in [(0, 0), (5, 3), (60, 9), (119, 13), (300, 30)] { + let (x, y) = (g.col_x(c) + 1.0, g.row_y(r) + 1.0); + assert_eq!((g.row_at(y), g.col_at(x)), (r, c), "cell ({r},{c})"); + assert!(g.on_cells(x, y)); + let (i, j) = g.page_at(x, y).unwrap(); + let (cx, cy, _, _) = g.content_rect(i, j); + assert!(x >= cx && y >= cy); + } + // The first row of the second page starts below the first page plus a gap. + let (a, _) = g.row_bands[1]; + assert!((g.row_y(a) - (g.page_y(1) + g.margins[2])).abs() < 1e-6); + assert!(g.row_y(a) > g.page_h + g.gap); + } + + #[test] + fn margins_gaps_and_header_zones() { + let mut wb = wb_with(10, 3); + if let Some(sh) = wb.sheet_mut(0) { + sh.print.orientation = Orientation::Landscape; + } + let g = PageGrid::build(&wb, 0, 50, 20).unwrap(); + assert!(g.page_w > g.page_h, "landscape pages are wider than tall"); + // A point in the top margin hits the header, thirds pick the section. + let (px, py, w, h) = g.page_rect(0, 0); + let mid_top = (px + w / 2.0, py + g.margins[2] / 2.0); + assert_eq!(g.hf_zone_at(mid_top.0, mid_top.1), Some(((0, 0), HfZone { footer: false, section: 1 }))); + assert_eq!(g.hf_zone_at(px + g.margins[0] + 2.0, py + h - 2.0).map(|z| z.1), Some(HfZone { footer: true, section: 0 })); + assert_eq!(g.hf_zone_at(px + w - g.margins[1] - 2.0, py + 3.0).map(|z| z.1.section), Some(2)); + assert!(!g.on_cells(mid_top.0, mid_top.1), "margins aren't cells"); + // The gap between pages is no page at all. + assert_eq!(g.page_at(px + w + g.gap / 2.0, py + 10.0), None); + assert_eq!(g.hf_zone_at(px - 1.0, py + 3.0), None); + // Page numbers count printed pages only. + assert_eq!(g.page_number(0, 0), Some((1, 1))); + assert_eq!(g.page_number(0, 1), None); + assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0)); + } + + #[test] + fn print_scale_and_hostile_settings_stay_bounded() { + let mut wb = wb_with(3, 3); + if let Some(sh) = wb.sheet_mut(0) { + sh.print.scale = 50; + sh.print.margins = [f32::NAN, 1e9, -4.0, f32::INFINITY, 0.0, 0.0]; + sh.print.row_breaks = vec![0, 1, 2, u32::MAX]; + } + let g = PageGrid::build(&wb, 0, MAX_ROWS, MAX_COLS).unwrap(); + assert!(g.rows_complete && g.cols_complete); + assert!(g.page_w.is_finite() && g.page_h.is_finite() && g.page_w > 0.0); + let (ex, ey) = g.extent(); + assert!(ex.is_finite() && ey.is_finite()); + assert_eq!(g.row_at(ey * 2.0), MAX_ROWS - 1); + assert_eq!(g.col_at(-5.0), 0); + assert_eq!(need_for(&Sheet::new("S"), f64::MAX, f64::MAX), (MAX_ROWS - 1, MAX_COLS - 1)); + assert!(PageGrid::build(&wb, 7, 0, 0).is_none()); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index e3b8b6386..65f52ae2a 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -15,7 +15,7 @@ pub mod workbook; pub use cell::{Cell, CellPicture, Formula}; pub use features::*; -pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility}; +pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility}; 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 d2d82ed94..ee0f380c3 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -19,6 +19,36 @@ pub enum Visibility { VeryHidden, } +/// How a sheet is displayed (View › Workbook Views; `` in XLSX). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SheetViewMode { + #[default] + Normal, + /// Pages on paper with rulers, margins and in-place headers and footers. + PageLayout, + PageBreakPreview, +} + +impl SheetViewMode { + /// The XLSX / command spelling: `normal`, `pageLayout` or `pageBreakPreview`. + pub fn as_str(self) -> &'static str { + match self { + SheetViewMode::Normal => "normal", + SheetViewMode::PageLayout => "pageLayout", + SheetViewMode::PageBreakPreview => "pageBreakPreview", + } + } + pub fn parse(s: &str) -> Option { + match s { + "normal" => Some(SheetViewMode::Normal), + "pageLayout" => Some(SheetViewMode::PageLayout), + "pageBreakPreview" => Some(SheetViewMode::PageBreakPreview), + _ => None, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Sheet { pub name: String, @@ -68,6 +98,9 @@ pub struct Sheet { /// Last selection and scroll position, saved with the file. pub view_active: CellRef, pub view_top_left: CellRef, + /// Normal, Page Layout or Page Break Preview. + #[serde(default)] + pub view: SheetViewMode, } /// Default width of a column (points) and height of a row (points) for the default font. @@ -111,6 +144,7 @@ impl Sheet { print: PrintSettings::default(), view_active: CellRef::default(), view_top_left: CellRef::default(), + view: SheetViewMode::Normal, } } diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 93359f03a..feed390c7 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -2,7 +2,7 @@ //! //! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx] //! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt] -//! [--dialog 'name={json}'] out.png` +//! [--dialog 'name={json}'] [--hf-edit header|footer] out.png` use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; @@ -21,6 +21,7 @@ fn main() { let mut chart: Option = None; let mut dialog: Option = None; let mut language: Option = None; + let mut hf_edit: Option = None; let mut out = "snapshot.png".to_string(); let mut i = 0; while i < args.len() { @@ -66,6 +67,10 @@ fn main() { i += 1; dialog = args.get(i).cloned(); } + "--hf-edit" => { + i += 1; + hf_edit = args.get(i).cloned(); + } "--language" => { i += 1; language = args.get(i).cloned(); @@ -113,6 +118,12 @@ fn main() { d.split_once('=').map(|(a, b)| (a, serde_json::from_str(b).unwrap_or(json!({})))).unwrap_or((d.as_str(), json!({}))); app.open_dialog(name, p); } + // Page Layout view: edit the first page's centre header (or footer) section. + let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet, s))); + if let (Some(part), Some((si, sh))) = (&hf_edit, active) { + let zone = gridcraft_engine::paper::HfZone { footer: part == "footer", section: 1 }; + gridcraft_ui_egui::paper_view::begin(&mut app, si, &sh, (0, 0), zone); + } app }, ); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..0a8ef12a6 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -84,6 +84,12 @@ pub struct GridState { /// Page ranges for Page Break Preview, cached per (doc uid, revision, sheet). pub pages: Option<((u64, u64, usize), Vec<(RangeRef, u32)>)>, pub header_menu: Option<(Pos2, bool)>, + /// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns). + pub paper: Option, + /// Header or footer being edited in place (Page Layout view). + pub hf_edit: Option, + /// The grid geometry of the last frame (hit-testing for agents and tests). + pub geo: Option, } impl GridState { @@ -111,6 +117,8 @@ pub struct Geo { /// Prefix sums over custom row heights / column widths, built once per frame. ri: std::sync::Arc, ci: std::sync::Arc, + /// Page Layout view: cells sit on pages (`scroll` is then in page-layout units). + pub paper: Option>, } impl Geo { @@ -127,9 +135,42 @@ impl Geo { let frozen_w = (ci.start(fc) as f32) * z; let frozen_h = (ri.start(fr) as f32) * z; let cells = Rect::from_min_max(pos2(rect.left() + header_w, rect.top() + header_h), rect.max); - Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, ri, ci } + Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, ri, ci, paper: None } + } + /// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale + /// on pages laid out by `grid`; frozen panes don't apply (as in Excel). + pub fn paged(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc) -> Geo { + let mut g = Geo::new(sh, rect, scroll); + let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); + g.z = zoom * DISPLAY_SCALE * grid.scale.clamp(0.1, 4.0); + (g.fr, g.fc, g.frozen_w, g.frozen_h) = (0, 0, 0.0, 0.0); + g.paper = Some(grid); + g + } + /// The point is over cells (in Page Layout view: on a page, inside its margins). + pub fn in_cells(&self, p: Pos2) -> bool { + if !self.cells.contains(p) { + return false; + } + match &self.paper { + Some(pg) => pg.on_cells( + self.scroll.x as f64 + ((p.x - self.cells.left()) / self.z) as f64, + self.scroll.y as f64 + ((p.y - self.cells.top()) / self.z) as f64, + ), + None => true, + } + } + /// Scroll offset (in `scroll` units) at which `row` reaches the top of the scrolled area. + pub fn scroll_top_of(&self, sh: &Sheet, row: u32) -> f32 { + match &self.paper { + Some(pg) => pg.row_y(row) as f32, + None => (sh.row_top(row) - sh.row_top(self.fr)) as f32, + } } pub fn x(&self, _sh: &Sheet, col: u32) -> f32 { + if let Some(pg) = &self.paper { + return self.cells.left() + (pg.col_x(col) - self.scroll.x as f64) as f32 * self.z; + } if col < self.fc { self.cells.left() + self.ci.start(col) as f32 * self.z } else { @@ -137,6 +178,9 @@ impl Geo { } } pub fn y(&self, _sh: &Sheet, row: u32) -> f32 { + if let Some(pg) = &self.paper { + return self.cells.top() + (pg.row_y(row) - self.scroll.y as f64) as f32 * self.z; + } if row < self.fr { self.cells.top() + self.ri.start(row) as f32 * self.z } else { @@ -155,6 +199,9 @@ impl Geo { Rect::from_min_max(a.min, b.max) } pub fn col_at(&self, _sh: &Sheet, x: f32) -> u32 { + if let Some(pg) = &self.paper { + return pg.col_at(self.scroll.x as f64 + ((x - self.cells.left()) / self.z) as f64); + } let dx = (x - self.cells.left()) / self.z; if dx < self.frozen_w / self.z { self.ci.at(dx.max(0.0) as f64) @@ -163,6 +210,9 @@ impl Geo { } } pub fn row_at(&self, _sh: &Sheet, y: f32) -> u32 { + if let Some(pg) = &self.paper { + return pg.row_at(self.scroll.y as f64 + ((y - self.cells.top()) / self.z) as f64); + } let dy = (y - self.cells.top()) / self.z; if dy < self.frozen_h / self.z { self.ri.at(dy.max(0.0) as f64) @@ -322,6 +372,19 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let Some(sh) = wb.sheet(si) else { return }; let sel = d.selection.clone(); let mut view = app.view(); + // 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); + let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { + 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) { + return Geo::paged(sh, grid_rect, scroll, g); + } + } + Geo::new(sh, rect, scroll) + }; // Scrolling. let (scroll_delta, zoom_delta, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers)); if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { @@ -329,7 +392,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round(); let _ = app.session.run("view.zoom", json!({"percent": z})); } else if scroll_delta != egui::Vec2::ZERO { - let z = sh.zoom as f32 / 100.0 * DISPLAY_SCALE; + let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; let mut d = scroll_delta; if modifiers.shift && d.x == 0.0 { d = vec2(d.y, 0.0); @@ -338,15 +401,28 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } } view.scroll = view.scroll.max(egui::Vec2::ZERO); - let max_y = sh.row_top(MAX_ROWS - 40) as f32; - let max_x = sh.col_left(MAX_COLS - 20) as f32; + let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32); + if paper { + // Page layout units run past sheet units (margins and gaps); the end is known once the + // pages reach the last row / column. + let g = make_geo(app, view.scroll); + if let Some(pg) = &g.paper { + let (ex, ey) = pg.extent(); + max_x = if pg.cols_complete { (ex as f32 - g.cells.width() / g.z).max(0.0) } else { 1.0e9 }; + max_y = if pg.rows_complete { (ey as f32 - g.cells.height() / g.z).max(0.0) } else { 1.0e9 }; + } + } view.scroll.x = view.scroll.x.min(max_x); view.scroll.y = view.scroll.y.min(max_y); - let mut geo = Geo::new(sh, rect, view.scroll); + let mut geo = make_geo(app, view.scroll); // Keep the active cell 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); + if paper { + crate::paper_view::ensure_covers(app, &wb, si, target); + geo = make_geo(app, view.scroll); + } let cr = geo.cell_rect(sh, target); let area = Rect::from_min_max(pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h), geo.cells.max); if target.row >= geo.fr { @@ -364,16 +440,70 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } } view.scroll = view.scroll.max(egui::Vec2::ZERO); - geo = Geo::new(sh, rect, view.scroll); + geo = make_geo(app, view.scroll); } // Snap scrolling to whole rows/columns at rest like Excel? Keep smooth (better on trackpads). if let Some(v) = app.view_mut() { *v = view; } app.grid.cells_rect = Some(geo.cells); + app.grid.geo = Some(geo.clone()); let painter = ui.painter_at(rect); painter.rect_filled(rect, 0.0, t.grid_bg); let mut cf = CfCache::new(); + let quads = if geo.paper.is_some() { + crate::paper_view::paint_pages(app, &painter, &geo, &wb, si, sh, &sel, &mut cf, &t) + } else { + paint_panes(app, &painter, &geo, &wb, si, sh, &sel, &mut cf, &t) + }; + // Objects above cells. + crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); + paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel); + if sh.view == gridcraft_engine::model::SheetViewMode::PageBreakPreview { + paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh); + } + if geo.paper.is_some() { + crate::paper_view::paint_headers_footers(app, ui, &painter, &geo, &wb, sh); + } + // Headers. + if sh.show_headings { + paint_headers(&painter, &geo, sh, &sel, &t, &quads); + } + if geo.paper.is_some() { + crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t); + } + // Interaction. + let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); + crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb); + interact(app, ui, &resp, &geo, sh, &wb); + crate::text_box::show(app, ui, &geo, sh); + if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { + in_cell_editor(app, ui, &geo, sh, &wb); + } + filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu); + list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); + header_menu(app, ui, previous_popups.2 == app.grid.header_menu); + context_menu(app, ui); + vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t); + if app.session.clipboard.is_some() { + app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); + } +} + +/// Normal view: up to four panes (frozen rows/columns), each painted with its own clip. +#[allow(clippy::too_many_arguments)] +fn paint_panes( + app: &SheetApp, + painter: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + cf: &mut CfCache, + t: &Tokens, +) -> Vec<((u32, u32), (u32, u32), Rect)> { // Quadrants: (rows, cols, clip). let (sr0, sr1) = geo.scroll_rows(sh); let (sc0, sc1) = geo.scroll_cols(sh); @@ -395,8 +525,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { continue; } let p = painter.with_clip_rect(*clip); - paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t); - paint_selection(&p, &geo, sh, &sel, &t, app); + paint_quadrant(&p, geo, wb, si, sh, *rows, *cols, cf, t); + paint_selection(&p, geo, sh, sel, t, app); } // Freeze lines. if geo.fr > 0 { @@ -405,32 +535,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if geo.fc > 0 { painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150))); } - // Objects above cells. - crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); - paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel); - if app.session.view_mode == "pageBreakPreview" || app.session.view_mode == "pageLayout" { - paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh); - } - // Headers. - if sh.show_headings { - paint_headers(&painter, &geo, sh, &sel, &t, &quads); - } - // Interaction. - let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); - interact(app, ui, &resp, &geo, sh, &wb); - crate::text_box::show(app, ui, &geo, sh); - if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { - in_cell_editor(app, ui, &geo, sh, &wb); - } - filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu); - list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); - header_menu(app, ui, previous_popups.2 == app.grid.header_menu); - context_menu(app, ui); - vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t); - if app.session.clipboard.is_some() { - app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); - } + quads } /// The worksheet is painted virtually, so it needs its own scrollbar rather than a @@ -443,7 +548,7 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G let visible = track.height() / geo.z; let scroll = app.view().scroll.y; let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); - let content = (sh.row_top(last) - sh.row_top(geo.fr)) as f32; + let content = geo.scroll_top_of(sh, last); let extent = (content - visible).max(scroll).max(0.0).min(limit); let id = ui.id().with("vscroll"); let resp = ui.interact(track, id, Sense::click_and_drag()); @@ -483,7 +588,17 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G } #[allow(clippy::too_many_arguments)] -fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, rows: (u32, u32), cols: (u32, u32), cf: &mut CfCache, t: &Tokens) { +pub(crate) fn paint_quadrant( + p: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + rows: (u32, u32), + cols: (u32, u32), + cf: &mut CfCache, + t: &Tokens, +) { let z = geo.z; let (r0, r1) = rows; let (c0, c1) = cols; @@ -901,7 +1016,7 @@ fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { } } -fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) { +pub(crate) fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) { // Fill all areas except the active cell. for r in &sel.ranges { if r.is_single() && sel.ranges.len() == 1 { @@ -973,7 +1088,14 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S } } -fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, quads: &[((u32, u32), (u32, u32), Rect)]) { +pub(crate) fn paint_headers( + p: &Painter, + geo: &Geo, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + t: &Tokens, + quads: &[((u32, u32), (u32, u32), Rect)], +) { let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0)); let r = geo.rect; let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top())); @@ -1049,7 +1171,7 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { // Text selection and editing must not also move the object or edit worksheet cells. - if app.text_box_editor.is_some() { + if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { return; } let ctx = ui.ctx().clone(); @@ -1058,7 +1180,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let pointer = ctx.input(|i| i.pointer.clone()); let mods = ctx.input(|i| i.modifiers); let pos = pointer.interact_pos().or(pointer.hover_pos()); - let in_cells = pos.is_some_and(|p| geo.cells.contains(p)); + let in_cells = pos.is_some_and(|p| geo.in_cells(p)); let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.rect.top() && p.x >= geo.cells.left()); let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.rect.left() && p.y >= geo.cells.top()); let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && geo.rect.contains(p)); @@ -1098,8 +1220,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if let Some(p) = pos && resp.dragged() { - let sx = sh.col_left(geo.fc) as f32 + geo.scroll.x + (p.x - geo.cells.left() - geo.frozen_w) / geo.z; - let sy = sh.row_top(geo.fr) as f32 + geo.scroll.y + (p.y - geo.cells.top() - geo.frozen_h) / geo.z; + // Through the cell under the pointer, so frozen panes and pages map right. + let c = geo.cell_at(sh, p); + let sx = sh.col_left(c.col) as f32 + (p.x - geo.x(sh, c.col)) / geo.z; + let sy = sh.row_top(c.row) as f32 + (p.y - geo.y(sh, c.row)) / geo.z; app.grid.ink.push([sx, sy]); } if !app.grid.ink.is_empty() { @@ -1109,10 +1233,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & .ink .iter() .map(|q| { - pos2( - geo.cells.left() + geo.frozen_w + (q[0] - sh.col_left(geo.fc) as f32 - geo.scroll.x) * geo.z, - geo.cells.top() + geo.frozen_h + (q[1] - sh.row_top(geo.fr) as f32 - geo.scroll.y) * geo.z, - ) + let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64)); + pos2(geo.x(sh, col) + (q[0] - sh.col_left(col) as f32) * geo.z, geo.y(sh, row) + (q[1] - sh.row_top(row) as f32) * geo.z) }) .collect(); ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9)))); @@ -1571,7 +1693,7 @@ fn on_border(r: Rect, p: Pos2) -> bool { fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> { let c = geo.col_at(sh, x); for cc in [c, c.saturating_sub(1)] { - let edge = geo.x(sh, cc + 1); + let edge = geo.x(sh, cc) + sh.col_width(cc) * geo.z; if (x - edge).abs() <= 4.0 { return Some((cc, edge)); } @@ -1582,7 +1704,7 @@ fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> { fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> { let r = geo.row_at(sh, y); for rr in [r, r.saturating_sub(1)] { - let edge = geo.y(sh, rr + 1); + let edge = geo.y(sh, rr) + sh.row_height(rr) * geo.z; if (y - edge).abs() <= 3.0 { return Some((rr, edge)); } @@ -1963,7 +2085,7 @@ pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option { } #[allow(clippy::too_many_arguments)] -fn paint_overlays( +pub(crate) fn paint_overlays( app: &mut SheetApp, ui: &egui::Ui, p: &Painter, diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..cf5b424be 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -416,6 +416,32 @@ const JAPANESE: &[(&str, &str)] = &[ ("More\nFunctions", "その他の関数"), ("Sheet Options|View", "画面"), ("Print", "印刷"), + // Page Layout view and the Header & Footer tab. + ("Click to add data", "クリックしてデータを追加"), + ("Add header", "ヘッダーの追加"), + ("Add footer", "フッターの追加"), + ("(none)", "(なし)"), + ("Header", "ヘッダー"), + ("Footer", "フッター"), + ("Page\nNumber", "ページ\n番号"), + ("Number\nof Pages", "ページ数"), + ("Current\nDate", "現在の\n日付"), + ("Current\nTime", "現在の\n時刻"), + ("File\nPath", "ファイルの\nパス"), + ("File\nName", "ファイル名"), + ("Sheet\nName", "シート名"), + ("Go to\nHeader", "ヘッダーに\n移動"), + ("Go to\nFooter", "フッターに\n移動"), + ("Page Number", "ページ番号"), + ("Number of Pages", "ページ数"), + ("Current Date", "現在の日付"), + ("Current Time", "現在の時刻"), + ("File Path", "ファイルのパス"), + ("File Name", "ファイル名"), + ("Sheet Name", "シート名"), + ("Go to Header", "ヘッダーに移動"), + ("Go to Footer", "フッターに移動"), + ("Ruler", "ルーラー"), ]; #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3cab..e7bd44c9f 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -268,6 +268,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "어둡게", "Sheet Options|View" => "화면", "Print" => "인쇄", + // Page Layout view and the Header & Footer tab. + "Click to add data" => "클릭하여 데이터 추가", + "Add header" => "머리글 추가", + "Add footer" => "바닥글 추가", + "(none)" => "(없음)", + "Header" => "머리글", + "Footer" => "바닥글", + "Page\nNumber" => "페이지\n번호", + "Number\nof Pages" => "페이지 수", + "Current\nDate" => "현재\n날짜", + "Current\nTime" => "현재\n시간", + "File\nPath" => "파일\n경로", + "File\nName" => "파일\n이름", + "Sheet\nName" => "시트\n이름", + "Go to\nHeader" => "머리글로\n이동", + "Go to\nFooter" => "바닥글로\n이동", + "Page Number" => "페이지 번호", + "Number of Pages" => "페이지 수", + "Current Date" => "현재 날짜", + "Current Time" => "현재 시간", + "File Path" => "파일 경로", + "File Name" => "파일 이름", + "Sheet Name" => "시트 이름", + "Go to Header" => "머리글로 이동", + "Go to Footer" => "바닥글로 이동", + "Ruler" => "눈금자", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3f2..0f9e0c19f 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -269,6 +269,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Escuro", "Sheet Options|View" => "Exibir", "Print" => "Imprimir", + // Page Layout view and the Header & Footer tab. + "Click to add data" => "Clique para adicionar dados", + "Add header" => "Adicionar cabeçalho", + "Add footer" => "Adicionar rodapé", + "(none)" => "(nenhum)", + "Header" => "Cabeçalho", + "Footer" => "Rodapé", + "Page\nNumber" => "Número\nda Página", + "Number\nof Pages" => "Número\nde Páginas", + "Current\nDate" => "Data\nAtual", + "Current\nTime" => "Hora\nAtual", + "File\nPath" => "Caminho\ndo Arquivo", + "File\nName" => "Nome do\nArquivo", + "Sheet\nName" => "Nome da\nPlanilha", + "Go to\nHeader" => "Ir para o\nCabeçalho", + "Go to\nFooter" => "Ir para o\nRodapé", + "Page Number" => "Número da Página", + "Number of Pages" => "Número de Páginas", + "Current Date" => "Data Atual", + "Current Time" => "Hora Atual", + "File Path" => "Caminho do Arquivo", + "File Name" => "Nome do Arquivo", + "Sheet Name" => "Nome da Planilha", + "Go to Header" => "Ir para o Cabeçalho", + "Go to Footer" => "Ir para o Rodapé", + "Ruler" => "Régua", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d5485658..b32f5786c 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -268,6 +268,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Тёмная", "Sheet Options|View" => "На экране", "Print" => "Печать", + // Page Layout view and the Header & Footer tab. + "Click to add data" => "Щелкните, чтобы добавить данные", + "Add header" => "Добавить верхний колонтитул", + "Add footer" => "Добавить нижний колонтитул", + "(none)" => "(нет)", + "Header" => "Верхний колонтитул", + "Footer" => "Нижний колонтитул", + "Page\nNumber" => "Номер\nстраницы", + "Number\nof Pages" => "Число\nстраниц", + "Current\nDate" => "Текущая\nдата", + "Current\nTime" => "Текущее\nвремя", + "File\nPath" => "Путь\nк файлу", + "File\nName" => "Имя\nфайла", + "Sheet\nName" => "Имя\nлиста", + "Go to\nHeader" => "Перейти к верхнему\nколонтитулу", + "Go to\nFooter" => "Перейти к нижнему\nколонтитулу", + "Page Number" => "Номер страницы", + "Number of Pages" => "Число страниц", + "Current Date" => "Текущая дата", + "Current Time" => "Текущее время", + "File Path" => "Путь к файлу", + "File Name" => "Имя файла", + "Sheet Name" => "Имя листа", + "Go to Header" => "Перейти к верхнему колонтитулу", + "Go to Footer" => "Перейти к нижнему колонтитулу", + "Ruler" => "Линейка", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada73..3f98ff7f2 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -268,6 +268,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "深色", "Sheet Options|View" => "屏幕", "Print" => "打印", + // Page Layout view and the Header & Footer tab. + "Click to add data" => "单击可添加数据", + "Add header" => "添加页眉", + "Add footer" => "添加页脚", + "(none)" => "(无)", + "Header" => "页眉", + "Footer" => "页脚", + "Page\nNumber" => "页码", + "Number\nof Pages" => "页数", + "Current\nDate" => "当前\n日期", + "Current\nTime" => "当前\n时间", + "File\nPath" => "文件\n路径", + "File\nName" => "文件名", + "Sheet\nName" => "工作表名", + "Go to\nHeader" => "转至\n页眉", + "Go to\nFooter" => "转至\n页脚", + "Page Number" => "页码", + "Number of Pages" => "页数", + "Current Date" => "当前日期", + "Current Time" => "当前时间", + "File Path" => "文件路径", + "File Name" => "文件名", + "Sheet Name" => "工作表名", + "Go to Header" => "转至页眉", + "Go to Footer" => "转至页脚", + "Ruler" => "标尺", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b2079c821..eb4606fe2 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -111,6 +111,14 @@ pub enum Icon { Sheet, Book, Folder, + /// Header & Footer: a page with its top / bottom band marked. + Header, + Footer, + PageNumber, + PageCount, + Calendar, + Clock, + FileName, } struct G<'a> { @@ -605,6 +613,44 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { Icon::Folder => { g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], YELLOW); } + Icon::Header | Icon::Footer => { + g.rect(3.5, 1.5, 9.0, 13.0, None, Some(c)); + let y = if icon == Icon::Header { 2.5 } else { 11.0 }; + g.rect(4.5, y, 7.0, 2.5, Some(BLUE), None); + for yy in if icon == Icon::Header { [7.0, 9.5] } else { [4.5, 7.0] } { + g.stroke(&[(5.5, yy), (10.5, yy)], light); + } + } + Icon::PageNumber | Icon::PageCount => { + if icon == Icon::PageCount { + g.rect(5.5, 1.0, 8.5, 11.5, None, Some(light)); + } + g.rect(2.5, 3.0, 8.5, 12.0, Some(Color32::WHITE), Some(c)); + // A "#" mark. + g.stroke(&[(5.3, 6.5), (4.8, 12.5)], BLUE); + g.stroke(&[(8.3, 6.5), (7.8, 12.5)], BLUE); + g.stroke(&[(4.0, 8.4), (9.6, 8.4)], BLUE); + g.stroke(&[(3.8, 10.8), (9.4, 10.8)], BLUE); + } + Icon::Calendar => { + g.rect(2.0, 3.0, 12.0, 11.0, Some(Color32::WHITE), Some(c)); + g.rect(2.0, 3.0, 12.0, 3.0, Some(BLUE), None); + for (x, y) in [(4.5, 8.5), (7.5, 8.5), (10.5, 8.5), (4.5, 11.5), (7.5, 11.5)] { + g.rect(x - 0.6, y - 0.6, 1.4, 1.4, Some(c), None); + } + } + Icon::Clock => { + let center = g.pt(8.0, 8.0); + g.p.circle_stroke(center, 6.0 * g.s, Stroke::new(1.15 * g.s.clamp(1.0, 2.0), c)); + g.stroke(&[(8.0, 4.5), (8.0, 8.0), (10.8, 9.6)], BLUE); + } + Icon::FileName => { + g.poly(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5)], Color32::WHITE); + g.line(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5), (3.0, 1.5)], c, 1.15); + g.stroke(&[(10.0, 1.5), (10.0, 4.5), (13.0, 4.5)], c); + g.line(&[(5.0, 9.0), (11.0, 9.0)], BLUE, 1.4); + g.stroke(&[(5.0, 11.5), (9.0, 11.5)], light); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eecdf..c1f0abe28 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,6 +20,7 @@ pub mod i18n; pub mod icons; pub mod keytips; pub mod panes; +pub mod paper_view; pub mod pivot_pane; pub mod ribbon; pub mod tabs; diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs new file mode 100644 index 000000000..ee9b78a1f --- /dev/null +++ b/crates/ui-egui/src/paper_view.rs @@ -0,0 +1,605 @@ +//! Page Layout view (View › Workbook Views › Page Layout): the sheet drawn as printed pages on a +//! desk, with rulers, margins, headers and footers. +//! +//! Pagination and the cell ↔ page mapping come from [`gridcraft_engine::paper::PageGrid`] (the +//! print pagination, so pages here are the printed pages). [`crate::grid::Geo`] carries the grid, +//! so painting, hit-testing, selection and editing work as in Normal view. Headers and footers are +//! edited in place: clicking a page's top or bottom margin edits that section, and the edit is +//! committed through `pageLayout.headerFooter` (one undo step) when the user clicks elsewhere. + +use std::sync::Arc; + +use egui::{Align2, Color32, FontId, Key, Painter, Pos2, Rect, Stroke, StrokeKind, TextEdit, pos2, vec2}; +use gridcraft_engine::cf::CfCache; +use gridcraft_engine::cmd::print::{self, HfContext}; +use gridcraft_engine::model::{Sheet, SheetViewMode, Workbook}; +use gridcraft_engine::paper::{self, HfZone, PageGrid}; +use serde_json::json; + +use crate::SheetApp; +use crate::grid::{self, DISPLAY_SCALE, Geo}; +use crate::icons::Icon; +use crate::theme::{self, Tokens}; + +/// Ruler thickness in points. +pub const RULER: f32 = 18.0; +/// Pages whose cells are painted in one frame; further visible pages only get their paper. +const MAX_PAGES_PAINTED: usize = 96; +/// Page frames considered per frame (tiny zooms show many pages). +const MAX_PAGE_FRAMES: usize = 2_000; +/// The contextual ribbon tab shown while a header or footer is edited. +pub const TAB: &str = "Header & Footer"; + +/// Cached pagination: (doc uid, revision, sheet, sheet identity) → grid. +pub type PaperCache = ((u64, u64, usize, usize), Arc); + +/// A header or footer being edited in place. +#[derive(Clone, Debug)] +pub struct HfEdit { + pub sheet: usize, + /// Page (row band, column band) whose header/footer is shown in edit. + pub page: (usize, usize), + pub footer: bool, + /// 0 left, 1 centre, 2 right. + pub section: usize, + /// The three sections as the user sees them (`&[Page]` field names). + pub texts: [String; 3], + original: [String; 3], + request_focus: bool, + /// Caret (characters) of the active section, kept while the ribbon has focus. + caret: usize, + set_caret: Option, + prev_tab: String, +} + +/// Rulers measure in inches for English, centimetres otherwise. +fn metric(app: &SheetApp) -> bool { + !matches!(app.ui.language, crate::i18n::Language::En) +} + +fn cache_key(app: &SheetApp, wb: &Workbook, si: usize) -> Option<(u64, u64, usize, usize)> { + let d = app.session.active()?; + Some((d.uid, d.revision, si, wb.sheets.get(si).map_or(0, |a| Arc::as_ptr(a) as usize))) +} + +/// Paginates at least as far as `cell` (before scrolling to it). +pub fn ensure_covers(app: &mut SheetApp, wb: &Workbook, si: usize, cell: gridcraft_engine::core::CellRef) { + let Some(key) = cache_key(app, wb, si) else { return }; + let (rows, cols) = match app.grid.paper.as_ref().filter(|(k, _)| *k == key) { + Some((_, g)) if g.covers(cell.row, cell.col) => return, + Some((_, g)) => (g.row_bands.last().map_or(0, |b| b.1), g.col_bands.last().map_or(0, |b| b.1)), + None => (0, 0), + }; + if let Some(g) = PageGrid::build(wb, si, rows.max(cell.row).saturating_add(64), cols.max(cell.col).saturating_add(8)) { + app.grid.paper = Some((key, Arc::new(g))); + } +} + +/// The page grid for the active sheet, covering what the view shows (cached). +pub fn grid_for(app: &mut SheetApp, wb: &Workbook, si: usize, sh: &Sheet, view: egui::Vec2, scroll: egui::Vec2) -> Option> { + let key = cache_key(app, wb, si)?; + let cached = app.grid.paper.as_ref().filter(|(k, _)| *k == key).map(|(_, g)| g.clone()); + let base = match cached { + Some(g) => g, + None => Arc::new(PageGrid::build(wb, si, 0, 0)?), + }; + let z = ((sh.zoom as f32 / 100.0).clamp(0.1, 4.0) * DISPLAY_SCALE * base.scale.clamp(0.1, 4.0)).max(0.001); + let (x1, y1) = (scroll.x as f64 + (view.x / z) as f64, scroll.y as f64 + (view.y / z) as f64); + let (need_r, need_c) = paper::need_for(sh, x1, y1); + let g = if base.covers(need_r, need_c) { + base + } else { + // Grow in steps so scrolling doesn't re-paginate every frame. + Arc::new(PageGrid::build(wb, si, need_r.saturating_add(511) / 512 * 512, need_c.saturating_add(31) / 32 * 32)?) + }; + app.grid.paper = Some((key, g.clone())); + Some(g) +} + +/// Layout point → screen. +fn screen(geo: &Geo, x: f64, y: f64) -> Pos2 { + pos2(geo.cells.left() + (x - geo.scroll.x as f64) as f32 * geo.z, geo.cells.top() + (y - geo.scroll.y as f64) as f32 * geo.z) +} + +fn screen_rect(geo: &Geo, (x, y, w, h): (f64, f64, f64, f64)) -> Rect { + Rect::from_min_max(screen(geo, x, y), screen(geo, x + w, y + h)) +} + +/// Visible pages (row band range, column band range). +fn visible_pages(geo: &Geo, pg: &PageGrid) -> ((usize, usize), (usize, usize)) { + let (x0, y0) = (geo.scroll.x as f64, geo.scroll.y as f64); + let (x1, y1) = (x0 + (geo.cells.width() / geo.z) as f64, y0 + (geo.cells.height() / geo.z) as f64); + pg.pages_in(x0, y0, x1, y1) +} + +fn pages_iter(geo: &Geo, pg: &PageGrid) -> impl Iterator { + let ((i0, i1), (j0, j1)) = visible_pages(geo, pg); + (j0..=j1).flat_map(move |j| (i0..=i1).map(move |i| (i, j))).take(MAX_PAGE_FRAMES) +} + +/// Paints the desk, the pages and their cells; returns the per-page quadrants for the headings. +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_pages( + app: &SheetApp, + p: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + cf: &mut CfCache, + t: &Tokens, +) -> Vec<((u32, u32), (u32, u32), Rect)> { + let mut quads = Vec::new(); + let Some(pg) = geo.paper.clone() else { return quads }; + p.rect_filled(geo.cells, 0.0, t.desk); + let cells = p.with_clip_rect(geo.cells); + let mut painted = 0usize; + for (i, j) in pages_iter(geo, &pg) { + let page = screen_rect(geo, pg.page_rect(i, j)); + if !page.intersects(geo.cells) { + continue; + } + cells.rect_filled(page.translate(vec2(1.5, 2.0)), 0.0, t.shadow); + cells.rect_filled(page, 0.0, t.grid_bg); + let (Some((r0, r1)), Some((c0, c1))) = (pg.row_band(i), pg.col_band(j)) else { continue }; + let clip = screen_rect(geo, pg.content_rect(i, j)).intersect(geo.cells); + if !clip.is_positive() { + continue; + } + let rows = (geo.row_at(sh, clip.top()).max(r0), geo.row_at(sh, clip.bottom() - 0.5).min(r1)); + let cols = (geo.col_at(sh, clip.left()).max(c0), geo.col_at(sh, clip.right() - 0.5).min(c1)); + if rows.0 > rows.1 || cols.0 > cols.1 { + continue; + } + quads.push((rows, cols, clip)); + if painted >= MAX_PAGES_PAINTED { + continue; + } + painted += 1; + let pc = p.with_clip_rect(clip); + grid::paint_quadrant(&pc, geo, wb, si, sh, rows, cols, cf, t); + if !pg.is_data_page(i, j) { + // "Click to add data" pages: faded until something is typed there. + pc.rect_filled(clip, 0.0, t.grid_bg.gamma_multiply(0.75)); + let size = (14.0 * geo.z / DISPLAY_SCALE).clamp(8.0, 22.0); + pc.text(clip.center(), Align2::CENTER_CENTER, tr(app, "Click to add data"), theme::ui_font(size), Color32::from_gray(150)); + } + // The selection outline may straddle the margin edge (as at A1 in Normal view). + let sel_clip = clip.expand(3.0).intersect(page).intersect(geo.cells); + grid::paint_selection(&p.with_clip_rect(sel_clip), geo, sh, sel, t, app); + } + quads +} + +fn tr<'a>(app: &SheetApp, s: &'a str) -> &'a str { + app.ui.language.tr(s) +} + +/// Section rectangles (left, centre, right) of a page's header or footer on screen. +fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool) -> [Rect; 3] { + let (px, py, w, h) = pg.page_rect(i, j); + let [l, r, t, b, ..] = pg.margins; + let (y0, y1) = if footer { (py + h - b, py + h) } else { (py, py + t) }; + let inner = (w - l - r).max(3.0); + let third = inner / 3.0; + let pad = 3.0 / geo.z as f64; + let mut out = [Rect::NOTHING; 3]; + for (k, slot) in out.iter_mut().enumerate() { + let x = px + l + third * k as f64; + *slot = screen_rect(geo, (x, y0 + pad, third, (y1 - y0 - 2.0 * pad).max(1.0))); + } + out +} + +fn hf_font(geo: &Geo, size_pt: Option, bold: bool) -> FontId { + let px = size_pt.unwrap_or(10.0).clamp(1.0, 72.0) * 96.0 / 72.0 * geo.z; + let px = px.clamp(4.0, 120.0); + if bold { theme::ui_bold(px) } else { theme::ui_font(px) } +} + +/// Draws headers and footers on the visible printed pages, and the "Add header" hints on hover. +pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter, geo: &Geo, wb: &Workbook, sh: &Sheet) { + let Some(pg) = geo.paper.clone() else { return }; + let Some(d) = app.session.active() else { return }; + let p = p.with_clip_rect(geo.cells); + let editing = app.grid.hf_edit.as_ref().map(|e| (e.page, e.footer)); + let hover = ui.input(|i| i.pointer.hover_pos()).filter(|q| geo.cells.contains(*q)).and_then(|q| { + let (x, y) = + (geo.scroll.x as f64 + ((q.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((q.y - geo.cells.top()) / geo.z) as f64); + pg.hf_zone_at(x, y) + }); + let needs_text = !sh.print.header.is_empty() || !sh.print.footer.is_empty(); + let (date, time) = if needs_text { print::hf_date_time(wb) } else { Default::default() }; + for (i, j) in pages_iter(geo, &pg) { + let page = screen_rect(geo, pg.page_rect(i, j)); + if !page.intersects(geo.cells) { + continue; + } + for (spec, footer) in [(&sh.print.header, false), (&sh.print.footer, true)] { + if editing == Some(((i, j), footer)) { + continue; + } + let rects = section_rects(geo, &pg, (i, j), footer); + let hovered = hover.is_some_and(|(page, z)| page == (i, j) && z.footer == footer); + if hovered { + for r in &rects { + p.rect_stroke(*r, 0.0, Stroke::new(1.0, Color32::from_gray(185)), StrokeKind::Inside); + } + } + let Some((n, total)) = pg.page_number(i, j).or_else(|| (spec.is_empty()).then_some((1, 1))) else { continue }; + if spec.is_empty() { + if hovered { + let label = if footer { "Add footer" } else { "Add header" }; + p.text(rects[1].center(), Align2::CENTER_CENTER, tr(app, label), hf_font(geo, Some(10.0), false), Color32::from_gray(120)); + } + continue; + } + let cx = HfContext { + page: n, + pages: total, + sheet: sh.name.clone(), + file: d.display_title(), + path: d.path.clone().unwrap_or_default(), + date: date.clone(), + time: time.clone(), + }; + let secs = print::expand_header_footer(spec, &cx); + let (_, py, _, h) = pg.page_rect(i, j); + let [_, _, _, _, hm, fm] = pg.margins; + for (k, sec) in secs.iter().enumerate() { + if sec.text.trim().is_empty() { + continue; + } + let font = hf_font(geo, sec.size, sec.bold); + let r = rects[k]; + let (anchor, x) = match k { + 0 => (Align2::LEFT_TOP, r.left()), + 1 => (Align2::CENTER_TOP, r.center().x), + _ => (Align2::RIGHT_TOP, r.right()), + }; + let lines: Vec<&str> = sec.text.split('\n').take(20).collect(); + let line_h = font.size * 1.2; + let block = line_h * lines.len() as f32; + let top = if footer { screen(geo, 0.0, py + h - fm).y - block } else { screen(geo, 0.0, py + hm).y }; + for (li, line) in lines.iter().enumerate() { + p.text(pos2(x, top + li as f32 * line_h), anchor, *line, font.clone(), Color32::BLACK); + } + } + } + } +} + +/// Clicks on headers and footers, and the in-place editor. Runs before the grid's own +/// interaction (which stands aside while a header or footer is being edited). +pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { + // Leaving the sheet or the view commits. + if let Some(e) = &app.grid.hf_edit + && (e.sheet != wb.active_sheet || sh.view != SheetViewMode::PageLayout || geo.paper.is_none()) + { + commit(app); + } + let Some(pg) = geo.paper.clone() else { return }; + if resp.clicked_by(egui::PointerButton::Primary) + && let Some(p) = ui.input(|i| i.pointer.interact_pos()) + { + let (x, y) = + (geo.scroll.x as f64 + ((p.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((p.y - geo.cells.top()) / geo.z) as f64); + match pg.hf_zone_at(x, y) { + Some((page, zone)) if geo.cells.contains(p) => begin(app, wb.active_sheet, sh, page, zone), + _ => { + // A click anywhere else ends the edit; the grid then handles the click. + if app.grid.hf_edit.is_some() { + commit(app); + } + } + } + } + editor(app, ui, geo, &pg); +} + +/// Starts (or moves) editing a header or footer section. +pub fn begin(app: &mut SheetApp, sheet: usize, sh: &Sheet, page: (usize, usize), zone: HfZone) { + if let Some(e) = app.grid.hf_edit.as_mut() + && e.sheet == sheet + && e.page == page + && e.footer == zone.footer + { + if e.section != zone.section { + e.section = zone.section; + e.caret = e.texts.get(zone.section).map_or(0, |t| t.chars().count()); + e.set_caret = Some(e.caret); + } + e.request_focus = true; + return; + } + if app.grid.hf_edit.is_some() { + commit(app); + } + // A cell being edited is entered first, as when clicking another cell. + if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { + return; + } + let spec = if zone.footer { &sh.print.footer } else { &sh.print.header }; + let texts = print::split_header_footer(spec).map(|s| print::codes_to_fields(&s)); + let caret = texts.get(zone.section).map_or(0, |t| t.chars().count()); + let prev_tab = if app.ui.ribbon_tab == TAB { "Home".to_string() } else { app.ui.ribbon_tab.clone() }; + app.grid.hf_edit = Some(HfEdit { + sheet, + page, + footer: zone.footer, + section: zone.section.min(2), + original: texts.clone(), + texts, + request_focus: true, + caret, + set_caret: Some(caret), + prev_tab, + }); + app.ui.ribbon_tab = TAB.to_string(); + app.ui.ribbon_collapsed = false; +} + +/// Ends editing and writes the header or footer when it changed (one undo step). +pub fn commit(app: &mut SheetApp) { + let Some(e) = app.grid.hf_edit.take() else { return }; + if app.ui.ribbon_tab == TAB { + app.ui.ribbon_tab = e.prev_tab.clone(); + } + if e.texts == e.original { + return; + } + let spec = print::join_header_footer(&e.texts.clone().map(|s| print::fields_to_codes(&s))); + let key = if e.footer { "footer" } else { "header" }; + app.run_or_alert("pageLayout.headerFooter", json!({"sheet": e.sheet, key: spec})); +} + +/// Ends editing without writing. +pub fn cancel(app: &mut SheetApp) { + if let Some(e) = app.grid.hf_edit.take() + && app.ui.ribbon_tab == TAB + { + app.ui.ribbon_tab = e.prev_tab; + } +} + +/// Inserts a field (`&[Page]`…) at the caret of the section being edited. +pub fn insert_field(app: &mut SheetApp, field: &str) { + let Some(e) = app.grid.hf_edit.as_mut() else { return }; + let Some(text) = e.texts.get_mut(e.section) else { return }; + let at = text.char_indices().nth(e.caret).map_or(text.len(), |(b, _)| b); + text.insert_str(at, field); + e.caret += field.chars().count(); + e.set_caret = Some(e.caret); + e.request_focus = true; +} + +/// Moves editing to the header or footer of the same page (Go to Header / Go to Footer). +pub fn go_to(app: &mut SheetApp, footer: bool) { + let Some(e) = app.grid.hf_edit.as_ref() else { return }; + if e.footer == footer { + return; + } + let (sheet, page, section) = (e.sheet, e.page, e.section); + commit(app); + let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(sheet)).cloned() else { return }; + begin(app, sheet, &sh, page, HfZone { footer, section }); +} + +fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { + let Some(mut e) = app.grid.hf_edit.take() else { return }; + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { + app.grid.hf_edit = Some(e); + cancel(app); + return; + } + let rects = section_rects(geo, pg, e.page, e.footer); + let p = ui.painter().with_clip_rect(geo.cells); + let t = Tokens::get(ui.ctx()); + let font = hf_font(geo, Some(10.0), false); + let mut switch_to = None; + for (k, r) in rects.iter().enumerate() { + if !r.intersects(geo.cells) { + continue; + } + p.rect_filled(*r, 0.0, Color32::WHITE); + let active = k == e.section; + p.rect_stroke( + *r, + 0.0, + Stroke::new(if active { 1.5 } else { 1.0 }, if active { t.sel_border } else { Color32::from_gray(160) }), + StrokeKind::Inside, + ); + if active { + continue; + } + let Some(text) = e.texts.get(k) else { continue }; + let anchor = [Align2::LEFT_TOP, Align2::CENTER_TOP, Align2::RIGHT_TOP][k]; + let x = [r.left() + 3.0, r.center().x, r.right() - 3.0][k]; + p.text(pos2(x, r.top() + 2.0), anchor, text, font.clone(), Color32::BLACK); + if ui.interact(*r, ui.id().with(("hf_section", k)), egui::Sense::click()).clicked() { + switch_to = Some(k); + } + } + // Scrolled away: keep the edit, show it again when the page comes back. + let Some(r) = rects.get(e.section).copied().filter(|r| r.intersects(geo.cells)) else { + app.grid.hf_edit = Some(e); + return; + }; + let id = egui::Id::new("gridcraft.header_footer_editor"); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(3.0, 2.0)).intersect(geo.cells))); + let align = [egui::Align::LEFT, egui::Align::Center, egui::Align::RIGHT][e.section.min(2)]; + if let Some(text) = e.texts.get_mut(e.section) { + let mut output = TextEdit::multiline(text) + .id(id) + .frame(egui::Frame::NONE) + .font(font) + .text_color(Color32::BLACK) + .background_color(Color32::WHITE) + .horizontal_align(align) + .desired_width(r.width() - 6.0) + .desired_rows(1) + .lock_focus(true) + .show(&mut child); + if e.request_focus { + output.response.request_focus(); + e.request_focus = false; + } + if let Some(c) = e.set_caret.take() { + output.state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(c)))); + output.state.store(ui.ctx(), id); + } else if let Some(cr) = output.cursor_range { + e.caret = cr.primary.index.into(); + } + } + if let Some(k) = switch_to { + e.section = k; + e.caret = e.texts.get(k).map_or(0, |t| t.chars().count()); + e.set_caret = Some(e.caret); + e.request_focus = true; + } + app.grid.hf_edit = Some(e); +} + +/// 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 }; + if geo.rect.top() - outer.top() < RULER - 0.5 { + return; + } + let metric = metric(app); + // Screen points per unit: an inch is 96 sheet pixels at the print scale. + let inch = 96.0 / pg.scale.clamp(0.1, 4.0) * geo.z; + let unit = if metric { inch / 2.54 } else { inch }; + 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 vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + 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; + // 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)) + } else { + Rect::from_min_max(pos2(area.left() + 2.0, a), pos2(area.right() - 2.0, b)) + } + }; + let pc = p.with_clip_rect(area); + pc.rect_filled(band(from, to), 0.0, t.header_sel_bg); + pc.rect_filled(band(m0, m1), 0.0, t.header_bg); + if unit < 2.0 { + return; + } + let steps = (((m1 - m0) / unit * minor).ceil() as usize).min(4_000); + for s in 0..=steps { + let pos = m0 + s as f32 * unit / minor; + if pos > m1 + 0.5 { + break; + } + let major = s.is_multiple_of(minor as usize); + let len = if major { + 6.0 + } else if s.is_multiple_of(2) && !metric { + 4.0 + } else { + 2.5 + }; + if !major && unit / minor < 3.5 { + continue; + } + let n = s / minor as usize; + if major && n > 0 && n.is_multiple_of(label_every) { + let at = if horizontal { pos2(pos, area.center().y) } else { pos2(area.center().x, pos) }; + pc.text(at, Align2::CENTER_CENTER, n.to_string(), font.clone(), t.header_text); + continue; + } + let (a, b) = if horizontal { + (pos2(pos, area.bottom() - 2.0 - len), pos2(pos, area.bottom() - 2.0)) + } else { + (pos2(area.right() - 2.0 - len, pos), pos2(area.right() - 2.0, pos)) + }; + pc.line_segment([a, b], Stroke::new(1.0, t.header_text.gamma_multiply(0.6))); + } + }; + for j in j0..=j1.min(j0 + 200) { + let (x, _, w, _) = pg.page_rect(0, j); + let a = screen(geo, x, 0.0).x; + let b = screen(geo, x + w, 0.0).x; + span(p, a, b, screen(geo, x + ml, 0.0).x, screen(geo, x + w - mr, 0.0).x, true, hr); + } + for i in i0..=i1.min(i0 + 200) { + let (_, y, _, h) = pg.page_rect(i, 0); + 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); + } + 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())], + Stroke::new(1.0, t.header_line), + ); +} + +/// The Header & Footer contextual ribbon tab. +pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { + use crate::widgets::big_button; + let Some(e) = app.grid.hf_edit.as_ref() else { return }; + let footer = e.footer; + let (sheet_name, file) = + app.session.active().map(|d| (d.wb.sheet(e.sheet).map(|s| s.name.clone()).unwrap_or_default(), d.display_title())).unwrap_or_default(); + // Header & Footer presets (shown with sample values, as the result will read). + for (label, part) in [("Header", false), ("Footer", true)] { + let r = big_button(ui, if part { Icon::Footer } else { Icon::Header }, label, label, true); + egui::Popup::menu(&r).show(|ui| { + let presets: [(String, &str); 7] = [ + (tr(app, "(none)").to_string(), ""), + ("Page 1".into(), "&CPage &P"), + ("Page 1 of ?".into(), "&CPage &P of &N"), + (sheet_name.clone(), "&C&A"), + (file.clone(), "&C&F"), + (format!("{sheet_name}, Page 1"), "&C&A, Page &P"), + (format!("{file}, Page 1"), "&C&F, Page &P"), + ]; + for (text, spec) in presets { + if ui.button(text).clicked() { + let sheet = app.grid.hf_edit.as_ref().map(|e| e.sheet).unwrap_or(0); + cancel(app); + app.run_or_alert("pageLayout.headerFooter", json!({"sheet": sheet, (if part { "footer" } else { "header" }): spec})); + ui.close(); + } + } + }); + } + sep(ui); + for (icon, label, tip, field) in [ + (Icon::PageNumber, "Page\nNumber", "Page Number", "&[Page]"), + (Icon::PageCount, "Number\nof Pages", "Number of Pages", "&[Pages]"), + (Icon::Calendar, "Current\nDate", "Current Date", "&[Date]"), + (Icon::Clock, "Current\nTime", "Current Time", "&[Time]"), + (Icon::Folder, "File\nPath", "File Path", "&[Path]&[File]"), + (Icon::FileName, "File\nName", "File Name", "&[File]"), + (Icon::Sheet, "Sheet\nName", "Sheet Name", "&[Tab]"), + ] { + if big_button(ui, icon, label, tip, false).clicked() { + insert_field(app, field); + } + } + sep(ui); + if big_button(ui, Icon::Header, "Go to\nHeader", "Go to Header", false).clicked() && footer { + go_to(app, false); + } + if big_button(ui, Icon::Footer, "Go to\nFooter", "Go to Footer", false).clicked() && !footer { + go_to(app, true); + } +} + +fn sep(ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + 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)); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..0a398cb72 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -199,6 +199,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { "Automate" => automate(app, ui), "Table Design" => table_design(app, ui), "Chart Design" => chart_design(app, ui), + crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui), _ => home(app, ui), } }); @@ -244,6 +245,9 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { if app.selected_chart.is_some_and(|id| sh.charts.iter().any(|c| c.id == id)) { v.push("Chart Design"); } + if app.grid.hf_edit.is_some() { + v.push(crate::paper_view::TAB); + } } v } @@ -1635,7 +1639,14 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { sep(ui); let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true)); let lang = app.ui.language; + let page_layout = + app.session.active().and_then(|d| d.wb.active().map(|s| s.view == gridcraft_engine::model::SheetViewMode::PageLayout)).unwrap_or(false); ui.vertical(|ui| { + // Rulers exist only in Page Layout view. + let mut ruler = !app.session.hide_ruler; + if ui.add_enabled(page_layout, egui::Checkbox::new(&mut ruler, lang.tr("Ruler"))).changed() { + act(app, "view.ruler", json!({"on": ruler})); + } let mut fb = app.ui.formula_bar; if ui.checkbox(&mut fb, lang.tr("Formula Bar")).changed() { app.ui.formula_bar = fb; diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b1e345a66..f9f47ebb2 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -249,7 +249,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { (Icon::Normal, "Normal", "normal", "view.normal"), ] { let (r, resp) = ui.allocate_exact_size(vec2(26.0, 20.0), Sense::click()); - let cur = if app.session.view_mode.is_empty() { "normal" } else { app.session.view_mode.as_str() }; + let cur = app.session.active().and_then(|d| d.wb.active().map(|s| s.view.as_str())).unwrap_or("normal"); if cur == mode { ui.painter().rect_filled(r, 3.0, t.pressed); } else if resp.hovered() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 219e2d289..420dbc43d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -25,6 +25,8 @@ pub struct Tokens { pub pressed: Color32, pub separator: Color32, pub grid_bg: Color32, + /// Behind the pages in Page Layout view. + pub desk: Color32, pub gridline: Color32, pub header_bg: Color32, pub header_text: Color32, @@ -62,6 +64,7 @@ impl Tokens { pressed: Color32::from_rgb(0xDD, 0xDD, 0xDD), separator: Color32::from_rgb(0xE0, 0xE0, 0xE0), grid_bg: Color32::WHITE, + desk: Color32::from_rgb(0xDE, 0xDE, 0xDE), gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1), header_bg: Color32::from_rgb(0xF8, 0xF8, 0xF8), header_text: Color32::from_rgb(0x42, 0x42, 0x42), @@ -99,6 +102,7 @@ impl Tokens { pressed: Color32::from_rgb(0x4D, 0x4D, 0x4D), separator: Color32::from_rgb(0x48, 0x48, 0x48), grid_bg: Color32::WHITE, + desk: Color32::from_rgb(0x3A, 0x3A, 0x3A), gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1), header_bg: Color32::from_rgb(0x30, 0x30, 0x30), header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC), diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs new file mode 100644 index 000000000..d212d0577 --- /dev/null +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -0,0 +1,126 @@ +//! Page Layout view through real pointer and keyboard input: cells are hit through the page +//! transform, and headers are edited in place, committed on a click elsewhere, and undoable. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_engine::model::SheetViewMode; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::grid::Geo; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn fixture() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + for r in 1..=60 { + session.execute("cell.set", json!({"cell": format!("A{r}"), "input": r.to_string()})).unwrap(); + } + session.execute("pageLayout.headerFooter", json!({"header": "&LLeft"})).unwrap(); + session.execute("view.pageLayout", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(1200.0, 900.0)) + .with_step_dt(1.0 / 60.0) + .with_os(egui::os::OperatingSystem::Mac) + .build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn geo(h: &Harness) -> Geo { + h.state().grid.geo.clone().unwrap() +} + +fn click(h: &mut Harness, p: Pos2) { + // Keep clicks apart from egui's double-click window. + h.run_steps(40); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn header(h: &Harness) -> String { + h.state().session.doc().unwrap().wb.active().unwrap().print.header.clone() +} + +/// Screen point of a layout point. +fn screen(g: &Geo, x: f64, y: f64) -> Pos2 { + pos2(g.cells.left() + (x - g.scroll.x as f64) as f32 * g.z, g.cells.top() + (y - g.scroll.y as f64) as f32 * g.z) +} + +#[test] +fn cells_are_hit_through_the_page_transform() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().expect("Page Layout view lays out pages"); + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.view, SheetViewMode::PageLayout); + // Cells start inside the first page's margins, not at the grid's corner. + let a1 = g.cell_rect(sh, CellRef::new(0, 0)); + let content = screen(&g, pg.page_x(0) + pg.margins[0], pg.page_y(0) + pg.margins[2]); + assert!((a1.left() - content.x).abs() < 0.5 && (a1.top() - content.y).abs() < 0.5, "{a1:?} vs {content:?}"); + let c5 = g.cell_rect(sh, CellRef::parse("C5").unwrap()).center(); + assert_eq!(g.cell_at(sh, c5), CellRef::parse("C5").unwrap()); + click(&mut h, c5); + assert_eq!(h.state().session.doc().unwrap().selection.active, CellRef::parse("C5").unwrap()); + // A click in the gap left of the page selects nothing new. + let gap = screen(&g, pg.gap / 2.0, pg.page_y(0) + pg.margins[2] + 30.0); + click(&mut h, gap); + assert_eq!(h.state().session.doc().unwrap().selection.active, CellRef::parse("C5").unwrap()); +} + +#[test] +fn header_is_edited_in_place_committed_by_a_click_elsewhere_and_undoable() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + // The centre third of the first page's top margin. + let (px, py, w, _) = pg.page_rect(0, 0); + let centre = screen(&g, px + w / 2.0, py + pg.margins[2] / 2.0); + click(&mut h, centre); + { + let app = h.state(); + let e = app.grid.hf_edit.as_ref().expect("clicking the top margin edits the header"); + assert_eq!((e.footer, e.section), (false, 1)); + assert_eq!(app.ui.ribbon_tab, "Header & Footer"); + } + h.input_mut().events.push(Event::Text("Report ".into())); + h.run_steps(2); + gridcraft_ui_egui::paper_view::insert_field(h.state_mut(), "&[Page]"); + h.run_steps(2); + assert_eq!(h.state().grid.hf_edit.as_ref().unwrap().texts[1], "Report &[Page]"); + assert_eq!(header(&h), "&LLeft", "nothing is written while editing"); + // Clicking a cell commits (one undo step) and selects the cell. + let sh_geo = geo(&h); + let b3 = { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + sh_geo.cell_rect(sh, CellRef::parse("B3").unwrap()).center() + }; + click(&mut h, b3); + assert!(h.state().grid.hf_edit.is_none()); + assert_eq!(header(&h), "&LLeft&CReport &P"); + assert_eq!(h.state().session.doc().unwrap().selection.active, CellRef::parse("B3").unwrap()); + assert_ne!(h.state().ui.ribbon_tab, "Header & Footer"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(header(&h), "&LLeft"); + // Escape cancels an edit without writing. + click(&mut h, centre); + h.input_mut().events.push(Event::Text("x".into())); + h.run_steps(2); + 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.hf_edit.is_none()); + assert_eq!(header(&h), "&LLeft"); +} diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 065558e6b..8cf00d8af 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -208,6 +208,7 @@ fn read_views(e: &El, sheet: &mut Sheet) { sheet.show_formulas = v.flag("showFormulas", false); sheet.show_zeros = v.flag("showZeros", true); sheet.right_to_left = v.flag("rightToLeft", false); + sheet.view = v.attr("view").and_then(gridcraft_model::SheetViewMode::parse).unwrap_or_default(); if let Some(z) = v.attr_u32("zoomScale") { sheet.zoom = z.clamp(10, 400) as u16; } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index b1e3fca2c..242550615 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -152,6 +152,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if sheet.right_to_left { s.push_str(" rightToLeft=\"1\""); } + if sheet.view != gridcraft_model::SheetViewMode::Normal { + let _ = write!(s, " view=\"{}\"", sheet.view.as_str()); + } if sheet.zoom != 100 && (10..=400).contains(&sheet.zoom) { let _ = write!(s, " zoomScale=\"{0}\" zoomScaleNormal=\"{0}\"", sheet.zoom); } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 12bba62ed..63e980755 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -118,6 +118,7 @@ fn sample() -> Workbook { s.show_zeros = false; s.right_to_left = true; s.zoom = 125; + s.view = gridcraft_model::SheetViewMode::PageLayout; s.view_active = at("C7"); let red_b = Box::new(red.clone()); @@ -398,6 +399,7 @@ fn full_roundtrip() { assert_eq!(b.tab_color, a.tab_color); assert_eq!((b.show_gridlines, b.show_headings, b.show_zeros, b.right_to_left, b.zoom), (false, false, false, true, 125)); assert_eq!(b.view_active, a.view_active); + assert_eq!(b.view, gridcraft_model::SheetViewMode::PageLayout, "sheetView view=\"pageLayout\" round-trips"); assert_eq!(b.cond_formats, a.cond_formats); assert_eq!(b.validations, a.validations); assert_eq!(b.hyperlinks, a.hyperlinks); diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8d8..7958bfe10 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (§14 Page Layout view, #329) · **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 @@ -102,8 +102,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- **Estimate:** 22–35 h. ### 14. Page Layout view and print polish (U, F) -- No paper view with rulers and in-place header/footer editing; web print exports only (#141, PR - #143). **Estimate:** 12–20 h. +- 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; web print exports only (#141, PR #143). **Estimate:** 5–10 h. ### 15. Remaining functions and an oracle suite (F) - 22 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2e5ed01cd..a96e24e23 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-10 · **Last updated:** 2026-10-10 · **Change:** major (first full assessment; re-measure against Excel for Mac 16.113.4, replacing the estimates that lived in ROADMAP.md) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Page Layout view, #329) · **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: @@ -86,7 +86,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | 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, XLSX round-trip. Missing: slicers, timelines, calculated fields/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) | +| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, Page Layout view with in-place headers/footers (#329), PDF export and printing. 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. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | @@ -133,7 +133,7 @@ functions, co-authoring and admin, specialist hardware, and languages beyond the | Sort, filter, tables, validation | 10 | 70 | Feature-area table | | PivotTables (core: fields, layouts, grouping, refresh) | 8 | 60 | Slicers and calculated fields excluded as less frequent | | Charts (column, bar, line, pie, scatter, combo) | 9 | 45 | Common kinds work and survive save; no axis options or trendlines; resize broken (#190) | -| Page layout, print, PDF | 5 | 60 | Latin text exports; no Page Layout view | +| Page layout, print, PDF | 5 | 60 | Latin text exports | | Comments, notes, protection | 3 | 55 | Feature-area table | | Pictures and shapes | 2 | 45 | Feature-area table | | **Average depth** | 100 | **69.5** | | @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Page Layout view landed (#329); page layout notes updated, no score change | | 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) | | 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..ba910658d 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-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Page Layout view, #329) · **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 @@ -15,7 +15,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Element | Excel | GridCraft | Status | |---|---|---|---| | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | -| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | +| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs) | done; missing contextual tabs for pictures, shapes, sparklines, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | | 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 | @@ -24,7 +24,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | 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 | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | **no** (Page Break Preview only) | 8–12 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 | header/footer pictures, Different First Page / Odd & Even, ruler margin dragging missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction @@ -84,12 +84,13 @@ by rule and will never match Office's, by design. ## Work (50–80 h) Key tips 3–5 · shortcut audit 6–10 · context menus and Quick Analysis 5–8 · Page Layout view -8–12 · Options dialog and ribbon/QAT customization 6–10 · accessibility 12–18 · selection and +polish 1–3 · Options dialog and ribbon/QAT customization 6–10 · accessibility 12–18 · selection and scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Page Layout view with rulers and in-place headers and footers (#329) | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From c8ce333a74c14af603ce4f001f562243d19c1ba0 Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:35:04 +0200 Subject: [PATCH 075/102] i18n: Czech interface language (cs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Czech table with all 266 interface labels (the same key set as Korean, Russian, Simplified Chinese and Brazilian Portuguese) and register `cs` in Language: name "Čeština", code `cs`, system tags `cs-*` / `cs_*`, appended to the end of the switcher. The language command help, its error text and the snapshot example usage list `cs`. Like the other ribbon tables, it covers the ribbon, sheet tabs and widgets; dialogs, menus and messages stay English. Formulas stay canonical English: Czech uses the English formula locale like every non-Spanish language. Translated from the meaning of each English label (clean-room, not from another product's localization), with AI assistance. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/examples/snapshot.rs | 2 +- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/i18n.rs | 8 +- crates/ui-egui/src/i18n/cs.rs | 273 ++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 +- 5 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/i18n/cs.rs diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 93359f03a..0a481b283 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -1,7 +1,7 @@ //! Renders the whole GridCraft window offscreen (wgpu, no window) to a PNG. //! //! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx] -//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt] +//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt|cs] //! [--dialog 'name={json}'] out.png` use gridcraft_engine::Session; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e6da0f00b..f2a1e0549 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -174,7 +174,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.theme", "Display Theme", ""), ("view.zoom100", "100%", ""), ("ui.dialog", "Open Dialog", ""), - ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"), + ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"cs\"} (alias: code)"), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), ] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..2ff83990e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; +mod cs; mod ko; mod pt; mod ru; @@ -26,10 +27,11 @@ pub enum Language { Ru, #[serde(rename = "pt")] PtBr, + Cs, } impl Language { - pub const ALL: [Self; 7] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr]; + pub const ALL: [Self; 8] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::Cs]; /// The language's own name, shown in the switcher. pub fn name(self) -> &'static str { @@ -41,6 +43,7 @@ impl Language { Self::Ko => "한국어", Self::Ru => "Русский", Self::PtBr => "Português (Brasil)", + Self::Cs => "Čeština", } } @@ -54,6 +57,7 @@ impl Language { Self::Ko => "ko", Self::Ru => "ru", Self::PtBr => "pt", + Self::Cs => "cs", } } @@ -70,6 +74,7 @@ impl Language { "ko" => Some(Self::Ko), "ru" => Some(Self::Ru), "pt" => Some(Self::PtBr), + "cs" => Some(Self::Cs), _ => None, } } @@ -103,6 +108,7 @@ impl Language { Self::Ko => ko::translate(text), Self::Ru => ru::translate(text), Self::PtBr => pt::translate(text), + Self::Cs => cs::translate(text), }; translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs new file mode 100644 index 000000000..1f547931c --- /dev/null +++ b/crates/ui-egui/src/i18n/cs.rs @@ -0,0 +1,273 @@ +//! Czech translations for the interface strings in i18n.rs. + +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Vložit", + "Paste Values" => "Vložit hodnoty", + "Paste Formulas" => "Vložit vzorce", + "Paste Formatting" => "Vložit formátování", + "Transpose" => "Transponovat", + "Paste Link" => "Vložit propojení", + "Paste Special…" => "Vložit jinak…", + "Cut (⌘X)" => "Vyjmout (⌘X)", + "Copy (⌘C)" => "Kopírovat (⌘C)", + "Format Painter (double-click to keep it on)" => "Štětec formátu (dvojklikem zůstane zapnutý)", + "Bold (⌘B)" => "Tučné (⌘B)", + "Italic (⌘I)" => "Kurzíva (⌘I)", + "Underline (⌘U)" => "Podtržení (⌘U)", + "Double Underline" => "Dvojité podtržení", + "Strikethrough" => "Přeškrtnutí", + "Underline" => "Podtržení", + "Increase Font Size" => "Zvětšit písmo", + "Decrease Font Size" => "Zmenšit písmo", + "Wrap Text" => "Zalamovat text", + "Merge & Center" => "Sloučit a vycentrovat", + "Merge Cells" => "Sloučit buňky", + "Merge Across" => "Sloučit po řádcích", + "Unmerge Cells" => "Zrušit sloučení buněk", + "Fill Color" => "Barva výplně", + "Font Color" => "Barva písma", + "Borders" => "Ohraničení", + "Top\nAlign" => "Zarovnat\nnahoru", + "Middle\nAlign" => "Zarovnat\ndoprostřed", + "Bottom\nAlign" => "Zarovnat\ndolů", + "Left" => "Doleva", + "Right" => "Doprava", + "Center" => "Zarovnat na střed", + "Horizontal" => "Vodorovně", + "Vertical Text" => "Svislý text", + "Decrease Indent" => "Zmenšit odsazení", + "Increase Indent" => "Zvětšit odsazení", + "Orientation" => "Orientace textu", + "Page Layout|Orientation" => "Orientace", + "Angle Counterclockwise" => "Natočit proti směru hodin", + "Angle Clockwise" => "Natočit po směru hodin", + "Rotate Text Up" => "Otočit text nahoru", + "Rotate Text Down" => "Otočit text dolů", + "Accounting Number Format" => "Účetnický formát čísla", + "Percent Style" => "Styl procent", + "Comma Style" => "Styl s oddělovačem tisíců", + "Increase Decimal" => "Přidat desetinné místo", + "Decrease Decimal" => "Odebrat desetinné místo", + "More Accounting Formats…" => "Další účetnické formáty…", + "Conditional\nFormatting" => "Podmíněné\nformátování", + "Conditional Formatting" => "Podmíněné formátování", + "Format\nas Table" => "Formátovat\njako tabulku", + "Cell\nStyles" => "Styly\nbuněk", + "Format Cells…" => "Formát buněk…", + "Format Cell Alignment…" => "Zarovnání buněk…", + "Format\nPane" => "Podokno\nformátu", + "Format" => "Formát", + "Insert" => "Vložit", + "Insert Cells…" => "Vložit buňky…", + "Delete" => "Odstranit", + "Delete Cells…" => "Odstranit buňky…", + "Insert Sheet Rows" => "Vložit řádky listu", + "Insert Sheet Columns" => "Vložit sloupce listu", + "Delete Sheet Rows" => "Odstranit řádky listu", + "Delete Sheet Columns" => "Odstranit sloupce listu", + "Row Height…" => "Výška řádku…", + "AutoFit Row Height" => "Přizpůsobit výšku řádku", + "Column Width…" => "Šířka sloupce…", + "AutoFit Column Width" => "Přizpůsobit šířku sloupce", + "Default Width…" => "Výchozí šířka…", + "Hide Rows" => "Skrýt řádky", + "Unhide Rows" => "Zobrazit řádky", + "Hide Columns" => "Skrýt sloupce", + "Unhide Columns" => "Zobrazit sloupce", + "Lock Cell" => "Zamknout buňku", + "Clear" => "Vymazat", + "Clear All" => "Vymazat vše", + "Clear Formats" => "Vymazat formáty", + "Clear Contents" => "Vymazat obsah", + "Clear Comments and Notes" => "Vymazat komentáře a poznámky", + "Clear Hyperlinks" => "Vymazat hypertextové odkazy", + "AutoSum" => "Automatický\nsoučet", + "AutoSum (⌘⇧T)" => "Automatický součet (⌘⇧T)", + "Flash\nFill" => "Dynamické\ndoplňování", + "Flash Fill" => "Dynamické doplňování", + "Fill" => "Vyplnit", + "Clear Print Area" => "Vymazat oblast tisku", + "Find &\nSelect" => "Najít a\nvybrat", + "Find…" => "Najít…", + "Replace…" => "Nahradit…", + "Go To…" => "Přejít na…", + "Go To Special…" => "Přejít na jinak…", + "PivotTable" => "Kontingenční\ntabulka", + "Recommended\nCharts" => "Doporučené\ngrafy", + "Table" => "Tabulka", + "Pictures" => "Obrázky", + "Shapes" => "Obrazce", + "Icons" => "Ikony", + "Text\nBox" => "Textové\npole", + "Comment" => "Komentář", + "New\nComment" => "Nový\nkomentář", + "New Note" => "Nová poznámka", + "Show/Hide Note" => "Zobrazit/skrýt poznámku", + "Header &\nFooter" => "Záhlaví a\nzápatí", + "Text to\nColumns" => "Text do\nsloupců", + "Link" => "Odkaz", + "Symbol" => "Symbol", + "Sparklines" => "Minigrafy", + "Header & Footer" => "Záhlaví a zápatí", + "Draw with ink" => "Kreslit od ruky", + "Eraser" => "Guma", + "Ink to\nShape" => "Rukopis\nna obrazec", + "Select\nObjects" => "Vybrat\nobjekty", + "Themes" => "Motivy", + "Margins" => "Okraje", + "Size" => "Velikost", + "Print\nArea" => "Oblast\ntisku", + "Set Print Area" => "Nastavit oblast tisku", + "Breaks" => "Konce", + "Insert Page Break" => "Vložit konec stránky", + "Remove Page Break" => "Odebrat konec stránky", + "Reset All Page Breaks" => "Obnovit všechny konce stránek", + "Print\nTitles" => "Tisk\nnázvů", + "Narrow" => "Úzké", + "Wide" => "Široké", + "Normal" => "Normální", + "Portrait" => "Na výšku", + "Landscape" => "Na šířku", + "Custom Margins…" => "Vlastní okraje…", + "Insert\nFunction" => "Vložit\nfunkci", + "Name\nManager" => "Správce\nnázvů", + "Define Name" => "Definovat název", + "Use in Formula" => "Použít ve vzorci", + "Create from Selection" => "Vytvořit z výběru", + "Trace Precedents" => "Sledovat předchůdce", + "Trace Dependents" => "Sledovat následníky", + "Remove Arrows" => "Odebrat šipky", + "Show Formulas" => "Zobrazit vzorce", + "Error Checking" => "Kontrola chyb", + "Evaluate Formula" => "Vyhodnotit vzorec", + "Watch Window" => "Okno sledování", + "Calculation\nOptions" => "Možnosti\nvýpočtu", + "Calculate Now" => "Přepočítat", + "Calculate Sheet" => "Přepočítat list", + "Automatic" => "Automaticky", + "Automatic Except for Data Tables" => "Automaticky kromě tabulek dat", + "Manual" => "Ručně", + "Sort &\nFilter" => "Seřadit a\nfiltrovat", + "Sort" => "Seřadit", + "Sort A to Z" => "Seřadit od A do Z", + "Sort Z to A" => "Seřadit od Z do A", + "Custom Sort…" => "Vlastní řazení…", + "Filter" => "Filtr", + "Reapply" => "Použít znovu", + "Get Data\n(Text/CSV)" => "Načíst data\n(text/CSV)", + "Remove\nDuplicates" => "Odebrat\nduplicity", + "Data\nValidation" => "Ověření\ndat", + "Data Validation" => "Ověření dat", + "Data Validation…" => "Ověření dat…", + "Circle Invalid Data" => "Zakroužkovat neplatná data", + "Clear Validation" => "Vymazat ověření", + "Group" => "Seskupit", + "Ungroup" => "Zrušit seskupení", + "Subtotal" => "Mezisoučet", + "What-If\nAnalysis" => "Citlivostní\nanalýza", + "Data Table…" => "Tabulka dat…", + "Scenario Manager…" => "Správce scénářů…", + "Goal Seek…" => "Hledat řešení…", + "Spelling" => "Pravopis", + "Check\nAccessibility" => "Zkontrolovat\npřístupnost", + "Threaded Comments" => "Komentáře ve vláknech", + "Show\nComments" => "Zobrazit\nkomentáře", + "Protect\nWorkbook" => "Zamknout\nsešit", + "Unprotect\nSheet" => "Odemknout\nlist", + "Protect Sheet…" => "Zamknout list…", + "Comments" => "Komentáře", + "Notes" => "Poznámky", + "Page Break\nPreview" => "Náhled\nkonců stránek", + "Page\nLayout" => "Rozložení\nstránky", + "Formula Bar" => "Řádek vzorců", + "Gridlines" => "Mřížka", + "Headings" => "Záhlaví", + "Zoom" => "Lupa", + "100%" => "100 %", + "Zoom to\nSelection" => "Přiblížit\nna výběr", + "Freeze\nPanes" => "Ukotvit\npříčky", + "Freeze Panes" => "Ukotvit příčky", + "Freeze Top Row" => "Ukotvit horní řádek", + "Freeze First Column" => "Ukotvit první sloupec", + "Unfreeze Panes" => "Uvolnit příčky", + "Dark Mode" => "Tmavý režim", + "Display theme" => "Vzhled", + "System" => "Podle systému", + "Light" => "Světlý", + "Dark" => "Tmavý", + "Interface language" => "Jazyk rozhraní", + "Command\nPalette" => "Paleta\npříkazů", + "Agent\nControl" => "Řízení\nagenty", + "Action\nJournal" => "Deník\nakcí", + "About\nGridCraft" => "O aplikaci\nGridCraft", + "Share" => "Sdílet", + "Add Chart\nElement" => "Přidat prvek\ngrafu", + "Change\nChart Type" => "Změnit\ntyp grafu", + "Switch\nRow/Column" => "Prohodit\nřádky/sloupce", + "Data Labels: None" => "Popisky dat: žádné", + "Data Labels: Show" => "Popisky dat: zobrazit", + "Legend: None" => "Legenda: žádná", + "Legend: Bottom" => "Legenda: dole", + "Legend: Top" => "Legenda: nahoře", + "Legend: Right" => "Legenda: vpravo", + "Gridlines: None" => "Mřížka: žádná", + "Gridlines: Show" => "Mřížka: zobrazit", + "Chart\nTitle" => "Název\ngrafu", + "Delete\nChart" => "Odstranit\ngraf", + "Convert\nto Range" => "Převést\nna rozsah", + "Table\nStyles" => "Styly\ntabulky", + "Top 10 Items" => "Prvních 10 položek", + "Top 10%" => "Prvních 10 %", + "Bottom 10 Items" => "Posledních 10 položek", + "Bottom 10%" => "Posledních 10 %", + "Above Average" => "Nad průměrem", + "Below Average" => "Pod průměrem", + "Clear Rules from Entire Sheet" => "Vymazat pravidla z celého listu", + "Clear Rules from Selected Cells" => "Vymazat pravidla z vybraných buněk", + "Constants" => "Konstanty", + "Data Bars" => "Datové pruhy", + "Color Scales" => "Barevné škály", + "Icon Sets" => "Sady ikon", + "Home" => "Domů", + "Draw" => "Kreslení", + "Page Layout" => "Rozložení stránky", + "Formulas" => "Vzorce", + "Data" => "Data", + "Review" => "Revize", + "View" => "Zobrazení", + "Automate" => "Automatizace", + "Table Design" => "Návrh tabulky", + "Chart Design" => "Návrh grafu", + "Ready" => "Připraveno", + "Enter" => "Zadávání", + "Edit" => "Úpravy", + "Point" => "Výběr odkazu", + "Calculate" => "Přepočítat", + "Select destination and press Enter or choose Paste" => "Vyberte cíl a stiskněte Enter nebo zvolte Vložit", + "General" => "Obecný", + "Number" => "Číslo", + "Currency" => "Měna", + "Accounting" => "Účetnický", + "Date" => "Datum", + "Time" => "Čas", + "Percentage" => "Procenta", + "Fraction" => "Zlomek", + "Scientific" => "Vědecký", + "Text" => "Text", + "Special" => "Speciální", + "Custom" => "Vlastní", + "Short Date" => "Krátké datum", + "Long Date" => "Dlouhé datum", + "More Number Formats…" => "Další formáty čísel…", + "Financial" => "Finanční", + "Logical" => "Logické", + "Date &\nTime" => "Datum a\nčas", + "Lookup &\nReference" => "Vyhledávání\na odkazy", + "Math &\nTrig" => "Matematika a\ntrigonometrie", + "More\nFunctions" => "Další\nfunkce", + "Sheet Options|View" => "Zobrazit", + "Print" => "Tisknout", + _ => return None, + }) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eecdf..99a996070 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -328,7 +328,7 @@ impl SheetApp { self.ui.language = l; Ok(json!({"language": l})) } - None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\" or \"pt\")")), + None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\" or \"cs\")")), } } "app.language.english" => { From f44b31e15443507e2a03d54cf741428d5f0d900c Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:36:33 +0200 Subject: [PATCH 076/102] i18n: cover Czech in the interface-language tests - The catalog-completeness test requires a non-empty Czech label for every key of the reference (Japanese) table. - Czech context keys: `Page Layout|Orientation` and `Orientation` get different labels; unknown text such as `Sheet1!A1` passes through. - `cs-CZ` / `cs_CZ.UTF-8` system tags and `parse("cs")` resolve to Czech. - The ribbon renders after switching to Czech, and the choice persists in UiState. - Czech keeps canonical formula entry (`=SUM(1.5,2.25)`), and `=SUMA(1;2)` is not accepted. - The UI font draws every Czech accented letter, upper and lower case, without tofu; Czech shares the default Han font order. Czech spells "Data" as English does, so the Czech checks probe "Formulas" -> "Vzorce", where an English fallback would be caught. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 10 ++++++++++ crates/ui-egui/src/theme.rs | 6 ++++-- crates/ui-egui/tests/formula_locale.rs | 16 ++++++++++++++++ crates/ui-egui/tests/interaction.rs | 7 +++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 2ff83990e..c55d9c889 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -440,6 +440,7 @@ mod tests { assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}"); assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}"); assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}"); + assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}"); } assert_eq!(Language::Ja.tr("Data"), "データ"); assert_eq!(Language::Zh.tr("Data"), "数据"); @@ -452,10 +453,16 @@ mod tests { assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1"); + // Czech "Data" is spelled as in English, so probe it with a label that differs. + assert_eq!(Language::Cs.tr("Data"), "Data"); + assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); + assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1"); // A `context|label` key shows only its label in English and splits the translation. assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation"); assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き"); assert_eq!(Language::Ja.tr("Orientation"), "方向"); + assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace"); + assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu"); assert_eq!(Language::parse("xx"), None); } @@ -486,6 +493,9 @@ mod tests { assert_eq!(Language::from_tag("pt-BR"), Some(Language::PtBr)); assert_eq!(Language::parse("pt"), Some(Language::PtBr)); assert_eq!(Language::parse("pt-br"), Some(Language::PtBr)); + assert_eq!(Language::from_tag("cs-CZ"), Some(Language::Cs)); + assert_eq!(Language::from_tag("cs_CZ.UTF-8"), Some(Language::Cs)); + assert_eq!(Language::parse("cs"), Some(Language::Cs)); } #[test] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 219e2d289..25b9ca878 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -587,7 +587,7 @@ mod tests { assert_eq!(keys(HanOrder::Japanese), ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); assert_eq!(keys(HanOrder::Chinese), ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); - for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr] { + for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::Cs] { assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); } } @@ -613,13 +613,15 @@ mod tests { // Real render check, per script: the proportional family (what cells and the UI use) must // resolve a sample string to a real glyph, not the tofu replacement char. Skips a script // whose face is absent (a minimal machine) and fails if a face is present but not wired in. - let cases: [(&str, Vec<(String, u32)>, &str); 6] = [ + let cases: [(&str, Vec<(String, u32)>, &str); 7] = [ ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), ("sys-cjk-hangul", cjk_hangul(), "한글"), ("sys-thai", thai(), "ทดสอบ"), ("sys-hebrew", hebrew(), "עברית"), ("ui", candidates(UI), "Русский"), + // Czech interface labels: every Czech accented letter, in both cases. + ("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), ]; for language in [Language::En, Language::Ja] { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/formula_locale.rs index a92ab1370..6ce22082d 100644 --- a/crates/ui-egui/tests/formula_locale.rs +++ b/crates/ui-egui/tests/formula_locale.rs @@ -185,3 +185,19 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() { assert_eq!(restored.language, Language::PtBr); assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientation"); } + +#[test] +fn czech_interface_retains_its_canonical_formula_fallback() { + // The Czech table translates interface labels only: formulas keep canonical English names, + // `,` separators and `.` decimals until a Czech formula locale exists. + let mut app = app(); + app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap(); + assert_eq!(app.ui.language, Language::Cs); + assert_eq!(app.ui.language.tr("Formulas"), "Vzorce"); + assert!(enter(&mut app, "=SUM(1.5,2.25)")); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); + assert!(!enter(&mut app, "=SUMA(1;2)")); + let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); + assert_eq!(restored.language, Language::Cs); +} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 84f20a675..1b71c82f1 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -209,6 +209,13 @@ fn language_switch_translates_the_ribbon_and_persists() { let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); assert_eq!(restored.language, language); } + // Czech spells "Data" as English does, so the Czech table is checked on a label that differs. + h.state_mut().run("app.language.set", json!({"language": "cs"})).unwrap(); + h.run_steps(2); + assert_eq!(h.state().ui.language, Language::Cs); + assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); + let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); + assert_eq!(restored.language, Language::Cs); h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap(); assert_eq!(h.state().ui.language, Language::En); } From 509afaf4cfef5e2b903e772ca12aaabe2e329855 Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:36:55 +0200 Subject: [PATCH 077/102] docs: list Czech (cs) in the localization status Czech joins Russian under "other shipped languages" in ROADMAP.md and docs/localization-parity.md: 266 ribbon labels (~12%), menus only, formulas still en-US. Timestamp lines and revision history updated. Kept separate from the catalog so it can be dropped if the docs are refreshed upstream. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 5 +++-- docs/localization-parity.md | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..8817691dd 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-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Excel for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Czech ribbon table listed under other shipped languages) · **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. @@ -110,7 +110,7 @@ is in PR #163. Detail: [localization-parity.md](docs/localization-parity.md). | Korean | ko | menus only | ~12% | | Vietnamese | vi | none | 0% | -Other shipped languages: 1 (Russian, menus only, ~12%). +Other shipped languages: 2 (Russian and Czech, menus only, ~12% each). ## Upcoming @@ -142,6 +142,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Czech (cs) listed under other shipped languages: ribbon only, ~12% | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 3a8a991e1..17a5efc77 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-10 · **Change:** minor (re-measured after the 10-10 language and font-fallback merges) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Czech ribbon table added) · **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, @@ -12,7 +12,7 @@ right-to-left sheets and full complex-script support. Part of - **A first catalog exists, for the ribbon only.** `crates/ui-egui/src/i18n.rs` (merged 10-10 in #31, #104, #82) adds an interface-language preference that defaults to the system language (closes #20) and a View-tab switcher, with English-keyed lookup tables: Japanese 268 labels, - Simplified Chinese, Korean, Russian and Brazilian Portuguese 266 each. The tables are used by + Simplified Chinese, Korean, Russian, Brazilian Portuguese and Czech 266 each. The tables are used by the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`); dialogs, menus, context menus, task panes, status messages, command labels and function descriptions are still English literals. Untranslated labels fall back to English. @@ -51,7 +51,8 @@ separators, number/date formats, machine translation with agent review, and scri | Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME unverified | no | menus only | 6–10 h | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics: unverified | no | none | 6–10 h | -Other shipped languages: Russian (ru), 266 labels (~12%), menus only. +Other shipped languages: Russian (ru) and Czech (cs), 266 labels each (~12%), menus only. Czech +formulas stay en-US (no Czech function names, `;` separator or decimal comma yet). **Total:** catalog work 10–20 h + languages 92–144 h ≈ **100–165 h**. Parallelizes almost fully once the catalog covers dialogs and commands. **Needs a human:** native-speaker review of every @@ -73,5 +74,6 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Czech (cs) ribbon table: 266 labels (~12%), menus only | | 2026-10-10 | minor | Re-measured after #31, #104, #82, #38 merged: ribbon translated into ja, zh, ko, pt-BR and ru (266–268 labels, ~12%), system-language default, CJK/Thai/Hebrew font fallback; ~10% ready, 100–165 h | | 2026-10-10 | major | Created: English only, no catalog, ~2,200 strings, script-support gaps, open language PRs | From 5f346dc3ccdc5682627a8b7609628bd8e25e3cca Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:47:02 +0200 Subject: [PATCH 078/102] i18n (cs): terminology review fixes An independent review of the table found two meaning errors: "Enter" is both the status-bar mode and the tooltip of the confirm button next to the formula bar, so "Zadat" fits both places better than "Zadavani"; "Convert to Range" uses "oblast" (as "Oblast tisku" does), not "rozsah". Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 1f547931c..5b56b209e 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -215,7 +215,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines: Show" => "Mřížka: zobrazit", "Chart\nTitle" => "Název\ngrafu", "Delete\nChart" => "Odstranit\ngraf", - "Convert\nto Range" => "Převést\nna rozsah", + "Convert\nto Range" => "Převést\nna oblast", "Table\nStyles" => "Styly\ntabulky", "Top 10 Items" => "Prvních 10 položek", "Top 10%" => "Prvních 10 %", @@ -240,7 +240,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Table Design" => "Návrh tabulky", "Chart Design" => "Návrh grafu", "Ready" => "Připraveno", - "Enter" => "Zadávání", + "Enter" => "Zadat", "Edit" => "Úpravy", "Point" => "Výběr odkazu", "Calculate" => "Přepočítat", From a9e743ee6d667be60a326a7b099ad826eda5b7af Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:47:13 +0200 Subject: [PATCH 079/102] contributors: add my own people.toml entry Own entry only, as AGENTS.md describes: credited by the name on my public GitHub profile (sync_github_name), otherwise by @username. Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 78683ae55..8086cf13a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -28,3 +28,6 @@ sync_github_name = false [people.edurbs] sync_github_name = true + +[people.Losminek] +sync_github_name = true From 206d83763916f444971c2ddca7a5a19e9e5e5946 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 16:48:43 +0200 Subject: [PATCH 080/102] Grid: header drags resize every Ctrl-selected row or column (#341) Dragging a header boundary inside a selection of whole columns (rows) sized only the current area, so with B, D and F Ctrl-selected only the last-selected one changed. The drag now targets every whole-column (whole-row) area of the selection, as Excel does; lines outside the selection, and partial-cell areas, keep their size. home.columnWidth / home.rowHeight accept a comma-separated list of areas ("B:B,D:F") so the drag stays one command and one undo step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/format.rs | 14 +++++++--- crates/engine/src/tests.rs | 24 ++++++++++++++++ crates/ui-egui/src/grid.rs | 11 ++++++-- crates/ui-egui/tests/header_autofit.rs | 38 ++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 4f13bf9c6..2b6e50b7f 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -156,7 +156,7 @@ pub fn specs() -> Vec { "Row Height…", ["Home", "Cells", "Format"], None, - "{rows?: \"2:5\", height: 20 (points on screen)}", + "{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}", has_doc, row_height ), @@ -165,7 +165,7 @@ pub fn specs() -> Vec { "Column Width…", ["Home", "Cells", "Format"], None, - "{cols?: \"B:D\", width: 64 (points) | chars: 8.43}", + "{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}", has_doc, col_width ), @@ -910,8 +910,14 @@ fn cell_style(s: &mut Session, p: &Json) -> Result { fn line_targets(s: &Session, p: &Json, rows: bool) -> Result> { let key = if rows { "rows" } else { "cols" }; if let Some(t) = str_param(p, key) { - let r = RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?; - return Ok(vec![if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }]); + // A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection. + return t + .split(',') + .map(|part| { + let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?; + Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }) + }) + .collect(); } Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55e..3c08ee085 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1633,3 +1633,27 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.hyperlinks, before.hyperlinks); assert_eq!(after.cells, before.cells); } + +#[test] +fn row_height_and_column_width_take_a_list_of_areas() { + // Issue #341: header drags on a multi-area selection size every selected line in one step. + let mut s = s(); + s.execute("home.columnWidth", json!({"cols": "B:B,D:E", "width": 120})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "2:2,4:5", "height": 40})).unwrap(); + let widths: Vec = (0..6).map(|c| s.doc().unwrap().wb.active().unwrap().col_width(c)).collect(); + let heights: Vec = (0..6).map(|r| s.doc().unwrap().wb.active().unwrap().row_height(r)).collect(); + for c in [1, 3, 4] { + assert_eq!(widths[c], 120.0, "column {c}"); + } + for c in [0, 2, 5] { + assert_ne!(widths[c], 120.0, "column {c}"); + } + for r in [1, 3, 4] { + assert_eq!(heights[r], 40.0, "row {r}"); + } + for r in [0, 2, 5] { + assert_ne!(heights[r], 40.0, "row {r}"); + } + assert!(s.execute("home.columnWidth", json!({"cols": "B:B,nope", "width": 50})).is_err()); + assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(1), 120.0, "a bad list changes nothing"); +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..d228e515d 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1359,8 +1359,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let w = (orig + (p.x - start) / geo.z).max(0.0); app.toast = Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms())); + // Inside a selection of whole columns, every selected column takes the new width + // (Ctrl-selected areas included); otherwise only the dragged one. let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) { - sel.current().a1() + sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::>().join(",") } else { RangeRef::cols(col, col).a1() }; @@ -1371,7 +1373,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let h = (orig + (p.y - start) / geo.z).max(0.0); app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms())); let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) { - format!("{}:{}", sel.current().start.row + 1, sel.current().end.row + 1) + sel.ranges + .iter() + .filter(|r| r.is_full_rows()) + .map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1)) + .collect::>() + .join(",") } else { format!("{}:{}", row + 1, row + 1) }; diff --git a/crates/ui-egui/tests/header_autofit.rs b/crates/ui-egui/tests/header_autofit.rs index 8a6673855..7d1bbbc0f 100644 --- a/crates/ui-egui/tests/header_autofit.rs +++ b/crates/ui-egui/tests/header_autofit.rs @@ -114,3 +114,41 @@ fn dragging_a_header_boundary_still_resizes_normally() { assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.autofit"))); } } + +#[test] +fn dragging_a_boundary_inside_a_multi_area_selection_resizes_every_selected_line() { + // Issue #341: Ctrl-selected rows/columns resize together; unselected lines keep their size. + for (rows, selection, dragged, changed) in [ + (false, "A:A,C:C", 2, [true, false, true, false]), + (false, "A:A,C:C", 0, [true, false, true, false]), + (false, "A:A,C:D", 3, [true, false, true, true]), + (false, "A:A,C:C", 1, [false, true, false, false]), + (false, "A:A,C3", 0, [true, false, false, false]), + (true, "1:1,3:3", 2, [true, false, true, false]), + (true, "1:1,3:3", 0, [true, false, true, false]), + (true, "1:1,3:4", 3, [true, false, true, true]), + (true, "1:1,3:3", 1, [false, true, false, false]), + (true, "1:1,C3", 0, [true, false, false, false]), + ] { + let mut h = harness(selection); + let before = sizes(&h, rows); + let (start, zoom) = edge(&h, rows, dragged); + let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) }; + button(&mut h, start, true); + h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(start + delta)); + h.step(); + button(&mut h, start + delta, false); + h.run_steps(2); + let after = sizes(&h, rows); + let expected = (before[dragged as usize] + 30.0 / zoom).round(); + for (index, changed) in changed.iter().enumerate() { + let want = if *changed { expected } else { before[index] }; + assert_eq!(after[index], want, "selection {selection}, dragged {dragged}, line {index}"); + } + assert_eq!(h.state().session.active().unwrap().undo.len(), 1, "selection {selection}: one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sizes(&h, rows), before, "selection {selection}: undo restores every line"); + } +} From f7f7769460e6589a062aa4c930fa4b8db4534a2b Mon Sep 17 00:00:00 2001 From: Sasha Syrotenko Date: Sun, 11 Oct 2026 17:50:23 +0300 Subject: [PATCH 081/102] Fold 'SUM', 'COUNT' and 'AVERAGE' over ranges in place --- crates/calc/src/eval.rs | 123 ++++++++++++++++++++++++++ crates/calc/src/recalc.rs | 65 +++++++++++++- crates/calc/src/tests.rs | 182 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index eff659d98..bf5c97196 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -71,6 +71,11 @@ pub trait Host { fn used_range(&mut self, sheet: usize) -> Option { self.workbook().sheet(sheet).and_then(|s| s.used_range()) } + /// Calls `f` on each stored value of a block, row-major (formula cells evaluated first), + /// until `f` returns false. `false` when this host can't (callers use `range_values`). + fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool { + false + } } pub struct Evaluator<'h> { @@ -726,6 +731,9 @@ impl<'h> Evaluator<'h> { if args.len() < spec.min_args || args.len() > spec.max_args { return err(CellError::Value); } + if let Some(kind) = Agg::of(spec.name) { + return self.aggregate(kind, args); + } 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; @@ -762,6 +770,70 @@ impl<'h> Evaluator<'h> { gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) } + /// Performs aggregation operations without copying its ranges: blocks are folded over the stored + /// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one + /// after another, as the array path does, so volatile formulas (like `RAND`) see the same order. + /// Only `SUM`, `COUNT` and `AVG` are supported currently. + fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev { + let mut acc = Acc::default(); + let mut first_error = None; + for a in args { + let ev = self.eval(a); + let steps = match ev { + Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(), + ev => { + let value = self.deref(ev); + vec![self.aggregate_value(kind, value, false, &mut acc)] + } + }; + if first_error.is_none() { + first_error = steps.into_iter().find_map(Result::err); + } + } + first_error.unwrap_or_else(|| Ev::V(acc.finish(kind))) + } + + /// Adds one area to `acc`; `Err` is the result the function ends with. + fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> { + if !a.range.is_single() { + let mut error = None; + let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) { + Ok(()) => true, + Err(e) => { + error = Some(e); + false + } + }); + if walked { + return error.map_or(Ok(()), |e| Err(err(e))); + } + } + let value = self.area_value(a); + self.aggregate_value(kind, value, true, acc) + } + + /// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules + /// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`). + fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> { + let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else { + return Err(err(CellError::Name)); + }; + let args = [Arg { value, from_ref }]; + if kind != Agg::Count { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) { + 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 }) { + Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), + other => return Err(Ev::V(other)), + } + } + Ok(()) + } + pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { if args.len() > l.params.len() { return err(CellError::Value); @@ -1239,6 +1311,57 @@ pub fn r1c1_to_a1(s: &str, at: CellRef) -> Option { Some(format!("{sheet}{out}")) } +/// The aggregates that can fold a reference without building an array. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Agg { + Sum, + Count, + Average, +} + +impl Agg { + fn of(name: &str) -> Option { + match name { + "SUM" => Some(Agg::Sum), + "COUNT" => Some(Agg::Count), + "AVERAGE" => Some(Agg::Average), + _ => None, + } + } +} + +/// What a folded aggregate carries from one value to the next. +#[derive(Default)] +struct Acc { + sum: f64, + count: u64, +} + +impl Acc { + /// Takes a value found inside a reference: only numbers count. `Err` is an error value that + /// ends `SUM` and `AVERAGE`; `COUNT` skips errors. + fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> { + match v { + Value::Number(n) => { + self.sum += n; + self.count = self.count.saturating_add(1); + Ok(()) + } + Value::Error(e) if kind != Agg::Count => Err(*e), + _ => Ok(()), + } + } + + fn finish(&self, kind: Agg) -> Value { + match kind { + Agg::Sum => Value::number(self.sum), + Agg::Count => Value::number(self.count as f64), + Agg::Average if self.count == 0 => Value::Error(CellError::Div0), + Agg::Average => Value::number(self.sum / self.count as f64), + } + } +} + // ---------------------------------------------------------------- operators fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool) -> Value { diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 4cf0c6a21..74c406b25 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -598,6 +598,30 @@ impl Host for PassHost<'_> { } out } + fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool { + let wb = self.wb; + let Some(sh) = wb.sheet(sheet) else { return false }; + // Formula cells first, in row-major order like `range_values`: reading one calculates it + // and the pass remembers the value, so the walk below gets the same value again without + // this method holding any. + for (c, cell) in sh.cells.iter_range(range) { + if cell.formula.is_some() { + let _ = self.cell_value(sheet, c); + } + } + // Spilled values sit between the stored cells (and the formulas just calculated may have + // produced some): leave such blocks to `range_values`. + if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) { + return false; + } + for (c, cell) in sh.cells.iter_range(range) { + let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) }; + if !go { + break; + } + } + true + } fn used_range(&mut self, sheet: usize) -> Option { let wb = self.wb; *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) @@ -974,13 +998,13 @@ fn prof_now() -> f64 { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use std::time::{Duration, Instant}; use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{Cell, Formula, Workbook}; - use super::{Calc, Intervals, Key}; + use super::{Calc, HashMap, HashSet, Host, Intervals, Key, PassHost}; /// xorshift64 for repeatable pseudo-random cases. fn rng(state: &mut u64) -> u64 { @@ -1181,4 +1205,41 @@ mod tests { calc.graph.dependents(1, CellRef::parse("A2").unwrap(), &mut deps); assert_eq!(deps, vec![at("DA9"), at("DA9")]); } + + /// For tests of `PassHost::visit_range`, which can't be reached from outside this module: the + /// values it hands over for `range` while the formulas in `pending` (cell, formula) have not been + /// calculated yet, or `None` when it declines the block. + pub(crate) fn probe_visit_range(wb: &Workbook, pending: &[(&str, &str)], range: RangeRef) -> Option> { + let mut exprs = HashMap::default(); + for (at, formula) in pending { + let expr = gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).ok()?; + exprs.insert((0, CellRef::parse(at)?), std::sync::Arc::new(expr)); + } + + // A cell in `pending` is calculated the first time something reads it, which is the state + // of a real pass partway through; `visit_range` has to cope with the formulas it reads + // being calculated under it. + let mut rng = 1u64; + let mut host = PassHost { + wb, + pending: exprs.keys().copied().collect(), + exprs: &exprs, + results: HashMap::default(), + in_progress: HashSet::default(), + spills: HashMap::default(), + used: HashMap::default(), + rng: &mut rng, + now: 0.0, + cycle: false, + cycles: vec![], + depth: 0, + }; + + let mut seen = Vec::new(); + host.visit_range(0, range, &mut |v| { + seen.push(v.clone()); + true + }) + .then_some(seen) + } } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index d1e8576dc..5fb6410c7 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -467,3 +467,185 @@ fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() { assert_eq!(t.num("C1"), 10.0); assert_eq!(t.num("C2"), 5.0); } + +/// `formula` in AB1 over a small mixed sheet. The ranges in the cases below are cut from it: +/// +/// ```text +/// A B C E +/// 1 1.5 10 =A1*4 (C1 is a formula cell inside the ranges) +/// 2 2 TRUE #DIV/0! (E2 only when `error` is set) +/// 3 text 3 +/// 4 -0.25 +/// ``` +fn aggregate_of(error: bool, formula: &str) -> Value { + let mut t = T::new(); + for (at, v) in [("A1", "1.5"), ("A2", "2"), ("A3", "skipped"), ("A4", "-0.25"), ("B1", "10"), ("B2", "TRUE"), ("B3", "3"), ("C1", "=A1*4")] { + t.set(at, v); + } + if error { + t.set("E2", "#DIV/0!"); + } + + // AB1 is outside every range used, so the formula can't read itself. + t.set("AB1", formula); + t.get("AB1") +} + +#[test] +fn sum_count_average_over_ranges() { + // Text, booleans, blanks and a formula cell inside ranges; direct arguments; unions; whole + // columns; blocks holding an error or nothing numeric. The expected values are what the + // array path (the ranges copied into an array) gave before the ranges were folded in place, + // so any difference is a change of behavior. + let n = Value::Number; + let e = Value::Error; + + // (formula written with SUM, error in E2?, [SUM, COUNT, AVERAGE] of that formula) + #[rustfmt::skip] + let cases: &[(&str, bool, [Value; 3])] = &[ + ("=SUM(A1:A1,B3)", false, [n(4.5), n(2.0), n(2.25)]), + ("=SUM(A1:A1,B3)", true, [n(4.5), n(2.0), n(2.25)]), + ("=SUM((A1:A2,B1:B3))", false, [n(16.5), n(4.0), n(4.125)]), + ("=SUM((A1:A2,B1:B3))", true, [n(16.5), n(4.0), n(4.125)]), + ("=SUM(A1:A4,B1:B3)", false, [n(16.25), n(5.0), n(3.25)]), + ("=SUM(A1:A4,B1:B3)", true, [n(16.25), n(5.0), n(3.25)]), + ("=SUM(A1:A4,\"x\")", false, [e(CellError::Value), n(3.0), e(CellError::Value)]), + ("=SUM(A1:A4,\"x\")", true, [e(CellError::Value), n(3.0), e(CellError::Value)]), + ("=SUM(A1:B4,5,\"7\",TRUE)", false, [n(29.25), n(8.0), n(3.65625)]), + ("=SUM(A1:B4,5,\"7\",TRUE)", true, [n(29.25), n(8.0), n(3.65625)]), + ("=SUM(A1:C4)+SUM(B1:B3)", false, [n(35.25), n(8.0), n(10.208333333333334)]), + ("=SUM(A1:C4)+SUM(B1:B3)", true, [n(35.25), n(8.0), n(10.208333333333334)]), + ("=SUM(A1:C4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A1:C4)", true, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A1:E4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A1:E4)", true, [e(CellError::Div0), n(6.0), e(CellError::Div0)]), + ("=SUM(A1)", false, [n(1.5), n(1.0), n(1.5)]), + ("=SUM(A1)", true, [n(1.5), n(1.0), n(1.5)]), + ("=SUM(A3,B2)", false, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(A3,B2)", true, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(A:C)", false, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A:C)", true, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(E1:E3)", false, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(E1:E3)", true, [e(CellError::Div0), n(0.0), e(CellError::Div0)]), + ("=SUM(Z1:Z9)", false, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(Z1:Z9)", true, [n(0.0), n(0.0), e(CellError::Div0)]), + ]; + + for (formula, error, want) in cases { + for (func, want) in ["SUM", "COUNT", "AVERAGE"].into_iter().zip(want) { + // The same formula for each function: `SUM(` becomes `COUNT(` or `AVERAGE(`. + let f = formula.replace("SUM", func); + + assert_eq!(&aggregate_of(*error, &f), want, "{f} (error in E2: {error})"); + } + } +} + +#[test] +fn sum_count_average_have_no_size_limit() { + // One stored cell far down stretches the used range to 27 million cells (27 columns by a + // million rows), past the 16M-cell limit of ranges copied into an array. Only two cells are + // stored, so the sheet itself is tiny. + for (func, want) in [("SUM", Value::Number(2.5)), ("COUNT", Value::Number(2.0)), ("AVERAGE", Value::Number(1.25))] { + for range in ["A1:AA1000000", "A:AA"] { + let mut t = T::new(); + t.set("A1", "1.5"); + t.set("Z1000000", "1"); + + t.set("AB1", &format!("={func}({range})")); + + assert_eq!(t.get("AB1"), want, "{func}({range})"); + } + } +} + +#[test] +fn sum_count_average_over_spilled_values() { + // A1 spills over A1:A3, so the block holds values that exist only as a spill (they have no + // stored cell of their own). The host must not fold such a block in place: it falls back + // to the array path, which sees the spill. Expected: 1 + 2 + 3 + 10. + let mut t = T::new(); + t.set("A1", "=SEQUENCE(3)"); + t.set("A4", "10"); + + for (f, want) in [("SUM", 16.0), ("COUNT", 4.0), ("AVERAGE", 4.0)] { + t.set("C1", &format!("={f}(A1:A5)")); + + assert_eq!(t.num("C1"), want, "{f}"); + } +} + +#[test] +fn aggregate_arguments_are_read_in_order() { + // Volatile cells draw a random number each time they are calculated, so the order in which + // the arguments are read decides which draw each one gets. `B1:B2` comes first, so B2 must + // draw before the `RAND()` argument does. (Reading the plain `RAND()` first once gave + // 7.9043 here.) The expected values are what the array path gave; they depend on the random + // generator and its seed, and must be updated if either changes. + let mut t = T::new(); + t.set("B1", "1"); + t.set("B2", "=RAND()*10"); + + t.set("A1", "=SUM(B1:B2,RAND())"); + + assert_eq!((t.num("A1"), t.num("B2")), (4.968520165759784, 3.3112028100185356)); +} + +#[test] +fn sum_count_average_follow_edits_inside_the_range() { + // A range read in place must still count as a precedent of the formula: if it didn't, an + // edit inside it would leave the formula showing a stale value. Each step below changes one + // cell inside the range and checks all three functions; the last one changes a cell outside. + for range in ["A1:A5", "A:A"] { + let mut t = T::new(); + t.set("A1", "1"); + t.set("A3", "3"); + t.set("C1", &format!("=SUM({range})")); + t.set("C2", &format!("=COUNT({range})")); + t.set("C3", &format!("=AVERAGE({range})")); + let all = |t: &T| (t.num("C1"), t.num("C2"), t.num("C3")); + assert_eq!(all(&t), (4.0, 2.0, 2.0), "{range}"); + + t.set("A1", "5"); + assert_eq!(all(&t), (8.0, 2.0, 4.0), "{range}: edit"); + + t.set("A2", "10"); + assert_eq!(all(&t), (18.0, 3.0, 6.0), "{range}: fill a blank"); + + t.set("A3", "text"); + assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: number to text"); + + t.set("A2", ""); + assert_eq!(all(&t), (5.0, 1.0, 5.0), "{range}: clear"); + + t.set("A5", "=A1*2"); + assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: formula in the range"); + + t.set("A1", "1"); + assert_eq!(all(&t), (3.0, 2.0, 1.5), "{range}: its precedent changes"); + + // A7 is inside the whole column A:A but outside A1:A5. + t.set("A7", "100"); + let outside = if range == "A:A" { (103.0, 3.0, 103.0 / 3.0) } else { (3.0, 2.0, 1.5) }; + assert_eq!(all(&t), outside, "{range}: a cell outside the range"); + } +} + +#[test] +fn visit_range_declines_when_a_formula_in_the_block_spills_into_it() { + // The sheet: D1 = SEQUENCE(3) and D4 = 10, and D1 has not been calculated yet when the + // block D1:D5 is read. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.cells.set(c("D1"), Cell::formula(Formula::new("=SEQUENCE(3)"))); + sh.cells.set(c("D4"), Cell::value(Value::Number(10.0))); + let block = gridcraft_core::RangeRef::parse("D1:D5").unwrap(); + + // Reading the block calculates D1, which spills over D2:D3. A walk over the stored cells + // would never see 2 and 3, so the host must decline and leave the block to `range_values`. + // (`Calc` recalculates a pass that produced new spills, so final results are right either + // way; only this direct call shows the host's contract.) + let walked = crate::recalc::tests::probe_visit_range(&wb, &[("D1", "=SEQUENCE(3)")], block); + + assert_eq!(walked, None, "walked a block holding a spill it can't see"); +} From a8eb09604d44ef516f40293f0be19f2a029257cf Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 16:55:13 +0200 Subject: [PATCH 082/102] Review: mask the Protect / Unprotect Sheet password (#347) The password fields in Protect Sheet and Unprotect Sheet were plain text fields, so the password was readable on screen and in the accessibility tree. A new Password form field masks it as typed and adds a "Show password" check box that reveals it on purpose (off each time the dialog opens). These are the only password fields in the UI: Protect Workbook runs without a dialog and encrypted files are refused on open. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 20 ++++++- crates/ui-egui/tests/protect_password.rs | 70 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/protect_password.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb10..3c75d1a82 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -13,6 +13,7 @@ use crate::theme; #[derive(Clone, Debug, PartialEq)] pub enum Field { Text { key: &'static str, label: &'static str }, + Password { key: &'static str, label: &'static str }, Number { key: &'static str, label: &'static str }, Choice { key: &'static str, label: &'static str, options: Vec<(&'static str, &'static str)> }, Check { key: &'static str, label: &'static str }, @@ -31,6 +32,8 @@ pub struct Dialog { pub search: String, pub list_index: usize, pub result: Option, + /// "Show password" is ticked: password fields show their text. + pub show_password: bool, picture_inbox: Option, picture_target: Option<(u64, usize)>, } @@ -51,6 +54,7 @@ impl Dialog { search: String::new(), list_index: 0, result: None, + show_password: false, picture_inbox: None, picture_target: None, } @@ -67,6 +71,7 @@ impl Dialog { search: String::new(), list_index: 0, result: None, + show_password: false, picture_inbox: None, picture_target: None, } @@ -151,7 +156,7 @@ impl Dialog { "Protect Sheet", "review.protectSheet", vec![ - Text { key: "password", label: "Password (optional):" }, + Password { key: "password", label: "Password (optional):" }, Note("Allow all users of this sheet to:".into()), Check { key: "formatCells", label: "Format cells" }, Check { key: "formatColumns", label: "Format columns" }, @@ -167,7 +172,7 @@ impl Dialog { "unprotectSheet", "Unprotect Sheet", "review.unprotectSheet", - vec![Text { key: "password", label: "Password:" }], + vec![Password { key: "password", label: "Password:" }], json!({}), ), "unhideSheet" => { @@ -989,6 +994,17 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) } }); } + Field::Password { key, label } => { + // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. + ui.vertical(|ui| { + ui.label(label); + let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string(); + if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).changed() { + d.values.insert(key.into(), json!(s)); + } + ui.checkbox(&mut d.show_password, "Show password"); + }); + } Field::Number { key, label } => { ui.horizontal(|ui| { ui.label(label); diff --git a/crates/ui-egui/tests/protect_password.rs b/crates/ui-egui/tests/protect_password.rs new file mode 100644 index 000000000..dc2b1c59a --- /dev/null +++ b/crates/ui-egui/tests/protect_password.rs @@ -0,0 +1,70 @@ +//! Protect Sheet / Unprotect Sheet mask the password as it's typed (issue #347). + +use egui::accesskit::Role; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +const SECRET: &str = "s3cret"; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + 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(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn type_password(h: &mut egui_kittest::Harness<'static, SheetApp>) { + h.get_by_role(Role::PasswordInput).click(); + h.run_steps(2); + h.input_mut().events.push(egui::Event::Text(SECRET.into())); + h.run_steps(2); +} + +fn secret_on_screen(h: &egui_kittest::Harness<'static, SheetApp>) -> bool { + h.query_all_by(|n| n.value().is_some_and(|v| v.contains(SECRET)) || n.label().is_some_and(|l| l.contains(SECRET))).next().is_some() +} + +#[test] +fn protect_and_unprotect_sheet_mask_the_password() { + let mut h = harness(); + h.state_mut().open_dialog("protectSheet", json!({})); + h.run_steps(3); + type_password(&mut h); + assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("password")), Some(&json!(SECRET))); + assert!(!secret_on_screen(&h), "the typed password must not be readable"); + assert!(h.get_by_role(Role::PasswordInput).value().is_some_and(|v| !v.is_empty() && !v.contains(SECRET))); + + // Show password reveals it on purpose; unticking masks it again. + h.get_by_label("Show password").click(); + h.run_steps(3); + assert!(h.query_by_role(Role::PasswordInput).is_none()); + assert!(secret_on_screen(&h)); + h.get_by_label("Show password").click(); + h.run_steps(3); + assert!(h.query_by_role(Role::PasswordInput).is_some()); + assert!(!secret_on_screen(&h)); + + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password_hash.is_some())); + + // Unprotect asks for the password with a masked field too, and it starts hidden again. + h.state_mut().open_dialog("unprotectSheet", json!({})); + h.run_steps(3); + type_password(&mut h); + assert!(!secret_on_screen(&h), "the typed password must not be readable"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.is_none()); +} From d665499055deb924903e625748f07e9b4042fbd6 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 17:02:39 +0200 Subject: [PATCH 083/102] Clipboard: copy lined-up multiple selections; say why others can't be copied (#348) Copying a multi-area selection always failed in the engine, and the UI dropped the error, so Ctrl+C did nothing and Ctrl+V had nothing to paste. Now, as in Excel: - Areas that cover the same rows (or the same columns) and don't overlap are copied as one block, slid together left to right (top to bottom). The clipboard keeps a snapshot with the block packed at the first area's place; formulas travel as their values, and column widths or row heights, comments, pictures and merges come along. - Other layouts, Cut of several areas and Paste Link of a multi-area copy are refused with "This action won't work on multiple selections."; Copy and Cut from the keyboard, ribbon and context menus now show that message instead of failing silently. - The marching ants go around each copied area. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 111 +++++++++++++++++++++++++++--- crates/engine/src/lib.rs | 3 + crates/engine/src/tests.rs | 61 ++++++++++++++++ crates/ui-egui/src/grid.rs | 12 ++-- crates/ui-egui/src/lib.rs | 20 ++++-- crates/ui-egui/tests/clipboard.rs | 19 +++++ 6 files changed, 205 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb54fe03c..d1d6837b5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -604,25 +604,34 @@ fn redo(s: &mut Session, p: &Json) -> Result { // ---------------------------------------------------------------- clipboard fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { + let multiple = || EngineError::Other("This action won't work on multiple selections.".into()); let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; - if ranges.len() > 1 { - return Err(EngineError::Other("This action won't work on multiple selections.".into())); + if ranges.len() > 1 && cut { + return Err(multiple()); } - let range = ranges.first().copied().unwrap_or_default(); let d = s.doc()?; let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; - let range = if range.is_full_cols() || range.is_full_rows() { - let used = sh.used_range().unwrap_or(RangeRef::cell(range.start)); - range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range) - } else { - range + let trim = |range: RangeRef| { + if range.is_full_cols() || range.is_full_rows() { + let used = sh.used_range().unwrap_or(RangeRef::cell(range.start)); + range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range) + } else { + range + } }; - let text = crate::display::range_text(&d.wb, sheet, range); + let (wb, range, areas) = if ranges.len() > 1 { + let areas: Vec = ranges.iter().map(|r| trim(*r)).collect(); + let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?; + (std::sync::Arc::new(wb), block, areas) + } else { + (d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new()) + }; + let text = crate::display::range_text(&wb, sheet, range); // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and // scripts get it only when they ask for it. - let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() }; + let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; + let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas }; s.clipboard = Some(clip); let mut result = json!({"range": range.a1(), "text": text}); if let Some(html) = html { @@ -631,6 +640,83 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { Ok(result) } +/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they +/// slide together side by side, left to right) or all cover the same columns (stacked, top to +/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed +/// into one block at the first area's place, and that block. Formulas travel as their values, as +/// in Excel. `None` when the areas don't line up. +fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> { + let first = *areas.first()?; + let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row); + let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col); + let mut sorted = areas.to_vec(); + if across { + sorted.sort_by_key(|a| a.start.col); + } else if down { + sorted.sort_by_key(|a| a.start.row); + } else { + return None; + } + if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) { + return None; + } + let anchor = sorted.first()?.start; + let (h, w) = if across { + (first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add)) + } else { + (sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width()) + }; + let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?); + let src = wb.sheet(sheet)?; + let mut out = wb.clone(); + let dst = out.sheet_mut(sheet)?; + dst.take_cells(block); + dst.spill.retain(|c, _| !block.contains(*c)); + dst.spill_ranges.retain(|c, _| !block.contains(*c)); + dst.comments.retain(|c, _| !block.contains(*c)); + dst.merges.retain(|m| !m.intersects(&block)); + if across { + dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c)); + } else { + dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r)); + } + let mut next = if across { anchor.col } else { anchor.row }; + for a in &sorted { + let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) }; + let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c))); + for c in cells { + let Some(to) = c.offset(dr, dc) else { continue }; + let style = src.cell(c).map(|x| x.style).unwrap_or_default(); + dst.set_cell(to, Cell { value: src.value(c), formula: None, style }); + if let Some(picture) = src.cell_pictures.get(&c) { + dst.cell_pictures.insert(to, picture.clone()); + } + } + for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) { + if let Some(to) = c.offset(dr, dc) { + dst.comments.insert(to, comment.clone()); + } + } + for m in src.merges.iter().filter(|m| a.contains_range(m)) { + if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { + dst.merges.push(RangeRef::new(s, e)); + } + } + // Column widths (side by side) or row heights and hidden rows (stacked) follow their cells. + if across { + for (c, info) in src.cols.range(a.start.col..=a.end.col) { + dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info); + } + } else { + for (r, info) in src.rows.range(a.start.row..=a.end.row) { + dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info); + } + } + next = next.saturating_add(if across { a.width() } else { a.height() }); + } + Some((out, block)) +} + fn copy(s: &mut Session, p: &Json) -> Result { copy_impl(s, p, false) } @@ -769,6 +855,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false); let transpose = bool_param(p, "transpose").unwrap_or(false); let link = bool_param(p, "link").unwrap_or(false); + if link && !clip.areas.is_empty() { + return Err(EngineError::Other("This action won't work on multiple selections.".into())); + } let dest_sheet = target_sheet(s, p)?; let sel = s.doc()?.selection.current(); let at = cell_param(p, "at").unwrap_or(sel.start); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de786a1f4..267ef837b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -70,6 +70,9 @@ pub struct Clipboard { pub doc_uid: u64, /// Plain text (tab-separated) for the system clipboard. pub text: String, + /// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid + /// together into one block at `range`. Empty for a single-range copy. + pub areas: Vec, } /// In-cell edit mode as shown in the status bar. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55e..228612af8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1633,3 +1633,64 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.hyperlinks, before.hyperlinks); assert_eq!(after.cells, before.cells); } + +#[test] +fn copying_areas_that_share_rows_or_columns_pastes_them_as_one_block() { + // Issue #348: Excel slides lined-up areas together; formulas travel as their values. + let mut s = s(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [["a1", "b1", "c1", "d1"], [1, 2, 3, 4], ["a3", "b3", "c3", "d3"], ["a4", "b4", "c4", "d4"]]}), + ) + .unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=A2+B2"})).unwrap(); + + // Same rows, selected right to left: pasted left to right, B left out. + s.execute("selection.set", json!({"range": "C1:C2,A1:A2"})).unwrap(); + let copied = s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(copied["text"], "a1\tc1\n1\t3\n"); + s.execute("edit.paste", json!({"at": "F1"})).unwrap(); + assert_eq!(v(&s, "F1"), Value::text("a1")); + assert_eq!(v(&s, "G1"), Value::text("c1")); + assert_eq!(v(&s, "F2"), Value::Number(1.0)); + assert_eq!(v(&s, "G2"), Value::Number(3.0)); + assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("G2").unwrap()).unwrap().formula.is_none()); + assert!(v(&s, "H1").is_empty()); + assert_eq!(s.doc().unwrap().selection.current().a1(), "F1:G2"); + // The source is untouched. + assert_eq!(v(&s, "B1"), Value::text("b1")); + assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).unwrap().formula.is_some()); + + // Same columns: stacked, row 2 left out. + s.execute("selection.set", json!({"range": "A1:B1,A3:B4"})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "a1\tb1\na3\tb3\na4\tb4\n"); + s.execute("edit.paste", json!({"at": "F5"})).unwrap(); + for (cell, want) in [("F5", "a1"), ("G5", "b1"), ("F6", "a3"), ("G7", "b4")] { + assert_eq!(v(&s, cell), Value::text(want), "{cell}"); + } + assert!(v(&s, "F8").is_empty()); + + // Whole columns are trimmed to the used rows like a single-area copy. + s.execute("edit.undo", json!({"steps": 2})).unwrap(); + s.execute("selection.set", json!({"range": "B:B,D:D"})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "b1\td1\n2\t4\nb3\td3\nb4\td4\n"); +} + +#[test] +fn copying_areas_that_do_not_line_up_is_refused_with_a_message() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]})).unwrap(); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + for (range, cut) in [("A1:A2,C2:C3", false), ("A1:A2,C1:D3", false), ("A1:A2,A2:A3", false), ("A1:A2,A1:A2", false), ("A1:A2,C1:C2", true)] { + s.execute("selection.set", json!({"range": range})).unwrap(); + let e = s.execute(if cut { "edit.cut" } else { "edit.copy" }, json!({})).unwrap_err().to_string(); + assert!(e.contains("This action won't work on multiple selections."), "{range}: {e}"); + assert_eq!(s.clipboard.as_ref().map(|c| c.range.a1()), Some("A1".into()), "{range}: the earlier copy stays"); + } + // Paste Link can't point at a slid-together block. + s.execute("selection.set", json!({"range": "A1:A2,C1:C2"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "E1"})).is_err()); + assert!(v(&s, "E1").is_empty()); +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..122f86309 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -966,10 +966,14 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S if let Some(clip) = &app.session.clipboard && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet) { - let rr = geo.range_rect(sh, clip.range).expand(0.5); - let phase = app.grid.marching_phase; - let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()]; - p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase)); + // A multi-area copy marks each area as selected. + let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() }; + for area in areas { + let rr = geo.range_rect(sh, *area).expand(0.5); + let phase = app.grid.marching_phase; + let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()]; + p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase)); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eecdf..650483f5e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -250,7 +250,10 @@ impl SheetApp { /// their existing internal clipboard behavior and do not write to the host clipboard. pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { // Only ask the engine for HTML when the host can publish it. - let Ok(result) = self.run(command, json!({"html": self.services.copy_html.is_some()})) else { return }; + let result = match self.run(command, json!({"html": self.services.copy_html.is_some()})) { + Ok(result) => result, + Err(e) => return self.alert(e), + }; let Some(text) = result.get("text").and_then(Json::as_str) else { return }; if let Some(html) = result.get("html").and_then(Json::as_str) && let Some(copy_html) = &mut self.services.copy_html @@ -279,11 +282,16 @@ impl SheetApp { /// Runs a command and shows its error in a message box (for menu/ribbon clicks). pub fn run_or_alert(&mut self, id: &str, params: Json) { if let Err(e) = self.run(id, params) { - if e.contains("is not available right now") { - self.toast = Some((e, now_ms())); - } else { - self.message = Some(("GridCraft".into(), clean_error(&e))); - } + 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") { + self.toast = Some((e, now_ms())); + } else { + self.message = Some(("GridCraft".into(), clean_error(&e))); } } diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs index 188dbdc78..392270852 100644 --- a/crates/ui-egui/tests/clipboard.rs +++ b/crates/ui-egui/tests/clipboard.rs @@ -83,3 +83,22 @@ fn programmatic_copy_does_not_write_to_the_system_clipboard() { assert_eq!(*calls.borrow(), 0); assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_)))); } + +#[test] +fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() { + // Issue #348: lined-up areas copy as one block; others get Excel's message instead of nothing. + let mut h = harness(Services::default()); + h.state_mut().session.execute("selection.set", json!({"range": "B1:B2,A1:A2"})).unwrap(); + h.input_mut().events.push(Event::Copy); + h.step(); + assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(text) if text == "Name\tScore\nAda\t42\n"))); + assert!(h.state().message.is_none()); + assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2)); + + h.state_mut().session.execute("selection.set", json!({"range": "A1,B2"})).unwrap(); + h.input_mut().events.push(Event::Copy); + h.step(); + assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_)))); + 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"); +} From c1a500c5b9aee87312fc7b054a7b41ca40c74b94 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 17:17:30 +0200 Subject: [PATCH 084/102] Journal: leave sheet protection passwords out Protect Sheet and Unprotect Sheet were journaled with their `password` param, so the password showed in plain text in the journal dialog and `engine.journal`, and a recorded script (Automate > Record Actions) saved it in the workbook's hidden `_sc_scripts` name. Session::execute now journals the params without secret ones (`password`, or a name ending in `Password`). A replayed entry runs without that param: Protect Sheet protects with the same options but no password, and the matching Unprotect Sheet still succeeds. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 15 +++++++++++++++ crates/engine/src/lib.rs | 3 ++- crates/engine/src/tests.rs | 37 ++++++++++++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c4a290e47..a37c1a849 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -69,6 +69,21 @@ impl CommandSpec { } } +/// Params that hold a secret: `password`, or a name ending in `Password`. +pub fn is_secret_param(key: &str) -> bool { + key == "password" || key.ends_with("Password") +} + +/// The params as the journal records them: secret params are left out, so a password never +/// reaches the journal dialog, `engine.journal` or a recorded script (saved in the workbook). +/// Replaying such an entry runs the command without that param. +pub fn journal_params(params: &Json) -> Json { + match params { + Json::Object(o) => Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()), + other => other.clone(), + } +} + pub fn always(_: &Session) -> std::result::Result<(), String> { Ok(()) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de786a1f4..a614e168a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -184,6 +184,7 @@ pub struct Session { pub clipboard: Option, pub prefs: Prefs, pub mode: Mode, + /// Commands run so far, without their passwords ([`cmd::journal_params`]). pub journal: Vec<(String, Json)>, pub ui_requests: Vec, /// Format painter source style (sticky when double-clicked). @@ -284,7 +285,7 @@ impl Session { } }; if spec.journal && result.is_ok() { - self.journal.push((id.to_string(), params.clone())); + self.journal.push((id.to_string(), cmd::journal_params(¶ms))); if self.journal.len() > 10_000 { self.journal.drain(..5_000); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55e..671a46e16 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -512,6 +512,43 @@ fn sort_and_filter() { assert_eq!(r["hiddenRows"], 1); } +#[test] +fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() { + const PW: &str = "synthetic-pw-7319"; + let mut s = s(); + s.execute("automate.recordActions", json!({})).unwrap(); + s.execute("review.protectSheet", json!({"password": PW, "formatCells": true})).unwrap(); + // The password still protects the sheet; the refused attempt isn't journaled. + assert!(s.execute("review.unprotectSheet", json!({})).is_err()); + s.execute("review.unprotectSheet", json!({"password": PW})).unwrap(); + s.execute("automate.recordActions", json!({"stop": true, "name": "Lock"})).unwrap(); + + let journal = serde_json::to_string(&s.journal).unwrap(); + assert!(!journal.contains(PW), "{journal}"); + let params = |s: &Session, id: &str| s.journal.iter().find(|(c, _)| c == id).map(|(_, p)| p.clone()); + assert_eq!(params(&s, "review.protectSheet"), Some(json!({"formatCells": true}))); + assert_eq!(params(&s, "review.unprotectSheet"), Some(json!({}))); + + // The recorded script is stored in the workbook, so it is saved with the file. + let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap(); + let (reopened, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap(); + let script = reopened.names.iter().find(|n| n.name == "_sc_scripts").map(|n| n.formula.clone()).unwrap(); + assert!(script.contains("review.protectSheet"), "{script}"); + assert!(!script.contains(PW), "{script}"); + + // The journal still replays: protection without a password, then unprotection. + let mut r = self::s(); + for (id, p) in s.journal.iter().filter(|(id, _)| !id.starts_with("automate.")) { + r.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}: {e}")); + let prot = r.doc().unwrap().wb.active().unwrap().protection.clone(); + if id == "review.protectSheet" { + assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password_hash.is_none()), "{prot:?}"); + } + } + assert!(r.doc().unwrap().wb.active().unwrap().protection.is_none()); + s.execute("automate.runScript", json!({"name": "Lock"})).unwrap(); +} + /// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected. fn protected(extra: serde_json::Value) -> Session { let mut s = s(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aeb540d82..e515a6daf 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -21,7 +21,7 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. |---|---|---| | `engine.execute` | `{command, params}` | Runs any engine command (see `engine.commands` or `gridcraft-cli commands`) | | `engine.commands` | `{search?}` | Every command: id, label, ribbon path, shortcut, params doc, enabled; `search` keeps those whose id, label or ribbon path contains it (case-insensitive) | -| `engine.journal` | | Commands run so far (replayable) | +| `engine.journal` | | Commands run so far (replayable). Passwords (`password`, `…Password` params) are left out, so a replayed Protect Sheet has no password | | `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels | | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | From dfa15c3f25e57a13f2feaf8b4057bbd1902fedc0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 11 Oct 2026 11:54:22 -0400 Subject: [PATCH 085/102] ci: refactor workflows to use storytold/craft-common-actions (#336) * ci: refactor workflows to use reusable artcraft-common-actions * ci: point reusable workflows to storytold/craft-common-actions * ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly * ci: use @main (unpinned) for craft-common-actions with explanation comment --- .github/workflows/packaging-lint.yml | 77 +---- .github/workflows/release.yml | 448 +-------------------------- .github/workflows/windows-arm64.yml | 73 +---- 3 files changed, 25 insertions(+), 573 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 056cb05ef..bb3989d30 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,78 +19,5 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/gridcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. - if grep -oE '"$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.gridcraft" and m["command"] == "gridcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF - - desktop-shortcut: - name: Windows desktop shortcut smoke - runs-on: windows-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Build fixture MSIs and test shortcut lifecycle - shell: pwsh - run: | - dotnet tool install --tool-path "$env:RUNNER_TEMP\wix" wix --version 5.0.2 - if ($LASTEXITCODE -ne 0) { throw 'Could not install WiX' } - $env:PATH = "$env:RUNNER_TEMP\wix;$env:PATH" - ./packaging/windows/test-desktop-shortcut.ps1 - - name: Capture native MSI dialog previews - shell: pwsh - run: | - pwsh -NoProfile -STA -File ./packaging/windows/preview-installer-dialogs.ps1 ` - -Msi "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\gridcraft-90.0.0.msi" ` - -OutputDir "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\dialogs" - if ($LASTEXITCODE -ne 0) { throw 'Native MSI dialog preview failed' } - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - if: always() - with: - name: desktop-shortcut-msi-evidence - path: | - ${{ runner.temp }}/gridcraft-shortcut-smoke/*.log - ${{ runner.temp }}/gridcraft-shortcut-smoke/dialogs/* - if-no-files-found: ignore - retention-days: 7 + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c9050a9e..87d895415 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "GridCraft v". See docs/releasing.md. -# Copied from DesignCraft (craftrules release/playbook.md); keep the two in step. -# -# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment -# (restricted to the `release` branch), which holds the signing secrets. Every secret is -# optional: missing ones produce unsigned artifacts and a warning, never a failed build. The -# other jobs sign nothing and get no secrets, so a manual run from another branch is a dry run: -# they build and upload their artifacts, the environment refuses the signing jobs, and the -# draft-release job (which needs them) is skipped. name: Release on: @@ -21,431 +11,27 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - GRIDCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - # No `environment: release`: this job needs no secrets (see the header about dry runs). - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### GridCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/gridcraft$|metainfo|applications' - ldd target/release/gridcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.gridcraft.yml')); assert m['id'] == 'ai.storyteller.gridcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - flatpak: - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/gridcraft-$GRIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - TRUNK_VERSION: 0.21.14 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-web - - name: Install trunk - run: | - curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" trunk - trunk --version - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - - linux-riscv64: - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI is smoke-tested under QEMU. - runs-on: ubuntu-24.04 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - - name: Build and package (tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - - # FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true, - # uploads the release-freebsd artifact (gridcraft--freebsd-x86_64.tar.gz). It needs no - # secrets, so the called workflow runs without the release environment (and in dry runs). - freebsd: - name: FreeBSD - needs: version - uses: ./.github/workflows/freebsd.yml - with: - version: ${{ needs.version.outputs.version }} - version_override: ${{ inputs.version != '' }} - package: true - release: - name: Draft GitHub Release - needs: [version, macos, windows, linux, linux-riscv64, flatpak, web, freebsd] - # Belt and braces for branch dry runs: only the release branch drafts a release (the - # environment's branch rule refuses this job and the signing jobs anywhere else, too). - if: github.ref == 'refs/heads/release' - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="GridCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: gridcraft + app-title: GridCraft + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 92239fa56..2f9b1416d 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -17,71 +17,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - if: github.ref == 'refs/heads/release' - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: gridcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: gridcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'GridCraft' - foreach ($exe in 'gridcraft.exe', 'gridcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively on ARM64. - $cli = Join-Path $dir 'gridcraft-cli.exe' - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "gridcraft-cli --version exited $LASTEXITCODE" } - Write-Output "ok gridcraft-cli --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'gridcraft.exe')) { throw 'uninstall left gridcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: gridcraft + app-dir: GridCraft From 2f6c2cd486d6fe0068e9152678ae32eac1f127f2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 11:59:24 -0400 Subject: [PATCH 086/102] Fix-up: Spanish and German strings for Interface size (#294 met #242/#271); rustfmt after merges Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 2 +- crates/ui-egui/src/i18n/de.rs | 2 ++ crates/ui-egui/src/i18n/es.rs | 2 ++ crates/ui-egui/tests/interaction.rs | 11 ++++++++--- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ef2f65ee6..3101e00a1 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; -mod es; mod de; +mod es; mod ko; mod pt; mod ru; diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index d797baa64..5640187da 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Interface size" => "Größe der Benutzeroberfläche", + "Auto" => "Automatisch", "100%" => "100%", "Zoom to\nSelection" => "Zoom auf die\nAuswahl", "Freeze\nPanes" => "Fenster\nfixieren", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index fe201cc17..18a30ab24 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Interface size" => "Tamaño de la interfaz", + "Auto" => "Automático", "100%" => "100%", "Zoom to\nSelection" => "Ampliar\nselección", "Freeze\nPanes" => "Inmovilizar\npaneles", diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index affb8bb99..8ab774b9d 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -253,9 +253,14 @@ fn language_switch_translates_the_ribbon_and_persists() { for id in ["app.language.set", "app.language.english", "app.language.japanese"] { assert!(ids.contains(&id), "engine.commands lists {id}"); } - for (language, expected) in - [(Language::Zh, "数据"), (Language::Ja, "データ"), (Language::Ko, "데이터"), (Language::Ru, "Данные"), (Language::PtBr, "Dados"), (Language::De, "Daten")] - { + for (language, expected) in [ + (Language::Zh, "数据"), + (Language::Ja, "データ"), + (Language::Ko, "데이터"), + (Language::Ru, "Данные"), + (Language::PtBr, "Dados"), + (Language::De, "Daten"), + ] { h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap(); h.run_steps(2); // every locale renders the ribbon without panicking assert_eq!(h.state().ui.language, language); From 5f3e60b48a63f13e3b6549e2ffe691be5d17181f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:02:26 -0400 Subject: [PATCH 087/102] Docs: headline counts after the community merges (509 / 523 functions, 500 working; catalog 260 / 290) Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- ROADMAP.md | 4 ++-- docs/function-parity.md | 2 +- docs/target-app-parity.md | 10 +++++----- docs/ui-parity.md | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8e65dbbc4..2331a73c4 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog -([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 505 of Excel's 523 worksheet functions +([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 509 of Excel's 523 worksheet functions ([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage, diff --git a/ROADMAP.md b/ROADMAP.md index 5964519ed..450afc106 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-11 · **Change:** minor (worksheet functions 505 / 523) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 509 / 523, catalog 260 / 290) · **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. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 505 / 523 (96.6%; 496 working, 94.8%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 260 / 290 (89.7%), worksheet functions 509 / 523 (97.3%; 500 working, 95.6%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | diff --git a/docs/function-parity.md b/docs/function-parity.md index 75eddfc0f..4d1dfac72 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -26,7 +26,7 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage -**Measured: 505 / 523 by name (96.6%); 496 / 523 working (94.8%).** +**Measured: 509 / 523 by name (97.3%); 500 / 523 working (95.6%).** | Category | Excel | Ours | Working | % working | Missing | |---|---|---|---|---|---| diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3b8a9143c..08dabb690 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 (worksheet functions 505 / 523: the four ODD* bond functions) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 509 / 523, catalog 260 / 290) · **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: @@ -13,9 +13,9 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **258 / 290 (89.0%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | -| Worksheet functions, by name | **505 / 523 (96.6%)** | measured: registered functions vs Microsoft's published function list (method below) | -| Worksheet functions that do real work | **492 / 523 (94.1%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | +| Ribbon and menu catalog | **260 / 290 (89.7%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Worksheet functions, by name | **509 / 523 (97.3%)** | measured: registered functions vs Microsoft's published function list (method below) | +| Worksheet functions that do real work | **500 / 523 (95.6%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | | **Mainstream practitioner** | **~50%** | estimated; [method](#mainstream-practitioner) (69.5 depth × 0.90 × 0.92 × 0.88 = 50.6) | | **Essentials user** | **~63%** | estimated; [method](#essentials-user) (80.2 depth × 0.92 × 0.95 × 0.90 = 63.1) | @@ -80,7 +80,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Area | Weight | Breadth | Ready | Hours | Evidence | |---|---|---|---|---|---| | Formula language and calculation | 18 | ~92% | ~72% | 20–30 | A1/R1C1 parser, reference adjustment, dependency-graph recalc, dynamic arrays and `#SPILL!`, `A1#`, LET/LAMBDA and helpers, structured references, names, 3-D references. Open correctness bugs: 15-digit equality (#57), stale spills after structural edits and cleared blockers (#133, #139), 3-D span endpoint deletion (#140), sheet rename and qualified names (#134). Single-threaded | -| Worksheet functions | 12 | 96.6% (measured) | ~82% | 16–26 | 505 / 523 names, 496 working. Missing: FORECAST.ETS family, GETPIVOTDATA, TRIMRANGE, PERCENTOF, BAHTTEXT, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) | +| Worksheet functions | 12 | 97.3% (measured) | ~82% | 16–26 | 509 / 523 names, 500 working. Missing: FORECAST.ETS family, GETPIVOTDATA, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) | | 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 410b8dbb4..ed4cd1f1e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -4,7 +4,7 @@ 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 -separately in [parity-checklist.md](parity-checklist.md) (258 / 290). +separately in [parity-checklist.md](parity-checklist.md) (260 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes From 267a2183ac74feec6ab7a8750b397f204998a6ac Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:13:09 -0400 Subject: [PATCH 088/102] Fix-up: Backstage options read the formula bar from engine prefs (#331 after #295) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/backstage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index c29174017..32000f7d9 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -773,7 +773,7 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { ui.end_row(); }); ui.add_space(6.0); - let mut formula_bar = app.ui.formula_bar; + let mut formula_bar = app.session.prefs.formula_bar; if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); } From b3b2cd322174ce3d3e8b83ef3e2fe6885ef100bc Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:16:03 +0200 Subject: [PATCH 089/102] i18n (cs): translate "Interface size" and "Auto" (#294) Two new upstream catalog strings (interface scale control in the View tab). Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/i18n/cs.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 5b56b209e..981e64be8 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Interface size" => "Velikost rozhraní", + "Auto" => "Automaticky", "100%" => "100 %", "Zoom to\nSelection" => "Přiblížit\nna výběr", "Freeze\nPanes" => "Ukotvit\npříčky", From 4d2335c8f59f05005a93d0517d824ce792536faa Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:21:15 -0400 Subject: [PATCH 090/102] Fix-up: Spanish, German and Czech strings for the features merged alongside them; theme test goes through Backstage Options (#318 after #331) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 2 +- crates/ui-egui/src/i18n/cs.rs | 72 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/de.rs | 72 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/es.rs | 70 +++++++++++++++++++++++++++ crates/ui-egui/tests/system_theme.rs | 3 +- 5 files changed, 217 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index bbaf71e8b..eae37a494 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -9,9 +9,9 @@ use serde::{Deserialize, Serialize}; +mod cs; mod de; mod es; -mod cs; mod ko; mod pt; mod ru; diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 5b56b209e..f8605b54f 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,78 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Checkbox" => "Zaškrtávací políčko", + "Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk", + "Sheet\nRight-to-Left" => "List zprava\ndoleva", + "Sheet Right-to-Left" => "List zprava doleva", + "Data\nAnalysis" => "Analýza\ndat", + "Data Analysis" => "Analýza dat", + "Solver" => "Řešitel", + "Allow Edit\nRanges" => "Povolit úpravy\noblastí", + "Allow Edit Ranges" => "Povolit úpravy oblastí", + "Interface size" => "Velikost rozhraní", + "Auto" => "Automaticky", + "Drag to freeze columns" => "Přetažením ukotvíte sloupce", + "Drag to freeze rows" => "Přetažením ukotvíte řádky", + "Select\nData" => "Vybrat\ndata", + "File" => "Soubor", + "Back" => "Zpět", + "New" => "Nový", + "Open" => "Otevřít", + "Info" => "Informace", + "Save" => "Uložit", + "Save As" => "Uložit jako", + "Export" => "Exportovat", + "Close" => "Zavřít", + "Options" => "Možnosti", + "Blank workbook" => "Prázdný sešit", + "Recent" => "Naposledy použité", + "No recent workbooks yet." => "Zatím žádné naposledy použité sešity.", + "More workbooks" => "Další sešity", + "Browse…" => "Procházet…", + "Not saved yet" => "Zatím neuloženo", + "Location" => "Umístění", + "Sheets" => "Listy", + "Protect Workbook" => "Zamknout sešit", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "Struktura listů je zamčená: listy nelze přidávat, přesouvat, přejmenovávat ani odstraňovat." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Zamkne strukturu listů, aby listy nebylo možné přidávat, přesouvat, přejmenovávat ani odstraňovat." + } + "Properties" => "Vlastnosti", + "Title" => "Název", + "Subject" => "Předmět", + "Author" => "Autor", + "Company" => "Společnost", + "Keywords" => "Klíčová slova", + "Description" => "Popis", + "No workbook is open." => "Není otevřený žádný sešit.", + "Save the workbook under another name or in another folder." => "Uloží sešit pod jiným názvem nebo do jiné složky.", + "Download (.xlsx)" => "Stáhnout (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV a webové stránky najdete na stránce Exportovat.", + "Print Active Sheets" => "Vytisknout aktivní listy", + "Print Entire Workbook" => "Vytisknout celý sešit", + "Fit Sheet on One Page" => "Přizpůsobit list na jednu stránku", + "Page Setup…" => "Vzhled stránky…", + "Page {page} of {pages}" => "Stránka {page} z {pages}", + "Nothing to print." => "Není co tisknout.", + "Previous page" => "Předchozí stránka", + "Next page" => "Další stránka", + "Create PDF" => "Vytvořit PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Uloží list jako dokument PDF se zachováním rozložení stránky.", + "Change File Type" => "Změnit typ souboru", + "CSV (Comma delimited)" => "CSV (oddělený čárkami)", + "Save the active sheet as comma-separated text." => "Uloží aktivní list jako text oddělený čárkami.", + "Web Page (.html)" => "Webová stránka (.html)", + "Save the workbook as a web page." => "Uloží sešit jako webovou stránku.", + "Options|General" => "Obecné", + "Options|Save" => "Uložit", + "Show formula bar" => "Zobrazit řádek vzorců", + "Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně", + "Workbook calculation" => "Výpočet sešitu", + "Enable iterative calculation" => "Povolit iterativní výpočet", + "Calculation settings are saved with the workbook." => "Nastavení výpočtů se ukládá se sešitem.", "100%" => "100 %", "Zoom to\nSelection" => "Přiblížit\nna výběr", "Freeze\nPanes" => "Ukotvit\npříčky", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 5640187da..e0f350884 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,78 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Checkbox" => "Kontrollkästchen", + "Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen", + "Sheet\nRight-to-Left" => "Blatt von rechts\nnach links", + "Sheet Right-to-Left" => "Blatt von rechts nach links", + "Data\nAnalysis" => "Daten-\nanalyse", + "Data Analysis" => "Datenanalyse", + "Solver" => "Solver", + "Allow Edit\nRanges" => "Bearbeiten von\nBereichen zulassen", + "Allow Edit Ranges" => "Bearbeiten von Bereichen zulassen", + "Drag to freeze columns" => "Ziehen, um Spalten zu fixieren", + "Drag to freeze rows" => "Ziehen, um Zeilen zu fixieren", + "Select\nData" => "Daten\nauswählen", + "File" => "Datei", + "Back" => "Zurück", + "New" => "Neu", + "Open" => "Öffnen", + "Info" => "Informationen", + "Save" => "Speichern", + "Save As" => "Speichern unter", + "Export" => "Exportieren", + "Close" => "Schließen", + "Options" => "Optionen", + "Blank workbook" => "Leere Arbeitsmappe", + "Recent" => "Zuletzt verwendet", + "No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.", + "More workbooks" => "Weitere Arbeitsmappen", + "Browse…" => "Durchsuchen…", + "Not saved yet" => "Noch nicht gespeichert", + "Location" => "Speicherort", + "Sheets" => "Blätter", + "Protect Workbook" => "Arbeitsmappe schützen", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "Die Blattstruktur ist gesperrt: Blätter können nicht hinzugefügt, verschoben, umbenannt oder gelöscht werden." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Sperrt die Blattstruktur, sodass Blätter nicht hinzugefügt, verschoben, umbenannt oder gelöscht werden können." + } + "Properties" => "Eigenschaften", + "Title" => "Titel", + "Subject" => "Thema", + "Author" => "Autor", + "Company" => "Firma", + "Keywords" => "Stichwörter", + "Description" => "Beschreibung", + "No workbook is open." => "Es ist keine Arbeitsmappe geöffnet.", + "Save the workbook under another name or in another folder." => { + "Speichert die Arbeitsmappe unter einem anderen Namen oder in einem anderen Ordner." + } + "Download (.xlsx)" => "Herunterladen (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV und Webseiten finden Sie auf der Seite „Exportieren“.", + "Print Active Sheets" => "Aktive Blätter drucken", + "Print Entire Workbook" => "Gesamte Arbeitsmappe drucken", + "Fit Sheet on One Page" => "Blatt auf einer Seite darstellen", + "Page Setup…" => "Seite einrichten…", + "Page {page} of {pages}" => "Seite {page} von {pages}", + "Nothing to print." => "Nichts zu drucken.", + "Previous page" => "Vorherige Seite", + "Next page" => "Nächste Seite", + "Create PDF" => "PDF erstellen", + "Save the sheet as a PDF document that keeps its page layout." => "Speichert das Blatt als PDF-Dokument mit unverändertem Seitenlayout.", + "Change File Type" => "Dateityp ändern", + "CSV (Comma delimited)" => "CSV (Trennzeichen-getrennt)", + "Save the active sheet as comma-separated text." => "Speichert das aktive Blatt als durch Kommas getrennten Text.", + "Web Page (.html)" => "Webseite (.html)", + "Save the workbook as a web page." => "Speichert die Arbeitsmappe als Webseite.", + "Options|General" => "Allgemein", + "Options|Save" => "Speichern", + "Show formula bar" => "Bearbeitungsleiste anzeigen", + "Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern", + "Workbook calculation" => "Arbeitsmappenberechnung", + "Enable iterative calculation" => "Iterative Berechnung aktivieren", + "Calculation settings are saved with the workbook." => "Berechnungseinstellungen werden mit der Arbeitsmappe gespeichert.", "Interface size" => "Größe der Benutzeroberfläche", "Auto" => "Automatisch", "100%" => "100%", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 18a30ab24..fe6f36873 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,76 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Checkbox" => "Casilla", + "Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas", + "Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda", + "Sheet Right-to-Left" => "Hoja de derecha a izquierda", + "Data\nAnalysis" => "Análisis\nde datos", + "Data Analysis" => "Análisis de datos", + "Solver" => "Solver", + "Allow Edit\nRanges" => "Permitir editar\nrangos", + "Allow Edit Ranges" => "Permitir editar rangos", + "Drag to freeze columns" => "Arrastrar para inmovilizar columnas", + "Drag to freeze rows" => "Arrastrar para inmovilizar filas", + "Select\nData" => "Seleccionar\ndatos", + "File" => "Archivo", + "Back" => "Atrás", + "New" => "Nuevo", + "Open" => "Abrir", + "Info" => "Información", + "Save" => "Guardar", + "Save As" => "Guardar como", + "Export" => "Exportar", + "Close" => "Cerrar", + "Options" => "Opciones", + "Blank workbook" => "Libro en blanco", + "Recent" => "Recientes", + "No recent workbooks yet." => "Todavía no hay libros recientes.", + "More workbooks" => "Más libros", + "Browse…" => "Examinar…", + "Not saved yet" => "Aún no se ha guardado", + "Location" => "Ubicación", + "Sheets" => "Hojas", + "Protect Workbook" => "Proteger libro", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "La estructura de hojas está bloqueada: no se pueden agregar, mover, cambiar de nombre ni eliminar hojas." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Bloquea la estructura de hojas para que no se puedan agregar, mover, cambiar de nombre ni eliminar hojas." + } + "Properties" => "Propiedades", + "Title" => "Título", + "Subject" => "Asunto", + "Author" => "Autor", + "Company" => "Organización", + "Keywords" => "Palabras clave", + "Description" => "Descripción", + "No workbook is open." => "No hay ningún libro abierto.", + "Save the workbook under another name or in another folder." => "Guarda el libro con otro nombre o en otra carpeta.", + "Download (.xlsx)" => "Descargar (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV y páginas web están en la página Exportar.", + "Print Active Sheets" => "Imprimir hojas activas", + "Print Entire Workbook" => "Imprimir todo el libro", + "Fit Sheet on One Page" => "Ajustar hoja en una página", + "Page Setup…" => "Configurar página…", + "Page {page} of {pages}" => "Página {page} de {pages}", + "Nothing to print." => "No hay nada que imprimir.", + "Previous page" => "Página anterior", + "Next page" => "Página siguiente", + "Create PDF" => "Crear PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Guarda la hoja como documento PDF conservando el diseño de página.", + "Change File Type" => "Cambiar el tipo de archivo", + "CSV (Comma delimited)" => "CSV (delimitado por comas)", + "Save the active sheet as comma-separated text." => "Guarda la hoja activa como texto separado por comas.", + "Web Page (.html)" => "Página web (.html)", + "Save the workbook as a web page." => "Guarda el libro como página web.", + "Options|General" => "General", + "Options|Save" => "Guardar", + "Show formula bar" => "Mostrar barra de fórmulas", + "Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio", + "Workbook calculation" => "Cálculo del libro", + "Enable iterative calculation" => "Habilitar cálculo iterativo", + "Calculation settings are saved with the workbook." => "La configuración de cálculo se guarda con el libro.", "Interface size" => "Tamaño de la interfaz", "Auto" => "Automático", "100%" => "100%", diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/system_theme.rs index a0a6b007d..73a4f194c 100644 --- a/crates/ui-egui/tests/system_theme.rs +++ b/crates/ui-egui/tests/system_theme.rs @@ -178,9 +178,10 @@ fn title_bar_switch_and_file_menu_choose_the_theme() { h.run_steps(2); assert_appearance(&h, false); + // File opens Backstage; its Options page holds the Office Theme choice (#331). h.get_by_label("File").click(); h.run_steps(3); - h.get_by_label("Display theme ⏵").click(); + h.get_by_label("Options").click(); h.run_steps(3); h.get_by_label("System").click(); h.run_steps(3); From abbd8181b9ac5a4ac476ee8197eccfa498b484e0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:31:17 -0400 Subject: [PATCH 091/102] Fix-up: theme test picks System from Backstage Options' Display theme combo (#318 after #331) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/system_theme.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/system_theme.rs index 73a4f194c..e6b054216 100644 --- a/crates/ui-egui/tests/system_theme.rs +++ b/crates/ui-egui/tests/system_theme.rs @@ -181,7 +181,10 @@ fn title_bar_switch_and_file_menu_choose_the_theme() { // File opens Backstage; its Options page holds the Office Theme choice (#331). h.get_by_label("File").click(); h.run_steps(3); - h.get_by_label("Options").click(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Options").click(); + h.run_steps(3); + // The first combo box on the page is Display theme. + h.get_all_by_role(egui::accesskit::Role::ComboBox).next().expect("theme combo").click(); h.run_steps(3); h.get_by_label("System").click(); h.run_steps(3); From cd9662f1d4ec276b9261d73365e921837c0ba2bc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:31:17 -0400 Subject: [PATCH 092/102] Docs: headline counts after wave 3 (513 / 523 functions, 504 working; catalog 266 / 290) Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- ROADMAP.md | 4 ++-- docs/function-parity.md | 2 +- docs/parity-checklist.md | 12 +++++------- docs/target-app-parity.md | 10 +++++----- docs/ui-parity.md | 2 +- 6 files changed, 15 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index fbae96b2e..72a17349b 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog -([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 509 of Excel's 523 worksheet functions +([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 of Excel's 523 worksheet functions ([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage, diff --git a/ROADMAP.md b/ROADMAP.md index 9561dbeaf..d44c74345 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-11 · **Change:** minor (community merges: worksheet functions 509 / 523, catalog 260 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 266 / 290) · **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. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 260 / 290 (89.7%), worksheet functions 509 / 523 (97.3%; 500 working, 95.6%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 266 / 290 (91.7%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | diff --git a/docs/function-parity.md b/docs/function-parity.md index 6afc4c359..901507e72 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -27,7 +27,7 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage -**Measured: 509 / 523 by name (97.3%); 500 / 523 working (95.6%).** +**Measured: 513 / 523 by name (98.1%); 504 / 523 working (96.4%).** | Category | Excel | Ours | Working | % working | Missing | |---|---|---|---|---|---| diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7cda7c60c..3002b8907 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 260 / 290 commands implemented (89.7%).** +**Overall: 266 / 290 commands implemented (91.7%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -14,8 +14,8 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | -| Data | 21 | 28 | 75% | -| Review | 9 | 18 | 50% | +| Data | 22 | 28 | 79% | +| Review | 10 | 18 | 56% | | View | 20 | 20 | 100% | | Automate | 3 | 3 | 100% | | Table Design | 12 | 13 | 92% | @@ -237,7 +237,7 @@ Implemented: - `formulas.calculateNow` Calculate Now (Calculation) - `formulas.calculateSheet` Calculate Sheet (Calculation) -## Data (21 / 28, 75%) +## Data (22 / 28, 79%) Implemented: @@ -261,8 +261,8 @@ Implemented: - `data.subtotal` Subtotal (Outline) - `data.showDetail` Show Detail (Outline) - `data.hideDetail` Hide Detail (Outline) -- `data.analysisToolPak` Data Analysis (Analysis) - `data.solver` Solver (Analysis) +- `data.analysisToolPak` Data Analysis (Analysis) Missing: @@ -272,8 +272,6 @@ Missing: - `data.relationships` Relationships (Data Tools) - `data.forecastSheet` Forecast Sheet (Forecast) - `data.analyzeData` Analyze Data (Analysis) -- `data.solver` Solver (Analysis) -- `data.analysisToolPak` Data Analysis (Analysis) ## Review (10 / 18, 56%) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5966723c2..05ce81861 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 (community merges: worksheet functions 509 / 523, catalog 260 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 266 / 290) · **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: @@ -13,9 +13,9 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **260 / 290 (89.7%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | -| Worksheet functions, by name | **509 / 523 (97.3%)** | measured: registered functions vs Microsoft's published function list (method below) | -| Worksheet functions that do real work | **500 / 523 (95.6%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | +| Ribbon and menu catalog | **266 / 290 (91.7%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | +| Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | | **Mainstream practitioner** | **~50%** | estimated; [method](#mainstream-practitioner) (69.5 depth × 0.90 × 0.92 × 0.88 = 50.6) | | **Essentials user** | **~63%** | estimated; [method](#essentials-user) (80.2 depth × 0.92 × 0.95 × 0.90 = 63.1) | @@ -80,7 +80,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Area | Weight | Breadth | Ready | Hours | Evidence | |---|---|---|---|---|---| | Formula language and calculation | 18 | ~92% | ~72% | 20–30 | A1/R1C1 parser, reference adjustment, dependency-graph recalc, dynamic arrays and `#SPILL!`, `A1#`, LET/LAMBDA and helpers, structured references, names, 3-D references. Open correctness bugs: 15-digit equality (#57), stale spills after structural edits and cleared blockers (#133, #139), 3-D span endpoint deletion (#140), sheet rename and qualified names (#134). Single-threaded | -| Worksheet functions | 12 | 97.3% (measured) | ~82% | 16–26 | 509 / 523 names, 500 working. Missing: FORECAST.ETS family, GETPIVOTDATA, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) | +| Worksheet functions | 12 | 98.1% (measured) | ~82% | 16–26 | 513 / 523 names, 504 working. Missing: GETPIVOTDATA, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) | | 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7a810c09f..a4f3b448f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -4,7 +4,7 @@ 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 -separately in [parity-checklist.md](parity-checklist.md) (260 / 290). +separately in [parity-checklist.md](parity-checklist.md) (266 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes From 4cb4f6586eff5d8296afee20aaf04ae12fa9010e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:42:02 -0400 Subject: [PATCH 093/102] rustfmt the sheet-tab scroll area (review fix-up for #268) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tabs.rs | 269 ++++++++++++++++++------------------- 1 file changed, 132 insertions(+), 137 deletions(-) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index a56be8435..a652a4f1c 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -40,146 +40,141 @@ 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); - 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. - let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) { - ui.input(|i| { - let d = i.smooth_scroll_delta; - if d.x.abs() > d.y.abs() { d.x } else { d.y } - }) - } else { - 0.0 - }; - - egui::ScrollArea::horizontal() - .id_salt("worksheet_tabs_horizontal") - .auto_shrink([false, false]) - .scroll_bar_visibility( - egui::scroll_area::ScrollBarVisibility::AlwaysHidden - ) - .show(ui, |ui| { - if wheel_scroll.abs() > 0.0 { - ui.scroll_with_delta(vec2(wheel_scroll, 0.0)); - } - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - for (i, name, color, active) in sheets { - let renaming = app.grid.renaming_tab == Some(i); - let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) }; - 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()); - 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); - ui.painter().rect_stroke( - Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), - 4.0, - Stroke::new(1.0, t.ribbon_border), - StrokeKind::Inside, - ); - } else if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover); - } - if let Some(c) = color { - let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0)); - ui.painter().rect_filled(band, 1.0, theme::color32(c)); - } else if active { - let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5)); - ui.painter().rect_filled(band, 1.0, t.accent); - } - if renaming { - let mut text = app.grid.rename_text.clone(); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0)))); - let ed = child.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)); - app.grid.rename_text = text.clone(); - if rename_needs_focus(ed.has_focus(), ed.lost_focus()) { - ed.request_focus(); - } - if ed.lost_focus() { - if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name { - app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text})); - } - app.grid.renaming_tab = None; - } + 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. + let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) { + ui.input(|i| { + let d = i.smooth_scroll_delta; + if d.x.abs() > d.y.abs() { d.x } else { d.y } + }) } else { - 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); + 0.0 + }; + + egui::ScrollArea::horizontal() + .id_salt("worksheet_tabs_horizontal") + .auto_shrink([false, false]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + if wheel_scroll.abs() > 0.0 { + ui.scroll_with_delta(vec2(wheel_scroll, 0.0)); } - app.run_or_alert("sheet.activate", json!({"sheet": i})); - } - } - if resp.double_clicked() { - app.grid.renaming_tab = Some(i); - app.grid.rename_text = name.clone(); - } - // Drag to reorder. - if resp.drag_stopped() - && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) - { - let dx = p.x - r.center().x; - let steps = (dx / w).round() as i64; - if steps != 0 { - let to = (i as i64 + steps).max(0) as usize; - app.run_or_alert("sheet.move", json!({"sheet": i, "to": to})); - } - } - resp.context_menu(|ui| { - ui.set_min_width(180.0); - if ui.button("Insert Sheet").clicked() { - app.run_or_alert("home.insertSheet", json!({"before": i})); - } - if ui.button("Delete").clicked() { - app.run_or_alert("home.deleteSheet", json!({"sheet": i})); - } - if ui.button("Rename").clicked() { - app.grid.renaming_tab = Some(i); - app.grid.rename_text = name.clone(); - } - if ui.button("Move or Copy…").clicked() { - app.open_dialog("moveSheet", json!({"sheet": i})); - } - if ui.button("Duplicate").clicked() { - app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true})); - } - ui.menu_button("Tab Color", |ui| { - let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") { - app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); - ui.close(); - } - }); - ui.separator(); - if ui.button("Hide").clicked() { - app.run_or_alert("sheet.hide", json!({"sheet": i})); - } - if ui.button("Unhide…").clicked() { - app.run_or_alert("sheet.unhide", json!({})); - } - ui.separator(); - if ui.button("Protect Sheet…").clicked() { - app.open_dialog("protectSheet", json!({})); - } - if ui.button("Select All Sheets").clicked() { - ui.close(); - } - }); - } - }); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (i, name, color, active) in sheets { + let renaming = app.grid.renaming_tab == Some(i); + let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) }; + 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()); + 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); + ui.painter().rect_stroke( + Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), + 4.0, + Stroke::new(1.0, t.ribbon_border), + StrokeKind::Inside, + ); + } else if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + if let Some(c) = color { + let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0)); + ui.painter().rect_filled(band, 1.0, theme::color32(c)); + } else if active { + let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5)); + ui.painter().rect_filled(band, 1.0, t.accent); + } + if renaming { + let mut text = app.grid.rename_text.clone(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0)))); + let ed = + child.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)); + app.grid.rename_text = text.clone(); + if rename_needs_focus(ed.has_focus(), ed.lost_focus()) { + ed.request_focus(); + } + if ed.lost_focus() { + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name { + app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text})); + } + app.grid.renaming_tab = None; + } + } else { + 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})); + } + } + if resp.double_clicked() { + app.grid.renaming_tab = Some(i); + app.grid.rename_text = name.clone(); + } + // Drag to reorder. + if resp.drag_stopped() + && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) + { + let dx = p.x - r.center().x; + let steps = (dx / w).round() as i64; + if steps != 0 { + let to = (i as i64 + steps).max(0) as usize; + app.run_or_alert("sheet.move", json!({"sheet": i, "to": to})); + } + } + resp.context_menu(|ui| { + ui.set_min_width(180.0); + if ui.button("Insert Sheet").clicked() { + app.run_or_alert("home.insertSheet", json!({"before": i})); + } + if ui.button("Delete").clicked() { + app.run_or_alert("home.deleteSheet", json!({"sheet": i})); + } + if ui.button("Rename").clicked() { + app.grid.renaming_tab = Some(i); + app.grid.rename_text = name.clone(); + } + if ui.button("Move or Copy…").clicked() { + app.open_dialog("moveSheet", json!({"sheet": i})); + } + if ui.button("Duplicate").clicked() { + app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true})); + } + ui.menu_button("Tab Color", |ui| { + let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); + if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") { + app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); + ui.close(); + } + }); + ui.separator(); + if ui.button("Hide").clicked() { + app.run_or_alert("sheet.hide", json!({"sheet": i})); + } + if ui.button("Unhide…").clicked() { + app.run_or_alert("sheet.unhide", json!({})); + } + ui.separator(); + if ui.button("Protect Sheet…").clicked() { + app.open_dialog("protectSheet", json!({})); + } + if ui.button("Select All Sheets").clicked() { + ui.close(); + } + }); + } }); - }, - ); + }); + }); ui.add_space(6.0); if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, "New sheet (⇧F11)", vec2(26.0, 26.0)).clicked() { app.run_or_alert("home.insertSheet", json!({})); From e78cc6edc91d35f4ca6af6fd8b7bdcce1bd3198f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:55:17 -0400 Subject: [PATCH 094/102] Fix-up #332: bound .xls formula nesting and share one record budget across sheets Chained PtgParen/PtgUminus (or operator) tokens built a ~16K-deep Expr whose printing or drop overflowed the stack; nesting past the formula parser's MAX_DEPTH (256) is now reported as unsupported. BoundSheet records pointing into one huge substream each got their own 40M-record allowance; the allowance is now shared by the whole Workbook stream, and two sheets at the same offset are refused. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/tests/xls_tests.rs | 39 ++++++++ crates/xlsx/src/xls/formula.rs | 138 ++++++++++++++++++++--------- crates/xlsx/src/xls/mod.rs | 49 +++++++--- 3 files changed, 169 insertions(+), 57 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index a150dc0b3..fd37795ec 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -437,3 +437,42 @@ fn hostile_xls_input_never_panics_or_hangs() { let _ = read_xls(&b); } } + +#[test] +fn sheets_share_one_record_budget() { + // Many BoundSheet records point into one long substream (at successive nested BOFs), so each + // sheet would read the whole tail. The record allowance is shared, not per sheet. + let n = 300u32; + let tail: Vec = (0..2000u16).flat_map(|r| rec(0x0203, &cat(&[&cell(r, 0, 0), &1.0f64.to_le_bytes()]))).collect(); + 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 = |base: u32| { + let mut g = bof(0x0005); + for i in 0..n { + g.extend(boundsheet(base + i * bof(0x0010).len() as u32, &format!("S{i}"))); + } + g.extend(eof()); + g + }; + let base = globals(0).len() as u32; + let mut stream = globals(base); + for _ in 0..n { + stream.extend(bof(0x0010)); + } + stream.extend(tail); + stream.extend(eof()); + let file = compound("Workbook", &stream); + // About n × 2,000 records in all: over a 100,000 shared allowance, though each sheet alone fits. + assert!(matches!(crate::xls::read_xls_with(&file, crate::xls::Budget::with_max_records(100_000)), Err(IoError::TooLarge(_)))); + assert!(crate::xls::read_xls_with(&file, crate::xls::Budget::with_max_records(10_000_000)).is_ok()); + + // Two sheets at the same offset are refused outright. + let mut dup = bof(0x0005); + let at = (dup.len() + 2 * boundsheet(0, "A").len() + eof().len()) as u32; + dup.extend(boundsheet(at, "A")); + dup.extend(boundsheet(at, "B")); + dup.extend(eof()); + dup.extend(bof(0x0010)); + dup.extend(eof()); + let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); + assert!(err.contains("same substream"), "{err}"); +} diff --git a/crates/xlsx/src/xls/formula.rs b/crates/xlsx/src/xls/formula.rs index 3fc3fc40c..87a512c59 100644 --- a/crates/xlsx/src/xls/formula.rs +++ b/crates/xlsx/src/xls/formula.rs @@ -180,13 +180,35 @@ fn function_name(name: &str) -> String { n.to_ascii_uppercase() } -fn pop(stack: &mut Vec) -> Result { - stack.pop().ok_or(Unsupported("malformed formula (operand missing)")) +/// Expression nesting at most, as in the formula parser: deeper trees would overflow the stack +/// when printed, evaluated or dropped (a hostile file can chain thousands of PtgParen tokens). +const MAX_DEPTH: usize = 256; + +/// Operand stack entries: an expression and its nesting depth. +type Stack = Vec<(Expr, usize)>; + +/// Pops an operand, recording its depth in `kids` (the deepest operand this token consumed). +fn pop(stack: &mut Stack, kids: &mut usize) -> Result { + let (e, d) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?; + *kids = (*kids).max(d); + Ok(e) } -fn pop_n(stack: &mut Vec, n: usize) -> Result, Unsupported> { +fn pop_n(stack: &mut Stack, n: usize, kids: &mut usize) -> Result, Unsupported> { let at = stack.len().checked_sub(n).ok_or(Unsupported("malformed formula (argument missing)"))?; - Ok(stack.split_off(at)) + let popped = stack.split_off(at); + *kids = popped.iter().map(|(_, d)| *d).fold(*kids, usize::max); + Ok(popped.into_iter().map(|(e, _)| e).collect()) +} + +/// Pushes the result of a token one level above the operands it consumed. +fn push(stack: &mut Stack, kids: usize, e: Expr) -> Result<(), Unsupported> { + let depth = kids.saturating_add(1); + if depth > MAX_DEPTH { + return Err(Unsupported("formula nested too deeply")); + } + stack.push((e, depth)); + Ok(()) } /// Decodes `rgce` (tokens) with `rgcb` (the extra data for array constants) evaluated at @@ -197,9 +219,10 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } let mut t = Bytes { b: rgce, pos: 0 }; let mut extra = Bytes { b: rgcb, pos: 0 }; - let mut stack: Vec = Vec::new(); + let mut stack: Stack = Vec::new(); while t.pos < rgce.len() { let ptg = t.u8()?; + let mut kids = 0usize; // Operand classes (reference / value / array) share one token number. let id = if ptg & 0x60 != 0 { 0x20 | (ptg & 0x1F) } else { ptg }; match id { @@ -221,9 +244,9 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: 0x10 => BinOp::Union, _ => BinOp::Range, }; - let b = pop(&mut stack)?; - let a = pop(&mut stack)?; - stack.push(Expr::Binary(op, Box::new(a), Box::new(b))); + let b = pop(&mut stack, &mut kids)?; + let a = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Binary(op, Box::new(a), Box::new(b)))?; } 0x12..=0x14 => { let op = match id { @@ -231,18 +254,18 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: 0x13 => UnOp::Neg, _ => UnOp::Percent, }; - let x = pop(&mut stack)?; - stack.push(Expr::Unary(op, Box::new(x))); + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Unary(op, Box::new(x)))?; } 0x15 => { - let x = pop(&mut stack)?; - stack.push(Expr::Paren(Box::new(x))); + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Paren(Box::new(x)))?; } - 0x16 => stack.push(Expr::Missing), + 0x16 => push(&mut stack, kids, Expr::Missing)?, 0x17 => { let cch = usize::from(t.u8()?); let high = t.u8()? & 1 != 0; - stack.push(Expr::Text(Arc::from(t.chars(cch, high)?))); + push(&mut stack, kids, Expr::Text(Arc::from(t.chars(cch, high)?)))?; } 0x19 => { let kind = t.u8()?; @@ -255,34 +278,34 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } if kind & 0x10 != 0 { // PtgAttrSum: SUM of the single operand. - let x = pop(&mut stack)?; - stack.push(Expr::Call("SUM".into(), vec![x])); + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Call("SUM".into(), vec![x]))?; } // Semi (volatile), If, Goto, Space, Baxcel: no effect on the expression. } 0x1C => { let e = t.u8()?; - stack.push(Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?)); + push(&mut stack, kids, Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?))?; } - 0x1D => stack.push(Expr::Bool(t.u8()? != 0)), - 0x1E => stack.push(Expr::Number(f64::from(t.u16()?))), + 0x1D => push(&mut stack, kids, Expr::Bool(t.u8()? != 0))?, + 0x1E => push(&mut stack, kids, Expr::Number(f64::from(t.u16()?)))?, 0x1F => { let n = t.f64()?; if !n.is_finite() { return Err(Unsupported("invalid number constant")); } - stack.push(Expr::Number(n)); + push(&mut stack, kids, Expr::Number(n))?; } 0x20 => { t.take(7)?; - stack.push(array_constant(&mut extra)?); + push(&mut stack, kids, array_constant(&mut extra)?)?; } 0x21 => { let iftab = t.u16()?; let (name, args) = function(iftab).ok_or(Unsupported("unknown function"))?; let n = usize::try_from(args).map_err(|_| Unsupported("unknown function argument count"))?; - let args = pop_n(&mut stack, n)?; - stack.push(Expr::Call(name.to_string(), args)); + let args = pop_n(&mut stack, n, &mut kids)?; + push(&mut stack, kids, Expr::Call(name.to_string(), args))?; } 0x22 => { let n = usize::from(t.u8()?); @@ -290,7 +313,7 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: if tab & 0x8000 != 0 { return Err(Unsupported("macro sheet commands")); } - let mut args = pop_n(&mut stack, n)?; + let mut args = pop_n(&mut stack, n, &mut kids)?; if tab & 0x7FFF == 0x00FF { // A function called by name (add-in or newer function): the name comes first. if args.is_empty() { @@ -300,10 +323,10 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: Expr::Name(n) => function_name(&n), _ => return Err(Unsupported("function call through an expression")), }; - stack.push(Expr::Call(name, args)); + push(&mut stack, kids, Expr::Call(name, args))?; } else { let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?; - stack.push(Expr::Call(name.to_string(), args)); + push(&mut stack, kids, Expr::Call(name.to_string(), args))?; } } 0x23 => { @@ -314,17 +337,17 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: .and_then(|i| links.names.get(i)) .filter(|n| !n.is_empty()) .ok_or(Unsupported("reference to a missing defined name"))?; - stack.push(Expr::Name(name.clone())); + push(&mut stack, kids, Expr::Name(name.clone()))?; } 0x24 | 0x2C => { let (r, c) = (t.u16()?, t.u16()?); let a = anchor(r, c, base, id == 0x2C); - stack.push(Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) })); + push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) }))?; } 0x25 | 0x2D => { let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); let kind = area(r0, r1, c0, c1, base, id == 0x2D); - stack.push(Expr::Ref(Reference { sheet: SheetSel::Current, kind })); + push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind }))?; } 0x26 => { // PtgMemArea: a cache of the sub-expression that follows; skip its extra data. @@ -340,42 +363,50 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } 0x2A => { t.take(4)?; - stack.push(Expr::Error(CellError::Ref)); + push(&mut stack, kids, Expr::Error(CellError::Ref))?; } 0x2B => { t.take(8)?; - stack.push(Expr::Error(CellError::Ref)); + push(&mut stack, kids, Expr::Error(CellError::Ref))?; } 0x39 => { let ixti = t.u16()?; let i = t.u32()?; - stack.push(Expr::Name(links.extern_name(ixti, i)?)); + push(&mut stack, kids, Expr::Name(links.extern_name(ixti, i)?))?; } 0x3A => { let ixti = t.u16()?; let (r, c) = (t.u16()?, t.u16()?); let a = anchor(r, c, base, mode == Mode::Name); - stack.push(match links.sheet_sel(ixti)? { - Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }), - None => Expr::Error(CellError::Ref), - }); + push( + &mut stack, + kids, + match links.sheet_sel(ixti)? { + Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }), + None => Expr::Error(CellError::Ref), + }, + )?; } 0x3B => { let ixti = t.u16()?; let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); let kind = area(r0, r1, c0, c1, base, mode == Mode::Name); - stack.push(match links.sheet_sel(ixti)? { - Some(sheet) => Expr::Ref(Reference { sheet, kind }), - None => Expr::Error(CellError::Ref), - }); + push( + &mut stack, + kids, + match links.sheet_sel(ixti)? { + Some(sheet) => Expr::Ref(Reference { sheet, kind }), + None => Expr::Error(CellError::Ref), + }, + )?; } 0x3C => { t.take(6)?; - stack.push(Expr::Error(CellError::Ref)); + push(&mut stack, kids, Expr::Error(CellError::Ref))?; } 0x3D => { t.take(10)?; - stack.push(Expr::Error(CellError::Ref)); + push(&mut stack, kids, Expr::Error(CellError::Ref))?; } 0x01 => return Err(Unsupported("nested shared or array formula reference")), 0x02 => return Err(Unsupported("what-if data tables")), @@ -386,7 +417,7 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: return Err(Unsupported("formula too complex")); } } - let e = pop(&mut stack)?; + let (e, _) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?; if !stack.is_empty() { return Err(Unsupported("malformed formula (unused operands)")); } @@ -493,4 +524,25 @@ mod tests { assert!(text(&[0x21, 0xFF, 0x7F], &[]).is_err()); assert!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &[0xFF, 0xFF, 0xFF]).is_err()); } + + #[test] + fn deep_nesting_is_refused_not_a_stack_overflow() { + // 1 followed by thousands of PtgParen / PtgUminus tokens: a hostile file used to build a + // 16K-deep tree that overflowed the stack when printed or dropped. + for op in [0x15u8, 0x13] { + let mut rgce = vec![0x1E, 1, 0]; + rgce.resize(MAX_RGCE, op); + assert_eq!(text(&rgce, &[]), Err(Unsupported("formula nested too deeply"))); + } + // Binary chains nest too: 1+1+1+... left-associated. + let mut chain = vec![0x1E, 1, 0]; + while chain.len() + 4 <= MAX_RGCE { + chain.extend([0x1E, 1, 0, 0x03]); + } + assert!(text(&chain, &[]).is_err()); + // Nesting up to the limit still decodes. + let mut ok = vec![0x1E, 1, 0]; + ok.resize(3 + MAX_DEPTH - 1, 0x15); + assert!(text(&ok, &[]).is_ok()); + } } diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index b070478d5..c9aee66a3 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -31,7 +31,9 @@ const MAX_SHEETS: usize = 4096; const MAX_NAMES: usize = 65_536; const MAX_XFS: usize = 65_536; const MAX_MERGES: usize = 100_000; -/// Records read from one substream at most (a 65,536 × 256 sheet has fewer). +/// Records read from the whole Workbook stream at most (a 65,536 × 256 sheet has fewer). One +/// allowance shared by every substream: many BoundSheet records pointing into one huge +/// substream must not each get their own. const MAX_RECORDS: usize = 40_000_000; const BOF: u16 = 0x0809; @@ -53,13 +55,32 @@ pub(crate) fn is_xls(bytes: &[u8]) -> bool { cf.has_root_stream("Workbook") || cf.has_root_stream("Book") } -#[derive(Default)] -struct Budget { +pub(crate) struct Budget { cells: usize, text: usize, + records: usize, + max_records: usize, +} + +impl Default for Budget { + fn default() -> Self { + Budget { cells: 0, text: 0, records: 0, max_records: MAX_RECORDS } + } } impl Budget { + /// A budget with a smaller record allowance (tests). + #[cfg(test)] + pub(crate) fn with_max_records(max_records: usize) -> Self { + Budget { max_records, ..Budget::default() } + } + fn record(&mut self) -> Result<(), IoError> { + self.records = self.records.saturating_add(1); + if self.records > self.max_records { + return Err(IoError::TooLarge("XLS: too many records".into())); + } + Ok(()) + } fn cell(&mut self) -> Result<(), IoError> { self.cells += 1; if self.cells > MAX_CELLS { @@ -106,6 +127,11 @@ struct Globals { /// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + read_xls_with(bytes, Budget::default()) +} + +/// [`read_xls`] with an explicit resource budget. +pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workbook, ReadReport), IoError> { let cf = Compound::open(bytes).map_err(invalid)?; let stream = match cf.root_stream("Workbook").map_err(invalid)? { Some(s) => s, @@ -117,7 +143,6 @@ pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } }; let mut report = ReadReport::default(); - let mut budget = Budget::default(); let g = globals(&stream, &mut budget)?; let mut wb = Workbook::new(); @@ -195,12 +220,9 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { return Err(invalid("the Workbook stream does not start with the workbook globals")); } let mut g = Globals::default(); - let mut count = 0usize; + let mut offsets = std::collections::HashSet::new(); while let Some(rec) = recs.next_record()? { - count += 1; - if count > MAX_RECORDS { - return Err(IoError::TooLarge("XLS: too many records".into())); - } + budget.record()?; let mut c = Cursor::new(&rec.parts); match rec.id { EOF => break, @@ -210,6 +232,9 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { return Err(IoError::TooLarge("XLS: too many sheets".into())); } let pos = c.u32()? as usize; + if !offsets.insert(pos) { + return Err(invalid("two sheets point at the same substream")); + } let state = c.u8()?; let kind = c.u8()?; let name = c.short_string()?; @@ -460,12 +485,8 @@ fn worksheet( unsupported: vec![], }; let mut depth = 1usize; - let mut count = 0usize; while let Some(rec) = recs.next_record()? { - count += 1; - if count > MAX_RECORDS { - return Err(IoError::TooLarge("XLS: too many records in a sheet".into())); - } + budget.record()?; match rec.id { BOF => { // An embedded chart's substream. From c83f214c9d16cd145e971c63c6ac3dae9320c38a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:00:27 -0400 Subject: [PATCH 095/102] Spanish, German and Czech strings for comment navigation (review fix-up for #286) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 6 ++++++ crates/ui-egui/src/i18n/de.rs | 6 ++++++ crates/ui-egui/src/i18n/es.rs | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index f8605b54f..0d01ba921 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Previous" => "Předchozí", + "Next" => "Další", + "Previous Comment" => "Předchozí komentář", + "Next Comment" => "Další komentář", + "Show All Notes" => "Zobrazit všechny poznámky", + "Convert to Comments" => "Převést na komentáře", "Checkbox" => "Zaškrtávací políčko", "Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk", "Sheet\nRight-to-Left" => "List zprava\ndoleva", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index e0f350884..c5f71196e 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Previous" => "Zurück", + "Next" => "Weiter", + "Previous Comment" => "Vorheriger Kommentar", + "Next Comment" => "Nächster Kommentar", + "Show All Notes" => "Alle Notizen anzeigen", + "Convert to Comments" => "In Kommentare konvertieren", "Checkbox" => "Kontrollkästchen", "Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen", "Sheet\nRight-to-Left" => "Blatt von rechts\nnach links", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index fe6f36873..7cf8c9a5a 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Previous" => "Anterior", + "Next" => "Siguiente", + "Previous Comment" => "Comentario anterior", + "Next Comment" => "Comentario siguiente", + "Show All Notes" => "Mostrar todas las notas", + "Convert to Comments" => "Convertir en comentarios", "Checkbox" => "Casilla", "Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas", "Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda", From e52fc4aa08b5f1b6456507566e3eaf2ede6a9b10 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:05:45 -0400 Subject: [PATCH 096/102] Fix-up #333: escape kept content types and charge kept VML to the kept total Content types (and extensions) of kept parts come from the input file and were written into [Content_Types].xml unescaped, so a quote or angle bracket injected XML; they are now attribute-escaped. Each sheet re-read and kept its VML drawing (up to 16 MB) outside MAX_KEPT_TOTAL, so many sheets sharing one highly compressed drawing amplified memory; the drawing is now read within and charged to the remaining kept-bytes allowance. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/passthrough.rs | 30 +++++++++- crates/xlsx/src/tests/passthrough_tests.rs | 65 ++++++++++++++++++++-- crates/xlsx/src/write.rs | 5 +- 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index 187009bca..f121977e2 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -32,6 +32,7 @@ use quick_xml::events::{BytesStart, Event}; use quick_xml::name::QName; use quick_xml::{Reader, Writer}; +use crate::IoError; use crate::package::{self, normalize}; use crate::read::Ctx; use crate::write::{NS_REL, Out, Rels, XML_DECL}; @@ -54,6 +55,23 @@ const MAX_FRAGMENT: usize = 4 * 1024 * 1024; /// Deepest element nesting accepted in a kept fragment. const MAX_FRAGMENT_DEPTH: usize = 256; +#[cfg(test)] +thread_local! { + /// A smaller kept-bytes allowance for tests on this thread. + pub(crate) static KEPT_TOTAL_FOR_TEST: std::cell::Cell = const { std::cell::Cell::new(MAX_KEPT_TOTAL) }; +} + +fn kept_total() -> usize { + #[cfg(test)] + { + KEPT_TOTAL_FOR_TEST.with(std::cell::Cell::get) + } + #[cfg(not(test))] + { + MAX_KEPT_TOTAL + } +} + const NS_REL_STRICT: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships"; const CT_VML: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing"; @@ -194,7 +212,7 @@ impl KeptReader { strict: false, defaults: HashMap::new(), overrides: HashMap::new(), - budget: MAX_KEPT_TOTAL, + budget: kept_total(), parts_left: MAX_KEPT_PARTS, }; if let Ok(root) = raw_rels(cx, "") @@ -446,11 +464,19 @@ impl KeptReader { seen: &mut HashSet, ) -> Result<(PreservedPart, Vec), String> { let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?; - let data = match cx.pkg.read_limited(&rel.target, MAX_META) { + // Charged to the kept total like any other part: many sheets can point at one highly + // compressed VML drawing, and each keeps its own copy. + if self.budget == 0 { + return Err("it is too large".into()); + } + let limit = MAX_META.min(u64::try_from(self.budget).unwrap_or(u64::MAX)); + let data = match cx.pkg.read_limited(&rel.target, limit) { Ok(Some(d)) => d, Ok(None) => return Err(format!("{} is missing", rel.target)), + Err(IoError::TooLarge(_)) => return Err("it is too large".into()), Err(e) => return Err(e.to_string()), }; + self.budget = self.budget.saturating_sub(data.len()); let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?; let text = strip_notes(&text); let vrels = raw_rels(cx, &rel.target)?; diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 10b2681d7..4cde9d69e 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -45,14 +45,23 @@ const VML: &str = r##" Vec { let workbook = format!( r#""# ); let sheet1 = format!( r#"[1]Sheet1!$A$17SUM([1]Sheet1!A1:A3)*21410103050"# ); - let sheet2 = - format!(r#"1"#); + let sheet2 = if twin { + sheet1.clone() + } else { + format!(r#"1"#) + }; let root_rels = rels(&[("rId1", "officeDocument", "xl/workbook.xml"), ("rId2", "custom-properties", "docProps/custom.xml")]); let wb_rels = rels(&[ ("rId1", "worksheet", "worksheets/sheet1.xml"), @@ -74,8 +83,8 @@ fn rich_package() -> Vec { let link_rels = rels(&[("rId1", "externalLinkPath", "file:///C:/never/opened/Book2.xlsx")]); let vml_rels = rels(&[("rId1", "image", "../media/image1.emf")]); let item_props = r#""#; - make_zip(&[ - ("[Content_Types].xml", CONTENT_TYPES.as_bytes()), + let mut files: Vec<(&str, &[u8])> = vec![ + ("[Content_Types].xml", content_types.as_bytes()), ("_rels/.rels", root_rels.as_bytes()), ("docProps/custom.xml", CUSTOM_PROPS.as_bytes()), ("xl/workbook.xml", workbook.as_bytes()), @@ -97,7 +106,11 @@ fn rich_package() -> Vec { ("xl/externalLinks/externalLink1.xml", EXTERNAL_LINK.as_bytes()), ("xl/externalLinks/_rels/externalLink1.xml.rels", link_rels.as_bytes()), ("xl/connections.xml", CONNECTIONS.as_bytes()), - ]) + ]; + if twin { + files.push(("xl/worksheets/_rels/sheet2.xml.rels", sheet1_rels.as_bytes())); + } + make_zip(&files) } /// An unpacked package: parts by name, content types, resolved relationships. @@ -428,3 +441,45 @@ fn oversized_kept_parts_are_dropped() { assert!(wb.preserved.is_none()); assert!(rep.warnings.iter().any(|w| w.contains("customXml") && w.contains("dropped")), "{:?}", rep.warnings); } + +#[test] +fn kept_content_types_are_escaped_on_save() { + // A content type holding `"` and `<` (legal once unescaped by the XML reader) must not break + // out of the attribute in the saved [Content_Types].xml. + let hostile = r#"application/x-test" PartName="/x"/><Default Extension="evil"#; + let ct = CONTENT_TYPES.replace("application/vnd.openxmlformats-officedocument.customXmlProperties+xml", hostile); + let (wb, rep) = read_xlsx(&rich_package_with(&ct, false)).unwrap(); + assert!(wb.preserved.is_some(), "{:?}", rep.warnings); + let u = Unpacked::new(&write_xlsx(&wb).unwrap()); + let types = xml::parse(u.text("[Content_Types].xml").as_bytes()).unwrap(); + assert!(!types.kids("Default").any(|d| d.attr("Extension") == Some("evil"))); + let item = types.kids("Override").find(|o| o.attr("PartName") == Some("/customXml/itemProps1.xml")).unwrap(); + assert_eq!(item.attr("ContentType"), Some(r#"application/x-test" PartName="/x"/>() + }; + // Two sheets share one VML drawing: each would keep its own copy. With room for one sheet's + // content only, the second sheet's VML (and the controls drawn from it) is dropped. + crate::passthrough::KEPT_TOTAL_FOR_TEST.with(|c| c.set(one + VML.len() / 2)); + let read = read_xlsx(&rich_package_with(CONTENT_TYPES, true)); + crate::passthrough::KEPT_TOTAL_FOR_TEST.with(|c| c.set(crate::passthrough::MAX_KEPT_TOTAL)); + let (wb, rep) = read.unwrap(); + let kept = wb.preserved.as_ref().map_or(0, |p| p.byte_len()) + + wb.sheets.iter().filter_map(|s| s.preserved.as_ref()).map(|p| p.byte_len()).sum::(); + assert!(kept <= one + VML.len() / 2, "kept {kept} bytes"); + assert!(wb.sheet(0).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some())); + assert!(!wb.sheet(1).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some())); + assert!(rep.warnings.iter().any(|w| w.contains("\"Other\"") && w.contains("too large")), "{:?}", rep.warnings); + // Both sheets keep their drawings when there is room. + 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()))); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 95fbed9c6..dc2ddc484 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -375,10 +375,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { // Content types first, then the parts in creation order. let mut ct = format!("{XML_DECL}"); for (ext, t) in &out.defaults { - let _ = write!(ct, ""); + // Kept parts bring their content types (and extensions) from the input file: escape them. + let _ = write!(ct, "", esc_attr(ext), esc_attr(t)); } for (part, t) in &out.overrides { - let _ = write!(ct, "", esc_attr(part)); + let _ = write!(ct, "", esc_attr(part), esc_attr(t)); } ct.push_str(""); let mut zip = ZipOut::new(); From 1fb5f3264d1b4f222bfe6a1636ba396490f5c704 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:12:23 -0400 Subject: [PATCH 097/102] Spanish, German and Czech strings for Insert Copied/Cut Cells (review fix-up for #288) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 4 ++++ crates/ui-egui/src/i18n/de.rs | 4 ++++ crates/ui-egui/src/i18n/es.rs | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 0d01ba921..96b760408 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Insert Copied Cells" => "Vložit zkopírované buňky", + "Insert Copied Cells…" => "Vložit zkopírované buňky…", + "Insert Cut Cells" => "Vložit vyjmuté buňky", + "Insert Paste" => "Vložit a posunout", "Previous" => "Předchozí", "Next" => "Další", "Previous Comment" => "Předchozí komentář", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index c5f71196e..1fbf2e3bc 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Insert Copied Cells" => "Kopierte Zellen einfügen", + "Insert Copied Cells…" => "Kopierte Zellen einfügen…", + "Insert Cut Cells" => "Ausgeschnittene Zellen einfügen", + "Insert Paste" => "Einfügen und verschieben", "Previous" => "Zurück", "Next" => "Weiter", "Previous Comment" => "Vorheriger Kommentar", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 7cf8c9a5a..00fa6b93d 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Insert Copied Cells" => "Insertar celdas copiadas", + "Insert Copied Cells…" => "Insertar celdas copiadas…", + "Insert Cut Cells" => "Insertar celdas cortadas", + "Insert Paste" => "Insertar y pegar", "Previous" => "Anterior", "Next" => "Siguiente", "Previous Comment" => "Comentario anterior", From a0cb8f2ca286f82c60b68dc4a24a268963288be8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:13:01 -0400 Subject: [PATCH 098/102] Docs: catalog 271 / 290 after #286 and #288 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++-- docs/target-app-parity.md | 4 ++-- docs/ui-parity.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d44c74345..2ddc6b769 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-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 266 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 271 / 290) · **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. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 266 / 290 (91.7%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 271 / 290 (93.4%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 05ce81861..3eef3327a 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 (community merges: worksheet functions 513 / 523, catalog 266 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 271 / 290) · **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: @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **266 / 290 (91.7%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **271 / 290 (93.4%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 41e19b8b7..2af6715a9 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -4,7 +4,7 @@ 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 -separately in [parity-checklist.md](parity-checklist.md) (266 / 290). +separately in [parity-checklist.md](parity-checklist.md) (271 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes From 27ef18ec2ce9c4ed9b5b71fa4480094a891d591f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:14:30 -0400 Subject: [PATCH 099/102] symbol_glyphs test: compare against a private-use code point no font covers (review fix-up for #298) U+25FB is a real glyph in DejaVu, so the test could pass while symbols still rendered as boxes. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/symbol_glyphs.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/symbol_glyphs.rs b/crates/ui-egui/tests/symbol_glyphs.rs index 5e98daeef..7f2a55f74 100644 --- a/crates/ui-egui/tests/symbol_glyphs.rs +++ b/crates/ui-egui/tests/symbol_glyphs.rs @@ -22,7 +22,8 @@ fn ui_symbols_have_glyphs_when_a_symbol_face_is_installed() { let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK)); galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.chr, g.uv_rect))).collect::>() }; - let tofu: Vec<_> = glyphs("\u{25FB}").into_iter().map(|(_, uv)| uv).collect(); + // A private-use code point no font covers renders as the replacement box. + let tofu: Vec<_> = glyphs("\u{10FFFD}").into_iter().map(|(_, uv)| uv).collect(); let missing: String = glyphs(UI_SYMBOLS).into_iter().filter(|(_, uv)| tofu.contains(uv)).map(|(c, _)| c).collect(); assert!(missing.is_empty(), "these interface symbols render as boxes in {:?}: {missing}", font.family); } From baffd3c78d9fc57e2d0f0abff0f0a3e86f18309f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:30:47 -0400 Subject: [PATCH 100/102] Fix-up #325: cap Forecast Sheet history and forecast rows The default periods (a third of the history, up to ~33k) bypassed MAX_PERIODS, and each forecast row's three ETS formulas refit over the whole history, so 100k history rows x 10k periods froze the app. History is now at most 10,000 rows, periods at most 10,000, and history x periods at most 500,000 (about 3 us per history point per fit: a few seconds of recalc at the cap). The default is clamped to the cap; explicit periods or an end past it are refused. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/forecast.rs | 69 +++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 5bcd3d78e..f53ab6603 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -16,9 +16,25 @@ use crate::selection::current_region; const CMD: &str = "data.forecastSheet"; /// Most history rows a forecast sheet copies. -const MAX_HISTORY: u64 = 100_000; +const MAX_HISTORY: u64 = 10_000; /// Most forecast rows. const MAX_PERIODS: u32 = 10_000; +/// Most history rows × forecast rows. Every forecast row holds three ETS formulas that each refit +/// the model over the whole history (about 3 µs per history point in a release build), so this +/// bounds a sheet's recalculation to a few seconds: 10,000 rows of history get 50 forecast rows, +/// 50 rows of history the full 10,000. +const MAX_WORK: u64 = 500_000; + +/// Forecast rows by default: a third of the history, within [`max_periods`]. +fn default_periods(history: usize) -> u32 { + u32::try_from(history.div_ceil(3)).unwrap_or(u32::MAX).clamp(1, max_periods(history)) +} + +/// Most forecast rows for `history` rows of history. +fn max_periods(history: usize) -> u32 { + let h = u64::try_from(history).unwrap_or(u64::MAX).max(1); + u32::try_from(MAX_WORK / h).unwrap_or(u32::MAX).clamp(1, MAX_PERIODS) +} pub fn specs() -> Vec { vec![cmd!( @@ -26,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), 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), 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 )] @@ -215,12 +231,13 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { }, _ => None, }; + let cap = max_periods(rows.len()); let periods = match end { - Some(end) => steps_to(&timeline, end)?, + Some(end) => steps_to(&timeline, end, cap)?, None => match number(p, "periods")? { - Some(n) if n >= 1.0 && n <= f64::from(MAX_PERIODS) => n.ceil() as u32, - Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {MAX_PERIODS}"))), - None => (rows.len() as u32).div_ceil(3).max(1), + Some(n) if n >= 1.0 && n.ceil() <= f64::from(cap) => n.ceil() as u32, + Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {cap} for {} rows of history", rows.len()))), + None => default_periods(rows.len()), }, }; let future: Vec = @@ -392,14 +409,14 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { } /// Forecast rows needed to reach `end` (rounded up to whole steps). -fn steps_to(timeline: &Timeline, end: f64) -> Result { +fn steps_to(timeline: &Timeline, end: f64, cap: u32) -> Result { let steps = timeline.steps_after_last(end).ok_or_else(|| bad(CMD, "`end` is not on the timeline"))?; if steps < 1e-9 { return Err(EngineError::Other("The forecast end must be after the last point of the timeline.".into())); } let steps = steps.ceil(); - if steps > f64::from(MAX_PERIODS) { - return Err(bad(CMD, format!("at most {MAX_PERIODS} forecast steps"))); + if steps > f64::from(cap) { + return Err(bad(CMD, format!("at most {cap} forecast steps for this much history"))); } Ok(steps as u32) } @@ -469,6 +486,40 @@ mod tests { assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1); } + #[test] + fn history_and_periods_are_capped() { + use super::{MAX_HISTORY, MAX_PERIODS, MAX_WORK, default_periods, max_periods}; + // The default (a third of the history) never exceeds the caps. + assert_eq!(default_periods(16), 6); + assert_eq!(default_periods(1_200), 400); + assert_eq!(default_periods(3_000), 166); + assert_eq!(default_periods(100_000), 5); + assert_eq!(max_periods(10), MAX_PERIODS); + assert_eq!(max_periods(0), MAX_PERIODS); + for h in [1usize, 2, 3, 50, 51, 1_000, 1_300, 3_000, 10_000, 100_000, usize::MAX] { + let (d, m) = (default_periods(h), max_periods(h)); + assert!(d >= 1 && d <= m && m <= MAX_PERIODS, "{h}: {d} {m}"); + assert!(u64::try_from(h).unwrap_or(u64::MAX).saturating_mul(u64::from(m)) <= MAX_WORK.max(u64::try_from(h).unwrap_or(u64::MAX)), "{h}"); + } + // Explicit periods or an end past the cap are refused before anything is built. + let mut s = Session::new(); + s.new_workbook(); + let mut rows = vec![json!(["t", "y"])]; + rows.extend((1..=1_300).map(|t| json!([t, (t % 7) as f64 + t as f64 * 0.5]))); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + let sheets = s.doc().unwrap().wb.sheets.len(); + let cap = max_periods(1_300); // 384 + let err = |r: crate::Result| r.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": cap + 1}))).contains("from 1 to 384")); + assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "end": 1_300 + cap + 1}))).contains("at most 384")); + assert!(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": 10_000})).is_err()); + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets); + // More history rows than MAX_HISTORY are refused. + let big = MAX_HISTORY + 2; + let r = s.execute("data.forecastSheet", json!({"timeline": format!("A1:A{big}"), "values": format!("B1:B{big}")})); + assert!(r.is_err()); + } + #[test] fn forecast_sheet_parameters_and_errors() { let mut s = session_with_history(); From 8fcb9b1007e7a5193efd5899559b83816e81c58a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 14:15:18 -0400 Subject: [PATCH 101/102] Fix-up: es/de/cs strings for Forecast Sheet (#325) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/es.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 6ca8162b9..65e9defc0 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Forecast\nSheet" => "List\nprognózy", "Add footer" => "Přidat zápatí", "Add header" => "Přidat záhlaví", "Click to add data" => "Kliknutím přidáte data", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index af76af0ef..5cb9881b7 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Forecast\nSheet" => "Prognose-\nblatt", "Add footer" => "Fußzeile hinzufügen", "Add header" => "Kopfzeile hinzufügen", "Click to add data" => "Klicken, um Daten hinzuzufügen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 96d250aec..b30a1d132 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Forecast\nSheet" => "Hoja de\npronóstico", "Add footer" => "Agregar pie de página", "Add header" => "Agregar encabezado", "Click to add data" => "Haga clic para agregar datos", From fb8e8ac8f1f7c56ecb6a3ad5729cfaeb9797e35d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 14:22:39 -0400 Subject: [PATCH 102/102] Docs: catalog 272 / 290 after #325 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++-- docs/parity-checklist.md | 4 ++-- docs/target-app-parity.md | 4 ++-- docs/ui-parity.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5cd6bf601..a62418e19 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-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 271 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 272 / 290) · **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. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 271 / 290 (93.4%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 272 / 290 (93.8%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8d84e4d0f..3fa0ef0a0 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 271 / 290 commands implemented (93.4%).** +**Overall: 272 / 290 commands implemented (93.8%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -14,7 +14,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | -| Data | 22 | 28 | 79% | +| Data | 23 | 28 | 82% | | Review | 15 | 18 | 83% | | View | 20 | 20 | 100% | | Automate | 3 | 3 | 100% | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 86234f851..f68d716db 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 (community merges: worksheet functions 513 / 523, catalog 271 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 272 / 290) · **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: @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **271 / 290 (93.4%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **272 / 290 (93.8%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d7ce163fd..e56770edc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -4,7 +4,7 @@ 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 -separately in [parity-checklist.md](parity-checklist.md) (271 / 290). +separately in [parity-checklist.md](parity-checklist.md) (272 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes

1