diff --git a/Cargo.lock b/Cargo.lock index 9aca868e0..319b0be76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1589,6 +1589,7 @@ version = "0.5.0" dependencies = [ "egui", "egui_kittest", + "epaint_default_fonts", "gridcraft-chart", "gridcraft-engine", "image", @@ -1599,6 +1600,7 @@ dependencies = [ "sys-locale", "toml", "web-sys", + "zbus", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0c0001419..128ed07d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,8 @@ sys-locale = "0.3" # Font parser egui's epaint uses; the UI validates system font files with it before handing them # to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy. skrifa = { version = "0.44", default-features = false, features = ["std"] } +# Real font bytes for tests (Ubuntu Light, compiled into epaint). +epaint_default_fonts = "0.36" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 72a17349b..678495f1d 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. @@ -241,6 +242,11 @@ the first renderer fails to start, it tries the other one once. The log file rec (lines starting with `graphics:`). `WGPU_BACKEND` still overrides it (and then nothing else is tried), e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`. +DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or +`dxil.dll` is needed, and a copy another program put on `PATH` can no longer stop the app at start +("Parent device is lost", #339). `$env:WGPU_DX12_COMPILER = "dxc"` (or `"auto"`) selects wgpu's DXC +handling again for troubleshooting. + ### macOS | Build | File | Notes | diff --git a/ROADMAP.md b/ROADMAP.md index d44c74345..58082d0ed 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 274 / 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 274 / 290 (94.5%), 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) | @@ -132,6 +132,16 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) | +| 2026-10-11 | Merged cells draw their whole outline: right and bottom edges come from the last column's and last row's cells (#376) | +| 2026-10-11 | Shrink to fit draws too-wide text and numbers in a smaller font inside the cell or merged area (#375) | +| 2026-10-11 | Shift+Space and Ctrl+Space select the row / column without starting an edit; Control+Space on Mac (#338) | +| 2026-10-11 | Workbooks reopen at their saved scroll position (`topLeftCell`, also with frozen panes) and save the current one back (#378) | +| 2026-10-11 | Protect Sheet passwords use Excel-compatible hashes and round-trip in XLSX `sheetProtection` (imported passwords verify instead of being dropped) | +| 2026-10-11 | Review › Protect Workbook opens a dialog with an optional password (Excel-compatible hash, kept in XLSX `workbookProtection`); unprotecting verifies it (#345) | +| 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | +| 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-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | | 2026-10-11 | Startup no longer panics on a system font egui can't parse: fonts are validated and skipped (#226) | | 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) | | 2026-10-11 | Odd-period bond functions ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD: Financial 55 / 55 (#275) | @@ -161,6 +171,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | | 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row | | 2026-10-11 | minor | Worksheet functions 502 / 523 (BAHTTEXT); progress log row | diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 356c63b84..0f69e38e7 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index f5744bd19..8b1e51da2 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 { @@ -136,6 +136,11 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { log::error!("download of {name} failed: {e}"); } })), + print: Some(Box::new(|bytes: &[u8]| { + if let Err(e) = print_pdf(bytes) { + log::error!("print failed: {e}"); + } + })), open_url: Some(Box::new(|url: &str| { if let Some(w) = web_sys::window() { let _ = w.open_with_url_and_target(url, "_blank"); @@ -179,6 +184,48 @@ fn copy_html(html: &str, text: &str) -> Result<(), String> { Ok(()) } +/// Opens `bytes` (a PDF) as an object URL in a hidden iframe and calls the iframe's own +/// `print()` once it has finished loading — the standard way to drive the browser's native print +/// dialog on a PDF without a download or a popup window the browser might block. The blob URL and +/// the iframe are both cleaned up once the print dialog closes, so repeated prints don't leak +/// them. +fn print_pdf(bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type("application/pdf"); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; + + let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?; + iframe.style().set_property("display", "none").map_err(js)?; + document.body().ok_or("no body")?.append_child(&iframe).map_err(js)?; + + // `onload` fires once the PDF has actually rendered inside the iframe; + // printing before that would show a blank page. Set the handler BEFORE + // `src` so a fast/cached load can't fire before we're listening. + let iframe_for_load = iframe.clone(); + let url_for_cleanup = url.clone(); + let on_load = wasm_bindgen::closure::Closure::once_into_js(move || { + let Some(content_window) = iframe_for_load.content_window() else { return }; + let _ = content_window.print(); + // `afterprint` fires once the print dialog (or print preview) closes, whether the user + // printed or cancelled — the right moment to release the blob URL and drop the iframe. + let iframe_for_cleanup = iframe_for_load.clone(); + let on_after_print = wasm_bindgen::closure::Closure::once_into_js(move || { + web_sys::Url::revoke_object_url(&url_for_cleanup).ok(); + iframe_for_cleanup.remove(); + }); + let _ = content_window.add_event_listener_with_callback("afterprint", on_after_print.unchecked_ref()); + }); + iframe.set_onload(Some(on_load.unchecked_ref())); + iframe.set_src(&url); + Ok(()) +} + /// Triggers a browser download of `bytes`. fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); diff --git a/apps/gridcraft/src/graphics.rs b/apps/gridcraft/src/graphics.rs index 14344b4be..83478edde 100644 --- a/apps/gridcraft/src/graphics.rs +++ b/apps/gridcraft/src/graphics.rs @@ -3,11 +3,15 @@ //! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters //! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access //! violation happens in the driver, where no Rust error handling can catch it. +//! +//! DirectX 12 shaders are compiled with FXC (`d3dcompiler_47.dll`, part of Windows), never with +//! whichever `dxcompiler.dll` the DLL search path happens to find (#339, #354): see +//! [`dx12_compiler`]. use eframe::{ NativeOptions, egui_wgpu::WgpuSetup, - wgpu::{Backends, DeviceType}, + wgpu::{Backends, DeviceType, Dx12Compiler}, }; /// What the start-up probe learned about one DirectX 12 adapter. @@ -59,9 +63,24 @@ pub fn fallback_backends(backend_override: Option, first: Backends) -> (!rest.is_empty()).then_some(rest) } -/// Restricts the wgpu instance eframe creates to `backends`. +/// The DirectX 12 shader compiler: `env_choice` (normally `Dx12Compiler::from_env()`, i.e. +/// `WGPU_DX12_COMPILER`, for troubleshooting) or else FXC. +/// +/// wgpu's default (`Auto`) loads the first `dxcompiler.dll` on the DLL search path. GridCraft ships +/// none, so that is another program's copy (a Windows SDK, an Edge or WebView folder, anything on +/// `PATH`). Without a matching `dxil.dll` beside it, DXC prints "DXIL signing library not found", +/// wgpu treats that warning as a compile error, its internal compute pipelines fail, the device is +/// lost and the app quits before the first frame ("Parent device is lost"). FXC is part of Windows +/// and needs no extra DLL; egui's shaders don't need anything newer. +pub fn dx12_compiler(env_choice: Option) -> Dx12Compiler { + env_choice.unwrap_or(Dx12Compiler::Fxc) +} + +/// Restricts the wgpu instance eframe creates to `backends`, and pins its DirectX 12 shader +/// compiler ([`dx12_compiler`]). pub fn configure_backends(options: &mut NativeOptions, backends: Backends) { if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(Dx12Compiler::from_env()); // eframe's default includes Vulkan and GL. Creating the GL backend can crash inside AMD's // atio6axx.dll, and enumerating Vulkan adapters inside Intel's igvk64.dll (#120), before // adapter selection or any Rust error handling runs, so the window never appears. @@ -189,4 +208,25 @@ mod tests { // Both were already tried: nothing left to fall back to. assert_eq!(fallback_backends(None, Backends::DX12 | Backends::GL), None); } + + #[test] + fn dx12_compiles_shaders_with_fxc_unless_asked() { + // #339, #354: wgpu's `Auto` would load a stray dxcompiler.dll and lose the device. + assert!(matches!(dx12_compiler(None), Dx12Compiler::Fxc)); + assert!(matches!(dx12_compiler(Some(Dx12Compiler::Auto)), Dx12Compiler::Auto)); + assert!(matches!(dx12_compiler(Some(Dx12Compiler::default_dynamic_dxc())), Dx12Compiler::DynamicDxc { .. })); + // eframe's own default is `Auto`; the configured instance (first start and retry) uses FXC. + let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + if Dx12Compiler::from_env().is_none() { + assert!(matches!(eframe_default.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Auto)); + let mut options = NativeOptions::default(); + configure_backends(&mut options, Backends::DX12); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc)); + } + } } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 961a352e2..b7e15cf65 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -150,8 +150,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"]) @@ -271,6 +272,7 @@ fn main() -> eframe::Result<()> { session.new_workbook(); } let mut app = SheetApp::new(session, services()); + app.watch_desktop_theme(); app.after_engine(); // Show warnings from files opened on the command line. let control_port = control_port; load_prefs(&mut app); diff --git a/contributors/people.toml b/contributors/people.toml index 6d4b23e4c..681a4dda5 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -29,6 +29,11 @@ sync_github_name = false [people.edurbs] sync_github_name = true +[people.arcanii] +real_name = "Bryan Mark" +display_name = "Arcanii" +sync_github_name = false + [people.vangel76] real_name = "Valentin Klein" sync_github_name = true diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 3d4a743d9..d31a99722 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -249,6 +249,13 @@ impl<'h> Evaluator<'h> { let scope = def.scope.unwrap_or(self.sheet); return match gridcraft_formula::parse(&text) { Ok(expr) => { + // Relative references in a name are relative to A1: used here, they move by + // this cell's offset from it. + let expr = if gridcraft_formula::adjust::has_relative_refs(&expr) { + gridcraft_formula::adjust::shift_relative_wrapping(expr, self.at.row as i64, self.at.col as i64) + } else { + expr + }; let saved = self.sheet; // Unqualified references in a name refer to the scope sheet. self.sheet = scope; @@ -681,6 +688,7 @@ impl<'h> Evaluator<'h> { "OFFSET" => self.offset(args), "INDIRECT" => self.indirect(args), "INDEX" => self.index(args), + "XLOOKUP" => self.xlookup(args), "CELL" => self.cell_info(args), "TRIMRANGE" => self.trimrange(args), "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), @@ -1085,18 +1093,57 @@ impl<'h> Evaluator<'h> { return err(CellError::Value); } let src = self.eval(&args[0]); - let row = match self.arg_num(args, 1, 0.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let col = match self.arg_num(args, 2, 0.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), + let mut arg = |i: usize| match args.get(i) { + None | Some(Expr::Missing) => None, + Some(e) => Some(self.value(e)), }; + let (rows, cols) = (arg(1), arg(2)); let area_n = match self.arg_num(args, 3, 1.0) { Ok(n) => n.trunc() as i64, Err(e) => return err(e), }; + let two = args.len() == 2; + let num = |v: &Option| v.as_ref().map_or(Ok(0.0), |v| v.to_number()).map(|n| n.trunc() as i64); + // Arrays of positions pick a value for each: INDEX(A1:C10,{1;2},{1,3}) is 2×2. + if let (Some(Value::Array(_)), _) | (_, Some(Value::Array(_))) = (&rows, &cols) { + let as_array = |v: &Option| match v { + Some(Value::Array(a)) => Arc::clone(a), + Some(v) => Arc::new(Array::scalar(v.clone())), + None => Arc::new(Array::scalar(Value::Number(0.0))), + }; + let (ra, ca) = (as_array(&rows), as_array(&cols)); + let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols)); + if (h as u64).saturating_mul(w as u64) > MAX_CELLS { + return err(CellError::Num); + } + let mut out = Vec::with_capacity(h * w); + for r in 0..h { + for c in 0..w { + let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c)))); + let v = match pos { + (Ok(row), Ok(col)) => { + let one = self.index_one(src.clone(), row, col, area_n, two); + match self.deref(one) { + Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc), + v => v.scalar(), + } + } + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }; + out.push(v); + } + } + return Ev::V(Array::new(h, w, out).map(Value::from).unwrap_or(Value::Error(CellError::Value))); + } + let (row, col) = match (num(&rows), num(&cols)) { + (Ok(r), Ok(c)) => (r, c), + (Err(e), _) | (_, Err(e)) => return err(e), + }; + self.index_one(src, row, col, area_n, two) + } + + /// INDEX for one row and column position (0 = the whole column or row). + fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev { if row < 0 || col < 0 { return err(CellError::Value); } @@ -1105,7 +1152,7 @@ impl<'h> Evaluator<'h> { let Some(a) = areas.get((area_n - 1).max(0) as usize).copied() else { return err(CellError::Ref) }; let r = a.range; // A single row or column with one index picks along it. - let (row, col) = if args.len() == 2 && r.height() == 1 { (1, row) } else { (row, col) }; + let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) }; if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { return err(CellError::Ref); } @@ -1114,7 +1161,7 @@ impl<'h> Evaluator<'h> { Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }]) } Ev::V(Value::Array(arr)) => { - let (row, col) = if args.len() == 2 && arr.rows == 1 { (1, row) } else { (row, col) }; + let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) }; if row as usize > arr.rows || col as usize > arr.cols { return err(CellError::Ref); } @@ -1137,6 +1184,66 @@ impl<'h> Evaluator<'h> { } } + /// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can + /// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of + /// lookup values, array arguments, errors) goes to the library function. + fn xlookup(&mut self, args: &[Expr]) -> Ev { + if (3..=6).contains(&args.len()) + && let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2])) + && let ([l], [r]) = (look.as_slice(), ret.as_slice()) + && let Some(spec) = gridcraft_functions::lookup("XMATCH") + { + let key = self.value(&args[0]); + let (l, r) = (*l, *r); + let vertical = l.range.width() == 1 && l.range.height() > 1; + let horizontal = l.range.height() == 1 && l.range.width() > 1; + let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() }; + if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len { + let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }]; + // An empty match_mode before a given search_mode is the default (0), not the end. + let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing)); + let last = if given(5) { + 5 + } else if given(4) { + 4 + } else { + 3 + }; + for i in 4..=last { + match args.get(i) { + Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }), + Some(e) => { + let value = self.value(e); + xargs.push(Arg { value, from_ref: false }); + } + } + } + let mut ctx = FnCtx { ev: self }; + return match gridcraft_functions::call(spec, &xargs, &mut ctx) { + Value::Number(p) if p >= 1.0 => { + let k = p as u32 - 1; + let range = if vertical { + RangeRef::new( + CellRef::new(r.range.start.row + k, r.range.start.col), + CellRef::new(r.range.start.row + k, r.range.end.col), + ) + } else { + RangeRef::new( + CellRef::new(r.range.start.row, r.range.start.col + k), + CellRef::new(r.range.end.row, r.range.start.col + k), + ) + }; + Ev::R(vec![Area { sheet: r.sheet, range }]) + } + Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]), + Value::Error(e) => err(e), + _ => err(CellError::Value), + }; + } + } + self.builtin("XLOOKUP", args) + } + fn cell_info(&mut self, args: &[Expr]) -> Ev { let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { Some(Ok(t)) => t.to_ascii_lowercase(), diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 2205a1e61..791da3557 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -94,6 +94,39 @@ fn chains_out_of_order() { assert_eq!(t.num("A5"), 5.0); } +#[test] +fn xlookup_returns_a_reference() { + let mut t = T::new(); + for (i, (k, v)) in [("a", 1), ("b", 2), ("c", 3), ("d", 4)].iter().enumerate() { + t.set(&format!("A{}", i + 1), k); + t.set(&format!("B{}", i + 1), &v.to_string()); + } + t.set("D1", "=SUM(XLOOKUP(\"b\",A1:A4,B1:B4):XLOOKUP(\"d\",A1:A4,B1:B4))"); + assert_eq!(t.num("D1"), 9.0); + t.set("D2", "=SUM(B1:XLOOKUP(\"c\",A1:A4,B1:B4))"); + assert_eq!(t.num("D2"), 6.0); + t.set("D3", "=CELL(\"address\",XLOOKUP(\"c\",A1:A4,B1:B4))"); + assert_eq!(t.get("D3"), Value::from("$B$3")); + // Not found: if_not_found, else #N/A, as before. + t.set("D4", "=XLOOKUP(\"z\",A1:A4,B1:B4,\"none\")"); + assert_eq!(t.get("D4"), Value::from("none")); + t.set("D5", "=XLOOKUP(\"z\",A1:A4,B1:B4)"); + assert_eq!(t.get("D5"), Value::Error(CellError::NA)); + // A match on an empty cell is a blank reference: ISBLANK is TRUE and & adds nothing (#374). + t.set("A5", "e"); + t.set("D6", "=ISBLANK(XLOOKUP(\"e\",A1:A5,B1:B5))"); + assert_eq!(t.get("D6"), Value::Bool(true)); + t.set("D7", "=\"x\"&XLOOKUP(\"e\",A1:A5,B1:B5)&\"y\""); + assert_eq!(t.get("D7"), Value::from("xy")); + // An empty match_mode keeps the search_mode after it: search last to first. + t.set("A6", "b"); + t.set("B6", "20"); + t.set("D8", "=XLOOKUP(\"b\",A1:A6,B1:B6,,,-1)"); + assert_eq!(t.num("D8"), 20.0); + t.set("D9", "=XLOOKUP(\"b\",A1:A6,B1:B6)"); + assert_eq!(t.num("D9"), 2.0); +} + #[test] fn long_chain() { let mut t = T::new(); @@ -106,6 +139,24 @@ fn long_chain() { assert_eq!(t.num("A3000"), 3009.0); } +#[test] +fn index_with_arrays_of_positions() { + let mut t = T::new(); + for (i, row) in [[1, 2, 3], [4, 5, 6], [7, 8, 9]].iter().enumerate() { + for (j, v) in row.iter().enumerate() { + t.set(&format!("{}{}", ["A", "B", "C"][j], i + 1), &v.to_string()); + } + } + t.set("E1", "=INDEX(A1:C3,{1;3},{1,3})"); + assert_eq!([t.num("E1"), t.num("F1"), t.num("E2"), t.num("F2")], [1.0, 3.0, 7.0, 9.0]); + t.set("E4", "=SUM(INDEX(A1:C3,{1,2,3},2))"); + assert_eq!(t.num("E4"), 15.0); + t.set("E5", "=INDEX({10,20,30},{3,1})"); + assert_eq!([t.num("E5"), t.num("F5")], [30.0, 10.0]); + t.set("E7", "=INDEX(A1:C3,2,3)"); + assert_eq!(t.num("E7"), 6.0); +} + #[test] fn errors_and_cycles() { let mut t = T::new(); @@ -261,6 +312,32 @@ fn references_functions() { assert_eq!(t.num("B6"), 105.0); } +#[test] +fn relative_names() { + let mut t = T::new(); + // As files store them: relative to A1. `Left` is the cell to the left, `Above3` the three + // cells above, `Mixed` column A of the same row. + for (name, formula) in [("Left", "Sheet1!XFD1"), ("Above3", "Sheet1!A1048574:A1048576"), ("Mixed", "Sheet1!$A1")] { + t.wb.names.push(gridcraft_model::DefinedName { + name: name.into(), + scope: None, + formula: formula.into(), + comment: String::new(), + hidden: false, + }); + } + t.calc.rebuild(&t.wb); + for r in 1..=5 { + t.set(&format!("A{r}"), &(r * 10).to_string()); + } + t.set("B2", "=Left"); + assert_eq!(t.num("B2"), 20.0); + t.set("B4", "=Left+Mixed"); + assert_eq!(t.num("B4"), 80.0); + t.set("A6", "=SUM(Above3)"); + assert_eq!(t.num("A6"), 120.0); +} + #[test] fn spills() { let mut t = T::new(); diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 7a4f0c909..cb793e728 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -759,7 +759,7 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result { p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false); let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"'); - let dest = cell_param(p, "destination").unwrap_or(r.start); + let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start); edit(s, |cx| { let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; let mut out: Vec<(CellRef, String)> = Vec::new(); @@ -952,10 +952,26 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - return None; } let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; - let num = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, f).to_number().ok(); + // The formulas are written for the first cell of the range and see the value being entered, + // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). + let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); + let entered; + let wb = if sh.value(at) == v { + wb + } else { + let mut w = wb.clone(); + if let Some(sh) = w.sheet_mut(sheet) { + let style = sh.style_id(at); + sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() }); + } + entered = w; + &entered + }; + let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)); + let num = |f: &str| eval(f).to_number().ok(); let ok = match dv.kind { ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), - ValidationKind::Custom => gridcraft_calc::evaluate(wb, sheet, at, &dv.f1).to_bool().unwrap_or(false), + ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false), ValidationKind::TextLength => { let n = input.chars().count() as f64; compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) @@ -982,6 +998,21 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - Some((dv.clone(), msg)) } +/// A formula written for `first`, as seen from `at` (relative references shifted). +fn formula_at(f: &str, first: CellRef, at: CellRef) -> String { + if first == at { + return f.to_string(); + } + match gridcraft_formula::parse(f.trim_start_matches('=')) { + Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative( + e, + at.row as i64 - first.row as i64, + at.col as i64 - first.col as i64, + )), + Err(_) => f.to_string(), + } +} + fn compare_op(op: CfOperator, n: f64, a: Option, b: Option) -> bool { let Some(a) = a else { return false }; match op { @@ -998,7 +1029,7 @@ fn compare_op(op: CfOperator, n: f64, a: Option, b: Option) -> bool { fn validate_cmd(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let input = str_param(p, "input").unwrap_or(""); match check_validation(&d.wb, d.wb.active_sheet, at, input) { None => Ok(json!({"ok": true})), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 23cc3e544..91a81b39b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -192,7 +192,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri fn cell_set(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let at = match cell_param(p, "cell") { + let at = match cell_param_on(s, p, "cell", sheet)? { Some(c) => c, None => s.doc()?.selection.active, }; @@ -346,8 +346,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result { s.doc_mut()?.wb_switch_sheet(i); } let ranges = target_ranges(s, p)?; + let active = cell_param(s, p, "active")?; let d = s.doc_mut()?; - let active = cell_param(p, "active").or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default(); + let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default(); let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; if let Some(sh) = d.wb.active() { sel.expand_merges(sh); @@ -616,6 +617,8 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { if ranges.len() > 1 && cut { return Err(multiple()); } + // The selection as made (untrimmed), for Insert Copied Cells. + let selected = ranges.first().copied().unwrap_or_default(); let d = s.doc()?; let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; let trim = |range: RangeRef| { @@ -637,7 +640,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { // 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(&wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas }; + let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected }; s.clipboard = Some(clip); let mut result = json!({"range": range.a1(), "text": text}); if let Some(html) = html { @@ -764,7 +767,7 @@ fn paste(s: &mut Session, p: &Json) -> Result { fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "at").unwrap_or(s.doc()?.selection.current().start); + let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); let rows: Vec> = parse_tsv(text); // The HTML flavour, when the host passed it: the number behind each cell's shown text. let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default(); @@ -892,14 +895,14 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { } let dest_sheet = target_sheet(s, p)?; let sel = s.doc()?.selection.current(); - let at = cell_param(p, "at").unwrap_or(sel.start); + let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start); let src = clip.range; let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; // Tile the copy over a larger destination that is a multiple of its size. let tile = sel.height() % h == 0 && sel.width() % w == 0 && !sel.is_single() - && cell_param(p, "at").is_none() + && cell_param_on(s, p, "at", dest_sheet)?.is_none() && !sel.is_full_cols() && !sel.is_full_rows() && sel.count() <= 4_000_000; diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index d87170596..9f2565ac4 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), @@ -424,7 +444,7 @@ fn move_chart(s: &mut Session, p: &Json) -> Result { Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?), None => None, }; - let at = cell_param(p, "at"); + let at = cell_param_on(s, p, "at", target.unwrap_or(si))?; edit(s, |cx| { let mut chart = cx.sheet_mut(si)?.charts.remove(ci); if let Some(a) = at { @@ -588,9 +608,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"; @@ -839,10 +876,16 @@ fn new_script(s: &mut Session, p: &Json) -> Result { } fn all_scripts(s: &mut Session, _: &Json) -> Result { - let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME); - Ok(Json::Array( - all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(), - )) + let wb = &s.doc()?.wb; + let all = stash_get(wb, SCRIPTS_NAME); + let mut list: Vec = + all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(); + // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on + // save but never executed here, so it has no step count. + if let Some(bytes) = &wb.vba_project { + list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false})); + } + Ok(Json::Array(list)) } fn run_script(s: &mut Session, p: &Json) -> Result { @@ -914,7 +957,7 @@ fn checkbox(s: &mut Session, p: &Json) -> Result { fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { let si = target_sheet(s, p)?; let d = s.doc()?; - let cells: Vec = match cell_param(p, "cell") { + let cells: Vec = match cell_param_on(s, p, "cell", si)? { Some(c) => vec![c], None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::>()).collect(), }; @@ -986,7 +1029,7 @@ fn new_cell_style(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string(); let d = s.doc()?; let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let c = cell_param(p, "fromCell").unwrap_or(d.selection.active); + let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active); let style = d.wb.styles.get(sh.style_id(c)).clone(); edit(s, |cx| { cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name)); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 16e714aa4..a938e988b 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), @@ -56,8 +56,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/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs new file mode 100644 index 000000000..f53ab6603 --- /dev/null +++ b/crates/engine/src/cmd/forecast.rs @@ -0,0 +1,546 @@ +//! 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 = 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!( + "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 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 + )] +} + +/// 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 cap = max_periods(rows.len()); + let periods = match end { + Some(end) => steps_to(&timeline, end, cap)?, + None => match number(p, "periods")? { + 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 = + (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, + pivot: None, + }); + 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, 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(cap) { + return Err(bad(CMD, format!("at most {cap} forecast steps for this much history"))); + } + 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 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(); + 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/formulas.rs b/crates/engine/src/cmd/formulas.rs index 2a3188a2a..df48a2c14 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -253,7 +253,15 @@ fn define_name(s: &mut Session, p: &Json) -> Result { Some(sh) => Some(d.wb.sheet_index(sh).ok_or_else(|| bad("formulas.defineName", "no such sheet"))?), }; let refers = match str_param(p, "refersTo") { - Some(r) => r.trim_start_matches('=').to_string(), + // As in Excel, relative references are relative to the active cell; names keep them + // relative to A1, as files do. + Some(r) => match gridcraft_formula::parse(r.trim_start_matches('=')) { + Ok(e) if gridcraft_formula::adjust::has_relative_refs(&e) => { + let at = d.selection.active; + gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative_wrapping(e, -(at.row as i64), -(at.col as i64))) + } + _ => r.trim_start_matches('=').to_string(), + }, None => { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let r = d.selection.current(); @@ -444,7 +452,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { fn trace_precedents(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at)); let v: Vec = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect(); Ok(Json::Array(v)) @@ -452,7 +460,7 @@ fn trace_precedents(s: &mut Session, p: &Json) -> Result { fn trace_dependents(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let mut deps = Vec::new(); d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps); deps.sort(); @@ -493,7 +501,7 @@ fn error_checking(s: &mut Session, _: &Json) -> Result { fn evaluate_formula(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let sheet = d.wb.active_sheet; let text = match str_param(p, "formula") { Some(f) => f.trim_start_matches('=').to_string(), @@ -525,7 +533,7 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result { fn evaluate(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let f = str_param(p, "formula").ok_or_else(|| bad("formulas.evaluate", "missing `formula`"))?; - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let v = gridcraft_calc::evaluate(&d.wb, d.wb.active_sheet, at, f); Ok(crate::cmd::inspect::value_json(&v)) } @@ -564,3 +572,27 @@ fn calc_options(s: &mut Session, p: &Json) -> Result { Ok(json!({"mode": format!("{:?}", cx.wb.calc.mode)})) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_with_relative_references() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap(); + // Defined from B2, `=Sheet1!A2` is "the cell to the left". + s.execute("selection.set", json!({"range": "B2"})).unwrap(); + let r = s.execute("formulas.defineName", json!({"name": "LeftCell", "refersTo": "=Sheet1!A2"})).unwrap(); + assert_eq!(r["refersTo"], "=Sheet1!XFD1"); + s.execute("cell.set", json!({"cell": "B1", "input": "=LeftCell"})).unwrap(); + s.execute("cell.set", json!({"cell": "B3", "input": "=LeftCell*10"})).unwrap(); + let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone(); + assert_eq!(v(&mut s, "B1"), json!(1.0)); + assert_eq!(v(&mut s, "B3"), json!(30.0)); + // Absolute references are kept as they are. + let r = s.execute("formulas.defineName", json!({"name": "First", "refersTo": "=Sheet1!$A$1"})).unwrap(); + assert_eq!(r["refersTo"], "=Sheet1!$A$1"); + } +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 607748334..6a9137a32 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -666,7 +666,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> { return Err(EngineError::Other("Select data for the chart first.".into())); } let id = d.wb.next_object_id(); - let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2)); + let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2)); let title = str_param(p, "title").map(str::to_string).or_else(|| { if series.len() == 1 { series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display()) @@ -722,6 +722,7 @@ fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { fn chart_set(s: &mut Session, p: &Json) -> Result { let (si, ci) = find_chart(s, p)?; + let at = cell_param_on(s, p, "at", si)?; edit(s, |cx| { let sh = cx.sheet_mut(si)?; let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) }; @@ -755,7 +756,7 @@ fn chart_set(s: &mut Session, p: &Json) -> Result { if let Some(t) = p.get("yTitle") { c.y_title = t.as_str().map(str::to_string); } - if let Some(at) = cell_param(p, "at") { + if let Some(at) = at { c.anchor.cell = at; } if let Some(w) = f64_param(p, "width") { @@ -838,7 +839,7 @@ fn chart_delete(s: &mut Session, p: &Json) -> Result { fn insert_sparkline(s: &mut Session, p: &Json) -> Result { let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string(); - let loc = cell_param(p, "location").ok_or_else(|| bad("insert.sparkline", "missing `location`"))?; + let loc = cell_param(s, p, "location")?.ok_or_else(|| bad("insert.sparkline", "missing `location`"))?; let kind = match str_param(p, "type") { Some("column") => SparklineKind::Column, Some("winLoss") => SparklineKind::WinLoss, @@ -897,7 +898,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240)); let d = s.doc()?; let id = d.wb.next_object_id(); - let at = cell_param(p, "at").unwrap_or(d.selection.active); + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let scale = (640.0 / w.max(1) as f32).min(1.0); let img = Image { id, @@ -960,7 +961,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { }; let d = s.doc()?; let id = d.wb.next_object_id(); - let at = cell_param(p, "at").unwrap_or(d.selection.active); + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let fill = super::format::color_param(p.get("fill")).unwrap_or(if kind == ShapeKind::TextBox { Color::rgb(255, 255, 255) } else { Color::Theme(4, 0) }); let line = super::format::color_param(p.get("line")).unwrap_or(if kind == ShapeKind::TextBox { @@ -1030,6 +1031,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result { match kind.as_str() { "chart" => sh.charts.retain(|c| c.id != id), "image" => sh.images.retain(|c| c.id != id), + "slicer" => sh.slicers.retain(|c| c.id != id), _ => sh.shapes.retain(|c| c.id != id), } Ok(Json::Null) @@ -1040,16 +1042,18 @@ fn move_object(s: &mut Session, p: &Json) -> Result { let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?; let kind = str_param(p, "kind").unwrap_or("").to_string(); let sheet = s.doc()?.wb.active_sheet; + let at = cell_param(s, p, "at")?; edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; let anchor = match kind.as_str() { "chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), "image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + "slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), "shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), _ => None, }; let Some(a) = anchor else { return Err(bad("object.move", "no such object")) }; - if let Some(at) = cell_param(p, "at") { + if let Some(at) = at { a.cell = at; } if let Some(v) = f64_param(p, "dx") { @@ -1096,7 +1100,7 @@ fn set_anchor_mode(s: &mut Session, p: &Json) -> Result { fn insert_link(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); if bool_param(p, "remove").unwrap_or(false) { return edit(s, |cx| { cx.sheet_mut(sheet)?.hyperlinks.remove(&at); @@ -1128,7 +1132,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); let text = str_param(p, "text").unwrap_or("").to_string(); let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone()); edit(s, |cx| { @@ -1142,7 +1146,7 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result { fn reply_comment(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); let text = str_param(p, "text").unwrap_or("").to_string(); let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone()); edit(s, |cx| { @@ -1154,7 +1158,7 @@ fn reply_comment(s: &mut Session, p: &Json) -> Result { fn resolve_comment(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); let v = bool_param(p, "resolved").unwrap_or(true); edit(s, |cx| { if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) { @@ -1175,7 +1179,7 @@ fn delete_comment(s: &mut Session, p: &Json) -> Result { fn show_note(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); edit(s, |cx| { if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) { c.visible = !c.visible; @@ -1402,7 +1406,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result { let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32; let d = s.doc()?; let id = d.wb.next_object_id(); - let at = cell_param(p, "at").unwrap_or(d.selection.active); + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0)); let shape = Shape { id, diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index de670e9b8..414ab42bf 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -119,7 +119,7 @@ fn cell_get(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let d = s.doc()?; let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let c = cell_param(p, "cell").unwrap_or(d.selection.active); + let c = cell_param_on(s, p, "cell", sheet)?.unwrap_or(d.selection.active); let cell = sh.cell(c); let style = d.wb.styles.get(sh.style_id(c)); Ok(json!({ diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 62baad093..7a380c713 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -7,6 +7,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; @@ -15,6 +16,7 @@ pub mod pivot; pub mod print; pub mod review; pub mod sheet; +pub mod slicer; pub mod solver; pub mod spelling; pub mod view; @@ -137,7 +139,9 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(spelling::specs()); v.extend(solver::specs()); v.extend(pivot::specs()); + v.extend(slicer::specs()); v.extend(whatif::specs()); + v.extend(forecast::specs()); v.extend(analysis::specs()); v.extend(print::specs()); v @@ -238,8 +242,28 @@ pub(crate) fn split_sheet(r: &str) -> (Option, &str) { } } -pub(crate) fn cell_param(p: &Json, key: &str) -> Option { - str_param(p, key).and_then(|t| CellRef::parse(split_sheet(t).1)) +/// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not +/// a cell reference, or that names a sheet other than the active one, is an error rather than a +/// silent fallback to some other cell. +pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result> { + cell_param_on(s, p, key, s.doc()?.wb.active_sheet) +} + +/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it). +pub(crate) fn cell_param_on(s: &Session, p: &Json, key: &str, sheet: usize) -> Result> { + let t = match p.get(key) { + None | Some(Json::Null) => return Ok(None), + Some(Json::String(t)) => t, + Some(v) => return Err(bad("cell", format!("invalid cell reference `{v}` for `{key}`"))), + }; + let (sh, body) = split_sheet(t); + let c = CellRef::parse(body).ok_or_else(|| bad("cell", format!("invalid cell reference `{t}` for `{key}`")))?; + if let Some(name) = sh + && s.doc()?.wb.sheet_index(&name) != Some(sheet) + { + return Err(bad("cell", format!("`{t}` for `{key}` names a sheet other than the one this command works on"))); + } + Ok(Some(c)) } /// Applies an edit to a copy of the active workbook, then recalculates and commits it. diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 630fa2b9a..0d73b0a4a 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -1338,7 +1338,8 @@ fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result { let series = pv::chart_series(&sh.name, area.start, &out); let t = str_param(p, "type").unwrap_or("column"); let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or("")); - let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1))); + let at = + cell_param(s, p, "at")?.unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1))); let title = match p.get("title") { Some(Json::String(t)) => Some(t.clone()), Some(Json::Null) => None, 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/review.rs b/crates/engine/src/cmd/review.rs index 12b296ba4..c55aac8e5 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -1,6 +1,8 @@ -//! Review tab: protection, spelling (basic), workbook statistics. +//! Review tab: protection, spelling (basic), workbook statistics, comment navigation and the +//! Comments pane, Show All Notes and Convert to Comments. -use gridcraft_model::{EditRange, PasswordHash, SheetProtection, password_hash}; +use gridcraft_core::CellRef; +use gridcraft_model::{EditRange, PasswordHash, SheetProtection, Visibility, password_hash}; use serde_json::{Value as Json, json}; use super::*; @@ -17,7 +19,15 @@ pub fn specs() -> Vec { protect_sheet ), cmd!("review.unprotectSheet", "Unprotect Sheet", ["Review", "Protect"], None, "{password?}", has_doc, unprotect_sheet), - cmd!("review.protectWorkbook", "Protect Workbook", ["Review", "Protect"], None, "{on?: bool}", has_doc, protect_workbook), + cmd!( + "review.protectWorkbook", + "Protect Workbook", + ["Review", "Protect"], + None, + "{on?: bool (default: toggle), password?} (structure protection; unprotecting needs the password when one was set)", + has_doc, + protect_workbook + ), cmd!(query "review.workbookStatistics", "Workbook Statistics", ["Review", "Proofing"], None, "{}", has_doc, stats), // Not journaled: the params can hold a password. CommandSpec { @@ -32,14 +42,106 @@ pub fn specs() -> Vec { undoable: true, }, cmd!(query "review.checkAccessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → issues", has_doc, accessibility), + cmd!(noundo "review.previousComment", "Previous Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| { + step_comment(s, false) + }), + cmd!(noundo "review.nextComment", "Next Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| step_comment( + s, true + )), + cmd!(query "review.showComments", "Show Comments", ["Review", "Comments"], None, "{on?: bool} (toggles the Comments pane)", has_doc, |s, p| { + s.comments_pane = bool_param(p, "on").unwrap_or(!s.comments_pane); + Ok(json!({"on": s.comments_pane})) + }), + cmd!(query "review.showAllNotes", "Show All Notes", ["Review", "Notes"], None, "{on?: bool}", has_doc, |s, p| { + s.show_all_notes = bool_param(p, "on").unwrap_or(!s.show_all_notes); + Ok(json!({"on": s.show_all_notes})) + }), + cmd!( + "review.convertToComments", + "Convert to Comments", + ["Review", "Notes"], + None, + "{} (every note in the workbook) → {converted}", + has_doc, + convert_notes + ), ] } +/// Threaded comments in navigation order: visible sheets in tab order, then row by row. +fn comment_cells(s: &Session) -> Vec<(usize, CellRef)> { + let Some(d) = s.active() else { return vec![] }; + d.wb.sheets + .iter() + .enumerate() + .filter(|(_, sh)| sh.visibility == Visibility::Visible) + // `CellRef` orders by row, then column: reading order. + .flat_map(|(i, sh)| sh.comments.iter().filter(|(_, c)| c.threaded).map(move |(at, _)| (i, *at))) + .collect() +} + +fn has_comments(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if comment_cells(s).is_empty() { Err("there are no comments in this workbook".into()) } else { Ok(()) } +} + +/// Selects the next (or previous) threaded comment after the active cell, moving on to the next +/// sheet and wrapping around at the end of the workbook. +fn step_comment(s: &mut Session, forward: bool) -> Result { + let list = comment_cells(s); + let d = s.doc()?; + let here = (d.wb.active_sheet, d.selection.active); + let target = if forward { + list.iter().find(|c| **c > here).or_else(|| list.first()) + } else { + list.iter().rev().find(|c| **c < here).or_else(|| list.last()) + }; + let Some(&(sheet, at)) = target else { + return Err(EngineError::Other("There are no comments in this workbook.".into())); + }; + let d = s.doc_mut()?; + d.wb_switch_sheet(sheet); + d.selection = crate::Selection::at(at); + if let Some(sh) = d.wb.active() { + d.selection.expand_merges(sh); + } + let name = d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(); + Ok(json!({"sheet": name, "cell": at.a1()})) +} + +/// Review › Notes › Convert to Comments: every note in the workbook becomes a threaded comment +/// with the same author and text. +fn convert_notes(s: &mut Session, _: &Json) -> Result { + let sheets: Vec = + s.doc()?.wb.sheets.iter().enumerate().filter(|(_, sh)| sh.comments.values().any(|c| !c.threaded)).map(|(i, _)| i).collect(); + if sheets.is_empty() { + return Ok(json!({"converted": 0})); + } + edit(s, |cx| { + let mut n = 0usize; + for i in &sheets { + for c in cx.sheet_mut(*i)?.comments.values_mut().filter(|c| !c.threaded) { + c.threaded = true; + c.visible = false; + n += 1; + } + } + Ok(json!({"converted": n})) + }) +} + fn protect_sheet(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; + // Re-protecting a password-protected sheet would replace the password without knowing it: + // unprotect it first, as in Excel. One without a password can take new options directly. + let has_password = |pr: &SheetProtection| pr.password.is_some() || pr.legacy_password_hash.is_some(); + if s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).is_some_and(has_password) { + return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into())); + } let b = |k: &str| bool_param(p, k).unwrap_or(false); let prot = SheetProtection { - password_hash: str_param(p, "password").filter(|x| !x.is_empty()).map(password_hash), + password: None, + legacy_password_hash: None, select_locked: true, select_unlocked: true, format_cells: b("formatCells"), @@ -52,6 +154,9 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result { sort: b("sort"), autofilter: b("autofilter"), }; + let d = s.doc()?; + let what = d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default(); + let prot = SheetProtection { password: str_param(p, "password").filter(|x| !x.is_empty()).map(|pw| new_password_hash(d, &what, pw)), ..prot }; edit(s, |cx| { cx.sheet_mut(sheet)?.protection = Some(prot.clone()); Ok(Json::Null) @@ -60,11 +165,16 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result { fn unprotect_sheet(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let ph = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).and_then(|pr| pr.password_hash); - if let Some(h) = ph - && str_param(p, "password").map(password_hash) != Some(h) - { - return Err(EngineError::Other("The password you supplied is not correct.".into())); + if let Some(pr) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()) { + let pw = str_param(p, "password").unwrap_or(""); + let ok = match (&pr.password, pr.legacy_password_hash) { + (Some(h), _) => h.verify(pw).map_err(EngineError::Other)?, + (None, Some(h)) => password_hash(pw) == h, + (None, None) => true, + }; + if !ok { + return Err(EngineError::Other("The password you supplied is not correct.".into())); + } } edit(s, |cx| { cx.sheet_mut(sheet)?.protection = None; @@ -72,6 +182,15 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result { }) } +/// A new Excel-compatible (SHA-512, salted) hash of `pw`. The salt mixes `what` is protected +/// with the document, a counter and the clock, so equal passwords get different hashes. +fn new_password_hash(d: &DocState, what: &str, pw: &str) -> PasswordHash { + static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let seed = format!("{what}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits()); + PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes())) +} + fn range_text(r: &EditRange) -> String { r.ranges.iter().map(|x| x.a1()).collect::>().join(",") } @@ -79,7 +198,6 @@ fn range_text(r: &EditRange) -> String { /// Review › Allow Edit Ranges: list, add, modify or delete the ranges that stay editable on a /// protected sheet, or unlock a password range for this session. fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result { - static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let sheet = target_sheet(s, p)?; let action = str_param(p, "action").unwrap_or("list"); let title = str_param(p, "title").map(str::trim).filter(|t| !t.is_empty()); @@ -93,11 +211,7 @@ fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result { .position(|r| r.title.eq_ignore_ascii_case(t)) .ok_or_else(|| bad("review.allowEditRanges", format!("no range titled `{t}`"))) }; - let hash = |pw: &str, title: &str| { - let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let seed = format!("{sheet_name}\u{0}{title}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits()); - PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes())) - }; + let hash = |pw: &str, title: &str| new_password_hash(d, &format!("{sheet_name}\u{0}{title}"), pw); if matches!(action, "add" | "modify" | "delete") && sh.is_protected() { return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into())); } @@ -183,11 +297,28 @@ fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result { )) } +/// Review › Protect Workbook: lock the workbook structure (sheets can't be added, moved, +/// renamed, hidden or deleted), optionally with a password that unprotecting must match. fn protect_workbook(s: &mut Session, p: &Json) -> Result { - let cur = s.doc()?.wb.protected_structure; + let d = s.doc()?; + let cur = d.wb.protected_structure; let on = bool_param(p, "on").unwrap_or(!cur); + if on == cur { + return Ok(json!({"on": on})); + } + let password = if on { + str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| new_password_hash(d, "workbook", pw)) + } else { + if let Some(h) = &d.wb.structure_password + && !h.verify(str_param(p, "password").unwrap_or("")).map_err(EngineError::Other)? + { + return Err(EngineError::Other("The password you supplied is not correct.".into())); + } + None + }; edit(s, |cx| { cx.wb.protected_structure = on; + cx.wb.structure_password = password.clone(); Ok(json!({"on": on})) }) } @@ -225,6 +356,131 @@ fn accessibility(s: &mut Session, _: &Json) -> Result { mod tests { use super::*; + fn book() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + for (sheet, cell) in [(0, "C1"), (0, "A3"), (1, "B2")] { + s.execute("review.newComment", json!({"sheet": sheet, "cell": cell, "text": "c", "author": "Ann"})).unwrap(); + } + s.execute("review.newNote", json!({"sheet": 0, "cell": "A2", "text": "note", "author": "Bob"})).unwrap(); + s + } + + fn at(s: &Session) -> (usize, String) { + let d = s.doc().unwrap(); + (d.wb.active_sheet, d.selection.active.a1()) + } + + #[test] + fn next_and_previous_comment_go_in_reading_order_across_sheets_and_wrap() { + let mut s = book(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + // Notes are skipped: A2 holds a note, not a comment. + let order = [(0, "C1"), (0, "A3"), (1, "B2"), (0, "C1")]; + for (sheet, cell) in order { + s.execute("review.nextComment", json!({})).unwrap(); + assert_eq!(at(&s), (sheet, cell.to_string())); + } + for (sheet, cell) in [(1, "B2"), (0, "A3"), (0, "C1")] { + s.execute("review.previousComment", json!({})).unwrap(); + assert_eq!(at(&s), (sheet, cell.to_string())); + } + } + + #[test] + fn comment_navigation_is_disabled_without_comments() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("review.newNote", json!({"cell": "A1", "text": "note"})).unwrap(); + assert!(s.execute("review.nextComment", json!({})).is_err()); + assert!(s.execute("review.previousComment", json!({})).is_err()); + assert_eq!(at(&s), (0, "A1".to_string())); + } + + #[test] + fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() { + let mut s = book(); + s.execute("review.protectSheet", json!({"password": "pa55"})).unwrap(); + let prot = s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap(); + assert!(matches!(&prot.password, Some(PasswordHash::Iterated { algorithm, .. }) if algorithm == "SHA-512")); + assert!(s.execute("review.unprotectSheet", json!({})).is_err()); + assert!(s.execute("review.unprotectSheet", json!({"password": "pass"})).is_err()); + // Protecting again can't swap the password out from under it. + assert!(s.execute("review.protectSheet", json!({})).is_err()); + assert_eq!(s.doc().unwrap().wb.active().unwrap().protection.as_ref(), Some(&prot)); + s.execute("review.unprotectSheet", json!({"password": "pa55"})).unwrap(); + assert!(!s.doc().unwrap().wb.active().unwrap().is_protected()); + // A GridCraft JSON workbook saved before carried the internal hash under `password_hash`. + let old = format!( + r#"{{"password_hash": {}, "select_locked": true, "select_unlocked": true, "format_cells": false, "format_columns": false, "format_rows": false, "insert_columns": false, "insert_rows": false, "delete_columns": false, "delete_rows": false, "sort": false, "autofilter": false}}"#, + password_hash("old") + ); + let prot: SheetProtection = serde_json::from_str(&old).unwrap(); + assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true)); + let i = s.doc().unwrap().wb.active_sheet; + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot); + assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err()); + s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap(); + } + + #[test] + fn protect_workbook_with_a_password_really_verifies() { + let mut s = book(); + s.execute("review.protectWorkbook", json!({"password": "pa55"})).unwrap(); + assert!(s.doc().unwrap().wb.protected_structure); + assert!(s.execute("home.insertSheet", json!({"name": "Three"})).is_err(), "the structure is locked"); + // A second Protect keeps it protected; unprotecting needs the right password. + assert_eq!(s.execute("review.protectWorkbook", json!({"on": true})).unwrap()["on"], true); + assert!(s.execute("review.protectWorkbook", json!({})).is_err()); + assert!(s.execute("review.protectWorkbook", json!({"on": false, "password": "pass"})).is_err()); + assert!(s.doc().unwrap().wb.protected_structure); + s.execute("review.protectWorkbook", json!({"on": false, "password": "pa55"})).unwrap(); + let wb = &s.doc().unwrap().wb; + assert!(!wb.protected_structure && wb.structure_password.is_none()); + // Without a password it toggles freely. + s.execute("review.protectWorkbook", json!({})).unwrap(); + s.execute("review.protectWorkbook", json!({})).unwrap(); + assert!(!s.doc().unwrap().wb.protected_structure); + } + + #[test] + fn convert_to_comments_turns_every_note_into_a_comment_and_undoes() { + let mut s = book(); + s.execute("review.newNote", json!({"sheet": 1, "cell": "D4", "text": "second", "author": "Cy"})).unwrap(); + s.execute("review.showNote", json!({"sheet": 1, "cell": "D4"})).unwrap(); + let r = s.execute("review.convertToComments", json!({})).unwrap(); + assert_eq!(r["converted"], 2); + let wb = &s.doc().unwrap().wb; + let a2 = &wb.sheets[0].comments[&CellRef::parse("A2").unwrap()]; + let d4 = &wb.sheets[1].comments[&CellRef::parse("D4").unwrap()]; + assert!(a2.threaded && d4.threaded && !d4.visible); + assert_eq!((a2.author.as_str(), a2.text.as_str(), d4.author.as_str(), d4.text.as_str()), ("Bob", "note", "Cy", "second")); + // Nothing left to convert; one undo restores the notes. + assert_eq!(s.execute("review.convertToComments", json!({})).unwrap()["converted"], 0); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!s.doc().unwrap().wb.sheets[0].comments[&CellRef::parse("A2").unwrap()].threaded); + } + + #[test] + fn show_comments_and_show_all_notes_toggle() { + let mut s = book(); + assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], true); + assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], false); + assert_eq!(s.execute("review.showComments", json!({"on": true})).unwrap()["on"], true); + assert!(s.comments_pane); + assert_eq!(s.execute("review.showAllNotes", json!({})).unwrap()["on"], true); + assert_eq!(s.execute("review.showAllNotes", json!({"on": false})).unwrap()["on"], false); + assert!(!s.show_all_notes); + } +} + +#[cfg(test)] +mod edit_range_tests { + use super::*; + fn protected_book() -> Session { let mut s = Session::new(); s.new_workbook(); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 175cb81ab..864dd1145 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -35,7 +35,17 @@ pub fn specs() -> Vec { p, Axis::Cols )), + cmd!("sheet.moveColumns", "Move Columns", [], None, "{cols?: \"B:C\", before: zero-based column index}", has_doc, move_columns), cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells), + cmd!( + "home.insertCopiedCells", + "Insert Copied Cells", + ["Home", "Cells", "Insert"], + None, + "{at?: \"B3\", shift?: down|right}: inserts the copied cells at `at` (the active cell) and pastes them there. Whole copied rows or columns insert whole rows or columns; other blocks shift the cells at `at` down (default) or right. A cut of whole rows or columns moves them (Insert Cut Cells)", + has_clipboard, + insert_copied_cells + ), cmd!("home.deleteCells", "Delete Cells…", ["Home", "Cells", "Delete"], None, "{range?, shift: left|up|row|column}", has_doc, delete_cells), cmd!("home.insertSheet", "Insert Sheet", ["Home", "Cells", "Insert"], Some("Shift+F11"), "{name?, before?: index}", has_doc, insert_sheet), cmd!("home.deleteSheet", "Delete Sheet", ["Home", "Cells", "Delete"], None, "{sheet?}", has_doc, delete_sheet), @@ -325,6 +335,105 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { Ok(out) } +/// Reorders a contiguous block of whole columns without replacing the destination columns. +/// `before` is a boundary in the original sheet: moving B:C before E produces A,D,B,C,E. +fn move_columns(s: &mut Session, p: &Json) -> Result { + let (from, count) = lines(s, p, Axis::Cols)?; + let before = u32_param(p, "before").ok_or_else(|| bad("sheet.moveColumns", "missing `before`"))?; + let end = + from.checked_add(count).filter(|end| *end <= MAX_COLS).ok_or_else(|| bad("sheet.moveColumns", "source columns are outside the worksheet"))?; + if before >= MAX_COLS || before.checked_add(count).is_none_or(|end| end > MAX_COLS) { + return Err(bad("sheet.moveColumns", "destination columns are outside the worksheet")); + } + if (from..=end).contains(&before) { + return Ok(json!({"cols": RangeRef::cols(from, end.saturating_sub(1)).a1()})); + } + let sheet = target_sheet(s, p)?; + if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) + && u.end.col >= before + && u.end.col as u64 + count as u64 >= MAX_COLS as u64 + { + return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into())); + } + check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?; + check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?; + + let moved_from = if before <= from { from + count } else { from }; + if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) { + return Err(bad("sheet.moveColumns", "the move is too close to the worksheet boundary")); + } + let destination = before; + let delete_at = moved_from; + let final_start = if before > from { before - count } else { before }; + let active_row = s.doc()?.selection.active.row; + let source = RangeRef::cols(moved_from, moved_from + count - 1); + let out = edit(s, |cx| { + let name = cx.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default(); + cx.protection_checked = true; + + { + let sh = cx.sheet_mut(sheet)?; + sh.cells.shift_cols(before, count as i64); + shift_sheet_features(sh, Axis::Cols, before, count, true); + } + rewrite(&mut cx.wb, &name, &Edit::Insert { axis: Axis::Cols, at: before, count }); + + let mut touched = Vec::new(); + { + let sh = cx.sheet_mut(sheet)?; + let pictures: Vec<_> = + sh.cell_pictures.iter().filter(|(cell, _)| source.contains(**cell)).map(|(cell, picture)| (*cell, picture.clone())).collect(); + let cells = sh.take_cells(source); + let moved_lines: Vec<_> = (0..count).filter_map(|offset| sh.cols.remove(&(moved_from + offset)).map(|info| (offset, info))).collect(); + let movement = Edit::Move { from: source, to_row: 0, to_col: destination }; + shift_cell_features(sh, &movement); + for object in sh + .charts + .iter_mut() + .map(|o| &mut o.anchor) + .chain(sh.images.iter_mut().map(|o| &mut o.anchor)) + .chain(sh.shapes.iter_mut().map(|o| &mut o.anchor)) + { + if object.mode != AnchorMode::Absolute && (moved_from..moved_from + count).contains(&object.cell.col) { + object.cell.col = destination + (object.cell.col - moved_from); + } + } + for (cell, value) in cells { + let dest = CellRef::new(cell.row, destination + (cell.col - moved_from)); + sh.cells.set(dest, value); + touched.extend([cell, dest]); + } + for (cell, picture) in pictures { + let dest = CellRef::new(cell.row, destination + (cell.col - moved_from)); + sh.cell_pictures.insert(dest, picture); + } + for (offset, info) in moved_lines { + sh.cols.insert(destination + offset, info); + } + } + for cell in touched { + cx.touch(sheet, cell); + } + rewrite(&mut cx.wb, &name, &Edit::Move { from: source, to_row: 0, to_col: destination }); + + { + let sh = cx.sheet_mut(sheet)?; + sh.cells.shift_cols(delete_at + count, -(count as i64)); + shift_sheet_features(sh, Axis::Cols, delete_at, count, false); + } + rewrite(&mut cx.wb, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count }); + *cx.sel = crate::Selection { + active: CellRef::new(active_row, final_start), + anchor: CellRef::new(active_row, final_start), + ranges: vec![RangeRef::cols(final_start, final_start + count - 1)], + }; + cx.structural = true; + Ok(json!({"cols": RangeRef::cols(final_start, final_start + count - 1).a1()})) + })?; + cancel_copy_mode(s); + Ok(out) +} + fn insert_cells(s: &mut Session, p: &Json) -> Result { let shift = str_param(p, "shift").unwrap_or("down"); let r = target_range(s, p)?; @@ -385,6 +494,125 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { Ok(out) } +/// Insert Copied Cells / Insert Cut Cells: makes room at the destination, then pastes (or, for a +/// cut, moves) the clipboard there, as one undo step. +fn insert_copied_cells(s: &mut Session, p: &Json) -> Result { + let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(d.selection.current().start); + let shift = str_param(p, "shift").unwrap_or("down"); + if shift != "down" && shift != "right" { + return Err(bad("home.insertCopiedCells", format!("unknown `shift`: {shift} (down or right)"))); + } + let sel = clip.selected; + let rows = sel.is_full_rows(); + let cols = sel.is_full_cols(); + if rows && cols { + return Err(EngineError::Other("The copy area is the whole sheet; there is no room to insert it.".into())); + } + let same_doc = d.uid == clip.doc_uid; + let cut = clip.cut && same_doc; + if cut && !rows && !cols { + return Err(EngineError::Other("Insert Cut Cells works on whole rows or columns; cut whole rows or columns, or use Paste.".into())); + } + // A moved block can't be inserted inside itself. + if cut && clip.sheet == sheet { + let inside = if rows { at.row > sel.start.row && at.row <= sel.end.row } else { at.col > sel.start.col && at.col <= sel.end.col }; + if inside { + return Err(EngineError::Other("The cut rows or columns can't be inserted inside themselves.".into())); + } + } + let revision = d.revision; + let before = (d.wb.clone(), d.selection.clone()); + let result = insert_copied_steps(s, &clip, sheet, at, shift, cut); + let d = s.doc_mut()?; + // The nested steps collapse into this command's single undo step. + for _ in 0..d.revision.saturating_sub(revision) { + d.undo.pop(); + } + match result { + Ok((final_range, out)) => { + d.selection = crate::Selection { active: final_range.start, anchor: final_range.start, ranges: vec![final_range] }; + // Like inserting cells, this ends copy mode (the copied block may have moved). + cancel_copy_mode(s); + Ok(out) + } + Err(e) => { + d.wb = before.0; + d.selection = before.1; + d.calc.rebuild(&d.wb); + s.clipboard = Some(clip); + Err(e) + } + } +} + +fn insert_copied_steps(s: &mut Session, clip: &crate::Clipboard, sheet: usize, at: CellRef, shift: &str, cut: bool) -> Result<(RangeRef, Json)> { + let sel = clip.selected; + let rows = sel.is_full_rows(); + let cols = sel.is_full_cols(); + // Where the copied block's top-left lands, relative to the (untrimmed) copied lines. + let (inserted, paste_at) = if rows { + let h = sel.height(); + let start = at.row; + let r = format!("{}:{}", start as u64 + 1, start as u64 + h as u64); + s.execute("home.insertRows", json!({"rows": r, "sheet": sheet}))?; + let dr = clip.range.start.row.saturating_sub(sel.start.row); + (RangeRef::rows(start, start.saturating_add(h - 1).min(MAX_ROWS - 1)), CellRef::new(start.saturating_add(dr), clip.range.start.col)) + } else if cols { + let w = sel.width(); + let start = at.col; + let end = start.saturating_add(w - 1).min(MAX_COLS - 1); + s.execute("home.insertColumns", json!({"cols": RangeRef::cols(start, end).a1(), "sheet": sheet}))?; + let dc = clip.range.start.col.saturating_sub(sel.start.col); + (RangeRef::cols(start, end), CellRef::new(clip.range.start.row, start.saturating_add(dc))) + } else { + let end = at + .offset(clip.range.height() as i64 - 1, clip.range.width() as i64 - 1) + .ok_or_else(|| EngineError::Other("The copied cells don't fit on the sheet at this position.".into()))?; + let block = RangeRef::new(at, end); + s.execute("home.insertCells", json!({"range": block.a1(), "shift": shift, "sheet": sheet}))?; + (block, at) + }; + // Inserting ended copy mode; bring the clipboard back for the paste. A cut reads the live + // workbook, so it follows the source if the insert pushed it along. + let mut clip = clip.clone(); + if cut { + let pushed = clip.sheet == sheet && if rows { sel.start.row >= at.row } else { sel.start.col >= at.col }; + if pushed { + let (dr, dc) = if rows { (inserted.height() as i64, 0) } else { (0, inserted.width() as i64) }; + clip.range = RangeRef::new(clip.range.start.offset_clamped(dr, dc), clip.range.end.offset_clamped(dr, dc)); + clip.selected = RangeRef::new(sel.start.offset_clamped(dr, dc), sel.end.offset_clamped(dr, dc)); + } + clip.wb = s.doc()?.wb.clone(); + } + let source_lines = clip.selected; + s.clipboard = Some(clip.clone()); + let out = s.execute("edit.pasteSpecial", json!({"what": "all", "at": paste_at.a1(), "sheet": sheet}))?; + if !cut { + return Ok((inserted, out)); + } + // Close the gap the moved lines left behind. + let (removed, before_dest) = if rows { + s.execute( + "home.deleteRows", + json!({"rows": format!("{}:{}", source_lines.start.row as u64 + 1, source_lines.end.row as u64 + 1), "sheet": clip.sheet}), + )?; + (source_lines.height(), clip.sheet == sheet && source_lines.start.row < inserted.start.row) + } else { + s.execute("home.deleteColumns", json!({"cols": RangeRef::cols(source_lines.start.col, source_lines.end.col).a1(), "sheet": clip.sheet}))?; + (source_lines.width(), clip.sheet == sheet && source_lines.start.col < inserted.start.col) + }; + let moved = if before_dest { + let (dr, dc) = if rows { (-(removed as i64), 0) } else { (0, -(removed as i64)) }; + RangeRef::new(inserted.start.offset_clamped(dr, dc), inserted.end.offset_clamped(dr, dc)) + } else { + inserted + }; + Ok((moved, out)) +} + fn insert_sheet(s: &mut Session, p: &Json) -> Result { if s.doc()?.wb.protected_structure { return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); @@ -573,6 +801,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/engine/src/cmd/slicer.rs b/crates/engine/src/cmd/slicer.rs new file mode 100644 index 000000000..00342a5a9 --- /dev/null +++ b/crates/engine/src/cmd/slicer.rs @@ -0,0 +1,230 @@ +//! Slicers: on-sheet widgets that filter one column of a table by clicking value tiles. +//! +//! A slicer is a thin front-end over the existing filter machinery: its selection is written as a +//! [`FilterCriterion::Values`] into the sheet's [`AutoFilter`] (over the table's range) and +//! [`super::data::apply_filter`] hides the rows. Creating a slicer (`insert.slicer` / +//! `table.insertSlicer`) selects everything, i.e. applies no filter. + +use gridcraft_core::CellRef; +use gridcraft_model::*; +use serde_json::{Value as Json, json}; + +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "insert.slicer", + "Slicer", + ["Insert", "Filters"], + None, + "{table?, column?: \"Region\", columns?: 1, at?: \"H2\", width?, height?}", + has_doc, + insert_slicer + ), + cmd!( + "table.insertSlicer", + "Insert Slicer", + ["Table", "Tools"], + None, + "{table?, column?: \"Region\", columns?: 1, at?, width?, height?}", + has_doc, + insert_slicer + ), + cmd!("slicer.toggle", "Toggle Slicer Value", [], None, "{slicer?: id, value: \"East\", multi?: false}", has_doc, slicer_toggle), + cmd!("slicer.selectAll", "Clear Slicer Filter", [], None, "{slicer?: id}", has_doc, slicer_select_all), + cmd!("slicer.clear", "Clear Slicer Filter", [], None, "{slicer?: id}", has_doc, slicer_select_all), + cmd!("slicer.delete", "Delete Slicer", [], None, "{slicer?: id}", has_doc, slicer_delete), + ] +} + +/// Distinct, non-blank display values of a table column, in first-seen order. Used to populate a +/// slicer's tiles and to detect "everything selected". +pub fn column_values(wb: &Workbook, sheet: usize, table: &str, column: &str) -> Vec { + let Some(sh) = wb.sheet(sheet) else { return vec![] }; + let Some(t) = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(table)) else { return vec![] }; + let Some(ci) = t.column_index(column) else { return vec![] }; + let Some(dr) = t.data_range() else { return vec![] }; + let col = dr.start.col + ci as u32; + let mut seen = std::collections::BTreeSet::new(); + let mut out = Vec::new(); + for row in dr.start.row..=dr.end.row { + let text = crate::display::cell_text(wb, sh, CellRef::new(row, col)); + if text.is_empty() { + continue; + } + if seen.insert(text.to_lowercase()) { + out.push(text); + } + } + out +} + +/// A slicer's tiles as `(value, selected)`, for the UI. `selected` is true for every tile when the +/// slicer has no filter (`selected == None`). +pub fn slicer_items(wb: &Workbook, sheet: usize, sl: &Slicer) -> Vec<(String, bool)> { + column_values(wb, sheet, &sl.table, &sl.column) + .into_iter() + .map(|v| { + let on = match &sl.selected { + None => true, + Some(set) => set.iter().any(|x| x.eq_ignore_ascii_case(&v)), + }; + (v, on) + }) + .collect() +} + +/// Writes the slicer's current selection into the sheet filter and re-applies it. +fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) { + let info = wb.sheet(sheet).and_then(|sh| { + let sl = sh.slicers.iter().find(|s| s.id == id)?; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; + let ci = t.column_index(&sl.column)?; + Some((t.range, t.range.start.col + ci as u32, sl.selected.clone())) + }); + let Some((table_range, abs_col, selected)) = info else { return }; + { + let Some(sh) = wb.sheet_mut(sheet) else { return }; + let af = sh.autofilter.get_or_insert_with(|| AutoFilter { range: table_range, criteria: vec![] }); + let off = abs_col.saturating_sub(af.range.start.col); + af.criteria.retain(|(o, _)| *o != off); + if let Some(values) = selected { + af.criteria.push((off, FilterCriterion::Values { values, blanks: false })); + } + } + super::data::apply_filter(wb, sheet); +} + +fn insert_slicer(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let t = match str_param(p, "table") { + Some(name) => sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name)), + None => sh.table_at(d.selection.active), + } + .ok_or_else(|| EngineError::Other("Select a cell inside a table first, or pass `table`.".into()))?; + let column = match str_param(p, "column") { + Some(c) => t + .columns + .iter() + .find(|tc| tc.name.eq_ignore_ascii_case(c)) + .map(|tc| tc.name.clone()) + .ok_or_else(|| bad("insert.slicer", format!("table has no column `{c}`")))?, + None => { + // Default to the active cell's column within the table, else the first column. + let idx = d.selection.active.col.checked_sub(t.range.start.col).map(|o| o as usize).filter(|o| *o < t.columns.len()).unwrap_or(0); + t.columns.get(idx).map(|tc| tc.name.clone()).ok_or_else(|| bad("insert.slicer", "the table has no columns"))? + } + }; + let table = t.name.clone(); + let at = cell_param(s, p, "at")? + .unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1))); + let columns = u32_param(p, "columns").unwrap_or(1).clamp(1, 10); + let width = f64_param(p, "width").unwrap_or(168.0).clamp(60.0, 10000.0) as f32; + let height = f64_param(p, "height").unwrap_or(200.0).clamp(60.0, 10000.0) as f32; + let id = d.wb.next_object_id(); + let slicer = Slicer { + id, + table, + column: column.clone(), + caption: column, + anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width, height, mode: AnchorMode::MoveOnly }, + selected: None, + columns, + }; + edit(s, |cx| { + cx.sheet_mut(sheet)?.slicers.push(slicer); + Ok(json!({"slicer": id})) + }) +} + +/// The target slicer: by `slicer` id, else the last one on the active sheet. +fn find_slicer(s: &Session, p: &Json) -> Result<(usize, u32)> { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let idx = match u32_param(p, "slicer") { + Some(id) => sh.slicers.iter().position(|x| x.id == id), + None => sh.slicers.len().checked_sub(1), + }; + idx.and_then(|i| sh.slicers.get(i).map(|x| (i, x.id))).ok_or_else(|| bad("slicer", "no slicer on this sheet")) +} + +fn slicer_toggle(s: &mut Session, p: &Json) -> Result { + let (idx, id) = find_slicer(s, p)?; + let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string(); + let multi = bool_param(p, "multi").unwrap_or(false); + let sheet = s.doc()?.wb.active_sheet; + let (table, column, current) = { + let d = s.doc()?; + let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?; + (sl.table.clone(), sl.column.clone(), sl.selected.clone()) + }; + let all = column_values(&s.doc()?.wb, sheet, &table, &column); + if !all.iter().any(|v| v.eq_ignore_ascii_case(&value)) { + return Err(bad("slicer.toggle", "value is not in the column")); + } + let new_selected: Option> = if multi { + let mut set: Vec = current.unwrap_or_else(|| all.clone()); + match set.iter().position(|x| x.eq_ignore_ascii_case(&value)) { + Some(pos) => { + set.remove(pos); + } + None => set.push(value.clone()), + } + // Everything selected is the same as no filter. + if set.len() == all.len() && all.iter().all(|a| set.iter().any(|x| x.eq_ignore_ascii_case(a))) { None } else { Some(set) } + } else { + // Single click: show only this value; clicking the sole selected value clears the filter. + match ¤t { + Some(set) if set.len() == 1 && set.iter().any(|x| x.eq_ignore_ascii_case(&value)) => None, + _ => Some(vec![value.clone()]), + } + }; + edit(s, |cx| { + if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { + sl.selected = new_selected; + } + reapply_slicer(&mut cx.wb, sheet, id); + cx.structural = true; + Ok(Json::Null) + }) +} + +fn slicer_select_all(s: &mut Session, p: &Json) -> Result { + let (idx, id) = find_slicer(s, p)?; + let sheet = s.doc()?.wb.active_sheet; + edit(s, |cx| { + if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { + sl.selected = None; + } + reapply_slicer(&mut cx.wb, sheet, id); + cx.structural = true; + Ok(Json::Null) + }) +} + +fn slicer_delete(s: &mut Session, p: &Json) -> Result { + let (idx, id) = find_slicer(s, p)?; + let sheet = s.doc()?.wb.active_sheet; + // Absolute column of the slicer, so its filter criterion can be removed too. + let abs_col = s.doc().ok().and_then(|d| { + let sh = d.wb.active()?; + let sl = sh.slicers.get(idx)?; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; + let ci = t.column_index(&sl.column)?; + Some(t.range.start.col + ci as u32) + }); + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + sh.slicers.retain(|x| x.id != id); + if let (Some(abs), Some(af)) = (abs_col, sh.autofilter.as_mut()) { + let off = abs.saturating_sub(af.range.start.col); + af.criteria.retain(|(o, _)| *o != off); + } + super::data::apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 10892bc31..1f1401495 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -21,17 +21,23 @@ pub fn specs() -> Vec { fn dictionary() -> &'static HashSet { static DICT: OnceLock> = OnceLock::new(); DICT.get_or_init(|| { - let mut set = HashSet::new(); + #[cfg(target_arch = "wasm32")] + { + HashSet::new() + } #[cfg(not(target_arch = "wasm32"))] - for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] { - if let Ok(text) = std::fs::read_to_string(path) { - for w in text.lines().take(2_000_000) { - set.insert(w.trim().to_lowercase()); + { + let mut set = HashSet::new(); + for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] { + if let Ok(text) = std::fs::read_to_string(path) { + for w in text.lines().take(2_000_000) { + set.insert(w.trim().to_lowercase()); + } + break; } - break; } + set } - set }) } @@ -155,7 +161,7 @@ fn change(s: &mut Session, p: &Json) -> Result { if bool_param(p, "all").unwrap_or(false) { return s.execute("edit.replace", json!({"what": word, "with": to, "matchCase": true, "all": true})); } - let c = cell_param(p, "cell").ok_or_else(|| bad("review.changeSpelling", "missing `cell`"))?; + let c = cell_param(s, p, "cell")?.ok_or_else(|| bad("review.changeSpelling", "missing `cell`"))?; let text = s.doc()?.wb.active().and_then(|sh| sh.cell(c)).map(|x| x.input_text()).unwrap_or_default(); s.execute("cell.set", json!({"cell": c.a1(), "input": text.replacen(&word, &to, 1)})) } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index ac97040f2..747cb730c 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -96,7 +96,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 ), @@ -118,7 +118,7 @@ fn freeze_panes(s: &mut Session, p: &Json) -> Result { if sh.freeze.is_some() && p.get("cell").is_none() { return set_freeze(s, None); } - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let top = sh.view_top_left; let rows = at.row.saturating_sub(top.row); let cols = at.col.saturating_sub(top.col); @@ -259,7 +259,7 @@ fn scale_fit(s: &mut Session, p: &Json) -> Result { } fn breaks(s: &mut Session, p: &Json) -> Result { - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active); let insert = bool_param(p, "insert").unwrap_or(false); let remove = bool_param(p, "remove").unwrap_or(false); let reset = bool_param(p, "reset").unwrap_or(false); @@ -286,17 +286,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/display.rs b/crates/engine/src/display.rs index a604f0b50..977051824 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -31,6 +31,37 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted { format_value(v, &parsed(code), wb.date_system) } +/// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a +/// constant number in a date or time format is shown as a date (as the grid shows it), not as its +/// serial. When the formatted text would not read back as the same date (for example +/// `dddd, mmmm d`), an ISO `yyyy-mm-dd` (with `hh:mm:ss` for a time of day; just `hh:mm:ss` +/// for a time alone) is used instead, and if even that would not read back, the serial. +pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { + let raw = sheet.input_text(c); + let Some(cell) = sheet.cell(c) else { return raw }; + let Value::Number(n) = &cell.value else { return raw }; + let code = wb.styles.get(cell.style).num_fmt.as_str(); + if cell.formula.is_some() || !parsed(code).is_date() { + return raw; + } + let reads_back = |s: &str| matches!(gridcraft_core::parse::parse_input(s, wb.date_system).value, Value::Number(m) if (m - n).abs() < 1e-9); + let shown = format(&cell.value, code, wb).text; + if reads_back(&shown) { + return shown; + } + let Some(dt) = gridcraft_core::date::datetime_from_serial(wb.date_system, *n) else { return raw }; + let time = format!("{:02}:{:02}:{:02}", dt.hour, dt.minute, dt.second); + let date = format!("{:04}-{:02}-{:02}", dt.year, dt.month, dt.day); + let iso = if *n < 1.0 { + time + } else if n.fract() == 0.0 { + date + } else { + format!("{date} {time}") + }; + if reads_back(&iso) { iso } else { raw } +} + /// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a /// protected sheet. A spilled cell follows its anchor. pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 25dec989b..e388f8734 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, @@ -106,7 +113,17 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) { pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { let sheet = wb.active_sheet; match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) { + // A macro-free .xlsx can't hold a VBA project (Excel refuses such a file), so only a .xlsm + // keeps the macros; any other name saves without them, as Excel does. + FileKind::Xlsx if wb.vba_project.is_some() && !path.to_ascii_lowercase().ends_with(".xlsm") => { + let mut plain = wb.clone(); + plain.vba_project = None; + gridcraft_xlsx::write_xlsx(&plain).map_err(|e| EngineError::Other(e.to_string())) + } 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()), @@ -356,6 +373,20 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> { mod tests { use super::*; + #[test] + fn macros_are_saved_only_into_xlsm() { + let mut wb = Workbook::new(); + wb.vba_project = Some(b"vba".to_vec()); + let has_macros = |path: &str| { + let bytes = save_bytes(&wb, path).unwrap(); + gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some() + }; + assert!(has_macros("book.xlsm")); + assert!(has_macros("BOOK.XLSM")); + assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project"); + assert!(wb.vba_project.is_some(), "the open workbook keeps its macros"); + } + /// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a /// root entry and the given stream names. fn cfb(streams: &[&str]) -> Vec { @@ -388,10 +419,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/lib.rs b/crates/engine/src/lib.rs index 90f9138a7..8dc28d15a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod cmd; pub mod display; pub mod fill; pub mod io; +pub mod paper; pub mod pivot; pub mod pivot_calc; pub mod sample; @@ -67,6 +68,9 @@ pub struct Clipboard { pub wb: Arc, pub sheet: usize, pub range: RangeRef, + /// The range as copied, before whole rows/columns were trimmed to the used range + /// (Insert Copied Cells inserts whole lines for a row or column copy). + pub selected: RangeRef, pub cut: bool, /// Document uid of the source (a cut only moves within the same workbook). pub doc_uid: u64, @@ -135,6 +139,35 @@ impl DocState { pub fn sheet_index(&self) -> usize { self.wb.active_sheet } + /// Records the view state that is saved with the file (`sheetView topLeftCell`, the active + /// cell): the first visible cell of the scrolling area per sheet index in `tops`, and the + /// active sheet's active cell. View state never makes the workbook dirty. + pub fn store_view(&mut self, tops: &[(usize, CellRef)]) { + let active = self.selection.active; + let si = self.wb.active_sheet; + let changed = tops.iter().any(|(i, c)| self.wb.sheets.get(*i).is_some_and(|sh| sh.view_top_left != *c)) + || self.wb.sheets.get(si).is_some_and(|sh| sh.view_active != active); + if !changed { + return; + } + let dirty = self.is_dirty(); + let wb = Arc::make_mut(&mut self.wb); + for (i, c) in tops { + if let Some(sh) = wb.sheets.get_mut(*i) + && sh.view_top_left != *c + { + Arc::make_mut(sh).view_top_left = *c; + } + } + if let Some(sh) = wb.sheets.get_mut(si) + && sh.view_active != active + { + Arc::make_mut(sh).view_active = active; + } + if !dirty { + self.saved = self.wb.clone(); + } + } pub fn display_title(&self) -> String { self.path .as_deref() @@ -202,8 +235,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. @@ -212,6 +245,11 @@ pub struct Session { pub draw_tool: String, pub draw_color: String, pub draw_width: f32, + /// Review › Show Comments: the Comments pane is open. + pub comments_pane: bool, + /// Review › Notes › Show All Notes: every note is displayed. An application display setting + /// (like Excel's comment indicator option), so it's not saved in the file. + pub show_all_notes: bool, } impl Session { 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/engine/src/tests.rs b/crates/engine/src/tests.rs index 78ade6315..c9f4d0aa3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -521,6 +521,123 @@ fn insert_delete_rows_adjust() { assert_eq!(v(&s, "A1"), Value::Number(10.0)); } +#[test] +fn move_columns_reorders_without_overwriting_and_undoes_once() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4, 5, 6, 7, 8]]})).unwrap(); + s.execute("cell.set", json!({"cell": "H2", "input": "=B1+D1"})).unwrap(); + s.execute("cell.set", json!({"cell": "H3", "input": "=SUM(B:B)"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "B:B", "width": 101})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "C:C", "width": 102})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + s.doc_mut().unwrap().undo.clear(); + + let result = s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 4})).unwrap(); + assert_eq!(result["cols"], "C:D"); + assert_eq!( + (0..8).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::>(), + vec![ + Value::Number(1.0), + Value::Number(4.0), + Value::Number(2.0), + Value::Number(3.0), + Value::Number(5.0), + Value::Number(6.0), + Value::Number(7.0), + Value::Number(8.0), + ] + ); + assert_eq!(s.execute("cell.get", json!({"cell": "H2"})).unwrap()["formula"], "=C1+B1"); + assert_eq!(s.execute("cell.get", json!({"cell": "H3"})).unwrap()["formula"], "=SUM(C:C)"); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.col_width(2), 101.0); + assert_eq!(sh.col_width(3), 102.0); + assert_eq!(s.doc().unwrap().selection.current().a1(), "C:D"); + assert_eq!(s.doc().unwrap().undo.len(), 1); + + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.doc().unwrap().wb, *before); +} + +#[test] +fn move_columns_can_reorder_left_and_ignores_drops_inside_the_selection() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap(); + s.execute("sheet.moveColumns", json!({"cols": "D:E", "before": 1})).unwrap(); + assert_eq!( + (0..5).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::>(), + vec!["A".into(), "D".into(), "E".into(), "B".into(), "C".into()] + ); + let undo = s.doc().unwrap().undo.len(); + s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 2})).unwrap(); + assert_eq!(s.doc().unwrap().undo.len(), undo); +} + +#[test] +fn insert_copied_cells_inserts_rows_columns_and_blocks() { + // Issue #21: Insert Copied Cells. + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "=A2*10"})).unwrap(); + s.execute("edit.copy", json!({"range": "2:2"})).unwrap(); + s.execute("selection.set", json!({"range": "C4"})).unwrap(); + s.execute("home.insertCopiedCells", json!({})).unwrap(); + assert_eq!(v(&s, "A4"), Value::Number(2.0)); + assert_eq!(v(&s, "B4"), Value::Number(20.0), "the formula follows its new row"); + assert_eq!(v(&s, "A5"), Value::Number(4.0)); + assert_eq!(v(&s, "A6"), Value::Number(5.0)); + assert_eq!(s.doc().unwrap().selection.current(), gridcraft_core::RangeRef::rows(3, 3)); + // One undo step puts everything back. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A4"), Value::Number(4.0)); + assert_eq!(v(&s, "A6"), Value::Empty); + // Whole columns. + s.execute("edit.copy", json!({"range": "A:A"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "A1"})).unwrap(); + assert_eq!(v(&s, "A3"), Value::Number(3.0)); + assert_eq!(v(&s, "B3"), Value::Number(3.0)); + assert_eq!(v(&s, "C2"), Value::Number(20.0), "the old column B moved right"); + // A block shifts cells right. + s.execute("cell.set", json!({"cell": "F1", "input": "x"})).unwrap(); + s.execute("cell.set", json!({"cell": "G1", "input": "y"})).unwrap(); + s.execute("edit.copy", json!({"range": "F1"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "G1", "shift": "right"})).unwrap(); + assert_eq!(v(&s, "G1"), Value::from("x")); + assert_eq!(v(&s, "H1"), Value::from("y")); + assert!(s.clipboard.is_none(), "inserting ends copy mode"); + s.execute("edit.copy", json!({"range": "F1"})).unwrap(); + assert!(s.execute("home.insertCopiedCells", json!({"shift": "up"})).is_err()); +} + +#[test] +fn insert_cut_cells_moves_whole_rows() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=A5"})).unwrap(); + // Up: row 5 goes above row 2. + s.execute("edit.cut", json!({"range": "5:5"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "A2"})).unwrap(); + let col: Vec = (1..=6).map(|r| v(&s, &format!("A{r}"))).collect(); + assert_eq!(col, [1.0, 5.0, 2.0, 3.0, 4.0].map(Value::Number).into_iter().chain([Value::Empty]).collect::>()); + assert_eq!(v(&s, "C1"), Value::Number(5.0), "references follow the moved cells"); + assert!(s.clipboard.is_none()); + // Down: row 1 goes above row 4. + s.execute("edit.cut", json!({"range": "1:1"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "A4"})).unwrap(); + let col: Vec = (1..=5).map(|r| v(&s, &format!("A{r}"))).collect(); + assert_eq!(col, [5.0, 2.0, 1.0, 3.0, 4.0].map(Value::Number).to_vec()); + assert_eq!(s.doc().unwrap().selection.current(), gridcraft_core::RangeRef::rows(2, 2)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); + // Inside itself, and a cut block, are refused without changing anything. + s.execute("edit.cut", json!({"range": "2:4"})).unwrap(); + assert!(s.execute("home.insertCopiedCells", json!({"at": "A3"})).is_err()); + s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); + assert!(s.execute("home.insertCopiedCells", json!({"at": "B5"})).is_err()); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); + assert_eq!(v(&s, "B5"), Value::Empty); +} + #[test] fn inserting_or_deleting_cells_cancels_copy_mode() { let mut s = s(); @@ -699,7 +816,7 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() { 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!(prot.as_ref().is_some_and(|p| p.format_cells && p.password.is_none()), "{prot:?}"); } } assert!(r.doc().unwrap().wb.active().unwrap().protection.is_none()); @@ -862,6 +979,59 @@ fn tables_and_structured_refs() { assert_eq!(v(&s, "B4"), Value::Number(7.0)); } +#[test] +fn slicer_filters_table() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 2], ["West", 5], ["East", 3], ["North", 1]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap(); + s.execute("selection.set", json!({"cell": "A2"})).unwrap(); + + // A new slicer on the Region column filters nothing (all values selected). + let r = s.execute("insert.slicer", json!({})).unwrap(); + let id = r["slicer"].as_u64().unwrap() as u32; + let hidden = |s: &Session| { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + (1..=4).filter(|row| sh.is_row_hidden(*row)).count() // data rows are sheet rows 1..=4 (0-based header at 0) + }; + assert_eq!(hidden(&s), 0); + { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(sh.slicers.len(), 1); + assert_eq!(sh.slicers[0].column, "Region"); + } + + // Single-click "East": only the two East rows remain (West row 2 and North row 4 hidden). + s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap(); + assert_eq!(hidden(&s), 2); + { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(sh.is_row_hidden(2) && sh.is_row_hidden(4)); + assert!(!sh.is_row_hidden(1) && !sh.is_row_hidden(3)); + } + + // Ctrl-click "West" adds it: East + West visible, only North hidden. + s.execute("slicer.toggle", json!({"slicer": id, "value": "West", "multi": true})).unwrap(); + assert_eq!(hidden(&s), 1); + + // Clear restores everything. + s.execute("slicer.clear", json!({"slicer": id})).unwrap(); + assert_eq!(hidden(&s), 0); + assert!(s.doc().unwrap().wb.active().unwrap().slicers[0].selected.is_none()); + + // Deleting the slicer drops it and clears its filter. + s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap(); + assert_eq!(hidden(&s), 2); + s.execute("slicer.delete", json!({"slicer": id})).unwrap(); + assert_eq!(hidden(&s), 0); + assert!(s.doc().unwrap().wb.active().unwrap().slicers.is_empty()); + + // A bad value is rejected, not panicked. + assert!(s.execute("slicer.toggle", json!({"value": "Nowhere"})).is_err()); +} + #[test] fn create_names_from_selection() { let names = |s: &Session| -> Vec<(String, String)> { @@ -1087,6 +1257,19 @@ fn ods_import_never_reuses_source_as_save_target() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +fn validation_formulas_are_relative_and_see_the_new_value() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [100]]})).unwrap(); + s.execute("data.validation", json!({"range": "A1:A2", "type": "custom", "formula1": "=A1>10"})).unwrap(); + s.execute("data.validation", json!({"range": "C1:C2", "type": "whole", "operator": "lessOrEqual", "formula1": "=B1"})).unwrap(); + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + assert!(ok(&mut s, "A1", "20")); + assert!(!ok(&mut s, "A2", "5")); + assert!(ok(&mut s, "C2", "50")); + assert!(!ok(&mut s, "C1", "50")); +} + #[test] fn samples_build() { for (name, _) in crate::sample::SAMPLES { @@ -1525,6 +1708,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(); @@ -1917,6 +2185,40 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.cells, before.cells); } +#[test] +fn vertical_alignment_commands_and_roundtrip() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "top"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "middle"})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "bottom"})).unwrap(); + + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + s.execute("home.alignTop", json!({})).unwrap(); + + s.execute("selection.set", json!({"range": "A2"})).unwrap(); + s.execute("home.alignMiddle", json!({})).unwrap(); + + s.execute("selection.set", json!({"range": "A3"})).unwrap(); + s.execute("home.alignBottom", json!({})).unwrap(); + + let style_of = |s: &Session, cell: &str| { + let d = s.doc().unwrap(); + d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(cell).unwrap())).clone() + }; + + assert_eq!(style_of(&s, "A1").align.v, gridcraft_model::VAlign::Top); + assert_eq!(style_of(&s, "A2").align.v, gridcraft_model::VAlign::Center); + assert_eq!(style_of(&s, "A3").align.v, gridcraft_model::VAlign::Bottom); + + let d = s.doc().unwrap(); + let bytes = gridcraft_xlsx::write_xlsx(&d.wb).unwrap(); + let (reopened, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap(); + let reopen_style = |cell: &str| reopened.styles.get(reopened.active().unwrap().style_id(CellRef::parse(cell).unwrap())).clone(); + assert_eq!(reopen_style("A1").align.v, gridcraft_model::VAlign::Top); + assert_eq!(reopen_style("A2").align.v, gridcraft_model::VAlign::Center); + assert_eq!(reopen_style("A3").align.v, gridcraft_model::VAlign::Bottom); +} + /// Icon-set thresholds as read from a file (one per icon, the first the lower bound) decide the /// icon; a rule without thresholds uses Excel's defaults (percent 33 and 67) (#235). #[test] @@ -2030,3 +2332,31 @@ fn copying_areas_that_do_not_line_up_is_refused_with_a_message() { assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "E1"})).is_err()); assert!(v(&s, "E1").is_empty()); } + +#[test] +fn editing_a_date_cell_shows_a_date_not_the_serial() { + let mut s = s(); + let at = CellRef::parse("A1").unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "2026-03-04"})).unwrap(); + { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(crate::display::edit_text(&d.wb, sh, at), "2026-03-04"); + } + // A format whose text cannot be typed back falls back to ISO. + s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "dddd, mmmm d"}})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(crate::display::edit_text(&d.wb, sh, at), "2026-03-04"); + // A time alone falls back to a time, not to day 0 of 1900 ("9 AM" would lose the 36 minutes). + let mut s3 = self::s(); + s3.execute("cell.set", json!({"cell": "A1", "input": "0.4"})).unwrap(); + s3.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "h AM/PM"}})).unwrap(); + let d = s3.doc().unwrap(); + assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at), "09:36:00"); + // Plain numbers are untouched. + let mut s2 = self::s(); + s2.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + let d = s2.doc().unwrap(); + assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at), "42"); +} diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 760167436..6f01e0507 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -16,6 +16,35 @@ pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr { }) } +/// Defined names: relative parts move by `(dr, dc)`, wrapping around the sheet's edges. Files +/// store a name's relative references as seen from A1 (a name for "the cell to the left" defined +/// in B1 is `Sheet1!XFD1`), and a use in another cell moves them by that cell's offset from A1. +pub fn shift_relative_wrapping(e: Expr, dr: i64, dc: i64) -> Expr { + let wrap = |v: u32, abs: bool, d: i64, max: u32| if abs { v } else { (v as i64 + d).rem_euclid(max as i64) as u32 }; + let anchor = |a: Anchor| Anchor { row: wrap(a.row, a.row_abs, dr, MAX_ROWS), col: wrap(a.col, a.col_abs, dc, MAX_COLS), ..a }; + e.map(&mut |x| match x { + Expr::Ref(r) => { + let kind = match r.kind { + RefKind::Cell(a) => RefKind::Cell(anchor(a)), + RefKind::Range(a, b) => RefKind::Range(anchor(a), anchor(b)), + RefKind::Rows(r0, a0, r1, a1) => RefKind::Rows(wrap(r0, a0, dr, MAX_ROWS), a0, wrap(r1, a1, dr, MAX_ROWS), a1), + RefKind::Cols(c0, a0, c1, a1) => RefKind::Cols(wrap(c0, a0, dc, MAX_COLS), a0, wrap(c1, a1, dc, MAX_COLS), a1), + }; + Expr::Ref(Reference { sheet: r.sheet, kind }) + } + other => other, + }) +} + +/// Whether the formula has a relative (no `$`) row or column anywhere. +pub fn has_relative_refs(e: &Expr) -> bool { + e.references().iter().any(|r| match r.kind { + RefKind::Cell(a) => !a.row_abs || !a.col_abs, + RefKind::Range(a, b) => !a.row_abs || !a.col_abs || !b.row_abs || !b.col_abs, + RefKind::Rows(_, a0, _, a1) | RefKind::Cols(_, a0, _, a1) => !a0 || !a1, + }) +} + fn shift_anchor(a: &Anchor, dr: i64, dc: i64) -> Option { let row = if a.row_abs { a.row as i64 } else { a.row as i64 + dr }; let col = if a.col_abs { a.col as i64 } else { a.col as i64 + dc }; @@ -195,17 +224,6 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option { let kind = match &r.kind { RefKind::Cell(a) => RefKind::Cell(mv(a)?), RefKind::Range(a, b) => RefKind::Range(mv(a)?, mv(b)?), - k => k.clone(), - }; - Some(Reference { sheet: r.sheet.clone(), kind }) - } - Edit::MoveToSheet { from, to_sheet, to_row, to_col } => { - if !from.contains_range(&r.range()) { - return Some(r.clone()); - } - let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?; - // Whole-row/column references have no anchors, but their indices must move too. - moved.kind = match &r.kind { RefKind::Rows(a, aa, b, ba) => { let dr = *to_row as i64 - from.start.row as i64; RefKind::Rows(shift_line(*a, false, dr, MAX_ROWS)?, *aa, shift_line(*b, false, dr, MAX_ROWS)?, *ba) @@ -214,8 +232,14 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option { let dc = *to_col as i64 - from.start.col as i64; RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba) } - _ => moved.kind, }; + Some(Reference { sheet: r.sheet.clone(), kind }) + } + Edit::MoveToSheet { from, to_sheet, to_row, to_col } => { + if !from.contains_range(&r.range()) { + return Some(r.clone()); + } + let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?; moved.sheet = SheetSel::Named(to_sheet.clone()); Some(moved) } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 9a6dab3f8..78dd7eb6e 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -171,6 +171,8 @@ fn moves_and_renames() { let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 }; let e = parse("A1+B2+C3+SUM(A1:B2)").unwrap(); assert_eq!(print(&adjust(e, "S", "S", &mv)), "D10+E11+C3+SUM(D10:E11)"); + let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("B:C").unwrap(), to_row: 0, to_col: 4 }; + assert_eq!(print(&adjust(parse("SUM(B:B)+SUM($C:$C)+D1").unwrap(), "S", "S", &mv)), "SUM(E:E)+SUM($F:$F)+D1"); let e = parse("Old!A1+'Old'!B2").unwrap(); assert_eq!(print(&rename_sheet(e, "Old", "New Name")), "'New Name'!A1+'New Name'!B2"); } 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 a54691d26..1e4415e42 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/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 1d4abb40c..eb1f20503 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -404,3 +404,116 @@ fn file_commands_without_a_file_fail() { fails(&mut s, "execute_command", json!({"command": command, "params": {}})); } } + +// ---------- cell references ---------- + +fn cmd_fails(s: &mut Server, command: &str, params: Value) -> String { + fails(s, "execute_command", json!({"command": command, "params": params})) +} + +fn a1_display(s: &mut Server) -> String { + ok(s, "get_cell", json!({"cell": "A1"}))["text"].as_str().unwrap_or("").to_string() +} + +#[test] +fn set_cell_invalid_ref_is_error() { + let mut s = server(); + for bad in ["ZZZZ0", "B0", "A", "1", "A1:B2", "not a cell", "", "XFE1", "A1048577"] { + fails(&mut s, "set_cell", json!({"cell": bad, "input": "x"})); + } + assert_eq!(a1_display(&mut s), ""); +} + +#[test] +fn cell_ref_boundaries_still_work() { + let mut s = server(); + ok(&mut s, "set_cell", json!({"cell": "A1", "input": "first"})); + ok(&mut s, "set_cell", json!({"cell": "$B$2", "input": "abs"})); + ok(&mut s, "set_cell", json!({"cell": "XFD1048576", "input": "last"})); + ok(&mut s, "set_cell", json!({"cell": "xfd1", "input": "lower"})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], "abs"); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "XFD1048576"}))["text"], "last"); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "XFD1"}))["text"], "lower"); + assert_eq!(a1_display(&mut s), "first"); + // Ranges are still ranges for the commands that take them. + ok(&mut s, "read_range", json!({"range": "A1:B2"})); + ok(&mut s, "read_range", json!({"range": "$A$1:$B$2"})); + // The active sheet's own name is an accepted prefix; an absent cell still means the selection. + ok(&mut s, "set_cell", json!({"cell": "Sheet1!C3", "input": "named"})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "C3"}))["text"], "named"); + ok(&mut s, "execute_command", json!({"command": "formulas.tracePrecedents", "params": {}})); +} + +#[test] +fn bad_ref_in_command_is_error() { + let mut s = server(); + ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"})); + for (cmd, key) in [("view.freezePanes", "cell"), ("formulas.tracePrecedents", "cell"), ("formulas.traceDependents", "cell"), ("cell.get", "cell")] + { + cmd_fails(&mut s, cmd, json!({ key: "B0" })); + cmd_fails(&mut s, cmd, json!({ key: "nonsense" })); + } + assert_eq!(a1_display(&mut s), "7"); +} + +#[test] +fn other_sheet_prefix_is_error() { + let mut s = server(); + ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "Other"}})); + ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": 0}})); + fails(&mut s, "set_cell", json!({"cell": "Nope!B2", "input": "x"})); + // A command that cannot honour a sheet prefix must not drop it and use the active sheet. + cmd_fails(&mut s, "view.freezePanes", json!({"cell": "Other!B2"})); + cmd_fails(&mut s, "formulas.tracePrecedents", json!({"cell": "Other!B2"})); + ok(&mut s, "set_cell", json!({"cell": "Sheet1!B2", "input": "here"})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], "here"); + // cell.set resolves another existing sheet itself and writes there, not to the active sheet. + ok(&mut s, "set_cell", json!({"cell": "Other!B3", "input": "there"})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B3"}))["text"], ""); + let r = ok(&mut s, "read_range", json!({"range": "Other!B3"})); + assert!(r.to_string().contains("there"), "{r}"); +} + +#[test] +fn cross_sheet_cell_refs_resolve() { + let mut s = server(); + ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "Other"}})); + ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "My Sheet"}})); + ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": 0}})); + ok(&mut s, "set_cell", json!({"cell": "Other!B3", "input": "there"})); + ok(&mut s, "set_cell", json!({"cell": "'My Sheet'!A1", "input": "quoted"})); + // Reads honour the prefix. + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "Other!B3"}))["text"], "there"); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "'My Sheet'!A1"}))["text"], "quoted"); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B3"}))["text"], ""); + // Cell-level commands that resolve their own sheet land on it. + ok(&mut s, "execute_command", json!({"command": "review.newComment", "params": {"cell": "Other!B3", "text": "note"}})); + let c = ok(&mut s, "get_cell", json!({"cell": "Other!B3"})); + assert!(c.to_string().contains("note"), "{c}"); + let c = ok(&mut s, "get_cell", json!({"cell": "B3"})); + assert!(!c.to_string().contains("note"), "{c}"); + // The active sheet may be a quoted name with spaces. + ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": "My Sheet"}})); + ok(&mut s, "execute_command", json!({"command": "view.freezePanes", "params": {"cell": "'My Sheet'!B2"}})); + cmd_fails(&mut s, "view.freezePanes", json!({"cell": "Other!B2"})); + // A prefix naming a different sheet than the `sheet` argument is an error, not resolved in favour of either. + cmd_fails(&mut s, "cell.get", json!({"cell": "Other!B3", "sheet": "Sheet1"})); +} + +#[test] +fn text_to_columns_destination_follows_its_sheet() { + let mut s = server(); + ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "Other"}})); + ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": 0}})); + ok(&mut s, "set_cell", json!({"cell": "Other!A1", "input": "a,b"})); + ok( + &mut s, + "execute_command", + json!({"command": "data.textToColumns", "params": {"range": "Other!A1", "delimiters": [","], "destination": "Other!D1"}}), + ); + let r = ok(&mut s, "read_range", json!({"range": "Other!D1:E1"})); + assert!(r.to_string().contains('a') && r.to_string().contains('b'), "{r}"); + // An unparsable destination and a prefix that contradicts the command's sheet are errors, not A1. + cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "D0"})); + cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "Sheet1!D1"})); +} 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/model/src/b64.rs b/crates/model/src/b64.rs index 6354952f7..30db4b43d 100644 --- a/crates/model/src/b64.rs +++ b/crates/model/src/b64.rs @@ -49,6 +49,26 @@ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> decode(&s).ok_or_else(|| serde::de::Error::custom("invalid base64")) } +/// Base64 for optional byte blobs (VBA projects): `null`/missing stays `None`. +pub mod opt { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(v: &Option>, s: S) -> Result { + match v { + Some(data) => s.serialize_str(&super::encode(data)), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + let s = Option::::deserialize(d)?; + match s { + None => Ok(None), + Some(s) => super::decode(&s).map(Some).ok_or_else(|| serde::de::Error::custom("invalid base64")), + } + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 1864d6d45..0292c3f3a 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -499,6 +499,26 @@ pub struct AutoFilter { pub criteria: Vec<(u32, FilterCriterion)>, } +/// A slicer: an on-sheet filter widget bound to one column of a table. Clicking its value tiles +/// sets that column's `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applies it, +/// so a slicer is just a visual front-end over the existing filter machinery. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Slicer { + pub id: u32, + /// Name of the table this slicer filters. + pub table: String, + /// Header name of the filtered column within the table. + pub column: String, + /// Caption shown in the slicer header (defaults to the column name). + pub caption: String, + pub anchor: Anchor, + /// Selected display values. `None` means every value (no filter from this slicer); an empty + /// list means nothing is selected (all rows hidden), matching Excel's "clear all buttons". + pub selected: Option>, + /// Number of button columns in the layout (Excel default 1). + pub columns: u32, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Orientation { #[default] @@ -554,8 +574,14 @@ impl Default for PrintSettings { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct SheetProtection { - /// Hash of the password (our own salted FNV; never the password itself). - pub password_hash: Option, + /// Hash of the password (Excel-compatible, written to XLSX `sheetProtection`; never the + /// password itself). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + /// The internal hash older GridCraft JSON workbooks stored ([`password_hash`]); still + /// verified when unprotecting, never written for new passwords. + #[serde(rename = "password_hash", default, skip_serializing_if = "Option::is_none")] + pub legacy_password_hash: Option, pub select_locked: bool, pub select_unlocked: bool, pub format_cells: bool, @@ -587,8 +613,8 @@ impl EditRange { } } -/// Simple non-cryptographic password hash for sheet protection (like Excel's, protection is a -/// convenience, not security). +/// The non-cryptographic password hash older GridCraft versions stored for sheet protection +/// (only verified now, see [`SheetProtection::legacy_password_hash`]). pub fn password_hash(pw: &str) -> u64 { let mut h: u64 = 0xcbf29ce484222325 ^ 0x5348_4545_5443; for b in pw.bytes() { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 275d2b2e7..1bea84218 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -9,6 +9,7 @@ pub mod cell; mod cellmap; pub mod features; pub mod password; +pub mod preserved; pub mod sheet; pub mod store; pub mod style; @@ -17,7 +18,8 @@ pub mod workbook; pub use cell::{Cell, CellPicture, Formula}; pub use features::*; pub use password::PasswordHash; -pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility}; +pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml}; +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/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 ec5b70a07..696c9ef21 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, @@ -60,6 +90,9 @@ pub struct Sheet { pub images: Vec, pub shapes: Vec, pub sparklines: Vec, + /// On-sheet slicer widgets (filter a table column by clicking value tiles). + #[serde(default)] + pub slicers: Vec, /// PivotTables whose output lives on this sheet. #[serde(default)] pub pivots: Vec, @@ -71,6 +104,13 @@ 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, + /// 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. @@ -109,12 +149,15 @@ impl Sheet { images: vec![], shapes: vec![], sparklines: vec![], + slicers: vec![], pivots: vec![], protection: None, edit_ranges: vec![], print: PrintSettings::default(), view_active: CellRef::default(), view_top_left: CellRef::default(), + view: SheetViewMode::Normal, + preserved: None, } } diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 6ccef9e9c..e4be016da 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -67,10 +67,21 @@ pub struct Workbook { pub props: DocProps, /// Workbook structure protection. pub protected_structure: bool, + /// Password of the structure protection (XLSX `workbookProtection`), never the password. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub structure_password: Option, /// Custom fill lists (Options › Custom Lists) beyond the built-in ones. pub custom_lists: Vec>, /// Named cell styles (Cell Styles gallery): name → style. pub cell_styles: Vec<(String, crate::style::Style)>, + /// VBA project (`xl/vbaProject.bin`) carried through unchanged. GridCraft does not run + /// macros; the blob is preserved so that saving a macro-enabled workbook never destroys the + /// macros it arrived with. base64 in JSON. `None` = the workbook has no macros. + #[serde(default, with = "crate::b64::opt")] + pub vba_project: Option>, + /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON. + #[serde(skip)] + pub preserved: Option>, } impl Default for Workbook { @@ -92,8 +103,11 @@ impl Workbook { active_sheet: 0, props: DocProps::default(), protected_structure: false, + structure_password: None, custom_lists: vec![], cell_styles: vec![], + vba_project: None, + preserved: None, } } pub fn sheet(&self, i: usize) -> Option<&Sheet> { @@ -216,6 +230,9 @@ impl Workbook { for c in &s.tables { m = m.max(c.id); } + for c in &s.slicers { + m = m.max(c.id); + } } m + 1 } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 4f44fd985..e5847f0c5 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,8 +18,15 @@ log = { workspace = true } image = { workspace = true } skrifa = { workspace = true } +# Linux only, where winit reports no system theme: the desktop portal's light/dark answer (see +# `desktop_theme.rs`). Already in the tree, with these features, through egui's own +# accessibility bus; pinned to one version so nothing is built twice. +[target.'cfg(target_os = "linux")'.dependencies] +zbus = "=5.19.0" + [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +epaint_default_fonts = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # The desktop's preferred language for the first-run interface language. diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 0a481b283..a171199a1 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|cs] -//! [--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/backstage.rs b/crates/ui-egui/src/backstage.rs index 32000f7d9..0f18bcfdb 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -422,6 +422,20 @@ fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { recent_list(app, ui, out, usize::MAX); } +/// Review › Protect Workbook and File › Info › Protect Workbook: the protect dialog (with an +/// optional password) when the structure is open; when it's locked, the password prompt if one +/// was set, else unprotect right away. +pub fn protect_workbook(app: &mut SheetApp) { + let (on, password) = app.session.active().map(|d| (d.wb.protected_structure, d.wb.structure_password.is_some())).unwrap_or((false, false)); + if !on { + app.open_dialog("protectWorkbook", json!({})); + } else if password { + app.open_dialog("unprotectWorkbook", json!({})); + } else { + app.run_or_alert("review.protectWorkbook", json!({"on": false})); + } +} + fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) { let t = Tokens::get(ui.ctx()); let lang = app.ui.language; @@ -451,7 +465,7 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) { 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})); + protect_workbook(app); } ui.add_space(12.0); ui.vertical(|ui| { @@ -724,6 +738,7 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool { } }; } + app.store_view_state(); 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); diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 2cd47a9ba..e773ebfac 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -345,9 +345,115 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo } } } + paint_slicers(app, p, geo, wb, si, sh); painted } +/// Geometry of a slicer's parts within its screen rect. +pub struct SlicerLayout { + pub header: Rect, + /// Clear-filter button in the header. + pub clear: Rect, + /// One rect per value tile, in order. + pub tiles: Vec, +} + +pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32) -> SlicerLayout { + let header_h = 26.0 * z; + let pad = 6.0 * z; + let gap = 3.0 * z; + let tile_h = 22.0 * z; + let header = Rect::from_min_size(rect.min, vec2(rect.width(), header_h)); + let cb = 16.0 * z; + let clear = Rect::from_min_size(pos2(header.right() - pad - cb, header.top() + (header_h - cb) / 2.0), vec2(cb, cb)); + let cols = columns.max(1) as f32; + let inner_left = rect.left() + pad; + let inner_top = header.bottom() + pad; + let inner_w = (rect.width() - 2.0 * pad).max(1.0); + let tile_w = ((inner_w - (cols - 1.0) * gap) / cols).max(1.0); + let mut tiles = Vec::with_capacity(n); + for i in 0..n { + let col = (i as f32) % cols; + let row = (i as f32 / cols).floor(); + let x = inner_left + col * (tile_w + gap); + let y = inner_top + row * (tile_h + gap); + tiles.push(Rect::from_min_size(pos2(x, y), vec2(tile_w, tile_h))); + } + SlicerLayout { header, clear, tiles } +} + +fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) { + let tk = theme::Tokens::get(p.ctx()); + for sl in &sh.slicers { + let r = anchor_rect(geo, sh, &sl.anchor); + if !r.intersects(p.clip_rect()) { + continue; + } + let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + let radius = 4.0 * geo.z; + // Card + soft shadow. + p.rect_filled(r.translate(vec2(0.0, 1.5)), radius, Color32::from_black_alpha(18)); + p.rect_filled(r, radius, tk.window); + p.rect_stroke(r, radius, Stroke::new(1.0, tk.separator), StrokeKind::Inside); + // Header. + p.rect_filled(lay.header, radius, tk.accent_soft); + p.rect_filled(Rect::from_min_max(pos2(lay.header.left(), lay.header.bottom() - radius), lay.header.max), 0.0, tk.accent_soft); + let cap = if sl.caption.is_empty() { sl.column.clone() } else { sl.caption.clone() }; + p.text(lay.header.left_center() + vec2(8.0 * geo.z, 0.0), Align2::LEFT_CENTER, cap, theme::ui_bold(12.5 * geo.z), tk.accent_dark); + // Clear-filter button (tinted when a filter is active). + let filtered = sl.selected.is_some(); + if let Some(icon) = crate::icons::from_name("filter") { + crate::icons::paint(p, lay.clear, icon, if filtered { tk.accent } else { tk.text_dim }); + } + // Value tiles. + for ((val, on), tr) in items.iter().zip(&lay.tiles) { + if !r.contains(tr.left_top()) { + continue; // overflowed the card; skip (no scroll yet) + } + let (fill, fg) = if *on { (tk.accent, Color32::WHITE) } else { (tk.hover, tk.text_dim) }; + p.rect_filled(*tr, 3.0 * geo.z, fill); + let cp = p.with_clip_rect(tr.intersect(p.clip_rect())); + cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg); + } + if app.selected_chart == Some(sl.id) { + selection_frame(p, r); + } + } +} + +/// What a click inside a slicer hit. +pub enum SlicerAction { + Toggle(String), + Clear, +} + +/// A click on a slicer's value tile or clear button, if any. A click elsewhere in a slicer returns +/// `None` so the caller can treat it as a select/drag on the object. +pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> Option<(u32, SlicerAction)> { + if !geo.cells.contains(p) { + return None; + } + for sl in sh.slicers.iter().rev() { + let r = anchor_rect(geo, sh, &sl.anchor); + if !r.contains(p) { + continue; + } + let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + if lay.clear.contains(p) { + return Some((sl.id, SlicerAction::Clear)); + } + for ((val, _on), tr) in items.iter().zip(&lay.tiles) { + if r.contains(tr.left_top()) && tr.contains(p) { + return Some((sl.id, SlicerAction::Toggle(val.clone()))); + } + } + return None; // inside this (topmost) slicer, but on its header/background → select/drag + } + None +} + fn selection_frame(p: &Painter, r: Rect) { p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside); for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] { @@ -360,6 +466,12 @@ fn selection_frame(p: &Painter, r: Rect) { /// The topmost object under `p`: (kind, id, rect). pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(&'static str, u32, Rect)> { let _ = app; + for sl in sh.slicers.iter().rev() { + let r = anchor_rect(geo, sh, &sl.anchor); + if r.contains(p) && geo.cells.contains(p) { + return Some(("slicer", sl.id, r)); + } + } for ch in sh.charts.iter().rev() { let r = anchor_rect(geo, sh, &ch.anchor); if r.expand(4.0).contains(p) && geo.cells.contains(p) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7c9a39e11..3ec3f7225 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -12,8 +12,8 @@ //! - `ui.ribbon {tab}`, `ui.dialog {name}`, `ui.dialog.set {field, value}`, //! `ui.dialog.confirm`, `ui.dialog.cancel`, `ui.message.dismiss` //! - `ui.edit.begin {text?}`, `ui.edit.commit {move?}`, `ui.edit.cancel` -//! - `ui.screenshot {path?}` — PNG of the window (base64 when no path) -//! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?}` +//! - `ui.screenshot {path?}` — PNG of the window (base64 when no path); animations are settled +//! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?, ribbonCollapsed?, animations?}` //! - `app.open {path}`, `app.save {path?}`, `app.quit` use std::sync::mpsc::Sender; @@ -50,6 +50,14 @@ pub struct Shots { pending: Vec<(u64, Option, Sender, f64)>, } +impl Shots { + /// A screenshot was asked for and hasn't been delivered yet: animations hold still, so the + /// picture shows the settled state. + pub(crate) fn in_flight(&self) -> bool { + !self.queued.is_empty() || !self.pending.is_empty() + } +} + fn ok(v: Json) -> Outcome { Outcome::Done(json!({"ok": true, "result": v})) } @@ -172,6 +180,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.collapseRibbon", "Collapse Ribbon", ""), ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), + ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), ("ui.dialog", "Open Dialog", ""), ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"), @@ -202,6 +211,9 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - if let Some(v) = p.get("ribbonCollapsed").and_then(Json::as_bool) { app.ui.ribbon_collapsed = v; } + if let Some(v) = p.get("animations").and_then(Json::as_bool) { + app.ui.animations = v; + } let mut out = serde_json::to_value(&app.ui).unwrap_or_default(); if let Some(o) = out.as_object_mut() { o.insert("formulaBar".into(), json!(app.session.prefs.formula_bar)); diff --git a/crates/ui-egui/src/desktop_theme.rs b/crates/ui-egui/src/desktop_theme.rs new file mode 100644 index 000000000..2eb5d8371 --- /dev/null +++ b/crates/ui-egui/src/desktop_theme.rs @@ -0,0 +1,132 @@ +//! The desktop's own light/dark choice, for an interface setting that follows it. +//! +//! winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` on every +//! desktop there — so "Use system setting" has to ask the desktop itself. The XDG Desktop Portal's +//! Settings interface answers on GNOME, KDE and XFCE alike, over X11 and Wayland alike. + +use std::sync::mpsc::{Receiver, TryRecvError, channel}; +use std::time::Duration; + +/// How often [`DesktopTheme::start`] re-reads the desktop's answer. The portal also publishes a +/// change signal, but subscribing needs a connection kept alive for the life of every window; one +/// round trip every few seconds is cheap and reacts soon enough after a theme switch. +const POLL: Duration = Duration::from_secs(4); + +/// Whether the desktop asks applications to draw dark. `None` when it says nothing, and when +/// there is no session bus or no portal to ask (a bare X server, a container), so the caller +/// keeps whatever it chose itself. +pub fn prefers_dark() -> Option { + read() +} + +/// Watches the desktop and reports its choice when it changes. [`prefers_dark`] covers the +/// answer at start-up; this covers the user switching theme while the app runs. +#[derive(Default)] +pub struct DesktopTheme { + /// `None` when no watcher thread could be started; the one-shot read still works. + changes: Option>>, +} + +impl DesktopTheme { + /// Start watching. Nothing runs before this is called, and calling it once is enough. The + /// first answer is sent straight away, so the caller does not have to ask for it separately. + pub fn start() -> Self { + let (tx, rx) = channel(); + let started = std::thread::Builder::new().name("gridcraft-desktop-theme".into()).spawn(move || { + let mut last = read(); + if tx.send(last).is_err() { + return; + } + loop { + std::thread::sleep(POLL); + let now = read(); + if now != last { + last = now; + if tx.send(now).is_err() { + return; + } + } + } + }); + Self { changes: started.ok().map(|_| rx) } + } + + /// The desktop's new answer if it changed since the last call, to be polled once a frame. + /// Several changes in one frame collapse to the newest, and a desktop that stops answering + /// leaves the last answer in place. + pub fn take_change(&mut self) -> Option { + let Some(changes) = &self.changes else { return None }; + let mut newest = None; + loop { + match changes.try_recv() { + Ok(changed) => newest = changed, + Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return newest, + } + } + } +} + +/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's +/// thread is its only caller. +#[cfg(target_os = "linux")] +fn read() -> Option { + const PORTAL: &str = "org.freedesktop.portal.Desktop"; + const SETTINGS: &str = "org.freedesktop.portal.Settings"; + const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")]; + let connection = zbus::blocking::Connection::session().ok()?; + for (schema, key) in SCHEMAS { + let Ok(reply) = connection.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(schema, key)) else { + continue; + }; + let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue }; + if let Ok(scheme) = String::try_from(&*answer) { + return parse(&scheme); + } + } + None +} + +/// The portal's `color-scheme` answer. `default` means the desktop states no preference, so +/// neither does the application. +#[cfg(target_os = "linux")] +fn parse(scheme: &str) -> Option { + match scheme { + "prefer-dark" => Some(true), + "prefer-light" => Some(false), + _ => None, + } +} + +/// macOS and Windows answer through winit, which the caller already has. +#[cfg(not(target_os = "linux"))] +fn read() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The portal answers with one of three strings; only the two preferences are a choice. + #[test] + #[cfg(target_os = "linux")] + fn the_portal_answer_reads_as_a_choice_or_as_none() { + assert_eq!(parse("prefer-dark"), Some(true)); + assert_eq!(parse("prefer-light"), Some(false)); + assert_eq!(parse("default"), None); + assert_eq!(parse(""), None); + } + + /// Off Linux the caller already has winit's answer, so this adds nothing. + #[test] + #[cfg(not(target_os = "linux"))] + fn other_platforms_answer_through_winit() { + assert_eq!(prefers_dark(), None); + } + + /// A watcher that was never started is inert rather than a panic. + #[test] + fn a_watcher_without_a_thread_is_inert() { + assert_eq!(DesktopTheme::default().take_change(), None); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c214b865b..c3f0ea6e6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -100,6 +100,13 @@ impl Dialog { }], json!({"shift": "down"}), ), + "insertPaste" => Dialog::form( + "insertPaste", + "Insert Paste", + "home.insertCopiedCells", + vec![Choice { key: "shift", label: "Insert", options: vec![("right", "Shift cells right"), ("down", "Shift cells down")] }], + json!({"shift": "down"}), + ), "deleteCells" => Dialog::form( "deleteCells", "Delete", @@ -212,6 +219,23 @@ impl Dialog { json!({"action": "add", "range": sel}), ) } + "protectWorkbook" => Dialog::form( + "protectWorkbook", + "Protect Structure and Windows", + "review.protectWorkbook", + vec![ + Password { key: "password", label: "Password (optional):" }, + Note("Protect workbook for: Structure (sheets can't be added, moved, renamed, hidden or deleted).".into()), + ], + json!({"on": true}), + ), + "unprotectWorkbook" => Dialog::form( + "unprotectWorkbook", + "Unprotect Workbook", + "review.protectWorkbook", + vec![Password { key: "password", label: "Password:" }], + json!({"on": false}), + ), "unprotectSheet" => Dialog::form( "unprotectSheet", "Unprotect Sheet", @@ -366,6 +390,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"}), + ), "analysisToolPak" => { // Opened with a known `tool`, it goes straight to that tool's form. let tool = params.get("tool").and_then(Json::as_str).unwrap_or(""); @@ -653,6 +711,11 @@ impl Dialog { ) } "journal" => Dialog::custom("journal", "Action Journal", json!({})), + "macros" => { + let mut d = Dialog::custom("macros", "Macros", json!({})); + d.result = app.session.run("view.macros", json!({})).ok(); + d + } "statistics" => { let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({})); d.result = app.session.run("review.workbookStatistics", json!({})).ok(); @@ -706,7 +769,8 @@ impl Dialog { "saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title")})), "start" => Dialog::custom("start", "GridCraft", json!({})), "comments" => { - app.grid.pane = Some("comments".into()); + app.grid.pane = None; + app.run_or_alert("review.showComments", json!({"on": true})); return None; } "watch" | "selectionPane" | "formatChart" => { @@ -804,7 +868,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let width = match d.name.as_str() { "formatCells" => 560.0, "about" => 660.0, - "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0, + "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0, "recommendedCharts" => 600.0, "analysisToolPak" => 420.0, "solver" => 460.0, @@ -851,6 +915,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } }); } + "macros" => macros(ui, &mut d), "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { let r = if d.name == "comments" { app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) @@ -1243,6 +1308,34 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) { } } +/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the +/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is +/// preserved and not executed. +fn macros(ui: &mut egui::Ui, d: &mut Dialog) { + ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); + ui.add_space(6.0); + let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); + if items.is_empty() { + ui.weak("This workbook has no scripts or macros."); + return; + } + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for it in &items { + let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); + let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba"); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).strong()); + if is_vba { + ui.weak("— VBA project (preserved, not run)"); + } else { + let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); + ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" })); + } + }); + } + }); +} + fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { ui.horizontal(|ui| { for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] { @@ -1338,8 +1431,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } "Font" => { egui::ComboBox::from_label("Font").selected_text(st.font.name.clone()).show_ui(ui, |ui| { - for f in crate::ribbon::FONTS { - ui.selectable_value(&mut st.font.name, f.to_string(), *f); + for f in crate::fonts::families() { + ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); } }); ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text("Size")); diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs new file mode 100644 index 000000000..d5a8ea1d9 --- /dev/null +++ b/crates/ui-egui/src/fonts.rs @@ -0,0 +1,423 @@ +//! The fonts installed on the machine, so the font list can offer them and cells can render with +//! them (#4). +//! +//! egui has no font discovery of its own and panics when asked for a family that was never bound +//! to font data. So we do both halves: scan the platform's font folders for family names (a read +//! of each font file's `name` table — damaged or unreadable files yield nothing, never a panic), +//! and bind the files a workbook actually uses into the families +//! [`theme::font_definitions`](crate::theme::font_definitions) assembles. +//! +//! Native only: on wasm there are no system fonts, every function here reports "nothing", and the +//! font list is the standard [`crate::ribbon::FONTS`] set. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::OnceLock; + +/// A font file is skipped by the scan past this size: a catalog only needs each face's name, and a +/// damaged or enormous file must not turn startup into a long read. +#[cfg(not(target_arch = "wasm32"))] +const MAX_FONT_FILE: u64 = 64 << 20; + +/// One face of one installed font file. +#[derive(Clone, Debug)] +pub struct Face { + /// Typographic family name, e.g. `"Segoe UI"`. + pub family: String, + /// The file the face lives in. + pub path: PathBuf, + /// Face index within the file (collections hold several, e.g. a family's Regular and Bold). + pub index: u32, + /// Weight implied by the face's style name (400 = regular). + pub weight: f32, + /// Whether the style name reads as italic or oblique. + pub italic: bool, +} + +/// The font folders of the platform (the system's and the user's). Empty on wasm. +pub fn system_font_dirs() -> Vec { + #[cfg(target_arch = "wasm32")] + { + Vec::new() + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut dirs: Vec = Vec::new(); + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from); + if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join("Library/Fonts")); + } + } else if cfg!(windows) { + let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows")); + dirs.push(root.join("Fonts")); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } + } else { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join(".fonts")); + dirs.push(h.join(".local/share/fonts")); + } + } + dirs + } +} + +/// Weight implied by a style name (`"Bold"` → 700, `"Light"` → 300). +#[cfg(not(target_arch = "wasm32"))] +fn style_weight(style: &str) -> f32 { + let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect(); + const TABLE: &[(&str, f32)] = &[ + ("hairline", 100.0), + ("thin", 100.0), + ("extralight", 200.0), + ("ultralight", 200.0), + ("light", 300.0), + ("medium", 500.0), + ("demibold", 600.0), + ("semibold", 600.0), + ("extrabold", 800.0), + ("ultrabold", 800.0), + ("bold", 700.0), + ("black", 900.0), + ("heavy", 900.0), + ]; + TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0) +} + +/// Whether a style name reads as italic or oblique. +#[cfg(not(target_arch = "wasm32"))] +fn style_italic(style: &str) -> bool { + let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect(); + s.contains("italic") || s.contains("oblique") +} + +/// Every face in `data` as `(index, family, style)`. Damaged data yields nothing, never a panic. +#[cfg(not(target_arch = "wasm32"))] +fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> { + use skrifa::raw::FileRef; + use skrifa::{MetadataProvider, string::StringId}; + let count = match FileRef::new(data) { + Ok(FileRef::Font(_)) => 1, + Ok(FileRef::Collection(c)) => c.len(), + Err(_) => return Vec::new(), + }; + let name = |f: &skrifa::FontRef<'_>, ids: &[StringId]| -> Option { + ids.iter().find_map(|id| f.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) + }; + (0..count) + .filter_map(|i| { + let f = skrifa::FontRef::from_index(data, i).ok()?; + let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; + let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + Some((i, family, style)) + }) + .collect() +} + +/// Catalog every face under `dirs`, recursing into subfolders. A folder reached twice (through a +/// symlink) is read once, so a link back to a parent ends the walk. +#[cfg(not(target_arch = "wasm32"))] +pub fn scan_dirs(dirs: &[PathBuf]) -> Vec { + let mut out: Vec = Vec::new(); + let mut stack: Vec = dirs.to_vec(); + let mut seen_dirs: HashSet = HashSet::new(); + while let Some(dir) = stack.pop() { + let key = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone()); + if !seen_dirs.insert(key) { + continue; + } + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + let is_font = + path.extension().and_then(|e| e.to_str()).is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")); + if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) { + continue; + } + let Ok(data) = std::fs::read(&path) else { continue }; + for (index, family, style) in file_faces(&data) { + out.push(Face { family, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) }); + } + } + } + out +} + +#[cfg(target_arch = "wasm32")] +pub fn scan_dirs(_dirs: &[PathBuf]) -> Vec { + Vec::new() +} + +static CATALOG: OnceLock> = OnceLock::new(); + +/// The catalog of installed faces, scanned on first use. Blocks until the scan finishes. +pub fn catalog() -> &'static [Face] { + CATALOG.get_or_init(|| scan_dirs(&system_font_dirs())) +} + +/// The catalog if the scan has finished, else `None` — never blocks, for use while painting. +fn catalog_now() -> Option<&'static [Face]> { + CATALOG.get().map(Vec::as_slice) +} + +/// Start cataloging the installed fonts on a background thread, so the first frame doesn't wait for +/// it. A no-op on wasm and after the scan has started. +pub fn start_scan() { + static STARTED: OnceLock<()> = OnceLock::new(); + STARTED.get_or_init(|| { + #[cfg(not(target_arch = "wasm32"))] + if CATALOG.get().is_none() { + // A failed spawn leaves the scan to the first blocking lookup. + let _ = std::thread::Builder::new().name("font-scan".into()).spawn(|| { + catalog(); + }); + } + }); +} + +/// Whether the background scan has finished. +pub fn scan_done() -> bool { + CATALOG.get().is_some() +} + +/// The faces of one installed family, regular-weight first (the order roles pick from). +pub fn faces_of(family: &str) -> Vec { + let Some(cat) = catalog_now() else { return Vec::new() }; + let mut v: Vec = cat.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).cloned().collect(); + v.sort_by(|a, b| a.weight.total_cmp(&b.weight).then(a.italic.cmp(&b.italic)).then(a.index.cmp(&b.index))); + v +} + +/// The subset of `names` that is installed, sorted and deduped. These are the families whose files +/// get bound for a workbook. +pub fn installed_among(names: &[String]) -> Vec { + let Some(cat) = catalog_now() else { return Vec::new() }; + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for wanted in names { + if !seen.insert(wanted.to_lowercase()) { + continue; + } + if let Some(f) = cat.iter().find(|f| f.family.eq_ignore_ascii_case(wanted)) { + out.push(f.family.clone()); + } + } + out.sort_by_key(|a| a.to_lowercase()); + out +} + +/// The font list a combo box shows: the standard [`crate::ribbon::FONTS`] names first (what Excel +/// offers, and what we substitute), then every other installed family A–Z. Never blocks: until the +/// background scan finishes it returns just the standard names. +pub fn families() -> Vec { + let mut out: Vec = crate::ribbon::FONTS.iter().map(|s| s.to_string()).collect(); + let Some(cat) = catalog_now() else { return out }; + let mut seen: HashSet = out.iter().map(|s| s.to_lowercase()).collect(); + let mut installed: Vec = cat.iter().map(|f| f.family.clone()).collect(); + installed.sort_by_key(|a| a.to_lowercase()); + installed.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + for f in installed { + if seen.insert(f.to_lowercase()) { + out.push(f); + } + } + out +} + +/// The egui family name for one role of one family: `fnt:{family}:{b|n}{i|n}`. +pub fn role_key(family: &str, bold: bool, italic: bool) -> String { + format!("fnt:{family}:{}{}", if bold { 'b' } else { 'n' }, if italic { 'i' } else { 'n' }) +} + +/// The face to use for a role, or `None` when the family has no faces. Missing bold/italic faces +/// fall back to the family's regular face, so every bound role has data behind it. +fn pick_face(faces: &[Face], bold: bool, italic: bool) -> Option<&Face> { + let regular = || faces.iter().find(|f| !f.italic && (f.weight - 400.0).abs() < 1.0); + let exact = |wanted_bold: bool, wanted_italic: bool| { + faces.iter().find(|f| f.italic == wanted_italic && if wanted_bold { f.weight >= 600.0 } else { f.weight < 600.0 }) + }; + (match (bold, italic) { + (true, true) => exact(true, true).or_else(|| exact(true, false)).or_else(|| exact(false, true)), + (true, false) => exact(true, false), + (false, true) => exact(false, true), + (false, false) => None, + }) + .or_else(regular) + .or_else(|| faces.first()) +} + +/// A stable key for a face's font data, unique per file/index within the process. +fn face_key(family: &str, face: &Face) -> String { + let file = face.path.file_name().and_then(|s| s.to_str()).unwrap_or("font"); + format!("fnt-src:{family}:{file}:{}", face.index) +} + +/// Bind one installed family's faces into `fonts`, one egui family per cell role, and return the +/// role keys that are now bound. A role with no exact face chains to the family's regular face, so +/// no role is ever left pointing at missing data (egui panics on that). A family with no faces +/// binds nothing. +/// +/// Each role's chain ends with egui's default proportional fonts, so glyphs the installed font +/// lacks (emoji, a stray script) still render. +pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face]) -> Vec { + if faces.is_empty() { + return Vec::new(); + } + let base: Vec = fonts.families.get(&egui::FontFamily::Proportional).cloned().unwrap_or_default(); + let mut bound: Vec = Vec::new(); + for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] { + let Some(primary) = pick_face(faces, bold, italic) else { continue }; + let mut chain: Vec = Vec::new(); + for face in [Some(primary), pick_face(faces, false, false)] { + let Some(face) = face else { continue }; + let key = face_key(family, face); + if chain.contains(&key) { + continue; + } + let Ok(bytes) = std::fs::read(&face.path) else { continue }; + let mut fd = egui::FontData::from_owned(bytes); + fd.index = face.index; + fonts.font_data.entry(key.clone()).or_insert_with(|| std::sync::Arc::new(fd)); + chain.push(key); + } + if chain.is_empty() { + continue; + } + chain.extend(base.iter().cloned()); + let role = role_key(family, bold, italic); + fonts.families.insert(egui::FontFamily::Name(role.clone().into()), chain); + bound.push(role); + } + bound +} + +/// Bind every installed face of `family` (see [`bind_faces`]). +pub fn bind_family(fonts: &mut egui::FontDefinitions, family: &str) -> Vec { + bind_faces(fonts, family, &faces_of(family)) +} + +/// The role families currently bound to font data. `cell_family` consults this, so it never names a +/// family egui hasn't been given. +static BOUND: OnceLock>> = OnceLock::new(); + +fn bound() -> &'static std::sync::RwLock> { + BOUND.get_or_init(|| std::sync::RwLock::new(HashSet::new())) +} + +/// Record the role families a freshly built [`egui::FontDefinitions`] bound. +pub fn set_bound(keys: impl IntoIterator) { + *bound().write().unwrap_or_else(|e| e.into_inner()) = keys.into_iter().collect(); +} + +/// Is this exact role family bound to font data? +pub fn is_bound(role: &str) -> bool { + bound().read().unwrap_or_else(|e| e.into_inner()).contains(role) +} + +/// Serializes tests that mutate the process-wide bound-family registry. +#[cfg(test)] +pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn style_names_map_to_weight_and_slant() { + assert_eq!(style_weight("Bold"), 700.0); + assert_eq!(style_weight("SemiBold Italic"), 600.0); + assert_eq!(style_weight("Regular"), 400.0); + assert_eq!(style_weight("Light Oblique"), 300.0); + assert!(style_italic("Bold Italic") && style_italic("Oblique")); + assert!(!style_italic("Bold")); + // The weight table is checked in order, so "extralight" wins over "light". + assert_eq!(style_weight("ExtraLight"), 200.0); + } + + #[test] + fn role_keys_are_stable_and_distinct() { + assert_eq!(role_key("Segoe UI", false, false), "fnt:Segoe UI:nn"); + assert_eq!(role_key("Segoe UI", true, false), "fnt:Segoe UI:bn"); + assert_eq!(role_key("Segoe UI", false, true), "fnt:Segoe UI:ni"); + assert_eq!(role_key("Segoe UI", true, true), "fnt:Segoe UI:bi"); + } + + #[test] + fn nothing_is_bound_until_font_definitions_says_so() { + let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // A role key is only safe to name after font_definitions bound it. + let k = role_key("Some Installed Font", true, false); + assert!(!is_bound(&k)); + set_bound([k.clone()]); + assert!(is_bound(&k)); + set_bound([]); + assert!(!is_bound(&k)); + } + + #[test] + fn damaged_or_non_font_files_contribute_nothing() { + assert!(file_faces(b"ttcf\x00\x01\x00\x00\xff\xff\xff\xff").is_empty()); + assert!(file_faces(b"not a font at all").is_empty()); + assert!(file_faces(&[]).is_empty()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn the_scan_catalogs_fonts_and_reads_each_folder_once() { + let dir = std::env::temp_dir().join(format!("gridcraft-fonts-scan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Sub")).expect("temp dir"); + std::fs::write(dir.join("Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).expect("write"); + std::fs::write(dir.join("damaged.ttf"), b"ttcf\x00\x01\x00\x00\xff\xff\xff\xff").expect("write"); + std::fs::write(dir.join("readme.txt"), b"not a font").expect("write"); + // The same folder listed twice (once through `Sub/..`) is read once. + let faces = scan_dirs(&[dir.clone(), dir.join("Sub/..")]); + let families: Vec<&str> = faces.iter().map(|f| f.family.as_str()).collect(); + assert_eq!(families, ["Ubuntu"], "one face, family read from the name table"); + assert_eq!(faces[0].weight, 300.0, "“Light” reads as weight 300"); + assert!(!faces[0].italic); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn a_link_back_to_a_parent_folder_ends_the_scan() { + let dir = std::env::temp_dir().join(format!("gridcraft-fonts-loop-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Sub")).expect("temp dir"); + std::fs::write(dir.join("Sub/Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).expect("write"); + std::os::unix::fs::symlink(&dir, dir.join("Sub/loop")).expect("symlink"); + assert_eq!(scan_dirs(std::slice::from_ref(&dir)).len(), 1, "the loop doesn't multiply faces"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn binding_a_family_binds_all_four_roles_and_never_stores_missing_data() { + let dir = std::env::temp_dir().join(format!("gridcraft-fonts-bind-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("Ubuntu-Light.ttf"); + std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write"); + let face = Face { family: "Ubuntu".into(), path, index: 0, weight: 300.0, italic: false }; + + let mut fonts = egui::FontDefinitions::default(); + let bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&face)); + assert_eq!(bound.len(), 4, "regular, bold, italic and bold-italic roles"); + for key in &bound { + let chain = fonts.families.get(&egui::FontFamily::Name(key.as_str().into())).expect("role family"); + assert!(chain.iter().all(|k| fonts.font_data.contains_key(k)), "every chained key has data"); + } + // A family with no faces binds nothing and never touches the map. + assert!(bind_faces(&mut fonts, "No Such Font", &[]).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 40a25c45c..7404a6132 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -78,7 +78,7 @@ fn active_input(app: &SheetApp) -> String { // A legacy array formula shows in braces (`{=A1:A2*2}`), as `Cell::bar_text` does. if f.array.is_some() { format!("{{{shown}}}") } else { shown } } else { - c.input_text() + gridcraft_engine::display::edit_text(&d.wb, sh, a) }; } // A spilled cell shows the anchor's formula greyed out in Excel; we show it plainly. diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 557afe4ed..2f5aa18c5 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use egui::{Align2, Color32, CursorIcon, FontId, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use gridcraft_engine::cf::{CfCache, CfLook}; use gridcraft_engine::core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters}; -use gridcraft_engine::model::{BorderStyle, Color, HAlign, LineIndex, PatternType, Sheet, Style, Underline, VAlign, Workbook}; +use gridcraft_engine::model::{BorderLine, BorderStyle, Color, HAlign, LineIndex, PatternType, Sheet, Style, Underline, VAlign, Workbook}; use serde_json::json; use crate::SheetApp; @@ -27,6 +27,11 @@ pub enum Drag { Select, Rows(u32), Cols(u32), + /// Reordering one contiguous whole-column selection by its header. + MoveCols { + source: RangeRef, + before: Option, + }, ResizeCol { col: u32, start: f32, @@ -70,6 +75,8 @@ pub struct GridState { pub filter_menu: Option<(u32, Pos2)>, pub context_menu: Option, pub marching_phase: f32, + /// The selection outline on its way to a new selection. + pub glide: crate::selection_glide::SelectionGlide, pub last_click: Option<(CellRef, f64)>, pub hover_cell: Option, pub autosave: bool, @@ -89,6 +96,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 { @@ -121,6 +134,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 { @@ -137,7 +152,37 @@ 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, rtl: sh.right_to_left, ri, ci } + Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, rtl: sh.right_to_left, 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, + } } /// Screen x for a logical x (and back): identity unless the sheet is right-to-left. pub fn mirror_x(&self, x: f32) -> f32 { @@ -154,6 +199,9 @@ impl Geo { self.mirror_rect(self.cell_rect(sh, c)) } 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 { @@ -161,6 +209,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 { @@ -179,6 +230,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) @@ -187,6 +241,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) @@ -380,15 +437,29 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let si = wb.active_sheet; let Some(sh) = wb.sheet(si) else { return }; let sel = d.selection.clone(); + let place = (d.uid, si); 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)); + let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { if zoom_delta != 1.0 { 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; // A horizontal swipe moves the content the way the fingers go, which is towards // higher columns on a mirrored sheet. @@ -402,15 +473,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 { @@ -428,18 +512,94 @@ 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.mirror_rect(geo.cells)); + // The selection outline glides to a new selection. It stays on its cells when Animations is + // off, when egui's animations are (the headless harness), and while a screenshot is on its way. + let outline = app.grid.glide.frame(crate::selection_glide::Frame { + place, + range: sel.current(), + target: geo.range_rect(sh, sel.current()), + view: geo.cells, + now, + frame_nr: ui.ctx().cumulative_frame_nr(), + animate: app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(), + }); + if app.grid.glide.is_gliding() { + ui.ctx().request_repaint(); + } + app.grid.geo = Some(geo.clone()); let painter = ui.painter_at(rect); painter.rect_filled(rect, 0.0, t.grid_bg); // Everything up to the headers is painted in logical (left-to-right) coordinates. let mirror_from = painter.add(egui::Shape::Noop); let mut cf = CfCache::new(); + let quads = if geo.paper.is_some() { + crate::paper_view::paint_pages(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) + } else { + paint_panes(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) + }; + // Objects above cells. + let objects = 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); + paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); + } + if geo.rtl { + let mirror_to = painter.add(egui::Shape::Noop); + mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects); + } + 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); + let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; + vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); + if app.session.clipboard.is_some() { + // By the clock, not per frame: a gliding outline repaints more often than the ants step. + app.grid.marching_phase = crate::selection_glide::ants_phase(now); + 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, + outline: Rect, + 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); @@ -461,8 +621,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, *rows, *cols); + paint_quadrant(&p, geo, wb, si, sh, *rows, *cols, cf, t); + paint_selection(&p, geo, sh, sel, outline, t, app, *rows, *cols); } // Freeze lines. if geo.fr > 0 { @@ -471,38 +631,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. - let objects = 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); - paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); - } - if geo.rtl { - let mirror_to = painter.add(egui::Shape::Noop); - mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects); - } - // 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); - let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; - vertical_scrollbar(app, ui, sh, &geo, track_x, 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 @@ -515,7 +644,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()); @@ -554,6 +683,28 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G } } +pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { + let pad_v = 1.5 * z; + let y_top = rect.top() + pad_v; + let y_bottom = (rect.bottom() - gh - pad_v).max(y_top); + match valign { + VAlign::Top => y_top, + VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, + VAlign::Bottom => y_bottom, + } +} + +pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { + let top_inset = 1.0 * z; + let y_top = rect.top() - top_inset; + let y_bottom = (rect.bottom() - gh - 0.5 * z).max(y_top); + match valign { + VAlign::Top => y_top, + VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, + VAlign::Bottom => y_bottom, + } +} + /// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left /// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction /// and only move so that their box lands mirrored. Floating objects (`objects`: shape index @@ -608,7 +759,17 @@ fn mirror_shape(shape: &mut egui::Shape, axis: f32) { } #[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; @@ -768,19 +929,22 @@ fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, HAlign::Right => rect.right() - pad, _ => text_left + pad + indent, }; - let y = match st.align.v { - VAlign::Top => rect.top() + 1.0, - VAlign::Center => rect.center().y - galley.size().y / 2.0, - _ => rect.bottom() - galley.size().y - 1.0, - }; + let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z); p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color); continue; } let first_line = text.lines().next().unwrap_or("").to_string(); + // Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the + // merged area): it neither spills, nor is clipped, nor turns into ####. + let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none()) + .then(|| shrink_font(p, &first_line, &font, avail_w - 2.0 * pad - indent)) + .flatten(); + let shrunk = small.is_some(); + let font = small.unwrap_or(font); let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color); let mut w = galley.size().x; // Numbers and dates never overflow: they show #### when too narrow. - if (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill { + if !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill { let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize; galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); w = galley.size().x; @@ -838,11 +1002,7 @@ fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, _ => text_left + pad + indent, }; let gh = galley.size().y; - let y = match st.align.v { - VAlign::Top => rect.top() + 1.0, - VAlign::Center | VAlign::Justify | VAlign::Distributed => rect.center().y - gh / 2.0, - VAlign::Bottom => rect.bottom() - gh - 0.5 * z, - }; + let y = cell_text_y(st.align.v, *rect, gh, z); let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); if rotated { let angle = -(st.align.rotation as f32).to_radians(); @@ -884,12 +1044,19 @@ fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, // Borders (over fills and text). for (c, rect, lk, _) in &looks { let b = &lk.style.borders; - let edges = [ - (b.top, [rect.left_top(), rect.right_top()]), - (b.bottom, [rect.left_bottom(), rect.right_bottom()]), - (b.left, [rect.left_top(), rect.left_bottom()]), - (b.right, [rect.right_top(), rect.right_bottom()]), - ]; + let merge = merges.iter().find(|m| m.start == *c); + let edges = match merge { + // A merged area's outline lives on the cells along each of its edges (Excel writes the + // right edge on the last column's cells and the bottom edge on the last row's), so each + // edge is drawn cell by cell from the cell it belongs to. + Some(m) => merged_edges(wb, si, sh, geo, *m, *rect, (r0, r1), (c0, c1), cf), + None => vec![ + (b.top, [rect.left_top(), rect.right_top()]), + (b.bottom, [rect.left_bottom(), rect.right_bottom()]), + (b.left, [rect.left_top(), rect.left_bottom()]), + (b.right, [rect.right_top(), rect.right_bottom()]), + ], + }; for (line, pts) in edges { if line.is_none() { continue; @@ -960,6 +1127,60 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s)) } +/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the +/// first row's and first column's cells, the bottom and right edges from the last row's and last +/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`). +#[allow(clippy::too_many_arguments)] +fn merged_edges( + wb: &Workbook, + si: usize, + sh: &Sheet, + geo: &Geo, + m: RangeRef, + rect: Rect, + rows: (u32, u32), + cols: (u32, u32), + cf: &mut CfCache, +) -> Vec<(BorderLine, [Pos2; 2])> { + let mut out = Vec::new(); + let (cs, ce) = (m.start.col.max(cols.0), m.end.col.min(cols.1)); + for col in cs..=ce { + if sh.col_width(col) == 0.0 { + continue; + } + let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1))); + let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top; + let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom; + out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())])); + out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())])); + } + let (rs, re) = (m.start.row.max(rows.0), m.end.row.min(rows.1)); + for row in rs..=re { + if sh.row_height(row) == 0.0 { + continue; + } + let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1))); + let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left; + let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right; + out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)])); + out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)])); + } + out +} + +/// The smaller font that makes `text` fit in `room` points of width (Shrink to fit), or `None` +/// when it already fits at its own size. +fn shrink_font(p: &Painter, text: &str, font: &FontId, room: f32) -> Option { + let natural = p.layout_no_wrap(text.to_string(), font.clone(), Color32::PLACEHOLDER).size().x; + let room = room.max(1.0); + if natural.is_nan() || natural <= room { + return None; + } + // Glyph advances don't scale exactly linearly (rounding), so leave a little slack. + let size = (font.size * room / natural * 0.97).max(1.0); + Some(FontId::new(size, font.family.clone())) +} + pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { let w = style.width(); let (a, b) = (pts[0], pts[1]); @@ -1026,6 +1247,51 @@ fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { } } +/// Where the active cell is on screen: the whole merged cell when it is part of one. +fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect { + match sh.merge_at(sel.active) { + Some(m) => geo.range_rect(sh, m), + None => geo.cell_rect(sh, sel.active), + } +} + +/// The rectangles shaded for a selection: every area except the active cell, which stays clear so +/// it is plain where typing goes. A single selected cell has no fill. `outline` is where the +/// current area is drawn this frame: it glides to a new selection, and its fill with it. +pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect) -> Vec { + selection_fill_where(geo, sh, sel, outline, |_| true) +} + +/// `selection_fill` for the areas `keep` accepts (a frozen pane draws only the areas it shows). +fn selection_fill_where(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect, keep: impl Fn(&RangeRef) -> bool) -> Vec { + if sel.is_single_cell() { + return vec![]; + } + let active = active_rect(geo, sh, sel); + let area = |r: &RangeRef| if *r == sel.current() { outline } else { geo.range_rect(sh, *r) }; + sel.ranges.iter().filter(|r| keep(r)).flat_map(|r| rect_around(area(r), active)).collect() +} + +/// `area` without `hole`: the strips above and below the hole, then what is left beside it. The +/// selection fill is translucent, so the active cell can't be cleared by painting over it; the +/// pieces never overlap for the same reason. Pieces with no size, or whose numbers aren't finite, +/// are dropped. +fn rect_around(area: Rect, hole: Rect) -> impl Iterator { + // `intersect` takes the sane side of a NaN, which would make a broken hole cover the area. + let hole = if hole.is_finite() { area.intersect(hole) } else { Rect::NOTHING }; + let parts = if hole.is_positive() { + [ + Rect::from_min_max(area.min, pos2(area.max.x, hole.min.y)), + Rect::from_min_max(pos2(area.min.x, hole.max.y), area.max), + Rect::from_min_max(pos2(area.min.x, hole.min.y), pos2(hole.min.x, hole.max.y)), + Rect::from_min_max(pos2(hole.max.x, hole.min.y), pos2(area.max.x, hole.max.y)), + ] + } else { + [area, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING] + }; + parts.into_iter().filter(|r| r.is_positive() && r.is_finite()) +} + /// 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. @@ -1033,36 +1299,27 @@ 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 } +/// `outline` is where the current area's border is drawn this frame (see `selection_glide`). #[allow(clippy::too_many_arguments)] -fn paint_selection( +pub(crate) fn paint_selection( p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, + outline: Rect, 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); + for rect in selection_fill_where(geo, sh, sel, outline, |r| range_in_pane(*r, rows, cols)) { p.rect_filled(rect, 0.0, t.sel_fill); } 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 = active_rect(geo, sh, sel); 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()); // Editing a formula: outline its references instead of the selection. if let Some(ed) = &app.editor && ed.is_formula() @@ -1084,14 +1341,14 @@ fn paint_selection( } } if cur_here { - p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); + p.rect_stroke(outline.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() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) { - let h = Rect::from_center_size(cur.right_bottom(), vec2(6.0, 6.0)); + let h = Rect::from_center_size(outline.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); } @@ -1115,6 +1372,10 @@ fn paint_selection( p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); } } + if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag { + let x = geo.x(sh, *before); + p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent)); + } // 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) @@ -1131,7 +1392,14 @@ fn paint_selection( } } -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())); @@ -1210,7 +1478,7 @@ 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() { + if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { return; } let ctx = ui.ctx().clone(); @@ -1220,7 +1488,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let mods = ctx.input(|i| i.modifiers); // Hit-testing works in logical coordinates (see `Geo::rtl`). let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); - 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)); @@ -1273,7 +1541,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } else if in_cells { ctx.set_cursor_icon(CursorIcon::Cell); } else if in_col_header { - ctx.set_cursor_icon(CursorIcon::Default); + let col = geo.col_at(sh, p.x); + let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); + ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); } app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); } @@ -1285,8 +1555,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() { @@ -1296,9 +1568,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & .ink .iter() .map(|q| { + let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64)); geo.mirror_pos(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, + 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(); @@ -1320,7 +1593,25 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } } - // Objects (charts, pictures, shapes) take clicks first. + // A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear + // button resets it. Runs before the generic object select/drag so tiles stay clickable. + if primary_clicked + && !resp.drag_started() + && let Some(p) = pos + && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet, p) + { + app.selected_chart = Some(id); + match act { + crate::chartview::SlicerAction::Toggle(value) => { + let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl})); + } + crate::chartview::SlicerAction::Clear => { + let _ = app.run("slicer.clear", json!({"slicer": id})); + } + } + return; + } + // Objects (charts, pictures, shapes, slicers) take clicks first. if resp.drag_started() || primary_clicked { // 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 }; @@ -1396,6 +1687,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let c = geo.col_at(sh, p.x); + if resp.drag_started() + && !mods.any() + && sel.ranges.len() == 1 + && sel.current().is_full_cols() + && sel.current().contains(CellRef::new(0, c)) + { + app.grid.drag = Drag::MoveCols { source: sel.current(), before: None }; + return; + } let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) }; select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c)); app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c }); @@ -1479,9 +1779,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & d.selection.anchor = sel.anchor; } } else if mods.command { - let mut ranges: Vec = sel.ranges.iter().map(|r| r.a1()).collect(); - ranges.push(c.a1()); - let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": c.a1()})); + select_ranges(app, RangeRef::cell(c), true, c); } else { let _ = app.session.run("selection.set", json!({"cell": c.a1()})); if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { @@ -1549,6 +1847,14 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); scroll_by(app, vec2(auto.x, 0.0)); } + Drag::MoveCols { source, .. } => { + let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); + let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; + let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1); + let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before); + app.grid.drag = Drag::MoveCols { source, before }; + scroll_by(app, vec2(auto.x, 0.0)); + } Drag::ResizeCol { col, start, orig } => { let w = (orig + (p.x - start) / geo.z).max(0.0); app.toast = @@ -1647,6 +1953,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let _ = app.run("edit.paste", json!({"at": to.a1()})); } } + Drag::MoveCols { source, before: Some(before) } => { + if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } _ => {} } app.grid.drag_select = false; @@ -1838,7 +2149,7 @@ fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tok 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)); } @@ -1849,7 +2160,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)); } @@ -1871,6 +2182,7 @@ fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor), "image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor), + "slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor), _ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor), } } @@ -1903,11 +2215,29 @@ pub fn follow_link(app: &mut SheetApp, target: &str) { } } +/// The areas after a click: with `add` (Ctrl) the clicked area is added, or removed when it is already +/// selected (unless it is the only one, so the selection never ends up empty). +fn toggled_ranges(mut ranges: Vec, clicked: String, add: bool) -> Vec { + if !add { + return vec![clicked]; + } + if ranges.len() > 1 && ranges.contains(&clicked) { + ranges.retain(|x| *x != clicked); + } else if !ranges.contains(&clicked) { + ranges.push(clicked); + } + ranges +} + fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { - let mut ranges: Vec = + let current: Vec = if add { app.session.active().map(|d| d.selection.ranges.iter().map(|x| x.a1()).collect()).unwrap_or_default() } else { vec![] }; - ranges.push(r.a1()); - let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()})); + let clicked = r.a1(); + let ranges = toggled_ranges(current, clicked.clone(), add); + // A removed area takes the active cell with it; `selection.set` then puts it at the start of the last area. + let params = + if ranges.contains(&clicked) { json!({"range": ranges.join(","), "active": active.a1()}) } else { json!({"range": ranges.join(",")}) }; + let _ = app.session.run("selection.set", params); } /// Grid keyboard handling when no text field has focus. @@ -1930,6 +2260,11 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) }); let events = ctx.input(|i| i.events.clone()); + // A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit. + let shortcut_pressed = events.iter().any(|e| { + matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. } + if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space))) + }); let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64; for ev in events { match ev { @@ -1940,6 +2275,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: app.copy_to_clipboard(ctx, "edit.cut"); } egui::Event::Paste(text) => app.paste_external(text), + egui::Event::Text(_) if shortcut_pressed => {} 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() { @@ -2008,8 +2344,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: app.session.format_painter = None; } Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})), - Key::Space if cmd && !shift => app.run_or_alert("selection.column", json!({})), - Key::Space if shift && !cmd => app.run_or_alert("selection.row", json!({})), + Key::F8 if m.alt => { + app.open_dialog("macros", json!({})); + } Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), Key::F4 if !cmd => {} _ if cmd => crate::ribbon::shortcut(app, key, m), @@ -2073,7 +2410,12 @@ 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.first().filter(|t| col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range); + 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); if sh.autofilter.is_none() && let Some(r) = table_range { @@ -2172,7 +2514,8 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { let resp = egui::Area::new(egui::Id::new("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(200.0); - let items: &[(&str, &str)] = &[ + let insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = vec![ ("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste"), @@ -2194,15 +2537,20 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { ("Define Name…", "ui:defineName"), ("Link…", "ui:insertLink"), ]; + if let Some(item) = insert_copied { + items.insert(6, item); + } + let lang = crate::i18n::current(ui.ctx()); for (label, id) in items { - if *label == "-" { + if label == "-" { ui.separator(); continue; } - if ui.add(egui::Button::new(*label).frame(false).min_size(vec2(190.0, 20.0))).clicked() { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { close = true; - match *id { + match id { "ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})), + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), "ui:insertCells" => app.open_dialog("insertCells", json!({})), "ui:deleteCells" => app.open_dialog("deleteCells", json!({})), "ui:formatCells" => app.open_dialog("formatCells", json!({})), @@ -2271,7 +2619,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, @@ -2310,8 +2658,9 @@ fn paint_overlays( } // Notes: shown on hover (or always when marked visible). let hover = app.grid.hover_cell; + let all_notes = app.session.show_all_notes; for (c, cm) in &sh.comments { - let show = cm.visible || hover == Some(*c); + let show = cm.visible || (all_notes && !cm.threaded) || hover == Some(*c); if !show || app.grid.drag != Drag::None { continue; } @@ -2413,7 +2762,8 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let response = egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); - let items: Vec<(&str, &str)> = if rows { + let insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = if rows { vec![ ("Cut", "edit.cut"), ("Copy", "edit.copy"), @@ -2447,14 +2797,19 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { ("Group", "data.group"), ] }; + if let Some(item) = insert_copied { + items.insert(5, item); + } + let lang = crate::i18n::current(ui.ctx()); for (label, id) in items { if label == "-" { ui.separator(); continue; } - if ui.add(egui::Button::new(label).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { close = true; match id { + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), "edit.copy" | "edit.cut" => { @@ -2471,6 +2826,21 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { } } +/// The context-menu entry for Insert Copied Cells / Insert Cut Cells, when the clipboard holds a +/// copy or cut of this app: whole rows or columns insert at once, a block asks which way to shift +/// (Insert Paste). A cut block can only be pasted, so it gets no entry. +fn insert_copied_item(app: &SheetApp) -> Option<(&'static str, &'static str)> { + let clip = app.session.clipboard.as_ref()?; + let lines = clip.selected.is_full_rows() != clip.selected.is_full_cols(); + let cut = clip.cut && app.session.active().is_some_and(|d| d.uid == clip.doc_uid); + match (cut, lines) { + (true, true) => Some(("Insert Cut Cells", "home.insertCopiedCells")), + (true, false) => None, + (false, true) => Some(("Insert Copied Cells", "home.insertCopiedCells")), + (false, false) => Some(("Insert Copied Cells…", "ui:insertPaste")), + } +} + fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool { let Some(d) = app.session.active() else { return false }; d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str() == "checkbox" @@ -2540,6 +2910,114 @@ 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 selection_fill_tests { + use super::*; + + #[test] + fn rect_around_leaves_the_hole_and_never_overlaps() { + let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 250.0)); + let check = |hole: Rect, pieces: usize| { + let parts: Vec = rect_around(area, hole).collect(); + assert_eq!(parts.len(), pieces, "{hole:?}: {parts:?}"); + let cut = area.intersect(hole); + let cut_area = if cut.is_positive() { cut.area() } else { 0.0 }; + let shaded: f32 = parts.iter().map(Rect::area).sum(); + assert!((shaded - (area.area() - cut_area)).abs() < 0.5, "everything but the hole is shaded: {hole:?}"); + for (i, a) in parts.iter().enumerate() { + assert!(area.contains_rect(*a), "nothing outside the area: {a:?}"); + assert!(!a.shrink(0.01).intersects(hole), "the hole stays clear: {a:?} vs {hole:?}"); + // The fill is translucent: a part shaded twice would show darker. + assert!(parts.iter().skip(i + 1).all(|b| !a.shrink(0.01).intersects(*b)), "{parts:?}"); + } + }; + let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)); + // In the middle, in each corner (where a drag began), along an edge. + check(cell(200.0, 100.0), 4); + check(cell(100.0, 50.0), 2); + check(cell(336.0, 50.0), 2); + check(cell(100.0, 234.0), 2); + check(cell(336.0, 234.0), 2); + check(cell(100.0, 100.0), 3); + // A merged cell as wide, or as tall, as the area; one that is all of it, or more. + check(Rect::from_min_max(pos2(100.0, 100.0), pos2(400.0, 132.0)), 2); + check(Rect::from_min_max(pos2(200.0, 50.0), pos2(264.0, 250.0)), 2); + check(area, 0); + check(area.expand(30.0), 0); + // The active cell is in another area, touches this one, or is only partly inside it. + check(cell(500.0, 300.0), 1); + check(cell(400.0, 50.0), 1); + check(cell(380.0, 240.0), 2); + } + + #[test] + fn rect_around_survives_hostile_numbers() { + let sane = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16.0)); + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0]; + for a in bad { + for b in bad { + let weird = Rect::from_min_max(pos2(a, b), pos2(b, a)); + for (area, hole) in [(sane, weird), (weird, sane), (weird, weird)] { + for part in rect_around(area, hole) { + assert!(part.is_positive() && part.is_finite(), "{area:?} around {hole:?}: {part:?}"); + } + } + // A hole that makes no sense leaves the whole area shaded. + if !weird.intersects(sane) { + assert_eq!(rect_around(sane, weird).collect::>(), vec![sane]); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { + let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let gh = 18.5; + let z = 1.0; + + let y_top_std = cell_text_y(VAlign::Top, rect_std, gh, z); + let y_center_std = cell_text_y(VAlign::Center, rect_std, gh, z); + let y_bottom_std = cell_text_y(VAlign::Bottom, rect_std, gh, z); + + assert!(y_top_std < y_center_std, "Top ({y_top_std}) must be higher than Center ({y_center_std})"); + assert!(y_center_std < y_bottom_std, "Center ({y_center_std}) must be higher than Bottom ({y_bottom_std})"); + + // Verify tall row (e.g. 40px) + let rect_tall = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); + let y_top_tall = cell_text_y(VAlign::Top, rect_tall, gh, z); + let y_center_tall = cell_text_y(VAlign::Center, rect_tall, gh, z); + let y_bottom_tall = cell_text_y(VAlign::Bottom, rect_tall, gh, z); + + assert!(y_top_tall < y_center_tall, "Top ({y_top_tall}) must be higher than Center ({y_center_tall})"); + assert!(y_center_tall < y_bottom_tall, "Center ({y_center_tall}) must be higher than Bottom ({y_bottom_tall})"); + } + + #[test] + fn wrapped_cell_text_y_positions_correctly_and_safeguards_overflow() { + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); + let gh = 30.0; + let z = 1.0; + + let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); + let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); + let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); + + assert!(y_top < y_center); + assert!(y_center < y_bottom); + + // Overflow case: gh exceeds rect height + let gh_overflow = 60.0; + let y_bottom_ovf = wrapped_cell_text_y(VAlign::Bottom, rect, gh_overflow, z); + assert!(y_bottom_ovf >= rect.top()); + } +} + #[cfg(test)] mod pane_tests { use super::*; @@ -2562,6 +3040,16 @@ mod pane_tests { assert!(!range_in_pane(sel, corner.0, corner.1)); } + #[test] + fn ctrl_click_on_a_selected_header_deselects_it() { + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), true), s(&["A:A"])); + assert_eq!(toggled_ranges(s(&["A:A"]), "C:C".into(), true), s(&["A:A", "C:C"])); + assert_eq!(toggled_ranges(s(&["A:A"]), "A:A".into(), true), s(&["A:A"])); + assert_eq!(toggled_ranges(s(&["A1", "B2", "3:3"]), "B2".into(), true), s(&["A1", "3:3"])); + assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), false), s(&["C:C"])); + } + #[test] fn selection_across_the_freeze_line_is_drawn_in_both_panes() { let sel = rng(5, 0, 7, 6); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index eae37a494..9b2831caf 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -171,6 +171,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Transpose", "行列を入れ替え"), ("Paste Link", "リンクの貼り付け"), ("Paste Special…", "形式を選択して貼り付け…"), + ("Insert Copied Cells", "コピーしたセルの挿入"), + ("Insert Copied Cells…", "コピーしたセルの挿入…"), + ("Insert Cut Cells", "切り取ったセルの挿入"), + ("Insert Paste", "挿入貼り付け"), ("Cut (⌘X)", "切り取り (⌘X)"), ("Copy (⌘C)", "コピー (⌘C)"), ("Format Painter (double-click to keep it on)", "書式のコピー/貼り付け (ダブルクリックで固定)"), @@ -338,10 +342,17 @@ const JAPANESE: &[(&str, &str)] = &[ ("Data Table…", "データ テーブル…"), ("Scenario Manager…", "シナリオ マネージャー…"), ("Goal Seek…", "ゴール シーク…"), + ("Forecast\nSheet", "予測シート"), ("Spelling", "スペルチェック"), ("Check\nAccessibility", "アクセシビリティ チェック"), ("Threaded Comments", "スレッド化されたコメント"), ("Show\nComments", "コメントの表示"), + ("Previous", "前へ"), + ("Next", "次へ"), + ("Previous Comment", "前のコメント"), + ("Next Comment", "次のコメント"), + ("Show All Notes", "すべてのメモを表示"), + ("Convert to Comments", "コメントに変換"), ("Protect\nWorkbook", "ブックの保護"), ("Allow Edit\nRanges", "範囲の編集を許可"), ("Allow Edit Ranges", "範囲の編集を許可"), @@ -364,6 +375,11 @@ const JAPANESE: &[(&str, &str)] = &[ ("Unfreeze Panes", "ウィンドウ枠固定の解除"), ("Dark Mode", "ダーク モード"), ("Display theme", "表示テーマ"), + ("Animations", "アニメーション"), + ( + "Glide the selection outline to the cell you move to instead of jumping there", + "選択範囲の枠を、移動先のセルまで滑らかに動かします(瞬時には切り替えません)", + ), ("System", "システム"), ("Light", "ライト"), ("Dark", "ダーク"), @@ -444,6 +460,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", "ルーラー"), // File › Backstage view. ("File", "ファイル"), ("Back", "戻る"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index f8605b54f..65e9defc0 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,46 @@ 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", + "Current Date" => "Aktuální datum", + "Current\nDate" => "Aktuální\ndatum", + "Current\nTime" => "Aktuální\nčas", + "Current Time" => "Aktuální čas", + "File Name" => "Název souboru", + "File\nName" => "Název\nsouboru", + "File\nPath" => "Cesta\nk souboru", + "File Path" => "Cesta k souboru", + "Footer" => "Zápatí", + "Go to Footer" => "Přejít na zápatí", + "Go to Header" => "Přejít na záhlaví", + "Go to\nFooter" => "Přejít\nna zápatí", + "Go to\nHeader" => "Přejít\nna záhlaví", + "Header" => "Záhlaví", + "(none)" => "(žádné)", + "Number\nof Pages" => "Počet\nstránek", + "Number of Pages" => "Počet stránek", + "Page\nNumber" => "Číslo\nstránky", + "Page Number" => "Číslo stránky", + "Ruler" => "Pravítko", + "Sheet Name" => "Název listu", + "Sheet\nName" => "Název\nlistu", + "Animations" => "Animace", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Plynule posouvat rámeček výběru na buňku, na kterou přejdete, místo skoku" + } + "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ář", + "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..5cb9881b7 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,46 @@ 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", + "Current Date" => "Aktuelles Datum", + "Current\nDate" => "Aktuelles\nDatum", + "Current\nTime" => "Aktuelle\nUhrzeit", + "Current Time" => "Aktuelle Uhrzeit", + "File Name" => "Dateiname", + "File\nName" => "Datei-\nname", + "File\nPath" => "Datei-\npfad", + "File Path" => "Dateipfad", + "Footer" => "Fußzeile", + "Go to Footer" => "Zur Fußzeile wechseln", + "Go to Header" => "Zur Kopfzeile wechseln", + "Go to\nFooter" => "Zur\nFußzeile", + "Go to\nHeader" => "Zur\nKopfzeile", + "Header" => "Kopfzeile", + "(none)" => "(keine)", + "Number\nof Pages" => "Anzahl\nder Seiten", + "Number of Pages" => "Anzahl der Seiten", + "Page\nNumber" => "Seiten-\nzahl", + "Page Number" => "Seitenzahl", + "Ruler" => "Lineal", + "Sheet Name" => "Blattname", + "Sheet\nName" => "Blatt-\nname", + "Animations" => "Animationen", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Den Auswahlrahmen zur angesteuerten Zelle gleiten lassen, statt zu springen" + } + "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", + "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..b30a1d132 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,46 @@ 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", + "Current Date" => "Fecha actual", + "Current\nDate" => "Fecha\nactual", + "Current\nTime" => "Hora\nactual", + "Current Time" => "Hora actual", + "File Name" => "Nombre de archivo", + "File\nName" => "Nombre de\narchivo", + "File\nPath" => "Ruta de\narchivo", + "File Path" => "Ruta de archivo", + "Footer" => "Pie de página", + "Go to Footer" => "Ir al pie de página", + "Go to Header" => "Ir al encabezado", + "Go to\nFooter" => "Ir al pie\nde página", + "Go to\nHeader" => "Ir al\nencabezado", + "Header" => "Encabezado", + "(none)" => "(ninguno)", + "Number\nof Pages" => "Número\nde páginas", + "Number of Pages" => "Número de páginas", + "Page\nNumber" => "Número de\npágina", + "Page Number" => "Número de página", + "Ruler" => "Regla", + "Sheet Name" => "Nombre de la hoja", + "Sheet\nName" => "Nombre de\nla hoja", + "Animations" => "Animaciones", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Desliza el contorno de la selección hasta la celda a la que te mueves en lugar de saltar" + } + "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", + "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", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 89d9d4e17..fb33cbd44 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -9,6 +9,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "행/열 바꾸기", "Paste Link" => "연결하여 붙여넣기", "Paste Special…" => "선택하여 붙여넣기…", + "Insert Copied Cells" => "복사한 셀 삽입", + "Insert Copied Cells…" => "복사한 셀 삽입…", + "Insert Cut Cells" => "잘라낸 셀 삽입", + "Insert Paste" => "삽입 붙여넣기", "Cut (⌘X)" => "잘라내기 (⌘X)", "Copy (⌘C)" => "복사 (⌘C)", "Format Painter (double-click to keep it on)" => "서식 복사 (두 번 클릭하여 유지)", @@ -175,10 +179,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "데이터 표…", "Scenario Manager…" => "시나리오 관리자…", "Goal Seek…" => "목표값 찾기…", + "Forecast\nSheet" => "예측 시트", "Spelling" => "맞춤법 검사", "Check\nAccessibility" => "접근성\n검사", "Threaded Comments" => "스레드 댓글", "Show\nComments" => "댓글\n표시", + "Previous" => "이전", + "Next" => "다음", + "Previous Comment" => "이전 댓글", + "Next Comment" => "다음 댓글", + "Show All Notes" => "모든 메모 표시", + "Convert to Comments" => "댓글로 변환", "Protect\nWorkbook" => "통합 문서\n보호", "Allow Edit\nRanges" => "범위 편집\n허용", "Allow Edit Ranges" => "범위 편집 허용", @@ -277,11 +288,41 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "기타\n함수", "Page Layout|Orientation" => "용지 방향", "Display theme" => "표시 테마", + "Animations" => "애니메이션", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "선택 테두리가 바로 건너뛰지 않고 이동한 셀까지 부드럽게 미끄러집니다" + } "System" => "시스템", "Light" => "밝게", "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" => "눈금자", // File › Backstage view. "File" => "파일", "Back" => "뒤로", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 3149f2f92..35f1336dd 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -10,6 +10,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Transpor", "Paste Link" => "Colar Vínculo", "Paste Special…" => "Colar Especial…", + "Insert Copied Cells" => "Inserir Células Copiadas", + "Insert Copied Cells…" => "Inserir Células Copiadas…", + "Insert Cut Cells" => "Inserir Células Recortadas", + "Insert Paste" => "Inserir Colagem", "Cut (⌘X)" => "Recortar (⌘X)", "Copy (⌘C)" => "Copiar (⌘C)", "Format Painter (double-click to keep it on)" => "Pincel de Formatação (clique duas vezes para mantê-lo ativo)", @@ -176,10 +180,17 @@ 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", "Show\nComments" => "Mostrar\nComentários", + "Previous" => "Anterior", + "Next" => "Próximo", + "Previous Comment" => "Comentário Anterior", + "Next Comment" => "Próximo Comentário", + "Show All Notes" => "Mostrar Todas as Notas", + "Convert to Comments" => "Converter em Comentários", "Protect\nWorkbook" => "Proteger\nPasta de Trabalho", "Allow Edit\nRanges" => "Permitir Edição\nde Intervalos", "Allow Edit Ranges" => "Permitir Edição de Intervalos", @@ -278,11 +289,41 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Mais\nFunções", "Page Layout|Orientation" => "Orientação", "Display theme" => "Tema de exibição", + "Animations" => "Animações", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Desliza o contorno da seleção até a nova célula em vez de saltar" + } "System" => "Sistema", "Light" => "Claro", "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", // File › Backstage view. "File" => "Arquivo", "Back" => "Voltar", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index eec5dbbba..e50e5ffba 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -9,6 +9,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Транспонировать", "Paste Link" => "Вставить связь", "Paste Special…" => "Специальная вставка…", + "Insert Copied Cells" => "Вставить скопированные ячейки", + "Insert Copied Cells…" => "Вставить скопированные ячейки…", + "Insert Cut Cells" => "Вставить вырезанные ячейки", + "Insert Paste" => "Вставка со сдвигом", "Cut (⌘X)" => "Вырезать (⌘X)", "Copy (⌘C)" => "Копировать (⌘C)", "Format Painter (double-click to keep it on)" => "Формат по образцу (дважды щёлкните, чтобы закрепить)", @@ -175,10 +179,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "Таблица данных…", "Scenario Manager…" => "Диспетчер сценариев…", "Goal Seek…" => "Подбор параметра…", + "Forecast\nSheet" => "Лист\nпрогноза", "Spelling" => "Орфография", "Check\nAccessibility" => "Проверка\nдоступности", "Threaded Comments" => "Комментарии с ветками", "Show\nComments" => "Показать\nкомментарии", + "Previous" => "Назад", + "Next" => "Далее", + "Previous Comment" => "Предыдущий комментарий", + "Next Comment" => "Следующий комментарий", + "Show All Notes" => "Показать все заметки", + "Convert to Comments" => "Преобразовать в комментарии", "Protect\nWorkbook" => "Защитить\nкнигу", "Allow Edit\nRanges" => "Разрешить\nизменение диапазонов", "Allow Edit Ranges" => "Разрешить изменение диапазонов", @@ -277,11 +288,41 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Другие\nфункции", "Page Layout|Orientation" => "Ориентация", "Display theme" => "Тема оформления", + "Animations" => "Анимация", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Рамка выделения плавно скользит к новой ячейке, а не перескакивает" + } "System" => "Системная", "Light" => "Светлая", "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" => "Линейка", // File › Backstage view. "File" => "Файл", "Back" => "Назад", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index e6769cc5f..db71a16ad 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -9,6 +9,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "转置", "Paste Link" => "粘贴链接", "Paste Special…" => "选择性粘贴…", + "Insert Copied Cells" => "插入复制的单元格", + "Insert Copied Cells…" => "插入复制的单元格…", + "Insert Cut Cells" => "插入剪切的单元格", + "Insert Paste" => "插入粘贴", "Cut (⌘X)" => "剪切 (⌘X)", "Copy (⌘C)" => "复制 (⌘C)", "Format Painter (double-click to keep it on)" => "格式刷(双击可持续使用)", @@ -175,10 +179,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "数据表…", "Scenario Manager…" => "方案管理器…", "Goal Seek…" => "单变量求解…", + "Forecast\nSheet" => "预测工作表", "Spelling" => "拼写检查", "Check\nAccessibility" => "检查\n辅助功能", "Threaded Comments" => "线程式批注", "Show\nComments" => "显示\n批注", + "Previous" => "上一条", + "Next" => "下一条", + "Previous Comment" => "上一条批注", + "Next Comment" => "下一条批注", + "Show All Notes" => "显示所有备注", + "Convert to Comments" => "转换为批注", "Protect\nWorkbook" => "保护\n工作簿", "Allow Edit\nRanges" => "允许编辑\n区域", "Allow Edit Ranges" => "允许编辑区域", @@ -277,11 +288,39 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "更多\n函数", "Page Layout|Orientation" => "纸张方向", "Display theme" => "显示主题", + "Animations" => "动画", + "Glide the selection outline to the cell you move to instead of jumping there" => "选择框平滑滑动到新的单元格,而不是直接跳过去", "System" => "系统", "Light" => "浅色", "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" => "标尺", // File › Backstage view. "File" => "文件", "Back" => "返回", diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b0b2592a5..fe9f7bf51 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -115,6 +115,14 @@ pub enum Icon { Sheet, Book, Folder, + /// Header & Footer: a page with its top / bottom band marked. + Header, + Footer, + PageNumber, + PageCount, + Calendar, + Clock, + FileName, /// File › Backstage navigation (line art, drawn in the navigation colour). Back, NewFile, @@ -637,6 +645,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); + } 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); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 1dc90e404..65dbcca85 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -108,6 +108,11 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio Period if cmd && s => run("home.increaseFontSize"), Comma if cmd && s => run("home.decreaseFontSize"), Space if cmd && s => run("edit.selectAll"), + // Shift+Space selects the row; Ctrl+Space (Windows) / Control+Space (Excel for Mac) the + // column. Cmd+Space stays free: it is the macOS Spotlight shortcut. + Space if plain && s => run("selection.row"), + Space if !mac && cmd && !s => run("selection.column"), + Space if mac && ctrl && !m.command && !s => run("selection.column"), Home if cmd && s => Shortcut::ExtendToStart, End if cmd && s => Shortcut::ExtendToEnd, Home if plain && s => Shortcut::ExtendToRowStart, @@ -184,7 +189,7 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic Shortcut::FormulaAbove | Shortcut::ValueAbove => { let Some(above) = active.row.checked_sub(1).map(|r| CellRef::new(r, active.col)) else { return true }; let text = if shortcut == Shortcut::FormulaAbove { - sh.cell(above).map(|c| c.input_text()).unwrap_or_default() + app.session.active().map(|d| gridcraft_engine::display::edit_text(&d.wb, sh, above)).unwrap_or_default() } else { app.session .active() diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 6fc620a6a..a5bf155bb 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -101,6 +101,9 @@ impl KeyTips { "HAR" => self.finish(Action::Command("home.alignRight"), &mut actions), "HAL" => self.finish(Action::Command("home.alignLeft"), &mut actions), "HAC" => self.finish(Action::Command("home.alignCenter"), &mut actions), + "HAT" => self.finish(Action::Command("home.alignTop"), &mut actions), + "HAM" => self.finish(Action::Command("home.alignMiddle"), &mut actions), + "HAB" => self.finish(Action::Command("home.alignBottom"), &mut actions), "HVS" | "ES" => self.finish(Action::Dialog("pasteSpecial"), &mut actions), "HOI" | "OCA" => self.finish(Action::Command("home.autofitColumnWidth"), &mut actions), _ => {} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d17439708..7a0147b90 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,7 @@ pub mod control; pub mod credits; pub mod dialogs; pub mod editor; +pub mod fonts; pub mod formula_bar; mod formula_locale; pub mod grid; @@ -22,8 +23,10 @@ pub mod icons; pub mod keymap; pub mod keytips; pub mod panes; +pub mod paper_view; pub mod pivot_pane; pub mod ribbon; +pub mod selection_glide; pub mod tabs; pub mod text_box; pub mod theme; @@ -37,6 +40,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value as Json, json}; pub use control::{ControlRequest, ControlResponse}; +pub mod desktop_theme; /// Persisted UI preferences. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -49,6 +53,8 @@ pub struct UiState { pub ribbon_collapsed: bool, pub formula_bar_expanded: bool, pub status_bar: bool, + /// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations). + pub animations: bool, pub recent: Vec, /// Interface language. English by default (so tests and headless renders never depend on the /// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has @@ -86,6 +92,7 @@ impl Default for UiState { ribbon_collapsed: false, formula_bar_expanded: false, status_bar: true, + animations: true, recent: vec![], language: i18n::Language::En, interface_scale: 100, @@ -126,6 +133,23 @@ pub struct SheetView { pub scroll: egui::Vec2, } +/// Severity of a notice shown in the info bar under the ribbon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoticeKind { + Info, + Warning, +} + +/// A dismissible bar under the ribbon, in the spirit of Excel's message bar. Used to tell the +/// user what an opened file carried that GridCraft handled differently (e.g. preserved macros). +#[derive(Clone, Debug, PartialEq)] +pub struct Notice { + pub text: String, + pub kind: NoticeKind, + /// Optional button: `(label, dialog name)` — opens that dialog when clicked. + pub action: Option<(String, String)>, +} + /// Host services the platform shell provides (file pickers, opening URLs). #[derive(Default)] pub struct Services { @@ -150,6 +174,9 @@ pub struct Services { pub open_async: Option>, /// Files delivered asynchronously (name, bytes), opened on the next frame. pub inbox: Option, + /// Web: hand PDF bytes to the browser's print flow directly (no download, + /// no intermediate file) — opens the system print dialog on that PDF. + pub print: Option>, } /// Shared queue of files read asynchronously (browser file picker, drag and drop). @@ -174,6 +201,8 @@ pub struct SheetApp { pub backstage: Option, pub message: Option<(String, String)>, pub toast: Option<(String, f64)>, + /// Dismissible info bar under the ribbon (open-time notices). + pub notice: Option, pub services: Services, /// Synthetic input from agents, one batch per frame (drags need several frames). pub synthetic: std::collections::VecDeque>, @@ -182,10 +211,22 @@ pub struct SheetApp { pub perf: Perf, pub fonts_ready: bool, fonts_set: bool, + /// Workbook fonts bound into the current font set; a change rebinds (installed fonts only). + fonts_bound: Vec, + /// The (doc uid, revision) `fonts_bound` was computed for, so the style table is walked only + /// when the workbook changes. + fonts_seen: (u64, u64), + /// Set while a font-set change is pending activation: skip this frame's paint, like the + /// startup `fonts_ready` gate (egui applies new definitions at the next pass). + fonts_rebind: bool, /// Han face order the installed fonts were built with; a language switch rebuilds the fonts /// only when it changes this (see `theme::HanOrder`). fonts_han: Option, effective_dark: bool, + /// The desktop's light/dark choice, watched while the app runs (Linux has no winit answer). + desktop_theme: desktop_theme::DesktopTheme, + /// The last answer [`desktop_theme`] gave, so a frame that asks costs nothing. + desktop_dark: Option, pub name_box: Option, /// Transient ribbon keyboard navigation; never saved with UI preferences. pub keytips: keytips::KeyTips, @@ -207,6 +248,7 @@ impl SheetApp { backstage: None, message: None, toast: None, + notice: None, services, synthetic: Default::default(), control_rx: None, @@ -219,8 +261,13 @@ impl SheetApp { perf: Perf::default(), fonts_ready: false, fonts_set: false, + fonts_bound: Vec::new(), + fonts_seen: (0, 0), + fonts_rebind: false, fonts_han: None, effective_dark: false, + desktop_theme: desktop_theme::DesktopTheme::default(), + desktop_dark: None, name_box: None, keytips: keytips::KeyTips::default(), shots: control::Shots::default(), @@ -229,8 +276,12 @@ impl SheetApp { } } - /// One-time context setup: fonts and visuals (default Han order; never reads the host locale). + /// One-time context setup: start cataloging the machine's fonts, then install the fonts and + /// visuals. Workbook-specific fonts are bound later, once the scan is done (see [`logic`]). + /// + /// [`logic`]: SheetApp::logic pub fn setup_context(ctx: &egui::Context, dark: bool) { + fonts::start_scan(); Self::setup_context_for_language(ctx, dark, i18n::Language::En); } @@ -240,8 +291,23 @@ impl SheetApp { theme::apply(ctx, dark); } + /// The font names the active workbook uses: every style's font plus the theme's heading/body + /// fonts. These are the families [`fonts`] binds an installed file for. + pub fn used_fonts(&self) -> Vec { + let mut names: Vec = Vec::new(); + if let Some(d) = self.session.active() { + names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); + names.push(d.wb.theme.major_font.clone()); + names.push(d.wb.theme.minor_font.clone()); + } + names + } + /// Runs an engine command (or a UI command) and handles UI requests it makes. pub fn run(&mut self, id: &str, params: Json) -> Result { + if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { + self.store_view_state(); + } if id.starts_with("file.") || matches!( id, @@ -323,7 +389,17 @@ impl SheetApp { } /// Runs a command and shows its error in a message box (for menu/ribbon clicks). + /// Follow the desktop's light/dark choice while the app runs (Linux; the desktop app calls + /// this once — tests and embedders don't start the watcher thread). + pub fn watch_desktop_theme(&mut self) { + self.desktop_theme = desktop_theme::DesktopTheme::start(); + } + pub fn run_or_alert(&mut self, id: &str, params: Json) { + // Printing from the UI also hands the PDF to the host's print flow (web). + if id == "file.print" { + return self.print_now(params); + } if let Err(e) = self.run(id, params) { self.alert(e); } @@ -338,6 +414,26 @@ impl SheetApp { } } + /// Runs `file.print`, builds the PDF and (where the host provides the hook — the web + /// build) hands the bytes straight to the browser's print flow; errors show the usual + /// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert` + /// (which still produces the PDF — just without a UI to act on the result yet). + pub fn print_now(&mut self, params: Json) { + match self.run("file.print", params) { + Ok(r) => { + let Some(b64) = r.get("base64").and_then(Json::as_str) else { return }; + let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else { + self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into())); + return; + }; + if let Some(print) = &self.services.print { + print(&bytes); + } + } + Err(e) => self.alert(e), + } + } + /// UI-only commands (dialogs, view toggles that aren't saved in the file). fn run_ui_command(&mut self, id: &str, p: &Json) -> Option> { let r = match id { @@ -366,6 +462,10 @@ impl SheetApp { } Ok(json!({"mode": self.ui.theme_mode()})) } + "view.animations" => { + self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations); + Ok(json!({"on": self.ui.animations})) + } "view.interfaceScale" => { // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. let percent = match p.get("percent") { @@ -425,6 +525,18 @@ impl SheetApp { self.message = None; Ok(Json::Null) } + "ui.notice" => Ok(match &self.notice { + Some(n) => json!({ + "text": n.text, + "kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" }, + "action": n.action, + }), + None => Json::Null, + }), + "ui.notice.dismiss" => { + self.notice = None; + Ok(Json::Null) + } _ => return None, }; Some(r) @@ -476,8 +588,9 @@ impl SheetApp { let path = if std::path::Path::new(&path).extension().is_none() { format!("{path}.xlsx") } else { path }; self.run_or_alert("file.save", json!({"path": path})); } - } else if let Some(dl) = &self.services.download - && let Ok(r) = self.session.run("file.saveBytes", json!({"format": "xlsx"})) + } else if self.services.download.is_some() + && let Ok(r) = self.run("file.saveBytes", json!({"format": "xlsx"})) + && let Some(dl) = &self.services.download && let Some(b) = 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).unwrap_or("Book.xlsx").to_string(); @@ -508,27 +621,92 @@ impl SheetApp { pub fn open_path(&mut self, path: &str) { match self.run("file.open", json!({"path": path})) { - Ok(_) => { + Ok(r) => { self.ui.recent.retain(|p| p != path); self.ui.recent.insert(0, path.to_string()); self.ui.recent.truncate(20); + self.show_open_warnings(&r); } Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); } + /// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the + /// most important one (a preserved VBA project outranks, say, a skipped optional part). + /// Kept deliberately generic: any open-time warning can reach the user this way. + pub fn show_open_warnings(&mut self, r: &Json) { + let Some(list) = r.get("warnings").and_then(Json::as_array) else { + self.notice = None; + return; + }; + let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect(); + if msgs.is_empty() { + // A clean open clears any notice left over from the previous document. + self.notice = None; + return; + } + let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect(); + if !vba.is_empty() { + self.notice = Some(Notice { + text: "This workbook contains VBA macros. GridCraft keeps them intact when you save, but does not run them.".into(), + kind: NoticeKind::Warning, + action: Some(("What this means".into(), "macros".into())), + }); + } else { + let text = if msgs.len() == 1 { + msgs[0].to_string() + } else { + format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0]) + }; + self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None }); + } + } + pub fn view_key(&self) -> Option<(u64, usize)> { self.session.active().map(|d| (d.uid, d.wb.active_sheet)) } pub fn view(&self) -> SheetView { - self.view_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_default() + self.view_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) } pub fn view_mut(&mut self) -> Option<&mut SheetView> { let k = self.view_key()?; - Some(self.views.entry(k).or_default()) + let saved = self.saved_view(); + Some(self.views.entry(k).or_insert(saved)) + } + + /// The view a sheet opens with: scrolled to the file's saved first visible cell (`topLeftCell`). + fn saved_view(&self) -> SheetView { + let Some(sh) = self.session.active().and_then(|d| d.wb.active()) else { return SheetView::default() }; + if sh.view != gridcraft_engine::model::SheetViewMode::Normal { + return SheetView::default(); + } + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + let top = sh.view_top_left; + let y = sh.row_top(top.row.max(fr)) - sh.row_top(fr); + let x = sh.col_left(top.col.max(fc)) - sh.col_left(fc); + SheetView { scroll: egui::vec2(x.max(0.0) as f32, y.max(0.0) as f32) } + } + + /// Hands the active document's scroll positions (first visible cell per sheet) and active + /// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only: + /// Page Layout view scrolls over pages, not cells. + pub fn store_view_state(&mut self) { + let Some(d) = self.session.active_mut() else { return }; + let mut tops = Vec::new(); + for (&(uid, si), v) in &self.views { + let Some(sh) = d.wb.sheets.get(si).filter(|sh| uid == d.uid && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { + continue; + }; + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + // A hair past the edge so a row that starts exactly at the scroll offset is the one found. + let row = sh.row_at(sh.row_top(fr) + v.scroll.y.max(0.0) as f64 + 0.01); + let col = sh.col_at(sh.col_left(fc) + v.scroll.x.max(0.0) as f64 + 0.01); + tops.push((si, CellRef::new(row, col))); + } + d.store_view(&tops); } /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. @@ -550,11 +728,10 @@ impl SheetApp { let current = if sh.cell_pictures.contains_key(&at) { String::new() } else { - sh.cell(at) - .map( - |c| if c.formula.is_some() { formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet) } else { c.input_text() }, - ) - .unwrap_or_default() + match sh.cell(at) { + Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet), + _ => gridcraft_engine::display::edit_text(&d.wb, sh, at), + } }; let (text, replace) = match text { Some(t) => (t, true), @@ -652,11 +829,42 @@ impl SheetApp { } else { SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); self.fonts_set = true; + // The rebuilt set has no workbook fonts yet: bind them again once it is active. + self.fonts_bound.clear(); + self.fonts_seen = (0, 0); self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); ctx.request_repaint(); } + } else if self.fonts_rebind { + // A font-set change made last frame is active now. + self.fonts_rebind = false; + } else if fonts::scan_done() { + // Bind the workbook's installed fonts once the background scan has finished. Adding + // fonts re-applies the font set, which egui activates at the next pass, so skip this + // frame's paint (same one-frame cost as startup). Only when the workbook changed. + let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0)); + if seen != self.fonts_seen { + let used = self.used_fonts(); + self.fonts_seen = seen; + if used != self.fonts_bound { + ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); + self.fonts_bound = used; + self.fonts_rebind = true; + ctx.request_repaint(); + } + } } - let preference = self.ui.theme_preference(); + if let Some(answer) = self.desktop_theme.take_change() { + self.desktop_dark = Some(answer); + } + let preference = match ( + self.ui.system_theme, + ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), + ) { + (true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark, + (true, Some(egui::Theme::Light)) => egui::ThemePreference::Light, + _ => self.ui.theme_preference(), + }; if ctx.options(|o| o.theme_preference) != preference { ctx.set_theme(preference); ctx.request_repaint(); @@ -684,7 +892,10 @@ impl SheetApp { let b64 = gridcraft_engine::io::base64_encode(&bytes); 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, + Ok(r) => { + self.backstage = None; + self.show_open_warnings(&r); + } Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); @@ -703,7 +914,7 @@ impl SheetApp { /// Lays out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); - if !self.fonts_ready { + if !self.fonts_ready || self.fonts_rebind { ctx.request_repaint(); return; } @@ -721,6 +932,7 @@ impl SheetApp { backstage::show(self, ui); } else { ribbon::show(self, ui); + widgets::notice_bar(self, ui); if self.session.prefs.formula_bar { formula_bar::show(self, ui); } @@ -761,6 +973,7 @@ impl SheetApp { && self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))) { self.grid.last_autosave = now; + self.store_view_state(); let _ = self.session.run("file.save", json!({})); } // Window title. diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 47da52d1f..3100cfeda 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -7,7 +7,15 @@ use crate::SheetApp; use crate::theme::{self, Tokens}; pub fn show(app: &mut SheetApp, ui: &mut Ui) { - let Some(pane) = app.grid.pane.clone() else { return }; + // The Comments pane is engine state (`review.showComments`, so agents can drive it); the other + // panes are UI state. One pane shows at a time: opening another one closes Comments. + if app.grid.pane.as_deref() == Some("comments") { + app.grid.pane = None; + app.run_or_alert("review.showComments", json!({"on": true})); + } else if app.grid.pane.is_some() && app.session.comments_pane { + app.run_or_alert("review.showComments", json!({"on": false})); + } + let Some(pane) = app.grid.pane.clone().or_else(|| app.session.comments_pane.then(|| "comments".to_string())) else { return }; let t = Tokens::get(ui.ctx()); egui::Panel::right("task_pane").default_size(300.0).resizable(true).frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| { ui.horizontal(|ui| { @@ -21,6 +29,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { ui.label(egui::RichText::new(title).font(theme::ui_bold(15.0))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button(ui, crate::icons::Icon::Close, t.text_dim, "Close", vec2(22.0, 22.0)).clicked() { + if pane == "comments" { + app.run_or_alert("review.showComments", json!({"on": false})); + } app.grid.pane = None; } }); diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs new file mode 100644 index 000000000..1546bbbcf --- /dev/null +++ b/crates/ui-egui/src/paper_view.rs @@ -0,0 +1,606 @@ +//! 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, + outline: Rect, + 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, outline, t, app, rows, cols); + } + 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 bb8c53ce6..a0a57f4db 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -98,7 +98,6 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ("New Workbook", "file.new"), ("Open…", "file.open"), ("Save As…", "file.saveAs"), - ("Print…", "file.print"), ("Sort A to Z", "data.sortAscending"), ("Calculate Now", "formulas.calculateNow"), ] { @@ -106,6 +105,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { app.run_or_alert(id, json!({})); } } + if ui.button("Print…").clicked() { + app.print_now(json!({})); + } }); // Centered title. let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); @@ -231,6 +233,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), } }); @@ -273,6 +276,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 } @@ -527,7 +533,16 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { (Icon::AlignMiddle, VAlign::Center, "home.alignMiddle", "Middle Align"), (Icon::AlignBottom, VAlign::Bottom, "home.alignBottom", "Bottom Align"), ] { - if toggle_button(ui, icon, st.align.v == v, tip).clicked() { + let response = toggle_button(ui, icon, st.align.v == v, tip); + if matches!(app.keytips.prefix(), Some("H" | "HA")) { + let sequence = match v { + VAlign::Top => "HAT", + VAlign::Center => "HAM", + _ => "HAB", + }; + keytip(app, ui, sequence, response.rect.center_bottom()); + } + if response.clicked() { act(app, id, json!({})); } } @@ -814,9 +829,9 @@ fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { let mut name = st.font.name.clone(); let before = name.clone(); egui::ComboBox::from_id_salt("font_name").width(150.0).selected_text(egui::RichText::new(&name).font(theme::ui_font(12.5))).show_ui(ui, |ui| { - for f in FONTS { - let fam = theme::cell_family(f, false, false); - ui.selectable_value(&mut name, f.to_string(), egui::RichText::new(*f).font(egui::FontId::new(14.0, fam))); + for f in crate::fonts::families() { + let fam = theme::cell_family(&f, false, false); + ui.selectable_value(&mut name, f.clone(), egui::RichText::new(&f).font(egui::FontId::new(14.0, fam))); } }); if name != before { @@ -1251,6 +1266,10 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { }); }); sep(ui); + if big_button(ui, Icon::Filter, "Slicer", "Insert a slicer to filter a table", false).clicked() { + act(app, "insert.slicer", json!({})); + } + sep(ui); let sp = big_button(ui, Icon::Sparkline, "Sparklines", "Sparklines", true); egui::Popup::menu(&sp).show(|ui| { for (label, kind) in [("Line", "line"), ("Column", "column"), ("Win/Loss", "winLoss")] { @@ -1524,10 +1543,10 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { if small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false).clicked() { app.open_dialog("evaluateFormula", json!({})); } - if small_button(ui, Icon::Search, "Watch Window", "Watch Window", false).clicked() { - app.grid.pane = Some("watch".into()); - } }); + if big_button(ui, Icon::Search, "Watch\nWindow", "Watch Window", false).clicked() { + app.grid.pane = Some("watch".into()); + } sep(ui); let co = big_button(ui, Icon::Calc, "Calculation\nOptions", "Calculation Options", true); egui::Popup::menu(&co).show(|ui| { @@ -1614,6 +1633,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() { @@ -1650,15 +1672,43 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { if big_button(ui, Icon::Comment, "New\nComment", "New Comment", false).clicked() { app.open_dialog("comment", json!({"threaded": true})); } - if big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false).clicked() { - app.grid.pane = Some("comments".into()); - } if big_button(ui, Icon::Delete, "Delete", "Delete Comment", false).clicked() { act(app, "review.deleteComment", json!({})); } + ui.vertical(|ui| { + for (icon, label, tip, id) in + [(Icon::Left, "Previous", "Previous Comment", "review.previousComment"), (Icon::Right, "Next", "Next Comment", "review.nextComment")] + { + if small_button(ui, icon, label, tip, false).clicked() { + act(app, id, json!({})); + app.grid.ensure_visible = true; + } + } + }); + let shown = app.session.comments_pane; + let r = big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false); + if shown { + ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if r.clicked() { + act(app, "review.showComments", json!({"on": !shown})); + if app.session.comments_pane { + app.grid.pane = None; + } + } let notes = big_button(ui, Icon::Note, "Notes", "Notes", true); egui::Popup::menu(¬es).show(|ui| { - menu_items(app, ui, &[("New Note", "dialog:comment", json!({"threaded": false})), ("Show/Hide Note", "review.showNote", json!({}))]); + menu_items( + app, + ui, + &[ + ("New Note", "dialog:comment", json!({"threaded": false})), + ("Show/Hide Note", "review.showNote", json!({})), + ("Show All Notes", "review.showAllNotes", json!({})), + ("-", "", json!({})), + ("Convert to Comments", "review.convertToComments", json!({})), + ], + ); }); sep(ui); let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); @@ -1670,7 +1720,7 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { } } if big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false).clicked() { - act(app, "review.protectWorkbook", json!({})); + crate::backstage::protect_workbook(app); } if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() { app.open_dialog("allowEditRanges", json!({})); @@ -1692,7 +1742,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.session.prefs.formula_bar; if ui.checkbox(&mut fb, lang.tr("Formula Bar")).changed() { act(app, "view.formulaBar", json!({"on": fb})); @@ -1746,6 +1803,11 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { } } }); + let mut animations = app.ui.animations; + let hint = lang.tr("Glide the selection outline to the cell you move to instead of jumping there"); + if ui.checkbox(&mut animations, lang.tr("Animations")).on_hover_text(hint).changed() { + act(app, "view.animations", json!({"on": animations})); + } }); sep(ui); // Interface size (issue #230): scales the whole window on top of the desktop's scale. @@ -1782,6 +1844,9 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { } fn automate(app: &mut SheetApp, ui: &mut Ui) { + if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { + app.open_dialog("macros", json!({})); + } if big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false).clicked() { app.open_dialog("commandSearch", json!({})); } @@ -1821,6 +1886,10 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) { act(app, "table.convertToRange", json!({"table": tname})); } sep(ui); + if big_button(ui, Icon::Filter, "Insert\nSlicer", "Insert Slicer", false).clicked() { + act(app, "table.insertSlicer", json!({"table": tname})); + } + sep(ui); ui.vertical(|ui| { for (label, cmd, v) in [ ("Header Row", "table.headerRow", header && filter), @@ -1927,6 +1996,10 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { Key::O => Some(("file.open", json!({}))), Key::N => Some(("file.new", json!({}))), Key::W => Some(("file.close", json!({}))), + Key::P => { + app.print_now(json!({})); + None + } Key::K => { app.open_dialog("insertLink", json!({})); None diff --git a/crates/ui-egui/src/selection_glide.rs b/crates/ui-egui/src/selection_glide.rs new file mode 100644 index 000000000..dbd8bfa64 --- /dev/null +++ b/crates/ui-egui/src/selection_glide.rs @@ -0,0 +1,383 @@ +//! The selection outline glides to a new selection instead of jumping there, like Excel's cell +//! cursor: press an arrow key or click a cell and the border slides over in about a tenth of a +//! second; extend the selection and it grows edge by edge. View ▸ Animations turns it off. +//! +//! The behaviour comes from using Excel as a user; the duration and the easing curve are an +//! estimate ([`GLIDE_SECS`]), not yet measured against Excel. +//! +//! What glides and what doesn't: +//! - the selection changed (arrows, Enter, Tab, a click, a drag, Shift+arrows, the Name Box, any +//! command): the outline of the current area, its fill and its fill handle glide from wherever +//! they are drawn right now, so a held arrow key reads as one continuous motion; +//! - the same cells moved on screen (scrolling, zoom, a resized column or window): the outline +//! stays glued to its cells; +//! - another sheet or workbook, an outline that wasn't drawn the frame before, a move that starts +//! or ends off screen: it appears in place; +//! - a control-channel screenshot is on its way, or egui's animations are off (the headless +//! harness turns them off): it appears in place, so scripted pictures never catch it in flight; +//! - Animations is turned off (`view.animations`): it jumps. +//! +//! Only painting is delayed. The selection itself, hit testing (the fill handle, the border you +//! drag) and the cell editor use the real cells at once. +//! +//! The glide is kept as each edge's distance from its target, decaying to zero, so the outline is +//! always "the cells' rectangle plus what is left of the offset", in screen points. Scrolling +//! under a gliding outline therefore carries it along, and an arrow key held at the edge of the +//! window (the sheet scrolls, the cell stays where it is on screen) doesn't make it wobble. + +use egui::{Rect, pos2}; +use gridcraft_engine::core::RangeRef; + +/// How long a glide takes, in seconds. +pub const GLIDE_SECS: f64 = 0.12; +/// How far outside the cell area a glide may start or end (screen points). +const OFFSCREEN_SLACK: f32 = 48.0; + +/// What the grid knows about the selection outline in one frame. +#[derive(Clone, Copy, Debug)] +pub struct Frame { + /// The workbook (its uid) and sheet on screen: the outline never glides between two of them. + pub place: (u64, usize), + /// The current area of the selection. + pub range: RangeRef, + /// Where that area's cells are on screen. + pub target: Rect, + /// The grid's cell area on screen. + pub view: Rect, + /// egui's clock, in seconds. + pub now: f64, + /// egui's frame counter: an outline that skipped a frame doesn't glide from its stale place. + pub frame_nr: u64, + /// Glides may run this frame; otherwise the outline is on its cells. + pub animate: bool, +} + +/// The outline's motion between frames. Time-based, so it looks the same at any frame rate. +#[derive(Clone, Debug)] +pub struct SelectionGlide { + /// What the outline was last drawn for. + last: Option<((u64, usize), RangeRef)>, + /// Where it was drawn (screen points). + drawn: Rect, + /// Each edge's distance from the target when the glide set off: left, top, right, bottom. + offset: [f32; 4], + started: f64, + gliding: bool, + frame_nr: u64, +} + +impl Default for SelectionGlide { + fn default() -> Self { + SelectionGlide { last: None, drawn: Rect::ZERO, offset: [0.0; 4], started: 0.0, gliding: false, frame_nr: 0 } + } +} + +impl SelectionGlide { + /// Advance to this frame; returns where to draw the outline. + pub fn frame(&mut self, f: Frame) -> Rect { + let fresh = f.frame_nr.wrapping_sub(self.frame_nr) <= 1; + self.frame_nr = f.frame_nr; + let key = Some((f.place, f.range)); + if self.last != key { + // A new destination: set off from where the outline is drawn now (mid-glide or at rest). + let same_sheet = self.last.is_some_and(|(place, _)| place == f.place); + self.offset = if same_sheet && fresh { offset_between(self.drawn, f.target, f.view) } else { [0.0; 4] }; + self.started = f.now; + self.last = key; + } + if !f.animate { + self.offset = [0.0; 4]; + } + // Arrived when the time is up (or the clock went backwards). + let progress = (f.now - self.started) / GLIDE_SECS; + self.gliding = self.offset != [0.0; 4] && (0.0..1.0).contains(&progress); + let left = if self.gliding { 1.0 - ease_out(progress as f32) } else { 0.0 }; + let [l, t, r, b] = self.offset.map(|o| o * left); + self.drawn = Rect::from_min_max(pos2(f.target.min.x + l, f.target.min.y + t), pos2(f.target.max.x + r, f.target.max.y + b)); + self.drawn + } + + /// The outline is on its way somewhere: keep repainting. + pub fn is_gliding(&self) -> bool { + self.gliding + } + + /// Where the outline was drawn last frame (screen points). + pub fn drawn(&self) -> Rect { + self.drawn + } +} + +/// Decelerating curve: quick off the mark, soft landing. +fn ease_out(t: f32) -> f32 { + let r = 1.0 - t.clamp(0.0, 1.0); + 1.0 - r * r * r +} + +/// Each edge's distance from `to` back to `from`. No glide (all zero) when either end is off +/// screen or the numbers are not sane. An edge sets off at most one view away, so the far edge of +/// a whole column or row slides in from the border of the window instead of popping in at the end. +fn offset_between(from: Rect, to: Rect, view: Rect) -> [f32; 4] { + let seen = view.expand(OFFSCREEN_SLACK); + if !(seen.intersects(from) && seen.intersects(to)) { + return [0.0; 4]; + } + let limit = |v: f32| if v.is_finite() { v.abs() } else { 0.0 }; + let (lx, ly) = (limit(view.width()), limit(view.height())); + let offset = [ + (from.min.x - to.min.x).clamp(-lx, lx), + (from.min.y - to.min.y).clamp(-ly, ly), + (from.max.x - to.max.x).clamp(-lx, lx), + (from.max.y - to.max.y).clamp(-ly, ly), + ]; + if offset.iter().all(|o| o.is_finite()) { offset } else { [0.0; 4] } +} + +/// The marching ants' dash offset around a copied range, by egui's clock: half a point every +/// 60 ms, wrapping with the 8-point dash pattern. Stepping it once per painted frame is steady +/// only while nothing else repaints; a gliding outline repaints every frame and would make the +/// ants lurch forward. +pub fn ants_phase(now: f64) -> f32 { + let phase = (now / 0.12).rem_euclid(8.0) as f32; + if phase.is_finite() { phase } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::vec2; + use gridcraft_engine::core::CellRef; + + const PLACE: (u64, usize) = (1, 0); + const VIEW: Rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(1000.0, 600.0)); + const DT: f64 = 1.0 / 240.0; + + fn cell(row: u32, col: u32) -> RangeRef { + RangeRef::cell(CellRef::new(row, col)) + } + + /// A cell-sized rectangle at a place on screen. + fn at(x: f32, y: f32) -> Rect { + Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)) + } + + fn near(a: Rect, b: Rect) -> bool { + (a.min - b.min).length() < 1e-3 && (a.max - b.max).length() < 1e-3 + } + + /// Drives a glide the way the grid does: one frame per step, a clock, a frame counter. + struct Rig { + glide: SelectionGlide, + f: Frame, + } + + impl Rig { + fn new(range: RangeRef, target: Rect) -> Self { + let f = Frame { place: PLACE, range, target, view: VIEW, now: 10.0, frame_nr: 100, animate: true }; + let mut rig = Rig { glide: SelectionGlide::default(), f }; + rig.step(0.0); + rig + } + + /// The next frame, `dt` seconds later. + fn step(&mut self, dt: f64) -> Rect { + self.f.now += dt; + self.f.frame_nr += 1; + self.glide.frame(self.f) + } + + /// The selection changes; the next frame shows it. + fn select(&mut self, range: RangeRef, target: Rect) { + self.f.range = range; + self.f.target = target; + } + + /// Steps until the outline rests, returning every rectangle drawn on the way. + fn settle(&mut self, dt: f64) -> Vec { + let mut drawn = vec![self.step(dt)]; + while self.glide.is_gliding() && drawn.len() < 10_000 { + drawn.push(self.step(dt)); + } + drawn + } + } + + #[test] + fn the_first_outline_is_on_its_cells() { + let r = Rig::new(cell(0, 0), at(10.0, 10.0)); + assert_eq!(r.glide.drawn(), at(10.0, 10.0)); + assert!(!r.glide.is_gliding()); + } + + #[test] + fn a_move_glides_and_lands_exactly() { + let (a, b) = (at(0.0, 0.0), at(64.0, 32.0)); + let mut r = Rig::new(cell(0, 0), a); + r.select(cell(2, 1), b); + let drawn = r.settle(DT); + assert_eq!(drawn.first(), Some(&a), "it sets off from where it was"); + assert_eq!(drawn.last(), Some(&b), "and lands exactly on the cells"); + assert!(drawn.len() > 20, "over about {GLIDE_SECS} s, not in one frame: {} frames", drawn.len()); + for w in drawn.windows(2) { + assert!(w[0].min.x <= w[1].min.x && w[0].min.y <= w[1].min.y, "never backwards or past the cells: {w:?}"); + assert!(w[1].min.x <= b.min.x && w[1].min.y <= b.min.y); + assert!((w[1].width() - 64.0).abs() < 1e-3 && (w[1].height() - 16.0).abs() < 1e-3, "it keeps its shape on the way"); + } + // Decelerating: more than half of the way in the first third of the time. + let third = drawn[drawn.len() / 3]; + assert!(third.min.x > 32.0, "{third:?}"); + } + + #[test] + fn the_glide_is_the_same_at_any_frame_rate() { + let at_time = |dt: f64, frames: usize| { + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.select(cell(0, 5), at(320.0, 0.0)); + r.step(0.0); + (0..frames).map(|_| r.step(dt)).last().unwrap() + }; + // 60 ms into the glide at 50, 100 and 200 frames a second. + let (slow, mid, fast) = (at_time(0.02, 3), at_time(0.01, 6), at_time(0.005, 12)); + assert!(near(slow, mid) && near(mid, fast), "{slow:?} {mid:?} {fast:?}"); + assert!(slow.min.x > 0.0 && slow.min.x < 320.0); + } + + #[test] + fn extending_the_selection_grows_it_edge_by_edge() { + let one = at(0.0, 0.0); + let two = Rect::from_min_size(pos2(0.0, 0.0), vec2(128.0, 16.0)); + let mut r = Rig::new(cell(0, 0), one); + r.select(RangeRef::new(CellRef::new(0, 0), CellRef::new(0, 1)), two); + r.step(0.0); + let mid = r.step(GLIDE_SECS / 2.0); + assert_eq!(mid.min, one.min, "the anchored corner stays put"); + assert!(mid.max.x > one.max.x && mid.max.x < two.max.x && mid.max.y == two.max.y, "{mid:?}"); + } + + #[test] + fn a_new_move_takes_over_from_where_the_outline_is() { + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.select(cell(0, 1), at(64.0, 0.0)); + r.step(0.0); + let mid = r.step(GLIDE_SECS / 3.0); + assert!(mid.min.x > 0.0 && mid.min.x < 64.0); + // The next arrow key lands before the first glide is over. + r.select(cell(0, 2), at(128.0, 0.0)); + let next = r.step(0.0); + assert!(near(next, mid), "no jump when the destination changes: {next:?} vs {mid:?}"); + assert_eq!(r.settle(DT).last(), Some(&at(128.0, 0.0))); + } + + #[test] + fn the_same_cells_moving_on_screen_never_glide() { + let mut r = Rig::new(cell(4, 0), at(0.0, 64.0)); + // Scrolled, zoomed, a column resized: same range, another rectangle. + r.f.target = at(0.0, 16.0); + assert_eq!(r.step(DT), at(0.0, 16.0)); + r.f.target = Rect::from_min_size(pos2(8.0, 24.0), vec2(96.0, 24.0)); + assert_eq!(r.step(DT), r.f.target); + assert!(!r.glide.is_gliding()); + // Scrolling under a gliding outline carries it along with the sheet. + r.select(cell(5, 0), at(0.0, 200.0)); + r.step(0.0); + let before = r.step(GLIDE_SECS / 4.0); + r.f.target = at(0.0, 300.0); + let after = r.step(0.0); + assert!(near(after, before.translate(vec2(0.0, 100.0))), "{after:?} vs {before:?}"); + // An arrow key held at the window's edge: the sheet scrolls and the next cell is drawn + // where the last one was. Nothing to glide. + let mut r = Rig::new(cell(30, 0), at(0.0, 580.0)); + r.select(cell(31, 0), at(0.0, 580.0)); + assert_eq!(r.step(DT), at(0.0, 580.0)); + assert!(!r.glide.is_gliding()); + } + + #[test] + fn another_sheet_a_stale_or_off_screen_outline_appears_in_place() { + // Another sheet, another workbook. + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.f.place = (1, 1); + r.select(cell(9, 9), at(500.0, 300.0)); + assert_eq!(r.step(DT), at(500.0, 300.0)); + r.f.place = (2, 1); + r.select(cell(0, 0), at(0.0, 0.0)); + assert_eq!(r.step(DT), at(0.0, 0.0)); + assert!(!r.glide.is_gliding()); + // The outline wasn't drawn for a few frames: its old place means nothing now. + r.f.frame_nr += 5; + r.select(cell(0, 3), at(192.0, 0.0)); + assert_eq!(r.step(DT), at(192.0, 0.0)); + // It was scrolled out of sight, and the move brings the view back. + let mut r = Rig::new(cell(500, 0), at(0.0, -8000.0)); + r.select(cell(501, 0), at(0.0, 300.0)); + assert_eq!(r.step(DT), at(0.0, 300.0)); + // The move goes somewhere off screen. + r.select(cell(900, 0), at(0.0, 9000.0)); + assert_eq!(r.step(DT), at(0.0, 9000.0)); + assert!(!r.glide.is_gliding()); + } + + #[test] + fn far_edges_set_off_no_further_than_one_view_away() { + // A whole column: its bottom edge is millions of points below the window. + let column = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16_000_000.0)); + let mut r = Rig::new(RangeRef::cols(0, 0), column); + r.select(cell(3, 0), at(0.0, 48.0)); + let first = r.step(DT); + assert_eq!(first.min, column.min); + assert_eq!(first.max.y, 64.0 + VIEW.height(), "from the window's height below, not from row 1,048,576"); + assert_eq!(r.settle(DT).last(), Some(&at(0.0, 48.0))); + // And back: the far edge leaves the same way. + r.select(RangeRef::cols(0, 0), column); + assert_eq!(r.step(DT).max.y, 16_000_000.0 - VIEW.height()); + assert_eq!(r.settle(DT).last(), Some(&column)); + } + + #[test] + fn turned_off_or_hostile_numbers_never_glide_or_panic() { + // No animations this frame: on the cells, and a glide under way stops. + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.f.animate = false; + r.select(cell(0, 1), at(64.0, 0.0)); + assert_eq!(r.step(DT), at(64.0, 0.0)); + r.f.animate = true; + r.select(cell(0, 2), at(128.0, 0.0)); + r.step(0.0); + r.step(DT); + assert!(r.glide.is_gliding()); + r.f.animate = false; + assert_eq!(r.step(DT), at(128.0, 0.0)); + assert!(!r.glide.is_gliding()); + // Geometry and clocks that make no sense: whatever is drawn meanwhile, nothing panics, + // and a sane selection afterwards is drawn on its cells once its glide is over. + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0] { + for now in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0e300, 0.0] { + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.f.view = Rect::from_min_max(pos2(v, 0.0), pos2(1000.0, v)); + r.select(cell(0, 1), Rect::from_min_max(pos2(v, v), pos2(64.0, 16.0))); + r.step(DT); + r.f.now = now; + r.select(cell(0, 2), Rect::from_min_size(pos2(64.0, 0.0), egui::Vec2::splat(v))); + r.step(DT); + r.step(DT); + r.f.view = VIEW; + r.f.now = 50.0; + r.select(cell(0, 3), at(192.0, 0.0)); + r.step(DT); + assert_eq!(r.step(1.0), at(192.0, 0.0), "view {v}, clock {now}"); + } + } + assert_eq!((ease_out(-3.0), ease_out(0.0), ease_out(1.0), ease_out(7.0)), (0.0, 0.0, 1.0, 1.0)); + } + + #[test] + fn the_ants_march_by_the_clock() { + assert_eq!(ants_phase(0.0), 0.0); + assert!((ants_phase(0.06) - 0.5).abs() < 1e-4, "half a point every 60 ms"); + assert!((ants_phase(0.96 + 0.06) - 0.5).abs() < 1e-4, "wrapping with the dash pattern"); + for now in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -12.3, 1.0e300, 7.77] { + let p = ants_phase(now); + assert!((0.0..=8.0).contains(&p), "{now}: {p}"); + } + } +} diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index a652a4f1c..e55f575c6 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -164,8 +164,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { app.run_or_alert("sheet.unhide", json!({})); } ui.separator(); - if ui.button("Protect Sheet…").clicked() { - app.open_dialog("protectSheet", json!({})); + let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); + if ui.button(if protected { "Unprotect Sheet…" } else { "Protect Sheet…" }).clicked() { + app.open_dialog(if protected { "unprotectSheet" } else { "protectSheet" }, json!({})); } if ui.button("Select All Sheets").clicked() { ui.close(); @@ -229,6 +230,10 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { }; ui.label(egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim)); if let Some(d) = app.session.active() { + if d.wb.vba_project.is_some() { + ui.add_space(12.0); + ui.label(egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger)); + } if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual { ui.add_space(12.0); ui.label(egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim)); @@ -278,7 +283,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 6812c5105..92189290e 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, @@ -43,6 +45,11 @@ pub struct Tokens { pub menu_bg: Color32, pub shadow: Color32, pub danger: Color32, + /// Info-bar (notice strip) fills, by severity. + pub notice_warn_bg: Color32, + pub notice_warn_border: Color32, + pub notice_info_bg: Color32, + pub notice_info_border: Color32, } impl Tokens { @@ -62,6 +69,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), @@ -80,6 +88,10 @@ impl Tokens { menu_bg: Color32::WHITE, shadow: Color32::from_black_alpha(40), danger: Color32::from_rgb(0xC4, 0x2B, 0x1C), + notice_warn_bg: Color32::from_rgb(0xFF, 0xF4, 0xCE), + notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E), + notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB), + notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC), } } @@ -99,6 +111,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), @@ -117,6 +130,10 @@ impl Tokens { menu_bg: Color32::from_rgb(0x30, 0x30, 0x30), shadow: Color32::from_black_alpha(90), danger: Color32::from_rgb(0xF1, 0x70, 0x5F), + notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E), + notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30), + notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44), + notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72), } } @@ -142,7 +159,16 @@ pub fn ui_bold(size: f32) -> FontId { } /// The font family to use for a cell font name and weight. +/// +/// When the name is an installed font whose faces were bound by [`font_definitions`], that family +/// is used; otherwise the name is bucketed to one of the built-in roles (mono/serif/cell). egui +/// panics on a family that was never bound, so the installed branch is gated on +/// [`crate::fonts::is_bound`] and never names a family egui hasn't been given. pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily { + let role = crate::fonts::role_key(name, bold, italic); + if crate::fonts::is_bound(&role) { + return FontFamily::Name(role.into()); + } let lower = name.to_ascii_lowercase(); if lower.contains("courier") || lower.contains("mono") || lower.contains("consolas") { return FontFamily::Name(MONO.into()); @@ -240,6 +266,11 @@ fn candidates(role: &str) -> Vec<(String, u32)> { add(format!("{d}/dejavu/DejaVuSans.ttf"), 0); add(format!("{d}/liberation/LiberationSans-Regular.ttf"), 0); } + // Fedora keeps each family in its own package folder (#54). + add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0); + add("/usr/share/fonts/google-noto/NotoSans-Regular.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf".into(), 0); } "ui-bold" => { add(format!("{mac_sys}/HelveticaNeue.ttc"), 1); @@ -249,6 +280,9 @@ fn candidates(role: &str) -> Vec<(String, u32)> { add(format!("{d}/dejavu/DejaVuSans-Bold.ttf"), 0); add(format!("{d}/liberation/LiberationSans-Bold.ttf"), 0); } + add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf".into(), 0); } "cell" | "cell-bold" | "cell-italic" | "cell-bold-italic" => { let (carlito, calibri_win, calibri_mac, arial_mac, arial_win, lib, dejavu) = match role { @@ -345,9 +379,17 @@ impl HanOrder { /// Builds the font set with the Han fallback faces ordered for the interface language. pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDefinitions { + font_definitions_with(language, &[]) +} + +/// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are +/// installed, so cells set in them render with them; the rest fall back to the roles. The bound +/// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has. +pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); + let mut bound: Vec = Vec::new(); // The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin, // and epaint picks the first face that has the glyph, so they only catch their own scripts. let fallback = load_fallback(&mut fonts, HanOrder::of(language)); @@ -368,12 +410,20 @@ pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDef chain.extend(fallback.iter().cloned()); chain.retain(|k| fonts.font_data.contains_key(k)); fonts.families.insert(FontFamily::Name(role.into()), chain); + bound.push(role.to_string()); + } + // Installed workbook fonts, so a cell's chosen font actually renders in it. + for family in crate::fonts::installed_among(used) { + bound.extend(crate::fonts::bind_family(&mut fonts, &family)); } // Default proportional text in widgets uses the UI font. if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() { fonts.families.insert(FontFamily::Proportional, ui); } drop_unparseable_fonts(&mut fonts); + // Only families egui still has data for count as bound (a dropped face can empty a chain). + bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty())); + crate::fonts::set_bound(bound); fonts } @@ -422,7 +472,15 @@ fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { HanOrder::Japanese => (ja, zh), HanOrder::Chinese => (zh, ja), }; - vec![first, second, ("sys-cjk-hangul", cjk_hangul()), ("sys-thai", thai()), ("sys-hebrew", hebrew())] + vec![ + first, + second, + ("sys-cjk-hangul", cjk_hangul()), + ("sys-thai", thai()), + ("sys-hebrew", hebrew()), + ("sys-symbols", symbols()), + ("sys-symbols-2", symbols_more()), + ] } /// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, @@ -547,6 +605,59 @@ fn hebrew() -> Vec<(String, u32)> { v } +/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's +/// built-in Ubuntu) lack arrows, check marks, geometric shapes and keyboard symbols (▾ ✓ ✕ ▲ ↳ ⇧), +/// which then render as empty boxes (#197, #54). DejaVu Sans covers most of them; the paths cover +/// Debian/Ubuntu, Fedora and Arch layouts. [`load_fallback`] skips the file if Hebrew already +/// loaded it. +#[cfg(not(target_arch = "wasm32"))] +fn symbols() -> Vec<(String, u32)> { + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + let mut v: Vec<(String, u32)> = vec![(format!("{win}\\seguisym.ttf"), 0), ("/System/Library/Fonts/Apple Symbols.ttf".into(), 0)]; + for d in [ + "/usr/share/fonts/truetype/dejavu", + "/usr/share/fonts/dejavu-sans-fonts", + "/usr/share/fonts/TTF", + "/usr/share/fonts/dejavu", + "/usr/share/fonts/truetype/ttf-dejavu", + "/usr/local/share/fonts/dejavu", + "/usr/local/share/fonts", + ] { + v.push((format!("{d}/DejaVuSans.ttf"), 0)); + } + v +} + +/// A second symbol face for what the first lacks (⌘ and other technical symbols): Noto Sans +/// Symbols 2 where it is installed, or Segoe UI Symbol behind Segoe UI. +#[cfg(not(target_arch = "wasm32"))] +fn symbols_more() -> Vec<(String, u32)> { + let mut v: Vec<(String, u32)> = Vec::new(); + for d in [ + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/google-noto-sans-symbols-2-fonts", + "/usr/share/fonts/google-noto", + "/usr/local/share/fonts/noto", + ] { + v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); + } + v +} + +/// Whether any symbol fallback face is installed (tests skip their glyph checks without one). +pub fn symbol_face_installed() -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + symbols().iter().chain(symbols_more().iter()).any(|(p, _)| std::path::Path::new(p).exists()) + } + #[cfg(target_arch = "wasm32")] + { + false + } +} + pub fn apply(ctx: &egui::Context, dark: bool) { // Install both palettes before System can switch between the style slots. for theme in [egui::Theme::Light, egui::Theme::Dark] { @@ -589,6 +700,54 @@ pub fn color32(rgb: [u8; 3]) -> Color32 { Color32::from_rgb(rgb[0], rgb[1], rgb[2]) } +#[cfg(test)] +mod font_binding_tests { + use super::*; + + /// The families [`font_definitions`] always binds, whatever is installed. + fn always_bound() -> Vec { + [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO].iter().map(|s| s.to_string()).collect() + } + + #[test] + fn a_name_that_is_not_an_installed_font_gets_a_built_in_role() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Nothing installed: every name buckets to a role that font_definitions bound. + crate::fonts::set_bound(always_bound()); + for name in ["", "Calibri", "Courier New", "Times New Roman", "Grossly Missing Font", "\u{1F600}"] { + for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { + let FontFamily::Name(fam) = cell_family(name, b, i) else { panic!("{name:?} should name a family") }; + assert!(crate::fonts::is_bound(&fam), "{name:?} {b} {i} → unbound {fam:?}"); + } + } + } + + #[test] + fn a_bound_installed_font_is_used_by_name() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let role = crate::fonts::role_key("Ubuntu", true, false); + crate::fonts::set_bound(always_bound().into_iter().chain([role.clone()])); + assert!(matches!(cell_family("Ubuntu", true, false), FontFamily::Name(f) if *f == *role)); + // The same family in a style that wasn't bound falls back to a built-in role that is. + let FontFamily::Name(fallback) = cell_family("Ubuntu", false, true) else { panic!("should name a family") }; + assert!(crate::fonts::is_bound(&fallback), "the fallback is a bound role"); + crate::fonts::set_bound(always_bound()); + } + + #[test] + fn arbitrary_names_never_yield_an_unbound_family() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // A workbook can hold any font name; none may name a family egui has no data for. + crate::fonts::set_bound(always_bound()); + for seed in 0..500u32 { + let name: String = + (0..(seed % 40)).map(|k| char::from_u32(0x20 + (seed.wrapping_mul(31).wrapping_add(k) % 0x5F)).unwrap_or('x')).collect(); + let FontFamily::Name(fam) = cell_family(&name, seed % 2 == 0, seed % 3 == 0) else { continue }; + assert!(crate::fonts::is_bound(&fam), "{name:?} → unbound {fam:?}"); + } + } +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; @@ -618,8 +777,14 @@ mod tests { #[test] fn language_reorders_only_the_han_faces() { let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); - assert_eq!(keys(HanOrder::Japanese), ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); - assert_eq!(keys(HanOrder::Chinese), ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); + assert_eq!( + keys(HanOrder::Japanese), + ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); + assert_eq!( + keys(HanOrder::Chinese), + ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs] { assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 736989228..a006c6b48 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -236,3 +236,51 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) { ); ctx.request_repaint_after(std::time::Duration::from_millis(200)); } + +/// The info bar under the ribbon (Excel's message-bar slot). Shows an open-time notice with an +/// optional action button and a dismiss ✕. Returns nothing; dismissing clears `app.notice`. +pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) { + let Some(notice) = app.notice.clone() else { return }; + let t = Tokens::get(ui.ctx()); + let (bg, border) = match notice.kind { + crate::NoticeKind::Warning => (t.notice_warn_bg, t.notice_warn_border), + crate::NoticeKind::Info => (t.notice_info_bg, t.notice_info_border), + }; + let mut dismiss = false; + let mut action: Option<(String, String)> = None; + egui::Panel::top("notice_bar").frame(egui::Frame::NONE.fill(bg).inner_margin(egui::Margin::symmetric(12, 0))).show(ui, |ui| { + // Bottom border as a 1px strip spanning the bar. + let bar = ui.max_rect(); + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, border), StrokeKind::Inside); + ui.horizontal_centered(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + icons::paint( + ui.painter(), + r, + match notice.kind { + crate::NoticeKind::Warning => Icon::Lock, + crate::NoticeKind::Info => Icon::Note, + }, + border, + ); + ui.add_space(6.0); + ui.label(egui::RichText::new(¬ice.text).font(theme::ui_font(12.5)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icon_button(ui, Icon::Close, t.text_dim, "Dismiss", vec2(20.0, 20.0)).clicked() { + dismiss = true; + } + if let Some((label, dialog)) = ¬ice.action + && ui.button(egui::RichText::new(label).font(theme::ui_font(12.5))).clicked() + { + action = Some((label.clone(), dialog.clone())); + } + }); + }); + }); + if let Some((_, dialog)) = action { + app.open_dialog(&dialog, serde_json::json!({})); + } + if dismiss { + app.notice = None; + } +} diff --git a/crates/ui-egui/tests/borders.rs b/crates/ui-egui/tests/borders.rs index af8a1a9dc..f248fc842 100644 --- a/crates/ui-egui/tests/borders.rs +++ b/crates/ui-egui/tests/borders.rs @@ -127,3 +127,36 @@ fn border_preview_uses_the_colored_draft_until_ok_and_cancel_discards_it() { h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(borders(&h, "A1"), original); } + +/// #376: a merged range's outline lives on the cells along each edge (the right edge on the last +/// column's cells, the bottom on the last row's), not on the anchor alone. All four sides are drawn. +#[test] +fn merged_range_draws_the_outline_from_its_edge_cells() { + let mut h = harness(); + h.state_mut().run("home.borders", json!({"range": "B2:E5", "preset": "thickOutside", "color": "#C81E1E"})).unwrap(); + h.state_mut().run("home.mergeCells", json!({"range": "B2:E5"})).unwrap(); + h.state_mut().run("selection.set", json!({"range": "H10"})).unwrap(); + h.run_steps(6); + assert!(borders(&h, "B2").right.is_none(), "the anchor carries only the top and left edges"); + let red = Color32::from_rgb(0xC8, 0x1E, 0x1E); + let segs: Vec<[egui::Pos2; 2]> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::LineSegment { points, stroke } if stroke.color == red => Some(*points), + _ => None, + }) + .collect(); + let vertical = |s: &[egui::Pos2; 2]| (s[0].x - s[1].x).abs() < 0.5; + let xs: Vec = segs.iter().filter(|s| vertical(s)).map(|s| s[0].x).collect(); + let ys: Vec = segs.iter().filter(|s| !vertical(s)).map(|s| s[0].y).collect(); + let (min_x, max_x) = xs.iter().fold((f32::MAX, f32::MIN), |(a, b), x| (a.min(*x), b.max(*x))); + let (min_y, max_y) = ys.iter().fold((f32::MAX, f32::MIN), |(a, b), y| (a.min(*y), b.max(*y))); + // Left and right edges (four rows each), top and bottom edges (four columns each). + assert_eq!(xs.iter().filter(|x| (**x - min_x).abs() < 0.5).count(), 4, "left edge"); + assert_eq!(xs.iter().filter(|x| (**x - max_x).abs() < 0.5).count(), 4, "right edge"); + assert_eq!(ys.iter().filter(|y| (**y - min_y).abs() < 0.5).count(), 4, "top edge"); + assert_eq!(ys.iter().filter(|y| (**y - max_y).abs() < 0.5).count(), 4, "bottom edge"); + assert!(max_x - min_x > 100.0 && max_y - min_y > 40.0, "the outline spans the whole merged area"); +} diff --git a/crates/ui-egui/tests/header_column_drag.rs b/crates/ui-egui/tests/header_column_drag.rs new file mode 100644 index 000000000..d28f9ae33 --- /dev/null +++ b/crates/ui-egui/tests/header_column_drag.rs @@ -0,0 +1,65 @@ +//! Whole-column reordering driven through real egui pointer events. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::{Session, core::CellRef}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap(); + session.execute("selection.set", json!({"range": "B:C"})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + harness.run_steps(4); + harness.state_mut().session.active_mut().unwrap().undo.clear(); + harness.state_mut().session.journal.clear(); + harness +} + +fn header_center(harness: &egui_kittest::Harness<'_, SheetApp>, col: u32) -> Pos2 { + let app = harness.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + pos2(geo.x(sheet, col) + sheet.col_width(col) * geo.z * 0.5, geo.rect.top() + geo.header_h * 0.5) +} + +fn pointer_button(harness: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, pressed: bool) { + harness + .input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + harness.step(); +} + +#[test] +fn dragging_selected_column_headers_reorders_the_block() { + let mut harness = harness(); + let start = header_center(&harness, 1); + let target = { + let center = header_center(&harness, 4); + pos2(center.x - 20.0, center.y) + }; + + pointer_button(&mut harness, start, true); + harness.input_mut().events.push(Event::PointerMoved(target)); + harness.step(); + harness.input_mut().events.push(Event::PointerMoved(target + vec2(1.0, 0.0))); + harness.step(); + pointer_button(&mut harness, target + vec2(1.0, 0.0), false); + harness.run_steps(2); + + let app = harness.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let values: Vec<_> = (0..5).map(|col| sheet.value(CellRef::new(0, col))).collect(); + assert_eq!(values, vec!["A".into(), "D".into(), "B".into(), "C".into(), "E".into()]); + assert_eq!(app.session.active().unwrap().selection.current().a1(), "C:D"); + assert_eq!(app.session.journal.iter().filter(|(id, _)| id == "sheet.moveColumns").count(), 1); + assert_eq!(app.session.active().unwrap().undo.len(), 1); +} diff --git a/crates/ui-egui/tests/insert_copied_cells.rs b/crates/ui-egui/tests/insert_copied_cells.rs new file mode 100644 index 000000000..89621f21b --- /dev/null +++ b/crates/ui-egui/tests/insert_copied_cells.rs @@ -0,0 +1,60 @@ +//! The context menus offer Insert Copied Cells while the clipboard holds a copy (#21). + +use egui::{Event, Modifiers, PointerButton, pos2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4]]})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn right_click_row_header(h: &mut Harness, cell: &str) { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let geo = grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + let y = geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center().y; + let pos = pos2((geo.rect.left() + geo.cells.left()) / 2.0, y); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn row_header_menu_inserts_copied_rows() { + let mut h = harness(); + // Nothing copied: no entry. + right_click_row_header(&mut h, "A3"); + assert!(h.state().grid.header_menu.is_some()); + assert!(h.query_by_label("Insert Copied Cells").is_none()); + h.state_mut().grid.header_menu = None; + h.run_steps(2); + + h.state_mut().run("edit.copy", json!({"range": "1:1"})).unwrap(); + right_click_row_header(&mut h, "A3"); + h.get_by_label("Insert Copied Cells").click(); + h.run_steps(2); + assert_eq!(value(&h, "A3"), Value::Number(1.0)); + assert_eq!(value(&h, "A4"), Value::Number(3.0)); + assert_eq!(value(&h, "A5"), Value::Number(4.0)); +} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 260b80acb..709393389 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -384,6 +384,7 @@ fn dialogs_open_and_close() { "goTo", "pivotCalculatedField", "about", + "macros", "analysisToolPak", "solver", ] { @@ -484,6 +485,90 @@ fn column_autocomplete_completes_on_enter() { assert_eq!(value(&h, "A5"), Value::from("Ea")); } +/// An .xlsm carrying a VBA project, saved to bytes. +fn macro_workbook_bytes() -> Vec { + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec()); + gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap() +} + +#[test] +fn opening_a_macro_workbook_shows_a_notice() { + // The reader's "macros kept but not executed" warning must reach a dismissible info bar, + // not be silently dropped (issue #6 follow-up: Excel-style message bar). + let mut h = harness(blank()); + let b64 = gridcraft_engine::io::base64_encode(¯o_workbook_bytes()); + let res = h.state_mut().session.run("file.open", json!({"name": "macros.xlsm", "base64": b64})).unwrap(); + assert!( + res.get("warnings").and_then(|w| w.as_array()).is_some_and(|a| a.iter().any(|m| m.as_str().is_some_and(|s| s.contains("macros")))), + "engine reports the macro warning: {res}" + ); + // The UI-side handler turns that open result into the notice (open_path does this in the app). + h.state_mut().show_open_warnings(&res); + let n = h.state().notice.clone().expect("a notice was shown"); + assert!(n.text.contains("VBA"), "{}", n.text); + assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Warning); + assert_eq!(n.action.as_ref().map(|a| a.1.as_str()), Some("macros"), "action opens the Macros dialog"); + + // The notice bar renders without panic; `ui.notice` surfaces it; dismissing clears it. + h.run_steps(2); + let got = h.state_mut().run("ui.notice", json!({})).unwrap(); + assert_eq!(got.get("kind").and_then(|k| k.as_str()), Some("warning")); + h.state_mut().run("ui.notice.dismiss", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().notice.is_none()); +} + +#[test] +fn plain_open_warning_becomes_an_info_notice() { + let mut h = harness(blank()); + h.state_mut().show_open_warnings(&json!({"warnings": ["links to external workbooks are not supported"]})); + let n = h.state().notice.clone().unwrap(); + assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Info); + assert!(n.action.is_none()); + assert!(n.text.contains("external")); + // No warnings → no notice, no panic. + h.state_mut().show_open_warnings(&json!({"warnings": []})); + assert!(h.state().notice.is_none()); +} + +#[test] +fn macros_dialog_lists_vba_and_scripts() { + let mut s = blank(); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]); + s.active_mut().unwrap().wb = std::sync::Arc::new(wb); + let mut h = harness(s); + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(3); + let d = h.state().dialog.as_ref().expect("macros dialog open"); + assert_eq!(d.name(), "macros"); + let items = d.result.as_ref().and_then(|r| r.as_array()).cloned().unwrap_or_default(); + assert!(items.iter().any(|i| i.get("kind").and_then(|k| k.as_str()) == Some("vba")), "{items:?}"); + h.state_mut().dialog = None; +} + +#[test] +fn status_bar_shows_macro_badge() { + let mut s = blank(); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]); + s.active_mut().unwrap().wb = std::sync::Arc::new(wb); + // Rendering the status bar must not panic with a VBA project present. + let mut h = harness(s); + h.run_steps(3); + h.state_mut().ui.dark = true; + h.run_steps(2); +} + +#[test] +fn alt_f8_opens_the_macros_dialog() { + let mut h = harness(blank()); + key(&mut h, Key::F8, Modifiers::ALT); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("macros")); + h.state_mut().dialog = None; +} + #[test] fn general_numbers_fit_by_the_cells_own_font() { // 9pt marks in narrow columns (a stored width of 4, i.e. 28px; common in mark sheets): "100" fits. diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index a06f918a3..8a2b1957a 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -106,3 +106,39 @@ fn windows_and_mac_variants() { assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None); assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None); } + +#[test] +fn space_selection_shortcuts_do_not_start_an_edit() { + let mut h = harness(book("B2")); + // Shift+Space: the key press, then the space the OS types with it. + let shift = Modifiers::SHIFT; + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: Some(Key::Space), pressed: true, repeat: false, modifiers: shift }); + h.input_mut().events.push(Event::Text(" ".into())); + h.run_steps(2); + assert!(h.state().editor.is_none(), "Shift+Space started an edit"); + assert_eq!(h.state().session.active().unwrap().selection.ranges[0].a1(), "2:2"); + h.state_mut().session.run("selection.set", json!({"range": "B2"})).unwrap(); + // Ctrl+Space selects the column, also without starting an edit. + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: Some(Key::Space), pressed: true, repeat: false, modifiers: CTRL }); + h.input_mut().events.push(Event::Text(" ".into())); + h.run_steps(2); + assert!(h.state().editor.is_none()); + assert_eq!(h.state().session.active().unwrap().selection.ranges[0].a1(), "B:B"); + // Plain typing still edits. + h.input_mut().events.push(Event::Text("x".into())); + h.run_steps(2); + assert!(h.state().editor.is_some()); +} + +#[test] +fn space_shortcuts_per_platform() { + let run = |id: &'static str| Some(Shortcut::Run(id, json!({}))); + let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + let space = |m, mac| lookup(Key::Space, Some(Key::Space), m, mac); + assert_eq!(space(Modifiers::SHIFT, false), run("selection.row")); + assert_eq!(space(Modifiers::SHIFT, true), run("selection.row")); + assert_eq!(space(CTRL, false), run("selection.column")); + assert_eq!(space(Modifiers::CTRL, true), run("selection.column")); + // Cmd+Space is Spotlight on macOS. + assert_eq!(space(mac_cmd, true), None); +} diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 2fe718be1..272f863d6 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -318,3 +318,12 @@ fn ordinary_typing_editor_shortcuts_and_altgr_remain_text_input() { assert_eq!(h.state().keytips.prefix(), None, "macOS Option must remain available for text composition"); assert_eq!(h.state().editor.as_ref().unwrap().text, "é"); } + +#[test] +fn vertical_alignment_keytips_run_their_commands() { + for (letter, id) in [(Key::T, "home.alignTop"), (Key::M, "home.alignMiddle"), (Key::B, "home.alignBottom")] { + let mut h = harness(blank()); + sequence(&mut h, &[Key::H, Key::A, letter], false); + assert_eq!(h.state().session.journal.iter().filter(|(j, _)| j == id).count(), 1, "HA{} runs {id}", letter.name()); + } +} 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/ui-egui/tests/protect_password.rs b/crates/ui-egui/tests/protect_password.rs index dc2b1c59a..ee68cf2b6 100644 --- a/crates/ui-egui/tests/protect_password.rs +++ b/crates/ui-egui/tests/protect_password.rs @@ -57,7 +57,7 @@ fn protect_and_unprotect_sheet_mask_the_password() { 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())); + assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password.is_some())); // Unprotect asks for the password with a masked field too, and it starts hidden again. h.state_mut().open_dialog("unprotectSheet", json!({})); diff --git a/crates/ui-egui/tests/scroll_position.rs b/crates/ui-egui/tests/scroll_position.rs new file mode 100644 index 000000000..7945eb344 --- /dev/null +++ b/crates/ui-egui/tests/scroll_position.rs @@ -0,0 +1,60 @@ +//! The saved scroll position (`sheetView topLeftCell`) is restored on open and saved back (#378). + +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::Workbook; +use gridcraft_engine::{DocState, Session}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(freeze: Option<(u32, u32)>) -> Harness { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + for r in 0..60 { + sh.set_value(CellRef::new(r, 0), Value::text(format!("row {}", r + 1))); + } + sh.view_top_left = CellRef::parse("A31").unwrap(); + sh.view_active = CellRef::parse("A31").unwrap(); + sh.freeze = freeze; + let mut session = Session::new(); + session.add_document(DocState::new(wb, None, "Book1".into())); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +/// The first row painted at the top of the scrolling area. +fn first_scrolled_row(h: &Harness) -> 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 (fr, _) = sheet.freeze.unwrap_or((0, 0)); + let top = geo.cells.top() + sheet.row_top(fr) as f32 * geo.z; + (fr..200).find(|r| geo.y(sheet, *r) <= top + 0.5 && geo.y(sheet, *r + 1) > top + 0.5).unwrap() +} + +#[test] +fn saved_scroll_position_is_restored_and_saved_back() { + for freeze in [None, Some((1, 0))] { + let mut h = harness(freeze); + assert_eq!(first_scrolled_row(&h), 30, "row 31 is the first visible row ({freeze:?})"); + // Scroll to row 51 and save: the file gets the new position, the document stays clean. + { + let app = h.state_mut(); + let sh = app.session.active().unwrap().wb.active().unwrap().clone(); + let fr = freeze.map_or(0, |f| f.0); + app.view_mut().unwrap().scroll.y = (sh.row_top(50) - sh.row_top(fr)) as f32; + app.run("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + } + let d = h.state().session.active().unwrap(); + assert_eq!(d.wb.active().unwrap().view_top_left, CellRef::parse("A51").unwrap()); + assert!(!d.is_dirty(), "scrolling and saving view state does not dirty the workbook"); + } +} diff --git a/crates/ui-egui/tests/selection_fill.rs b/crates/ui-egui/tests/selection_fill.rs new file mode 100644 index 000000000..446ccdd51 --- /dev/null +++ b/crates/ui-egui/tests/selection_fill.rs @@ -0,0 +1,99 @@ +//! The selection's fill leaves the active cell clear, so it is plain where typing goes. + +use egui::Rect; +use gridcraft_engine::Session; +use gridcraft_engine::core::RangeRef; +use gridcraft_ui_egui::grid::{Geo, selection_fill}; +use serde_json::json; + +fn session() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session +} + +fn geo(session: &Session) -> Geo { + let sheet = session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1200.0, 800.0)), egui::Vec2::ZERO) +} + +/// Where a cell or range is on screen. +fn cells(session: &Session, a1: &str) -> Rect { + let sheet = session.active().unwrap().wb.active().unwrap(); + geo(session).range_rect(sheet, RangeRef::parse(a1).unwrap()) +} + +/// The fill at rest: the current area's outline is on its cells. +fn fill(session: &Session) -> Vec { + let doc = session.active().unwrap(); + let sheet = doc.wb.active().unwrap(); + let geo = geo(session); + selection_fill(&geo, sheet, &doc.selection, geo.range_rect(sheet, doc.selection.current())) +} + +fn shaded(fill: &[Rect], cell: Rect) -> bool { + fill.iter().any(|part| part.intersects(cell.shrink(1.0))) +} + +fn area(fill: &[Rect]) -> f32 { + fill.iter().map(Rect::area).sum() +} + +#[test] +fn the_active_cell_of_a_range_is_not_shaded() { + let mut s = session(); + // At the corner where a drag began, then wherever Tab and Enter move it inside the range. + for active in ["B2", "C4", "D5"] { + s.execute("selection.set", json!({"range": "B2:D5", "active": active})).unwrap(); + let fill = fill(&s); + assert!(!shaded(&fill, cells(&s, active)), "{active} stays clear: {fill:?}"); + for other in ["B2", "B5", "C3", "C4", "D2", "D5"].into_iter().filter(|c| *c != active) { + assert!(shaded(&fill, cells(&s, other)), "{other} is shaded with {active} active"); + } + assert!((area(&fill) - (cells(&s, "B2:D5").area() - cells(&s, active).area())).abs() < 0.5, "the rest of the range, once"); + } +} + +#[test] +fn a_single_selected_cell_has_no_fill() { + let mut s = session(); + s.execute("selection.set", json!({"cell": "C3"})).unwrap(); + assert!(fill(&s).is_empty()); +} + +#[test] +fn other_areas_are_shaded_whole_and_a_lone_active_cell_not_at_all() { + let mut s = session(); + s.execute("selection.set", json!({"range": "A1:A3,C2:D3,F6", "active": "F6"})).unwrap(); + assert_eq!(s.active().unwrap().selection.ranges.len(), 3); + let fill = fill(&s); + assert!(!shaded(&fill, cells(&s, "F6"))); + assert!(shaded(&fill, cells(&s, "A2")) && shaded(&fill, cells(&s, "D3"))); + assert!((area(&fill) - (cells(&s, "A1:A3").area() + cells(&s, "C2:D3").area())).abs() < 0.5, "{fill:?}"); +} + +#[test] +fn a_merged_active_cell_is_left_clear_as_a_whole() { + let mut s = session(); + s.execute("selection.set", json!({"range": "B2:C3"})).unwrap(); + s.execute("home.mergeCells", json!({})).unwrap(); + s.execute("selection.set", json!({"range": "B2:E6", "active": "B2"})).unwrap(); + let fill = fill(&s); + assert!(!shaded(&fill, cells(&s, "B2:C3")), "the merged cell, not just its first cell: {fill:?}"); + assert!(shaded(&fill, cells(&s, "D2")) && shaded(&fill, cells(&s, "B4")) && shaded(&fill, cells(&s, "E6"))); + assert!((area(&fill) - (cells(&s, "B2:E6").area() - cells(&s, "B2:C3").area())).abs() < 0.5); +} + +#[test] +fn the_current_area_is_shaded_where_its_gliding_outline_is() { + let mut s = session(); + s.execute("selection.set", json!({"range": "A1:A3,C2:D3", "active": "C2"})).unwrap(); + let doc = s.active().unwrap(); + let sheet = doc.wb.active().unwrap(); + // Mid-glide the outline of the current area (C2:D3) is somewhere else on screen. + let outline = Rect::from_min_max(egui::pos2(600.0, 400.0), egui::pos2(700.0, 500.0)); + let fill = selection_fill(&geo(&s), sheet, &doc.selection, outline); + assert!(fill.contains(&outline), "the current area follows its outline: {fill:?}"); + assert!(shaded(&fill, cells(&s, "A2")), "the other areas stay on their cells"); + assert!(!shaded(&fill, cells(&s, "D3")), "nothing is left behind on the current area's cells"); +} diff --git a/crates/ui-egui/tests/selection_glide.rs b/crates/ui-egui/tests/selection_glide.rs new file mode 100644 index 000000000..fd8bf4e73 --- /dev/null +++ b/crates/ui-egui/tests/selection_glide.rs @@ -0,0 +1,203 @@ +//! The selection outline glides to a new selection, and View ▸ Animations turns that off. + +use egui::Rect; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::control::{self, Outcome}; +use gridcraft_ui_egui::{ControlRequest, SheetApp, UiState, grid::Geo}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// 240 frames a second, so a glide spans many frames. +const DT: f32 = 1.0 / 240.0; + +fn app() -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + SheetApp::new(session, Default::default()) +} + +fn harness() -> Harness { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(DT).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app(), + ); + h.run_steps(4); + h +} + +/// The harness turns egui's animations off (and the glide follows that switch); this turns them on. +fn animated() -> Harness { + let mut h = harness(); + h.ctx.all_styles_mut(|s| s.animation_time = 0.1); + h.step(); + h +} + +/// Where a cell is on screen right now. +fn cell_rect(h: &Harness, a1: &str) -> Rect { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(a1).unwrap()) +} + +/// Where the outline was drawn in the last frame. +fn outline(h: &Harness) -> Rect { + h.state().grid.glide.drawn() +} + +fn gliding(h: &Harness) -> bool { + h.state().grid.glide.is_gliding() +} + +fn select(h: &mut Harness, a1: &str) { + h.state_mut().run("selection.set", json!({"cell": a1})).unwrap(); +} + +fn press(h: &mut Harness, key: egui::Key) { + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }); + } +} + +fn steps(h: &mut Harness, n: usize) { + for _ in 0..n { + h.step(); + } +} + +fn reply(outcome: Outcome) -> Value { + match outcome { + Outcome::Done(v) => v, + _ => Value::Null, + } +} + +#[test] +fn an_arrow_key_glides_the_outline_to_the_next_cell() { + let mut h = animated(); + let (a1, b1) = (cell_rect(&h, "A1"), cell_rect(&h, "B1")); + assert_eq!(outline(&h), a1, "at rest the outline is on its cells"); + press(&mut h, egui::Key::ArrowRight); + h.step(); + // The selection itself moves at once; only its outline takes its time. + assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()).as_deref(), Some("B1")); + steps(&mut h, 2); + let x = outline(&h).min.x; + assert!(gliding(&h) && x > a1.min.x && x < b1.min.x, "on its way, drawn in between: {x}"); + steps(&mut h, 40); + assert!(!gliding(&h), "and it comes to rest"); + assert_eq!(outline(&h), b1, "exactly on the cell"); + // Zooming moves the cell on screen but doesn't glide the outline. + h.state_mut().run("view.zoom", json!({"percent": 150})).unwrap(); + for _ in 0..3 { + h.step(); + assert!(!gliding(&h)); + } + assert_ne!(outline(&h), b1); + assert_eq!(outline(&h), cell_rect(&h, "B1")); +} + +#[test] +fn with_eguis_animations_off_the_outline_is_always_on_its_cells() { + // The headless harness (so the offscreen snapshot too) never shows an outline in flight. + let mut h = harness(); + select(&mut h, "D6"); + let d6 = cell_rect(&h, "D6"); + for _ in 0..4 { + h.step(); + assert_eq!(outline(&h), d6); + assert!(!gliding(&h)); + } +} + +#[test] +fn the_view_tab_checkbox_turns_the_glide_off_and_on() { + let mut h = animated(); + h.state_mut().ui.ribbon_tab = "View".into(); + h.run_steps(2); + h.get_by_label("Animations").click(); + h.run_steps(2); + assert!(!h.state().ui.animations, "the checkbox turns the setting off"); + select(&mut h, "E9"); + let e9 = cell_rect(&h, "E9"); + for _ in 0..5 { + h.step(); + assert_eq!(outline(&h), e9, "no animations: the outline is on its cells at once"); + assert!(!gliding(&h)); + } + h.get_by_label("Animations").click(); + h.run_steps(2); + assert!(h.state().ui.animations, "and on again"); + select(&mut h, "B1"); + steps(&mut h, 2); + let (x, b1) = (outline(&h).min.x, cell_rect(&h, "B1")); + assert!(gliding(&h) && x > b1.min.x && x < e9.min.x, "turned back on, it glides: {x}"); + steps(&mut h, 40); + assert_eq!(outline(&h), b1); +} + +#[test] +fn a_screenshot_never_catches_the_outline_in_flight() { + let mut h = animated(); + let (a1, b1) = (cell_rect(&h, "A1"), cell_rect(&h, "B1")); + let (tx, rx) = std::sync::mpsc::channel(); + h.state_mut().control_rx = Some(rx); + let (shot, _reply) = ControlRequest::new("ui.screenshot", json!({})); + tx.send(shot).unwrap(); + press(&mut h, egui::Key::ArrowRight); + for _ in 0..20 { + h.step(); + let drawn = outline(&h); + assert!(drawn == a1 || drawn == b1, "{drawn:?}"); + } + assert_eq!(outline(&h), b1); +} + +#[test] +fn animations_are_on_until_turned_off_and_the_choice_is_saved() { + assert!(UiState::default().animations); + // A `ui.json` written before the setting existed leaves them on. + let old: UiState = serde_json::from_str(r#"{"dark": true, "ribbonTab": "View"}"#).unwrap(); + assert!(old.animations && old.dark); + let off: UiState = serde_json::from_value(json!({"animations": false})).unwrap(); + let saved = serde_json::to_value(&off).unwrap(); + assert_eq!(saved["animations"], json!(false)); + assert!(!serde_json::from_value::(saved).unwrap().animations); +} + +#[test] +fn view_animations_sets_and_toggles_without_a_dialog() { + let mut a = app(); + assert_eq!(a.run("view.animations", json!({"on": false})), Ok(json!({"on": false}))); + assert_eq!(a.run("view.animations", json!({"on": false})), Ok(json!({"on": false})), "setting it twice changes nothing"); + // No parameters: it toggles, and never opens a dialog. + assert_eq!(a.run("view.animations", json!({})), Ok(json!({"on": true}))); + assert_eq!(a.run("view.animations", json!({})), Ok(json!({"on": false}))); + assert!(a.dialog.is_none() && a.message.is_none()); + // Parameters that make no sense toggle too, rather than failing or panicking. + assert_eq!(a.run("view.animations", json!({"on": "yes"})), Ok(json!({"on": true}))); + assert_eq!(a.run("view.animations", Value::Null), Ok(json!({"on": false}))); +} + +#[test] +fn agents_find_run_and_read_the_setting_over_the_control_channel() { + let mut a = app(); + let ctx = egui::Context::default(); + let found = reply(control::handle(&mut a, &ctx, "engine.commands", &json!({"search": "animations"}))); + let entry = found["result"].as_array().and_then(|all| all.iter().find(|c| c["id"] == "view.animations")).cloned().unwrap(); + assert_eq!((&entry["label"], &entry["ui"]), (&json!("Animations"), &json!(true))); + assert!(entry["params"].as_str().is_some_and(|doc| doc.contains("on")), "{entry}"); + let ran = reply(control::handle(&mut a, &ctx, "engine.execute", &json!({"command": "view.animations", "params": {"on": false}}))); + assert_eq!(ran, json!({"ok": true, "result": {"on": false}})); + assert_eq!(reply(control::handle(&mut a, &ctx, "ui.inspect", &json!({})))["result"]["ui"]["animations"], json!(false)); + let set = reply(control::handle(&mut a, &ctx, "ui.set", &json!({"animations": true}))); + assert_eq!(set["result"]["animations"], json!(true)); + assert!(a.ui.animations); +} diff --git a/crates/ui-egui/tests/shrink_to_fit.rs b/crates/ui-egui/tests/shrink_to_fit.rs new file mode 100644 index 000000000..a9428fa23 --- /dev/null +++ b/crates/ui-egui/tests/shrink_to_fit.rs @@ -0,0 +1,61 @@ +//! Shrink to fit (#375): too-wide content is drawn in a smaller font inside its cell. + +use egui::Shape; +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 harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + let shrink = json!({"align": {"shrink": true}}); + s.execute("cell.set", json!({"cell": "A1", "input": "Executive Producer"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1", "style": shrink})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "Control Producer"})).unwrap(); + s.execute("cell.set", json!({"cell": "A4", "input": "123456789012"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A4", "style": {"num_fmt": "#,##0", "align": {"shrink": true}}})).unwrap(); + s.execute("cell.set", json!({"cell": "A5", "input": "123456789012"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A5", "style": {"num_fmt": "#,##0"}})).unwrap(); + s.execute("selection.set", json!({"range": "H20"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +/// The painted text shape whose text is `text`: (font size, width, left x). +fn text_shape(h: &Harness, text: &str) -> Option<(f32, f32, f32)> { + h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == text => { + let size = t.galley.job.sections.first().map(|s| s.format.font_id.size)?; + Some((size, t.galley.size().x, t.pos.x)) + } + _ => None, + }) +} + +#[test] +fn shrink_to_fit_text_and_numbers_fit_their_cell() { + let h = harness(); + 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 a1 = geo.cell_rect(sheet, CellRef::parse("A1").unwrap()); + let (small, w, x) = text_shape(&h, "Executive Producer").expect("A1 is drawn"); + let (normal, cw, _) = text_shape(&h, "Control Producer").expect("A2 is drawn"); + assert!(small < normal, "A1 is drawn smaller ({small} vs {normal})"); + assert!(cw > a1.width(), "the control is wider than the column"); + assert!(x >= a1.left() && x + w <= a1.right(), "A1 fits inside column A"); + // A number shrinks instead of turning into ####; its unshrunk twin still shows ####. + let (num, nw, _) = text_shape(&h, "123,456,789,012").expect("A4 shows the number"); + assert!(num < normal && nw <= a1.width()); + assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.job.text.starts_with("##")))); +} diff --git a/crates/ui-egui/tests/symbol_glyphs.rs b/crates/ui-egui/tests/symbol_glyphs.rs new file mode 100644 index 000000000..7f2a55f74 --- /dev/null +++ b/crates/ui-egui/tests/symbol_glyphs.rs @@ -0,0 +1,30 @@ +//! The interface's symbol characters must not render as empty boxes (#197, #54): the UI font often +//! lacks arrows, check marks and geometric shapes, so a symbol face backs it up. + +use egui::{Color32, FontFamily, FontId}; +use gridcraft_ui_egui::theme; + +/// Symbols the interface draws as text (buttons, menus, dialogs, tooltips, Insert › Symbol). +const UI_SYMBOLS: &str = "▲▼▸▾✓✕←↑→↓↳↘↗⇧⌃⌘★•›≠≤≥∞"; + +#[test] +fn ui_symbols_have_glyphs_when_a_symbol_face_is_installed() { + if !theme::symbol_face_installed() { + return; // a minimal machine without any symbol face; nothing to assert + } + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test + for family in [FontFamily::Proportional, FontFamily::Name(theme::CELL.into())] { + let font = FontId::new(14.0, family); + let glyphs = |text: &str| { + 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::>() + }; + // 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); + } +} diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/system_theme.rs index e6b054216..aa8469354 100644 --- a/crates/ui-egui/tests/system_theme.rs +++ b/crates/ui-egui/tests/system_theme.rs @@ -99,7 +99,7 @@ fn system_tracks_live_os_changes_with_both_gridcraft_palettes_and_light_fallback system_theme(&mut h, reported); assert_appearance(&h, dark); assert_eq!(preferences(&h)["systemTheme"], true); - assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + assert_eq!(h.ctx.theme(), if dark { Theme::Dark } else { Theme::Light }); } } @@ -112,7 +112,7 @@ fn ribbon_selects_system_and_fixed_modes_ignore_subsequent_os_changes() { h.run_steps(2); select_ribbon_theme(&mut h, "System"); assert_eq!(preferences(&h)["systemTheme"], true); - assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + assert_eq!(h.ctx.theme(), Theme::Dark); assert_appearance(&h, true); for (label, dark, preference) in [("Dark", true, ThemePreference::Dark), ("Light", false, ThemePreference::Light)] { select_ribbon_theme(&mut h, label); 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/fmla.rs b/crates/xlsx/src/fmla.rs index 9ac4ccf09..c48341462 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -132,6 +132,8 @@ mod tests { assert_eq!(to_file(&Formula::new("FILTER(A1:A3,B1:B3)")), "_xlfn._xlws.FILTER(A1:A3,B1:B3)"); assert_eq!(from_file("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); assert_eq!(text_to_file("=IFS(A1>0,1)"), "_xlfn.IFS(A1>0,1)"); + assert_eq!(to_file(&Formula::new("REGEXTEST(A1,\"x\")&ENCODEURL(B1)")), "_xlfn.REGEXTEST(A1,\"x\")&_xlfn.ENCODEURL(B1)"); + assert_eq!(to_file(&Formula::new("GROUPBY(A1:A3,B1:B3,\"SUM\")")), "_xlfn.GROUPBY(A1:A3,B1:B3,\"SUM\")"); } #[test] diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 75188fea1..23e3b7b8b 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. @@ -20,6 +21,7 @@ mod feature_bags; mod fmla; mod ods; mod package; +mod passthrough; mod pivot; mod read; mod richdata; @@ -29,6 +31,7 @@ mod styles; mod tables; mod theme; mod write; +mod xls; mod xlsb; mod xml; @@ -40,6 +43,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. @@ -106,7 +110,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, } @@ -123,9 +129,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/passthrough.rs b/crates/xlsx/src/passthrough.rs new file mode 100644 index 000000000..f121977e2 --- /dev/null +++ b/crates/xlsx/src/passthrough.rs @@ -0,0 +1,924 @@ +//! 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::IoError; +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; + +#[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"; + +/// 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: 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")?; + // 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)?; + 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 f76f35d33..77996dc58 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) => { @@ -184,6 +185,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } if let Some(p) = wb_xml.child("workbookProtection") { wb.protected_structure = p.flag("lockStructure", false); + wb.structure_password = if wb.protected_structure { crate::sheet_read::read_password(p, "workbook") } else { None }; } let active_tab = wb_xml.path(&["bookViews", "workbookView"]).and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize; @@ -218,6 +220,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, @@ -311,12 +314,20 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { wb.props.company = x.child("Company").map(|e| e.text.trim().to_string()).unwrap_or_default(); } - 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"); + // A VBA project is carried through unchanged: GridCraft never runs macros, but preserving the + // binary keeps macro-enabled workbooks from being silently stripped on save. + if let Some(r) = find_rel(&wb_rels, "vbaProject").cloned() { + match cx.pkg.read(&r.target) { + Ok(Some(data)) => { + wb.vba_project = Some(data); + cx.warn("macros (VBA project) were kept but not executed"); + } + Ok(None) => cx.warn("macros (VBA project) reference a missing part and were dropped"), + Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)), + Err(e) => cx.warn(format!("macros (VBA project) could not be read and were dropped: {e}")), + } } + 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 b2868b47e..093cc02cf 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -143,11 +143,9 @@ 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, + password: read_password(e, ""), + legacy_password_hash: None, select_locked: !e.flag("selectLockedCells", false), select_unlocked: !e.flag("selectUnlockedCells", false), format_cells: !e.flag("formatCells", true), @@ -182,7 +180,6 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_ext(cx, e, &mut sheet), "protectedRanges" => read_protected_ranges(e, &mut sheet), - "oleObjects" | "controls" => cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not supported")), _ => {} } } @@ -209,6 +206,35 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result Option { + let attr = |base: &str| e.attr(&password_attr(prefix, base)); + if attr("algorithmName").is_some() || attr("hashValue").is_some() { + return Some(PasswordHash::Iterated { + algorithm: attr("algorithmName").unwrap_or("").to_string(), + hash: attr("hashValue").unwrap_or("").to_string(), + salt: attr("saltValue").unwrap_or("").to_string(), + spin_count: e.attr_u32(&password_attr(prefix, "spinCount")).unwrap_or(0), + }); + } + attr("password").map(|h| match u16::from_str_radix(h.trim(), 16) { + Ok(h) => PasswordHash::Legacy(h), + Err(_) => PasswordHash::Iterated { algorithm: String::new(), hash: String::new(), salt: String::new(), spin_count: 0 }, + }) +} + +/// `base` preceded by `prefix` in camel case: ("workbook", "hashValue") → `workbookHashValue`. +pub(crate) fn password_attr(prefix: &str, base: &str) -> String { + let mut chars = base.chars(); + match chars.next() { + Some(c) if !prefix.is_empty() => format!("{prefix}{}{}", c.to_ascii_uppercase(), chars.as_str()), + _ => base.to_string(), + } +} + /// Allow Edit Ranges. A range whose password can't be read keeps a hash that never matches /// (or an unsupported algorithm), so it stays locked rather than open. fn read_protected_ranges(e: &El, sheet: &mut Sheet) { @@ -217,20 +243,7 @@ fn read_protected_ranges(e: &El, sheet: &mut Sheet) { if ranges.is_empty() { continue; } - let password = if p.attr("algorithmName").is_some() || p.attr("hashValue").is_some() { - Some(PasswordHash::Iterated { - algorithm: p.attr("algorithmName").unwrap_or("").to_string(), - hash: p.attr("hashValue").unwrap_or("").to_string(), - salt: p.attr("saltValue").unwrap_or("").to_string(), - spin_count: p.attr_u32("spinCount").unwrap_or(0), - }) - } else { - p.attr("password").map(|h| match u16::from_str_radix(h.trim(), 16) { - Ok(h) => PasswordHash::Legacy(h), - // Unreadable: keep it locked. - Err(_) => PasswordHash::Iterated { algorithm: String::new(), hash: String::new(), salt: String::new(), spin_count: 0 }, - }) - }; + let password = read_password(p, ""); let title = p.attr("name").unwrap_or("").to_string(); sheet.edit_ranges.push(EditRange { title, ranges, password, security_descriptor: p.attr("securityDescriptor").map(str::to_string) }); } @@ -243,6 +256,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; } @@ -256,6 +270,10 @@ fn read_views(e: &El, sheet: &mut Sheet) { let rows = p.attr_f64("ySplit").unwrap_or(0.0).clamp(0.0, (MAX_ROWS - 1) as f64) as u32; if rows > 0 || cols > 0 { sheet.freeze = Some((rows, cols)); + // With frozen panes the scroll position is the scrolling pane's first cell. + if let Some(c) = p.attr("topLeftCell").and_then(CellRef::parse) { + sheet.view_top_left = c; + } } } active_pane = p.attr("activePane"); diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 276b13c5f..28e3ccf27 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -154,6 +154,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); } @@ -179,7 +182,11 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let _ = write!( s, " topLeftCell=\"{}\" activePane=\"{pane}\" state=\"frozen\"/>", - CellRef::new(rows.min(gridcraft_core::MAX_ROWS - 1), cols.min(gridcraft_core::MAX_COLS - 1)).a1() + CellRef::new( + rows.max(sheet.view_top_left.row).min(gridcraft_core::MAX_ROWS - 1), + cols.max(sheet.view_top_left.col).min(gridcraft_core::MAX_COLS - 1) + ) + .a1() ); let _ = write!(s, ""); } @@ -329,6 +336,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if !p.select_unlocked { s.push_str(" selectUnlockedCells=\"1\""); } + if let Some(h) = &p.password { + s.push_str(&password_attrs(h, "")); + } s.push_str("/>"); } s.push_str(&protected_ranges_xml(sheet)); @@ -417,6 +427,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)); @@ -432,7 +444,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() { @@ -516,11 +532,16 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml")); } 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(); @@ -1104,26 +1125,33 @@ fn threaded_xml(sheet: &Sheet, ids: &BTreeMap>, persons: &m s } +/// A protection password hash as attributes, each name preceded by `prefix` (see +/// [`crate::sheet_read::read_password`]). +pub(crate) fn password_attrs(h: &gridcraft_model::PasswordHash, prefix: &str) -> String { + let name = |base: &str| crate::sheet_read::password_attr(prefix, base); + match h { + gridcraft_model::PasswordHash::Legacy(h) => format!(" {}=\"{h:04X}\"", name("password")), + gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count } => format!( + " {}=\"{}\" {}=\"{}\" {}=\"{}\" {}=\"{spin_count}\"", + name("algorithmName"), + esc_attr(algorithm), + name("hashValue"), + esc_attr(hash), + name("saltValue"), + esc_attr(salt), + name("spinCount") + ), + } +} + /// Allow Edit Ranges: `` (after `sheetProtection`). fn protected_ranges_xml(sheet: &Sheet) -> String { let mut s = String::new(); for r in sheet.edit_ranges.iter().filter(|r| !r.ranges.is_empty()) { let sqref: Vec = r.ranges.iter().map(|x| x.a1()).collect(); let _ = write!(s, " { - let _ = write!(s, " password=\"{h:04X}\""); - } - Some(gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count }) => { - let _ = write!( - s, - " algorithmName=\"{}\" hashValue=\"{}\" saltValue=\"{}\" spinCount=\"{spin_count}\"", - esc_attr(algorithm), - esc_attr(hash), - esc_attr(salt) - ); - } - None => {} + if let Some(h) = &r.password { + s.push_str(&password_attrs(h, "")); } if let Some(sd) = &r.security_descriptor { let _ = write!(s, " securityDescriptor=\"{}\"", esc_attr(sd)); diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index 2d63dec74..fe7cbdc42 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -133,6 +133,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "DAYS", "DECIMAL", "DROP", + "ENCODEURL", "ERF.PRECISE", "ERFC.PRECISE", "EXPAND", @@ -157,6 +158,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "GAMMA.INV", "GAMMALN.PRECISE", "GAUSS", + "GROUPBY", "HSTACK", "HYPGEOM.DIST", "IFNA", @@ -199,6 +201,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "PERCENTRANK.INC", "PERMUTATIONA", "PHI", + "PIVOTBY", "POISSON.DIST", "QUARTILE.EXC", "QUARTILE.INC", @@ -206,6 +209,9 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "RANK.AVG", "RANK.EQ", "REDUCE", + "REGEXEXTRACT", + "REGEXREPLACE", + "REGEXTEST", "RRI", "SCAN", "SEC", diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 5533e0105..9cd09a33c 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -48,7 +48,7 @@ fn shared_formulas_and_value_types() { #[test] fn workbook_settings_names_and_states() { - let wb_extra = r#""#; + let wb_extra = r#""#; // Defined names must come after ; build the workbook by hand. let wbx = format!( r#"{wb_extra} @@ -69,6 +69,9 @@ fn workbook_settings_names_and_states() { assert_eq!(wb.sheets.len(), 3); assert_eq!(wb.date_system, DateSystem::D1904); assert!(wb.protected_structure); + // The legacy structure password ("password" hashes to 83AF) is kept and really verifies. + let pw = wb.structure_password.as_ref().unwrap(); + assert!(pw.verify("password").unwrap() && !pw.verify("Password").unwrap()); assert_eq!(wb.calc.mode, CalcMode::Manual); assert!(wb.calc.iterative); assert_eq!(wb.calc.max_iterations, 50); @@ -220,7 +223,7 @@ fn hyperlinks_comments_tables_validation_filters() { &CTitlePage &P - + Other!$A$1:$A$3G1:G4 Data!A1:E1F1"#; @@ -267,7 +270,10 @@ Check this"#; assert_eq!(s.print.row_breaks, vec![10]); let p = s.protection.as_ref().unwrap(); assert!(p.format_cells && p.sort && !p.insert_rows && p.select_locked); - assert!(rep.warnings.iter().any(|w| w.contains("password"))); + // The legacy sheet password is imported (CBEB is the hash of "test") and verifies. + let pw = p.password.as_ref().unwrap(); + assert!(pw.verify("test").unwrap() && !pw.verify("Test").unwrap()); + assert!(!rep.warnings.iter().any(|w| w.contains("password")), "{:?}", rep.warnings); assert_eq!(s.sparklines.len(), 1); assert_eq!(s.sparklines[0].kind, gridcraft_model::SparklineKind::Column); } diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 43639fee4..a9ce0bf0f 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -5,10 +5,12 @@ mod csv_tests; mod fixtures; mod malformed; mod ods_tests; +mod passthrough_tests; 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/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs new file mode 100644 index 000000000..4cde9d69e --- /dev/null +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -0,0 +1,485 @@ +//! 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 { + rich_package_with(CONTENT_TYPES, false) +} + +/// [`rich_package`] with other content types; `twin` makes sheet "Other" a copy of "Data" that +/// shares its VML drawing and embedded parts. +fn rich_package_with(content_types: &str, twin: bool) -> Vec { + let workbook = format!( + r#""# + ); + let sheet1 = format!( + r#"[1]Sheet1!$A$17SUM([1]Sheet1!A1:A3)*21410103050"# + ); + 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"), + ("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#""#; + 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()), + ("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()), + ]; + 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. +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); +} + +#[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/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 8937e83af..5f280d55d 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -22,6 +22,7 @@ fn sample() -> Workbook { wb.date_system = DateSystem::D1904; wb.calc = CalcSettings { mode: CalcMode::Manual, iterative: true, max_iterations: 42, max_change: 0.5, precision_as_displayed: true }; wb.protected_structure = true; + wb.structure_password = Some(gridcraft_model::PasswordHash::new_sha512("s3cret", b"0123456789abcdef")); wb.props = DocProps { title: "T & ".into(), subject: "S".into(), @@ -118,6 +119,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()); @@ -246,7 +248,14 @@ fn sample() -> Workbook { (3, FilterCriterion::FillColor(Color::Rgb(0xFF0000))), ], }); - s.protection = Some(SheetProtection { format_cells: true, sort: true, select_locked: true, select_unlocked: true, ..Default::default() }); + s.protection = Some(SheetProtection { + format_cells: true, + sort: true, + select_locked: true, + select_unlocked: true, + password: Some(gridcraft_model::PasswordHash::new_sha512("sheet pw", b"fedcba9876543210")), + ..Default::default() + }); s.print = PrintSettings { orientation: Orientation::Landscape, paper: "A4".into(), @@ -411,6 +420,8 @@ fn full_roundtrip() { assert_eq!(back.date_system, wb.date_system); assert_eq!(back.calc, wb.calc); assert!(back.protected_structure); + assert_eq!(back.structure_password, wb.structure_password); + assert!(back.structure_password.as_ref().unwrap().verify("s3cret").unwrap()); assert_eq!(back.props, wb.props); assert_eq!(back.theme, wb.theme); assert_eq!(back.names, wb.names); @@ -445,6 +456,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); @@ -619,6 +631,56 @@ fn writing_repairs_invalid_models() { assert_eq!(s.cell(at("B2")).unwrap().style, StyleId::DEFAULT); } +#[test] +fn vba_project_roundtrip() { + // A VBA project blob is preserved byte-identically through write and read, and the package + // carries the macro-enabled content type plus the vbaProject relationship. + let vba = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec(); + let mut wb = Workbook::new(); + wb.vba_project = Some(vba.clone()); + let bytes = write_xlsx(&wb).unwrap(); + assert_eq!(write_xlsx(&wb).unwrap(), bytes, "output is deterministic"); + + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap(); + let names: Vec = (0..z.len()).map(|i| z.name_for_index(i).unwrap().to_string()).collect(); + assert!(names.iter().any(|x| x == "xl/vbaProject.bin"), "missing vbaProject.bin"); + let mut bin = vec![]; + z.by_name("xl/vbaProject.bin").unwrap().read_to_end(&mut bin).unwrap(); + assert_eq!(bin, vba, "vbaProject.bin written byte-identically"); + let mut ct = String::new(); + z.by_name("[Content_Types].xml").unwrap().read_to_string(&mut ct).unwrap(); + assert!(ct.contains("macroEnabled.main+xml"), "macro-enabled main content type: {ct}"); + assert!(ct.contains("/xl/vbaProject.bin"), "vbaProject content type override: {ct}"); + let mut rels = String::new(); + z.by_name("xl/_rels/workbook.xml.rels").unwrap().read_to_string(&mut rels).unwrap(); + assert!(rels.contains("relationships/vbaProject"), "vbaProject relationship: {rels}"); + + let (back, rep) = read_xlsx(&bytes).unwrap(); + assert_eq!(back.vba_project.as_deref(), Some(vba.as_slice()), "vbaProject survives the read"); + assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings); + + // A workbook without macros stays macro-free and free of VBA warnings. + let (plain, rp) = read_xlsx(&write_xlsx(&Workbook::new()).unwrap()).unwrap(); + assert_eq!(plain.vba_project, None); + assert!(rp.warnings.iter().all(|w| !w.contains("VBA")), "{:?}", rp.warnings); +} + +#[test] +fn vba_project_read_from_foreign_file() { + // An .xlsm built by another producer is captured on read (not dropped with a plain warning). + let vba: &[u8] = b"PK\x03\x04-not-really-but-opaque-bytes"; + let bytes = super::minimal( + "", + &[("xl/vbaProject.bin", "ignored-as-text")], + r#""#, + "", + ); + let (wb, rep) = read_xlsx(&bytes).unwrap(); + assert!(wb.vba_project.is_some(), "VBA blob captured"); + assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings); + let _ = vba; +} + fn sheet1_xml(bytes: &[u8]) -> (String, bool) { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut s = String::new(); @@ -761,3 +823,28 @@ fn bad_comment_dates_are_left_out() { let c = &back.sheets[0].comments[&at("A1")]; assert!(c.threaded && c.dates.is_empty()); } + +/// #378: the saved scroll position survives a round trip, with and without frozen panes (where it +/// is the scrolling pane's `topLeftCell`). +#[test] +fn scroll_position_round_trips_with_and_without_frozen_panes() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().view_top_left = at("A31"); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.sheet(0).unwrap().view_top_left, at("A31")); + + let s = wb.sheet_mut(0).unwrap(); + s.freeze = Some((1, 0)); + s.view_top_left = at("C40"); + let bytes = write_xlsx(&wb).unwrap(); + let (xml, _) = sheet1_xml(&bytes); + assert!(xml.contains(r#" [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); + } +} + +#[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/write.rs b/crates/xlsx/src/write.rs index 025c8b2f3..0cb8246f1 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -20,6 +20,11 @@ pub const CT_DRAWING: &str = "application/vnd.openxmlformats-officedocument.draw pub const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; pub const CT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"; pub const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"; +/// Main workbook part: macro-free and macro-enabled variants (Excel refuses a `.xlsm` whose main +/// part uses the macro-free content type, and vice versa). +pub const CT_WORKBOOK_MAIN: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"; +pub const CT_WORKBOOK_MAIN_MACRO: &str = "application/vnd.ms-excel.sheet.macroEnabled.main+xml"; +pub const CT_VBA: &str = "application/vnd.ms-office.vbaProject"; pub const CT_THREADED_COMMENTS: &str = "application/vnd.ms-excel.threadedcomments+xml"; pub const CT_PERSONS: &str = "application/vnd.ms-excel.person+xml"; pub const REL_THREADED_COMMENT: &str = "http://schemas.microsoft.com/office/2017/10/relationships/threadedComment"; @@ -39,6 +44,9 @@ impl Rels { let id = format!("rId{}", self.items.len() + 1); let ty = match kind { "core-properties" => "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties".to_string(), + // The VBA project relationship lives in the Microsoft-documented namespace, not the + // OpenXML one; Excel rejects the file if this type is wrong. + "vbaProject" => "http://schemas.microsoft.com/office/2006/relationships/vbaProject".to_string(), k => format!("{NS_REL}/{k}"), }; self.items.push((id.clone(), ty, target.to_string(), false)); @@ -55,6 +63,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() } @@ -114,6 +128,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 { @@ -173,6 +189,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"); @@ -259,9 +276,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { let mut w = format!("{XML_DECL}"); w.push_str(if wb.date_system == gridcraft_core::DateSystem::D1904 { "" } else { "" }); if wb.protected_structure { - w.push_str(""); + let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default(); + let _ = write!(w, ""); } 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}"); } @@ -327,7 +346,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { out.part("xl/persons/person.xml", Some(CT_PERSONS), x.into_bytes()); wb_rels.add_uri(REL_PERSON, "persons/person.xml"); } - out.part("xl/workbook.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), w.into_bytes()); + out.part("xl/workbook.xml", Some(if wb.vba_project.is_some() { CT_WORKBOOK_MAIN_MACRO } else { CT_WORKBOOK_MAIN }), w.into_bytes()); + if let Some(data) = &wb.vba_project { + wb_rels.add("vbaProject", "vbaProject.bin"); + out.part("xl/vbaProject.bin", Some(CT_VBA), data.clone()); + } out.part("xl/_rels/workbook.xml.rels", None, wb_rels.xml().into_bytes()); // Document properties. @@ -359,15 +382,17 @@ 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. 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(); diff --git a/crates/xlsx/src/xls/formula.rs b/crates/xlsx/src/xls/formula.rs new file mode 100644 index 000000000..87a512c59 --- /dev/null +++ b/crates/xlsx/src/xls/formula.rs @@ -0,0 +1,548 @@ +//! 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() +} + +/// 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 Stack, n: usize, kids: &mut usize) -> Result, Unsupported> { + let at = stack.len().checked_sub(n).ok_or(Unsupported("malformed formula (argument missing)"))?; + 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 +/// `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: 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 { + 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, &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 { + 0x12 => UnOp::Plus, + 0x13 => UnOp::Neg, + _ => UnOp::Percent, + }; + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Unary(op, Box::new(x)))?; + } + 0x15 => { + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Paren(Box::new(x)))?; + } + 0x16 => push(&mut stack, kids, Expr::Missing)?, + 0x17 => { + let cch = usize::from(t.u8()?); + let high = t.u8()? & 1 != 0; + push(&mut stack, kids, 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, &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()?; + push(&mut stack, kids, Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?))?; + } + 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")); + } + push(&mut stack, kids, Expr::Number(n))?; + } + 0x20 => { + t.take(7)?; + 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, &mut kids)?; + push(&mut stack, kids, 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, &mut kids)?; + 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")), + }; + push(&mut stack, kids, Expr::Call(name, args))?; + } else { + let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?; + push(&mut stack, kids, 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"))?; + 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); + 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); + 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. + 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)?; + push(&mut stack, kids, Expr::Error(CellError::Ref))?; + } + 0x2B => { + t.take(8)?; + push(&mut stack, kids, Expr::Error(CellError::Ref))?; + } + 0x39 => { + let ixti = t.u16()?; + let i = t.u32()?; + 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); + 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); + 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)?; + push(&mut stack, kids, Expr::Error(CellError::Ref))?; + } + 0x3D => { + t.take(10)?; + 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")), + 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, _) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?; + 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()); + } + + #[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/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..c9aee66a3 --- /dev/null +++ b/crates/xlsx/src/xls/mod.rs @@ -0,0 +1,786 @@ +//! 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 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; +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") +} + +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 { + 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> { + 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, + 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 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 offsets = std::collections::HashSet::new(); + while let Some(rec) = recs.next_record()? { + budget.record()?; + 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; + 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()?; + 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; + while let Some(rec) = recs.next_record()? { + budget.record()?; + 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/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index 9e03f5ff0..43bb5b53e 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -83,7 +83,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/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/control-protocol.md b/docs/control-protocol.md index 783d1a903..c142ebffd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -33,8 +33,8 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `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 (`formulaBar` runs `view.formulaBar`) | +| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state | +| `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?, animations?}` | UI preferences (`formulaBar` runs `view.formulaBar`) | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | | `app.open` / `app.save` / `app.quit` | `{path}` / `{path?}` | Files and lifetime | @@ -46,6 +46,10 @@ System follows live OS appearance changes and falls back to Light when the platf The saved choice remains System; new users still start in Light. The legacy `view.darkMode` command and `ui.set {dark}` select a fixed Light or Dark theme. +`{command: "view.animations", params: {on: false}}` turns motion in the UI off (the selection +outline then jumps to a new selection instead of gliding there); without `on` it toggles. It is +the View ▸ Animations checkbox, saved with the other UI preferences and on by default. + `{command: "app.language.set", params: {language: "ja"}}` switches the interface language (`code` is accepted as an alias for `language`); `app.language.english` and `app.language.japanese` are shortcuts. Only interface chrome is translated, never command ids or diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d1c774fc5..06dd4529f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (XLSX threaded comments and persons written; note visibility read, #300) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **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,20 +9,21 @@ 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 | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures | | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, **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` | @@ -63,9 +64,9 @@ 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 | +| 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 | @@ -75,6 +76,8 @@ 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-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-11 | minor | XLSX: threaded comments and persons parts written (they came back as notes before); note visibility read from VML (#300) | | 2026-10-11 | minor | XLSX: Allow Edit Ranges (`protectedRanges`) read and written with their password hashes (#307) | | 2026-10-10 | minor | Encrypted and `.xls` files are now recognised and refused with a message (#17) | diff --git a/docs/gaps.md b/docs/gaps.md index c53f80906..35a8f87a0 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 (§15: the ODD* bond functions landed) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **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, @@ -64,11 +66,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 items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, @@ -77,7 +82,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Estimate:** 20–30 h for beta (25–40 h full). ### 9. Keyboard-first users: key tips, shortcuts, context menus (U) -- **Evidence:** #33, #49 (key tips: initial Home-tab support landed in #43 on 10-10, the rest of the ribbon not yet), #110, #21 (Insert Copied Cells), #28 (selection), +- **Evidence:** #33, #49 (key tips: initial Home-tab support landed in #43 on 10-10, the rest of the ribbon not yet), #110, #28 (selection), 59 command shortcuts vs Excel's 200+. - **Estimate:** 12–20 h. [ui-parity.md](ui-parity.md) @@ -98,12 +103,13 @@ 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 - #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) - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. @@ -153,6 +159,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §16: comment navigation, Show Comments, Show All Notes and Convert to Comments landed (#281) | +| 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) | +| 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-11 | minor | §13: Forecast Sheet landed (#322) | | 2026-10-11 | minor | §15: ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added (18 functions missing) | | 2026-10-11 | minor | §15: PERCENTOF and EUROCONVERT added (20 functions missing) | | 2026-10-11 | minor | §15: BAHTTEXT added (21 functions missing) | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index f8ea38f8e..9d2c5e7f5 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Windows renderer fallback, #120) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Windows DirectX 12 shaders with FXC, #339) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 The hardware Excel uses, per platform, against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). @@ -8,7 +8,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Hardware feature | Excel | GridCraft (macOS · Windows · Linux · BSD · web) | Gap and estimate | |---|---|---|---| | Multi-core recalculation | Multi-threaded recalculation on all cores (Windows and Mac), configurable thread count | Single-threaded everywhere (no threading in `crates/calc`) | Parallel recalc of independent dependency chains on native builds; WASM stays single-threaded or uses workers later: 12–18 h | -| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | +| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | | Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h | | Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h | | Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h | @@ -20,5 +20,6 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) | | 2026-10-11 | trivial | Windows: OpenGL fallback when DirectX 12 has no GPU or fails to start; Vulkan never by default (#120) | | 2026-10-10 | major | Created: single-threaded calc, wgpu rendering, no pen pressure, single window | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 3002b8907..d218c8169 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,21 +4,21 @@ 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: 266 / 290 commands implemented (91.7%).** +**Overall: 274 / 290 commands implemented (94.5%).** | Tab | Implemented | Total | Parity | |---|---|---|---| | File | 11 | 11 | 100% | | Home | 94 | 94 | 100% | -| Insert | 16 | 25 | 64% | +| Insert | 17 | 25 | 68% | | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | -| Data | 22 | 28 | 79% | -| Review | 10 | 18 | 56% | +| Data | 23 | 28 | 82% | +| Review | 15 | 18 | 83% | | View | 20 | 20 | 100% | | Automate | 3 | 3 | 100% | -| Table Design | 12 | 13 | 92% | +| Table Design | 13 | 13 | 100% | | Chart Design | 8 | 8 | 100% | | Format | 3 | 3 | 100% | | PivotTable Analyze | 13 | 13 | 100% | @@ -139,7 +139,7 @@ Implemented: - `edit.goToSpecial` Go To Special (Editing) - `home.addIns` Add-ins (Add-ins) -## Insert (16 / 25, 64%) +## Insert (17 / 25, 68%) Implemented: @@ -153,6 +153,7 @@ Implemented: - `insert.chart` Charts (Charts) - `insert.pivotChart` PivotChart (Charts) - `insert.sparkline` Sparklines (Sparklines) +- `insert.slicer` Slicer (Filters) - `insert.link` Link (Links) - `review.newComment` Comment (Comments) - `insert.textBox` Text Box (Text) @@ -165,7 +166,6 @@ Missing: - `insert.smartArt` SmartArt (Illustrations) - `insert.screenshot` Screenshot (Illustrations) - `insert.map` Maps (Charts) -- `insert.slicer` Slicer (Filters) - `insert.timeline` Timeline (Filters) - `insert.wordArt` WordArt (Text) - `insert.signatureLine` Signature Line (Text) @@ -237,7 +237,7 @@ Implemented: - `formulas.calculateNow` Calculate Now (Calculation) - `formulas.calculateSheet` Calculate Sheet (Calculation) -## Data (22 / 28, 79%) +## Data (23 / 28, 82%) Implemented: @@ -256,6 +256,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) @@ -270,10 +271,9 @@ 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) -## Review (10 / 18, 56%) +## Review (15 / 18, 83%) Implemented: @@ -282,8 +282,13 @@ Implemented: - `review.workbookStatistics` Workbook Statistics (Proofing) - `review.checkAccessibility` Check Accessibility (Accessibility) - `review.deleteComment` Delete Comment (Comments) +- `review.previousComment` Previous Comment (Comments) +- `review.nextComment` Next Comment (Comments) +- `review.showComments` Show Comments (Comments) - `review.newNote` New Note (Notes) - `review.showNote` Show/Hide Note (Notes) +- `review.showAllNotes` Show All Notes (Notes) +- `review.convertToComments` Convert to Comments (Notes) - `review.unprotectSheet` Unprotect Sheet (Protect) - `review.protectWorkbook` Protect Workbook (Protect) - `review.allowEditRanges` Allow Edit Ranges (Protect) @@ -292,11 +297,6 @@ Missing: - `review.smartLookup` Smart Lookup (Insights) - `review.translate` Translate (Language) -- `review.previousComment` Previous Comment (Comments) -- `review.nextComment` Next Comment (Comments) -- `review.showComments` Show Comments (Comments) -- `review.showAllNotes` Show All Notes (Notes) -- `review.convertToComments` Convert to Comments (Notes) - `review.showChanges` Show Changes (Changes) ## View (20 / 20, 100%) @@ -332,7 +332,7 @@ Implemented: - `automate.allScripts` All Scripts (Scripting Tools) - `automate.recordActions` Record Actions (Scripting Tools) -## Table Design (12 / 13, 92%) +## Table Design (13 / 13, 100%) Implemented: @@ -341,6 +341,7 @@ Implemented: - `table.summarizePivot` Summarize with PivotTable (Tools) - `table.removeDuplicates` Remove Duplicates (Tools) - `table.convertToRange` Convert to Range (Tools) +- `table.insertSlicer` Insert Slicer (Tools) - `table.headerRow` Header Row (Table Style Options) - `table.totalRow` Total Row (Table Style Options) - `table.bandedRows` Banded Rows (Table Style Options) @@ -349,10 +350,6 @@ Implemented: - `table.bandedColumns` Banded Columns (Table Style Options) - `table.style` Table Styles (Table Styles) -Missing: - -- `table.insertSlicer` Insert Slicer (Tools) - ## Chart Design (8 / 8, 100%) Implemented: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 05ce81861..8a00f5a3e 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 274 / 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 | **274 / 290 (94.5%)** | 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) | @@ -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** | | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a4f3b448f..b6e3b91be 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-11 · **Change:** minor (selected objects can be deleted from the keyboard) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selected columns can be reordered by dragging their headers) · **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) (266 / 290). +separately in [parity-checklist.md](parity-checklist.md) (274 / 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 @@ -15,6 +15,8 @@ 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, 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) | | 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 | | 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 | @@ -26,7 +28,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | **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 @@ -34,15 +36,17 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Behaviour | Status | |---|---| | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | +| Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing | | 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; 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) | +| Reorder selected whole columns by dragging their headers | done (10-11) | | Freeze panes, split panes | freeze done; split to verify | | Copy as HTML table for other apps | done (#36, 10-10) | -| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Missing: Insert Copied/Cut Cells (#21), paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | +| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | charts can't be resized (#190) | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | @@ -120,8 +124,8 @@ text, so AltGr symbols (e.g. `~` on a German layout) never trigger a shortcut. 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, Data Analysis and others exist. Missing or partial: Options, Solver, -Forecast Sheet, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, +Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing or partial: Options, +Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. ## Accessibility @@ -146,10 +150,15 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | +| 2026-10-11 | minor | Selected whole columns can be reordered by dragging their headers to an insertion point | +| 2026-10-11 | minor | Page Layout view with rulers and in-place headers and footers (#329) | +| 2026-10-11 | minor | Forecast Sheet dialog (#322) | | 2026-10-11 | minor | Delete and Backspace now remove the selected chart, picture or shape (#213) | | 2026-10-10 | minor | Keep formula helpers inside the worksheet when scrolling (#200) | | 2026-10-11 | trivial | Theme: title-bar light/dark switch, File › Display theme, system appearance on first run (#194, #239) | | 2026-10-11 | minor | Keyboard shortcut audit against Microsoft's Windows and Mac lists; missing shortcuts for existing features added (#110) | | 2026-10-11 | minor | File tab Backstage view and File › Options (#174) | +| 2026-10-11 | minor | Selection outline glide and the Animations setting | | 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 |