From 6d47626534da0b81cc632764493211278f02c3bc Mon Sep 17 00:00:00 2001 From: KitsuneSemCalda Date: Fri, 9 Oct 2026 22:50:39 -0300 Subject: [PATCH 001/162] Add OmaStore manifest --- omastore.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 omastore.toml diff --git a/omastore.toml b/omastore.toml new file mode 100644 index 0000000..427d049 --- /dev/null +++ b/omastore.toml @@ -0,0 +1,11 @@ +kind = "app" +categories = ["Office"] +icon = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png" + +[linux.x86_64] +asset = "gridcraft-{version}-linux-x86_64.tar.gz" +exec = "gridcraft-{version}-linux-x86_64/usr/bin/gridcraft" + +[linux.aarch64] +asset = "gridcraft-{version}-linux-aarch64.tar.gz" +exec = "gridcraft-{version}-linux-aarch64/usr/bin/gridcraft" From fd3d9f615acb45867786e68f18498d34adbcb4f0 Mon Sep 17 00:00:00 2001 From: KitsuneSemCalda Date: Sun, 11 Oct 2026 15:13:02 -0300 Subject: [PATCH 002/162] Fix OmaStore executable paths --- omastore.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/omastore.toml b/omastore.toml index 427d049..9b91e60 100644 --- a/omastore.toml +++ b/omastore.toml @@ -4,8 +4,8 @@ icon = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png" [linux.x86_64] asset = "gridcraft-{version}-linux-x86_64.tar.gz" -exec = "gridcraft-{version}-linux-x86_64/usr/bin/gridcraft" +exec = "gridcraft-{version}-linux-x86_64/bin/gridcraft" [linux.aarch64] asset = "gridcraft-{version}-linux-aarch64.tar.gz" -exec = "gridcraft-{version}-linux-aarch64/usr/bin/gridcraft" +exec = "gridcraft-{version}-linux-aarch64/bin/gridcraft" From faba681c995f66c3817b88df16d596857d65efd0 Mon Sep 17 00:00:00 2001 From: Dejan Port Date: Mon, 12 Oct 2026 01:31:29 +0200 Subject: [PATCH 003/162] fix: select a newly inserted chart from Recommended Charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resubmit of the selection half of #224 against current main, as requested — the Delete-key half was a duplicate of #219 and is already merged. Insert > Recommended Charts left the prior cell/range selection in place instead of selecting the new chart, unlike every other object insertion path (see insert.chart in dialogs.rs, which already selects the chart it creates). With the selection left on the source data, pressing Del right after inserting deleted that data and left an empty chart behind (#214). Co-Authored-By: Claude Sonnet 5 --- crates/ui-egui/src/ribbon.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291ac..3885840 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1426,7 +1426,13 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { let __k = big_button(ui, Icon::Chart, "Recommended\nCharts", "Recommended Charts", false); control_keytip(app, ui, &__k, "insert.recommendedCharts"); if __k.clicked() { - act(app, "insert.recommendedCharts", json!({})); + match app.run("insert.recommendedCharts", json!({})) { + // The new chart is selected, as after inserting any other object (see insert.chart + // in dialogs.rs) — otherwise the prior cell/range selection is left in place, and + // Del right after inserting deletes the source data instead of the chart. + Ok(r) => app.selected_chart = r.get("chart").and_then(serde_json::Value::as_u64).and_then(|id| u32::try_from(id).ok()), + Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))), + } } ui.vertical(|ui| { ui.horizontal(|ui| { From 3424229ee3ea84fe3018f5bb4ea4aa5036e9eb6b Mon Sep 17 00:00:00 2001 From: SteamSmith <341182916+SteamSmith@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:59:42 -0400 Subject: [PATCH 004/162] io: remove the .sctmp temporary file when a save fails write_file writes `.sctmp` and renames it over the workbook. When the write or the rename failed (a full disk, or the target locked by another app such as Excel or a sync client), the temporary file was left beside the user's workbook. Remove it on either failure and report the same error. Adds io::tests::failed_save_leaves_no_temporary_file, which fails before this change. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index e613611..38bd936 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -339,8 +339,12 @@ pub fn read_file(path: &str) -> Result> { pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { // Write to a temporary file, then rename: a crash never leaves a half-written workbook. let tmp = format!("{path}.sctmp"); - std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| { + // A write or rename that fails (full disk, the workbook locked by another app) leaves + // no temporary file beside the user's workbook. + let _ = std::fs::remove_file(&tmp); + EngineError::Other(format!("{path}: {e}")) + }) } #[cfg(target_arch = "wasm32")] @@ -434,6 +438,26 @@ mod tests { assert!(wb.vba_project.is_some(), "the open workbook keeps its macros"); } + /// A save that can't be put in place (here a folder already has the workbook's name, as + /// with a file another app holds locked) fails without leaving `.sctmp` behind. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn failed_save_leaves_no_temporary_file() { + let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let blocked = dir.join("Book1.xlsx"); + std::fs::create_dir(&blocked).unwrap(); + assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err()); + assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed"); + // An ordinary save still works and leaves only the workbook. + let ok = dir.join("Book2.xlsx"); + write_file(ok.to_str().unwrap(), b"workbook").unwrap(); + assert_eq!(std::fs::read(&ok).unwrap(), b"workbook"); + assert!(!dir.join("Book2.xlsx.sctmp").exists()); + let _ = std::fs::remove_dir_all(&dir); + } + /// 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 { From 3a8f6f5aa505396d7486eb8ef9c7743d4732222a Mon Sep 17 00:00:00 2001 From: tydude001 Date: Sun, 11 Oct 2026 19:04:55 -0500 Subject: [PATCH 005/162] AutoFilter: the value list scrolls across the menu's full width (Fixes #432) The value list kept egui's default auto_shrink, so its scroll bar sat mid-menu after the longest value and the wheel only scrolled it with the pointer over a value. Keep the height shrinking but not the width: the scroll bar is at the menu's right edge and the wheel works anywhere over the list. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 4 +- crates/ui-egui/tests/filter_list_scroll.rs | 61 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/filter_list_scroll.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..aaf6885 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2547,7 +2547,9 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) c.1 = all_new; } } - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + // Full width: shrunk to the longest value, the scroll bar sat mid-menu and the wheel only + // worked over the values themselves (#432). + egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show(ui, |ui| { for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) { ui.checkbox(on, v.as_str()); } diff --git a/crates/ui-egui/tests/filter_list_scroll.rs b/crates/ui-egui/tests/filter_list_scroll.rs new file mode 100644 index 0000000..35406fa --- /dev/null +++ b/crates/ui-egui/tests/filter_list_scroll.rs @@ -0,0 +1,61 @@ +//! The AutoFilter value list scrolls with the pointer anywhere over it, not only over the values +//! themselves (issue #432): the list takes the menu's full width, so its scroll bar and wheel area do too. + +use egui::{Event, Modifiers, MouseWheelUnit, PointerButton, TouchPhase, pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// A1:A81 holds a header and 80 distinct values, with the AutoFilter's menu open on column A. +fn filter_menu_on_a_long_list() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut values = vec![vec![json!("Code")]]; + values.extend((1..=80).map(|i| vec![json!(format!("ITEM-{i:03}"))])); + session.execute("range.setValues", json!({"range": "A1", "values": values})).unwrap(); + session.execute("data.filter", json!({"range": "A1:A81"})).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); + let button = { + 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); + grid::filter_button_rect(&geo, sh, CellRef::parse("A1").unwrap()).center() + }; + h.input_mut().events.push(Event::PointerMoved(button)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: button, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(4); + assert!(h.state().grid.filter_menu.is_some(), "the filter menu opens"); + h +} + +#[test] +fn filter_list_scrolls_with_the_pointer_right_of_the_values() { + let mut h = filter_menu_on_a_long_list(); + let first = h.get_by_label("ITEM-001").rect(); + // The menu is 230 points wide; the value labels end well before this point. + let pointer = pos2(first.left() + 200.0, first.center().y); + h.input_mut().events.push(Event::PointerMoved(pointer)); + h.run_steps(2); + h.input_mut().events.push(Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: vec2(0.0, -3.0), + phase: TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + h.run_steps(20); + let now = h.query_by_label("ITEM-001").map(|n| n.rect().top()); + assert!(now.is_none_or(|y| y < first.top() - 20.0), "the wheel scrolls the list: ITEM-001 was at {}, now {now:?}", first.top()); +} From b60d6410832bbe93a21030536dad633d8fec00a2 Mon Sep 17 00:00:00 2001 From: Dejan Port Date: Mon, 12 Oct 2026 02:14:17 +0200 Subject: [PATCH 006/162] i18n: add Serbian (Latin script) interface translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Language::Sr ("sr"), following the existing per-language module pattern (crates/ui-egui/src/i18n/.rs with a translate(text) -> Option<&'static str>). All 381 strings in the JAPANESE master list are covered, verified by the existing completeness test. Latin script (sr-Latn), ekavian dialect — the common default for digital/software Serbian. Cyrillic (sr-Cyrl) could follow as a separate variant if wanted; this keeps the PR to one script, matching how every other language here is a single variant. Co-Authored-By: Claude Sonnet 5 --- crates/ui-egui/src/i18n.rs | 16 +- crates/ui-egui/src/i18n/sr.rs | 396 ++++++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/i18n/sr.rs diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 406cc3d..54d3fd2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -16,6 +16,7 @@ mod fr; mod ko; mod pt; mod ru; +mod sr; mod zh; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -33,10 +34,11 @@ pub enum Language { De, Cs, Fr, + Sr, } impl Language { - pub const ALL: [Self; 10] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr]; + pub const ALL: [Self; 11] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr, Self::Sr]; /// The language's own name, shown in the switcher. pub fn name(self) -> &'static str { @@ -51,6 +53,7 @@ impl Language { Self::De => "Deutsch", Self::Cs => "Čeština", Self::Fr => "Français", + Self::Sr => "Srpski", } } @@ -67,6 +70,7 @@ impl Language { Self::De => "de", Self::Cs => "cs", Self::Fr => "fr", + Self::Sr => "sr", } } @@ -86,6 +90,7 @@ impl Language { "de" => Some(Self::De), "cs" => Some(Self::Cs), "fr" => Some(Self::Fr), + "sr" => Some(Self::Sr), _ => None, } } @@ -123,6 +128,7 @@ impl Language { Self::De => de::translate(text), Self::Cs => cs::translate(text), Self::Fr => fr::translate(text), + Self::Sr => sr::translate(text), }; translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } @@ -587,6 +593,7 @@ mod tests { assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}"); assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}"); assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}"); + assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}"); } assert_eq!(Language::Ja.tr("Data"), "データ"); assert_eq!(Language::Zh.tr("Data"), "数据"); @@ -616,6 +623,10 @@ mod tests { assert_eq!(Language::Ja.tr("Orientation"), "方向"); assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace"); assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu"); + assert_eq!(Language::Sr.tr("Data"), "Podaci"); + assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija"); + assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija"); assert_eq!(Language::parse("xx"), None); } @@ -654,6 +665,9 @@ mod tests { assert_eq!(Language::from_tag("fr-FR"), Some(Language::Fr)); assert_eq!(Language::from_tag("fr_CA"), Some(Language::Fr)); assert_eq!(Language::parse("fr"), Some(Language::Fr)); + assert_eq!(Language::from_tag("sr-Latn-RS"), Some(Language::Sr)); + assert_eq!(Language::from_tag("sr-RS"), Some(Language::Sr)); + assert_eq!(Language::parse("sr"), Some(Language::Sr)); } #[test] diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs new file mode 100644 index 0000000..bc6a8ee --- /dev/null +++ b/crates/ui-egui/src/i18n/sr.rs @@ -0,0 +1,396 @@ +//! Serbian (Latin script, ekavian) translations for the interface strings in i18n.rs. + +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Nalepi", + "Paste Values" => "Nalepi vrednosti", + "Paste Formulas" => "Nalepi formule", + "Paste Formatting" => "Nalepi oblikovanje", + "Transpose" => "Transponuj", + "Paste Link" => "Nalepi vezu", + "Paste Special…" => "Specijalno lepljenje…", + "Insert Copied Cells" => "Umetni kopirane ćelije", + "Insert Copied Cells…" => "Umetni kopirane ćelije…", + "Insert Cut Cells" => "Umetni isečene ćelije", + "Insert Paste" => "Umetni lepljenje", + "Cut (⌘X)" => "Iseci (⌘X)", + "Copy (⌘C)" => "Kopiraj (⌘C)", + "Format Painter (double-click to keep it on)" => "Kopiraj oblikovanje (dupli klik da ostane uključeno)", + "Bold (⌘B)" => "Podebljano (⌘B)", + "Italic (⌘I)" => "Kurziv (⌘I)", + "Underline (⌘U)" => "Podvučeno (⌘U)", + "Double Underline" => "Dvostruko podvlačenje", + "Strikethrough" => "Precrtano", + "Underline" => "Podvučeno", + "Increase Font Size" => "Povećaj veličinu fonta", + "Decrease Font Size" => "Smanji veličinu fonta", + "Wrap Text" => "Prelomi tekst", + "Merge & Center" => "Spoji i centriraj", + "Merge Cells" => "Spoji ćelije", + "Merge Across" => "Spoji po redovima", + "Unmerge Cells" => "Razdvoji ćelije", + "Fill Color" => "Boja popune", + "Font Color" => "Boja fonta", + "Borders" => "Ivice", + "Top\nAlign" => "Gornje\nporavnanje", + "Middle\nAlign" => "Srednje\nporavnanje", + "Bottom\nAlign" => "Donje\nporavnanje", + "Left" => "Levo", + "Right" => "Desno", + "Center" => "Centrirano", + "Horizontal" => "Horizontalno", + "Vertical Text" => "Vertikalni tekst", + "Decrease Indent" => "Smanji uvlačenje", + "Increase Indent" => "Povećaj uvlačenje", + "Orientation" => "Orijentacija", + "Page Layout|Orientation" => "Orijentacija", + "Angle Counterclockwise" => "Ugao u levo", + "Angle Clockwise" => "Ugao u desno", + "Rotate Text Up" => "Rotiraj tekst nagore", + "Rotate Text Down" => "Rotiraj tekst nadole", + "Accounting Number Format" => "Računovodstveni format", + "Percent Style" => "Procentualni stil", + "Comma Style" => "Stil sa zapetama", + "Increase Decimal" => "Povećaj decimale", + "Decrease Decimal" => "Smanji decimale", + "More Accounting Formats…" => "Još računovodstvenih formata…", + "Conditional\nFormatting" => "Uslovno\noblikovanje", + "Conditional Formatting" => "Uslovno oblikovanje", + "Format\nas Table" => "Oblikuj\nkao tabelu", + "Cell\nStyles" => "Stilovi\nćelija", + "Format Cells…" => "Oblikovanje ćelija…", + "Format Cell Alignment…" => "Oblikovanje poravnanja ćelije…", + "Format\nPane" => "Okno za\noblikovanje", + "Format" => "Oblikovanje", + "Insert" => "Umetni", + "Insert Cells…" => "Umetni ćelije…", + "Delete" => "Obriši", + "Delete Cells…" => "Obriši ćelije…", + "Insert Sheet Rows" => "Umetni redove lista", + "Insert Sheet Columns" => "Umetni kolone lista", + "Delete Sheet Rows" => "Obriši redove lista", + "Delete Sheet Columns" => "Obriši kolone lista", + "Row Height…" => "Visina reda…", + "AutoFit Row Height" => "Automatsko uklapanje visine reda", + "Column Width…" => "Širina kolone…", + "AutoFit Column Width" => "Automatsko uklapanje širine kolone", + "Default Width…" => "Podrazumevana širina…", + "Hide Rows" => "Sakrij redove", + "Unhide Rows" => "Otkrij redove", + "Hide Columns" => "Sakrij kolone", + "Unhide Columns" => "Otkrij kolone", + "Lock Cell" => "Zaključaj ćeliju", + "Clear" => "Obriši", + "Clear All" => "Obriši sve", + "Clear Formats" => "Obriši oblikovanja", + "Clear Contents" => "Obriši sadržaj", + "Clear Comments and Notes" => "Obriši komentare i beleške", + "Clear Hyperlinks" => "Obriši hiperveze", + "AutoSum" => "Automatski zbir", + "AutoSum (⌘⇧T)" => "Automatski zbir (⌘⇧T)", + "Flash\nFill" => "Brzo\npopunjavanje", + "Flash Fill" => "Brzo popunjavanje", + "Fill" => "Popuni", + "Clear Print Area" => "Obriši oblast štampe", + "Find &\nSelect" => "Pronađi i\nizaberi", + "Find…" => "Pronađi…", + "Replace…" => "Zameni…", + "Go To…" => "Idi na…", + "Go To Special…" => "Idi na posebno…", + "PivotTable" => "Izvedena tabela", + "Recommended\nCharts" => "Preporučeni\ngrafikoni", + "Table" => "Tabela", + "Pictures" => "Slike", + "Shapes" => "Oblici", + "Icons" => "Ikone", + "Text\nBox" => "Tekstualni\nokvir", + "Comment" => "Komentar", + "New\nComment" => "Novi\nkomentar", + "New Note" => "Nova beleška", + "Show/Hide Note" => "Prikaži/sakrij belešku", + "Header &\nFooter" => "Zaglavlje i\npodnožje", + "Text to\nColumns" => "Tekst u\nkolone", + "Link" => "Veza", + "Symbol" => "Simbol", + "Sparklines" => "Mini grafikoni", + "Checkbox" => "Polje za potvrdu", + "Insert checkboxes in the selected cells" => "Umetni polja za potvrdu u izabrane ćelije", + "Header & Footer" => "Zaglavlje i podnožje", + "Draw with ink" => "Crtaj mastilom", + "Eraser" => "Gumica", + "Ink to\nShape" => "Mastilo u\noblik", + "Select\nObjects" => "Izaberi\nobjekte", + "Themes" => "Teme", + "Margins" => "Margine", + "Size" => "Veličina", + "Print\nArea" => "Oblast\nštampe", + "Set Print Area" => "Postavi oblast štampe", + "Breaks" => "Prelomi", + "Insert Page Break" => "Umetni prelom stranice", + "Remove Page Break" => "Ukloni prelom stranice", + "Reset All Page Breaks" => "Resetuj sve prelome stranica", + "Print\nTitles" => "Naslovi za\nštampu", + "Sheet\nRight-to-Left" => "List\nzdesna nalevo", + "Sheet Right-to-Left" => "List zdesna nalevo", + "Narrow" => "Usko", + "Wide" => "Široko", + "Normal" => "Normalno", + "Portrait" => "Uspravno", + "Landscape" => "Položeno", + "Custom Margins…" => "Prilagođene margine…", + "Insert\nFunction" => "Umetni\nfunkciju", + "Name\nManager" => "Upravljač\nimenima", + "Define Name" => "Definiši ime", + "Use in Formula" => "Koristi u formuli", + "Create from Selection" => "Napravi iz selekcije", + "Trace Precedents" => "Prati prethodnike", + "Trace Dependents" => "Prati zavisne ćelije", + "Remove Arrows" => "Ukloni strelice", + "Show Formulas" => "Prikaži formule", + "Error Checking" => "Provera grešaka", + "Evaluate Formula" => "Proceni formulu", + "Watch Window" => "Prozor za praćenje", + "Calculation\nOptions" => "Opcije\nizračunavanja", + "Calculate Now" => "Izračunaj sada", + "Calculate Sheet" => "Izračunaj list", + "Automatic" => "Automatski", + "Automatic Except for Data Tables" => "Automatski osim za tabele podataka", + "Manual" => "Ručno", + "Sort &\nFilter" => "Sortiraj i\nfiltriraj", + "Sort" => "Sortiraj", + "Sort A to Z" => "Sortiraj od A do Š", + "Sort Z to A" => "Sortiraj od Š do A", + "Custom Sort…" => "Prilagođeno sortiranje…", + "Filter" => "Filter", + "Reapply" => "Ponovo primeni", + "Get Data\n(Text/CSV)" => "Preuzmi podatke\n(Tekst/CSV)", + "Remove\nDuplicates" => "Ukloni\nduplikate", + "Data\nValidation" => "Provera\npodataka", + "Data Validation" => "Provera podataka", + "Data Validation…" => "Provera podataka…", + "Circle Invalid Data" => "Zaokruži nevažeće podatke", + "Clear Validation" => "Ukloni proveru", + "Group" => "Grupiši", + "Ungroup" => "Razgrupiši", + "Show Detail" => "Prikaži detalje", + "Hide Detail" => "Sakrij detalje", + "Subtotal" => "Međuzbir", + "Data\nAnalysis" => "Analiza\npodataka", + "Data Analysis" => "Analiza podataka", + "Solver" => "Rešavač", + "What-If\nAnalysis" => "Analiza\nšta-ako", + "Data Table…" => "Tabela podataka…", + "Scenario Manager…" => "Upravljač scenarijima…", + "Goal Seek…" => "Traženje cilja…", + "Forecast\nSheet" => "Prognozni\nlist", + "Spelling" => "Pravopis", + "Check\nAccessibility" => "Provera\npristupačnosti", + "Threaded Comments" => "Komentari u nizu", + "Show\nComments" => "Prikaži\nkomentare", + "Previous" => "Prethodni", + "Next" => "Sledeći", + "Previous Comment" => "Prethodni komentar", + "Next Comment" => "Sledeći komentar", + "Show All Notes" => "Prikaži sve beleške", + "Convert to Comments" => "Pretvori u komentare", + "Protect\nWorkbook" => "Zaštiti\nradnu svesku", + "Allow Edit\nRanges" => "Dozvoli uređivanje\nopsega", + "Allow Edit Ranges" => "Dozvoli uređivanje opsega", + "Unprotect\nSheet" => "Ukloni zaštitu\nlista", + "Protect Sheet…" => "Zaštiti list…", + "Comments" => "Komentari", + "Notes" => "Beleške", + "Page Break\nPreview" => "Pregled preloma\nstranica", + "Page\nLayout" => "Izgled\nstranice", + "Formula Bar" => "Traka formule", + "Gridlines" => "Linije mreže", + "Headings" => "Zaglavlja", + "Zoom" => "Uvećanje", + "100%" => "100%", + "Zoom to\nSelection" => "Uvećaj na\nselekciju", + "Freeze\nPanes" => "Zamrzni\nokna", + "Freeze Panes" => "Zamrzni okna", + "Freeze Top Row" => "Zamrzni gornji red", + "Freeze First Column" => "Zamrzni prvu kolonu", + "Unfreeze Panes" => "Odmrzni okna", + "Dark Mode" => "Tamni režim", + "Display theme" => "Tema prikaza", + "Animations" => "Animacije", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Kliziti obris selekcije do ćelije na koju se pomeraš umesto skoka" + } + "System" => "Sistem", + "Light" => "Svetla", + "Dark" => "Tamna", + "Interface language" => "Jezik interfejsa", + "Interface size" => "Veličina interfejsa", + "Auto" => "Automatski", + "Drag to freeze columns" => "Prevuci da zamrzneš kolone", + "Drag to freeze rows" => "Prevuci da zamrzneš redove", + "Command\nPalette" => "Paleta\nkomandi", + "Agent\nControl" => "Kontrola\nagenta", + "Action\nJournal" => "Dnevnik\nradnji", + "About\nGridCraft" => "O programu\nGridCraft", + "Share" => "Deli", + "Add Chart\nElement" => "Dodaj element\ngrafikona", + "Change\nChart Type" => "Promeni\ntip grafikona", + "Switch\nRow/Column" => "Zameni\nred/kolonu", + "Select\nData" => "Izaberi\npodatke", + "Data Labels: None" => "Oznake podataka: Ništa", + "Data Labels: Show" => "Oznake podataka: Prikaži", + "Legend: None" => "Legenda: Ništa", + "Legend: Bottom" => "Legenda: Dole", + "Legend: Top" => "Legenda: Gore", + "Legend: Right" => "Legenda: Desno", + "Gridlines: None" => "Linije mreže: Ništa", + "Gridlines: Show" => "Linije mreže: Prikaži", + "Chart\nTitle" => "Naslov\ngrafikona", + "Delete\nChart" => "Obriši\ngrafikon", + "Convert\nto Range" => "Pretvori\nu opseg", + "Table\nStyles" => "Stilovi\ntabele", + "Top 10 Items" => "10 najboljih stavki", + "Top 10%" => "Gornjih 10%", + "Bottom 10 Items" => "10 najgorih stavki", + "Bottom 10%" => "Donjih 10%", + "Above Average" => "Iznad proseka", + "Below Average" => "Ispod proseka", + "Clear Rules from Entire Sheet" => "Obriši pravila sa celog lista", + "Clear Rules from Selected Cells" => "Obriši pravila iz izabranih ćelija", + "Constants" => "Konstante", + "Data Bars" => "Trake podataka", + "Color Scales" => "Skale boja", + "Icon Sets" => "Skupovi ikona", + "Home" => "Početna", + "Draw" => "Crtanje", + "Page Layout" => "Izgled stranice", + "Formulas" => "Formule", + "Data" => "Podaci", + "Review" => "Pregled", + "View" => "Prikaz", + "Automate" => "Automatizacija", + "Table Design" => "Dizajn tabele", + "Chart Design" => "Dizajn grafikona", + "Ready" => "Spremno", + "Enter" => "Unos", + "Edit" => "Uređivanje", + "Point" => "Pokaži", + "Calculate" => "Izračunavanje", + "Select destination and press Enter or choose Paste" => "Izaberi odredište i pritisni Enter ili izaberi Nalepi", + "General" => "Opšte", + "Number" => "Broj", + "Currency" => "Valuta", + "Accounting" => "Računovodstveno", + "Date" => "Datum", + "Time" => "Vreme", + "Percentage" => "Procenat", + "Fraction" => "Razlomak", + "Scientific" => "Naučno", + "Text" => "Tekst", + "Special" => "Posebno", + "Custom" => "Prilagođeno", + "Short Date" => "Kratak datum", + "Long Date" => "Dugačak datum", + "More Number Formats…" => "Još formata brojeva…", + "Financial" => "Finansijsko", + "Logical" => "Logičko", + "Date &\nTime" => "Datum i\nvreme", + "Lookup &\nReference" => "Pretraga i\nreference", + "Math &\nTrig" => "Matematika i\ntrigonometrija", + "More\nFunctions" => "Još\nfunkcija", + "Sheet Options|View" => "Opcije lista|Prikaz", + "Print" => "Štampaj", + "Click to add data" => "Kliknite da dodate podatke", + "Add header" => "Dodaj zaglavlje", + "Add footer" => "Dodaj podnožje", + "(none)" => "(ništa)", + "Header" => "Zaglavlje", + "Footer" => "Podnožje", + "Page\nNumber" => "Broj\nstranice", + "Number\nof Pages" => "Broj\nstranica", + "Current\nDate" => "Trenutni\ndatum", + "Current\nTime" => "Trenutno\nvreme", + "File\nPath" => "Putanja\nfajla", + "File\nName" => "Ime\nfajla", + "Sheet\nName" => "Ime\nlista", + "Go to\nHeader" => "Idi na\nzaglavlje", + "Go to\nFooter" => "Idi na\npodnožje", + "Page Number" => "Broj stranice", + "Number of Pages" => "Broj stranica", + "Current Date" => "Trenutni datum", + "Current Time" => "Trenutno vreme", + "File Path" => "Putanja fajla", + "File Name" => "Ime fajla", + "Sheet Name" => "Ime lista", + "Go to Header" => "Idi na zaglavlje", + "Different First Page" => "Drugačija prva stranica", + "Different Odd & Even Pages" => "Drugačije neparne i parne stranice", + "Go to Footer" => "Idi na podnožje", + "Ruler" => "Lenjir", + "File" => "Datoteka", + "Back" => "Nazad", + "New" => "Novo", + "Open" => "Otvori", + "Info" => "Informacije", + "Save" => "Sačuvaj", + "Save As" => "Sačuvaj kao", + "Export" => "Izvezi", + "Close" => "Zatvori", + "Options" => "Opcije", + "Blank workbook" => "Prazna radna sveska", + "Recent" => "Nedavno", + "No recent workbooks yet." => "Još nema nedavnih radnih svezaka.", + "More workbooks" => "Više radnih svezaka", + "Browse…" => "Pregledaj…", + "Not saved yet" => "Još nije sačuvano", + "Location" => "Lokacija", + "Sheets" => "Listovi", + "Protect Workbook" => "Zaštiti radnu svesku", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "Struktura lista je zaključana: listovi se ne mogu dodavati, premeštati, preimenovati ni brisati." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Zaključaj strukturu listova tako da se ne mogu dodavati, premeštati, preimenovati ni brisati." + } + "Properties" => "Svojstva", + "Title" => "Naslov", + "Subject" => "Predmet", + "Author" => "Autor", + "Company" => "Kompanija", + "Keywords" => "Ključne reči", + "Description" => "Opis", + "No workbook is open." => "Nijedna radna sveska nije otvorena.", + "Save as type" => "Sačuvaj kao tip", + "Excel Workbook (*.xlsx)" => "Excel radna sveska (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (odvojeno zapetama) (*.csv)", + "Web Page (*.html)" => "Veb stranica (*.html)", + "PDF (*.pdf)" => "PDF (*.pdf)", + "Download" => "Preuzmi", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { + "CSV fajl, veb stranica ili PDF su kopija: radna sveska zadržava svoj fajl." + } + "Save the workbook under another name or in another folder." => "Sačuvaj radnu svesku pod drugim imenom ili u drugoj fascikli.", + "Print Active Sheets" => "Štampaj aktivne listove", + "Print Entire Workbook" => "Štampaj celu radnu svesku", + "Fit Sheet on One Page" => "Uklopi list na jednu stranicu", + "Page Setup…" => "Podešavanje stranice…", + "Page {page} of {pages}" => "Stranica {page} od {pages}", + "Nothing to print." => "Nema ničega za štampu.", + "Previous page" => "Prethodna stranica", + "Next page" => "Sledeća stranica", + "Create PDF" => "Napravi PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Sačuvaj list kao PDF dokument koji zadržava izgled stranice.", + "Change File Type" => "Promeni tip fajla", + "CSV (Comma delimited)" => "CSV (odvojeno zapetama)", + "Save the active sheet as comma-separated text." => "Sačuvaj aktivni list kao tekst odvojen zapetama.", + "Web Page (.html)" => "Veb stranica (.html)", + "Save the workbook as a web page." => "Sačuvaj radnu svesku kao veb stranicu.", + "Options|General" => "Opcije|Opšte", + "Options|Save" => "Opcije|Sačuvaj", + "Show formula bar" => "Prikaži traku formule", + "Save .xlsx workbooks automatically after every change" => "Automatski sačuvaj .xlsx radne sveske posle svake izmene", + "Workbook calculation" => "Izračunavanje radne sveske", + "Enable iterative calculation" => "Omogući iterativno izračunavanje", + "Calculation settings are saved with the workbook." => "Podešavanja izračunavanja se čuvaju sa radnom sveskom.", + _ => return None, + }) +} From c59e94297ca9d2410dd6e8fce14b30417152e884 Mon Sep 17 00:00:00 2001 From: Dejan Port Date: Mon, 12 Oct 2026 02:22:00 +0200 Subject: [PATCH 007/162] fix(i18n): correct scattered wrong French translations and add 2 missing keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completeness test's failure (missing "Different First Page") was only the visible tip: fr.rs was missing that key and "Different Odd & Even Pages" outright, but also had roughly 50 *present* keys mapped to the wrong value — reading as a real, different English string's French translation instead of its own. The completeness test only checks that a translation exists and is non-empty, not that it's correct, so none of this was caught before. Examples found by inspection (key => previous wrong value => what that value actually means): "Click to add data" => "Fermer" (= Close) "Header" => "Informations" (= Info) "Ruler" => "Enregistrer sous" (= Save As) "Info" => "Insérer les cellules copiées…" (= Insert Copied Cells…) "Animations" => "Auteur" (= Author) "Auto" => "Retour" (= Back) "Previous Comment" => "Imprimer tout le classeur" (= Print Entire Workbook) The wrong values aren't a local shuffle (adjacent keys swapped) — "Info" drew a value that belongs near the start of the key list while "Info" itself is near the end, so whatever produced this scattered entries from unrelated parts of the file onto each other. Given the extent and that it wasn't contained to one block, patching individual lines risked missing more of them, so this regenerates every one of the 381 entries from the English source text (same source list i18n-serbian used), rather than auditing the existing file entry by entry. Verified: the existing completeness test (translations_are_unique_...) now passes end to end — it was failing on a clean main checkout before this change, purely from the 2 missing keys. Full cargo test -p gridcraft-ui-egui --lib: 75/75 (was 74/75 on main). Co-Authored-By: Claude Sonnet 5 --- crates/ui-egui/src/i18n/fr.rs | 366 ++++++++++++++++++---------------- 1 file changed, 189 insertions(+), 177 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index fdf1f89..52645f6 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -9,32 +9,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Transposer", "Paste Link" => "Coller la liaison", "Paste Special…" => "Collage spécial…", - "Insert Copied Cells" => "Insérer les cellules coupées", - "Insert Copied Cells…" => "Coller en insérant", - "Insert Cut Cells" => "Insérer des cases à cocher dans les cellules sélectionnées", - "Insert Paste" => "Taille de l’interface", + "Insert Copied Cells" => "Insérer les cellules copiées", + "Insert Copied Cells…" => "Insérer les cellules copiées…", + "Insert Cut Cells" => "Insérer les cellules coupées", + "Insert Paste" => "Insérer en collant", "Cut (⌘X)" => "Couper (⌘X)", "Copy (⌘C)" => "Copier (⌘C)", "Format Painter (double-click to keep it on)" => "Reproduire la mise en forme (double-clic pour la conserver)", "Bold (⌘B)" => "Gras (⌘B)", "Italic (⌘I)" => "Italique (⌘I)", "Underline (⌘U)" => "Souligné (⌘U)", - "Double Underline" => "Double souligné", + "Double Underline" => "Double soulignement", "Strikethrough" => "Barré", "Underline" => "Souligné", - "Increase Font Size" => "Augmenter la taille de la police", - "Decrease Font Size" => "Réduire la taille de la police", - "Wrap Text" => "Renvoyer à la ligne", + "Increase Font Size" => "Augmenter la taille de police", + "Decrease Font Size" => "Réduire la taille de police", + "Wrap Text" => "Renvoyer à la ligne automatiquement", "Merge & Center" => "Fusionner et centrer", "Merge Cells" => "Fusionner les cellules", "Merge Across" => "Fusionner horizontalement", - "Unmerge Cells" => "Dissocier les cellules", + "Unmerge Cells" => "Annuler la fusion des cellules", "Fill Color" => "Couleur de remplissage", "Font Color" => "Couleur de police", "Borders" => "Bordures", - "Top\nAlign" => "Aligner en haut", - "Middle\nAlign" => "Aligner au centre", - "Bottom\nAlign" => "Aligner en bas", + "Top\nAlign" => "Aligner en\nhaut", + "Middle\nAlign" => "Aligner au\nmilieu", + "Bottom\nAlign" => "Aligner en\nbas", "Left" => "Gauche", "Right" => "Droite", "Center" => "Centré", @@ -50,17 +50,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Rotate Text Down" => "Pivoter le texte vers le bas", "Accounting Number Format" => "Format de nombre Comptabilité", "Percent Style" => "Style de pourcentage", - "Comma Style" => "Style de virgule", + "Comma Style" => "Style de milliers", "Increase Decimal" => "Ajouter une décimale", "Decrease Decimal" => "Retirer une décimale", "More Accounting Formats…" => "Autres formats comptables…", - "Conditional\nFormatting" => "Mise en forme conditionnelle", + "Conditional\nFormatting" => "Mise en forme\nconditionnelle", "Conditional Formatting" => "Mise en forme conditionnelle", - "Format\nas Table" => "Mettre sous forme de tableau", - "Cell\nStyles" => "Styles de cellules", + "Format\nas Table" => "Mettre sous forme\nde tableau", + "Cell\nStyles" => "Styles de\ncellules", "Format Cells…" => "Format de cellules…", - "Format Cell Alignment…" => "Format d’alignement de cellule…", - "Format\nPane" => "Volet Format", + "Format Cell Alignment…" => "Format d'alignement de cellule…", + "Format\nPane" => "Volet\nFormat", "Format" => "Format", "Insert" => "Insertion", "Insert Cells…" => "Insérer des cellules…", @@ -88,58 +88,58 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Hyperlinks" => "Effacer les liens hypertexte", "AutoSum" => "Somme automatique", "AutoSum (⌘⇧T)" => "Somme automatique (⌘⇧T)", - "Flash\nFill" => "Remplissage instantané", + "Flash\nFill" => "Remplissage\ninstantané", "Flash Fill" => "Remplissage instantané", "Fill" => "Remplir", - "Clear Print Area" => "Effacer la zone d’impression", - "Find &\nSelect" => "Rechercher et sélectionner", + "Clear Print Area" => "Effacer la zone d'impression", + "Find &\nSelect" => "Rechercher et\nsélectionner", "Find…" => "Rechercher…", "Replace…" => "Remplacer…", "Go To…" => "Atteindre…", "Go To Special…" => "Atteindre spécial…", "PivotTable" => "Tableau croisé dynamique", - "Recommended\nCharts" => "Graphiques recommandés", + "Recommended\nCharts" => "Graphiques\nrecommandés", "Table" => "Tableau", "Pictures" => "Images", "Shapes" => "Formes", "Icons" => "Icônes", - "Text\nBox" => "Zone de texte", + "Text\nBox" => "Zone de\ntexte", "Comment" => "Commentaire", - "New\nComment" => "Nouveau commentaire", + "New\nComment" => "Nouveau\ncommentaire", "New Note" => "Nouvelle note", "Show/Hide Note" => "Afficher/masquer la note", - "Header &\nFooter" => "En-tête et pied de page", - "Text to\nColumns" => "Convertir", + "Header &\nFooter" => "En-tête et\npied de page", + "Text to\nColumns" => "Convertir le texte\nen colonnes", "Link" => "Lien", "Symbol" => "Symbole", - "Sparklines" => "Sparklines", - "Checkbox" => "Cliquez pour ajouter des données", - "Insert checkboxes in the selected cells" => "Mots-clés", + "Sparklines" => "Courbes de tendance", + "Checkbox" => "Case à cocher", + "Insert checkboxes in the selected cells" => "Insérer des cases à cocher dans les cellules sélectionnées", "Header & Footer" => "En-tête et pied de page", - "Draw with ink" => "Dessiner avec l’encre", + "Draw with ink" => "Dessiner à l'encre", "Eraser" => "Gomme", - "Ink to\nShape" => "Encre vers forme", - "Select\nObjects" => "Sélection d’objets", + "Ink to\nShape" => "Encre en\nforme", + "Select\nObjects" => "Sélectionner les\nobjets", "Themes" => "Thèmes", "Margins" => "Marges", "Size" => "Taille", - "Print\nArea" => "Zone d’impression", - "Set Print Area" => "Définir la zone d’impression", + "Print\nArea" => "Zone\nd'impression", + "Set Print Area" => "Définir la zone d'impression", "Breaks" => "Sauts", "Insert Page Break" => "Insérer un saut de page", "Remove Page Break" => "Supprimer le saut de page", "Reset All Page Breaks" => "Réinitialiser tous les sauts de page", - "Print\nTitles" => "Titres à imprimer", - "Sheet\nRight-to-Left" => "Afficher la barre de formule", - "Sheet Right-to-Left" => "Feuilles", - "Narrow" => "Étroites", - "Wide" => "Larges", - "Normal" => "Normales", + "Print\nTitles" => "Titres à\nimprimer", + "Sheet\nRight-to-Left" => "Feuille de\ndroite à gauche", + "Sheet Right-to-Left" => "Feuille de droite à gauche", + "Narrow" => "Étroit", + "Wide" => "Large", + "Normal" => "Normal", "Portrait" => "Portrait", "Landscape" => "Paysage", "Custom Margins…" => "Marges personnalisées…", - "Insert\nFunction" => "Insérer une fonction", - "Name\nManager" => "Gestionnaire de noms", + "Insert\nFunction" => "Insérer une\nfonction", + "Name\nManager" => "Gestionnaire\nde noms", "Define Name" => "Définir un nom", "Use in Formula" => "Utiliser dans la formule", "Create from Selection" => "Créer à partir de la sélection", @@ -150,90 +150,92 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Vérification des erreurs", "Evaluate Formula" => "Évaluation de formule", "Watch Window" => "Fenêtre Espion", - "Calculation\nOptions" => "Options de calcul", + "Calculation\nOptions" => "Options de\ncalcul", "Calculate Now" => "Calculer maintenant", "Calculate Sheet" => "Calculer la feuille", "Automatic" => "Automatique", "Automatic Except for Data Tables" => "Automatique sauf pour les tables de données", "Manual" => "Manuel", - "Sort &\nFilter" => "Trier et filtrer", + "Sort &\nFilter" => "Trier et\nfiltrer", "Sort" => "Trier", "Sort A to Z" => "Trier de A à Z", "Sort Z to A" => "Trier de Z à A", "Custom Sort…" => "Tri personnalisé…", "Filter" => "Filtrer", "Reapply" => "Réappliquer", - "Get Data\n(Text/CSV)" => "Obtenir des données (Texte/CSV)", - "Remove\nDuplicates" => "Supprimer les doublons", - "Data\nValidation" => "Validation des données", + "Get Data\n(Text/CSV)" => "Obtenir des données\n(Texte/CSV)", + "Remove\nDuplicates" => "Supprimer les\ndoublons", + "Data\nValidation" => "Validation des\ndonnées", "Data Validation" => "Validation des données", "Data Validation…" => "Validation des données…", "Circle Invalid Data" => "Cerner les données non valides", "Clear Validation" => "Effacer la validation", "Group" => "Grouper", "Ungroup" => "Dissocier", - "Show Detail" => "Titre", - "Hide Detail" => "Insérer les cellules copiées", + "Show Detail" => "Afficher le détail", + "Hide Detail" => "Masquer le détail", "Subtotal" => "Sous-total", - "Data\nAnalysis" => "Description", - "Data Analysis" => "Analyse\nde données", - "Solver" => "Page web (.html)", - "What-If\nAnalysis" => "Analyse scénarios", + "Data\nAnalysis" => "Analyse de\ndonnées", + "Data Analysis" => "Analyse de données", + "Solver" => "Solveur", + "What-If\nAnalysis" => "Analyse\nde scénarios", "Data Table…" => "Table de données…", "Scenario Manager…" => "Gestionnaire de scénarios…", "Goal Seek…" => "Valeur cible…", - "Forecast\nSheet" => "Aller au pied de page", + "Forecast\nSheet" => "Feuille de\nprévision", "Spelling" => "Orthographe", - "Check\nAccessibility" => "Vérifier l’accessibilité", - "Threaded Comments" => "Commentaires threadés", - "Show\nComments" => "Afficher les commentaires", - "Previous" => "Imprimer les feuilles actives", - "Next" => "Aucun classeur récent pour le moment.", - "Previous Comment" => "Imprimer tout le classeur", - "Next Comment" => "Aucun classeur n’est ouvert.", - "Show All Notes" => "Objet", - "Convert to Comments" => "Créer un PDF", - "Protect\nWorkbook" => "Protéger le classeur", - "Allow Edit\nRanges" => "Animations", - "Allow Edit Ranges" => "Autoriser la\nmodification des plages", - "Unprotect\nSheet" => "Ôter la protection de la feuille", + "Check\nAccessibility" => "Vérifier\nl'accessibilité", + "Threaded Comments" => "Commentaires en fil de discussion", + "Show\nComments" => "Afficher les\ncommentaires", + "Previous" => "Précédent", + "Next" => "Suivant", + "Previous Comment" => "Commentaire précédent", + "Next Comment" => "Commentaire suivant", + "Show All Notes" => "Afficher toutes les notes", + "Convert to Comments" => "Convertir en commentaires", + "Protect\nWorkbook" => "Protéger le\nclasseur", + "Allow Edit\nRanges" => "Autoriser la modification\ndes plages", + "Allow Edit Ranges" => "Autoriser la modification des plages", + "Unprotect\nSheet" => "Ôter la protection\nde la feuille", "Protect Sheet…" => "Protéger la feuille…", "Comments" => "Commentaires", "Notes" => "Notes", - "Page Break\nPreview" => "Aperçu des sauts de page", - "Page\nLayout" => "Mise en page", + "Page Break\nPreview" => "Aperçu des sauts\nde page", + "Page\nLayout" => "Mise en\npage", "Formula Bar" => "Barre de formule", "Gridlines" => "Quadrillage", "Headings" => "En-têtes", "Zoom" => "Zoom", - "100%" => "100%", - "Zoom to\nSelection" => "Zoom sur la sélection", - "Freeze\nPanes" => "Figer les volets", + "100%" => "100 %", + "Zoom to\nSelection" => "Zoom sur la\nsélection", + "Freeze\nPanes" => "Figer les\nvolets", "Freeze Panes" => "Figer les volets", "Freeze Top Row" => "Figer la ligne supérieure", "Freeze First Column" => "Figer la première colonne", "Unfreeze Panes" => "Libérer les volets", "Dark Mode" => "Mode sombre", - "Display theme" => "Thème d’affichage", - "Animations" => "Auteur", - "Glide the selection outline to the cell you move to instead of jumping there" => "Aller à l’en-tête", + "Display theme" => "Thème d'affichage", + "Animations" => "Animations", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Faire glisser le contour de la sélection jusqu'à la cellule de destination au lieu d'y sauter directement" + } "System" => "Système", "Light" => "Clair", "Dark" => "Sombre", - "Interface language" => "Langue de l’interface", - "Interface size" => "Emplacement", - "Auto" => "Retour", - "Drag to freeze columns" => "Faites glisser pour figer les lignes", - "Drag to freeze rows" => "Activer le calcul itératif", - "Command\nPalette" => "Palette de commandes", - "Agent\nControl" => "Contrôle de l’agent", - "Action\nJournal" => "Journal des actions", - "About\nGridCraft" => "À propos de GridCraft", + "Interface language" => "Langue de l'interface", + "Interface size" => "Taille de l'interface", + "Auto" => "Automatique", + "Drag to freeze columns" => "Faites glisser pour figer les colonnes", + "Drag to freeze rows" => "Faites glisser pour figer les lignes", + "Command\nPalette" => "Palette de\ncommandes", + "Agent\nControl" => "Contrôle de\nl'agent", + "Action\nJournal" => "Journal des\nactions", + "About\nGridCraft" => "À propos de\nGridCraft", "Share" => "Partager", - "Add Chart\nElement" => "Ajouter un élément de graphique", - "Change\nChart Type" => "Modifier le type de graphique", - "Switch\nRow/Column" => "Permuter ligne/colonne", - "Select\nData" => "Nom de la\nfeuille", + "Add Chart\nElement" => "Ajouter un élément\nde graphique", + "Change\nChart Type" => "Modifier le type\nde graphique", + "Switch\nRow/Column" => "Permuter\nligne/colonne", + "Select\nData" => "Sélectionner les\ndonnées", "Data Labels: None" => "Étiquettes de données : Aucune", "Data Labels: Show" => "Étiquettes de données : Afficher", "Legend: None" => "Légende : Aucune", @@ -242,10 +244,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Right" => "Légende : Droite", "Gridlines: None" => "Quadrillage : Aucun", "Gridlines: Show" => "Quadrillage : Afficher", - "Chart\nTitle" => "Titre du graphique", - "Delete\nChart" => "Supprimer le graphique", - "Convert\nto Range" => "Convertir en plage", - "Table\nStyles" => "Styles de tableau", + "Chart\nTitle" => "Titre du\ngraphique", + "Delete\nChart" => "Supprimer le\ngraphique", + "Convert\nto Range" => "Convertir\nen plage", + "Table\nStyles" => "Styles de\ntableau", "Top 10 Items" => "10 premiers éléments", "Top 10%" => "10 % les plus élevés", "Bottom 10 Items" => "10 derniers éléments", @@ -257,7 +259,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Constants" => "Constantes", "Data Bars" => "Barres de données", "Color Scales" => "Échelles de couleurs", - "Icon Sets" => "Jeux d’icônes", + "Icon Sets" => "Jeux d'icônes", "Home" => "Accueil", "Draw" => "Dessin", "Page Layout" => "Mise en page", @@ -291,96 +293,106 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More Number Formats…" => "Autres formats numériques…", "Financial" => "Financières", "Logical" => "Logiques", - "Date &\nTime" => "Date et heure", - "Lookup &\nReference" => "Recherche et référence", - "Math &\nTrig" => "Maths et trigonométrie", - "More\nFunctions" => "Autres fonctions", - "Sheet Options|View" => "Affichage", + "Date &\nTime" => "Date et\nheure", + "Lookup &\nReference" => "Recherche et\nréférence", + "Math &\nTrig" => "Maths et\ntrigonométrie", + "More\nFunctions" => "Autres\nfonctions", + "Sheet Options|View" => "Options de la feuille|Affichage", "Print" => "Impression", - "Click to add data" => "Fermer", - "Add header" => "Autoriser la modification des plages", - "Add footer" => "Ajouter un en-tête", + "Click to add data" => "Cliquez pour ajouter des données", + "Add header" => "Ajouter un en-tête", + "Add footer" => "Ajouter un pied de page", "(none)" => "(aucun)", - "Header" => "Informations", - "Footer" => "Feuille de\nprévision", - "Page\nNumber" => "Page précédente", - "Number\nof Pages" => "Général", - "Current\nDate" => "Heure\nactuelle", - "Current\nTime" => "Analyse de données", - "File\nPath" => "Ajuster la feuille sur une seule page", - "File\nName" => "Chemin\ndu fichier", - "Sheet\nName" => "Afficher le détail", - "Go to\nHeader" => "Masquer le détail", - "Go to\nFooter" => "En-tête", - "Page Number" => "Numéro\nde page", - "Number of Pages" => "Options", - "Current Date" => "Heure actuelle", - "Current Time" => "Date\nactuelle", - "File Path" => "Nom du\nfichier", - "File Name" => "Chemin du fichier", - "Sheet Name" => "Feuille de\ndroite à gauche", - "Go to Header" => "Aller à\nl’en-tête", - "Go to Footer" => "Aller au\npied de page", - "Ruler" => "Enregistrer sous", - "File" => "Nom du fichier", - "Back" => "Classeur vide", - "New" => "Page suivante", - "Open" => "Enregistrement", - "Info" => "Insérer les cellules copiées…", - "Save" => "Type de fichier", - "Save As" => "Enregistrer la feuille sous forme de document PDF en conservant sa mise en page.", - "Export" => "Fichier", - "Close" => "Société", - "Options" => "PDF (*.pdf)", - "Blank workbook" => "Parcourir…", - "Recent" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification", - "No recent workbooks yet." => "Rien à imprimer.", - "More workbooks" => "Commentaire suivant", - "Browse…" => "CSV (séparé par des virgules)", - "Not saved yet" => "Nombre\nde pages", - "Location" => "Nouveau", - "Sheets" => "Solveur", - "Protect Workbook" => "Enregistrer", - "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => "Afficher toutes les notes", - "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => "Suivant", - "Properties" => "Règle", + "Header" => "En-tête", + "Footer" => "Pied de page", + "Page\nNumber" => "Numéro de\npage", + "Number\nof Pages" => "Nombre\nde pages", + "Current\nDate" => "Date\nactuelle", + "Current\nTime" => "Heure\nactuelle", + "File\nPath" => "Chemin\ndu fichier", + "File\nName" => "Nom du\nfichier", + "Sheet\nName" => "Nom de la\nfeuille", + "Go to\nHeader" => "Aller à\nl'en-tête", + "Go to\nFooter" => "Aller au\npied de page", + "Page Number" => "Numéro de page", + "Number of Pages" => "Nombre de pages", + "Current Date" => "Date actuelle", + "Current Time" => "Heure actuelle", + "File Path" => "Chemin du fichier", + "File Name" => "Nom du fichier", + "Sheet Name" => "Nom de la feuille", + "Go to Header" => "Aller à l'en-tête", + "Different First Page" => "Première page différente", + "Different Odd & Even Pages" => "Pages paires et impaires différentes", + "Go to Footer" => "Aller au pied de page", + "Ruler" => "Règle", + "File" => "Fichier", + "Back" => "Retour", + "New" => "Nouveau", + "Open" => "Ouvrir", + "Info" => "Informations", + "Save" => "Enregistrer", + "Save As" => "Enregistrer sous", + "Export" => "Exporter", + "Close" => "Fermer", + "Options" => "Options", + "Blank workbook" => "Classeur vide", + "Recent" => "Récent", + "No recent workbooks yet." => "Aucun classeur récent pour le moment.", + "More workbooks" => "Plus de classeurs", + "Browse…" => "Parcourir…", + "Not saved yet" => "Pas encore enregistré", + "Location" => "Emplacement", + "Sheets" => "Feuilles", + "Protect Workbook" => "Protéger le classeur", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "La structure de la feuille est verrouillée : les feuilles ne peuvent pas être ajoutées, déplacées, renommées ou supprimées." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Verrouiller la structure des feuilles pour qu'elles ne puissent pas être ajoutées, déplacées, renommées ou supprimées." + } + "Properties" => "Propriétés", "Title" => "Titre", - "Subject" => "Calcul du classeur", - "Author" => "Automatique", - "Company" => "Convertir en commentaires", - "Keywords" => "Plus de classeurs", - "Description" => "Télécharger", - "No workbook is open." => "Nombre de pages", - "Save as type" => "Enregistrer le classeur sous forme de page web.", - "Excel Workbook (*.xlsx)" => "Exporter", - "CSV UTF-8 (Comma delimited) (*.csv)" => "Les paramètres de calcul sont enregistrés avec le classeur.", + "Subject" => "Sujet", + "Author" => "Auteur", + "Company" => "Société", + "Keywords" => "Mots-clés", + "Description" => "Description", + "No workbook is open." => "Aucun classeur n'est ouvert.", + "Save as type" => "Type de fichier", + "Excel Workbook (*.xlsx)" => "Classeur Excel (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (séparateur point-virgule) (*.csv)", "Web Page (*.html)" => "Page web (*.html)", - "PDF (*.pdf)" => "Page {page} sur {pages}", - "Download" => "Faites glisser pour figer les colonnes", - "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => "Ajouter un pied de page", - "Save the workbook under another name or in another folder." => "Feuille de droite à gauche", - "Print Active Sheets" => "Protéger le classeur", - "Print Entire Workbook" => "Récents", - "Fit Sheet on One Page" => "Pied de page", - "Page Setup…" => "Précédent", - "Page {page} of {pages}" => "Commentaire précédent", - "Nothing to print." => "Ouvrir", - "Previous page" => "Propriétés", - "Next page" => "Pas encore enregistré", - "Create PDF" => "Date actuelle", - "Save the sheet as a PDF document that keeps its page layout." => "Sélectionner\nles données", - "Change File Type" => "Case à cocher", - "CSV (Comma delimited)" => "CSV UTF-8 (séparé par des virgules) (*.csv)", - "Save the active sheet as comma-separated text." => "Enregistrer le classeur sous un autre nom ou dans un autre dossier.", + "PDF (*.pdf)" => "PDF (*.pdf)", + "Download" => "Télécharger", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { + "Un fichier CSV, une page web ou un PDF est une copie : le classeur conserve son propre fichier." + } + "Save the workbook under another name or in another folder." => "Enregistrer le classeur sous un autre nom ou dans un autre dossier.", + "Print Active Sheets" => "Imprimer les feuilles actives", + "Print Entire Workbook" => "Imprimer tout le classeur", + "Fit Sheet on One Page" => "Ajuster la feuille sur une page", + "Page Setup…" => "Mise en page…", + "Page {page} of {pages}" => "Page {page} sur {pages}", + "Nothing to print." => "Rien à imprimer.", + "Previous page" => "Page précédente", + "Next page" => "Page suivante", + "Create PDF" => "Créer un PDF", + "Save the sheet as a PDF document that keeps its page layout." => { + "Enregistrer la feuille en tant que document PDF qui conserve sa mise en page." + } + "Change File Type" => "Changer le type de fichier", + "CSV (Comma delimited)" => "CSV (séparateur point-virgule)", + "Save the active sheet as comma-separated text." => "Enregistrer la feuille active sous forme de texte séparé par des points-virgules.", "Web Page (.html)" => "Page web (.html)", - "Save the workbook as a web page." => "Nom de la feuille", - "Options|General" => "Numéro de page", - "Options|Save" => "Mise en page…", - "Show formula bar" => "Page web (*.html)", - "Save .xlsx workbooks automatically after every change" => "Enregistrer la feuille active sous forme de texte séparé par des virgules.", + "Save the workbook as a web page." => "Enregistrer le classeur en tant que page web.", + "Options|General" => "Options|Général", + "Options|Save" => "Options|Enregistrer", + "Show formula bar" => "Afficher la barre de formule", + "Save .xlsx workbooks automatically after every change" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification", "Workbook calculation" => "Calcul du classeur", - "Enable iterative calculation" => "Classeur Excel (*.xlsx)", - "Calculation settings are saved with the workbook." => "Modifier le type de fichier", + "Enable iterative calculation" => "Activer le calcul itératif", + "Calculation settings are saved with the workbook." => "Les paramètres de calcul sont enregistrés avec le classeur.", _ => return None, }) } From 587d879f13f82af162af59569a336ae63c4b34c6 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 03:25:47 +0200 Subject: [PATCH 008/162] Print: adopt craft-print with native settings and job feedback --- Cargo.lock | 18 ++++ ROADMAP.md | 4 +- apps/gridcraft/Cargo.toml | 1 + apps/gridcraft/src/main.rs | 70 ++------------- apps/gridcraft/src/print.rs | 75 ++++++++++++++++ crates/engine/src/io.rs | 29 +++++- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/backstage.rs | 12 ++- crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/lib.rs | 78 ++++++++++++---- crates/ui-egui/src/printing.rs | 98 +++++++++++++++++++++ crates/ui-egui/tests/native_print.rs | 127 +++++++++++++++++++++++++++ docs/control-protocol.md | 12 +++ docs/gaps.md | 5 +- docs/hardware-parity.md | 5 +- 15 files changed, 449 insertions(+), 88 deletions(-) create mode 100644 apps/gridcraft/src/print.rs create mode 100644 crates/ui-egui/src/printing.rs create mode 100644 crates/ui-egui/tests/native_print.rs diff --git a/Cargo.lock b/Cargo.lock index 2dec069..f924b94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -742,6 +742,22 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +dependencies = [ + "tempfile", +] + +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +dependencies = [ + "egui", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1483,6 +1499,7 @@ name = "gridcraft" version = "0.5.0" dependencies = [ "arboard", + "craft-print", "eframe", "egui", "fmv-macos-events", @@ -1616,6 +1633,7 @@ version = "0.5.0" name = "gridcraft-ui-egui" version = "0.5.0" dependencies = [ + "craft-ui", "egui", "egui_kittest", "epaint_default_fonts", diff --git a/ROADMAP.md b/ROADMAP.md index fbe48d3..e8e1c06 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 (password-encrypted workbooks open, #11) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | | 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | @@ -177,6 +178,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 94dc2b0..c867d24 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -24,6 +24,7 @@ image = { workspace = true } winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } rfd = { workspace = true } arboard = { workspace = true } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 2f67466..8093205 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -16,6 +16,7 @@ mod graphics; mod logging; #[cfg(target_os = "macos")] mod native_menu; +mod print; use std::cell::RefCell; use std::rc::Rc; @@ -39,6 +40,10 @@ impl eframe::App for App { apple_events::poll(&self.2, &mut self.0, ctx); } self.0.logic(ctx); + self.0.poll_printing(ctx); + if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(raw); @@ -53,6 +58,7 @@ impl eframe::App for App { self.0.ui(ui); } fn on_exit(&mut self) { + print::finish_pending_jobs(); save_prefs(&self.0); // macOS (#117): end the process here, before eframe tears the window down. AccessKit's // macOS adapter subclasses the window's content view and restores its class on teardown; @@ -180,12 +186,8 @@ fn services() -> Services { dialog.save_file().and_then(|p| p.to_str().map(str::to_string)) })), open_url: Some(Box::new(|url: &str| open_with_system(url))), - // File › Print, Ctrl+P: the print PDF opens in the system's PDF viewer, whose Print - // command reaches the printer dialog (GridCraft has no printer driver of its own). - print: Some(Box::new(|bytes: &[u8]| match write_print_pdf(&std::env::temp_dir(), bytes) { - Ok(path) => open_with_system(&path.to_string_lossy()), - Err(e) => log::warn!("print: {e}"), - })), + printers: Some(print::discover()), + print_native: Some(print::submit()), ..Default::default() } } @@ -204,32 +206,6 @@ fn open_with_system(target: &str) { } } -/// Writes a print PDF into `dir` under a fresh name (an earlier print may still be open in the -/// viewer) and returns its path. The temp folder is shared, so the file is created exclusively -/// (an existing file or a planted symlink is never followed or overwritten) and, on Unix, readable -/// only by the user. -fn write_print_pdf(dir: &std::path::Path, bytes: &[u8]) -> std::io::Result { - use std::io::Write; - static PRINTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - for _ in 0..64 { - let n = PRINTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1); - let path = dir.join(format!("GridCraft print {}-{n}.pdf", std::process::id())); - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); - match options.open(&path) { - Ok(mut file) => { - file.write_all(bytes)?; - return Ok(path); - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(e), - } - } - Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free print file name")) -} - fn icon() -> Option { #[cfg(target_os = "macos")] let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png"); @@ -393,33 +369,3 @@ fn main() -> eframe::Result<()> { } result } - -#[cfg(test)] -mod print_tests { - #[test] - fn print_pdfs_get_fresh_names_in_the_given_folder() { - let dir = std::env::temp_dir().join(format!("gridcraft-print-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let a = super::write_print_pdf(&dir, b"%PDF-1.4 a").unwrap(); - let b = super::write_print_pdf(&dir, b"%PDF-1.4 b").unwrap(); - assert_ne!(a, b); - assert!(a.starts_with(&dir) && a.extension().is_some_and(|e| e == "pdf")); - assert_eq!(std::fs::read(&b).unwrap(), b"%PDF-1.4 b"); - assert!(super::write_print_pdf(&dir.join("missing"), b"x").is_err()); - // A file (or symlink) already at the next name is skipped, never overwritten. - let pid = std::process::id(); - let taken: Vec<_> = (1..=200).map(|n| dir.join(format!("GridCraft print {pid}-{n}.pdf"))).filter(|p| !p.exists()).take(3).collect(); - for p in &taken { - std::fs::write(p, b"keep").unwrap(); - } - let c = super::write_print_pdf(&dir, b"%PDF-1.4 c").unwrap(); - assert!(taken.iter().all(|p| std::fs::read(p).unwrap() == b"keep")); - assert_eq!(std::fs::read(&c).unwrap(), b"%PDF-1.4 c"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!(std::fs::metadata(&c).unwrap().permissions().mode() & 0o077, 0, "only the user can read it"); - } - let _ = std::fs::remove_dir_all(&dir); - } -} diff --git a/apps/gridcraft/src/print.rs b/apps/gridcraft/src/print.rs new file mode 100644 index 0000000..09bb801 --- /dev/null +++ b/apps/gridcraft/src/print.rs @@ -0,0 +1,75 @@ +//! Desktop spooler adapter. Discovery and submission never run on the UI thread. +use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit}; + +pub fn discover() -> Discover { + Box::new(|| { + let (sender, receiver) = std::sync::mpsc::channel(); + let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || { + let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect(); + let _ = sender.send(printers); + }); + receiver + }) +} + +// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would +// let process exit kill PowerShell/CUPS before it hands the job to the spooler. +static JOBS: std::sync::Mutex>> = std::sync::Mutex::new(Vec::new()); + +pub fn finish_pending_jobs() { + let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner())); + for worker in workers { + if worker.join().is_err() { + log::error!("the print worker stopped unexpectedly during shutdown"); + } + } +} + +pub fn submit() -> Submit { + Box::new(|bytes, settings: Settings| { + let (sender, receiver) = std::sync::mpsc::channel(); + let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || { + let job = craft_print::Job { + printer: Some(settings.printer), + copies: settings.copies.clamp(1, 999), + duplex: settings.duplex.map(|value| match value { + Duplex::LongEdge => craft_print::Duplex::LongEdge, + Duplex::ShortEdge => craft_print::Duplex::ShortEdge, + Duplex::Off => craft_print::Duplex::Off, + }), + grayscale: settings.grayscale, + scaling: craft_print::Scaling::ShrinkToFit, + title: "GridCraft document".into(), + ..Default::default() + }; + let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string())); + }); + if let Ok(worker) = worker { + let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner()); + jobs.retain(|job| !job.is_finished()); + jobs.push(worker); + } + receiver + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn shutdown_waits_for_pending_submission_workers() { + let (release, wait) = std::sync::mpsc::channel(); + let (completed, completion) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + wait.recv().unwrap(); + completed.send(()).unwrap(); + }); + JOBS.lock().unwrap().push(worker); + let shutdown = std::thread::spawn(finish_pending_jobs); + assert!(completion.try_recv().is_err(), "the print worker is still pending"); + release.send(()).unwrap(); + shutdown.join().unwrap(); + completion.recv().unwrap(); + assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index e613611..bc7011d 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -175,7 +175,18 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { /// characters in each cell" (Microsoft Support, *Excel formatting and features that are not /// transferred to other file formats*): number formats applied, never column-width `#####`. fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec { - gridcraft_xlsx::write_csv(sh, delimiter, |c| crate::display::value_text(wb, sh, c)) + gridcraft_xlsx::write_csv(sh, delimiter, |c| { + // General's grid-width formatting rounds large identifiers to scientific notation. + // Delimited files have no column width, so preserve the whole numeric value instead. + if wb.styles.get(sh.style_id(c)).num_fmt.as_str().eq_ignore_ascii_case("General") + && let gridcraft_core::Value::Number(number) = sh.value(c) + && number.is_finite() + { + number.to_string() + } else { + crate::display::value_text(wb, sh, c) + } + }) } fn esc(s: &str) -> String { @@ -568,6 +579,22 @@ mod tests { assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}"); } + #[test] + fn delimited_exports_preserve_general_precision_and_explicit_number_formats() { + use gridcraft_core::Value; + use gridcraft_model::{Cell, NumFmt, StyleId}; + let mut workbook = Workbook::new(); + let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00")); + let sheet = workbook.sheet_mut(0).unwrap(); + sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() }); + sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() }); + sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() }); + for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] { + let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap(); + assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n")); + } + } + #[test] fn template_extension_saves_template_content_type() { let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap(); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index bfce89e..85a24dd 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,6 +9,7 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b workspace = true [dependencies] +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } gridcraft-engine = { workspace = true } gridcraft-chart = { workspace = true } egui = { workspace = true } diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 824b4da..ae21554 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -570,9 +570,15 @@ fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.set_width(250.0); - if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) { - app.run_or_alert("file.print", params.clone()); - out.close = true; + if let Some(discover) = &app.services.printers { + crate::printing::show(&mut app.printing, ui, discover, |label| lang.tr(label).to_owned()); + } + let can_print = app.services.print_native.is_none() || app.printing.ready(); + if ui.add_enabled_ui(can_print, |ui| square_button(ui, Icon::Print, lang.tr("Print"))).inner.clicked() + && app.commit_edit(0, 0, false, false) + { + app.print_now(params.clone()); + out.close = app.services.print_native.is_none(); } ui.add_space(16.0); let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" }; diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index fdf1f89..7bf9fd1 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -2,6 +2,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { + "Different First Page" => "Première page différente", + "Different Odd & Even Pages" => "Pages paires et impaires différentes", "Paste" => "Coller", "Paste Values" => "Coller les valeurs", "Paste Formulas" => "Coller les formules", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec29139..0b19b25 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,7 @@ pub mod outline; pub mod panes; pub mod paper_view; pub mod pivot_pane; +pub mod printing; pub mod ribbon; pub mod selection_glide; pub mod tabs; @@ -159,6 +160,10 @@ pub struct Notice { /// Host services the platform shell provides (file pickers, opening URLs). #[derive(Default)] pub struct Services { + /// Desktop: discover configured printers on a worker. + pub printers: Option, + /// Desktop: submit the PDF with the chosen settings on a worker. + pub print_native: Option, pub pick_open: Option Option>>, pub pick_save: Option Option>>, /// Native picture picker (PNG/JPEG), separate from workbook opening. @@ -210,6 +215,8 @@ pub struct SheetApp { /// Dismissible info bar under the ribbon (open-time notices). pub notice: Option, pub services: Services, + pub printing: printing::State, + print_feedback_seen: Option, /// Synthetic input from agents, one batch per frame (drags need several frames). pub synthetic: std::collections::VecDeque>, pub control_rx: Option>, @@ -256,6 +263,8 @@ impl SheetApp { toast: None, notice: None, services, + printing: Default::default(), + print_feedback_seen: None, synthetic: Default::default(), control_rx: None, grid: grid::GridState { @@ -404,8 +413,12 @@ impl SheetApp { } 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). + // A menu/shortcut opens the settings first; the Print page's button submits explicitly. if id == "file.print" { + if self.services.print_native.is_some() { + self.open_backstage(backstage::Page::Print); + return; + } return self.print_now(params); } if let Err(e) = self.run(id, params) { @@ -422,29 +435,39 @@ 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). + /// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the + /// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible + /// on the Print page. The engine command still returns PDF bytes without submitting. 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), + if let Err(error) = self.submit_print(params) { + self.alert(error); } } + fn submit_print(&mut self, params: Json) -> Result { + if self.services.print_native.is_some() && !self.printing.ready() { + return Err("Select an available printer in File > Print before submitting.".into()); + } + let pdf = self.run("file.print", params)?; + let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?; + let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?; + let len = bytes.len(); + if let Some(submit) = &self.services.print_native { + self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?; + self.print_feedback_seen = None; + } else if let Some(print) = &self.services.print { + print(&bytes); + } else { + return Err("Printing is unavailable here; export a PDF instead.".into()); + } + Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()})) + } + /// 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 { + "ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()), + "ui.printSubmit" => self.submit_print(p.clone()), "ui.ribbonTab" => { self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string(); Ok(Json::Null) @@ -834,6 +857,26 @@ impl SheetApp { self.grid.ensure_visible = true; } + /// Terminal feedback also reaches the normal window when printing starts through control. + pub fn poll_printing(&mut self, ctx: &egui::Context) { + self.printing.poll(ctx); + if self.printing.message == self.print_feedback_seen { + return; + } + self.print_feedback_seen = self.printing.message.clone(); + let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) = + self.printing.message.clone() + else { + return; + }; + let text = printing::feedback(&message, |label| self.ui.language.tr(label).to_owned()); + if matches!(message, printing::Feedback::Submitted(_)) { + self.toast = Some((text, now_ms())); + } else { + self.alert(text); + } + } + /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { // A keytip control request lives one frame: the ribbon consumes it during `ui`, and @@ -939,6 +982,7 @@ impl SheetApp { /// Lays out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); + self.poll_printing(&ctx); if !self.fonts_ready || self.fonts_rebind { ctx.request_repaint(); return; diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs new file mode 100644 index 0000000..7c34120 --- /dev/null +++ b/crates/ui-egui/src/printing.rs @@ -0,0 +1,98 @@ +//! Localized print-page wording around the shared, backend-independent controls. +use craft_ui::printing::Labels; +pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State, Submit}; + +pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) { + let labels = Labels { + printer: &translate("Printer"), + select_printer: &translate("Select a printer"), + refresh: &translate("Refresh printers"), + copies: &translate("Copies"), + duplex: &translate("Two-sided printing"), + printer_default: &translate("Printer default"), + one_sided: &translate("One-sided"), + long_edge: &translate("Two-sided: long edge"), + short_edge: &translate("Two-sided: short edge"), + grayscale: &translate("Grayscale"), + }; + state.show(ui, egui::Id::new("gridcraft_print_settings"), discover, &labels); + if let Some(message) = &state.message { + ui.label(feedback(message, translate)); + } +} + +/// Read print settings/status, or validate an explicit update before changing anything. +/// `refresh: true` starts discovery only; this command never submits a job or opens a dialog. +pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option<&Discover>) -> Result { + use serde_json::{Value, json}; + let object = params.as_object().ok_or("print settings must be an object")?; + if object.keys().any(|key| !["printer", "copies", "duplex", "grayscale", "refresh"].contains(&key.as_str())) { + return Err("unknown print setting; use printer, copies, duplex, grayscale or refresh".into()); + } + if state.busy() && !object.is_empty() { + return Err("wait for the active print submission before changing settings".into()); + } + let mut next = state.settings.clone(); + if let Some(value) = object.get("printer") { + let printer = value.as_str().ok_or("printer must be a name from the discovered list")?; + if !state.printers.iter().any(|available| available.name == printer) { + return Err("printer must be a name from the discovered list".into()); + } + next.printer = printer.to_owned(); + } + if let Some(value) = object.get("copies") { + let copies = value.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")?; + next.copies = copies as u32; + } + if let Some(value) = object.get("duplex") { + next.duplex = match value.as_str() { + Some("printerDefault") => None, + Some("off") => Some(Duplex::Off), + Some("longEdge") => Some(Duplex::LongEdge), + Some("shortEdge") => Some(Duplex::ShortEdge), + None if value.is_null() => None, + _ => return Err("duplex must be printerDefault, off, longEdge or shortEdge".into()), + }; + } + if let Some(value) = object.get("grayscale") { + next.grayscale = value.as_bool().ok_or("grayscale must be a boolean")?; + } + let refresh = object.get("refresh").map(|value| value.as_bool().ok_or("refresh must be a boolean")).transpose()?.unwrap_or(false); + if refresh { + let discover = discover.ok_or("native printer discovery is unavailable here")?; + if !state.refresh(discover) { + return Err("printer discovery or submission is already active".into()); + } + } + state.settings = next; + let (status, detail) = match state.message.as_ref() { + None => ("idle", ""), + Some(Feedback::FindingPrinters) => ("findingPrinters", ""), + Some(Feedback::NoPrinters) => ("noPrinters", ""), + Some(Feedback::DiscoveryFailed) => ("discoveryFailed", ""), + Some(Feedback::Submitting) => ("submitting", ""), + Some(Feedback::Submitted(receipt)) => ("submitted", receipt.as_str()), + Some(Feedback::Failed(error)) => ("failed", error.as_str()), + Some(Feedback::WorkerStopped) => ("workerStopped", ""), + }; + Ok(json!({ + "printer": state.settings.printer, "copies": state.settings.copies, + "duplex": match state.settings.duplex { None => "printerDefault", Some(Duplex::Off) => "off", Some(Duplex::LongEdge) => "longEdge", Some(Duplex::ShortEdge) => "shortEdge" }, + "grayscale": state.settings.grayscale, + "printers": state.printers.iter().map(|printer| json!({"name": printer.name, "default": printer.default})).collect::>(), + "busy": state.busy(), "discovering": state.discovering(), "ready": state.ready(), "status": status, "detail": detail, + })) +} + +pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String { + match message { + Feedback::FindingPrinters => translate("Finding printers…"), + Feedback::NoPrinters => translate("No printers found. Configure a system printer or export a PDF."), + Feedback::DiscoveryFailed => translate("Printer discovery failed."), + Feedback::Submitting => translate("Submitting print job…"), + Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."), + Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt), + Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error), + Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."), + } +} diff --git a/crates/ui-egui/tests/native_print.rs b/crates/ui-egui/tests/native_print.rs new file mode 100644 index 0000000..7875dc0 --- /dev/null +++ b/crates/ui-egui/tests/native_print.rs @@ -0,0 +1,127 @@ +//! Native print onboarding preserves engine PDF generation and submits the selected settings. +use gridcraft_engine::Session; +use gridcraft_ui_egui::printing::{Duplex, Feedback, Printer}; +use gridcraft_ui_egui::{Services, SheetApp}; +use serde_json::json; +use std::{cell::RefCell, rc::Rc}; + +#[test] +fn native_print_uses_selected_settings_and_reports_spool_errors() { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Services::default()); + app.run("cell.set", json!({"cell":"A1", "input":"A sheet to print"})).unwrap(); + let jobs = Rc::new(RefCell::new(Vec::new())); + let observed = jobs.clone(); + app.services.print_native = Some(Box::new(move |bytes, settings| { + observed.borrow_mut().push((bytes, settings)); + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Err("printer is offline".into())).unwrap(); + receiver + })); + app.run_or_alert("file.print", json!({})); + assert!(app.backstage.is_some(), "a shortcut opens settings without submitting"); + let pdf = app.run("file.print", json!({"sheets":"active"})).unwrap(); + assert!(pdf["base64"].as_str().is_some(), "the engine still returns PDF bytes without submitting"); + assert!(jobs.borrow().is_empty(), "opening settings/exporting does not spool"); + app.printing.printers.push(Printer { name: "Chosen queue".into(), default: false }); + let configured = app.run("ui.printSettings", json!({"printer":"Chosen queue", "copies":3,"duplex":"shortEdge","grayscale":true})).unwrap(); + assert_eq!(configured["ready"], true); + assert_eq!(configured["printers"][0]["name"], "Chosen queue"); + app.run("ui.printSubmit", json!({"sheets":"active"})).unwrap(); + assert!(app.printing.busy()); + let jobs = jobs.borrow(); + assert_eq!(jobs.len(), 1); + assert!(jobs[0].0.starts_with(b"%PDF")); + assert_eq!(jobs[0].1.printer, "Chosen queue"); + assert_eq!(jobs[0].1.copies, 3); + assert_eq!(jobs[0].1.duplex, Some(Duplex::ShortEdge)); + assert!(jobs[0].1.grayscale); + app.poll_printing(&egui::Context::default()); + assert_eq!(app.printing.message, Some(Feedback::Failed("printer is offline".into()))); + assert!(app.message.as_ref().unwrap().1.contains("printer is offline")); +} + +#[test] +fn print_settings_are_visible_in_a_narrow_window() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Services::default()); + app.run("cell.set", json!({"cell":"A1", "input":"A sheet to print"})).unwrap(); + app.services.printers = Some(Box::new(|| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap(); + receiver + })); + app.services.print_native = Some(Box::new(|_, _| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Ok("queued".into())).unwrap(); + receiver + })); + app.run("ui.backstage", json!({"page":"print"})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 700.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(5); + assert!(!harness.get_by_label("Refresh printers").accesskit_node().is_disabled()); + assert!(!harness.get_by_label("Grayscale").accesskit_node().is_disabled()); + harness.get_by_label("Grayscale").click(); + harness.run_steps(2); + assert!(harness.state().printing.settings.grayscale); + assert!(harness.state().printing.ready()); +} + +#[test] +fn invalid_print_settings_are_rejected_without_partial_changes() { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Services::default()); + app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + let before = app.run("ui.printSettings", json!({})).unwrap(); + for params in [ + json!({"printer":"Missing"}), + json!({"copies":0}), + json!({"copies":1000}), + json!({"copies":1.5}), + json!({"copies":-1}), + json!({"copies":"3"}), + json!({"duplex":"maybe"}), + json!({"grayscale":"yes"}), + json!({"refresh":"yes"}), + json!({"copies":3,"duplex":"invalid"}), + json!([]), + ] { + assert!(app.run("ui.printSettings", params.clone()).is_err(), "{params}"); + assert_eq!(app.run("ui.printSettings", json!({})).unwrap(), before, "no partial update: {params}"); + } +} + +#[test] +fn repeated_fast_jobs_each_surface_an_identical_receipt() { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Services::default()); + app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap(); + app.services.print_native = Some(Box::new(|_, _| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Ok("same receipt".into())).unwrap(); + receiver + })); + for _ in 0..2 { + app.toast = None; + app.run("ui.printSubmit", json!({})).unwrap(); + // Completion may be consumed by the shared widget before the app's logic pass. + app.printing.poll(&egui::Context::default()); + app.poll_printing(&egui::Context::default()); + assert!(app.toast.as_ref().unwrap().0.contains("same receipt")); + app.toast = None; + app.poll_printing(&egui::Context::default()); + assert!(app.toast.is_none()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a16208a..347508e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -65,3 +65,15 @@ the password, and never replaces the encrypted original. For a headless equivalent (no window), use `gridcraft-cli mcp` or `gridcraft-cli run`; see [`mcp.md`](mcp.md) and [`cli.md`](cli.md). + +### Native printing + +`engine.execute` can call `ui.printSettings` with `{}` to inspect the printer list, selected +printer, copies, duplex, grayscale, discovery/submission status and backend receipt/error. +`{"refresh":true}` starts background discovery; poll the same command until `discovering` is +false. Set `printer` to a discovered name, integer `copies` from 1 to 999, `duplex` to +`printerDefault`, `off`, `longEdge` or `shortEdge`, and boolean `grayscale`. Invalid updates are +rejected without changing earlier settings. `ui.printSubmit` explicitly submits the current +print-ready PDF without opening a dialog; inspect `ui.printSettings` until `busy` becomes false +and check `status`/`detail`. The browser keeps its own print hook. PDF export remains separate. +GridCraft accepts the existing `file.print` sheet/pagination params on `ui.printSubmit`; the engine `file.print` command returns the PDF without spooling. diff --git a/docs/gaps.md b/docs/gaps.md index 09870ba..17a56b8 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 (§6: iterative calculation landed) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **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 @@ -111,7 +111,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging - margins on the ruler; web print exports only (#141, PR #143). **Estimate:** 5–10 h. + margins on the ruler. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. ### 15. Remaining functions and an oracle suite (F) - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. @@ -160,6 +160,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | | 2026-10-11 | minor | §4: trendlines (six types, forecast, equation and R²) read, written and drawn | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 9d2c5e7..3f9135c 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 DirectX 12 shaders with FXC, #339) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **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). @@ -14,12 +14,13 @@ The hardware Excel uses, per platform, against GridCraft. Part of | 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 | | High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — | | Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) | -| Printers | System print with driver options | Desktop prints through the system dialog via PDF; web exports only | Counted under print | +| Printers | System print with driver options | Desktop submits through craft-print with printer choice, copies, duplex and grayscale; web browser printing | Counted under print | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | 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 | From fb7bcefaa49ef297d521c593f9cf7db2acbfaeda Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:25:39 +0000 Subject: [PATCH 009/162] OFFSET: huge offsets and sizes give #REF! instead of overflowing --- crates/calc/src/eval.rs | 8 +++---- crates/calc/src/tests.rs | 29 ++++++++++++++++++++++++++ crates/engine/tests/offset_overflow.rs | 24 +++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/offset_overflow.rs diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..caa22ae 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1054,10 +1054,10 @@ impl<'h> Evaluator<'h> { if h == 0 || w == 0 { return err(CellError::Ref); } - let r0 = base.range.start.row as i64 + rows; - let c0 = base.range.start.col as i64 + cols; - let (r0, r1) = if h > 0 { (r0, r0 + h - 1) } else { (r0 + h + 1, r0) }; - let (c0, c1) = if w > 0 { (c0, c0 + w - 1) } else { (c0 + w + 1, c0) }; + let r0 = (base.range.start.row as i64).saturating_add(rows); + let c0 = (base.range.start.col as i64).saturating_add(cols); + let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) }; + let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) }; if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 { return err(CellError::Ref); } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..c0579e9 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -968,3 +968,32 @@ fn trim_references() { t.set("K20", "=SUM(Q1:.Q9)"); assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); } + +#[test] +fn offset_extreme_arguments_return_ref() { + let wb = Workbook::new(); + for formula in [ + "=OFFSET(B2,1E300,0)", + "=OFFSET(B2,0,1E300)", + "=OFFSET(B2,0,0,1E300,1)", + "=OFFSET(B2,0,0,1,1E300)", + "=OFFSET(B2,-1E300,0)", + "=OFFSET(B2,0,-1E300)", + "=OFFSET(B2,0,0,-1E300,1)", + "=OFFSET(B2,0,0,1,-1E300)", + "=OFFSET(B2,1E300,0,-1E300,1)", + "=OFFSET(B2,0,1E300,1,-1E300)", + "=OFFSET(B2,-1E300,0,-1E300,1)", + ] { + assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Error(CellError::Ref), "{formula}"); + } + for (formula, expected) in [ + ("=ROW(OFFSET(B2,1.9,0))", 3.0), + ("=COLUMN(OFFSET(B2,0,-1.9))", 1.0), + ("=ROWS(OFFSET(B2,0,0,-2,1))", 2.0), + ("=COLUMNS(OFFSET(B2,0,0,1,-2))", 2.0), + ("=ROWS(OFFSET(A1,0,0,1048576,1))", 1048576.0), + ] { + assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}"); + } +} diff --git a/crates/engine/tests/offset_overflow.rs b/crates/engine/tests/offset_overflow.rs new file mode 100644 index 0000000..de59888 --- /dev/null +++ b/crates/engine/tests/offset_overflow.rs @@ -0,0 +1,24 @@ +use gridcraft_core::{CellError, CellRef, Value}; +use gridcraft_engine::Session; +use serde_json::json; + +#[test] +fn cell_set_offset_extreme_arguments_return_ref() { + let mut session = Session::new(); + session.new_workbook(); + for formula in [ + "=OFFSET(B2,1E300,0)", + "=OFFSET(B2,0,1E300)", + "=OFFSET(B2,0,0,1E300,1)", + "=OFFSET(B2,0,0,1,1E300)", + "=OFFSET(B2,-1E300,0)", + "=OFFSET(B2,0,-1E300)", + "=OFFSET(B2,0,0,-1E300,1)", + "=OFFSET(B2,0,0,1,-1E300)", + "=OFFSET(B2,1E300,0,-1E300,1)", + "=OFFSET(B2,0,1E300,1,-1E300)", + ] { + session.execute("cell.set", json!({"cell": "D1", "input": formula})).unwrap(); + assert_eq!(session.doc().unwrap().wb.sheet(0).unwrap().value(CellRef::new(0, 3)), Value::Error(CellError::Ref), "{formula}"); + } +} From fa2b9566fb3db91852b57e92965144f0afc8cd31 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:09:10 -0400 Subject: [PATCH 010/162] Data: Solver Evolutionary method Builds on the Solver of #327. method: evolutionary runs a genetic search for models that aren't smooth: tournament selection, blend crossover and Gaussian mutation inside the variable bounds, feasibility-first ranking and integer rounding, then a compass search around the best member. It stops when the population converges, when the best member stops improving for a number of generations, or at the evaluation budget. A seeded generator makes runs repeatable. Population size, mutation rate, random seed, convergence and Require Bounds are options; the method and its options are saved in the hidden solver_* names. The Solver Parameters dialog offers the method. --- crates/engine/src/cmd/solver.rs | 428 +++++++++++++++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 16 +- docs/gaps.md | 7 +- docs/target-app-parity.md | 2 +- 4 files changed, 436 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index 5759f54..cd3da0e 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -14,6 +14,12 @@ //! projection. It finds a local optimum of a smooth model; integer constraints use a small //! branch and bound on top. //! +//! - **Evolutionary**: a genetic search for models that aren't smooth (IF, lookups, rounding), +//! with tournament selection, blend crossover and Gaussian mutation inside the variable bounds, +//! feasibility-first ranking and integer rounding, followed by a compass (pattern) search around +//! the best member. It stops when the population has converged, when the best member stops +//! improving, or at the evaluation budget. +//! //! Work is bounded by an evaluation budget (no clock: `Instant` traps on wasm). The model is //! saved as hidden sheet-scoped names (`solver_opt`, `solver_adj`, `solver_lhs1`…), so it //! travels with the workbook through XLSX. @@ -36,6 +42,9 @@ const MAX_EVALS: u64 = 200_000; const LP_WORK: u64 = 2_000_000_000; const MAX_NODES_LP: usize = 10_000; const MAX_NODES_NLP: usize = 200; +/// Evolutionary population sizes. +const MIN_POPULATION: usize = 10; +const MAX_POPULATION: usize = 1000; /// Constraint precision (Excel's default 0.000001). const PRECISION: f64 = 1e-6; const EPS: f64 = 1e-9; @@ -46,7 +55,7 @@ pub fn specs() -> Vec { "Solver…", ["Data", "Analysis"], None, - "{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex (default grg), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden solver_* names), maxEvaluations?: 20000, integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override)} → {status: optimal|converged|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, evaluations, changed}. No `objective` → {model: the saved model} (the menu opens the dialog).", + "{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden solver_* names), maxEvaluations?: 20000, integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, evaluations, changed}. No `objective` → {model: the saved model} (the menu opens the dialog).", has_doc, run )] @@ -132,6 +141,38 @@ enum Goal { enum Method { Simplex, Grg, + Evolutionary, +} + +/// The Evolutionary method's options (Solver Options › Evolutionary). The search keeps no +/// clock, so the time without improvement is counted in generations. +#[derive(Clone, Copy, Debug, PartialEq)] +struct EvoOptions { + population: usize, + mutation: f64, + seed: u64, + convergence: f64, + stall: u64, + require_bounds: bool, +} + +impl Default for EvoOptions { + fn default() -> EvoOptions { + EvoOptions { population: 100, mutation: 0.075, seed: 0, convergence: 1e-4, stall: 50, require_bounds: true } + } +} + +fn evo_options(p: &Json) -> EvoOptions { + let d = EvoOptions::default(); + let num = |k: &str| p.get(k).and_then(Json::as_f64).or_else(|| str_param(p, k).and_then(num_text)).filter(|v| v.is_finite()); + EvoOptions { + population: num("populationSize").map_or(d.population, |v| v.clamp(MIN_POPULATION as f64, MAX_POPULATION as f64) as usize), + mutation: num("mutationRate").map_or(d.mutation, |v| v.clamp(0.0, 1.0)), + seed: num("randomSeed").map_or(d.seed, |v| v.clamp(0.0, f64::from(u32::MAX)) as u64), + convergence: num("convergence").map_or(d.convergence, |v| v.clamp(0.0, 1.0)), + stall: num("generationsWithoutImprovement").map_or(d.stall, |v| v.clamp(1.0, 100_000.0) as u64), + require_bounds: bool_param(p, "requireBounds").unwrap_or(d.require_bounds), + } } #[derive(Clone, Debug)] @@ -144,6 +185,7 @@ struct Model { cons: Vec, non_negative: bool, method: Method, + evo: EvoOptions, } /// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`. @@ -301,10 +343,10 @@ fn parse_model(s: &Session, p: &Json) -> Result { let method = match str_param(p, "method").unwrap_or("grg").to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { "grg" | "grgnonlinear" | "nonlinear" => Method::Grg, "simplex" | "simplexlp" | "lp" | "linear" => Method::Simplex, - "evolutionary" => return Err(EngineError::Other("The Evolutionary method isn't available; use GRG Nonlinear or Simplex LP.".into())), - m => return Err(bad(CMD, format!("unknown method `{m}` (grg or simplex)"))), + "evolutionary" | "evo" | "genetic" => Method::Evolutionary, + m => return Err(bad(CMD, format!("unknown method `{m}` (grg, simplex or evolutionary)"))), }; - Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method }) + Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p) }) } // ---------------------------------------------------------------- saved model @@ -359,7 +401,18 @@ fn save_model(wb: &mut Workbook, m: &Model) { } put("solver_num".into(), m.cons.len().to_string()); put("solver_neg".into(), if m.non_negative { "1" } else { "2" }.into()); - put("solver_eng".into(), if m.method == Method::Simplex { "2" } else { "1" }.into()); + let eng = match m.method { + Method::Grg => "1", + Method::Simplex => "2", + Method::Evolutionary => "3", + }; + put("solver_eng".into(), eng.into()); + if m.method == Method::Evolutionary { + put("solver_ssz".into(), m.evo.population.to_string()); + put("solver_mrt".into(), m.evo.mutation.to_string()); + put("solver_rsd".into(), m.evo.seed.to_string()); + put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into()); + } put("solver_ver".into(), "3".into()); } @@ -398,15 +451,29 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option { } }); } - Some(json!({ + let method = match get("solver_eng").as_deref().map(str::trim) { + Some("2") => "simplex", + Some("3") => "evolutionary", + _ => "grg", + }; + let mut model = json!({ "objective": objective, "goal": goal, "value": value, "variables": get("solver_adj").map(local).unwrap_or_default(), "constraints": constraints, "nonNegative": get("solver_neg").as_deref().map(str::trim) != Some("2"), - "method": if get("solver_eng").as_deref().map(str::trim) == Some("2") { "simplex" } else { "grg" }, - })) + "method": method, + }); + for (name, key) in [("solver_ssz", "populationSize"), ("solver_mrt", "mutationRate"), ("solver_rsd", "randomSeed")] { + if let Some(v) = get(name).and_then(|v| num_text(&v)) { + model[key] = json!(v); + } + } + if let Some(v) = get("solver_rbv") { + model["requireBounds"] = json!(v.trim() != "2"); + } + Some(model) } // ---------------------------------------------------------------- evaluation @@ -1182,6 +1249,248 @@ fn nlp(ev: &mut Evaluator, x0: &[f64], lo: &[f64], hi: &[f64], goal: Goal) -> Ou Outcome::Solved(x, phi, (converged || viol <= PRECISION * scale) && !out_of_budget) } +// ---------------------------------------------------------------- evolutionary + +/// A small deterministic generator (SplitMix64), so a seed reproduces a run on every platform. +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Rng { + Rng(seed ^ 0x2545_F491_4F6C_DD1D) + } + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + /// Uniform in [0, 1). + fn uniform(&mut self) -> f64 { + (self.next() >> 11) as f64 / (1u64 << 53) as f64 + } + fn between(&mut self, a: f64, b: f64) -> f64 { + a + (b - a) * self.uniform() + } + fn below(&mut self, n: usize) -> usize { + if n == 0 { 0 } else { usize::try_from(self.next() % n as u64).unwrap_or(0) } + } + /// Standard normal (Box–Muller). + fn normal(&mut self) -> f64 { + let u = self.uniform().max(f64::MIN_POSITIVE); + (-2.0 * u.ln()).sqrt() * (std::f64::consts::TAU * self.uniform()).cos() + } +} + +/// A candidate: its point, the objective to minimise and its constraint violation. +#[derive(Clone, Debug)] +struct Member { + x: Vec, + phi: f64, + viol: f64, +} + +impl Member { + /// Feasible beats infeasible; then the lower objective, or the smaller violation. + fn better(&self, o: &Member, tol: f64) -> bool { + match (self.viol <= tol, o.viol <= tol) { + (true, false) => true, + (false, true) => false, + (true, true) => self.phi < o.phi, + (false, false) => self.viol < o.viol, + } + } +} + +/// Why the Evolutionary search stopped. +#[derive(Clone, Copy, Debug, PartialEq)] +enum EvoEnd { + /// Almost all of the population is within Convergence of the best member. + Converged, + /// The best member didn't improve for the set number of generations. + Stalled, + /// The evaluation budget ran out. + Budget, +} + +/// The search box, integer flags and scoring shared by the genetic and pattern phases. +struct EvoSearch<'a> { + ev: &'a mut Evaluator, + rows: Vec, + senses: Vec, + goal: Goal, + lo: Vec, + hi: Vec, + integer: &'a [bool], + tol: f64, +} + +impl EvoSearch<'_> { + /// Clamps into the box and rounds integer variables. + fn repair(&self, x: &mut [f64]) { + for (j, v) in x.iter_mut().enumerate() { + let (l, h) = (self.lo.get(j).copied().unwrap_or(0.0), self.hi.get(j).copied().unwrap_or(0.0)); + *v = if v.is_finite() { v.clamp(l, h) } else { l }; + if self.integer.get(j).copied().unwrap_or(false) { + *v = v.round().clamp(l.ceil(), h.floor()); + } + } + } + + /// Scores `x`; `None` once the budget is spent. + fn score(&mut self, x: Vec) -> Option { + let p = self.ev.eval(&x)?; + let (phi, cons) = shape(&p, &self.rows, self.goal); + let viol = violation(&cons, &self.senses); + let phi = if phi.is_finite() { phi } else { f64::INFINITY }; + let viol = if viol.is_finite() && cons.iter().all(|c| c.is_finite()) { viol } else { f64::INFINITY }; + Some(Member { x, phi, viol }) + } +} + +/// The Evolutionary method within `lo..hi` from `x0`. +fn evolutionary(ev: &mut Evaluator, x0: &[f64], lo: &[f64], hi: &[f64], integer: &[bool], goal: Goal, o: &EvoOptions) -> (Outcome, EvoEnd) { + let rows = ev.rows.clone(); + let senses: Vec = rows.iter().map(|r| r.sense).collect(); + let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max); + let mut start = x0.to_vec(); + project(&mut start, lo, hi); + // Without a bound, search within 100 of the start. + let blo: Vec = lo.iter().zip(&start).map(|(l, s)| if l.is_finite() { *l } else { s - 100.0 }).collect(); + let bhi: Vec = hi.iter().zip(&start).map(|(h, s)| if h.is_finite() { *h } else { s + 100.0 }).collect(); + let no_integer = blo.iter().zip(&bhi).zip(integer).any(|((l, h), int)| *int && l.ceil() > h.floor()); + if blo.iter().zip(&bhi).any(|(l, h)| l > h) || no_integer { + return (Outcome::Infeasible, EvoEnd::Converged); + } + let span: Vec = blo.iter().zip(&bhi).map(|(l, h)| (h - l).max(1e-9)).collect(); + let mut se = EvoSearch { ev, rows, senses, goal, lo: blo, hi: bhi, integer, tol: PRECISION * scale }; + let mut rng = Rng::new(o.seed); + let size = o.population.clamp(MIN_POPULATION, MAX_POPULATION); + let tol = se.tol; + let mut end = EvoEnd::Budget; + // The first generation: the start and random points in the box. + let mut pop: Vec = Vec::with_capacity(size); + se.repair(&mut start); + let mut next_x = Some(start); + while pop.len() < size { + let x = next_x.take().unwrap_or_else(|| { + let mut x: Vec = se.lo.iter().zip(&se.hi).map(|(l, h)| rng.between(*l, *h)).collect(); + se.repair(&mut x); + x + }); + match se.score(x) { + Some(m) => pop.push(m), + None => break, + } + } + let mut best: Option = None; + for m in &pop { + if best.as_ref().is_none_or(|b| m.better(b, tol)) { + best = Some(m.clone()); + } + } + let mut last_improved = 0u64; + let mut generation = 0u64; + 'search: while pop.len() == size { + let Some(b) = best.clone() else { break }; + // Converged: 99% of the population is feasible and within Convergence of the best. + let close = pop.iter().filter(|m| m.viol <= tol && (m.phi - b.phi).abs() <= o.convergence * b.phi.abs().max(1.0)).count(); + if b.viol <= tol && generation >= 5 && close * 100 >= size * 99 { + end = EvoEnd::Converged; + break; + } + if generation.saturating_sub(last_improved) >= o.stall { + end = EvoEnd::Stalled; + break; + } + // The next generation: the best survives, the rest are children of two tournaments. + let mut next = Vec::with_capacity(size); + next.push(b.clone()); + while next.len() < size { + let mut parent = || { + let (i, k) = (rng.below(size), rng.below(size)); + match (pop.get(i), pop.get(k)) { + (Some(p), Some(q)) => Some(if q.better(p, tol) { q.x.clone() } else { p.x.clone() }), + _ => None, + } + }; + let (Some(pa), Some(pb)) = (parent(), parent()) else { break 'search }; + let mut x: Vec = pa + .iter() + .zip(&pb) + .zip(&span) + .map(|((a, c), w)| { + let mut v = a + rng.between(-0.25, 1.25) * (c - a); + if rng.uniform() < o.mutation { + v += 0.1 * w * rng.normal(); + } + v + }) + .collect(); + se.repair(&mut x); + match se.score(x) { + Some(m) => next.push(m), + None => break 'search, + } + } + generation += 1; + for m in &next { + let Some(cur) = &best else { continue }; + if m.better(cur, tol) { + let improved = cur.viol > tol || cur.phi - m.phi > 1e-9 * cur.phi.abs().max(1.0); + best = Some(m.clone()); + if improved { + last_improved = generation; + } + } + } + pop = next; + } + // Polish: a compass search on the continuous variables around the best member. + if let Some(mut b) = best.clone() { + let mut step: Vec = span.iter().map(|w| 0.01 * w).collect(); + let n = b.x.len(); + 'polish: for _ in 0..10_000 { + if step.iter().zip(&span).zip(integer).all(|((s, w), int)| *int || *s <= 1e-9 * w.max(1.0)) { + break; + } + let mut moved = false; + for j in 0..n { + if integer.get(j).copied().unwrap_or(false) { + continue; + } + for dir in [1.0, -1.0] { + let mut x = b.x.clone(); + if let (Some(v), Some(s)) = (x.get_mut(j), step.get(j)) { + *v += dir * s; + } + se.repair(&mut x); + if x == b.x { + continue; + } + let Some(m) = se.score(x) else { break 'polish }; + if m.better(&b, tol) { + b = m; + moved = true; + break; + } + } + } + if !moved { + for s in &mut step { + *s *= 0.5; + } + } + } + best = Some(b); + } + match best { + Some(b) if b.viol <= tol => (Outcome::Solved(b.x, b.phi, end != EvoEnd::Budget), end), + _ if end == EvoEnd::Budget => (Outcome::Limit, end), + _ => (Outcome::Infeasible, end), + } +} + // ---------------------------------------------------------------- command fn run(s: &mut Session, p: &Json) -> Result { @@ -1227,7 +1536,14 @@ fn run(s: &mut Session, p: &Json) -> Result { budget, }; let any_int = prob.integer.iter().any(|b| *b); + if model.method == Method::Evolutionary && model.evo.require_bounds && prob.lo.iter().chain(&prob.hi).any(|b| !b.is_finite()) { + return Err(EngineError::Other( + "The Evolutionary method needs a lower and an upper bound on every variable cell (constraints such as \"A1:A3 <= 10\"), or requireBounds: false." + .into(), + )); + } let mut not_linear = false; + let mut evo_end = None; let outcome = if prob.lo.iter().zip(&prob.hi).any(|(l, h)| l > h) { Outcome::Infeasible } else { @@ -1271,10 +1587,21 @@ fn run(s: &mut Session, p: &Json) -> Result { nlp(&mut ev, &x0, &prob.lo, &prob.hi, goal) } } + Method::Evolutionary => { + let (out, end) = evolutionary(&mut ev, &x0, &prob.lo, &prob.hi, &prob.integer, model.goal, &model.evo); + evo_end = Some(end); + out + } } }; let (status, message, solution) = match outcome { _ if not_linear => ("notLinear", "The model isn't linear in the variable cells, so Simplex LP can't solve it. Try GRG Nonlinear.", None), + Outcome::Solved(x, _, true) if model.method == Method::Evolutionary => match evo_end { + Some(EvoEnd::Stalled) => { + ("cannotImprove", "Solver stopped when the best solution stopped improving. All constraints are satisfied.", Some(x)) + } + _ => ("converged", "The population converged. The best solution found satisfies all constraints.", Some(x)), + }, Outcome::Solved(x, _, true) => ( if model.method == Method::Simplex { "optimal" } else { "converged" }, if model.method == Method::Simplex { @@ -1432,6 +1759,90 @@ mod tests { assert!((n(&s, "E1") - 2.0).abs() < 1e-4); } + #[test] + fn evolutionary_finds_the_global_minimum_of_non_smooth_and_multimodal_models() { + let mut s = s(); + // |x − 3| + |y + 2| has a kink at its minimum (3, −2). + set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=ABS(A1-3)+ABS(A2+2)")]); + let p = + json!({"objective": "B1", "goal": "min", "variables": "A1:A2", "constraints": ["A1:A2 >= -10", "A1:A2 <= 10"], "method": "evolutionary"}); + let r = s.execute("data.solver", p.clone()).unwrap(); + assert!(r["status"] == "converged" || r["status"] == "cannotImprove", "{r}"); + assert!((n(&s, "A1") - 3.0).abs() < 1e-6 && (n(&s, "A2") + 2.0).abs() < 1e-6, "{r}"); + // The same seed repeats the run exactly. + set(&mut s, &[("A1", "0"), ("A2", "0")]); + let again = s.execute("data.solver", p).unwrap(); + assert_eq!(again["values"], r["values"]); + assert_eq!(again["evaluations"], r["evaluations"]); + // Rastrigin's function 20 + Σ (x² − 10 cos 2πx) on [−5.12, 5.12]² has its global minimum 0 + // at the origin among many local minima; GRG from (3.3, −2.7) stops at a local one. + set(&mut s, &[("C1", "3.3"), ("C2", "-2.7"), ("D1", "=20+C1^2-10*COS(2*PI()*C1)+C2^2-10*COS(2*PI()*C2)")]); + let base = json!({"objective": "D1", "goal": "min", "variables": "C1:C2", "constraints": ["C1:C2 >= -5.12", "C1:C2 <= 5.12"]}); + s.execute("data.solver", base.clone()).unwrap(); + assert!(n(&s, "D1") > 0.5, "GRG is local: {}", n(&s, "D1")); + set(&mut s, &[("C1", "3.3"), ("C2", "-2.7")]); + let mut p = base.clone(); + p["method"] = json!("evolutionary"); + let r = s.execute("data.solver", p).unwrap(); + assert!(n(&s, "D1") < 1e-8 && n(&s, "C1").abs() < 1e-5 && n(&s, "C2").abs() < 1e-5, "{r}"); + // A stepped model: ROUND makes it flat almost everywhere; the minimum is at 7.2. + set(&mut s, &[("E1", "0"), ("F1", "=ABS(ROUND(E1,0)-7)+ABS(E1-7.2)/100")]); + s.execute("data.solver", json!({"objective": "F1", "goal": "min", "variables": "E1", "constraints": ["E1 <= 20"], "method": "evolutionary"})) + .unwrap(); + assert!((n(&s, "E1") - 7.2).abs() < 1e-6, "{}", n(&s, "E1")); + } + + #[test] + fn evolutionary_handles_integers_bounds_and_the_saved_model() { + let mut s = s(); + // The 0/1 knapsack of the branch-and-bound test: items 2 and 3, value 20. + set(&mut s, &[("D1", "=10*C1+13*C2+7*C3"), ("D2", "=5*C1+6*C2+4*C3")]); + let p = json!({"objective": "D1", "variables": "C1:C3", "constraints": ["D2 <= 10", "C1:C3 bin"], "method": "evolutionary", "populationSize": 30, "randomSeed": 7}); + let r = s.execute("data.solver", p).unwrap(); + assert!(r["status"] == "converged" || r["status"] == "cannotImprove", "{r}"); + assert_eq!((n(&s, "C1"), n(&s, "C2"), n(&s, "C3"), n(&s, "D1")), (0.0, 1.0, 1.0, 20.0)); + // The method and its options are saved, survive XLSX and solve again. + let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "e.xlsx", "base64": b64})).unwrap(); + let m = s.execute("data.solver", json!({})).unwrap(); + assert_eq!(m["model"]["method"], "evolutionary"); + assert_eq!((m["model"]["populationSize"].as_f64(), m["model"]["randomSeed"].as_f64()), (Some(30.0), Some(7.0))); + set(&mut s, &[("C1", "0"), ("C2", "0"), ("C3", "0")]); + s.execute("data.solver", json!({"saved": true})).unwrap(); + assert_eq!(n(&s, "D1"), 20.0); + // Integer constraints: max x + y with x + 2y ≤ 7 on integers in [0, 10] → (7, 0). + set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=A1+A2"), ("B2", "=A1+2*A2")]); + let r = s + .execute( + "data.solver", + json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 7", "A1:A2 <= 10", "A1:A2 int"], "method": "evolutionary"}), + ) + .unwrap(); + assert_eq!((n(&s, "A1"), n(&s, "A2")), (7.0, 0.0), "{r}"); + // Every variable needs bounds unless requireBounds is off; then it searches around the start. + set(&mut s, &[("E1", "1"), ("F1", "=ABS(E1-4)")]); + let p = json!({"objective": "F1", "goal": "min", "variables": "E1", "method": "evolutionary"}); + assert!(s.execute("data.solver", p.clone()).is_err()); + let mut p = p; + p["requireBounds"] = json!(false); + s.execute("data.solver", p).unwrap(); + assert!((n(&s, "E1") - 4.0).abs() < 1e-6); + // Infeasible constraints and a tiny budget are reported without changing the cells. + set(&mut s, &[("E1", "1")]); + let r = s + .execute("data.solver", json!({"objective": "F1", "variables": "E1", "constraints": ["E1 <= 3", "F1 >= 10"], "method": "evolutionary"})) + .unwrap(); + assert_eq!(r["status"], "infeasible", "{r}"); + let r = s + .execute( + "data.solver", + json!({"objective": "F1", "variables": "E1", "constraints": ["E1 <= 3"], "method": "evolutionary", "maxEvaluations": 3}), + ) + .unwrap(); + assert_eq!(r["status"], "iterationLimit", "{r}"); + assert_eq!(n(&s, "E1"), 1.0); + } + #[test] fn infeasible_unbounded_and_nonlinear_models_are_reported() { let mut s = s(); @@ -1469,6 +1880,7 @@ mod tests { json!({"objective": "B1", "variables": "A1", "goal": "sideways"}), json!({"objective": "B1", "variables": "A1", "goal": "value"}), json!({"objective": "B1", "variables": "A1", "method": "evolutionary"}), + json!({"objective": "B1", "variables": "A1", "method": "annealing"}), json!({"objective": "B1", "variables": "A1", "constraints": ["A1 ?? 3"]}), json!({"objective": "B1", "variables": "A1", "constraints": "A1 <= 3"}), json!({"objective": "B1", "variables": "A1", "constraints": ["A1:A3 <= C1:C2"]}), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c6..53ac8b8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2352,9 +2352,13 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool let method = text(d, "method"); ui.horizontal(|ui| { ui.label("Select a Solving Method:"); - let label = |m: &str| if m == "simplex" { "Simplex LP" } else { "GRG Nonlinear" }; + let label = |m: &str| match m { + "simplex" => "Simplex LP", + "evolutionary" => "Evolutionary", + _ => "GRG Nonlinear", + }; egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| { - for m in ["grg", "simplex"] { + for m in ["grg", "simplex", "evolutionary"] { if ui.selectable_label(method == m, label(m)).clicked() { d.values.insert("method".into(), json!(m)); } @@ -2362,10 +2366,10 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool }); }); ui.label( - egui::RichText::new(if method == "simplex" { - "Simplex LP: for linear models; integer and binary constraints by branch and bound." - } else { - "GRG Nonlinear: a local optimum of a smooth model, from the current values." + egui::RichText::new(match method.as_str() { + "simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.", + "evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.", + _ => "GRG Nonlinear: a local optimum of a smooth model, from the current values.", }) .small(), ); diff --git a/docs/gaps.md b/docs/gaps.md index 09870ba..a1cfb11 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -105,8 +105,11 @@ 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` 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. +- Solver (`data.solver`, #327) has Simplex LP with integer/binary branch and bound, a local + nonlinear method (augmented Lagrangian, not GRG) and the Evolutionary method; AllDifferent + constraints, the Solver Options dialog, reports and Load/Save Model remain. The Analysis ToolPak + (`data.analysisToolPak`) has 13 of 19 tools. Forecast Sheet (`data.forecastSheet`) and the + FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 12–20 h. ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8b82092..e2c0511 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -87,7 +87,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | -| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts). Missing: Solver, Forecast Sheet, Analyze Data | +| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; no reports or options dialog). Missing: Forecast Sheet, Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | From 019d5de98e7abd627d92ced9e60f83f0418dd78c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:21:07 -0400 Subject: [PATCH 011/162] Data: Solver Answer and Limits reports Builds on the Solver of #327. data.solver takes reports: [answer, limits] and writes them on new sheets before the model's sheet once a solution is found. The Answer report lists the objective and variable cells with their original and final values and each constraint's value, formula, status (Binding or Not Binding) and slack; cells are named by the nearest labels to their left and above. The Limits report minimises and maximises each variable with the others held at the solution, under the model's constraints, and gives the objective at both ends (Simplex LP and GRG Nonlinear, without integer constraints). data.solverReport writes the reports for a given solution and model, so data.solver's result (which now carries the original values and the model) can be passed to it as is. The Solver Results dialog offers the two reports; they are written after Keep Solver Solution or Restore Original Values. --- crates/engine/src/cmd/solver.rs | 696 +++++++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 22 + crates/ui-egui/tests/interaction.rs | 27 ++ docs/gaps.md | 5 +- docs/target-app-parity.md | 2 +- 5 files changed, 683 insertions(+), 69 deletions(-) diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index cd3da0e..4465874 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -25,13 +25,14 @@ //! travels with the workbook through XLSX. use gridcraft_core::{CellRef, RangeRef, Value}; -use gridcraft_model::{Cell, DefinedName, Workbook}; +use gridcraft_model::{Cell, DefinedName, Sheet, Workbook}; use serde_json::{Value as Json, json}; use super::whatif::Trial; use super::*; const CMD: &str = "data.solver"; +const REPORT_CMD: &str = "data.solverReport"; /// Excel's limit on decision variables. const MAX_VARS: usize = 200; /// Most scalar constraints (after expanding ranges). @@ -50,15 +51,26 @@ const PRECISION: f64 = 1e-6; const EPS: f64 = 1e-9; pub fn specs() -> Vec { - vec![cmd!( - "data.solver", - "Solver…", - ["Data", "Analysis"], - None, - "{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden solver_* names), maxEvaluations?: 20000, integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, evaluations, changed}. No `objective` → {model: the saved model} (the menu opens the dialog).", - has_doc, - run - )] + vec![ + cmd!( + "data.solver", + "Solver…", + ["Data", "Analysis"], + None, + "{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden solver_* names), maxEvaluations?: 20000, integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override), reports?: [answer|limits] (written on new sheets when a solution is found)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, original: {cell: value}, model: the model as params, evaluations, changed, reports?: [sheet names]}. No `objective` → {model: the saved model} (the menu opens the dialog).", + has_doc, + run + ), + cmd!( + "data.solverReport", + "Solver Reports", + [], + None, + "{reports: [answer|limits], values?: {cell: number} (the solution; default the variable cells' current values), original?: {cell: number} (the values before solving; default `values`), message?: text (the result line), model?: data.solver params (default the sheet's saved model, with any data.solver params given here over it)} → {sheets: [names]}. Writes Answer and Limits reports on new sheets before the model's sheet; Limits needs Simplex LP or GRG Nonlinear and no integer constraints. data.solver's result can be passed as is.", + has_doc, + report + ), + ] } // ---------------------------------------------------------------- model @@ -476,6 +488,49 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option { Some(model) } +/// The model as `data.solver` params, in the form [`saved_model`] returns (the objective +/// qualified by its sheet). +fn model_params(wb: &Workbook, m: &Model) -> Json { + let sheet = wb.sheet(m.sheet).map(|s| crate::pivot::quote_sheet(&s.name)).unwrap_or_default(); + let (goal, value) = match m.goal { + Goal::Max => ("max", 0.0), + Goal::Min => ("min", 0.0), + Goal::Value(v) => ("value", v), + }; + let constraints: Vec = m + .cons + .iter() + .map(|c| match c.rel { + Rel::Int | Rel::Bin => json!(format!("{} {}", c.lhs_text, c.rel.text())), + _ => json!(format!("{} {} {}", c.lhs_text, c.rel.text(), c.rhs_text)), + }) + .collect(); + let method = match m.method { + Method::Simplex => "simplex", + Method::Grg => "grg", + Method::Evolutionary => "evolutionary", + }; + let mut out = json!({ + "objective": format!("{sheet}!{}", m.objective.a1()), + "goal": goal, + "value": value, + "variables": m.vars_text, + "constraints": constraints, + "nonNegative": m.non_negative, + "method": method, + }); + if m.method == Method::Evolutionary { + let e = &m.evo; + out["populationSize"] = json!(e.population); + out["mutationRate"] = json!(e.mutation); + out["randomSeed"] = json!(e.seed); + out["convergence"] = json!(e.convergence); + out["generationsWithoutImprovement"] = json!(e.stall); + out["requireBounds"] = json!(e.require_bounds); + } + out +} + // ---------------------------------------------------------------- evaluation #[derive(Clone, Copy, Debug, PartialEq)] @@ -509,6 +564,8 @@ struct Evaluator { rows: Vec, evals: u64, budget: u64, + /// The Limits report: the objective is this variable itself. + target: Option, } fn as_num(v: &Value) -> f64 { @@ -543,6 +600,10 @@ impl Evaluator { let vals = self.trial.run(&sets, &reads); let mut it = vals.iter().map(as_num); let f = it.next().unwrap_or(f64::NAN); + let f = match self.target { + Some(j) => x.get(j).copied().unwrap_or(f64::NAN), + None => f, + }; let mut g = Vec::with_capacity(self.rows.len()); for r in &self.rows { let l = it.next().unwrap_or(f64::NAN); @@ -1491,6 +1552,431 @@ fn evolutionary(ev: &mut Evaluator, x0: &[f64], lo: &[f64], hi: &[f64], integer: } } +/// What a solve ended with, and why Simplex LP or Evolutionary stopped. +struct Solve { + outcome: Outcome, + not_linear: bool, + evo_end: Option, +} + +/// Runs the model's method for `goal` within `lo..hi` from `x0`. +#[allow(clippy::too_many_arguments)] +fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], lo: &[f64], hi: &[f64], integer: &[bool], gap: f64) -> Solve { + let any_int = integer.iter().any(|b| *b); + let mut not_linear = false; + let mut evo_end = None; + let outcome = if lo.iter().zip(hi).any(|(l, h)| l > h) { + Outcome::Infeasible + } else { + match m.method { + Method::Simplex => match linearize(ev, x0) { + Err("nonlinear") | Err("error") => { + not_linear = true; + Outcome::Infeasible + } + Err(_) => Outcome::Limit, + Ok(((cf, f_const), lin)) => { + let mut rows: Vec<(Vec, Sense, f64)> = lin.iter().zip(&ev.rows).map(|((a, k), r)| (a.clone(), r.sense, -k)).collect(); + let c: Vec = match goal { + Goal::Max => cf.iter().map(|v| -v).collect(), + Goal::Min => cf.clone(), + Goal::Value(v) => { + rows.push((cf.clone(), Sense::Eq, v - f_const)); + vec![0.0; cf.len()] + } + }; + let size = (rows.len() as u64 + 2 * x0.len() as u64 + 1) * (4 * x0.len() as u64 + 2 * rows.len() as u64 + 1); + let mut work = LP_WORK.max(size); + let mut relax = |l: &[f64], h: &[f64]| solve_lp(&c, &rows, l, h, &mut work); + if any_int { branch_and_bound(lo, hi, integer, MAX_NODES_LP, gap, &mut relax).0 } else { relax(lo, hi) } + } + }, + Method::Grg => { + if any_int { + let mut relax = |l: &[f64], h: &[f64]| { + let mut start = x0.to_vec(); + project(&mut start, l, h); + nlp(ev, &start, l, h, goal) + }; + branch_and_bound(lo, hi, integer, MAX_NODES_NLP, gap, &mut relax).0 + } else { + nlp(ev, x0, lo, hi, goal) + } + } + Method::Evolutionary => { + let (out, end) = evolutionary(ev, x0, lo, hi, integer, goal, &m.evo); + evo_end = Some(end); + out + } + } + }; + Solve { outcome, not_linear, evo_end } +} + +// ---------------------------------------------------------------- reports + +#[derive(Clone, Copy, Debug, PartialEq)] +enum ReportKind { + Answer, + Limits, +} + +impl ReportKind { + fn title(self) -> &'static str { + match self { + ReportKind::Answer => "Answer", + ReportKind::Limits => "Limits", + } + } +} + +/// The `reports` list (`None` when absent). +fn report_kinds(cmd: &'static str, p: &Json) -> Result>> { + let Some(list) = p.get("reports") else { return Ok(None) }; + let list = list.as_array().ok_or_else(|| bad(cmd, "`reports` must be an array of answer and limits"))?; + let mut kinds = Vec::new(); + for k in list { + let kind = match k.as_str().map(|t| t.trim().to_ascii_lowercase()).as_deref() { + Some("answer") => ReportKind::Answer, + Some("limits") => ReportKind::Limits, + _ => return Err(bad(cmd, format!("unknown report {k} (answer or limits)"))), + }; + if !kinds.contains(&kind) { + kinds.push(kind); + } + } + Ok(Some(kinds)) +} + +/// A report's cells: (row, column, value, bold). +type Lines = Vec<(u32, u32, Value, bool)>; + +fn abs_ref(c: CellRef) -> String { + format!("${}${}", gridcraft_core::col_to_letters(c.col), u64::from(c.row) + 1) +} + +/// A cell's name in the reports: the nearest text to its left and the nearest text above it. +fn cell_name(sh: &Sheet, c: CellRef) -> String { + let text = |c: CellRef| match sh.value(c) { + Value::Text(t) if !t.trim().is_empty() => Some(t.to_string()), + _ => None, + }; + let left = (0..c.col).rev().find_map(|col| text(CellRef::new(c.row, col))); + let top = (0..c.row).rev().find_map(|row| text(CellRef::new(row, c.col))); + [left, top].into_iter().flatten().collect::>().join(" ") +} + +fn method_name(m: Method) -> &'static str { + match m { + Method::Simplex => "Simplex LP", + Method::Grg => "GRG Nonlinear", + Method::Evolutionary => "Evolutionary", + } +} + +/// Header labels in bold from column 1, then the rows below them; returns the next free row. +fn table(out: &mut Lines, row: u32, headers: &[&str], rows: Vec>) -> u32 { + for (k, h) in headers.iter().enumerate() { + out.push((row, 1 + k as u32, Value::text(*h), true)); + } + let mut r = row; + for cells in rows { + r = r.saturating_add(1); + for (k, v) in cells.into_iter().enumerate() { + out.push((r, 1 + k as u32, v, false)); + } + } + r.saturating_add(2) +} + +/// Everything a report describes: the model, its starting and final values, and the result. +struct ReportInput<'a> { + model: &'a Model, + prob: &'a Problem, + original: Vec, + solution: Vec, + message: String, + budget: u64, +} + +fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 { + out.push((0, 0, Value::text(format!("GridCraft {} Report", kind.title())), true)); + out.push((1, 0, Value::text(format!("Worksheet: {sheet}")), false)); + 3 +} + +fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines { + let m = inp.model; + let mut trial = Trial::new(d); + let read = |trial: &mut Trial, x: &[f64], cells: &[CellRef]| -> Vec { + let sets: Vec<(usize, CellRef, Value)> = m.vars.iter().zip(x).map(|(c, v)| (m.sheet, *c, Value::number(*v))).collect(); + let reads: Vec<(usize, CellRef)> = cells.iter().map(|c| (m.sheet, *c)).collect(); + trial.run(&sets, &reads).iter().map(as_num).collect() + }; + let f0 = read(&mut trial, &inp.original, &[m.objective]).first().copied().unwrap_or(f64::NAN); + // Every cell the constraints read, at the solution. + let mut cells = vec![m.objective]; + for c in &m.cons { + cells.extend(&c.lhs); + if let Rhs::Cells(r) = &c.rhs { + cells.extend(r); + } + } + let vals = read(&mut trial, &inp.solution, &cells); + let mut at = vals.iter().copied(); + let f1 = at.next().unwrap_or(f64::NAN); + let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }; + let mut out = Lines::new(); + let mut r = title(&mut out, ReportKind::Answer, &sh.name); + out.push((r, 0, Value::text(format!("Result: {}", inp.message)), false)); + out.push((r + 1, 0, Value::text(format!("Engine: {}", method_name(m.method))), false)); + r += 3; + let goal = match m.goal { + Goal::Max => "Max".to_string(), + Goal::Min => "Min".to_string(), + Goal::Value(v) => format!("Value Of {v}"), + }; + out.push((r, 0, Value::text(format!("Objective Cell ({goal})")), true)); + let obj = vec![vec![Value::text(abs_ref(m.objective)), Value::text(cell_name(sh, m.objective)), num(f0), num(f1)]]; + r = table(&mut out, r + 1, &["Cell", "Name", "Original Value", "Final Value"], obj); + out.push((r, 0, Value::text("Variable Cells"), true)); + let kind = |j: usize| { + let bin = m.cons.iter().any(|c| c.rel == Rel::Bin && m.vars.get(j).is_some_and(|v| c.lhs.contains(v))); + match (bin, inp.prob.integer.get(j).copied().unwrap_or(false)) { + (true, _) => "Binary", + (false, true) => "Integer", + _ => "Contin", + } + }; + let vars = m + .vars + .iter() + .enumerate() + .map(|(j, c)| { + let (a, b) = (inp.original.get(j).copied().unwrap_or(f64::NAN), inp.solution.get(j).copied().unwrap_or(f64::NAN)); + vec![Value::text(abs_ref(*c)), Value::text(cell_name(sh, *c)), num(a), num(b), Value::text(kind(j))] + }) + .collect(); + r = table(&mut out, r + 1, &["Cell", "Name", "Original Value", "Final Value", "Integer"], vars); + out.push((r, 0, Value::text("Constraints"), true)); + let mut rows = Vec::new(); + for c in &m.cons { + let lhs: Vec = c.lhs.iter().map(|_| at.next().unwrap_or(f64::NAN)).collect(); + let rhs: Vec = match &c.rhs { + Rhs::Cells(cells) => cells.iter().map(|_| at.next().unwrap_or(f64::NAN)).collect(), + Rhs::Const(v) => vec![*v], + Rhs::None => vec![], + }; + for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() { + let (formula, status, slack) = match (c.rel, &c.rhs) { + (Rel::Int, _) => (format!("{}=Integer", abs_ref(*cell)), "Binding", 0.0), + (Rel::Bin, _) => (format!("{}=Binary", abs_ref(*cell)), "Binding", 0.0), + (rel, rhs_cells) => { + let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN); + let rhs_text = match rhs_cells { + Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(), + _ => gridcraft_core::number_to_text(b), + }; + let slack = (v - b).abs(); + let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs()); + let slack = if binding { 0.0 } else { slack }; + (format!("{}{}{rhs_text}", abs_ref(*cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack) + } + }; + rows.push(vec![ + Value::text(abs_ref(*cell)), + Value::text(cell_name(sh, *cell)), + num(*v), + Value::text(formula), + Value::text(status), + num(slack), + ]); + } + } + table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows); + out +} + +fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result { + let m = inp.model; + if inp.prob.integer.iter().any(|b| *b) { + return Err(EngineError::Other("The Limits report doesn't apply to models with integer or binary constraints.".into())); + } + if m.method == Method::Evolutionary { + return Err(EngineError::Other("The Limits report is available for Simplex LP and GRG Nonlinear.".into())); + } + let n = m.vars.len(); + let x = &inp.solution; + // Each variable is minimised and maximised with the others held at the solution. + let per = (inp.budget / (2 * n as u64).max(1)).max(n as u64 + 3); + let mut ev = Evaluator { + trial: Trial::new(d), + sheet: m.sheet, + vars: m.vars.clone(), + objective: m.objective, + rows: inp.prob.rows.clone(), + evals: 0, + budget: 0, + target: None, + }; + let objective_at = |ev: &mut Evaluator, x: &[f64]| { + ev.target = None; + ev.budget = ev.evals.saturating_add(1); + ev.eval(x).map(|p| p.f).unwrap_or(f64::NAN) + }; + let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }; + let f = objective_at(&mut ev, x); + let mut rows = Vec::new(); + for (j, c) in m.vars.iter().enumerate() { + let (mut lo, mut hi) = (x.clone(), x.clone()); + if let (Some(l), Some(h), Some(pl), Some(ph)) = (lo.get_mut(j), hi.get_mut(j), inp.prob.lo.get(j), inp.prob.hi.get(j)) { + *l = *pl; + *h = *ph; + } + let mut ends = Vec::new(); + for goal in [Goal::Min, Goal::Max] { + ev.target = Some(j); + ev.evals = 0; + ev.budget = per; + let solve = optimise(&mut ev, m, goal, x, &lo, &hi, &inp.prob.integer, 0.0); + ends.push(match solve.outcome { + Outcome::Solved(p, _, _) => { + let v = p.get(j).copied().unwrap_or(f64::NAN); + (num(v), num(objective_at(&mut ev, &p))) + } + Outcome::Unbounded => (Value::text("Unbounded"), Value::Empty), + _ => (Value::Error(gridcraft_core::CellError::NA), Value::Empty), + }); + } + let mut row = vec![Value::text(abs_ref(*c)), Value::text(cell_name(sh, *c)), num(x.get(j).copied().unwrap_or(f64::NAN))]; + for (v, o) in ends { + row.push(v); + row.push(o); + } + rows.push(row); + } + let mut out = Lines::new(); + let r = title(&mut out, ReportKind::Limits, &sh.name); + let obj = vec![vec![Value::text(abs_ref(m.objective)), Value::text(cell_name(sh, m.objective)), num(f)]]; + let r = table(&mut out, r, &["Objective Cell", "Name", "Value"], obj); + table(&mut out, r, &["Variable Cell", "Name", "Value", "Lower Limit", "Objective Result", "Upper Limit", "Objective Result"], rows); + Ok(out) +} + +/// Writes the reports on new sheets ("Answer Report 1"…) before the model's sheet, in one step; +/// the model's sheet stays active. Returns the new sheets' names. +fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Result> { + if kinds.is_empty() { + return Ok(Vec::new()); + } + let d = s.doc()?; + if d.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } + let sh = d.wb.sheet(inp.model.sheet).ok_or(EngineError::NoDocument)?; + let mut built = Vec::new(); + for k in kinds { + let lines = match k { + ReportKind::Answer => answer_report(d, sh, inp), + ReportKind::Limits => limits_report(d, sh, inp)?, + }; + let name = (1..=10_000u32) + .map(|i| format!("{} Report {i}", k.title())) + .find(|n| d.wb.sheet_index(n).is_none() && !built.iter().any(|(b, _): &(String, Lines)| b == n)) + .ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?; + built.push((name, lines)); + } + let names: Vec = built.iter().map(|(n, _)| n.clone()).collect(); + let at = inp.model.sheet; + let count = built.len(); + edit(s, |cx| { + let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true); + for (i, (name, lines)) in built.into_iter().enumerate() { + let mut sheet = Sheet::new(name); + for (r, c, v, b) in lines { + let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT }; + sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style }); + } + for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] { + sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() }); + } + cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet)); + } + for nm in cx.wb.names.iter_mut() { + if let Some(sc) = nm.scope.as_mut() + && *sc >= at + { + *sc += count; + } + } + if cx.wb.active_sheet >= at { + cx.wb.active_sheet += count; + } + cx.structural = true; + Ok(()) + })?; + if let Ok(d) = s.doc_mut() { + for i in 0..count { + d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default()); + } + } + Ok(names) +} + +/// `data.solverReport`: reports for a solution of the sheet's saved model. +fn report(s: &mut Session, p: &Json) -> Result { + let kinds = report_kinds(REPORT_CMD, p)?.ok_or_else(|| bad(REPORT_CMD, "missing `reports` (answer, limits)"))?; + if kinds.is_empty() { + return Err(bad(REPORT_CMD, "`reports` is empty (answer, limits)")); + } + let d = s.doc()?; + // The given model as is, else the saved model with any params given here over it. + let params = match p.get("model") { + Some(m @ Json::Object(_)) => m.clone(), + Some(Json::Null) | None => { + let mut params = saved_model(&d.wb, d.wb.active_sheet).unwrap_or_else(|| json!({})); + if let (Some(base), Some(over)) = (params.as_object_mut(), p.as_object()) { + for (k, v) in over { + base.insert(k.clone(), v.clone()); + } + } + params + } + Some(_) => return Err(bad(REPORT_CMD, "`model` must be data.solver params")), + }; + let model = parse_model(s, ¶ms)?; + let prob = build_problem(&model); + let d = s.doc()?; + let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?; + let current: Vec = model.vars.iter().map(|c| as_num(&sh.value(*c))).collect(); + // `{cell: number}` maps; cells they leave out keep their current values. + let point = |key: &str| -> Result> { + let mut x = current.clone(); + let Some(map) = p.get(key) else { return Ok(x) }; + let map = map.as_object().ok_or_else(|| bad(REPORT_CMD, format!("`{key}` must map cells to numbers")))?; + for (cell, v) in map { + let c = CellRef::parse(&cell.replace('$', "")).ok_or_else(|| bad(REPORT_CMD, format!("not a cell: `{cell}`")))?; + let j = model.vars.iter().position(|v| *v == c).ok_or_else(|| bad(REPORT_CMD, format!("`{cell}` is not a variable cell")))?; + let v = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(REPORT_CMD, format!("`{key}.{cell}` must be a number")))?; + if let Some(e) = x.get_mut(j) { + *e = v; + } + } + Ok(x) + }; + let solution = point("values")?; + let original = if p.get("original").is_some() { point("original")? } else { solution.clone() }; + let budget = params + .get("maxEvaluations") + .and_then(Json::as_f64) + .filter(|v| v.is_finite() && *v >= 1.0) + .map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS)); + let message = str_param(p, "message").unwrap_or("").chars().take(500).collect(); + let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget }; + let sheets = write_reports(s, &kinds, &inp)?; + Ok(json!({"sheets": sheets})) +} + // ---------------------------------------------------------------- command fn run(s: &mut Session, p: &Json) -> Result { @@ -1522,6 +2008,7 @@ fn run(s: &mut Session, p: &Json) -> Result { let gap = params.get("integerOptimality").and_then(Json::as_f64).filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0); let keep = bool_param(¶ms, "keep").unwrap_or(true); let save = bool_param(¶ms, "saveModel").unwrap_or(true); + let reports = report_kinds(CMD, ¶ms)?; let prob = build_problem(&model); let d = s.doc()?; let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?; @@ -1534,66 +2021,15 @@ fn run(s: &mut Session, p: &Json) -> Result { rows: prob.rows.clone(), evals: 0, budget, + target: None, }; - let any_int = prob.integer.iter().any(|b| *b); if model.method == Method::Evolutionary && model.evo.require_bounds && prob.lo.iter().chain(&prob.hi).any(|b| !b.is_finite()) { return Err(EngineError::Other( "The Evolutionary method needs a lower and an upper bound on every variable cell (constraints such as \"A1:A3 <= 10\"), or requireBounds: false." .into(), )); } - let mut not_linear = false; - let mut evo_end = None; - let outcome = if prob.lo.iter().zip(&prob.hi).any(|(l, h)| l > h) { - Outcome::Infeasible - } else { - match model.method { - Method::Simplex => match linearize(&mut ev, &x0) { - Err("nonlinear") | Err("error") => { - not_linear = true; - Outcome::Infeasible - } - Err(_) => Outcome::Limit, - Ok(((cf, f_const), lin)) => { - let mut rows: Vec<(Vec, Sense, f64)> = lin.iter().zip(&prob.rows).map(|((a, k), r)| (a.clone(), r.sense, -k)).collect(); - let c: Vec = match model.goal { - Goal::Max => cf.iter().map(|v| -v).collect(), - Goal::Min => cf.clone(), - Goal::Value(v) => { - rows.push((cf.clone(), Sense::Eq, v - f_const)); - vec![0.0; cf.len()] - } - }; - let size = (rows.len() as u64 + 2 * model.vars.len() as u64 + 1) * (4 * model.vars.len() as u64 + 2 * rows.len() as u64 + 1); - let mut work = LP_WORK.max(size); - let mut relax = |l: &[f64], h: &[f64]| solve_lp(&c, &rows, l, h, &mut work); - if any_int { - branch_and_bound(&prob.lo, &prob.hi, &prob.integer, MAX_NODES_LP, gap, &mut relax).0 - } else { - relax(&prob.lo, &prob.hi) - } - } - }, - Method::Grg => { - let goal = model.goal; - if any_int { - let mut relax = |l: &[f64], h: &[f64]| { - let mut start = x0.clone(); - project(&mut start, l, h); - nlp(&mut ev, &start, l, h, goal) - }; - branch_and_bound(&prob.lo, &prob.hi, &prob.integer, MAX_NODES_NLP, gap, &mut relax).0 - } else { - nlp(&mut ev, &x0, &prob.lo, &prob.hi, goal) - } - } - Method::Evolutionary => { - let (out, end) = evolutionary(&mut ev, &x0, &prob.lo, &prob.hi, &prob.integer, model.goal, &model.evo); - evo_end = Some(end); - out - } - } - }; + let Solve { outcome, not_linear, evo_end } = optimise(&mut ev, &model, model.goal, &x0, &prob.lo, &prob.hi, &prob.integer, gap); let (status, message, solution) = match outcome { _ if not_linear => ("notLinear", "The model isn't linear in the variable cells, so Simplex LP can't solve it. Try GRG Nonlinear.", None), Outcome::Solved(x, _, true) if model.method == Method::Evolutionary => match evo_end { @@ -1630,6 +2066,7 @@ fn run(s: &mut Session, p: &Json) -> Result { values.insert(c.a1(), json!(v)); } } + let original: serde_json::Map = model.vars.iter().zip(&x0).map(|(c, v)| (c.a1(), json!(v))).collect(); let changed = keep && solution.is_some(); if changed || save { let sheet = model.sheet; @@ -1652,19 +2089,27 @@ fn run(s: &mut Session, p: &Json) -> Result { Ok(()) })?; } - Ok(json!({ + let mut out = json!({ "status": status, "message": message, "objective": objective, "values": values, + "original": original, + "model": model_params(&s.doc()?.wb, &model), "evaluations": evaluations, "changed": changed, - })) + }); + // Reports describe a solution; they are part of the solve's undo step. + if let (Some(kinds), Some(x)) = (reports, solution) { + let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x, message: message.to_string(), budget }; + out["reports"] = json!(write_reports(s, &kinds, &inp)?); + } + Ok(out) } #[cfg(test)] mod tests { - use gridcraft_core::CellRef; + use gridcraft_core::{CellRef, Value}; use serde_json::json; use crate::Session; @@ -1678,6 +2123,11 @@ mod tests { let d = s.doc().unwrap(); d.wb.active().unwrap().value(CellRef::parse(a).unwrap()).as_f64().unwrap_or(f64::NAN) } + /// The first saved-model name scoped to the active sheet. + fn saved(s: &Session) -> String { + let d = s.doc().unwrap(); + d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet)).map(|n| n.name.clone()).unwrap_or_default() + } fn set(s: &mut Session, cells: &[(&str, &str)]) { for (c, v) in cells { s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); @@ -1843,6 +2293,120 @@ mod tests { assert_eq!(n(&s, "E1"), 1.0); } + /// Text or number at `a1` on sheet `name`. + fn at(s: &Session, name: &str, a1: &str) -> Value { + let d = s.doc().unwrap(); + d.wb.sheet(d.wb.sheet_index(name).unwrap()).unwrap().value(CellRef::parse(a1).unwrap()) + } + + /// The row of the first cell holding `text` on sheet `name`. + fn row_of(s: &Session, name: &str, text: &str) -> u32 { + let d = s.doc().unwrap(); + let sh = d.wb.sheet(d.wb.sheet_index(name).unwrap()).unwrap(); + (0..60).find(|r| (0..8).any(|c| sh.value(CellRef::new(*r, c)) == Value::text(text))).unwrap() + } + + #[test] + fn answer_and_limits_reports_describe_the_wyndor_glass_solution() { + // Wyndor Glass with labels: doors and windows across, the three plants' hours down. + let mut s = s(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [[null, "Doors", "Windows", "Used", "Available"], ["Batches", 0, 0, null, null], ["Plant 1", 1, 0, null, 4], ["Plant 2", 0, 2, null, 12], ["Plant 3", 3, 2, null, 18], ["Profit", 3, 5, null, null]]}), + ) + .unwrap(); + for r in 3..=6 { + set(&mut s, &[(&format!("D{r}"), &format!("=SUMPRODUCT(B{r}:C{r},$B$2:$C$2)"))]); + } + let solved = s + .execute( + "data.solver", + json!({"objective": "D6", "variables": "B2:C2", "constraints": ["D3:D5 <= E3:E5"], "method": "simplex", "reports": ["answer", "limits"]}), + ) + .unwrap(); + assert_eq!(solved["status"], "optimal", "{solved}"); + assert_eq!(solved["reports"], json!(["Answer Report 1", "Limits Report 1"])); + assert_eq!(solved["original"], json!({"B2": 0.0, "C2": 0.0})); + let d = s.doc().unwrap(); + let names: Vec<&str> = d.wb.sheets.iter().map(|x| x.name.as_str()).collect(); + assert_eq!(names, ["Answer Report 1", "Limits Report 1", "Sheet1"]); + assert_eq!(d.wb.active().unwrap().name, "Sheet1", "the model's sheet stays active"); + assert_eq!(saved(&s), "solver_opt", "the saved model follows its sheet"); + let a = "Answer Report 1"; + assert_eq!(at(&s, a, "A1"), Value::text("GridCraft Answer Report")); + // Objective: $D$6 "Profit Used", from 0 to 36. + let r = row_of(&s, a, "$D$6"); + assert_eq!(at(&s, a, &format!("C{}", r + 1)), Value::text("Profit Used")); + assert_eq!((at(&s, a, &format!("D{}", r + 1)), at(&s, a, &format!("E{}", r + 1))), (Value::number(0.0), Value::number(36.0))); + // Variables: doors 0 → 2, windows 0 → 6, continuous. + let r = row_of(&s, a, "$B$2") + 1; + assert_eq!(at(&s, a, &format!("C{r}")), Value::text("Batches Doors")); + assert_eq!(at(&s, a, &format!("E{r}")), Value::number(2.0)); + assert_eq!(at(&s, a, &format!("F{r}")), Value::text("Contin")); + assert_eq!(at(&s, a, &format!("E{}", r + 1)), Value::number(6.0)); + // Constraints: plant 1 has 2 hours to spare; plants 2 and 3 are binding. + let r = row_of(&s, a, "$D$3") + 1; + assert_eq!(at(&s, a, &format!("E{r}")), Value::text("$D$3<=$E$3")); + assert_eq!((at(&s, a, &format!("F{r}")), at(&s, a, &format!("G{r}"))), (Value::text("Not Binding"), Value::number(2.0))); + for k in 1..=2 { + assert_eq!((at(&s, a, &format!("F{}", r + k)), at(&s, a, &format!("G{}", r + k))), (Value::text("Binding"), Value::number(0.0))); + } + // Limits with the other variable held: doors 0..2 (profit 30..36), windows 0..6 (6..36). + let l = "Limits Report 1"; + let r = row_of(&s, l, "$B$2") + 1; + let row = |r: u32| ["D", "E", "F", "G", "H"].map(|c| at(&s, l, &format!("{c}{r}")).as_f64().unwrap()); + assert_eq!(row(r), [2.0, 0.0, 30.0, 2.0, 36.0]); + assert_eq!(row(r + 1), [6.0, 0.0, 6.0, 6.0, 36.0]); + // data.solverReport writes reports from the saved model and a given solution. + let out = + s.execute("data.solverReport", json!({"reports": ["answer"], "values": {"B2": 2, "C2": 6}, "original": {"B2": 1, "C2": 1}})).unwrap(); + assert_eq!(out["sheets"], json!(["Answer Report 2"])); + let r = row_of(&s, "Answer Report 2", "$D$6") + 1; + assert_eq!(at(&s, "Answer Report 2", &format!("D{r}")), Value::number(8.0)); + // Undo removes the reports and keeps the solution; refused requests write nothing. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 3); + for p in [ + json!({}), + json!({"reports": []}), + json!({"reports": "answer"}), + json!({"reports": ["population"]}), + json!({"reports": ["answer"], "values": {"Z9": 1}}), + json!({"reports": ["answer"], "values": {"B2": "x"}}), + json!({"reports": ["limits"], "constraints": ["D3:D5 <= E3:E5", "B2:C2 int"]}), + json!({"reports": ["limits"], "method": "evolutionary", "constraints": ["D3:D5 <= E3:E5", "B2:C2 <= 10"]}), + ] { + assert!(s.execute("data.solverReport", p.clone()).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), 3); + // With the solve, its reports and the saved model undone (one step), the result still + // describes the solution. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!((s.doc().unwrap().wb.sheets.len(), n(&s, "B2")), (1, 0.0)); + let mut p = solved.clone(); + p["reports"] = json!(["answer"]); + assert_eq!(s.execute("data.solverReport", p).unwrap()["sheets"], json!(["Answer Report 1"])); + let r = row_of(&s, "Answer Report 1", "$B$2") + 1; + assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(2.0)); + } + + #[test] + fn limits_report_of_a_nonlinear_model() { + // The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the + // width ranges over 0..5, the area over 0..25. + let mut s = s(); + set(&mut s, &[("A1", "1"), ("A2", "1"), ("B1", "=A1*A2"), ("B2", "=2*A1+2*A2")]); + let r = + s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"], "reports": ["limits"]})).unwrap(); + assert_eq!(r["reports"], json!(["Limits Report 1"]), "{r}"); + let l = "Limits Report 1"; + let r = row_of(&s, l, "$A$1") + 1; + let got: Vec = ["E", "F", "G", "H"].iter().map(|c| at(&s, l, &format!("{c}{r}")).as_f64().unwrap()).collect(); + for (g, want) in got.iter().zip([0.0, 0.0, 5.0, 25.0]) { + assert!((g - want).abs() < 1e-3, "{got:?}"); + } + } + #[test] fn infeasible_unbounded_and_nonlinear_models_are_reported() { let mut s = s(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 53ac8b8..c881716 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2401,6 +2401,18 @@ fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: & d.values.insert("restore".into(), json!(true)); } } + // Reports describe the solution whether it is kept or not. + let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty()); + if solved { + ui.add_space(4.0); + ui.label("Reports:"); + for (key, label) in [("answerReport", "Answer"), ("limitsReport", "Limits")] { + let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut on, label).changed() { + d.values.insert(key.into(), json!(on)); + } + } + } let mut confirm = false; ok_cancel(ui, &mut confirm, open); if confirm { @@ -2408,6 +2420,16 @@ fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: & if changed && restore { app.run_or_alert("edit.undo", json!({})); } + let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")] + .into_iter() + .filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true)) + .map(|(_, kind)| kind) + .collect(); + if solved && !reports.is_empty() { + let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null); + let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"), "message": field("message")}); + app.run_or_alert("data.solverReport", params); + } *open = false; } } diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 7093933..fc9ee52 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -460,6 +460,33 @@ fn solver_dialog_loads_the_saved_model_and_results_can_restore() { assert_eq!(value(&h, "B1"), Value::Number(0.0)); } +#[test] +fn solver_results_write_the_chosen_reports() { + let mut s = blank(); + for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let r = s + .execute( + "data.solver", + json!({"objective": "B1", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", "B3 <= 18"], "method": "simplex"}), + ) + .unwrap(); + let mut h = harness(s); + // Restore Original Values with the Answer and Limits reports: the cells go back, the reports stay. + h.state_mut().open_dialog("solverResults", r); + for key in ["restore", "answerReport", "limitsReport"] { + h.state_mut().dialog.as_mut().unwrap().values.insert(key.into(), json!(true)); + } + h.run_steps(2); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + assert!(h.state().message.is_none(), "{:?}", h.state().message); + let names: Vec = h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect(); + assert_eq!(names, ["Answer Report 1", "Limits Report 1", "Sheet1"]); + assert_eq!(value(&h, "B1"), Value::Number(0.0)); +} + #[test] fn about_renders_every_tab() { let mut h = harness(blank()); diff --git a/docs/gaps.md b/docs/gaps.md index a1cfb11..5d3f509 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -106,8 +106,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) - Solver (`data.solver`, #327) has Simplex LP with integer/binary branch and bound, a local - nonlinear method (augmented Lagrangian, not GRG) and the Evolutionary method; AllDifferent - constraints, the Solver Options dialog, reports and Load/Save Model remain. The Analysis ToolPak + nonlinear method (augmented Lagrangian, not GRG), the Evolutionary method, and Answer and + Limits reports; AllDifferent constraints, the Solver Options dialog, the Sensitivity and + Population reports and Load/Save Model remain. The Analysis ToolPak (`data.analysisToolPak`) has 13 of 19 tools. Forecast Sheet (`data.forecastSheet`) and the FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 12–20 h. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e2c0511..45ce879 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -87,7 +87,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | -| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; no reports or options dialog). Missing: Forecast Sheet, Analyze Data | +| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; no Sensitivity report or options dialog). Missing: Forecast Sheet, Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | From 88cf4cf92d16bdd21b42f118f04ce9316fd836dc Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:25:17 +0000 Subject: [PATCH 012/162] INDEX: a huge area number gives #REF! instead of overflowing --- crates/calc/src/eval.rs | 3 ++- crates/calc/src/tests.rs | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..65de8b0 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1152,7 +1152,8 @@ impl<'h> Evaluator<'h> { } match src { Ev::R(areas) => { - let Some(a) = areas.get((area_n - 1).max(0) as usize).copied() else { return err(CellError::Ref) }; + let area = usize::try_from((area_n as u64).saturating_sub(1)).ok(); + let Some(a) = area.and_then(|i| areas.get(i)).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 two_args && r.height() == 1 { (1, row) } else { (row, col) }; diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..aa9d460 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -157,6 +157,21 @@ fn index_with_arrays_of_positions() { assert_eq!(t.num("E7"), 6.0); } +#[test] +fn index_area_number_overflow() { + let mut t = T::new(); + t.set("A1", "7"); + t.set("D1", "9"); + for (area, expected) in [("1", 7.0), ("2", 9.0)] { + t.set("G5", &format!("=INDEX((A1:B2,D1:E2),1,1,{area})")); + assert_eq!(t.num("G5"), expected, "area {area}"); + } + for area in ["3", "1E300", "-1", "-1E300"] { + t.set("G5", &format!("=INDEX((A1:B2,D1:E2),1,1,{area})")); + assert_eq!(t.get("G5"), Value::Error(CellError::Ref), "area {area}"); + } +} + #[test] fn errors_and_cycles() { let mut t = T::new(); From c7701f6e137cd7594a242ba3cd6b2bfde55879c2 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:40:27 -0500 Subject: [PATCH 013/162] =?UTF-8?q?Functions=20given=20by=20name:=20GROUPB?= =?UTF-8?q?Y(=E2=80=A6,SUM),=20BYROW(=E2=80=A6,SUM),=20MAP(=E2=80=A6,ABS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel lets a worksheet function be passed by name where a function is expected. In GridCraft GROUPBY(A2:A9,B2:B9,SUM) and PIVOTBY gave #NAME? (their documented form), and BYROW, BYCOL, MAP, REDUCE, SCAN and MAKEARRAY gave #VALUE!. A name that is a worksheet function, and not a LET/LAMBDA variable or a defined name, now works as a function there. Reading a file, the _xleta. prefix Excel writes for these names is removed. --- crates/calc/src/eval.rs | 43 +++++++++++++++++++++++++++++++----- crates/calc/src/tests.rs | 21 ++++++++++++++++++ crates/formula/src/parser.rs | 4 ++++ crates/formula/src/tests.rs | 1 + 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..357e3bf 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -737,6 +737,20 @@ impl<'h> Evaluator<'h> { Ev::V(Value::Bool(r)) } "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), + "GROUPBY" | "PIVOTBY" => { + // The aggregation may be a function given by name (`SUM`): pass it as its name. + let i = if name == "GROUPBY" { 2 } else { 3 }; + match args.get(i).and_then(|a| self.function_by_name(a)) { + Some(f) => { + let mut args = args.to_vec(); + if let Some(a) = args.get_mut(i) { + *a = Expr::Text(f.into()); + } + self.builtin(name, &args) + } + None => self.builtin(name, args), + } + } "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => { self.host.note_volatile(); self.builtin(name, args) @@ -903,7 +917,26 @@ impl<'h> Evaluator<'h> { r } - fn lambda_arg(&mut self, e: Option<&Expr>) -> Option> { + /// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`, + /// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has + /// that name. + fn function_by_name(&self, e: &Expr) -> Option { + let Expr::Name(n) = e else { return None }; + if self.env.iter().any(|(k, _)| k.eq_ignore_ascii_case(n)) || self.host.workbook().name(n, self.sheet).is_some() { + return None; + } + let u = n.to_ascii_uppercase(); + gridcraft_functions::lookup(&u).map(|_| u) + } + + /// The function argument of a LAMBDA helper; a function given by name takes `arity` + /// arguments. + fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option> { + if let Some(f) = e.and_then(|x| self.function_by_name(x)) { + let params: Vec = (0..arity).map(|i| format!("_xleta.{i}")).collect(); + let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect()); + return Some(Arc::new(Lambda { params, body, env: Vec::new() })); + } match e.map(|x| self.eval(x)) { Some(Ev::L(l)) => Some(l), _ => None, @@ -922,7 +955,7 @@ impl<'h> Evaluator<'h> { if args.len() < 2 { return err(CellError::Value); } - let Some(l) = self.lambda_arg(args.last()) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) }; let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); @@ -945,7 +978,7 @@ impl<'h> Evaluator<'h> { } let mut acc = self.value(&args[0]); let arr = as_array(self.value(&args[1])); - let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; if l.params.len() != 2 { return err(CellError::Value); } @@ -969,7 +1002,7 @@ impl<'h> Evaluator<'h> { return err(CellError::Value); } let arr = as_array(self.value(&args[0])); - let Some(l) = self.lambda_arg(args.get(1)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; let by_row = name == "BYROW"; let n = if by_row { arr.rows } else { arr.cols }; @@ -998,7 +1031,7 @@ impl<'h> Evaluator<'h> { if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 { return err(CellError::Value); } - let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; if l.params.len() != 2 { return err(CellError::Value); } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..5d71a26 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -501,6 +501,27 @@ fn let_lambda() { assert_eq!(t.num("A4"), 14.0); } +#[test] +fn functions_given_by_name() { + let mut t = T::new(); + for (i, (k, v)) in [("a", 1), ("b", -2), ("a", 3)].iter().enumerate() { + t.set(&format!("A{}", i + 1), k); + t.set(&format!("B{}", i + 1), &v.to_string()); + } + t.set("D1", "=BYROW(B1:B3,SUM)"); + assert_eq!(t.num("D2"), -2.0); + t.set("E1", "=MAP(B1:B3,ABS)"); + assert_eq!(t.num("E2"), 2.0); + t.set("F1", "=REDUCE(0,B1:B3,SUM)"); + assert_eq!(t.num("F1"), 2.0); + t.set("G1", "=GROUPBY(A1:A3,B1:B3,SUM,0,0)"); + assert_eq!(t.get("G1"), Value::from("a")); + assert_eq!(t.num("H1"), 4.0); + // A LET variable of that name is still the variable. + t.set("J1", "=LET(ABS,LAMBDA(x,x*10),SUM(MAP(B1:B3,ABS)))"); + assert_eq!(t.num("J1"), 20.0); +} + #[test] fn names_and_sheets() { let mut t = T::new(); diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 4fd3b7b..568f7c3 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -357,6 +357,10 @@ impl Parser { if w.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Err(self.err(format!("unexpected `{w}`"))); } + // A function passed by name is saved as `_xleta.SUM`. + if let Some(f) = w.get(..7).filter(|p| p.eq_ignore_ascii_case("_xleta.")).and_then(|_| w.get(7..)) { + return Ok(Expr::Name(f.to_ascii_uppercase())); + } Ok(Expr::Name(w.to_string())) } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c2..ad9adc2 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -48,6 +48,7 @@ fn normalises() { assert_eq!(rt("=sum(a1:b2)"), "SUM(A1:B2)"); assert_eq!(rt("= 1 + 2"), "1+2"); assert_eq!(rt("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); + assert_eq!(rt("_xlfn.GROUPBY(A1:A3,B1:B3,_xleta.SUM)"), "GROUPBY(A1:A3,B1:B3,SUM)"); assert_eq!(rt("true"), "TRUE"); assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1"); } From 1ea44bf00bf0eae265f42ec9b9ecf08f719c4b1c Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:34:18 +0000 Subject: [PATCH 014/162] INDIRECT: R1C1 offsets past the sheet give #REF! instead of overflowing --- crates/calc/src/eval.rs | 59 ++++++++++++++++++++++++---------------- crates/calc/src/tests.rs | 31 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 23 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..f50b99c 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1081,7 +1081,15 @@ impl<'h> Evaluator<'h> { v.scalar().to_bool().unwrap_or(true) } }; - let text = if a1 { text } else { r1c1_to_a1(&text, self.at).unwrap_or(text) }; + let text = if a1 { + text + } else { + match r1c1_to_a1(&text, self.at) { + Ok(Some(a1_text)) => a1_text, + Ok(None) => text, + Err(e) => return err(e), + } + }; match gridcraft_formula::parse(&text) { Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) { r @ Ev::R(_) => r, @@ -1406,37 +1414,42 @@ fn same_kind(a: &Value, b: &Value) -> bool { std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty()) } -/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). -pub fn r1c1_to_a1(s: &str, at: CellRef) -> Option { +/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is +/// not R1C1; `Err` means it is R1C1 but points past the sheet. +pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result, CellError> { let (sheet, body) = match s.rsplit_once('!') { Some((sh, b)) => (format!("{sh}!"), b), None => (String::new(), s), }; - let conv = |part: &str| -> Option { + let conv = |part: &str| -> Result, CellError> { let u = part.to_ascii_uppercase(); - let rest = u.strip_prefix('R')?; - let (rpart, cpart) = rest.split_once('C')?; - let num = |p: &str, base: u32| -> Option<(u32, bool)> { - if p.is_empty() { - return Some((base, false)); - } - if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) { - let d: i64 = inner.parse().ok()?; - let v = base as i64 + d; - return if v >= 0 { Some((v as u32, false)) } else { None }; - } - let v: u32 = p.parse().ok()?; - Some((v.checked_sub(1)?, true)) + let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) }; + // `None`: not R1C1. `Some(None)`: R1C1, but past the sheet. + let num = |p: &str, base: u32, max: u32| -> Option> { + let (v, absolute) = if p.is_empty() { + (Some(i64::from(base)), false) + } else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) { + (i64::from(base).checked_add(inner.parse().ok()?), false) + } else { + (p.parse::().ok()?.checked_sub(1).map(i64::from), true) + }; + Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute))) }; - let (r, ra) = num(rpart, at.row)?; - let (c, ca) = num(cpart, at.col)?; - Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1)) + let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) }; + let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) }; + Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1))) }; let out = match body.split_once(':') { - Some((a, b)) => format!("{}:{}", conv(a)?, conv(b)?), - None => conv(body)?, + Some((a, b)) => match (conv(a)?, conv(b)?) { + (Some(a), Some(b)) => format!("{a}:{b}"), + _ => return Ok(None), + }, + None => match conv(body)? { + Some(x) => x, + None => return Ok(None), + }, }; - Some(format!("{sheet}{out}")) + Ok(Some(format!("{sheet}{out}"))) } /// The aggregates that can fold a reference without building an array. diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..8ddce37 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -412,6 +412,37 @@ fn references_functions() { assert_eq!(t.num("B6"), 105.0); } +#[test] +fn indirect_r1c1_out_of_bounds() { + for reference in ["R[9223372036854775807]C", "RC[9223372036854775807]", "R[4294967296]C", "R[4294967291]C", "R[2000000]C", "RC[20000]"] { + let mut t = T::new(); + t.set("D5", &format!("=ROW(INDIRECT(\"{reference}\",FALSE))")); + assert_eq!(t.get("D5"), Value::Error(CellError::Ref), "{reference}"); + } +} + +#[test] +fn indirect_r1c1_past_the_sheet_does_not_fall_back_to_a1() { + let mut t = T::new(); + t.set("RC16385", "78"); + for reference in ["RC16385", "rc16385", "RC1:RC16385"] { + t.set("D5", &format!("=INDIRECT(\"{reference}\",FALSE)")); + assert_eq!(t.get("D5"), Value::Error(CellError::Ref), "{reference}"); + } +} + +#[test] +fn indirect_r1c1_valid_references() { + let mut t = T::new(); + t.set("E6", "11"); + t.set("A1", "22"); + t.set("D4", "33"); + for (reference, expected) in [("R[1]C[1]", 11.0), ("R1C1", 22.0), ("R[-1]C", 33.0)] { + t.set("D5", &format!("=INDIRECT(\"{reference}\",FALSE)")); + assert_eq!(t.num("D5"), expected, "{reference}"); + } +} + #[test] fn relative_names() { let mut t = T::new(); From a4c6ac3efff593fcaf0a6a233f155ec3d2ea6e9d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:08 +0200 Subject: [PATCH 015/162] Reject out-of-range numeric filterBy columns instead of overflowing (#573) --- crates/engine/src/cmd/data.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3ba..f7133e0 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -122,7 +122,7 @@ pub fn specs() -> Vec { fn col_param(v: Option<&Json>, base: u32) -> Option { match v? { Json::String(s) => gridcraft_core::letters_to_col(s.trim_start_matches('$')), - Json::Number(n) => n.as_u64().map(|o| base + o as u32), + Json::Number(n) => n.as_u64().and_then(|o| u32::try_from(o).ok()).and_then(|o| base.checked_add(o)), _ => None, } } @@ -488,7 +488,12 @@ fn filter_by(s: &mut Session, p: &Json) -> Result { s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))? } }; - let col = col_param(p.get("column"), af.range.start.col).ok_or_else(|| bad("data.filterBy", "missing `column`"))?; + let col = match col_param(p.get("column"), af.range.start.col) { + Some(c) => c, + // A number too large to be a column is outside the filter range, not a missing parameter. + None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")), + None => return Err(bad("data.filterBy", "missing `column`")), + }; if col < af.range.start.col || col > af.range.end.col { return Err(bad("data.filterBy", "column outside the filter range")); } @@ -1278,6 +1283,20 @@ fn subtotal(s: &mut Session, p: &Json) -> Result { mod tests { use super::*; + #[test] + fn filter_by_huge_numeric_column_is_rejected_not_overflowed() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); + s.execute("data.filter", json!({"range": "B1:C3"})).unwrap(); + for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] { + let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err(); + assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}"); + } + let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap(); + assert_eq!(ok["hiddenRows"], json!(1)); + } + #[test] fn partial_validation_edits_keep_the_rest_of_the_old_rule() { let mut s = Session::new(); From afd61a7c4a67624959408e788389b854a34c5b0d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:32 +0200 Subject: [PATCH 016/162] Parse two-digit years in spaced month-name dates (#566) --- crates/core/src/parse.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index a612a0b..a943103 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -252,10 +252,14 @@ fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> { // "Mar 4, 2026" / "March 4 2026" / "4 March 2026" let words: Vec<&str> = t.split([' ', ',']).filter(|w| !w.is_empty()).collect(); if words.len() == 3 { + // Two-digit years follow the same 2029 rule as the dashed forms above. + let expand = |w: &str, y: i64| if w.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y }; if let (Some(m), Some(d), Some(y)) = (month_from_name(words[0]), num(words[1]), num(words[2])) { + let y = expand(words[2], y); return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy")); } if let (Some(d), Some(m), Some(y)) = (num(words[0]), month_from_name(words[1]), num(words[2])) { + let y = expand(words[2], y); return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy")); } } @@ -317,6 +321,16 @@ mod tests { parse_input(s, DateSystem::D1900) } + #[test] + fn month_name_short_year() { + for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] { + assert_eq!(p(s).value, Value::Number(46085.0), "{s}"); + } + assert_eq!(p("Mar 4, 99").value, p("Mar 4, 1999").value); + assert_eq!(p("Mar 4, 1776").value, Value::text("Mar 4, 1776")); + assert_eq!(p("Feb 30, 26").value, Value::text("Feb 30, 26")); + } + #[test] fn numbers() { assert_eq!(p("42").value, Value::Number(42.0)); From c5d43a33e6ee1267b3e0658f198b60c54a730447 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:34 +0200 Subject: [PATCH 017/162] Reject table.resize to a range that does not overlap the table (#575) --- crates/engine/src/cmd/extra.rs | 2 +- crates/engine/tests/table_resize_overlap.rs | 29 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/table_resize_overlap.rs diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index a08f080..7eae0ff 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -812,7 +812,7 @@ fn table_resize(s: &mut Session, p: &Json) -> Result { let sheet = cx.sheet_mut(si)?; let headers: Vec = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect(); let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?; - if r.start.row != t.range.start.row { + if r.start.row != t.range.start.row || !r.intersects(&t.range) { return Err(EngineError::Other( "The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(), )); diff --git a/crates/engine/tests/table_resize_overlap.rs b/crates/engine/tests/table_resize_overlap.rs new file mode 100644 index 0000000..f8442cc --- /dev/null +++ b/crates/engine/tests/table_resize_overlap.rs @@ -0,0 +1,29 @@ +use gridcraft_engine::Session; +use serde_json::json; + +fn table() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"]]})).unwrap(); + s.execute("insert.table", json!({"range":"A1:B2", "header":true, "name":"T"})).unwrap(); + s +} + +fn table_range(s: &Session) -> String { + s.doc().unwrap().wb.active().unwrap().tables[0].range.a1() +} + +#[test] +fn resize_to_disjoint_range_is_rejected_and_keeps_table() { + let mut s = table(); + assert!(s.execute("table.resize", json!({"table":"T", "range":"D1:E2"})).is_err()); + assert_eq!(table_range(&s), "A1:B2"); + assert_eq!(s.doc().unwrap().wb.active().unwrap().tables[0].columns[1].name, "Qty"); +} + +#[test] +fn resize_to_overlapping_range_still_works() { + let mut s = table(); + s.execute("table.resize", json!({"table":"T", "range":"B1:C2"})).unwrap(); + assert_eq!(table_range(&s), "B1:C2"); +} From 593776f0defab13d6d32526e1cb6988969b80129 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:28:36 +0200 Subject: [PATCH 018/162] Keep a defined name that shares a table's name when the table is renamed (#574) --- crates/engine/src/cmd/insert.rs | 4 +++- crates/engine/src/tests.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 392c5ee..b967f06 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -470,6 +470,8 @@ fn table_rename(s: &mut Session, p: &Json) -> Result { for i in 0..cx.wb.sheets.len() { let keys: Vec<(CellRef, std::sync::Arc)> = cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); + // A bare name that is also a defined name resolves to the defined name, so it is not the table. + let shadowed = cx.wb.name(&old, i).is_some(); let Some(sh) = cx.wb.sheet_mut(i) else { continue }; for (c, f) in keys { let Some(e) = f.expr() else { continue }; @@ -478,7 +480,7 @@ fn table_rename(s: &mut Session, p: &Json) -> Result { st.table = name.clone(); gridcraft_formula::Expr::Struct(st) } - gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()), + gridcraft_formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()), o => o, }); if let Some(cell) = sh.cells.get_mut(c) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..3975e7c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1018,6 +1018,21 @@ fn tables_and_structured_refs() { assert_eq!(v(&s, "B4"), Value::Number(7.0)); } +#[test] +fn table_rename_keeps_a_defined_name_with_the_same_name() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap(); + s.execute("cell.set", json!({"cell": "D1", "input": "100"})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Data"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Data", "refersTo": "=Sheet1!$D$1"})).unwrap(); + s.execute("cell.set", json!({"cell": "E1", "input": "=Data"})).unwrap(); + s.execute("cell.set", json!({"cell": "F1", "input": "=SUM(Data[Qty])"})).unwrap(); + s.execute("table.rename", json!({"table": "Data", "name": "Later"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "E1"})).unwrap()["formula"], "=Data"); + assert_eq!(s.execute("cell.get", json!({"cell": "F1"})).unwrap()["formula"], "=SUM(Later[Qty])"); + assert_eq!((v(&s, "E1"), v(&s, "F1")), (Value::Number(100.0), Value::Number(7.0))); +} + #[test] fn slicer_filters_table() { let mut s = s(); From ea6bb98fc7e84e9bdcf9f5823ccb6b0a6b7699c1 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:28:50 +0200 Subject: [PATCH 019/162] fix(xlsx): keep leading and trailing whitespace in document properties on reopen (#554) --- crates/xlsx/src/read.rs | 4 ++-- crates/xlsx/src/tests/roundtrip.rs | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index a25444d..a83432b 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -301,7 +301,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if let Some(r) = find_rel(&root_rels, "core-properties").cloned() && let Ok(Some(x)) = cx.pkg.read_xml(&r.target) { - let get = |n: &str| x.child(n).map(|e| e.text.trim().to_string()).unwrap_or_default(); + let get = |n: &str| x.child(n).map(|e| e.text.clone()).unwrap_or_default(); wb.props.title = get("title"); wb.props.subject = get("subject"); wb.props.author = get("creator"); @@ -311,7 +311,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if let Some(r) = find_rel(&root_rels, "extended-properties").cloned() && let Ok(Some(x)) = cx.pkg.read_xml(&r.target) { - wb.props.company = x.child("Company").map(|e| e.text.trim().to_string()).unwrap_or_default(); + wb.props.company = x.child("Company").map(|e| e.text.clone()).unwrap_or_default(); } // A VBA project is carried through unchanged: GridCraft never runs macros, but preserving the diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 9601572..b800e29 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1014,3 +1014,18 @@ fn package_kind_sets_workbook_content_type() { } assert_eq!(crate::XlsxKind::from_path("noext"), crate::XlsxKind::Workbook); } + +#[test] +fn doc_props_keep_boundary_whitespace() { + let mut wb = Workbook::new(); + wb.props = DocProps { + title: " Title ".into(), + subject: "\n".into(), + author: "\tMe\t".into(), + company: " Co ".into(), + keywords: " a, b ".into(), + description: "\n d\n".into(), + }; + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.props, wb.props); +} From afe9a6301f062315ede862e34a38f46d5199b450 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:28:53 +0200 Subject: [PATCH 020/162] SUBTOTAL 109-111 leave out nested subtotals like 1-11 (#556) --- crates/calc/src/eval.rs | 3 ++- crates/calc/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..b76ab66 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1315,7 +1315,8 @@ impl<'h> Evaluator<'h> { // SUBTOTAL documentation); 101-111 also leave out rows hidden by hand. let skip_filtered = name == "SUBTOTAL"; let skip_errors = matches!(options, 2 | 3 | 6 | 7); - let skip_nested = options <= 3; + // SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3. + let skip_nested = name == "SUBTOTAL" || options <= 3; let mut values = Vec::new(); let mut k = None; let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest }; diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..29d2292 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -562,6 +562,29 @@ fn subtotal_skips_hidden() { assert_eq!(t.num("B2"), 6.0); } +#[test] +fn subtotal_109_skips_nested_subtotal() { + let mut t = T::new(); + t.set("A1", "2"); + t.set("A2", "=SUBTOTAL(9,A1)"); + t.set("C1", "=SUBTOTAL(109,A1:A2)"); + t.set("C2", "=SUBTOTAL(9,A1:A2)"); + t.set("C3", "=SUBTOTAL(109,A1)"); + assert_eq!(t.num("C1"), 2.0); + assert_eq!(t.num("C2"), 2.0); + assert_eq!(t.num("C3"), 2.0); + // Manually hidden rows are still left out by 109 only. + let mut t = T::new(); + t.set("A1", "1"); + t.set("A2", "2"); + t.set("A3", "=SUBTOTAL(9,A1:A2)"); + t.wb.sheet_mut(0).unwrap().rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); + t.set("B1", "=SUBTOTAL(109,A1:A3)"); + t.set("B2", "=SUBTOTAL(9,A1:A3)"); + assert_eq!(t.num("B1"), 1.0); + assert_eq!(t.num("B2"), 3.0); +} + /// SUBTOTAL leaves out rows a filter hides for every function number, while 1-11 keep rows /// hidden by hand (as in ConditionalFormattingSamples.xlsx, where `SUBTOTAL(9,Table69[Sales])` /// under a filtered table adds only the visible rows). From 1f16c2e268e482ff2b555de0b97ee71745b6512f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:06 +0200 Subject: [PATCH 021/162] fix(calc): recalculate dependents of a cleared spill past 65536 cells (#558) --- crates/calc/src/recalc.rs | 4 ++-- crates/engine/src/tests.rs | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 553e764..b391821 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -898,11 +898,11 @@ impl Calc { // Spill areas of changed anchors also change. for &k in changed { if let Some(r) = wb.sheet(k.0).and_then(|s| s.spill_ranges.get(&k.1)) { - seeds.extend(r.iter().take(65536).map(|c| (k.0, c))); + seeds.extend(r.iter().map(|c| (k.0, c))); } } for (si, r) in &cleared { - seeds.extend(r.iter().take(65536).map(|c| (*si, c))); + seeds.extend(r.iter().map(|c| (*si, c))); } let t0 = prof_now(); let dirty = self.dirty_closure(wb, &seeds); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..634f8e2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1617,6 +1617,17 @@ fn replacing_a_spilling_formula_clears_its_spill() { assert_eq!((v(&s, "A2"), v(&s, "B1")), (Value::Empty, Value::Number(0.0))); } +#[test] +fn clearing_a_spill_past_65536_cells_updates_a_late_dependent() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(65537)"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=A65536"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=A65537"})).unwrap(); + assert_eq!(v(&s, "C2"), Value::Number(65537.0)); + s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); + assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(0.0), Value::Number(0.0))); +} + #[test] fn range_ending_in_index_recalculates() { let mut s = s(); From 30fe8c4caeac27bfeeeb14de6bdce0fb957a3e9f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:48 +0200 Subject: [PATCH 022/162] Fix chart rasterizer panic on negative rectangle extents (#542) --- crates/chart/src/raster.rs | 3 ++- crates/chart/src/tests.rs | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/chart/src/raster.rs b/crates/chart/src/raster.rs index 5da9d35..67ed53e 100644 --- a/crates/chart/src/raster.rs +++ b/crates/chart/src/raster.rs @@ -138,7 +138,8 @@ fn circle(cx: f32, cy: f32, r: f32) -> Vec<[f32; 2]> { } fn rounded_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Vec<[f32; 2]> { - let r = r.clamp(0.0, w.min(h) / 2.0); + // A negative or NaN extent gives an upper bound of 0 (clamp panics if max < min or NaN). + let r = r.clamp(0.0, (w.min(h) / 2.0).max(0.0)); if r < 0.5 { return vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]]; } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a942677..5bfaa8b 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -420,6 +420,16 @@ fn rasterize_bar_center_has_bar_color() { assert!(rasterize(&[], 100_000, 100_000, [0; 4]).is_empty()); } +#[test] +fn rasterize_does_not_panic_on_negative_rect_extents() { + let red = [255, 0, 0, 255]; + for (w, h) in [(-10.0, 10.0), (10.0, -10.0), (-10.0, -10.0), (f32::NAN, f32::NAN)] { + let rect = Prim::Rect { x: 5.0, y: 5.0, w, h, fill: Some(red), stroke: Some((red, 2.0)), radius: 0.0 }; + let img = rasterize(&[rect], 20, 20, [0, 0, 0, 0]); + assert_eq!(img.len(), 20 * 20 * 4); + } +} + #[test] fn pie_starts_at_twelve_clockwise() { let c = chart(ChartKind::Pie); From 2eb5ef5b0d430f0a2c8bb9c91d04b52c520e94d2 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:48 +0200 Subject: [PATCH 023/162] Return #NUM! from PROB for zero probabilities (#539) --- crates/functions/src/stat.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 25449bd..c166cc3 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -461,7 +461,7 @@ fn prob(a: &[Arg], _: C) -> R { let (xs, ps) = arg_pairs(a, 0, 1)?; let lo = num(a, 2)?; let hi = if has(a, 3) { num(a, 3)? } else { lo }; - if ps.iter().any(|&p| !(0.0..=1.0).contains(&p)) { + if ps.iter().any(|&p| p <= 0.0 || p > 1.0) { return Err(CellError::Num); } let total: f64 = ps.iter().sum(); @@ -2472,7 +2472,10 @@ mod tests { let p = c(&[0.2, 0.3, 0.1, 0.4]); close(ev("PROB", vec![x.clone(), p.clone(), n(2.0)]), 0.1); close(ev("PROB", vec![x.clone(), p, n(1.0), n(3.0)]), 0.8); - is_err(ev("PROB", vec![x, c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num); + is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num); + // Excel: any probability <= 0 is #NUM!, even when it sums to one and lies outside the limits. + is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.0, 0.25, 0.25]), n(0.0), n(3.0)]), CellError::Num); + is_err(ev("PROB", vec![x, c(&[0.5, 0.0, 0.25, 0.25]), n(2.0), n(3.0)]), CellError::Num); } #[test] From 358334c7555ec236403712beeb01a63911d3a3a6 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:56 +0200 Subject: [PATCH 024/162] Return #VALUE! from FIND and SEARCH when the start is past the text end (#541) --- crates/functions/src/text.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 723cd4a..05abbbc 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -214,7 +214,7 @@ fn find_impl(a: &[Arg], wild: bool) -> R { let hay = text(a, 1)?; let start = opt_num(a, 2, 1.0)?; let hc = chars(&hay); - if (start.is_nan() || start < 1.0) || start > hc.len() as f64 + 1.0 { + if (start.is_nan() || start < 1.0) || start > hc.len().max(1) as f64 { return Err(CellError::Value); } let start = start as usize - 1; @@ -1032,6 +1032,10 @@ mod tests { close(ev("SEARCH", vec![t("~*"), t("a*b")]), 2.0); is_err(ev("SEARCH", vec![t("z"), t("abc")]), CellError::Value); is_err(ev("SEARCH", vec![t("a"), t("abc"), n(5.0)]), CellError::Value); + for f in ["FIND", "SEARCH"] { + close(ev(f, vec![t(""), t("abc"), n(3.0)]), 3.0); + is_err(ev(f, vec![t(""), t("abc"), n(4.0)]), CellError::Value); + } assert_eq!(ev("EXACT", vec![t("a"), t("A")]), Value::Bool(false)); assert_eq!(ev("EXACT", vec![t("a"), t("a")]), Value::Bool(true)); } From d64b30b3a462973e27e1a1c9bbdee4153d4306ab Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:56 +0200 Subject: [PATCH 025/162] Funnel chart: hide value labels when data labels are off (#544) --- crates/chart/src/render.rs | 12 +++++++----- crates/chart/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 63d30d9..d36e2ab 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -254,7 +254,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { Doughnut => pie(ctx, chart, data, area, 0.5, false), Sunburst => pie(ctx, chart, data, area, 0.2, true), Treemap => treemap(ctx, chart, data, area), - Funnel => funnel(ctx, data, area), + Funnel => funnel(ctx, chart, data, area), Radar => radar(ctx, chart, data, area), _ => { let area = axis_titles(ctx, chart, area); @@ -1301,7 +1301,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { // ---------------------------------------------------------------- funnel -fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let Some(s) = data.series.first() else { return }; let n = data.categories.len().max(s.values.len()).min(MAX_CATS); if n == 0 { @@ -1331,9 +1331,11 @@ fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) { } if show { let yc = y + bh / 2.0; - let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); - let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; - ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); + if chart.data_labels { + let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); + let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; + ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); + } let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a942677..f4a12ff 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -902,3 +902,25 @@ fn union_series_references() { c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000)); let _ = resolve(&wb, 0, &c); } + +#[test] +fn funnel_value_labels_follow_the_data_labels_flag() { + let data = ChartData { + categories: vec!["a".into(), "b".into(), "c".into()], + series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)], + }; + let mut c = chart(ChartKind::Funnel); + c.data_labels = false; + let off = render(&c, &data, 500.0, 350.0, &ApproxMeasure); + c.data_labels = true; + let on = render(&c, &data, 500.0, 350.0, &ApproxMeasure); + let values = ["$11,111", "$22,222", "$33,333"]; + for v in values { + assert!(texts(&on).contains(&v), "{v} missing with labels on"); + assert!(!texts(&off).contains(&v), "{v} drawn with labels off"); + } + for cat in ["a", "b", "c"] { + assert!(texts(&off).contains(&cat)); + assert!(texts(&on).contains(&cat)); + } +} From b5827f41985c56f4c355b40f33ab47fe12fc3a6b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:19 +0200 Subject: [PATCH 026/162] Spelling: skip suggestions for words longer than 64 characters (#532) --- crates/engine/src/cmd/spelling.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 1f14014..1dd3df7 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -64,7 +64,14 @@ fn known(s: &Session, w: &str) -> bool { false } +/// Longest word we generate suggestions for. Candidate text grows with the square of the word length, so longer +/// tokens (no real word is this long) get no suggestions instead of an unbounded allocation. +const MAX_SUGGEST_LEN: usize = 64; + fn edits1(w: &str) -> Vec { + if w.chars().count() > MAX_SUGGEST_LEN { + return Vec::new(); + } let chars: Vec = w.chars().collect(); let mut out = Vec::new(); let letters = "abcdefghijklmnopqrstuvwxyz"; @@ -177,3 +184,16 @@ fn thesaurus(_: &mut Session, p: &Json) -> Result { related.truncate(20); Ok(json!(related)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn edits1_is_capped_for_very_long_words() { + assert_eq!(edits1("helo").len(), 54 * 4 + 25); + assert_eq!(edits1(&"a".repeat(MAX_SUGGEST_LEN)).len(), 54 * MAX_SUGGEST_LEN + 25); + assert!(edits1(&"a".repeat(MAX_SUGGEST_LEN + 1)).is_empty()); + assert!(edits1(&"a".repeat(1_000_000)).is_empty()); + } +} From c13cda222966d1b1a8bfeeb448517d85d5e059de Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:04 +0200 Subject: [PATCH 027/162] Column sort rejects a zero row key with a parameter error instead of panicking (#534) --- crates/engine/src/cmd/data.rs | 56 ++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3ba..33c053b 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -198,27 +198,29 @@ fn sort(s: &mut Session, p: &Json) -> Result { let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); let by_cols = str_param(p, "orientation") == Some("columns"); let case = bool_param(p, "matchCase").unwrap_or(false); - let keys: Vec = p - .get("keys") - .and_then(Json::as_array) - .map(|a| { - a.iter() - .filter_map(|k| { - let col = if by_cols { - k.get("row").and_then(Json::as_u64).map(|r| r as u32 - 1).or_else(|| col_param(k.get("column"), r.start.row))? - } else { - col_param(k.get("column"), r.start.col)? - }; - Some(SortKey { - col, - desc: k.get("order").and_then(Json::as_str) == Some("desc"), - by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(), - list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()), - }) - }) - .collect() - }) - .unwrap_or_default(); + let mut keys: Vec = Vec::new(); + for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() { + let col = if by_cols { + let row = match k.get("row").and_then(Json::as_u64) { + // Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate. + Some(n) => { + let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1)); + Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?) + } + None => None, + }; + row.or_else(|| col_param(k.get("column"), r.start.row)) + } else { + col_param(k.get("column"), r.start.col) + }; + let Some(col) = col else { continue }; + keys.push(SortKey { + col, + desc: k.get("order").and_then(Json::as_str) == Some("desc"), + by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(), + list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()), + }); + } if keys.is_empty() { s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header}))); return ok(); @@ -1375,6 +1377,18 @@ mod tests { assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left")); } + #[test] + fn column_sort_rejects_row_key_zero_without_panicking() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + for row in [0u64, 1 << 32] { + let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]}); + let err = s.execute("data.sort", params).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}"); + } + } + #[test] fn split() { assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]); From 3f45edf14483db65053fe6e12179cc03291ba9fa Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:21 +0200 Subject: [PATCH 028/162] Fix ribbon checkboxes sending the pre-click value (#520) --- crates/ui-egui/src/ribbon.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291ac..9f62a35 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -343,6 +343,12 @@ fn anchor_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, targets: &[ } } +/// The `on` value for a ribbon checkbox command. A click has already flipped the checkbox bool, so +/// it is sent as is; a key tip leaves the bool untouched, so the command gets the opposite. +fn checkbox_on(clicked: bool, shown: bool) -> bool { + shown == clicked +} + /// Consume a one-shot keytip request for control `id` (set by `Action::Control`). fn invoke(app: &mut SheetApp, id: &str) -> bool { if app.ui.keytip_invoke == Some(id) { @@ -1698,13 +1704,13 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { let resp = ui.checkbox(&mut v, screen); control_keytip(app, ui, &resp, "view.gridlines"); if resp.changed() || invoke(app, "view.gridlines") { - act(app, "view.gridlines", json!({"on": !v})); + act(app, "view.gridlines", json!({"on": checkbox_on(resp.changed(), v)})); } let mut p = sh.2; let resp = ui.checkbox(&mut p, print); control_keytip(app, ui, &resp, "pageLayout.printGridlines"); if resp.changed() || invoke(app, "pageLayout.printGridlines") { - act(app, "pageLayout.printGridlines", json!({"on": !p})); + act(app, "pageLayout.printGridlines", json!({"on": checkbox_on(resp.changed(), p)})); } }); ui.vertical(|ui| { @@ -1713,13 +1719,13 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { let resp = ui.checkbox(&mut v, screen); control_keytip(app, ui, &resp, "view.headings"); if resp.changed() || invoke(app, "view.headings") { - act(app, "view.headings", json!({"on": !v})); + act(app, "view.headings", json!({"on": checkbox_on(resp.changed(), v)})); } let mut p = sh.3; let resp = ui.checkbox(&mut p, print); control_keytip(app, ui, &resp, "pageLayout.printHeadings"); if resp.changed() || invoke(app, "pageLayout.printHeadings") { - act(app, "pageLayout.printHeadings", json!({"on": !p})); + act(app, "pageLayout.printHeadings", json!({"on": checkbox_on(resp.changed(), p)})); } }); let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); @@ -2118,7 +2124,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar")); control_keytip(app, ui, &resp, "view.formulaBar"); if resp.changed() || invoke(app, "view.formulaBar") { - act(app, "view.formulaBar", json!({"on": !fb})); + act(app, "view.formulaBar", json!({"on": checkbox_on(resp.changed(), fb)})); } let mut g = s.0; let resp = ui.checkbox(&mut g, lang.tr("Gridlines")); @@ -2482,3 +2488,18 @@ mod title_bar_tests { assert_eq!(MAC_CONTENT_LEFT - lights_end, margin); } } + +#[cfg(test)] +mod checkbox_on_tests { + use super::*; + + #[test] + fn click_sends_the_new_checkbox_value_and_key_tip_the_opposite() { + // Click: the checkbox bool already holds the new choice. + assert!(!checkbox_on(true, false)); + assert!(checkbox_on(true, true)); + // Key tip: the bool still holds the current setting, so toggle it. + assert!(checkbox_on(false, false)); + assert!(!checkbox_on(false, true)); + } +} From 344888ceb583ebb4433f8dcc036892b9fa9f3861 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:20 +0200 Subject: [PATCH 029/162] Keep every range of a conditional format or validation sqref (#512) --- crates/xlsx/src/sheet_read.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 77b46d9..e7019a4 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -541,7 +541,7 @@ pub fn parse_iso_datetime(s: &str, sys: DateSystem) -> Option { } pub fn parse_sqref(s: Option<&str>) -> Vec { - s.unwrap_or("").split_whitespace().filter_map(RangeRef::parse).take(10_000).collect() + s.unwrap_or("").split_whitespace().filter_map(RangeRef::parse).collect() } fn cf_op(s: Option<&str>) -> CfOperator { @@ -973,4 +973,12 @@ mod tests { assert_eq!(parse_iso_datetime("garbage", DateSystem::D1900), None); assert_eq!(parse_iso_datetime("2024-13-01", DateSystem::D1900), None); } + + #[test] + fn sqref_keeps_more_than_ten_thousand_ranges() { + let sq = (1..=12_000).map(|r| format!("A{r}")).collect::>().join(" "); + let ranges = parse_sqref(Some(&sq)); + assert_eq!(ranges.len(), 12_000); + assert_eq!(ranges.last().copied(), RangeRef::parse("A12000")); + } } From a06219aeebecae952cf809eb5b4e2174d4dc6b87 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:33:05 +0200 Subject: [PATCH 030/162] fix(grid): filter dropdown installs the AutoFilter on the clicked table (#509) --- crates/ui-egui/src/grid.rs | 45 ++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..70a49ee 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -72,7 +72,8 @@ pub struct GridState { pub drag_select: bool, pub rect: Option, pub cells_rect: Option, - pub filter_menu: Option<(u32, Pos2)>, + /// Column and row of the clicked header cell, and where to open the menu. + pub filter_menu: Option<(u32, u32, Pos2)>, pub context_menu: Option, pub marching_phase: f32, /// The selection outline on its way to a new selection. @@ -1808,7 +1809,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } // Filter dropdown buttons. if is_filter_button(sh, geo, p) { - app.grid.filter_menu = Some((c.col, geo.mirror_pos(p))); + app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p))); return; } // Point mode while editing a formula: clicking inserts a reference. @@ -2480,20 +2481,20 @@ fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) } +/// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column. +fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { + sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) +} + fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { - let Some((col, at)) = app.grid.filter_menu else { return }; + let Some((col, row, at)) = app.grid.filter_menu else { return }; let Some(d) = app.session.active() else { return }; let wb = d.wb.clone(); let si = wb.active_sheet; let Some(sh) = wb.sheet(si) else { return }; let _ = geo; // Tables get an autofilter on demand. - let table_range = sh - .tables - .iter() - .find(|t| t.range.start.row <= at.y as u32 || true) - .filter(|t| col >= t.range.start.col && col <= t.range.end.col) - .map(|t| t.range); + let table_range = filter_table_range(sh, col, row); if sh.autofilter.is_none() && let Some(r) = table_range { @@ -3053,6 +3054,32 @@ mod selection_fill_tests { mod tests { use super::*; + #[test] + fn filter_menu_uses_the_table_whose_header_was_clicked() { + use gridcraft_engine::model::Table; + let table = |id: u32, r0: u32, r1: u32| Table { + id, + name: format!("T{id}"), + range: RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, 0)), + header_row: true, + totals_row: false, + columns: vec![], + style: String::new(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + }; + let mut sh = Sheet::new("S"); + sh.tables.push(table(1, 0, 2)); + sh.tables.push(table(2, 4, 7)); + assert_eq!(filter_table_range(&sh, 0, 4), Some(sh.tables[1].range)); + assert_eq!(filter_table_range(&sh, 0, 0), Some(sh.tables[0].range)); + assert_eq!(filter_table_range(&sh, 1, 4), None); + } + #[test] fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); From 576e625de696b87c3e8d505b30d210dcc4c36add Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:35 +0200 Subject: [PATCH 031/162] Return #VALUE! for AGGREGATE without a data argument (#572) --- crates/calc/src/eval.rs | 4 +++- crates/calc/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..5cbc1ca 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1290,7 +1290,9 @@ impl<'h> Evaluator<'h> { } fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev { - if args.len() < 2 { + // SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1. + let min_args = if name == "AGGREGATE" { 3 } else { 2 }; + if args.len() < min_args { return err(CellError::Value); } let code = match self.arg_num(args, 0, 0.0) { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..6c42eed 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -608,6 +608,23 @@ fn subtotal_skips_filtered_rows() { } } +#[test] +fn aggregate_without_data_argument_is_an_error() { + let mut t = T::new(); + t.set("A1", "1"); + t.set("A2", "2"); + t.set("D1", "=AGGREGATE(9,0)"); + t.set("D2", "=AGGREGATE(4,0)"); + t.set("D3", "=AGGREGATE(9,0,A1:A2)"); + t.set("D4", "=AGGREGATE(9,0,A3:A4)"); + t.set("D5", "=AGGREGATE(9,0,A1:A2,A1:A2)"); + assert_eq!(t.get("D1"), Value::Error(CellError::Value)); + assert_eq!(t.get("D2"), Value::Error(CellError::Value)); + assert_eq!(t.num("D3"), 3.0); + assert_eq!(t.num("D4"), 0.0); + assert_eq!(t.num("D5"), 6.0); +} + #[test] fn recalc_all_after_load() { let mut t = T::new(); From 40dfdd8331d21eb489e4e2a81379c80a1c7a01a7 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:26 +0200 Subject: [PATCH 032/162] Restore frozen panes when showing a custom view (#577) --- crates/engine/src/cmd/extra.rs | 11 +++++++++ crates/engine/tests/custom_view_freeze.rs | 28 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 crates/engine/tests/custom_view_freeze.rs diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index a08f080..69789a3 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -743,6 +743,17 @@ fn custom_views(s: &mut Session, p: &Json) -> Result { if let Some(z) = v["zoom"].as_u64() { s.execute("view.zoom", json!({"percent": z}))?; } + if let Some(f) = v.get("freeze") { + let freeze = match (f[0].as_u64(), f[1].as_u64()) { + (Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()), + _ => None, + }; + edit(s, |cx| { + let si = cx.sheet_index(); + cx.sheet_mut(si)?.freeze = freeze; + Ok(()) + })?; + } } else if let Some(name) = str_param(p, "delete") { if let Some(o) = views.as_object_mut() { o.remove(name); diff --git a/crates/engine/tests/custom_view_freeze.rs b/crates/engine/tests/custom_view_freeze.rs new file mode 100644 index 0000000..e0d6a2d --- /dev/null +++ b/crates/engine/tests/custom_view_freeze.rs @@ -0,0 +1,28 @@ +use gridcraft_engine::Session; +use serde_json::json; + +fn freeze(s: &Session) -> Option<(u32, u32)> { + s.doc().unwrap().wb.active().unwrap().freeze +} + +#[test] +fn showing_a_custom_view_restores_its_frozen_panes() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("view.freezePanes", json!({"cell": "B2"})).unwrap(); + s.execute("view.customViews", json!({"save": "Frozen"})).unwrap(); + s.execute("view.unfreezePanes", json!({})).unwrap(); + assert_eq!(freeze(&s), None); + s.execute("view.customViews", json!({"show": "Frozen"})).unwrap(); + assert_eq!(freeze(&s), Some((1, 1))); +} + +#[test] +fn showing_an_unfrozen_custom_view_clears_current_panes() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("view.customViews", json!({"save": "Plain"})).unwrap(); + s.execute("view.freezePanes", json!({"cell": "B2"})).unwrap(); + s.execute("view.customViews", json!({"show": "Plain"})).unwrap(); + assert_eq!(freeze(&s), None); +} From a98f6d127e90f693dea97179fdf3cbc99eef1401 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:47 +0200 Subject: [PATCH 033/162] Parse an intersection whose operand became #REF! (#568) --- crates/formula/src/parser.rs | 5 +++-- crates/formula/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 4fd3b7b..355a964 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -50,8 +50,9 @@ fn significant_spaces(toks: Vec) -> Vec { let mut out: Vec = Vec::with_capacity(toks.len()); for (i, t) in toks.iter().enumerate() { if t.tok == Tok::Space { - let prev_end = out.last().is_some_and(|p| matches!(p.tok, Tok::Word(_) | Tok::RParen | Tok::Struct(_))); - let next_start = toks.get(i + 1).is_some_and(|n| matches!(n.tok, Tok::Word(_) | Tok::Sheet(..) | Tok::LParen | Tok::Func(_))); + let prev_end = out.last().is_some_and(|p| matches!(p.tok, Tok::Word(_) | Tok::RParen | Tok::Struct(_) | Tok::Error(_))); + let next_start = + toks.get(i + 1).is_some_and(|n| matches!(n.tok, Tok::Word(_) | Tok::Sheet(..) | Tok::LParen | Tok::Func(_) | Tok::Error(_))); if prev_end && next_start { out.push(t.clone()); } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c2..2de0daa 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -5,6 +5,22 @@ fn rt(src: &str) -> String { print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}"))) } +#[test] +fn intersection_with_deleted_operand_reparses() { + // Deleting the columns of an intersection operand prints `A1:B2 #REF!`; that text must parse back. + assert_eq!(rt("A1:B2 #REF!"), "A1:B2 #REF!"); + assert_eq!(rt("#REF! A1:B2"), "#REF! A1:B2"); + assert_eq!(parse("A1:B2 #REF!"), parse("A1:B2 (#REF!)").map(|e| strip_paren(&e))); +} + +fn strip_paren(e: &Expr) -> Expr { + match e { + Expr::Binary(op, a, b) => Expr::Binary(*op, Box::new(strip_paren(a)), Box::new(strip_paren(b))), + Expr::Paren(x) => strip_paren(x), + other => other.clone(), + } +} + #[test] fn roundtrips() { for f in [ From 2586ec22df5e352b6762f7c5d498bb8c6a981eaa Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:28:09 +0200 Subject: [PATCH 034/162] Treat serials past 9999-12-31 as out of range in the 1904 date system (#565) --- crates/core/src/date.rs | 16 ++++++++++++++-- crates/numfmt/src/render.rs | 4 ++-- crates/numfmt/src/tests.rs | 7 +++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 3b126b7..bc9bde6 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -95,6 +95,14 @@ pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 { if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) } } +/// Last serial that is a valid date in a date system: 9999-12-31 (2958465 in the 1900 system, 2957003 in the 1904 system). +pub fn max_serial(sys: DateSystem) -> i64 { + match sys { + DateSystem::D1900 => 2_958_465, + DateSystem::D1904 => 2_958_465 - 1462, + } +} + /// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next /// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900 /// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60. @@ -117,7 +125,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti DateSystem::D1904 => first - UNIX_1904_01_01, }; let serial = first.checked_add(day)?.checked_sub(1)?; - if !(0..=2_958_465).contains(&serial) { + if !(0..=max_serial(sys)).contains(&serial) { return None; } Some(serial as f64) @@ -125,7 +133,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti /// Date/time from a serial. `None` for negative or too-large serials. pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option { - if !serial.is_finite() || !(0.0..2_958_466.0).contains(&serial) { + if !serial.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&serial) { return None; } let mut day = serial.floor() as i64; @@ -197,6 +205,10 @@ mod tests { assert_eq!(serial_from_ymd(s, 2000, 1, 1), Some(36526.0)); assert_eq!(serial_from_ymd(s, 2026, 10, 7), Some(46302.0)); assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0)); + assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 31), Some(2957003.0)); + assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 32), None); + assert!(datetime_from_serial(DateSystem::D1904, 2957003.5).is_some()); + assert!(datetime_from_serial(DateSystem::D1904, 2957004.0).is_none()); assert_eq!(serial_from_ymd(s, 2020, 13, 1), serial_from_ymd(s, 2021, 1, 1)); assert_eq!(serial_from_ymd(s, 2020, 3, 0), serial_from_ymd(s, 2020, 2, 29)); assert_eq!(serial_from_ymd(s, 120, 1, 1), serial_from_ymd(s, 2020, 1, 1)); diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 7befe59..f02d84b 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -1,7 +1,7 @@ //! Rendering a value with a parsed section. use gridcraft_core::DateSystem; -use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial}; +use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, max_serial}; use crate::decimal::{decimal_digits, fixed, fixed_from_digits, format_general_fit}; use crate::parse::{DatePart, Section, Tok}; @@ -58,7 +58,7 @@ pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out { /// Renders a date/time. `None` when the serial is outside the supported range. pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option { - if !v.is_finite() || !(0.0..2_958_466.0).contains(&v) { + if !v.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&v) { return None; } let sub_digits = sec.toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3); diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 6c2306c..f777c6a 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -247,6 +247,11 @@ fn dates_and_times() { assert_eq!(f("m/d/yyyy", 60.0), "2/29/1900"); assert_eq!(f("m/d/yyyy", 0.0), "1/0/1900"); assert_eq!(format_value(&Value::Number(0.0), &NumberFormat::parse("m/d/yyyy"), DateSystem::D1904).text, "1/1/1904"); + let fmt = NumberFormat::parse("m/d/yyyy"); + assert_eq!(format_value(&Value::Number(2957003.5), &fmt, DateSystem::D1904).text, "12/31/9999"); + assert_eq!(format_value(&Value::Number(2957004.0), &fmt, DateSystem::D1904).text, "########"); + assert_eq!(format_value(&Value::Number(2958465.0), &fmt, DateSystem::D1900).text, "12/31/9999"); + assert_eq!(format_value(&Value::Number(2958466.0), &fmt, DateSystem::D1900).text, "########"); assert_eq!(f("m/d/yyyy", -1.0), "########"); assert_eq!(f("m/d/yyyy", 3e6), "########"); assert_eq!(f("[$-409]m/d/yyyy", 46302.0), "10/7/2026"); @@ -339,6 +344,8 @@ fn text_fn() { assert_eq!(text_function(&Value::Empty, "0.00", S), Ok("0.00".into())); assert_eq!(text_function(&Value::Number(5.0), "", S), Ok("".into())); assert_eq!(text_function(&Value::Number(-1.0), "m/d/yyyy", S), Err(CellError::Value)); + assert_eq!(text_function(&Value::Number(2957003.0), "m/d/yyyy", DateSystem::D1904), Ok("12/31/9999".into())); + assert_eq!(text_function(&Value::Number(2957004.0), "m/d/yyyy", DateSystem::D1904), Err(CellError::Value)); assert_eq!(text_function(&Value::Bool(true), "0", S), Ok("TRUE".into())); // Booleans go through the text section as TRUE/FALSE (observed Excel results). let cases: &[(bool, &str, &str)] = &[ From 8427681d7ceeb638be393cfe31faa8dfd516b6a9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:28:30 +0200 Subject: [PATCH 035/162] Tables: names with a hyphen or other operator characters are rejected (#576) --- crates/engine/src/cmd/insert.rs | 17 +++++++++++------ crates/engine/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 392c5ee..8e9c735 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -240,7 +240,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name") .map(str::to_string) .unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into())); - if name.is_empty() || name.contains(' ') || name.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&name).is_some() { + if !valid_table_name(&name) { return Err(EngineError::Other("The name that you entered is not valid.".into())); } if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) { @@ -448,14 +448,19 @@ fn table_style(s: &mut Session, p: &Json) -> Result { }) } +/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`, +/// starting with a letter, `_` or `\`. Anything else (a hyphen, say) would read as an operator inside a +/// structured reference, and the writer would rename the table on save. +fn valid_table_name(name: &str) -> bool { + name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') + && CellRef::parse(name).is_none() + && name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\')) +} + fn table_rename(s: &mut Session, p: &Json) -> Result { let (si, ti) = find_table(s, p)?; let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string(); - if name.is_empty() - || name.contains(' ') - || name.chars().next().is_some_and(|c| c.is_ascii_digit()) - || gridcraft_core::CellRef::parse(&name).is_some() - { + if !valid_table_name(&name) { return Err(EngineError::Other("The name that you entered is not valid.".into())); } if s.doc()?.wb.table(&name).is_some_and(|x| x != (si, ti)) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..fa5409e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1170,6 +1170,22 @@ fn insert_and_delete_cells_never_shift_part_of_a_table() { assert!(e.to_string().contains("shift cells in a table"), "{e}"); } +#[test] +fn table_names_with_a_hyphen_are_rejected() { + let mut s = Session::new(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap(); + let e = s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "A-B"})).unwrap_err(); + assert!(e.to_string().contains("not valid"), "{e}"); + assert!(s.doc().unwrap().wb.table("A-B").is_none()); + s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Sales"})).unwrap(); + let e = s.execute("table.rename", json!({"table": "Sales", "name": "A-B"})).unwrap_err(); + assert!(e.to_string().contains("not valid"), "{e}"); + assert!(s.doc().unwrap().wb.table("Sales").is_some()); + assert!(s.execute("table.rename", json!({"table": "Sales", "name": ".AB"})).is_err()); + s.execute("table.rename", json!({"table": "Sales", "name": "A_B"})).unwrap(); + assert!(s.doc().unwrap().wb.table("A_B").is_some()); +} + #[test] fn undo_and_redo_keep_the_watch_window_list() { // Found by the command-sequence fuzzer: the list lives in the workbook but isn't an edit, so From 34125d7e43f572e13b57ef19e0e6d168730ea08c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:26 +0200 Subject: [PATCH 036/162] Quote sheet names when printing sheet-qualified defined names (#567) --- crates/formula/src/adjust.rs | 7 +++---- crates/formula/src/printer.rs | 10 +++++++++- crates/formula/src/tests.rs | 11 +++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 6f01e05..d4881aa 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -264,11 +264,10 @@ pub fn rename_sheet(e: Expr, old: &str, new: &str) -> Expr { } Expr::Ref(r) } - // Sheet-qualified defined name `Old!MyName` is stored as one string. + // Sheet-qualified defined name `Old!MyName` is stored as one string, with the bare sheet name as the parser + // stores it (the printer adds the quotes). Expr::Name(n) => match n.split_at_checked(old.len()) { - Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => { - Expr::Name(format!("{}{rest}", crate::printer::quote_sheet(new))) - } + Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => Expr::Name(format!("{new}{rest}")), _ => Expr::Name(n), }, other => other, diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index a63599b..a1ff7a7 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -178,7 +178,15 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { Some(at) => s.push_str(&reference_r1c1(r, at)), None => s.push_str(&reference_a1(r)), }, - Expr::Name(n) => s.push_str(n), + Expr::Name(n) => match n.rsplit_once('!') { + // A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back. + Some((sheet, name)) => { + s.push_str("e_sheet(sheet)); + s.push('!'); + s.push_str(name); + } + _ => s.push_str(n), + }, Expr::Struct(st) => s.push_str(&struct_ref(st)), Expr::Unary(op, x) => match op { UnOp::Neg => { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c2..66e1f01 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -5,6 +5,13 @@ fn rt(src: &str) -> String { print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}"))) } +#[test] +fn quoted_sheet_qualified_name_keeps_quotes() { + assert_eq!(rt("'My Sheet'!Total"), "'My Sheet'!Total"); + assert_eq!(rt("'Data'!Total"), "Data!Total"); + assert_eq!(rt("'It''s'!Total+1"), "'It''s'!Total+1"); +} + #[test] fn roundtrips() { for f in [ @@ -183,6 +190,10 @@ fn rename_sheet_updates_qualified_names() { assert_eq!(print(&rename_sheet(e, "data", "Numbers")), "Numbers!MyName+MyName+Other!MyName"); let e = parse("Data!MyName").unwrap(); assert_eq!(print(&rename_sheet(e, "Data", "New Name")), "'New Name'!MyName"); + // Stored bare, like a parsed name, so the evaluator finds the sheet and a second rename still matches. + let e = rename_sheet(parse("Data!MyName").unwrap(), "Data", "It's"); + assert_eq!(e, Expr::Name("It's!MyName".into())); + assert_eq!(print(&rename_sheet(e, "It's", "Totals")), "Totals!MyName"); } #[test] From ff14224ff964f3ccf2d64cddaa7ed64d7eea6ba3 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:32 +0200 Subject: [PATCH 037/162] Convert TODAY and NOW to the workbook date system in 1904 workbooks (#578) --- crates/core/src/date.rs | 8 ++++++++ crates/engine/src/cf.rs | 2 +- crates/engine/src/cmd/print.rs | 10 +++++++++- crates/functions/src/date.rs | 11 ++++++++--- crates/ui-egui/src/keymap.rs | 8 +++++--- crates/ui-egui/src/ribbon.rs | 6 ++++-- 6 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 3b126b7..29bd4b5 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -59,6 +59,14 @@ pub fn serial_from_unix_ms(ms: f64) -> f64 { 25569.0 + ms / 86_400_000.0 } +/// Converts a 1900-system serial (as the clock supplies it) to the serial of the same instant in `sys`. +pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 { + match sys { + DateSystem::D1900 => serial_1900, + DateSystem::D1904 => serial_1900 - 1462.0, + } +} + pub fn civil_from_days(z: i64) -> (i32, u32, u32) { let z = z + 719468; let era = if z >= 0 { z } else { z - 146096 } / 146097; diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index 21a32dd..b71ddb8 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -184,7 +184,7 @@ impl CfCache { }, CfRule::TimePeriod { period, style } => match v.as_f64() { Some(n) => { - let today = gridcraft_calc::now_serial().floor(); + let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor(); let d = n.floor(); let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0); let week_start = today - wd; diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 986351e..8ede4e2 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -391,7 +391,7 @@ pub struct HfContext { /// 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 now = gridcraft_core::date::serial_in_system(wb.date_system, 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) @@ -1765,6 +1765,14 @@ mod tests { } } + #[test] + fn header_footer_date_follows_the_workbook_date_system() { + let mut wb = Workbook::new(); + let date_1900 = hf_date_time(&wb).0; + wb.date_system = gridcraft_core::DateSystem::D1904; + assert_eq!(hf_date_time(&wb).0, date_1900); + } + #[test] fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() { let mut s = s(); diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs index 29aeb47..f68f26d 100644 --- a/crates/functions/src/date.rs +++ b/crates/functions/src/date.rs @@ -1,6 +1,6 @@ //! Date and time functions. -use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd}; +use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd, serial_in_system}; use gridcraft_core::parse::parse_input; use gridcraft_core::{CellError, DateSystem, Value}; @@ -98,10 +98,10 @@ fn second(a: &[Arg], _c: &mut dyn Ctx) -> R { } fn now(_a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(c.now_serial()) + num_val(serial_in_system(c.date_system(), c.now_serial())) } fn today(_a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(c.now_serial().floor()) + num_val(serial_in_system(c.date_system(), c.now_serial()).floor()) } fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R { @@ -794,6 +794,11 @@ mod tests { close(crate::call(spec, &[n(0.0)], &mut ctx), 6.0); let spec = crate::lookup("YEAR").unwrap(); close(crate::call(spec, &[n(0.0)], &mut ctx), 1904.0); + // The clock is 2026-10-07 12:00; in the 1904 system that is serial 44840.5. + close(crate::call(crate::lookup("NOW").unwrap(), &[], &mut ctx), 44840.5); + let today = crate::call(crate::lookup("TODAY").unwrap(), &[], &mut ctx); + close(today.clone(), 44840.0); + close(crate::call(spec, &[crate::Arg::val(today)], &mut ctx), 2026.0); // No fictitious day here: 1904 is a real leap year and 1900 is before the epoch. let spec = crate::lookup("DATE").unwrap(); close(crate::call(spec, &[n(1904.0), n(2.0), n(29.0)], &mut ctx), 59.0); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 21bf41f..3372e1d 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -179,12 +179,14 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic } Shortcut::Dialog(name, params) => app.open_dialog(name, params), Shortcut::Date | Shortcut::Time => { - let now = gridcraft_engine::calc::now_serial(); - let (value, format) = if shortcut == Shortcut::Date { (now.floor(), "m/d/yyyy") } else { (now.fract(), "h:mm AM/PM") }; let text = app .session .active() - .map(|d| gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(value), format, &d.wb).text) + .map(|d| { + let now = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial()); + let (value, format) = if shortcut == Shortcut::Date { (now.floor(), "m/d/yyyy") } else { (now.fract(), "h:mm AM/PM") }; + gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(value), format, &d.wb).text + }) .unwrap_or_default(); edit_with(app, text); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291ac..e84be82 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2447,11 +2447,13 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { } Key::Semicolon => { // Insert today's date. - let today = gridcraft_engine::calc::now_serial().floor(); let text = app .session .active() - .map(|d| gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(today), "m/d/yyyy", &d.wb).text) + .map(|d| { + let today = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial()).floor(); + gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(today), "m/d/yyyy", &d.wb).text + }) .unwrap_or_default(); app.begin_edit(Some(text), false); None From 0ae1eafc9b854360df24930293e9642c43962593 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:28:46 +0200 Subject: [PATCH 038/162] Open files by their content, not a mismatched extension (#569) --- crates/engine/src/io.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index e613611..e7abca9 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -102,6 +102,8 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { let import = match sniffed { gridcraft_xlsx::Format::Ods => Some(FileKind::Ods), gridcraft_xlsx::Format::Xlsb => Some(FileKind::Xlsb), + // An XLSX package is never read as ODS/XLSB just because of its name. + gridcraft_xlsx::Format::Xlsx => None, _ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)), }; if let Some(import) = import { @@ -109,7 +111,8 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { let (wb, report) = read(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?; return Ok((wb, report.warnings)); } - if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) { + // Text named `.xlsx` is read as text. + if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) { let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?; return Ok((wb, report.warnings)); } @@ -581,4 +584,19 @@ mod tests { "{types}" ); } + + #[test] + fn content_wins_over_a_mismatched_extension() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::Number(7.0)); + let package = save_bytes(&wb, "book.xlsx").unwrap(); + for name in ["book.xlsx", "book.ods", "book.xlsb", "book.csv"] { + let (opened, _) = open_bytes(name, &package).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 0)), gridcraft_core::Value::Number(7.0), "{name}"); + } + for name in ["data.xlsx", "data.csv"] { + let (opened, _) = open_bytes(name, b"a,b\n1,2\n").unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}"); + } + } } From bfe3a9483dbf0161f3bef65e867a0615c703bf78 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:28:50 +0200 Subject: [PATCH 039/162] MCP: reject invalid format_range borders as invalid params (#560) --- crates/mcp/src/tests.rs | 26 ++++++++++++++++++++++++++ crates/mcp/src/tools.rs | 40 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index eb1f205..ccb9c0f 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -517,3 +517,29 @@ fn text_to_columns_destination_follows_its_sheet() { 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"})); } + +#[test] +fn invalid_borders_are_invalid_params() { + let mut s = server(); + let bad = |s: &mut Server, id: u64, borders: Value| { + let v = rpc(s, id, "tools/call", json!({"name": "format_range", "arguments": {"range": "A1", "borders": borders}})); + assert_eq!(err_code(&v), -32602, "{v}"); + }; + bad(&mut s, 1, json!(123)); + bad(&mut s, 2, json!("bogus")); + bad(&mut s, 3, json!({"preset": "bogus"})); + bad(&mut s, 4, json!({"style": "thick"})); + ok(&mut s, "format_range", json!({"range": "A1", "borders": "all"})); + ok(&mut s, "format_range", json!({"range": "B1", "borders": {"preset": "all", "style": "thick", "color": "#123456"}})); +} + +#[test] +fn invalid_sort_keys_are_invalid_params() { + let mut s = server(); + ok(&mut s, "write_range", json!({"range": "A1", "values": [["b"], ["c"], ["a"]]})); + for (id, keys) in [(1, json!([123])), (2, json!([{"order": "desc"}])), (3, json!([{"column": "A", "order": "sideways"}]))] { + let v = rpc(&mut s, id, "tools/call", json!({"name": "sort_range", "arguments": {"range": "A1:A3", "keys": keys}})); + assert_eq!(err_code(&v), -32602, "{v}"); + } + ok(&mut s, "sort_range", json!({"range": "A1:A3", "keys": ["A", {"column": "A", "order": "desc"}]})); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index eb741b8..71fbdf4 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -391,8 +391,41 @@ fn type_matches(t: &str, v: &Value) -> bool { } } -/// Validates `args` against the tool's schema: unknown tool, missing required properties and -/// top-level type mismatches are JSON-RPC "invalid params" errors. +/// Checks a value against a (sub)schema: `type`, `enum`, `oneOf`, array `items`, and for objects the required and known properties. +fn schema_matches(schema: &Value, v: &Value) -> bool { + if let Some(t) = schema["type"].as_str() + && !type_matches(t, v) + { + return false; + } + if let Some(allowed) = schema["enum"].as_array() + && !allowed.contains(v) + { + return false; + } + if let Some(alts) = schema["oneOf"].as_array() + && !alts.iter().any(|alt| schema_matches(alt, v)) + { + return false; + } + if let Some(xs) = v.as_array() + && !xs.iter().all(|x| schema_matches(&schema["items"], x)) + { + return false; + } + if let Some(o) = v.as_object() { + if schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str).any(|r| o.get(r).is_none_or(Value::is_null)) { + return false; + } + if let Some(props) = schema["properties"].as_object() { + return o.iter().all(|(k, x)| props.get(k).is_some_and(|ps| x.is_null() || schema_matches(ps, x))); + } + } + true +} + +/// Validates `args` against the tool's schema: unknown tool, missing required properties, +/// top-level type mismatches and values matching none of a `oneOf` are JSON-RPC "invalid params" errors. pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> { let defs = tool_definitions(); let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?; @@ -421,6 +454,9 @@ pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> { { return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone()))); } + if (p["oneOf"].is_array() || p["items"]["oneOf"].is_array()) && !schema_matches(p, v) { + return Err(format!("{name}: argument `{k}` does not match the schema: {}", p["description"])); + } } Ok(()) } From e6c16472e430af8a8a6f6808c4c54f9da8da1087 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:09 +0200 Subject: [PATCH 040/162] Return #VALUE! when a LAMBDA call has too few argument positions (#557) --- crates/calc/src/eval.rs | 7 ++++++- crates/calc/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..063043a 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -860,7 +860,12 @@ impl<'h> Evaluator<'h> { } pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { - if args.len() > l.params.len() { + // Too many or too few argument positions is #VALUE!. An explicitly empty position, as in + // `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()` + // parses to no arguments, which for a one-parameter lambda can also be read as one empty + // position, so that call still binds the parameter as omitted. + let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1); + if !positions_ok { return err(CellError::Value); } let mut bound = Vec::with_capacity(l.params.len()); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..e2f4a3a 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -501,6 +501,38 @@ fn let_lambda() { assert_eq!(t.num("A4"), 14.0); } +#[test] +fn lambda_argument_count() { + let mut t = T::new(); + t.wb.names.push(gridcraft_model::DefinedName { + name: "AddPair".into(), + scope: None, + formula: "LAMBDA(a,b,a+b)".into(), + comment: String::new(), + hidden: false, + }); + t.calc.rebuild(&t.wb); + let omit = "LAMBDA(x,y,IF(ISOMITTED(y),\"Missing\",x+y))"; + t.set("A1", "=LAMBDA(a,b,a+b)(2)"); + t.set("A2", "=LAMBDA(a,b,a+b)(2,3,4)"); + t.set("A3", &format!("={omit}(1,)")); + t.set("A4", &format!("={omit}(1)")); + t.set("A5", &format!("={omit}(1,2)")); + t.set("A6", "=LAMBDA(7)()"); + t.set("A7", "=AddPair(2)"); + t.set("A8", "=AddPair(2,3)"); + assert_eq!(t.get("A1"), Value::Error(CellError::Value)); + assert_eq!(t.get("A2"), Value::Error(CellError::Value)); + assert_eq!(t.get("A3"), Value::Text("Missing".into())); + assert_eq!(t.get("A4"), Value::Error(CellError::Value)); + assert_eq!(t.num("A5"), 3.0); + assert_eq!(t.num("A6"), 7.0); + assert_eq!(t.get("A7"), Value::Error(CellError::Value)); + assert_eq!(t.num("A8"), 5.0); + t.set("A9", "=LAMBDA(x,ISOMITTED(x))()"); + assert_eq!(t.get("A9"), Value::Bool(true)); +} + #[test] fn names_and_sheets() { let mut t = T::new(); From 3301d59f442b1cd46b22337e48149b75a2787ed9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:48 +0200 Subject: [PATCH 041/162] Fix histogram counts disagreeing with interval labels on bin boundaries (#546) --- crates/chart/src/render.rs | 8 +++++--- crates/chart/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 63d30d9..0bb628e 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -1201,10 +1201,12 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { bins = 1; } let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 }; + // Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value + // equal to an edge belongs to the bin that ends there. + let edges: Vec = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect(); let mut counts = vec![0usize; bins]; for v in &vals { - let b = if width > 0.0 { ((v - lo) / width).floor() } else { 0.0 }; - let b = if b.is_finite() && b > 0.0 { (b as usize).min(bins - 1) } else { 0 }; + let b = edges.partition_point(|e| e < v).min(bins - 1); if let Some(c) = counts.get_mut(b) { *c += 1; } @@ -1213,7 +1215,7 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let labels: Vec = (0..bins) .map(|b| { let a = lo + b as f64 * width; - let e = if b + 1 == bins { hi.max(a) } else { a + width }; + let e = edges.get(b).copied().unwrap_or(a); let open = if b == 0 { '[' } else { '(' }; format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0))) }) diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a942677..b38bf17 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -877,6 +877,28 @@ fn treemap_and_histogram_shapes() { assert_eq!(bins, 10); } +#[test] +fn histogram_boundary_values_match_interval_labels() { + let heights = |vals: &[f64]| -> Vec { + let d = ChartData { categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); + prims.iter().filter_map(|p| if let Prim::Rect { h, fill: Some([10, 20, 30, 255]), .. } = p { Some(*h) } else { None }).collect() + }; + // Labels are "[0, 7.5]" and "(7.5, 15.0]": 7.5 belongs to the first bar, so the counts are 2 and 1. + let h = heights(&[0.0, 7.5, 15.0]); + assert_eq!(h.len(), 2); + assert!((h[0] / h[1] - 2.0).abs() < 0.01, "{h:?}"); + let h = heights(&[-15.0, -7.5, 0.0]); + assert!((h[0] / h[1] - 2.0).abs() < 0.01, "{h:?}"); + // A value off the boundary still goes right: [0, 7.5] holds 1, (7.5, 15.0] holds 2. + let h = heights(&[0.0, 7.75, 15.0]); + assert!((h[1] / h[0] - 2.0).abs() < 0.01, "{h:?}"); + // Five values, three bins [0,5] (5,10] (10,15]: counts 3, 1, 1. + let h = heights(&[0.0, 5.0, 5.0, 10.0, 15.0]); + assert_eq!(h.len(), 3); + assert!((h[0] / h[1] - 3.0).abs() < 0.01 && (h[1] - h[2]).abs() < 0.01, "{h:?}"); +} + #[test] fn union_series_references() { let mut wb = Workbook::new(); From 098b9e70b41c25b9e412cbbe15052dcf3dc23a32 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:55 +0200 Subject: [PATCH 042/162] Reject oversized TEXTSPLIT results before building the padded grid (#538) --- crates/functions/src/text.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 723cd4a..ba0d18e 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -5,7 +5,8 @@ use gridcraft_core::{CellError, Value, number_to_text}; use crate::criteria::wildcard_find; use crate::util::{ - A, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text, text_val, + A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text, + text_val, }; use crate::{Arg, Ctx, FnSpec, VAR}; @@ -610,6 +611,9 @@ fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R { } let ncols = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); let nrows = rows.len(); + if nrows.saturating_mul(ncols) > MAX_CELLS { + return Err(CellError::Num); + } let mut data = Vec::with_capacity(nrows * ncols); for r in rows { let n = r.len(); @@ -1133,6 +1137,13 @@ mod tests { assert_eq!(rows_of(&v)[1], vec![tv("c"), tv("-")]); } + #[test] + fn split_rejects_oversized_padded_grid() { + // One row of 8000 columns followed by 8000 one-cell rows: the text is under the cell-text limit, the padded grid is not. + let text = format!("{}{}", "a,".repeat(8000), "b;".repeat(8000)); + is_err(ev("TEXTSPLIT", vec![t(&text), t(","), t(";")]), CellError::Num); + } + #[test] fn to_text() { is_text(ev("VALUETOTEXT", vec![t("a\"b")]), "a\"b"); From b039465d414ead6b0c465bae0ecb01c384c255a8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:10 +0200 Subject: [PATCH 043/162] Refuse XLSX export of sheet names XLSX cannot hold (#552) --- crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/sheet_names.rs | 23 +++++++++++++++++++++++ crates/xlsx/src/write.rs | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 crates/xlsx/src/tests/sheet_names.rs diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index d5d2f2f..05302ad 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -10,6 +10,7 @@ mod passthrough_tests; mod pivot_tests; mod richdata_tests; mod roundtrip; +mod sheet_names; mod smooth_roundtrip; mod trendline_roundtrip; mod xls_tests; diff --git a/crates/xlsx/src/tests/sheet_names.rs b/crates/xlsx/src/tests/sheet_names.rs new file mode 100644 index 0000000..a557bd8 --- /dev/null +++ b/crates/xlsx/src/tests/sheet_names.rs @@ -0,0 +1,23 @@ +//! Sheet names XLSX cannot hold are refused on export instead of breaking formulas on reopen. + +use std::sync::Arc; + +use gridcraft_model::{Sheet, Workbook}; + +use crate::{IoError, write_xlsx}; + +fn book(names: &[&str]) -> Workbook { + let mut wb = Workbook::new(); + wb.sheets = names.iter().map(|n| Arc::new(Sheet::new(*n))).collect(); + wb +} + +#[test] +fn unrepresentable_sheet_names_are_refused() { + let long_a = format!("{}X", "A".repeat(31)); + let long_b = format!("{}Y", "A".repeat(31)); + for names in [vec!["Data", "a/b"], vec![long_a.as_str(), long_b.as_str()], vec!["Data", "data"], vec!["Data", ""]] { + assert!(matches!(write_xlsx(&book(&names)), Err(IoError::Format(_))), "{names:?}"); + } + assert!(write_xlsx(&book(&["Data", "Other"])).is_ok()); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 9c117f4..5492756 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -161,6 +161,30 @@ pub fn abs_range(r: &RangeRef) -> String { format!("${}${}:${}${}", c(r.start.col), r.start.row + 1, c(r.end.col), r.end.row + 1) } +/// Refuses sheet names XLSX cannot hold. The reader repairs such names (cuts, strips, renames +/// duplicates) but formulas keep the old spelling, so the saved file would turn into `#REF!`. +fn check_sheet_names(wb: &Workbook) -> Result<(), IoError> { + let mut seen = std::collections::HashSet::new(); + for sheet in &wb.sheets { + let name = &sheet.name; + let bad = if name.is_empty() { + Some("is blank") + } else if name.chars().count() > 31 { + Some("is longer than 31 characters") + } else if name.chars().any(|c| matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')) { + Some("contains one of : \\ / ? * [ ]") + } else if !seen.insert(name.to_ascii_lowercase()) { + Some("is used by another sheet") + } else { + None + }; + if let Some(why) = bad { + return Err(IoError::Format(format!("sheet name \"{name}\" {why}; rename the sheet before saving as XLSX"))); + } + } + Ok(()) +} + pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { if wb.sheets.is_empty() { return Err(IoError::Format("a workbook needs at least one sheet".into())); @@ -168,6 +192,7 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { if wb.sheets.iter().any(|sheet| sheet.cell_pictures.keys().any(|c| sheet.cell(*c).is_some_and(|cell| cell.formula.is_some()))) { return Err(IoError::Format("a static cell picture cannot also contain a formula".into())); } + check_sheet_names(wb)?; let mut out = Out { parts: vec![], overrides: vec![], From 555635e13e36d45ecc921671934cf13e1a945a7d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:55 +0200 Subject: [PATCH 044/162] Return zero from ROUNDDOWN and TRUNC at precisions beyond the number range (#540) --- crates/functions/src/math.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index bab219e..f7d80a0 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -75,7 +75,14 @@ fn round_dir(x: f64, digits: i32, up: bool) -> f64 { } let s = clean(scaled); let r = if up { s.ceil() } else { s.floor() }; - let out = if neg { r * f } else { clean(r / f) }; + // r == 0 with an infinite scale would be 0 * inf = NaN (#NUM!); the result is simply zero. + let out = if !neg { + clean(r / f) + } else if r == 0.0 { + 0.0 + } else { + r * f + }; if x < 0.0 { -out } else { out } } @@ -1373,6 +1380,13 @@ mod tests { close(ev2("ROUNDDOWN", -3.9, 0.0), -3.0); close(ev2("ROUNDDOWN", 2.71989, 3.0), 2.719); close(ev2("ROUNDDOWN", 31415.92654, -2.0), 31400.0); + for digits in [-308.0, -309.0, -400.0] { + for x in [1234.0, -1234.0, 0.5] { + close(ev2("ROUNDDOWN", x, digits), 0.0); + close(ev2("TRUNC", x, digits), 0.0); + } + } + assert!(matches!(ev2("ROUNDUP", 1234.0, -309.0), Value::Error(CellError::Num))); close(ev2("ROUNDUP", 0.1 + 0.2, 1.0), 0.3); close(ev2("TRUNC", 8.987, 2.0), 8.98); close(ev1("TRUNC", -8.9), -8.0); From 76d3879a94e67e16874d5b765d2193258c547dba Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:42 +0200 Subject: [PATCH 045/162] Fix data.filterBy inside a table installing the sheet AutoFilter instead of toggling the table button (#547) --- crates/engine/src/cmd/data.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3ba..c915bd9 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -462,6 +462,12 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result { Ok(json!({"filter": false})) }); } + install_filter(s, p) +} + +/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection. +fn install_filter(s: &mut Session, p: &Json) -> Result { + let sheet = s.doc()?.wb.active_sheet; let mut r = data_range(s, p)?; // A selected header row filters the data below it (#201). if p.get("range").is_none() && r.height() == 1 { @@ -484,7 +490,7 @@ fn filter_by(s: &mut Session, p: &Json) -> Result { let af = match &sh.autofilter { Some(a) => a.clone(), None => { - toggle_filter(s, &json!({}))?; + install_filter(s, &json!({}))?; s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))? } }; @@ -1278,6 +1284,24 @@ fn subtotal(s: &mut Session, p: &Json) -> Result { mod tests { use super::*; + #[test] + fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() { + for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap(); + s.execute("selection.set", sel.clone()).unwrap(); + s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}"); + assert!(sh.tables[0].filter_button, "{sel}"); + assert!(!sh.is_row_hidden(1), "{sel}"); + assert!(sh.is_row_hidden(2), "{sel}"); + assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}"); + } + } + #[test] fn partial_validation_edits_keep_the_rest_of_the_old_rule() { let mut s = Session::new(); From 125f460cef63312198ec6d750364b4a3c7c026c2 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:02 +0200 Subject: [PATCH 046/162] Paste Special: copy the validation rule with the Validation and All options (#562) --- crates/engine/src/cmd/data.rs | 14 +++++++++++++- crates/engine/src/cmd/edit.rs | 14 ++++++++++++++ crates/engine/src/tests.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3ba..d55fb10 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -896,7 +896,7 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { } /// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges. -fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { +pub(super) fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { for dv in validations.iter_mut() { for cut in cuts { dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect(); @@ -1046,6 +1046,18 @@ fn formula_at(f: &str, first: CellRef, at: CellRef) -> String { } } +/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to` +/// (pasting a rule); text without relative references (constants, literal lists) stays as typed. +pub(super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String { + if first == to { + return f.to_string(); + } + let moved = formula_at(f, first, to); + let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved); + let eq = if f.starts_with('=') { "=" } else { "" }; + if has_relative { format!("{eq}{moved}") } else { f.to_string() } +} + fn compare_op(op: CfOperator, n: f64, a: Option, b: Option) -> bool { let Some(a) = a else { return false }; match op { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3da230b..74fb504 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1111,6 +1111,20 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { } } } + // The copied cell's validation rule replaces whatever rule the destination had. + if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { + let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned(); + let sh = cx.sheet_mut(dest_sheet)?; + super::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); + if let Some(mut dv) = rule { + // Relative references move with the rule, as pasted formulas do. + let first = dv.ranges.first().map_or(sc, |r| r.start); + dv.f1 = super::data::move_rule_formula(&dv.f1, first, dest); + dv.f2 = dv.f2.map(|f| super::data::move_rule_formula(&f, first, dest)); + dv.ranges = vec![RangeRef::new(dest, dest)]; + sh.validations.push(dv); + } + } match what { What::Comments => { if let Some(cm) = src_sheet.comments.get(&sc).cloned() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..0359688 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2795,3 +2795,32 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() { assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched"); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn paste_special_validation_copies_the_rule() { + let mut s = s(); + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); + s.execute("cell.set", json!({"cell": "C3", "input": "7"})).unwrap(); + s.execute("data.validation", json!({"range": "A1", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "formats", "at": "E5"})).unwrap(); + assert!(ok(&mut s, "E5", "-1")); + s.execute("edit.pasteSpecial", json!({"what": "validation", "at": "C3"})).unwrap(); + assert!(!ok(&mut s, "C3", "-1")); + assert!(ok(&mut s, "C3", "1")); + assert!(!ok(&mut s, "A1", "-1")); + assert_eq!(s.execute("cell.get", json!({"cell": "C3"})).unwrap()["text"], json!("7")); +} + +#[test] +fn paste_special_validation_moves_relative_references() { + let mut s = s(); + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + s.execute("cell.set", json!({"cell": "A1", "input": "50"})).unwrap(); + s.execute("data.validation", json!({"range": "A1", "type": "custom", "formula1": "=A1>10"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "validation", "at": "C3"})).unwrap(); + assert!(!ok(&mut s, "C3", "5"), "the pasted rule checks C3, not A1"); + assert!(ok(&mut s, "C3", "20")); +} From 5fb854be9cb9bf5e5aa362d8d700b5482696d538 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:28:58 +0200 Subject: [PATCH 047/162] Chart: color pie, doughnut, treemap and sunburst points from the workbook theme (#579) --- crates/chart/src/lib.rs | 3 ++ crates/chart/src/render.rs | 3 +- crates/chart/src/resolve.rs | 5 ++- crates/chart/src/tests.rs | 80 +++++++++++++++++++++++++++++++------ 4 files changed, 77 insertions(+), 14 deletions(-) diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs index 083ff8b..697e2a7 100644 --- a/crates/chart/src/lib.rs +++ b/crates/chart/src/lib.rs @@ -124,6 +124,9 @@ impl Measure for ApproxMeasure { pub struct ChartData { pub categories: Vec, pub series: Vec, + /// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme. + #[serde(default)] + pub palette: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 63d30d9..6874cc2 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -129,7 +129,7 @@ fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { fn point_color(data: &ChartData, j: usize) -> Rgba { match (j, data.series.first()) { (0, Some(s)) => s.color, - _ => series_color(&Theme::default(), j), + _ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)), } } @@ -190,6 +190,7 @@ fn sanitize_vals(v: &[Option]) -> Vec> { fn sanitize(d: &ChartData) -> ChartData { ChartData { + palette: d.palette.iter().take(MAX_POINTS).copied().collect(), categories: d.categories.iter().take(MAX_POINTS).cloned().collect(), series: d .series diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 2c17eff..e059103 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -16,6 +16,8 @@ pub(crate) const MAX_TRENDLINES: usize = 16; /// Tints used for series beyond the six theme accents: lighter, darker, lighter still… const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2]; +/// `series_color` repeats after this many colours (six accents × the tints). +pub(crate) const PALETTE_CYCLE: usize = 6 * CYCLE_TINTS.len(); /// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them. pub fn series_color(theme: &Theme, i: usize) -> Rgba { @@ -239,5 +241,6 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { } let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0); let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect()); - ChartData { categories, series } + let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE)); + ChartData { categories, series, palette } } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a942677..f283f8d 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData { let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2); b.secondary = kind == ChartKind::Combo; ChartData { + palette: vec![], categories: cats.iter().map(|s| s.to_string()).collect(), series: vec![ series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1), @@ -362,7 +363,7 @@ fn scatter_with_text_x_values_numbers_the_points() { let d = resolve(&wb, 0, &c); assert_eq!(d.series[0].x, None, "{kind:?}"); let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); - let empty = render(&c, &ChartData { categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); + let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points"); // Numeric X values are kept. c.series[0].categories = Some("Sheet1!$C$1:$C$3".into()); @@ -445,7 +446,11 @@ fn pie_starts_at_twelve_clockwise() { #[test] fn negative_values_put_axis_below_zero() { let c = chart(ChartKind::ColumnClustered); - let d = ChartData { categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] }; + let d = ChartData { + palette: vec![], + categories: vec![], + series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)], + }; let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); let t = texts(&prims); assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}"); @@ -456,8 +461,13 @@ fn negative_values_put_axis_below_zero() { fn edge_cases_never_panic() { let m = ApproxMeasure; let empty = ChartData::default(); - let zeros = ChartData { categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)] }; + let zeros = ChartData { + palette: vec![], + categories: vec!["a".into(), "b".into()], + series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)], + }; let weird = ChartData { + palette: vec![], categories: vec![String::new(); 3], series: vec![SeriesData { name: "w".repeat(500), @@ -488,7 +498,7 @@ fn thousands_of_points_are_decimated() { let vals: Vec = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect(); let mut c = chart(ChartKind::Line); c.data_labels = true; - let d = ChartData { categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure); let max_line = prims .iter() @@ -513,7 +523,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = smooth; - let d = ChartData { categories: cats.clone(), series: vec![s] }; + let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] }; render(&chart(ChartKind::Line), &d, 600.0, 400.0, m) .iter() .filter_map(|p| match p { @@ -530,7 +540,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { // Rendering either way must stay finite and inside the canvas. let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; - let d = ChartData { categories: cats, series: vec![s] }; + let d = ChartData { palette: vec![], categories: cats, series: vec![s] }; assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0); } @@ -540,7 +550,7 @@ fn smooth_line_splits_at_gaps() { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; s.values[1] = None; // gap between two data points - let d = ChartData { categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; + let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m); // Two separate runs (before/after the gap) → at least two line primitives. let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count(); @@ -617,7 +627,7 @@ fn fuzz_random_data_and_sizes() { }) .collect(); let ncat = (r.next() % 50) as usize; - let d = ChartData { categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; + let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; let mut c = chart(kind); c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize]; c.data_labels = r.next().is_multiple_of(2); @@ -792,7 +802,7 @@ fn line_chart_draws_trendline_forecast_and_labels() { let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line); s.trendlines = vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }]; - let d = ChartData { categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; + let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; let mut c = chart(ChartKind::Line); c.legend = LegendPos::None; // keeps legend swatches out of the line counts let prims = render(&c, &d, 600.0, 400.0, &m); @@ -816,7 +826,7 @@ fn line_chart_draws_trendline_forecast_and_labels() { assert!(lines_of(&pie, tc).is_empty()); // One point: nothing to fit, nothing drawn. s.values.truncate(1); - let one = ChartData { categories: vec!["a".into()], series: vec![s] }; + let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] }; assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty()); } @@ -836,7 +846,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] }, TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] }, ]; - let d = ChartData { categories: vec![], series: vec![s] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![s] }; let mut c = chart(ChartKind::Scatter); c.legend = LegendPos::None; let prims = render(&c, &d, 600.0, 400.0, &m); @@ -851,6 +861,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { #[test] fn treemap_and_histogram_shapes() { let d = ChartData { + palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)], }; @@ -871,7 +882,7 @@ fn treemap_and_histogram_shapes() { assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}"); let vals: Vec = (0..100).map(|i| i as f64).collect(); - let d = ChartData { categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count(); assert_eq!(bins, 10); @@ -902,3 +913,48 @@ fn union_series_references() { c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000)); let _ = resolve(&wb, 0, &c); } + +#[test] +fn pie_points_follow_workbook_theme() { + let mut wb = Workbook::new(); + wb.theme.colors[4] = 0x4F6D8A; + wb.theme.colors[5] = 0x8AA1B8; + let mut c = chart(ChartKind::Pie); + c.series = vec![Series { + name: None, + categories: None, + values: "{5,3}".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + trendlines: Vec::new(), + }]; + let d = resolve(&wb, 0, &c); + let expected = vec![series_color(&wb.theme, 0), series_color(&wb.theme, 1)]; + assert_eq!(expected, vec![[0x4F, 0x6D, 0x8A, 0xFF], [0x8A, 0xA1, 0xB8, 0xFF]]); + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend, expected); + let fills: Vec = render(&c, &d, 480.0, 288.0, &ApproxMeasure) + .iter() + .filter_map(|p| match p { + Prim::Wedge { fill, .. } => Some(*fill), + _ => None, + }) + .collect(); + assert_eq!(fills, expected); + for kind in [ChartKind::Doughnut, ChartKind::Treemap, ChartKind::Sunburst] { + c.kind = kind; + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend, expected, "{kind:?}"); + } + // Past the 36-colour cycle the points keep following the workbook theme. + c.kind = ChartKind::Pie; + c.series[0].values = format!("{{{}}}", ["1"; 40].join(",")); + let d = resolve(&wb, 0, &c); + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend.len(), 40); + assert_eq!(legend.get(37), Some(&series_color(&wb.theme, 37))); + assert_ne!(series_color(&wb.theme, 37), series_color(&gridcraft_model::Theme::default(), 37)); +} From 389c539da4f12e8c26fa10e198abd7df8c7056a6 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:14 +0200 Subject: [PATCH 048/162] Honour the gridlines flag on box-and-whisker and stock charts (#543) --- crates/chart/src/render.rs | 12 ++++++------ crates/chart/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 63d30d9..0e4ebfd 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -266,8 +266,8 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area), Histogram => histogram(ctx, chart, data, area), Waterfall => waterfall(ctx, chart, data, area), - BoxWhisker => box_whisker(ctx, data, area), - Stock => stock(ctx, data, area), + BoxWhisker => box_whisker(ctx, chart, data, area), + Stock => stock(ctx, chart, data, area), _ => cartesian(ctx, chart, data, area, false), } } @@ -1557,12 +1557,12 @@ fn quantile(sorted: &[f64], q: f64) -> f64 { a + (b - a) * frac } -fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } @@ -1601,7 +1601,7 @@ fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) { // ---------------------------------------------------------------- stock -fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); if ncat == 0 { return; @@ -1610,7 +1610,7 @@ fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) { let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a942677..db821b7 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -902,3 +902,24 @@ fn union_series_references() { c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000)); let _ = resolve(&wb, 0, &c); } + +#[test] +fn box_and_stock_charts_honour_the_gridlines_flag() { + // Horizontal full-width lines in the gridline colour are the value-axis gridlines. + let grid: Rgba = [217, 217, 217, 255]; + let is_grid = |p: &Prim| match p { + Prim::Line { color, pts, .. } => *color == grid && pts.len() == 2 && pts[0][1] == pts[1][1] && pts[1][0] - pts[0][0] > 200.0, + _ => false, + }; + let grids = |prims: &[Prim]| prims.iter().filter(|p| is_grid(p)).count(); + for kind in [ChartKind::BoxWhisker, ChartKind::Stock, ChartKind::ColumnClustered] { + let mut d = sample(kind); + d.series.push(series("Close", &[10000.0, 12000.0, 9000.0, 14000.0, 20000.0, 16000.0], [90, 90, 90, 255], kind)); + let mut c = chart(kind); + let on = render(&c, &d, 500.0, 350.0, &ApproxMeasure); + c.gridlines = false; + let off = render(&c, &d, 500.0, 350.0, &ApproxMeasure); + assert!(grids(&on) > 0, "{kind:?}: gridlines on must draw lines"); + assert_eq!(grids(&off), 0, "{kind:?}: gridlines off must hide them"); + } +} From 7464ce6e6ec152835850c4c40a5087c9b5bbfdf4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:28 +0200 Subject: [PATCH 049/162] Return an error when a weekday fill runs past the supported dates (#535) --- crates/engine/src/fill.rs | 5 ++++- crates/engine/src/tests.rs | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 9e0c970..9f24e00 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -350,7 +350,10 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s let mut left = step.abs() as i64; while left > 0 { n += step.signum(); - if datetime_from_serial(sys, n).is_some_and(|d| d.weekday != 0 && d.weekday != 6) { + let Some(day) = datetime_from_serial(sys, n) else { + return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into())); + }; + if day.weekday != 0 && day.weekday != 6 { left -= 1; } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..ef3e09a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2505,6 +2505,15 @@ fn fill_series_month_clamps_to_month_end() { assert_eq!(v(&s, "A4"), ymd(2026, 4, 15)); } +#[test] +fn fill_series_weekday_out_of_range_step_errors() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "45000"})).unwrap(); + let r = s.execute("edit.fillSeries", json!({"range": "A1:A2", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": 1e12})); + assert!(r.is_err()); + assert_eq!(v(&s, "A2"), Value::Empty); +} + #[test] fn fill_series_month_leap_year() { let s = series_date("2028-01-31", "A1:A3", "month"); From 9bbb2636617d324ba348cc45bf7bf4b47a04a4db Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:28 +0200 Subject: [PATCH 050/162] HSTACK checks its padded output size before building it (#536) --- crates/functions/src/dynamic.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/dynamic.rs b/crates/functions/src/dynamic.rs index 3a66ca8..86f5afc 100644 --- a/crates/functions/src/dynamic.rs +++ b/crates/functions/src/dynamic.rs @@ -30,6 +30,11 @@ fn rows_of(a: &Array) -> Vec> { (0..a.rows).map(|r| (0..a.cols).map(|c| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect()).collect() } +/// `#NUM!` when an `h` x `w` output would exceed the function cell budget. +fn check_output_size(h: usize, w: usize) -> R<()> { + if h.saturating_mul(w) > MAX_CELLS { Err(CellError::Num) } else { Ok(()) } +} + /// Builds an array from rows (all rows padded to the widest with `pad`). Empty → `#CALC!`. fn from_rows(rows: Vec>, pad: &Value) -> R { let h = rows.len(); @@ -37,9 +42,7 @@ fn from_rows(rows: Vec>, pad: &Value) -> R { if h == 0 || w == 0 { return Err(CellError::Calc); } - if h.saturating_mul(w) > MAX_CELLS { - return Err(CellError::Num); - } + check_output_size(h, w)?; let mut data = Vec::with_capacity(h * w); for mut r in rows { r.resize(w, pad.clone()); @@ -321,16 +324,20 @@ fn vstack(args: &[Arg], _c: &mut dyn Ctx) -> R { fn hstack(args: &[Arg], _c: &mut dyn Ctx) -> R { let mut arrays = Vec::new(); let mut h = 0usize; + let mut w = 0usize; let mut total = 0usize; for i in 0..args.len() { let a = arr_arg(args, i)?; h = h.max(a.rows); + w = w.saturating_add(a.cols); total = total.saturating_add(a.data.len()); if total > MAX_CELLS { return Err(CellError::Num); } arrays.push(a); } + // The padded rectangle can be far larger than the inputs; refuse it before building any rows. + check_output_size(h, w)?; let mut rows = vec![Vec::new(); h]; for a in &arrays { for (r, row) in rows.iter_mut().enumerate() { @@ -1132,6 +1139,17 @@ mod tests { is_err(ev("TOROW", vec![av(arr(vec![vec![Value::Empty]])), n(1.0)]), CellError::Calc); } + #[test] + fn output_size_checked_before_padding() { + assert_eq!(super::check_output_size(1_000_001, 10), Err(CellError::Num)); + assert_eq!(super::check_output_size(usize::MAX, 2), Err(CellError::Num)); + assert_eq!(super::check_output_size(1_000_000, 10), Ok(())); + // A 1M-row column beside a 9-wide row: ~1M input cells, but the padded result would be 10,000,010. + let tall = Value::from(gridcraft_core::Array::column(vec![nv(1.0); 1_000_001])); + let wide = row(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]); + is_err(ev("HSTACK", vec![av(tall), av(wide)]), CellError::Num); + } + #[test] fn wrapping() { let r = ev("WRAPROWS", vec![av(row(&[1.0, 2.0, 3.0, 4.0, 5.0])), n(2.0)]); From 9a698aa1ffb1e269a6edad4fdcc36c3a284aaba7 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:31 +0200 Subject: [PATCH 051/162] Make gridcraft-cli check compare conditional-format rule contents, not just their count (#559) --- apps/gridcraft-cli/src/check.rs | 37 ++++++++++++++++++++++++++++++++- docs/cli.md | 7 ++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index 9fc81e3..b1ab389 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -559,6 +559,14 @@ fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet, b: &BTreeSet Vec { + let mut cfs: Vec<_> = s.cond_formats.iter().collect(); + cfs.sort_by_key(|c| c.priority); + cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect() +} + fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) { if a != b { d.push(|| format!("{sheet}: {a} → {b}")); @@ -634,7 +642,12 @@ pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec>(); let (ca, cb) = (chart_list(sa), chart_list(sb)); @@ -1303,6 +1316,28 @@ mod tests { assert_eq!(get("tables").diffs, 0); } + #[test] + fn diff_detects_changed_cond_format_rule() { + use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat}; + let bar = |gradient: bool, priority: u32| CondFormat { + ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()], + rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true }, + priority, + stop_if_true: false, + }; + let with = |cf: CondFormat| { + let mut wb = book(&[("A1", "2", n(2.0))]); + wb.sheet_mut(0).unwrap().cond_formats.push(cf); + wb + }; + let diffs = |a: &Workbook, b: &Workbook| { + diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs + }; + assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0); + } + fn zip_of(parts: &[(&str, &str)]) -> Vec { let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); for (name, body) in parts { diff --git a/docs/cli.md b/docs/cli.md index b3ee318..372f43c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -86,9 +86,10 @@ up by *signature*, `expected type → actual type` (e.g. `number → #SPILL!`), **Round trip.** The recalculated workbook is written with GridCraft's XLSX writer, the package is validated (every part has a content type, every content-type override and internal relationship target exists, relationship ids are unique: what Excel would "repair") and read back. Categories compared: `package`, `sheets` (names and order), -`values`, `formulas`, `numFmts` (per cell), `merges`, `names`, `tables` (name, range, columns), `condFormats` (rules -per sheet), `validations`, `charts` (kinds and series counts), `comments`, `hyperlinks`, `pivots`. `#SPILL!`/`#CALC!` -compare equal to `#VALUE!` and `#CIRC!` to 0, as that's what the file format stores. +`values`, `formulas`, `numFmts` (per cell), `merges`, `names`, `tables` (name, range, columns), `condFormats` (each +sheet's rules in priority order, priority numbers aside), `validations`, `charts` (kinds and series counts), `comments`, +`hyperlinks`, `pivots`. `#SPILL!`/`#CALC!` compare equal to `#VALUE!` and `#CIRC!` to 0, as +that's what the file format stores. Files that fail to open are reported with GridCraft's error and don't stop the run. Exit code: 0 when every file opened with no mismatch and no round-trip difference, 1 otherwise, 2 on a usage error. `--max-report` caps the From 2d0fb534a20c650f39cf79e415d323a9e35972f0 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:40 +0200 Subject: [PATCH 052/162] Fix saving a custom view or script when the stored list is not a JSON object (#517) --- crates/engine/src/cmd/extra.rs | 16 ++++++-- crates/engine/tests/stored_list_shape.rs | 48 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/stored_list_shape.rs diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index a08f080..5a23b8d 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -701,6 +701,16 @@ fn stash_get(wb: &Workbook, key: &str) -> Json { .unwrap_or(json!({})) } +/// Like `stash_get`, for the name-keyed lists (views, scripts): a stored value that is not a JSON object is treated as empty. +fn stash_map(wb: &Workbook, key: &str) -> Json { + let v = stash_get(wb, key); + if v.is_object() { + v + } else { + json!({}) + } +} + fn stash_set(wb: &mut Workbook, key: &str, v: &Json) { let text = v.to_string(); let formula = format!("\"{}\"", text.replace('"', "\"\"")); @@ -723,7 +733,7 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc fn custom_views(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let mut views = stash_get(&d.wb, VIEWS_NAME); + let mut views = stash_map(&d.wb, VIEWS_NAME); if let Some(name) = str_param(p, "save") { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); @@ -938,7 +948,7 @@ fn new_script(s: &mut Session, p: &Json) -> Result { return Err(bad("automate.newScript", "`commands` must be an array")); } edit(s, |cx| { - let mut all = stash_get(&cx.wb, SCRIPTS_NAME); + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); all[name.as_str()] = cmds.clone(); stash_set(&mut cx.wb, SCRIPTS_NAME, &all); Ok(json!({"name": name})) @@ -977,7 +987,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result { fn delete_script(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); edit(s, |cx| { - let mut all = stash_get(&cx.wb, SCRIPTS_NAME); + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); if let Some(o) = all.as_object_mut() { o.remove(&name); } diff --git a/crates/engine/tests/stored_list_shape.rs b/crates/engine/tests/stored_list_shape.rs new file mode 100644 index 0000000..0d38cd5 --- /dev/null +++ b/crates/engine/tests/stored_list_shape.rs @@ -0,0 +1,48 @@ +use gridcraft_engine::Session; +use serde_json::json; + +const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\""]; + +fn with_name(name: &str, refers_to: &str) -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap(); + s +} + +#[test] +fn saving_a_view_works_when_stored_list_is_not_an_object() { + for text in NOT_OBJECTS { + let mut s = with_name("_sc_custom_views", text); + let out = s.execute("view.customViews", json!({"save": "v"})).unwrap(); + assert!(out.get("v").is_some(), "{text}: {out}"); + } +} + +#[test] +fn saving_a_view_keeps_the_keys_of_a_stored_object() { + let mut s = with_name("_sc_custom_views", "\"{\"\"a\"\":1}\""); + let out = s.execute("view.customViews", json!({"save": "v"})).unwrap(); + assert!(out.get("a").is_some() && out.get("v").is_some(), "{out}"); +} + +#[test] +fn creating_a_script_works_when_stored_list_is_not_an_object() { + for text in NOT_OBJECTS { + let mut s = with_name("_sc_scripts", text); + s.execute("automate.newScript", json!({"name": "s1", "commands": []})).unwrap(); + let list = s.execute("automate.allScripts", json!({})).unwrap(); + assert!(list.to_string().contains("s1"), "{text}: {list}"); + } +} + +#[test] +fn stopping_a_recording_works_when_stored_list_is_not_an_object() { + for text in NOT_OBJECTS { + let mut s = with_name("_sc_scripts", text); + s.execute("automate.recordActions", json!({})).unwrap(); + s.execute("automate.recordActions", json!({"stop": true, "name": "s1"})).unwrap(); + let list = s.execute("automate.allScripts", json!({})).unwrap(); + assert!(list.to_string().contains("s1"), "{text}: {list}"); + } +} From 38463769372391369fd804b664ac4b6e044faec3 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:58 +0200 Subject: [PATCH 053/162] Refuse Total Row on a table that ends on the last worksheet row (#533) --- crates/engine/src/cmd/insert.rs | 3 ++ .../engine/tests/table_total_row_last_row.rs | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 crates/engine/tests/table_total_row_last_row.rs diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 392c5ee..8e0434a 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -349,6 +349,9 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result { "totals" => { let v = on.unwrap_or(!t.totals_row); if v != t.totals_row { + if v && t.range.end.row.saturating_add(1) >= MAX_ROWS { + return Err(EngineError::Other("There's no room below the table for the total row.".into())); + } t.totals_row = v; if v { t.range.end.row += 1; diff --git a/crates/engine/tests/table_total_row_last_row.rs b/crates/engine/tests/table_total_row_last_row.rs new file mode 100644 index 0000000..eb1bd3d --- /dev/null +++ b/crates/engine/tests/table_total_row_last_row.rs @@ -0,0 +1,34 @@ +use gridcraft_core::{CellRef, MAX_ROWS}; +use gridcraft_engine::Session; +use serde_json::json; + +fn refused(range: &str) { + let mut s = Session::new(); + s.new_workbook(); + s.execute("insert.table", json!({"range": range, "header": true})).unwrap(); + let before = s.doc().unwrap().wb.active().unwrap().tables[0].clone(); + assert!(s.execute("table.totalRow", json!({"on": true, "table": &before.name})).is_err(), "{range}"); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.tables[0].range, before.range, "{range}"); + assert_eq!(sh.tables[0].range.end.row, MAX_ROWS - 1, "{range}"); + assert!(!sh.tables[0].totals_row, "{range}"); + assert!(sh.value(CellRef::new(MAX_ROWS, 0)).is_empty(), "{range}"); +} + +#[test] +fn total_row_is_refused_on_a_table_ending_at_the_last_worksheet_row() { + refused("A1048575:B1048576"); + refused("A1048576:B1048576"); +} + +#[test] +fn total_row_still_grows_an_ordinary_table() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Amount"], ["a", 2], ["b", 3]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap(); + s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap(); + let t = &s.doc().unwrap().wb.active().unwrap().tables[0]; + assert!(t.totals_row); + assert_eq!(t.range.end.row, 3); +} From 53df51310fe9ac3278aba75e2f78d4b0834f8a96 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:17 +0200 Subject: [PATCH 054/162] Check the full PIVOTBY layout size before building column headers (#537) --- crates/functions/src/dynamic.rs | 44 ++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/functions/src/dynamic.rs b/crates/functions/src/dynamic.rs index 3a66ca8..e016759 100644 --- a/crates/functions/src/dynamic.rs +++ b/crates/functions/src/dynamic.rs @@ -785,6 +785,12 @@ fn groupby(args: &[Arg], _c: &mut dyn Ctx) -> R { from_rows(out, &Value::Empty) } +/// Height and width of a PIVOTBY grid: `header_rows` (one per column field, plus the value-name row) and +/// `body_rows` (row keys plus the total row), each `lead` row-field cells wide plus `value_cols` cells per column key. +fn pivot_dims(header_rows: usize, body_rows: usize, col_keys: usize, value_cols: usize, lead: usize) -> (usize, usize) { + (header_rows.saturating_add(body_rows), col_keys.saturating_mul(value_cols).saturating_add(lead)) +} + fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R { let rf = arr_arg(args, 0)?; let cf = arr_arg(args, 1)?; @@ -801,9 +807,6 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R { let ckeys: Vec> = p.rows.iter().map(|&r| key_row(&cf, r)).collect(); let rk = distinct_keys(&rkeys, if row_sort.unsigned_abs() as usize <= rf.cols { row_sort } else { 0 }); let ck = distinct_keys(&ckeys, if col_sort.unsigned_abs() as usize <= cf.cols { col_sort } else { 0 }); - if rk.len().saturating_add(2).saturating_mul((ck.len() + 1).saturating_mul(vals.cols).saturating_add(rf.cols)) > MAX_CELLS { - return Err(CellError::Num); - } let mut cell: HashMap<(String, String), Vec> = HashMap::new(); for ((r, c), &i) in rkeys.iter().zip(ckeys.iter()).zip(p.rows.iter()) { cell.entry((row_key(r), row_key(c))).or_default().push(i); @@ -832,6 +835,14 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R { cols.insert(0, None); } let lead = rf.cols; + let value_row = vals.cols > 1 || p.show_headers; + let header_rows = cf.cols.saturating_add(usize::from(value_row)); + let body_rows = rk.len().saturating_add(usize::from(row_total != 0)); + // Reject before building any header or body row: every column-field header row is as wide as the whole grid. + let (height, width) = pivot_dims(header_rows, body_rows, cols.len(), vals.cols, lead); + if height.saturating_mul(width) > MAX_CELLS { + return Err(CellError::Num); + } let mut out: Vec> = Vec::new(); // Column header rows: one per column field, plus value names when several value columns. for level in 0..cf.cols { @@ -847,7 +858,7 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R { } out.push(line); } - if vals.cols > 1 || p.show_headers { + if value_row { let mut line: Vec = if p.show_headers { p.field_headers.iter().take(lead).cloned().collect() } else { vec![Value::Empty; lead] }; line.resize(lead, Value::Empty); for _ in &cols { @@ -1225,4 +1236,29 @@ mod tests { ] ); } + + #[test] + fn pivotby_size_check_counts_column_header_rows() { + // One row, n column fields and n value columns: n header rows + value-name row + body row + total row, + // each 1 + 2n wide. The size checked up front matches the grid PIVOTBY builds. + let dims = |k: u32, totals: bool| { + let fields: Vec = (0..k).map(f64::from).collect(); + let mut args = vec![av(row(&[1.0])), av(row(&fields)), av(row(&fields)), t("SUM")]; + if !totals { + args.extend([empty(), n(0.0), empty(), n(0.0)]); + } + let r = rows_of(&ev("PIVOTBY", args)); + (r.len(), r.first().map_or(0, Vec::len)) + }; + assert_eq!(dims(2, true), super::pivot_dims(3, 2, 2, 2, 1)); + assert_eq!(dims(10, true), (13, 21)); + assert_eq!(dims(10, true), super::pivot_dims(11, 2, 2, 10, 1)); + assert_eq!(dims(10, false), (12, 11)); + assert_eq!(dims(10, false), super::pivot_dims(11, 1, 1, 10, 1)); + // n = 2300: the 2300 header rows alone are 10.58M cells, so the check must count them before any is built. + let (h, w) = super::pivot_dims(2301, 2, 2, 2300, 1); + assert!(2300 * w > crate::util::MAX_CELLS && h * w > crate::util::MAX_CELLS); + let wide: Vec = (0..2300).map(f64::from).collect(); + is_err(ev("PIVOTBY", vec![av(row(&[1.0])), av(row(&wide)), av(row(&wide)), t("SUM")]), CellError::Num); + } } From baac184ed87e55642527b72e37c194e8ae32f82d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:22 +0200 Subject: [PATCH 055/162] Reject row/column insert counts past the worksheet and clamp delete counts instead of overflowing (#531) --- crates/engine/src/cmd/sheet.rs | 9 ++++- crates/engine/tests/line_count_overflow.rs | 47 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/line_count_overflow.rs diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index f05b77d..4820905 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -249,10 +249,14 @@ fn cancel_copy_mode(s: &mut Session) { fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { let (at, count) = lines(s, p, axis)?; + let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS }; + if at.checked_add(count).is_none_or(|end| end > limit) { + let cmd = if axis == Axis::Rows { "home.insertRows" } else { "home.insertColumns" }; + return Err(bad(cmd, "the inserted lines would extend past the end of the worksheet")); + } check_lines_protection(s, p, axis, at, count, true)?; let sheet = target_sheet(s, p)?; // Refuse to push data off the sheet. - let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS }; if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) { let last = if axis == Axis::Rows { u.end.row } else { u.end.col }; if last >= at && last as u64 + count as u64 >= limit as u64 { @@ -290,6 +294,9 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { let (at, count) = lines(s, p, axis)?; + let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS }; + // Deleting past the last line only removes up to the end of the worksheet. + let count = count.min(limit.saturating_sub(at)).max(1); check_lines_protection(s, p, axis, at, count, false)?; let sheet = target_sheet(s, p)?; let out = edit(s, |cx| { diff --git a/crates/engine/tests/line_count_overflow.rs b/crates/engine/tests/line_count_overflow.rs new file mode 100644 index 0000000..9ae58d1 --- /dev/null +++ b/crates/engine/tests/line_count_overflow.rs @@ -0,0 +1,47 @@ +use gridcraft_engine::{EngineError, Session}; +use serde_json::json; + +fn session() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session +} + +#[test] +fn insert_with_an_out_of_range_count_is_a_normal_error() { + for (id, key, at) in [("home.insertRows", "rows", "3:3"), ("home.insertColumns", "cols", "C:C")] { + let mut session = session(); + let err = session.execute(id, json!({ key: at, "count": u32::MAX })).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{id}: {err:?}"); + } +} + +#[test] +fn insert_after_a_formatted_line_with_an_out_of_range_count_is_a_normal_error() { + // A custom size on the preceding line and no cells: the used-range guard has nothing to check. + let mut session = session(); + session.execute("home.rowHeight", json!({"rows": "2:2", "height": 30})).unwrap(); + let err = session.execute("home.insertRows", json!({"rows": "3:3", "count": u32::MAX})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}"); + + let mut session = session(); + session.execute("home.columnWidth", json!({"cols": "B:B", "width": 100})).unwrap(); + let err = session.execute("home.insertColumns", json!({"cols": "C:C", "count": u32::MAX})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}"); +} + +#[test] +fn delete_with_an_out_of_range_count_does_not_panic() { + for (id, key, at) in [("home.deleteRows", "rows", "3:3"), ("home.deleteColumns", "cols", "C:C")] { + let mut session = session(); + let res = session.execute(id, json!({ key: at, "count": u32::MAX })); + assert!(!matches!(res, Err(EngineError::Internal(..))), "{id}: {res:?}"); + } +} + +#[test] +fn insert_and_delete_with_a_small_count_still_work() { + let mut session = session(); + session.execute("home.insertRows", json!({"rows": "1:1", "count": 3})).unwrap(); + session.execute("home.deleteRows", json!({"rows": "1:1", "count": 3})).unwrap(); +} From c8a47c9fb3adcf7b2f52ef069d26215097ead083 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:44 +0200 Subject: [PATCH 056/162] Read omitted val on chart smooth and showVal as on (#518) --- crates/xlsx/src/chart.rs | 4 +-- crates/xlsx/src/tests/chart_bool_default.rs | 32 +++++++++++++++++++++ crates/xlsx/src/tests/mod.rs | 1 + 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 crates/xlsx/src/tests/chart_bool_default.rs diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index 052d620..020ce67 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -106,7 +106,7 @@ fn read_series(s: &El) -> Series { color, secondary: false, kind: None, - smooth: s.child("smooth").and_then(|e| e.attr("val")).and_then(|v| v.parse::().ok()).is_some_and(|v| v != 0), + smooth: bool_val(s, "smooth"), trendlines: s.kids("trendline").filter_map(read_trendline).collect(), } } @@ -201,7 +201,7 @@ pub fn read_chart(root: &El) -> Option { } } let kind = if groups.len() > 1 { ChartKind::Combo } else { first_kind }; - let show_val = |e: &El| e.child("dLbls").and_then(|d| d.child_val("showVal")).is_some_and(|v| v == "1" || v == "true"); + let show_val = |e: &El| e.child("dLbls").is_some_and(|d| bool_val(d, "showVal")); let data_labels = groups.iter().any(|(g, _)| show_val(g) || g.kids("ser").any(show_val)); let val_axes: Vec<&El> = plot.kids("valAx").collect(); let cat_ax = plot.child("catAx").or_else(|| plot.child("dateAx")); diff --git a/crates/xlsx/src/tests/chart_bool_default.rs b/crates/xlsx/src/tests/chart_bool_default.rs new file mode 100644 index 0000000..e9a074c --- /dev/null +++ b/crates/xlsx/src/tests/chart_bool_default.rs @@ -0,0 +1,32 @@ +//! A `CT_Boolean` chart flag with no `val` attribute is on (ECMA-376), for series smoothing and value labels. + +use crate::chart::read_chart; +use crate::xml::parse; + +/// Reads a line chart with `ser` appended to its one series and `group` inside the group's ``. +fn flags(ser: &str, group: &str) -> (bool, bool) { + let xml = format!( + "\ + \ + Data!$A$1:$A$5{ser}{group}\ + \ + " + ); + let root = parse(xml.as_bytes()).unwrap(); + let ch = read_chart(&root).unwrap(); + (ch.series[0].smooth, ch.data_labels) +} + +#[test] +fn omitted_val_means_on() { + assert_eq!(flags("", ""), (true, true)); + assert_eq!(flags("", ""), (false, true), "series-level value labels"); +} + +#[test] +fn explicit_values_are_honoured() { + assert_eq!(flags("", ""), (true, true)); + assert_eq!(flags("", ""), (true, true)); + assert_eq!(flags("", ""), (false, false)); + assert_eq!(flags("", ""), (false, false)); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index d5d2f2f..eef5d5f 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -1,6 +1,7 @@ //! Integration-style tests: round trips, hand-written fixtures, hostile input and CSV. mod array_export; +mod chart_bool_default; mod csv_tests; mod encrypted_tests; mod fixtures; From c20f4c2fcbbc94cebba313d58dc469d44ae00d42 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:35 +0200 Subject: [PATCH 057/162] Fail Web Page export on sheets taller than its row limit instead of dropping cells (#514) --- crates/engine/src/io.rs | 33 ++++++++++++++++++++++++++++----- docs/file-format-parity.md | 2 +- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index e613611..7dc4dff 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -167,7 +167,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()), FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()), FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), - FileKind::Html => Ok(to_html(wb, sheet).into_bytes()), + FileKind::Html => to_html(wb, sheet).map(String::into_bytes), } } @@ -285,9 +285,12 @@ impl std::fmt::Write for ClipboardHtml { } } +/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells. +const HTML_EXPORT_MAX_ROWS: u32 = 100_001; + /// A sheet as a standalone HTML table (Save as Web Page). -pub fn to_html(wb: &Workbook, sheet: usize) -> String { - let Some(sh) = wb.sheet(sheet) else { return String::new() }; +pub fn to_html(wb: &Workbook, sheet: usize) -> Result { + let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) }; let mut out = String::new(); let _ = write!( out, @@ -295,7 +298,13 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String { esc(&sh.name) ); if let Some(r) = sh.used_range() { - for row in r.start.row..=r.end.row.min(r.start.row + 100_000) { + if r.height() > HTML_EXPORT_MAX_ROWS { + return Err(EngineError::Other(format!( + "the sheet is too tall for a web page ({} rows, at most {HTML_EXPORT_MAX_ROWS}). Save as .xlsx or CSV instead.", + r.height() + ))); + } + for row in r.start.row..=r.end.row { if sh.is_row_hidden(row) { continue; } @@ -318,7 +327,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String { } } out.push_str(""); - out + Ok(out) } #[cfg(not(target_arch = "wasm32"))] @@ -418,6 +427,20 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> { mod tests { use super::*; + #[test] + fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near")); + sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far")); + assert!(save_bytes(&wb, "book.html").is_err()); + let sh = wb.sheet_mut(0).unwrap(); + sh.remove_cell(CellRef::new(149_999, 0)); + sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far")); + let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap(); + assert!(html.contains(">near") && html.contains(">far")); + } + #[test] fn macros_are_saved_only_into_xlsm() { let mut wb = Workbook::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8510659..22bc4ec 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -40,7 +40,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | -| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table) | Formatting not exported | +| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported | | `.mht` / `.mhtml` single-file web page | R/W (Windows) | no | no | | | `.json` | via Power Query | GridCraft's own workbook JSON (debug) and JSON import | GridCraft JSON | Not Power Query's JSON shaping; notes and hyperlinks break export (#126) | | `.xml` with XML maps | Developer ▸ Import/Export | no | no | | From dc6f7246a3de8f5ff5ef89e8eed560a62aed7588 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:34 +0200 Subject: [PATCH 058/162] Page Setup opens on the sheet's saved margins (#524) --- crates/ui-egui/src/dialogs.rs | 34 ++++++++++-------- crates/ui-egui/tests/page_setup_margins.rs | 42 ++++++++++++++++++++++ 2 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/tests/page_setup_margins.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c6..776ac82 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -370,20 +370,26 @@ impl Dialog { ], json!({"header": "", "footer": "&CPage &P of &N"}), ), - "pageSetup" => Dialog::form( - "pageSetup", - "Page Setup", - "pageLayout.margins", - vec![ - Number { key: "left", label: "Left margin (in):" }, - Number { key: "right", label: "Right margin (in):" }, - Number { key: "top", label: "Top margin (in):" }, - Number { key: "bottom", label: "Bottom margin (in):" }, - Number { key: "header", label: "Header (in):" }, - Number { key: "footer", label: "Footer (in):" }, - ], - json!({"left": 0.7, "right": 0.7, "top": 0.75, "bottom": 0.75, "header": 0.3, "footer": 0.3}), - ), + "pageSetup" => { + // Opens on the active sheet's saved margins, so OK without edits rewrites them unchanged. + let m = app.session.active().and_then(|d| d.wb.active()).map(|s| s.print.margins).unwrap_or([0.7, 0.7, 0.75, 0.75, 0.3, 0.3]); + // Round off f32 noise so the form shows 0.1, not 0.10000000149. + let [left, right, top, bottom, header, footer] = m.map(|v| (f64::from(v) * 1e4).round() / 1e4); + Dialog::form( + "pageSetup", + "Page Setup", + "pageLayout.margins", + vec![ + Number { key: "left", label: "Left margin (in):" }, + Number { key: "right", label: "Right margin (in):" }, + Number { key: "top", label: "Top margin (in):" }, + Number { key: "bottom", label: "Bottom margin (in):" }, + Number { key: "header", label: "Header (in):" }, + Number { key: "footer", label: "Footer (in):" }, + ], + json!({"left": left, "right": right, "top": top, "bottom": bottom, "header": header, "footer": footer}), + ) + } "textToColumns" => Dialog::form( "textToColumns", "Convert Text to Columns", diff --git a/crates/ui-egui/tests/page_setup_margins.rs b/crates/ui-egui/tests/page_setup_margins.rs new file mode 100644 index 0000000..c604d77 --- /dev/null +++ b/crates/ui-egui/tests/page_setup_margins.rs @@ -0,0 +1,42 @@ +//! Page Setup opens on the active sheet's saved margins, so OK without edits keeps them (issue #524). + +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::{Value, json}; + +fn margins(app: &SheetApp) -> [f32; 6] { + app.session.active().and_then(|d| d.wb.active()).map(|s| s.print.margins).unwrap_or_default() +} + +fn app_with_custom_margins() -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.run("pageLayout.margins", json!({"left": 2.0, "right": 2.5, "top": 3.0, "bottom": 0.25, "header": 0.1, "footer": 0.2})).unwrap(); + assert_eq!(margins(&app), [2.0, 2.5, 3.0, 0.25, 0.1, 0.2]); + app +} + +#[test] +fn page_setup_ok_without_edits_keeps_custom_margins() { + // Custom Margins / File > Page Setup open it bare; Print Titles opens it on the Sheet tab. + for params in [json!({}), json!({"tab": "Sheet"})] { + let mut app = app_with_custom_margins(); + app.open_dialog("pageSetup", params); + let d = app.dialog.take().unwrap(); + let shown = json!({"left": 2.0, "right": 2.5, "top": 3.0, "bottom": 0.25, "header": 0.1, "footer": 0.2}); + assert_eq!(Value::Object(d.values.clone()), shown); + app.run("pageLayout.margins", Value::Object(d.values)).unwrap(); + assert_eq!(margins(&app), [2.0, 2.5, 3.0, 0.25, 0.1, 0.2]); + } +} + +#[test] +fn page_setup_one_edited_field_keeps_the_other_margins() { + let mut app = app_with_custom_margins(); + app.open_dialog("pageSetup", json!({})); + let mut d = app.dialog.take().unwrap(); + d.values.insert("left".into(), json!(1.5)); + app.run("pageLayout.margins", Value::Object(d.values)).unwrap(); + assert_eq!(margins(&app), [1.5, 2.5, 3.0, 0.25, 0.1, 0.2]); +} From 5fbd726a11869c674a53f4ac6e544715139910fb Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:51 +0200 Subject: [PATCH 059/162] Treat non-object params as empty in themeColors, background and drawBorder (#516) --- crates/engine/src/cmd/extra.rs | 11 ++++++++--- crates/engine/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index a08f080..21dbd90 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1052,8 +1052,13 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { }) } +/// Copy of the params that is always a JSON object, so keys can be assigned; any other JSON value counts as no params (like null). +fn object_params(p: &Json) -> Json { + Json::Object(p.as_object().cloned().unwrap_or_default()) +} + fn theme_colors(s: &mut Session, p: &Json) -> Result { - let mut params = p.clone(); + let mut params = object_params(p); if params.get("name").is_none() { params["name"] = json!(s.doc()?.wb.theme.name.clone()); } @@ -1074,7 +1079,7 @@ fn background(s: &mut Session, p: &Json) -> Result { Ok(Json::Null) }); } - let mut params = p.clone(); + let mut params = object_params(p); params["alt"] = json!("Sheet background"); params["at"] = json!("A1"); s.execute("insert.picture", params) @@ -1090,7 +1095,7 @@ fn draw_border(s: &mut Session, p: &Json) -> Result { "outline" => "outside", _ => "all", }; - let mut params = p.clone(); + let mut params = object_params(p); params["preset"] = json!(preset); s.execute("home.borders", params) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..8aa6e95 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1696,6 +1696,23 @@ fn every_command_survives_empty_params() { } } +#[test] +fn non_object_params_do_not_panic_in_wrapper_commands() { + // Issue #516. + for cmd in ["pageLayout.themeColors", "pageLayout.background", "home.drawBorder"] { + for params in [json!([]), json!(5), json!("x"), json!(true)] { + let mut s = s(); + let r = s.execute(cmd, params.clone()); + assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "{cmd} {params}: {r:?}"); + } + } + // A saved script step forwards its params unchanged into the same call. + let mut s = s(); + s.execute("automate.newScript", json!({"name": "bad", "commands": [{"command": "home.drawBorder", "params": []}]})).unwrap(); + let r = s.execute("automate.runScript", json!({"name": "bad"})); + assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "script: {r:?}"); +} + fn fmt_of(s: &Session, a: &str) -> String { let wb = &s.doc().unwrap().wb; let sh = wb.active().unwrap(); From 91873e03eb82d723b83d692e42d350490142e773 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:20 +0200 Subject: [PATCH 060/162] Draw data labels on area and stacked area charts (#545) --- crates/chart/src/render.rs | 9 +++++++++ crates/chart/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 63d30d9..f1b1b3e 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -970,6 +970,8 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta let nslots = slot_keys.len().max(1) as f32; let bw = cw / (nslots + 1.5); let labels_on = chart.data_labels && ncat <= MAX_LABELS; + // Area labels wait until every area is filled, so a later (stacked) area can't paint over them. + let mut area_labels = Vec::new(); // Areas first, then columns, then lines on top. for pass in 0..3 { @@ -998,6 +1000,10 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta let c = center(j); top.push([c, vpos(t, &sc)]); base.push([c, vpos(b, &sc)]); + // Source values (not stack totals), just above the top edge like the line labels. + if labels_on && sp.is_some() && let Some(v) = s.values.get(j).copied().flatten() { + area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None))); + } } if top.len() == 1 { // A single point: give it some width. @@ -1072,6 +1078,9 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta } } } + for (x, y, t) in area_labels.drain(..) { + ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } } // Trendlines on top: x is the category number 1..n. let mut trend_labels = Vec::new(); diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a942677..7fb8301 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -902,3 +902,29 @@ fn union_series_references() { c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000)); let _ = resolve(&wb, 0, &c); } + +#[test] +fn area_charts_draw_source_value_labels_only_when_enabled() { + for kind in [ChartKind::Area, ChartKind::AreaStacked] { + let d = sample(kind); + let mut c = chart(kind); + c.data_labels = false; + let off = render(&c, &d, 640.0, 400.0, &ApproxMeasure); + c.data_labels = true; + let on = render(&c, &d, 640.0, 400.0, &ApproxMeasure); + let count = |p: &[Prim], v: &str| texts(p).iter().filter(|t| **t == v).count(); + // One label per point: 3 series x 6 categories. + assert_eq!(texts(&on).len(), texts(&off).len() + 18, "{kind:?}"); + for v in ["$12,000", "$38,000", "$9,000", "$27,000"] { + assert_eq!(count(&on, v), count(&off, v) + 1, "{kind:?}: {v}"); + } + // Stacked areas label the source values, not cumulative totals (12,000 + 38,000). + assert_eq!(count(&on, "$50,000"), count(&off, "$50,000"), "{kind:?}: cumulative total shown"); + let polys = |p: &[Prim]| p.iter().filter(|p| matches!(p, Prim::Polygon { .. })).cloned().collect::>(); + assert_eq!(polys(&on), polys(&off)); + // The first series' labels are drawn after every area, so the next area can't cover them. + let last_poly = on.iter().rposition(|p| matches!(p, Prim::Polygon { .. })); + let first_label = on.iter().position(|p| matches!(p, Prim::Text { text, .. } if text == "$12,000")); + assert!(first_label > last_poly, "{kind:?}: label drawn under a later area"); + } +} From 2ce94fa581da4c1139ac5085a078ca193824c9cd Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:01 +0200 Subject: [PATCH 061/162] Data Validation dialog opens on the active cell's current rule (#522) --- crates/ui-egui/src/dialogs.rs | 65 ++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c6..400084d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -575,7 +575,7 @@ impl Dialog { }, Text { key: "errorMessage", label: "Error message:" }, ], - json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}), + validation_defaults(app), ), "cfQuick" => { let ty = p("type").as_str().unwrap_or("cellIs").to_string(); @@ -807,6 +807,36 @@ impl Dialog { } } +/// Initial values of the Data Validation form: the rule stored on the active cell, so OK without +/// edits keeps it; a plain list rule when the cell has none. +fn validation_defaults(app: &SheetApp) -> Json { + let mut v = json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}); + let Some(doc) = app.session.active() else { return v }; + let Some(dv) = doc.wb.active().and_then(|s| s.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(doc.selection.active)))) else { + return v; + }; + // The enums serialize as their variant names; the command takes them in camelCase. + let camel = |x: Json| { + let name = x.as_str().unwrap_or_default(); + let mut chars = name.chars(); + chars.next().map(|c| c.to_lowercase().chain(chars).collect::()).unwrap_or_default() + }; + v["type"] = json!(camel(json!(dv.kind))); + v["operator"] = json!(camel(json!(dv.op))); + v["errorStyle"] = json!(camel(json!(dv.error_style))); + v["formula1"] = json!(dv.f1); + if let Some(f2) = &dv.f2 { + v["formula2"] = json!(f2); + } + v["ignoreBlank"] = json!(dv.allow_blank); + v["dropdown"] = json!(dv.in_cell_dropdown); + v["inputTitle"] = json!(dv.input_title); + v["inputMessage"] = json!(dv.input_message); + v["errorTitle"] = json!(dv.error_title); + v["errorMessage"] = json!(dv.error_message); + v +} + fn abs(r: &gridcraft_engine::core::RangeRef) -> String { let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1); if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } @@ -2460,3 +2490,36 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo } }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn data_validation_opens_on_the_rule_of_the_active_cell() { + let mut session = gridcraft_engine::Session::default(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.run("data.validation", json!({"range": "A1", "type": "whole", "operator": "greater", "formula1": "5"})).unwrap(); + let d = Dialog::open(&mut app, "dataValidation", json!({})).unwrap(); + assert_eq!(d.values["type"], "whole"); + assert_eq!(d.values["operator"], "greater"); + assert_eq!(d.values["formula1"], "5"); + // OK without edits keeps the rule: 6 is accepted, 4 is rejected. + app.run(d.command.unwrap(), build_params(&d)).unwrap(); + let doc = app.session.active().unwrap(); + let at = gridcraft_engine::core::CellRef::new(0, 0); + assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "6").is_none()); + assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "4").is_some()); + } + + #[test] + fn data_validation_without_a_rule_opens_on_a_blank_list() { + let mut session = gridcraft_engine::Session::default(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + let d = Dialog::open(&mut app, "dataValidation", json!({})).unwrap(); + assert_eq!(d.values["type"], "list"); + assert!(d.values.get("formula1").is_none()); + } +} From 310cade6e5a97ba6223e400bc254b00b732e4977 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:47 +0200 Subject: [PATCH 062/162] Formula: print flat operator chains iteratively so deep trees cannot overflow the stack (#527) --- crates/formula/src/printer.rs | 18 ++++++++++++++---- crates/formula/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index a63599b..eb981ef 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -224,10 +224,20 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { } }, }, - Expr::Binary(op, a, b) => { - write_expr(s, a, r1c1); - s.push_str(op.symbol()); - write_expr(s, b, r1c1); + Expr::Binary(..) => { + // Walk the left spine iteratively: a flat chain such as `1+1+1+...` parses into a + // tree one level deeper per operator, and recursing on it could exhaust the stack. + let mut rest: Vec<(&BinOp, &Expr)> = Vec::new(); + let mut first = e; + while let Expr::Binary(op, a, b) = first { + rest.push((op, b)); + first = a; + } + write_expr(s, first, r1c1); + for (op, right) in rest.into_iter().rev() { + s.push_str(op.symbol()); + write_expr(s, right, r1c1); + } } Expr::Call(name, args) => { s.push_str(name); diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c2..6faf202 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -301,3 +301,25 @@ fn trim_references_adjust() { let refs: Vec = parse("SUM(Sheet2!A1:.B5)").unwrap().references().iter().map(|r| printer::reference_a1(r)).collect(); assert_eq!(refs, ["Sheet2!A1:B5"]); } + +#[test] +fn prints_long_flat_chain_without_deep_recursion() { + // Small stack: a recursive printer overflows long before 20000 levels. + let handle = std::thread::Builder::new() + .stack_size(256 * 1024) + .spawn(|| { + let mut e = Expr::Number(1.0); + for _ in 0..20000 { + e = Expr::Binary(BinOp::Add, Box::new(e), Box::new(Expr::Number(1.0))); + } + let text = print(&e); + let r1c1 = print_r1c1(&e, gridcraft_core::CellRef { row: 0, col: 0 }); + // Dropping the deep tree recurses; leak it so only the printer is under test. + std::mem::forget(e); + (text, r1c1) + }) + .expect("spawn"); + let (text, r1c1) = handle.join().expect("printer must not overflow the stack"); + assert_eq!(text, vec!["1"; 20001].join("+")); + assert_eq!(r1c1, text); +} From 3ceb50f4043a1a5a01a371b4d1c1b0b2134eb6ee Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:34 +0200 Subject: [PATCH 063/162] Keep a solid data bar solid across an XLSX save and reopen (#550) --- crates/xlsx/src/read.rs | 3 ++ crates/xlsx/src/sheet_read.rs | 28 ++++++++++++- crates/xlsx/src/sheet_write.rs | 67 +++++++++++++++++++++++++++--- crates/xlsx/src/tests/roundtrip.rs | 28 +++++++++++++ 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index a25444d..9b16fef 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -28,6 +28,8 @@ pub struct Ctx<'a> { pub num_fmts: std::collections::HashMap, /// Value metadata blocks, indexed by the worksheet's one-based `vm` attribute. pub cell_pictures: Vec>>, + /// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`. + pub cf_ext_ids: Vec<(String, usize)>, } impl Ctx<'_> { @@ -79,6 +81,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { next_id: 1, num_fmts: Default::default(), cell_pictures: vec![], + cf_ext_ids: vec![], }; let root_rels = cx.pkg.rels("")?; let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into()); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 77b46d9..a457ffd 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -60,6 +60,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result { @@ -596,6 +597,12 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { let formulas: Vec = r.kids("formula").map(|f| from_file(f.text.trim())).collect(); let f0 = formulas.first().cloned().unwrap_or_default(); let text = r.attr("text").unwrap_or("").to_string(); + // The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there). + let ext_id = r + .child("extLst") + .and_then(|l| l.kids("ext").find_map(|x| x.child("id"))) + .map(|i| i.text.trim().to_string()) + .filter(|i| !i.is_empty()); let rule = match r.attr("type").unwrap_or("") { "cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style }, "expression" => CfRule::Expression { formula: f0, style }, @@ -649,6 +656,9 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { continue; } }; + if let Some(id) = ext_id { + cx.cf_ext_ids.push((id, sheet.cond_formats.len())); + } sheet.cond_formats.push(CondFormat { ranges: ranges.clone(), rule, @@ -953,7 +963,23 @@ fn read_ext(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { } } } - "conditionalFormattings" => cx.warn("some Excel 2010 conditional format extensions were skipped"), + "conditionalFormattings" => { + let mut skipped = false; + for r in c.kids("conditionalFormatting").flat_map(|f| f.kids("cfRule")) { + let idx = r.attr("id").and_then(|id| cx.cf_ext_ids.iter().find(|(i, _)| i == id)).map(|(_, n)| *n); + match (idx.and_then(|n| sheet.cond_formats.get_mut(n)), r.child("dataBar")) { + (Some(CondFormat { rule: CfRule::DataBar { gradient, .. }, .. }), Some(db)) => { + *gradient = db.flag("gradient", true); + // Only the fill style is modelled; borders, lengths, axis or negative colours are lost. + skipped |= db.attrs.iter().any(|(k, _)| k != "gradient") || db.children.iter().any(|k| k.name != "cfvo"); + } + _ => skipped = true, + } + } + if skipped { + cx.warn("some Excel 2010 conditional format extensions were skipped"); + } + } _ => {} } } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 0bfcb7d..8637c43 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -573,9 +573,33 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( } let _ = write!(s, "{parts}", tables.len()); } - // Sparklines (Excel 2010 extension). + // Excel 2010 extensions: solid data bars, then sparklines. + let solid_bars: Vec<(&CondFormat, u32)> = sheet + .cond_formats + .iter() + .enumerate() + .filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. })) + .map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1))) + .collect(); + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); + } + if !solid_bars.is_empty() { + let _ = write!( + s, + "" + ); + for (cf, p) in &solid_bars { + if let CfRule::DataBar { min, max, .. } = &cf.rule { + let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max)); + let _ = write!(s, "{lo}{hi}"); + let _ = write!(s, "{}", sqref(&cf.ranges)); + } + } + s.push_str(""); + } if !sheet.sparklines.is_empty() { - s.push_str(""); + let _ = write!(s, ""); for sp in &sheet.sparklines { let ty = match sp.kind { SparklineKind::Line => "", @@ -593,7 +617,10 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( sp.cell.a1() ); } - s.push_str(""); + s.push_str(""); + } + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); } s.push_str(""); (s, rels) @@ -714,6 +741,33 @@ fn quote(t: &str) -> String { format!("\"{}\"", t.replace('"', "\"\"")) } +const X14_NS: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; +const XM_NS: &str = "http://schemas.microsoft.com/office/excel/2006/main"; + +/// The id linking a solid data bar's `cfRule` to its x14 extension; unique per sheet because priorities are. +fn solid_bar_id(priority: u32) -> String { + format!("{{00000000-0000-0000-0000-{priority:012}}}") +} + +/// The `cfRule` extension that points a solid data bar at its x14 rule. +fn solid_bar_ext(priority: u32) -> String { + let id = solid_bar_id(priority); + format!("{id}") +} + +/// An x14 `cfvo`: unlike the 2006 one it has no `val` attribute, the value is an `xm:f` child. +fn x14_cfvo(v: &CfValueKind) -> String { + let (ty, val) = match v { + CfValueKind::Min => return "".into(), + CfValueKind::Max => return "".into(), + CfValueKind::Number(n) => ("num", num(*n)), + CfValueKind::Percent(n) => ("percent", num(*n)), + CfValueKind::Percentile(n) => ("percentile", num(*n)), + CfValueKind::Formula(f) => ("formula", text_to_file(f)), + }; + format!("{}", esc(&val)) +} + fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; @@ -809,12 +863,13 @@ fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { b.push_str(""); format!("{b}") } - CfRule::DataBar { min, max, color, show_value, .. } => format!( - "{}{}", + CfRule::DataBar { min, max, color, show_value, gradient } => format!( + "{}{}{}", if *show_value { "" } else { " showValue=\"0\"" }, cfvo_xml(min), cfvo_xml(max), - if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) } + if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) }, + if *gradient { String::new() } else { solid_bar_ext(priority) } ), CfRule::IconSet { set, thresholds, reverse, show_value } => { let mut b = format!("9"), "{xml}"); + let (back, rep) = read_xlsx(&bytes).unwrap(); + assert!(rep.warnings.is_empty(), "{:?}", rep.warnings); + let b = &back.sheets[0]; + assert_eq!(b.cond_formats, wb.sheets[0].cond_formats); + assert_eq!(b.sparklines.len(), 1); +} From 8517c7c48dd27d69a211f83c13ebc9645c28b6b2 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:47 +0200 Subject: [PATCH 064/162] xlsx: warn when hyperlinks past the 10,000-cell cap are dropped on read (#513) --- crates/xlsx/src/sheet_read.rs | 5 +++-- crates/xlsx/src/tests/malformed.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 77b46d9..3e5c83b 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -104,7 +104,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_hyperlinks(e, &rels, &mut sheet), + "hyperlinks" => read_hyperlinks(cx, name, e, &rels, &mut sheet), "autoFilter" => sheet.autofilter = read_autofilter(cx, e), "printOptions" => { sheet.print.gridlines = e.flag("gridLines", false); @@ -709,7 +709,7 @@ fn read_validation(v: &El) -> Option { }) } -fn read_hyperlinks(e: &El, rels: &[Rel], sheet: &mut Sheet) { +fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) { let mut cells = 0u64; for h in e.kids("hyperlink") { let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue }; @@ -727,6 +727,7 @@ fn read_hyperlinks(e: &El, rels: &[Rel], sheet: &mut Sheet) { let link = Hyperlink { target, tooltip: h.attr("tooltip").map(str::to_string) }; for c in range.iter() { if cells >= MAX_HYPERLINK_CELLS { + cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped")); return; } cells += 1; diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index b14e53a..4700f8b 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -193,3 +193,19 @@ fn hostile_protected_ranges_stay_locked() { assert!(!matches!(h.verify("password"), Ok(true)), "{}", r.title); } } + +#[test] +fn hyperlinks_past_the_cell_cap_are_reported() { + // A styles part, so the only possible warning is the hyperlink one. + let parts = [("xl/styles.xml", r#""#)]; + let rels = r#""#; + let body = r#""#; + let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap(); + assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 10_000); + assert!(rep.warnings.iter().any(|w| w.contains("hyperlinks past the first 10000 cells were dropped")), "{:?}", rep.warnings); + // A small list reads in full with no warning. + let body = r#""#; + let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap(); + assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 3); + assert!(rep.warnings.is_empty(), "{:?}", rep.warnings); +} From 5334ae5cbc376c88c22b2c0f94b795def7069179 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:45 +0200 Subject: [PATCH 065/162] Keep manually hidden and collapsed rows when removing an AutoFilter (#508) --- crates/engine/src/cmd/data.rs | 47 +++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3ba..f2505eb 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -454,9 +454,13 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result { if has { return edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; + // Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden. + let filtered: Vec = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect(); sh.autofilter = None; - for info in sh.rows.values_mut() { - info.hidden = false; + for r in filtered { + if let Some(info) = sh.rows.get_mut(&r) { + info.hidden = false; + } } cx.structural = true; Ok(json!({"filter": false})) @@ -1300,6 +1304,45 @@ mod tests { assert!(!ok(&mut s, "B1", "150")); } + #[test] + fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("data.group", json!({"rows": "2:4"})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + let hidden = |s: &Session| -> Vec { + let sh = s.doc().unwrap().wb.active().unwrap(); + sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect() + }; + let before = hidden(&s); + assert_eq!(before, [1, 2, 3, 9]); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none()); + assert_eq!(hidden(&s), before); + assert_eq!(row_state(&s, 4..=4), [(false, true)]); + } + + #[test] + fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap(); + s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + s.execute("selection.set", json!({"range": "E1"})).unwrap(); + // The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice. + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::>(), [2, 9]); + } + /// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6). fn nested_rows() -> Session { let mut s = Session::new(); From ba5e871329fb814ea258836e92258d0602169e36 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:33:14 +0200 Subject: [PATCH 066/162] Run the fill-handle double-click fill before the handle press returns (#507) --- crates/ui-egui/src/grid.rs | 42 +++++++------ .../ui-egui/tests/fill_handle_double_click.rs | 59 +++++++++++++++++++ 2 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/tests/fill_handle_double_click.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..23df0d4 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1743,6 +1743,29 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } + // Double-click the fill handle: a double-click is also a click, so run it before the handle press returns. + if resp.double_clicked() + && let Some(p) = pos + && on_handle(p) + { + // Fill down as far as the neighbouring column goes. + let src = sel.current(); + let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; + let mut last = src.end.row; + while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000 + { + last += 1; + } + if last > src.end.row { + let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); + let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); + } + app.grid.drag = Drag::None; + app.grid.drag_select = false; + resp.request_focus(); + return; + } + // Press: decide the drag mode. if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { // Where the press began (a drag is only recognised after the pointer has moved). @@ -2046,25 +2069,6 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.drag_select = false; } - // Double-click the fill handle. - if resp.double_clicked() - && let Some(p) = pos - && on_handle(p) - { - // Fill down as far as the neighbouring column goes. - let src = sel.current(); - let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; - let mut last = src.end.row; - while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000 - { - last += 1; - } - if last > src.end.row { - let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); - let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); - } - } - // Right-click: context menu. if resp.secondary_clicked() && let Some(p) = pos diff --git a/crates/ui-egui/tests/fill_handle_double_click.rs b/crates/ui-egui/tests/fill_handle_double_click.rs new file mode 100644 index 0000000..88b8737 --- /dev/null +++ b/crates/ui-egui/tests/fill_handle_double_click.rs @@ -0,0 +1,59 @@ +//! Double-clicking the fill handle fills down to the neighbouring column's last used row, +//! even with the pointer up for a frame between the two clicks. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5], [6]]})).unwrap(); + session.execute("range.setValues", json!({"range": "B1", "values": [[10], [20], [30]]})).unwrap(); + session.execute("selection.set", json!({"range": "B1:B3"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn handle(h: &egui_kittest::Harness<'_, SheetApp>) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::new(2, 1)).right_bottom() - vec2(1.0, 1.0) +} + +fn button(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, pressed: bool) { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn number(h: &egui_kittest::Harness<'_, SheetApp>, row: u32) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(row, 1)) +} + +#[test] +fn double_click_on_the_fill_handle_fills_down_with_a_frame_between_the_clicks() { + let mut h = harness(); + let pos = handle(&h); + button(&mut h, pos, true); + button(&mut h, pos, false); + h.step(); // One idle frame between the clicks, well inside the double-click interval. + button(&mut h, pos, true); + button(&mut h, pos, false); + h.run_steps(2); + + assert_eq!([number(&h, 3), number(&h, 4), number(&h, 5)], [Value::Number(40.0), Value::Number(50.0), Value::Number(60.0)]); + assert_eq!(h.state().session.active().unwrap().selection.a1(), "B1:B6"); + assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "edit.autoFill").count(), 1); +} From 0ccc94edd34ebae3308ab8dff61d151a35dc8ef7 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:47 +0200 Subject: [PATCH 067/162] Save a table that has only a header row and a totals row (#511) --- crates/xlsx/src/sheet_write.rs | 2 +- crates/xlsx/src/tests/roundtrip.rs | 36 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 0bfcb7d..0020adf 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -81,7 +81,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let tables: Vec<(&Table, Vec)> = sheet .tables .iter() - .filter(|t| t.range.height() > t.header_row as u32 + t.totals_row as u32 || (t.header_row && !t.totals_row && t.range.height() >= 1)) + .filter(|t| t.range.height() > t.header_row as u32 + t.totals_row as u32 || (t.header_row && t.range.height() > t.totals_row as u32)) .map(|t| (t, table_columns(t))) .collect(); for (t, cols) in &tables { diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 9601572..6d50c9d 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -988,6 +988,42 @@ fn table_newline_column_and_backslash_name_survive() { assert!(gridcraft_formula::parse("COUNTA(\\_Prime.1[Name])").is_ok(), "backslash table name parses as a structured reference"); } +/// A table that is only a header row and a totals row (Insert Table on one row, then Total +/// Row) is written like the header-only and one-data-row shapes, not dropped on save. +#[test] +fn header_and_totals_table_without_data_row_survives() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + s.tables.push(Table { + id: 1, + name: "Table1".into(), + range: rr("A1:B2"), + header_row: true, + totals_row: true, + columns: vec![ + TableColumn { name: "Name".into(), totals: TotalsFn::None, totals_label: Some("Total".into()), formula: None }, + TableColumn { name: "Amount".into(), totals: TotalsFn::Sum, totals_label: None, formula: None }, + ], + style: "TableStyleMedium2".into(), + banded_rows: true, + banded_cols: false, + first_col: true, + last_col: true, + filter_button: true, + filter: vec![], + }); + let (back, report) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + let tables = &back.sheet(0).unwrap().tables; + assert_eq!(tables.len(), 1); + let t = &tables[0]; + assert_eq!((t.name.as_str(), t.range.a1(), t.header_row, t.totals_row), ("Table1", "A1:B2".to_string(), true, true)); + assert_eq!((t.style.as_str(), t.banded_rows, t.first_col, t.last_col), ("TableStyleMedium2", true, true, true)); + assert_eq!(t.columns[0].totals_label.as_deref(), Some("Total")); + assert_eq!(t.columns[1].totals, TotalsFn::Sum); + assert_eq!(back.sheet(0).unwrap().value(at("A1")), Value::text("Name")); +} + /// Templates and macro-enabled files differ from `.xlsx` only in the workbook part's content /// type; a template saved with the workbook type is rejected by Excel. #[test] From 9da1814cdf3fa4fa2d3b67aa77d69be3edc9fd72 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:35 +0200 Subject: [PATCH 068/162] Refuse a PivotTable that cannot fit before aggregating all column groups (#515) --- crates/engine/src/cmd/pivot.rs | 34 +++++++++++++++++++++++++++++++++ crates/engine/src/pivot.rs | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 0d73b0a..d44f2e0 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -2039,4 +2039,38 @@ mod tests { assert!(e.contains("field name is not valid"), "{e}"); assert!(s.execute("insert.pivotTable", json!({"source": "NoSuchTable"})).is_err()); } + + #[test] + fn too_many_value_columns_are_refused() { + let mut s = Session::new(); + s.new_workbook(); + let mut records: Vec = vec![json!(["Cat", "Sub", "V"])]; + for r in 0..64 { + for c in 0..64 { + records.push(json!([format!("c{r}"), format!("s{c}"), 1.0])); + } + } + s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); + let req = |n: usize| { + let values: Vec = (0..n).map(|_| json!({"field": "V"})).collect(); + json!({"source": "A1:C4097", "rows": ["Cat"], "columns": ["Sub"], "values": values}) + }; + let e = s.execute("insert.pivotTable", req(256)).unwrap_err().to_string(); + assert!(e.contains("will not fit"), "{e}"); + assert!(s.execute("insert.pivotTable", req(128)).is_ok()); + } + + #[test] + fn collapsed_column_items_count_as_one_column_group() { + let mut s = Session::new(); + s.new_workbook(); + let mut records: Vec = vec![json!(["Grp", "Sub", "V"])]; + records.extend((0..64).map(|c| json!(["g", format!("s{c}"), 1.0]))); + s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); + let values: Vec = (0..256).map(|_| json!({"field": "V"})).collect(); + s.execute("insert.pivotTable", json!({"source": "A1:C65", "columns": ["Grp"], "values": values})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Grp"})).unwrap(); + // 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit. + s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap(); + } } diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index c76bf4b..a53ee40 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -693,6 +693,12 @@ enum ValSrc { Calc(usize), } +/// True when `col_groups` distinct column leaves (plus the grand-total column) times `values` value fields +/// (at least one data column per group) already exceed the sheet width. This is a lower bound on the final column count. +fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -> bool { + (col_groups + usize::from(grand_total)).saturating_mul(values.max(1)) > MAX_COLS as usize +} + /// Computes a PivotTable report from the workbook. pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result { let src = read_source(wb, &pt.source, pivot_sheet)?; @@ -752,6 +758,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result> = HashSet::new(); let mut col_set: HashSet> = HashSet::new(); + let mut col_leaves: HashSet> = HashSet::new(); let filter_fmts: Vec = filters.iter().map(|f| fmt_of(f.0)).collect(); let row_fmts: Vec = rows.iter().map(|(c, _)| fmt_of(*c)).collect(); let col_fmts: Vec = cols.iter().map(|(c, _)| fmt_of(*c)).collect(); @@ -788,8 +795,22 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result 5_000_000 { return Err("The PivotTable report is too large.".into()); } @@ -1428,3 +1449,17 @@ pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) { p.last_range = None; } } + +#[cfg(test)] +mod fit_tests { + use super::data_cols_exceed_sheet; + + #[test] + fn column_budget_counts_value_fields_and_grand_total() { + assert!(!data_cols_exceed_sheet(64, true, 128)); + assert!(data_cols_exceed_sheet(64, true, 256)); + assert!(!data_cols_exceed_sheet(64, false, 256)); + assert!(!data_cols_exceed_sheet(16_384, false, 0)); + assert!(data_cols_exceed_sheet(16_385, false, 0)); + } +} From 9be19c98d80c5e119c73e707cde49a423a68e315 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:35 +0200 Subject: [PATCH 069/162] Keep a literal chart category with a comma or semicolon as one point on save (#519) --- crates/xlsx/src/chart.rs | 38 +++++++++++- .../src/tests/literal_category_roundtrip.rs | 59 +++++++++++++++++++ crates/xlsx/src/tests/mod.rs | 1 + 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 crates/xlsx/src/tests/literal_category_roundtrip.rs diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index 052d620..3865eda 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -336,10 +336,46 @@ fn num_data(tag: &str, f: Option<&str>, wb: &Workbook, si: usize) -> String { s } +/// Splits the inside of an array constant on `,` and `;` outside double quotes; inside quotes `""` is a literal quote. +/// Unquoted items are trimmed; quoted ones keep the text between the quotes and drop the spaces around them. +fn split_array_items(inner: &str) -> Vec { + let mut items = Vec::new(); + let mut cur = String::new(); + let mut quoted = false; + let mut in_quote = false; + let mut chars = inner.chars().peekable(); + let finish = |cur: &mut String, quoted: &mut bool| { + let text = std::mem::take(cur); + let item = if *quoted { text } else { text.trim().to_string() }; + *quoted = false; + item + }; + while let Some(c) = chars.next() { + match c { + '"' if in_quote && chars.peek() == Some(&'"') => { + cur.push('"'); + chars.next(); + } + '"' => { + if !quoted && cur.trim().is_empty() { + cur.clear(); + } + in_quote = !in_quote; + quoted = true; + } + ',' | ';' if !in_quote => items.push(finish(&mut cur, &mut quoted)), + c if quoted && !in_quote && c.is_whitespace() => {} + _ => cur.push(c), + } + } + items.push(finish(&mut cur, &mut quoted)); + items +} + fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { let f = f.trim(); if f.starts_with('{') { - let vals: Vec = f.trim_matches(|c| c == '{' || c == '}').split([',', ';']).map(|v| v.trim().trim_matches('"').to_string()).collect(); + let vals = split_array_items(f.trim_matches(|c| c == '{' || c == '}')); let mut s = format!("", vals.len()); for (i, v) in vals.iter().enumerate() { let _ = write!(s, "{}", esc(v)); diff --git a/crates/xlsx/src/tests/literal_category_roundtrip.rs b/crates/xlsx/src/tests/literal_category_roundtrip.rs new file mode 100644 index 0000000..c818de2 --- /dev/null +++ b/crates/xlsx/src/tests/literal_category_roundtrip.rs @@ -0,0 +1,59 @@ +//! Literal chart categories that contain a comma, a semicolon or a quote stay one category on save. + +use gridcraft_model::{Anchor, Chart, ChartKind, LegendPos, Series, Sheet, Workbook}; +use std::sync::Arc; + +use crate::{read_xlsx, write_xlsx}; + +#[test] +fn literal_category_with_separators_survives_save() { + // (typed array constant, what reading the saved file gives back) + let cats = [ + (r#"{"a,b"}"#, r#"{"a,b"}"#), + (r#"{"a;b"}"#, r#"{"a;b"}"#), + (r#"{"red","blue"}"#, r#"{"red","blue"}"#), + (r#"{"say ""hi"", ok"}"#, r#"{"say ""hi"", ok"}"#), + (r#"{"a,b","c;d","e"}"#, r#"{"a,b","c;d","e"}"#), + (r#"{ "Jan" , "Feb, Mar" ; Apr }"#, r#"{"Jan","Feb, Mar","Apr"}"#), + ]; + let mut wb = Workbook::default(); + let mut s = Sheet::new("Data"); + let series: Vec = cats + .iter() + .map(|(c, _)| Series { + name: Some("North, South".into()), + categories: Some((*c).into()), + values: "{1,2,3}".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + trendlines: Vec::new(), + }) + .collect(); + s.charts.push(Chart { + id: 1, + kind: ChartKind::ColumnClustered, + anchor: Anchor { cell: gridcraft_core::CellRef::new(0, 3), width: 480.0, height: 300.0, ..Anchor::default() }, + title: None, + series, + legend: LegendPos::Bottom, + data_labels: false, + gridlines: true, + style: 2, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + wb.sheets = vec![Arc::new(s)]; + let bytes = write_xlsx(&wb).unwrap(); + let (back, _rep) = read_xlsx(&bytes).unwrap(); + let saved = &back.sheets[0].charts[0].series; + for (i, (_, want)) in cats.iter().enumerate() { + assert_eq!(saved[i].categories.as_deref(), Some(*want)); + assert_eq!(saved[i].name.as_deref(), Some("North, South")); + } +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index d5d2f2f..dd1f8b8 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -4,6 +4,7 @@ mod array_export; mod csv_tests; mod encrypted_tests; mod fixtures; +mod literal_category_roundtrip; mod malformed; mod ods_tests; mod passthrough_tests; From 85c7fc4d6e529ee310a87929409101d08d76d6b2 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:27 +0200 Subject: [PATCH 070/162] Zoom dialog: a Magnification choice sets the percentage to store (#521) --- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/dialogs.rs | 13 ++++- crates/ui-egui/tests/zoom_dialog.rs | 85 +++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/zoom_dialog.rs diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6f6280e..ef9dddd 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -232,7 +232,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - } else if field == "search" { d.search = s("value").unwrap_or("").to_string(); } else { - d.values.insert(field.to_string(), p.get("value").cloned().unwrap_or(Json::Null)); + d.set_value(field, p.get("value").cloned().unwrap_or(Json::Null)); } ok(json!(d.values)) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c6..da6b497 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -47,6 +47,17 @@ impl Dialog { &self.name } + /// Sets a form value. In Zoom, a Magnification choice is also the percentage `view.zoom` stores. + pub(crate) fn set_value(&mut self, key: &str, value: Json) { + if self.name == "zoom" + && key == "preset" + && let Some(p) = value.as_str().and_then(|s| s.parse::().ok()) + { + self.values.insert("percent".into(), json!(p)); + } + self.values.insert(key.into(), value); + } + fn form(name: &str, title: &str, command: &'static str, fields: Vec, defaults: Json) -> Dialog { Dialog { name: name.into(), @@ -1288,7 +1299,7 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); for (val, text) in options { if ui.radio(cur == val, text).clicked() { - d.values.insert(key.into(), json!(val)); + d.set_value(key, json!(val)); } } }); diff --git a/crates/ui-egui/tests/zoom_dialog.rs b/crates/ui-egui/tests/zoom_dialog.rs new file mode 100644 index 0000000..f5be908 --- /dev/null +++ b/crates/ui-egui/tests/zoom_dialog.rs @@ -0,0 +1,85 @@ +//! The Zoom dialog stores the Magnification the user picks. + +use egui::vec2; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::control::{Outcome, handle}; +use serde_json::{Value as Json, json}; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(6); + h +} + +fn zoom(h: &egui_kittest::Harness<'static, SheetApp>) -> u16 { + h.state().session.active().unwrap().wb.active().unwrap().zoom +} + +fn control(h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, params: Json) -> Json { + let ctx = h.ctx.clone(); + match handle(h.state_mut(), &ctx, method, ¶ms) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!(null), + } +} + +#[test] +fn choosing_a_magnification_and_pressing_ok_sets_that_zoom() { + let mut h = harness(); + h.state_mut().run("view.zoom", json!({"percent": 160})).unwrap(); + h.state_mut().open_dialog("zoom", json!({})); + h.run_steps(6); + h.get_by_label("50%").click(); + h.run_steps(6); + h.get_by_label(" OK ").click(); + h.run_steps(6); + assert!(h.state().dialog.is_none()); + assert_eq!(zoom(&h), 50); +} + +#[test] +fn choosing_a_magnification_and_confirming_by_command_sets_that_zoom() { + let mut h = harness(); + h.state_mut().run("view.zoom", json!({"percent": 180})).unwrap(); + h.state_mut().open_dialog("zoom", json!({})); + h.run_steps(6); + h.get_by_label("75%").click(); + h.run_steps(6); + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(zoom(&h), 75); +} + +#[test] +fn setting_the_magnification_over_the_control_channel_sets_that_zoom() { + let mut h = harness(); + h.state_mut().run("view.zoom", json!({"percent": 180})).unwrap(); + control(&mut h, "ui.dialog", json!({"name": "zoom"})); + control(&mut h, "ui.dialog.set", json!({"field": "preset", "value": "25"})); + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(zoom(&h), 25); +} + +#[test] +fn a_custom_percentage_after_a_magnification_wins_and_an_unedited_ok_keeps_the_zoom() { + let mut h = harness(); + control(&mut h, "ui.dialog", json!({"name": "zoom"})); + control(&mut h, "ui.dialog.set", json!({"field": "preset", "value": "25"})); + control(&mut h, "ui.dialog.set", json!({"field": "percent", "value": 225})); + control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(zoom(&h), 225); + control(&mut h, "ui.dialog", json!({"name": "zoom"})); + control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(zoom(&h), 225); +} From 8663be2c320a55016ac95b91476a46cad69a5f2b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:33:17 +0200 Subject: [PATCH 071/162] Keep a long outline group's bracket once its first 4096 lines scroll away (#510) --- crates/ui-egui/src/outline.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 7eaf133..62e2cc3 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -142,6 +142,13 @@ impl Dir<'_> { let (a0, a1) = (a.max(top), (a + size).min(self.hi)); (size > 0.0 && a1 > a0).then_some((a0, a1)) } + /// The first on-screen line in `start..=end`: the frozen ones first, then the scrolling ones from the line + /// at the pane's edge, so a long group is not scanned from its far-off start. + fn first_visible(&self, start: u32, end: u32) -> Option<(u32, (f32, f32))> { + let at = if self.rows { self.geo.row_at(self.sh, self.split) } else { self.geo.col_at(self.sh, self.split) }; + let scrolled = start.max(self.frozen).max(at); + (start..self.frozen.min(end.saturating_add(1))).chain(scrolled..=end).find_map(|l| self.visible(l).map(|v| (l, v))) + } fn point(&self, along: f32, across: f32) -> Pos2 { if self.rows { pos2(across, along) } else { pos2(along, across) } } @@ -198,7 +205,7 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { continue; } // The bracket, over the part of the detail that is on screen. - let first = (grp.start..=grp.end).take(4096).find_map(|l| dir.visible(l).map(|v| (l, v))); + let first = dir.first_visible(grp.start, grp.end); let Some((first_line, (a0, _))) = first else { continue }; let a1 = match button { Some(mid) => mid - side / 2.0, @@ -298,6 +305,28 @@ mod tests { assert_eq!(g.head_left(), g.rect.left()); } + #[test] + fn long_groups_keep_their_bracket_past_4096_lines() { + for rows in [true, false] { + let mut sh = Sheet::new("S"); + let map = if rows { &mut sh.rows } else { &mut sh.cols }; + for i in 0..5000u32 { + map.entry(i).or_default().outline = 1; + } + for line in [0u32, 4095, 4096, 4200, 4970] { + let scroll = if rows { vec2(0.0, sh.row_top(line) as f32) } else { vec2(sh.col_left(line) as f32, 0.0) }; + let g = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll); + let l = layout(&g, &sh); + assert_eq!(l.brackets.len(), 1, "rows={rows} line={line}"); + assert_eq!(l.brackets[0].tick.is_some(), line == 0, "rows={rows} line={line}"); + } + // Past the group's summary line nothing is left to bracket. + let scroll = if rows { vec2(0.0, sh.row_top(5100) as f32) } else { vec2(sh.col_left(5100) as f32, 0.0) }; + let g = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll); + assert!(layout(&g, &sh).brackets.is_empty(), "rows={rows}"); + } + } + #[test] fn bands_push_the_headers_and_cells_in() { let sh = sheet(); From a33a5741d83ee748d37a2090885f5293c45452c4 Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:25:35 +0200 Subject: [PATCH 072/162] i18n(cs): translate 120 labels and tooltips already looked up via tr() Ribbon tooltips and menu items, cell and header context menus, formula bar, sheet tabs and status controls had no Czech entry and fell back to English. Catalog-only: cs.rs, no new tr() call sites, no other language. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 127 ++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index ca2e559..3e25f82 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -391,6 +391,133 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Další\nfunkce", "Sheet Options|View" => "Zobrazit", "Print" => "Tisknout", + + // Additional runtime labels and tooltips, grouped by source file. + // formula_bar.rs (alphabetical keys). + "Cancel" => "Zrušit", + "Expand formula bar" => "Rozbalit řádek vzorců", + "Insert Function" => "Vložit funkci", + // grid.rs (alphabetical keys). + "Copy" => "Kopírovat", + "Cut" => "Vyjmout", + "Define Name…" => "Definovat název…", + "Delete…" => "Odstranit…", + "Filter by Selected Cell's Value" => "Filtrovat podle hodnoty vybrané buňky", + "Hide" => "Skrýt", + "Insert…" => "Vložit…", + "Link…" => "Odkaz…", + "New Comment" => "Nový komentář", + "Pick From Drop-down List…" => "Vybrat z rozevíracího seznamu…", + "Unhide" => "Zobrazit", + // ribbon.rs (alphabetical keys). + "$ English (United States)" => "$ Angličtina (Spojené státy)", + "Add Chart Element" => "Přidat prvek grafu", + "Align Left" => "Zarovnat doleva", + "Align Right" => "Zarovnat doprava", + "Bottom Align" => "Zarovnat dolů", + "Calculate Now (F9)" => "Přepočítat (F9)", + "Calculate Sheet (⇧F9)" => "Přepočítat list (⇧F9)", + "Calculation Options" => "Možnosti výpočtu", + "Cell Styles" => "Styly buněk", + "Change Chart Type" => "Změnit typ grafu", + "Check Accessibility" => "Zkontrolovat přístupnost", + "Clear Filter" => "Vymazat filtr", + "Column or Bar" => "Sloupcový nebo pruhový graf", + "Comments pane" => "Podokno komentářů", + "Convert the last ink stroke to a shape" => "Převést poslední tah rukopisu na obrazec", + "Convert to Range" => "Převést na oblast", + "Custom Sort" => "Vlastní řazení", + "Delete Comment" => "Odstranit komentář", + "Delete Sheet" => "Odstranit list", + "Delete chart" => "Odstranit graf", + "Down" => "Dolů", + "Edit chart title" => "Upravit název grafu", + "Erase ink strokes" => "Vymazat tahy rukopisu", + "Every command run in this session (replayable)" => "Všechny příkazy spuštěné v této relaci (lze znovu přehrát)", + "Filter (⌘⇧F)" => "Filtr (⌘⇧F)", + "Find & Select" => "Najít a vybrat", + "Flash Fill (⌘E)" => "Dynamické doplňování (⌘E)", + "Forecast Sheet" => "List prognózy", + "Format Chart pane" => "Podokno formátu grafu", + "Format as Table" => "Formátovat jako tabulku", + "Hide Sheet" => "Skrýt list", + "Hierarchy & more" => "Hierarchické a další grafy", + "Highlighter" => "Zvýrazňovač", + "How agents drive GridCraft (MCP / control channel)" => "Jak agenti ovládají GridCraft (MCP / řídicí kanál)", + "Import a text or CSV file" => "Importovat textový soubor nebo CSV", + "Insert\nSlicer" => "Vložit\nprůřez", + "Insert Function (⇧F3)" => "Vložit funkci (⇧F3)", + "Insert Link (⌘K)" => "Vložit odkaz (⌘K)", + "Insert Sheet" => "Vložit list", + "Insert Slicer" => "Vložit průřez", + "Insert a picture" => "Vložit obrázek", + "Insert a slicer to filter a table" => "Vložit průřez pro filtrování tabulky", + "Insert an icon" => "Vložit ikonu", + "Line or Area" => "Spojnicový nebo plošný graf", + "Macros" => "Makra", + "Macros and scripts in this workbook (Alt+F8)" => "Makra a skripty v tomto sešitu (Alt+F8)", + "Middle Align" => "Zarovnat doprostřed", + "More commands" => "Další příkazy", + "Move or Copy Sheet…" => "Přesunout nebo zkopírovat list…", + "Name Manager" => "Správce názvů", + "Page Breaks" => "Konce stránek", + "Paper Size" => "Velikost papíru", + "Paste (⌘V)" => "Vložit (⌘V)", + "Pen\nBlack" => "Pero\nčerné", + "Pen\nBlue" => "Pero\nmodré", + "Pen\nRed" => "Pero\nčervené", + "Pie or Doughnut" => "Výsečový nebo prstencový graf", + "Print Area" => "Oblast tisku", + "Print Titles" => "Tisk názvů", + "Protect\nSheet" => "Zamknout\nlist", + "Protect Sheet" => "Zamknout list", + "Protect Workbook structure" => "Zamknout strukturu sešitu", + "Recommended Charts" => "Doporučené grafy", + "Redo (⌘Y)" => "Znovu (⌘Y)", + "Remove Duplicates" => "Odebrat duplicity", + "Rename Sheet" => "Přejmenovat list", + "Save (⌘S)" => "Uložit (⌘S)", + "Scatter or Bubble" => "Bodový nebo bublinový graf", + "Search and run any command" => "Vyhledat a spustit libovolný příkaz", + "Search commands (⌘⇧Space)" => "Vyhledat příkazy (⌘⇧Space)", + "Select Data" => "Vybrat data", + "Select and move ink and shapes" => "Vybrat a přesunout rukopis a obrazce", + "Series…" => "Řady…", + "Share: export or save a copy" => "Sdílet: exportovat nebo uložit kopii", + "Show Formulas (⌃`)" => "Zobrazit vzorce (⌃`)", + "Slicer" => "Průřez", + "Sort & Filter" => "Seřadit a filtrovat", + "Spelling (F7)" => "Pravopis (F7)", + "Statistic" => "Statistické grafy", + "Switch Row/Column" => "Prohodit řádky/sloupce", + "Table (⌘T)" => "Tabulka (⌘T)", + "Table Styles" => "Styly tabulky", + "Text Box" => "Textové pole", + "Text to Columns" => "Text do sloupců", + "Top Align" => "Zarovnat nahoru", + "Undo (⌘Z)" => "Zpět (⌘Z)", + "Undo list" => "Seznam akcí k vrácení", + "Unhide Sheet…" => "Zobrazit list…", + "Up" => "Nahoru", + "Version, contributors and the AI models that helped" => "Verze, přispěvatelé a modely AI, které pomáhaly", + "Watch\nWindow" => "Okno\nsledování", + "What-If Analysis" => "Citlivostní analýza", + "Workbook\nStatistics" => "Statistika\nsešitu", + "Workbook Statistics" => "Statistika sešitu", + "Zoom to 100%" => "Přiblížit na 100 %", + "Zoom to Selection" => "Přiblížit na výběr", + "Zoom…" => "Lupa…", + "£ English (United Kingdom)" => "£ Angličtina (Spojené království)", + "¥ Japanese" => "¥ Japonština", + "€ Euro" => "€ Euro", + // tabs.rs (alphabetical keys). + "New sheet (⇧F11)" => "Nový list (⇧F11)", + "Next sheet" => "Další list", + "Previous sheet" => "Předchozí list", + "Zoom In" => "Přiblížit", + "Zoom Out" => "Oddálit", + // widgets.rs (alphabetical keys). + "Dismiss" => "Zavřít", _ => return None, }) } From 0514e537ac5608fafad3579345b002f01fb39109 Mon Sep 17 00:00:00 2001 From: derechopiano <257026466+derechopiano@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:56:52 +0100 Subject: [PATCH 073/162] #441: Open AutoFilter header menus with Alt+Down --- crates/ui-egui/src/keymap.rs | 3 + crates/ui-egui/tests/keyboard_shortcuts.rs | 65 ++++++++++++++++++++++ docs/ui-parity.md | 4 +- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 21bf41f..94f6d30 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -218,6 +218,9 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic Shortcut::DropDown => { if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) { app.grid.list_picker = Some(active); + } else if sh.has_filter_button(active) { + let at = geo.mirror_pos(crate::grid::filter_button_rect(geo, sh, active).center()); + app.grid.filter_menu = Some((active.col, at)); } } Shortcut::Zoom(step) => { diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index 82d3f2f..ae0ce5c 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -149,3 +149,68 @@ fn space_shortcuts_per_platform() { // Cmd+Space is Spotlight on macOS. assert_eq!(space(mac_cmd, true), None); } + +#[test] +fn alt_down_opens_autofilter_headers_without_changing_the_workbook() { + for rtl in [false, true] { + for cell in ["A1", "B1"] { + let mut session = book(cell); + session.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + session.execute("view.rightToLeft", json!({"on": rtl})).unwrap(); + let mut h = harness(session); + let before = h.state().session.active().unwrap().wb.clone(); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + let (column, at) = h.state().grid.filter_menu.expect("Alt+Down must open the filter header menu"); + let d = h.state().session.active().unwrap(); + let active = CellRef::parse(cell).unwrap(); + assert_eq!(column, active.col); + assert_eq!(d.selection.active, active); + assert_eq!(d.wb, before); + let sh = d.wb.active().unwrap(); + let geo = gridcraft_ui_egui::grid::Geo::new(sh, h.state().grid.rect.unwrap(), h.state().view().scroll); + assert_eq!(at, geo.mirror_pos(gridcraft_ui_egui::grid::filter_button_rect(&geo, sh, active).center())); + // Opening focuses the existing search box; typing must not edit the header. + h.input_mut().events.push(Event::Text("1".into())); + h.run_steps(2); + assert_eq!(h.ctx.data(|data| data.get_temp::(egui::Id::new(("filter_search", column)))).as_deref(), Some("1")); + assert!(h.state().editor.is_none()); + assert_eq!(h.state().session.active().unwrap().wb, before); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().grid.filter_menu.is_none()); + } + } +} + +#[test] +fn alt_down_keeps_validation_priority_and_plain_down_navigation() { + let mut session = book("A1"); + session.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + session.execute("data.validation", json!({"range": "A1", "type": "list", "formula1": "\"First,Second\""})).unwrap(); + let mut h = harness(session); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert_eq!(h.state().grid.list_picker, Some(CellRef::parse("A1").unwrap())); + assert!(h.state().grid.filter_menu.is_none()); + key(&mut h, Key::Escape, Modifiers::NONE); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2"); + assert!(h.state().grid.filter_menu.is_none()); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert!(h.state().grid.filter_menu.is_none(), "a data cell has no filter button"); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2"); +} + +#[test] +fn alt_down_only_opens_enabled_table_filter_buttons() { + for enabled in [false, true] { + let mut session = book("B1"); + session.execute("insert.table", json!({"range": "A1:B3"})).unwrap(); + if !enabled { + session.execute("table.headerRow", json!({"on": false})).unwrap(); + } + session.execute("selection.set", json!({"cell": "B1"})).unwrap(); + let mut h = harness(session); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert_eq!(h.state().grid.filter_menu.map(|(column, _)| column), enabled.then_some(1)); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..940305f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Alt+Down opens AutoFilter header menus) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -117,6 +117,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Ctrl+Alt+V | Cmd+Control+V, Cmd+Option+V | Paste Special | **added** | | Ctrl+Alt+= / Ctrl+Alt+- | — | zoom in / out (10% steps) | **added** | | Alt+=, Alt+Down, Alt+Page Down / Up | Option+Down, Option+Page Down / Up | AutoSum, drop-down list, one screen right / left | **added** | +| Alt+Down on a filter header | Option+Down | Open the existing AutoFilter popup; validation dropdowns keep priority | **added** (AutoFilter portion of #441; Ribbon dropdowns and column value picker are separate work) | | Alt+F1, Alt+Shift+F1, Shift+F11 | Shift+F11 | embedded chart, new sheet | **added** | | Shift+F2, Ctrl+Shift+F2 | Shift+F2, Cmd+Shift+F2 | note, threaded comment | **added** | | Shift+F3, Ctrl+F3, Ctrl+Shift+F3 | Shift+F3, Cmd+F3 (Define Name), Cmd+Shift+F3 | Insert Function, Name Manager, Create from Selection | **added** | @@ -160,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | From bf9a202222963cf5febea36a3b64d9e772362e1a Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 12 Oct 2026 20:29:00 +1100 Subject: [PATCH 074/162] Compute the Top 10 filter threshold once per column, not once per row (#661) apply_filter cloned and sorted the column's numbers for every row it tested, O(n^2 log n). The threshold is now computed once per criterion with the other column stats. Adds a test for top/bottom items and percent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ut3hr3a13szpQdQ7gbYbfc --- crates/engine/src/cmd/data.rs | 27 ++++++++++++++++----------- crates/engine/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3ba..0effb86 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -570,14 +570,23 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { let Some(af) = sh.autofilter.clone() else { return }; let r = af.range; let mut hide = Vec::new(); - // Precompute per-column numbers for top10/average. - let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); + // Precompute per-column top10 threshold and average once, not per row. + let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); for (off, c) in &af.criteria { if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) { let col = r.start.col + off; - let nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); + let mut nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::() / nums.len() as f64 }; - col_stats.insert(*off, (nums, avg)); + let thr = match c { + FilterCriterion::Top10 { bottom, percent, count } => { + nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); + let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; + let k = k.clamp(1, nums.len().max(1)); + nums.get(k - 1).copied() + } + _ => None, + }; + col_stats.insert(*off, (thr, avg)); } } for row in r.start.row + 1..=r.end.row { @@ -604,13 +613,9 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { None => x, } } - FilterCriterion::Top10 { bottom, percent, count } => match (v.as_f64(), col_stats.get(off)) { - (Some(n), Some((nums, _))) => { - let mut sorted = nums.clone(); - sorted.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); - let k = if *percent { ((sorted.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; - let k = k.clamp(1, sorted.len().max(1)); - let thr = sorted.get(k - 1).copied().unwrap_or(n); + FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(off)) { + (Some(n), Some((thr, _))) => { + let thr = thr.unwrap_or(n); if *bottom { n <= thr } else { n >= thr } } _ => false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..2d12822 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -805,6 +805,24 @@ fn sort_and_filter() { assert_eq!(r["hiddenRows"], 1); } +/// Top 10 keeps the top or bottom N items or percent, ties at the threshold included. +#[test] +fn filter_top10_items_and_percent() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Score"], [5], [1], [9], [3], [9], [7], ["x"]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + s.execute("data.filter", json!({})).unwrap(); + // Top 2 items: 9, 9 (the "x" text row is hidden too). + let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2}})).unwrap(); + assert_eq!(r["hiddenRows"], 5); + // Bottom 2 items: 1, 3. + let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2, "bottom": true}})).unwrap(); + assert_eq!(r["hiddenRows"], 5); + // Top 50 percent of 6 numbers = 3 items; the threshold 7 keeps 9, 9, 7. + let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 50, "percent": true}})).unwrap(); + assert_eq!(r["hiddenRows"], 4); +} + /// Turning the filter on with only the header row selected filters the data below it (#201). #[test] fn filter_on_a_selected_header_row_covers_the_data_below() { From b3acae6b7782ea236d0fb87a0121fd1ba2e14c2b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 05:44:31 -0400 Subject: [PATCH 075/162] Release prep: 0.6.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2dec069..c9f5393 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1480,7 +1480,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.5.0" +version = "0.6.0" dependencies = [ "arboard", "eframe", @@ -1499,7 +1499,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1511,7 +1511,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1522,7 +1522,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1534,7 +1534,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.5.0" +version = "0.6.0" dependencies = [ "js-sys", "serde", @@ -1544,7 +1544,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gridcraft-core", "serde", @@ -1574,7 +1574,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gridcraft-core", "regex", @@ -1582,7 +1582,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gridcraft-engine", "log", @@ -1591,7 +1591,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1603,18 +1603,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.5.0" +version = "0.6.0" [[package]] name = "gridcraft-ui-egui" -version = "0.5.0" +version = "0.6.0" dependencies = [ "egui", "egui_kittest", @@ -1635,7 +1635,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.5.0" +version = "0.6.0" dependencies = [ "eframe", "egui", @@ -1652,7 +1652,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.5.0" +version = "0.6.0" dependencies = [ "aes", "gridcraft-core", @@ -4506,7 +4506,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 11e4edf..ecec031 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 2589f3f7d46f7ff3f40f87a320c5adfc139b0d39 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 13:17:09 +0200 Subject: [PATCH 076/162] Data validation: hide the input message while the cell's list is open (#505) The input message is a tooltip-order area just below the cell, and the in-cell dropdown opens at the same spot, so the message covered the first items of the list. It now stays hidden while the list picker is open on the active cell and comes back when the list closes. --- crates/ui-egui/src/grid.rs | 3 +- .../ui-egui/tests/validation_input_message.rs | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/validation_input_message.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..80a39c8 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2715,8 +2715,9 @@ pub(crate) fn paint_overlays( p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside); crate::icons::paint(p, r.shrink(3.0), crate::icons::Icon::Chevron, Color32::from_gray(60)); } - // Input message of the active cell's validation. + // Input message of the active cell's validation, hidden while its list is open: it would cover the items. if app.editor.is_none() + && app.grid.list_picker != Some(sel.active) && let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active))) && dv.show_input && !(dv.input_title.is_empty() && dv.input_message.is_empty()) diff --git a/crates/ui-egui/tests/validation_input_message.rs b/crates/ui-egui/tests/validation_input_message.rs new file mode 100644 index 0000000..e05dbce --- /dev/null +++ b/crates/ui-egui/tests/validation_input_message.rs @@ -0,0 +1,56 @@ +//! A list validation's input message must not cover the dropdown it describes (#505). + +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const MESSAGE: &str = "Pick a colour from the list"; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("data.validation", json!({"range": "B2", "type": "list", "formula1": "\"Red,Green,Blue\"", "inputMessage": MESSAGE})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn message_shown(h: &Harness) -> bool { + h.query_by_label(MESSAGE).is_some() +} + +#[test] +fn input_message_hides_while_the_list_is_open() { + let mut h = harness(); + assert!(message_shown(&h), "the selected cell's input message is shown"); + + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let geo = grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + let pos = grid::validation_arrow(sh, &geo, CellRef::parse("B2").unwrap()).unwrap().center(); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + + assert!(h.state().grid.list_picker.is_some(), "the dropdown arrow opens the list"); + assert!(h.query_by_label("Green").is_some(), "the list shows its items"); + assert!(!message_shown(&h), "the input message must not cover the open list"); + + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().grid.list_picker.is_none(), "Escape closes the list"); + assert!(message_shown(&h), "the input message comes back once the list is closed"); +} From ef15e22815c84fc3043798c860e19dbe6c16e207 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 13:27:27 +0200 Subject: [PATCH 077/162] Charts: a newly inserted chart is selected, so Delete removes it (#668, #256) Only the Recommended Charts dialog selected the chart it inserted. The Insert gallery buttons and Alt+F1 dropped the id insert.chart returns, so the selection stayed on the source cells and Delete cleared the data instead of the chart. SheetApp::run now selects the new chart whenever insert.chart succeeds, which covers every caller. --- crates/ui-egui/src/lib.rs | 7 +++++++ crates/ui-egui/tests/interaction.rs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec29139..417a426 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -344,6 +344,13 @@ impl SheetApp { } let r = self.session.run(id, params); self.after_engine(); + // The new chart is selected, as after inserting any object, so Delete removes it, not its data. + if id == "insert.chart" + && let Ok(r) = &r + && let Some(chart) = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()) + { + self.selected_chart = Some(chart); + } if id == "file.exportPdf" && let Ok(r) = &r { diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 7093933..4c59bf0 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -224,6 +224,25 @@ fn delete_key_removes_the_selected_chart() { assert_eq!(value(&h, "A1"), Value::Number(1.0)); } +#[test] +fn inserted_chart_is_selected_so_delete_removes_it() { + // #668, #256: after Insert › Chart the selection stayed on the source cells, so Delete + // cleared the data and left the chart behind. + let mut session = blank(); + session.execute("range.setValues", json!({"range": "A1:B2", "values": [[1, 2], [3, 4]]})).unwrap(); + session.execute("selection.set", json!({"range": "A1:B2"})).unwrap(); + let mut h = harness(session); + + let id = h.state_mut().run("insert.chart", json!({"type": "column"})).unwrap()["chart"].as_u64().unwrap() as u32; + h.run_steps(2); + assert_eq!(h.state().selected_chart, Some(id), "the new chart is selected"); + + key(&mut h, Key::Delete, Modifiers::NONE); + + assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty()); + assert_eq!(value(&h, "A1"), Value::Number(1.0)); +} + #[test] fn renders_every_ribbon_tab_without_panicking() { let mut s = Session::new(); From f7f04df4d79f7aba84a35a5033e489ef4a53c727 Mon Sep 17 00:00:00 2001 From: derechopiano <257026466+derechopiano@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:55:18 +0100 Subject: [PATCH 078/162] #681: Reveal the selected worksheet tab after navigation --- crates/ui-egui/src/tabs.rs | 12 ++ crates/ui-egui/tests/sheet_tabs.rs | 176 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 3 +- docs/ui-parity.md | 4 +- 4 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/sheet_tabs.rs diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index e55f575..5bd5456 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -40,6 +40,15 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // Keep worksheet tabs in their own horizontally scrollable // viewport, leaving room for the New Sheet button and grid scrollbar. let tabs_width = (ui.available_width() - 180.0).max(100.0); + // Reveal the selected sheet after navigation or layout changes, but + // allow browsing other tabs without snapping back on every frame. + let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); + let reveal_id = ui.id().with("worksheet_tabs_reveal"); + let reveal_active = ui.ctx().data_mut(|data| { + let changed = data.get_temp::(reveal_id) != Some(reveal_key); + data.insert_temp(reveal_id, reveal_key); + changed + }); ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { // Translate vertical wheel movement into horizontal // worksheet-tab scrolling. @@ -68,6 +77,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x; let w = (tw + 32.0).max(72.0); let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); + if active && reveal_active { + resp.scroll_to_me(None); + } let r = r.shrink2(vec2(0.0, 1.0)); if active { ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active); diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs new file mode 100644 index 0000000..966ea60 --- /dev/null +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -0,0 +1,176 @@ +//! Overflowing worksheet tabs, exercised through their actual painted geometry and input. +use egui::{Color32, Event, Modifiers, PointerButton, Rect, Shape, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(width: f32, active: usize) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap(); + for i in 1..30 { + s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap(); + } + s.execute("sheet.activate", json!({"sheet":active})).unwrap(); + let mut fonts_ready = false; + let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state( + move |ui, app: &mut SheetApp| { + if !fonts_ready { + ui.ctx().set_fonts(gridcraft_ui_egui::theme::font_definitions()); + fonts_ready = true; + return; + } + app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0))); + gridcraft_ui_egui::tabs::sheet_tabs(app, ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(20); + h +} + +fn tab(h: &Harness, name: &str) -> Option<(Rect, Rect)> { + h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == name => Some((t.visual_bounding_rect(), s.clip_rect)), + _ => None, + }) +} + +fn assert_visible(h: &Harness, name: &str) { + let (text, clip) = tab(h, name).unwrap_or_else(|| panic!("{name} was not painted in the tab viewport")); + assert!(clip.contains_rect(text), "{name} is clipped: text={text:?}, viewport={clip:?}"); +} + +fn click(h: &mut Harness, at: egui::Pos2) { + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); +} + +#[test] +fn opening_and_activating_late_sheets_reveals_their_tabs() { + for width in [500.0, 1000.0] { + let mut h = harness(width, 29); + assert_visible(&h, "Tab29"); + h.state_mut().run("sheet.activate", json!({"sheet":0})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab00"); + h.state_mut().run("sheet.activate", json!({"sheet":20})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab20"); + } +} + +#[test] +fn navigation_arrows_reveal_the_selected_tab() { + let mut h = harness(700.0, 0); + let (_, clip) = tab(&h, "Tab00").unwrap(); + let next = pos2(clip.left() - 20.0, clip.center().y); + for _ in 0..29 { + click(&mut h, next); + } + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); + assert_visible(&h, "Tab29"); +} + +#[test] +fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() { + for delta in [vec2(0.0, -350.0), vec2(-350.0, 0.0)] { + let mut h = harness(700.0, 0); + let before = h.state().session.doc().unwrap().wb.clone(); + let (_, clip) = tab(&h, "Tab00").unwrap(); + h.input_mut().events.extend([ + Event::PointerMoved(clip.center()), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::Start, + delta: vec2(0.0, 0.0), + modifiers: Modifiers::NONE, + }, + ]); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::Move, + delta, + modifiers: Modifiers::NONE, + }); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::End, + delta: vec2(0.0, 0.0), + modifiers: Modifiers::NONE, + }); + h.run_steps(40); + assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "gesture should browse away from the selected tab: {delta:?}"); + assert_eq!(h.state().session.doc().unwrap().wb, before, "browsing tabs must not edit the workbook"); + h.run_steps(40); + assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "idle frames must not snap back"); + h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab29"); + } +} +#[test] +fn hidden_sheets_long_names_and_window_resize_keep_active_tab_visible() { + let mut h = harness(1000.0, 0); + for sheet in 1..10 { + h.state_mut().run("sheet.hide", json!({"sheet":sheet})).unwrap(); + } + let name = "Quarterly results and forecasts"; + h.state_mut().run("sheet.rename", json!({"sheet":29,"name":name})).unwrap(); + h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap(); + h.run_steps(20); + assert_visible(&h, name); + h.set_size(vec2(500.0, 120.0)); + h.run_steps(20); + assert_visible(&h, name); + h.set_size(vec2(1000.0, 120.0)); + h.run_steps(20); + assert_visible(&h, name); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); +} + +#[test] +fn new_sheet_button_stays_reachable_and_reveals_the_inserted_tab() { + let mut h = harness(700.0, 29); + let (_, clip) = tab(&h, "Tab29").unwrap(); + click(&mut h, pos2(clip.right() + 18.0, clip.center().y)); + let d = h.state().session.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 31); + let name = d.wb.active().unwrap().name.clone(); + assert_visible(&h, &name); +} + +#[test] +fn overflow_leaves_the_worksheet_scrollbar_reachable() { + for width in [500.0, 700.0, 1200.0] { + let mut h = harness(width, 0); + let (bar, clip) = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Rect(r) if r.fill == Color32::from_gray(190) && r.rect.height() == 8.0 => Some((r.rect, s.clip_rect)), + _ => None, + }) + .expect("worksheet scrollbar thumb must be painted"); + assert!(clip.contains_rect(bar), "worksheet scrollbar is offscreen: {bar:?}, {clip:?}"); + let at = bar.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerMoved(at + vec2(12.0, 0.0))); + h.step(); + assert!(h.state().view().scroll.x > 0.0, "worksheet scrollbar must respond to dragging"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8b82092..484de9e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (stability: command-sequence fuzzing against undo/redo, XLSX round-trip and full-recalc invariants) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (sheet-tab navigation reveals the selected tab without interrupting manual scrolling) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | | 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h | | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..37fd5b2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (sheet-tab navigation reveals the selected tab without interrupting manual scrolling) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | -| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | rename focus bug on macOS (#145) | +| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | From ae54c84bc90cda1032df8b7af03d6c82b09f9d0a Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 13 Oct 2026 00:10:54 +1100 Subject: [PATCH 079/162] Key the cached Top 10 cutoff by criterion, not column (#661) Two Top 10 criteria on one column (possible in native JSON files) shared one cache entry, so the last cutoff overwrote the first. Reported by @derechopiano. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ut3hr3a13szpQdQ7gbYbfc --- crates/engine/src/cmd/data.rs | 14 +++++++------- crates/engine/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 0effb86..f9b831c 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -570,9 +570,9 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { let Some(af) = sh.autofilter.clone() else { return }; let r = af.range; let mut hide = Vec::new(); - // Precompute per-column top10 threshold and average once, not per row. - let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); - for (off, c) in &af.criteria { + // Precompute per-criterion top10 threshold and average once, not per row. + let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); + for (i, (off, c)) in af.criteria.iter().enumerate() { if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) { let col = r.start.col + off; let mut nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); @@ -586,12 +586,12 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { } _ => None, }; - col_stats.insert(*off, (thr, avg)); + col_stats.insert(i, (thr, avg)); } } for row in r.start.row + 1..=r.end.row { let mut visible = true; - for (off, crit) in &af.criteria { + for (i, (off, crit)) in af.criteria.iter().enumerate() { let c = CellRef::new(row, r.start.col + off); let v = sh.value(c); let text = crate::display::cell_text(wb, sh, c); @@ -613,14 +613,14 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { None => x, } } - FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(off)) { + FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) { (Some(n), Some((thr, _))) => { let thr = thr.unwrap_or(n); if *bottom { n <= thr } else { n >= thr } } _ => false, }, - FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(off)) { + FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) { (Some(n), Some((_, avg))) => { if *above { n > *avg diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2d12822..8c8b08f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -823,6 +823,28 @@ fn filter_top10_items_and_percent() { assert_eq!(r["hiddenRows"], 4); } +/// Two Top 10 criteria on one column (possible in a native JSON file) each keep their own +/// cutoff: Top 3 and Bottom 4 of 1..5 keep 3 and 4. +#[test] +fn filter_top10_two_criteria_on_one_column() { + use gridcraft_model::FilterCriterion; + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["N"], [1], [2], [3], [4], [5]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + s.execute("data.filter", json!({})).unwrap(); + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + if let Some(af) = sh.autofilter.as_mut() { + af.criteria = vec![ + (0, FilterCriterion::Top10 { bottom: false, percent: false, count: 3 }), + (0, FilterCriterion::Top10 { bottom: true, percent: false, count: 4 }), + ]; + } + s.execute("data.reapply", json!({})).unwrap(); + let hidden: Vec = s.active().unwrap().wb.sheet(0).unwrap().rows.iter().filter(|(_, r)| r.hidden).map(|(k, _)| *k).collect(); + // Rows are 0-based: values 1, 2 and 5 sit on rows 1, 2 and 5. + assert_eq!(hidden, vec![1, 2, 5]); +} + /// Turning the filter on with only the header row selected filters the data below it (#201). #[test] fn filter_on_a_selected_header_row_covers_the_data_below() { From dfb780a544ea1131b63f3cbac49dd6eaa0f9e8f2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 15:56:22 +0200 Subject: [PATCH 080/162] UI: clicking a column or row header in point mode inserts N:N / n:n (#691) --- crates/ui-egui/src/grid.rs | 16 ++++ crates/ui-egui/tests/formula_point_headers.rs | 75 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 crates/ui-egui/tests/formula_point_headers.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..5f4583c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1770,6 +1770,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let c = geo.col_at(sh, p.x); + if point_header(app, wb, sh, RangeRef::cols(c, c)) { + return; + } if resp.drag_started() && !mods.any() && sel.ranges.len() == 1 @@ -1790,6 +1793,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let r = geo.row_at(sh, p.y); + if point_header(app, wb, sh, RangeRef::rows(r, r)) { + return; + } let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0))); app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r }); @@ -2097,6 +2103,16 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & keyboard(app, &ctx, resp, geo, sh); } +/// Point mode on a header: inserts the whole-column or whole-row reference instead of selecting. +fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) -> bool { + let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; + let r = range.a1(); + let r = if ed.sheet != wb.active_sheet { format!("{}!{r}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { r }; + ed.insert_ref(&r); + ed.request_focus = true; + true +} + fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { let cell = CellRef::new( geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), diff --git a/crates/ui-egui/tests/formula_point_headers.rs b/crates/ui-egui/tests/formula_point_headers.rs new file mode 100644 index 0000000..4313fb9 --- /dev/null +++ b/crates/ui-egui/tests/formula_point_headers.rs @@ -0,0 +1,75 @@ +//! Point mode on the headers: while a formula waits for a reference, a header click inserts a +//! whole-column or whole-row reference, driven through real egui pointer events. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("selection.set", json!({"range": "F8"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + h +} + +fn header_center(h: &egui_kittest::Harness<'_, SheetApp>, rows: bool, index: u32) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + if rows { + pos2(geo.rect.left() + geo.header_w * 0.5, geo.y(sheet, index) + sheet.row_height(index) * geo.z * 0.5) + } else { + pos2(geo.x(sheet, index) + sheet.col_width(index) * geo.z * 0.5, geo.rect.top() + geo.header_h * 0.5) + } +} + +fn click(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2) { + for pressed in [true, false] { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); + } + h.run_steps(2); +} + +#[test] +fn column_header_click_inserts_a_column_reference() { + let mut h = harness(); + let at = header_center(&h, false, 2); + click(&mut h, at); + let app = h.state(); + assert_eq!(app.editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(C:C")); + assert_eq!(app.session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn row_header_click_inserts_a_row_reference() { + let mut h = harness(); + let at = header_center(&h, true, 3); + click(&mut h, at); + let app = h.state(); + assert_eq!(app.editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(4:4")); + assert_eq!(app.session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn a_second_header_click_replaces_the_pointed_reference() { + let mut h = harness(); + let first = header_center(&h, false, 2); + click(&mut h, first); + let second = header_center(&h, false, 4); + click(&mut h, second); + assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(E:E")); +} From 9fc4108899123c0fa7f3e3bca4bb7bcd96ccc13d Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 16:50:03 +0200 Subject: [PATCH 081/162] Printing: expose native commands through control discovery --- crates/ui-egui/src/control.rs | 35 +++++++++++++++++++++++++++++++++++ docs/control-protocol.md | 3 +++ 2 files changed, 38 insertions(+) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6f6280e..b753b00 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -184,6 +184,16 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("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}"), + ( + "ui.printSettings", + "Print Settings", + "{printer?: discovered name, copies?: 1..999, duplex?: printerDefault|off|longEdge|shortEdge|null, grayscale?: bool, refresh?: bool} → {printer,copies,duplex,grayscale,printers,ready,busy,discovering,status,detail}; {} inspects without submitting", + ), + ( + "ui.printSubmit", + "Submit Print Job", + "existing file.print sheet/pagination params → {printing,bytes,pending}; submits without opening a dialog", + ), ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"} (alias: code)"), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), @@ -494,4 +504,29 @@ mod tests { assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty()); assert_eq!(filter_commands(sample(), None).len(), 3); } + + #[test] + fn print_command_discovery_documents_settings_and_submission() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"print"})) else { + panic!("command discovery must return immediately"); + }; + assert_eq!(reply["ok"], true); + let commands = reply["result"].as_array().unwrap(); + for (id, expected_docs) in [ + ("ui.printSettings", &["copies", "duplex", "refresh", "discovering", "status", "detail"][..]), + ("ui.printSubmit", &["file.print", "pagination", "pending", "without opening a dialog"][..]), + ] { + let command = commands.iter().find(|command| command["id"] == id).unwrap(); + assert_eq!(command["ui"], true); + let docs = command["params"].as_str().unwrap(); + for expected in expected_docs { + assert!(docs.contains(expected), "{id}: missing {expected}"); + } + } + assert!(!app.printing.busy(), "discovery must not submit a job"); + assert!(app.backstage.is_none(), "discovery must not open settings"); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 347508e..7cc6ec2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -68,6 +68,9 @@ see [`mcp.md`](mcp.md) and [`cli.md`](cli.md). ### Native printing +Desktop `engine.commands {"search":"print"}` (and connected MCP `list_commands`) +lists `ui.printSettings` and `ui.printSubmit` with their parameter and result fields. + `engine.execute` can call `ui.printSettings` with `{}` to inspect the printer list, selected printer, copies, duplex, grayscale, discovery/submission status and backend receipt/error. `{"refresh":true}` starts background discovery; poll the same command until `discovering` is From 746a1e036420525e4c2838f8109b20eee7fc84cb Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 11:03:13 -0400 Subject: [PATCH 082/162] File: Export as PDF in the File menu, and the palette asks for a path (#407) --- apps/gridcraft/src/native_menu.rs | 10 +++++++ crates/engine/src/cmd/print.rs | 17 ++++++++++++ crates/ui-egui/src/lib.rs | 28 +++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 3 ++ crates/ui-egui/tests/file_menu.rs | 46 +++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+) diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index b127543..fca3cdd 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -24,6 +24,7 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Close", "file.close", Some("CMD+W")), ("Save", "file.save", Some("CMD+S")), ("Save As…", "file.saveAs", Some("CMD+SHIFT+S")), + ("Export as PDF…", "file.exportPdf", None), ("Export as CSV…", "dialog:saveCopy:csv", None), ("Save as Web Page…", "dialog:saveCopy:html", None), ("-", "", None), @@ -315,6 +316,15 @@ impl NativeMenu { mod tests { use super::*; + #[test] + fn file_menu_has_export_as_pdf_dispatching_the_command() { + let tree = tree(); + let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu"); + let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu"); + assert_eq!(item.1, "file.exportPdf"); + assert!(gridcraft_engine::find_command("file.exportPdf").is_some()); + } + #[test] fn theme_choices_use_the_shared_preference_command() { let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default()); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 986351e..2aeffbc 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -2046,6 +2046,23 @@ mod tests { assert!(s.execute("file.exportPdf", json!({})).is_ok()); } + #[test] + fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() { + let mut s = s(); + for params in [json!({}), json!({"path": ""}), json!({"path": null})] { + let r = s.execute("file.exportPdf", params).unwrap(); + assert!(r.get("path").is_none(), "no file is written without a path"); + let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + } + assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog"); + // Headless callers (CLI, MCP) get the same bytes, not an error. + assert!(s.execute_headless("file.exportPdf", json!({})).is_ok()); + // With no workbook open it is a clear error, not a panic. + let mut none = Session::new(); + assert!(none.execute("file.exportPdf", json!({})).is_err()); + } + #[test] fn bands_unit() { let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec29139..d80c32a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -408,11 +408,39 @@ impl SheetApp { if id == "file.print" { return self.print_now(params); } + // Menu-style Export as PDF with no file named: ask where to save, like Save As does. + // Programmatic callers use `run` and keep getting the PDF bytes back. + if id == "file.exportPdf" && self.services.pick_save.is_some() && params.get("path").and_then(Json::as_str).is_none_or(str::is_empty) { + return self.export_pdf_with_picker(params); + } if let Err(e) = self.run(id, params) { self.alert(e); } } + /// Asks for a `.pdf` path with the save picker, then re-runs `file.exportPdf` with it. Other + /// params (range, sheets, fitToPage) are kept. A cancelled picker does nothing. + fn export_pdf_with_picker(&mut self, mut params: Json) { + if !self.commit_edit(0, 0, false, false) { + return; + } + let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string(); + let Some(pick) = &self.services.pick_save else { return }; + let Some(path) = pick(&format!("{stem}.pdf")) else { return }; + // The picker may suggest a workbook extension; the export writes a PDF. + let path = std::path::Path::new(&path).with_extension("pdf").to_string_lossy().into_owned(); + match &mut params { + Json::Object(map) => { + map.insert("path".into(), Json::String(path)); + } + other => *other = json!({"path": path}), + } + if let Err(e) = self.run("file.exportPdf", params) { + self.alert(e); + } + } + /// Shows a command error: a toast for an unavailable command, else a message box. fn alert(&mut self, e: String) { if e.contains("is not available right now") { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291ac..c4c3a2a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -131,6 +131,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true); egui::Popup::menu(&share).show(|ui| { + if ui.button("Export as PDF…").clicked() { + app.run_or_alert("file.exportPdf", json!({})); + } for (label, fmt) in [("Save a Copy as Excel Workbook (.xlsx)…", "xlsx"), ("Export as CSV…", "csv"), ("Export as Web Page (.html)…", "html")] { diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index c6ef2b2..691eef3 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -171,3 +171,49 @@ fn save_as_offers_pdf_and_leaves_the_workbook_file_alone() { assert!(d.path.is_none() && d.is_dirty(), "a PDF is a copy, not the workbook's file"); let _ = std::fs::remove_dir_all(&dir); } + +fn pdf_picker_harness(dir: &std::path::Path) -> (Arc>>, egui_kittest::Harness<'static, SheetApp>) { + let asked = Arc::new(Mutex::new(Vec::::new())); + let (capture, target) = (asked.clone(), dir.to_path_buf()); + let h = harness(Services { + pick_save: Some(Box::new(move |suggested| { + capture.lock().unwrap().push(suggested.to_string()); + Some(target.join("Report.xlsx").to_string_lossy().into_owned()) + })), + ..Default::default() + }); + (asked, h) +} + +#[test] +fn the_palette_export_as_pdf_asks_where_to_save() { + let dir = std::env::temp_dir().join(format!("gridcraft-export-pdf-palette-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (asked, mut h) = pdf_picker_harness(&dir); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + // The palette and menus go through run_or_alert. + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({})); + assert!(asked.lock().unwrap().first().is_some_and(|s| s.ends_with(".pdf")), "the picker is offered a .pdf name"); + assert!(std::fs::read(dir.join("Report.pdf")).unwrap().starts_with(b"%PDF-"), "the PDF is written, whatever extension the picker returned"); + assert!(h.state().message.is_none()); + let d = h.state().session.active().unwrap(); + assert!(d.path.is_none(), "a PDF is a copy, not the workbook's file"); + // Other params survive the round trip through the picker. + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"range": "nope"})); + assert!(h.state().message.is_some(), "a bad range is reported, not swallowed"); + // A path in the params skips the picker. + let before = asked.lock().unwrap().len(); + let named = dir.join("Named.pdf"); + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"path": named.to_string_lossy()})); + assert_eq!(asked.lock().unwrap().len(), before); + assert!(named.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_cancelled_export_pdf_picker_writes_nothing() { + let mut h = harness(Services { pick_save: Some(Box::new(|_| None)), ..Default::default() }); + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({})); + assert!(h.state().message.is_none()); + assert!(h.state_mut().session.take_ui_requests().is_empty()); +} From 9cb931bac9c9a73a257a312033e71cba71b57bbd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 11:08:25 -0400 Subject: [PATCH 083/162] Release workflow: pass the Azure signing secrets so Windows builds are signed (#440) Since the release workflow moved to storytold/craft-common-actions, it has passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads values from the release environment, but only for names the caller lists, so the AZURE_* names that Trusted Signing uses came through empty and Windows builds would ship unsigned. Same fix as storytold/craft-launcher#174. Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87d8954..83b5efa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} From fc45b7f6d8ab79ef20f74e3c8347a2fd731e6af5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 11:11:15 -0400 Subject: [PATCH 084/162] Release 0.6.1: rebuild with the fixed shared release workflow The previous release build failed in craft-common-actions, not in this repo: riscv64 packaging got --formats deb,rpm,tar (fixed in craft-common-actions#3), Linux jobs lacked the ALSA headers (craft-common-actions#5), and Windows signing needed the AZURE_* secrets listed in the caller. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e734190..ade3a5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.6.0" +version = "0.6.1" dependencies = [ "arboard", "craft-print", @@ -1516,7 +1516,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.6.0" +version = "0.6.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.6.0" +version = "0.6.1" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1539,7 +1539,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.6.0" +version = "0.6.1" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1551,7 +1551,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.6.0" +version = "0.6.1" dependencies = [ "js-sys", "serde", @@ -1561,7 +1561,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.6.0" +version = "0.6.1" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1582,7 +1582,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.6.0" +version = "0.6.1" dependencies = [ "gridcraft-core", "serde", @@ -1591,7 +1591,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.6.0" +version = "0.6.1" dependencies = [ "gridcraft-core", "regex", @@ -1599,7 +1599,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.6.0" +version = "0.6.1" dependencies = [ "gridcraft-engine", "log", @@ -1608,7 +1608,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.6.0" +version = "0.6.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1620,18 +1620,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.6.0" +version = "0.6.1" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.6.0" +version = "0.6.1" [[package]] name = "gridcraft-ui-egui" -version = "0.6.0" +version = "0.6.1" dependencies = [ "craft-ui", "egui", @@ -1653,7 +1653,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.6.0" +version = "0.6.1" dependencies = [ "eframe", "egui", @@ -1670,7 +1670,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.6.0" +version = "0.6.1" dependencies = [ "aes", "gridcraft-core", @@ -4524,7 +4524,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.6.0" +version = "0.6.1" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index ecec031..0bfee24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.6.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From d4874373a437db4d12ff5bcb731ff85877259365 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 12 Oct 2026 23:54:44 +0800 Subject: [PATCH 085/162] #551: preserve editable ink through XLSX round trips --- ROADMAP.md | 4 +- crates/engine/tests/ink_roundtrip.rs | 135 ++++++++++++++++ crates/xlsx/src/drawing.rs | 11 ++ crates/xlsx/src/drawing/ink.rs | 207 +++++++++++++++++++++++++ crates/xlsx/src/tests/ink_roundtrip.rs | 194 +++++++++++++++++++++++ crates/xlsx/src/tests/mod.rs | 1 + docs/file-format-parity.md | 10 +- docs/gaps.md | 5 +- 8 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/ink_roundtrip.rs create mode 100644 crates/xlsx/src/drawing/ink.rs create mode 100644 crates/xlsx/src/tests/ink_roundtrip.rs diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..f40210f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink survives XLSX saves) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/engine/tests/ink_roundtrip.rs b/crates/engine/tests/ink_roundtrip.rs new file mode 100644 index 0000000..bff1b61 --- /dev/null +++ b/crates/engine/tests/ink_roundtrip.rs @@ -0,0 +1,135 @@ +//! Native ink remains editable after XLSX saves, including a save after an unrelated cell edit. + +use gridcraft_engine::Session; +use gridcraft_engine::cmd::draw::{ink_points, ink_width}; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{Shape, ShapeKind}; +use serde_json::json; + +fn workbook() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session +} + +fn reopen(session: &mut Session) -> Session { + let saved = session.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.open", json!({"name": "ink.xlsx", "base64": saved["base64"]})).unwrap(); + reopened +} + +fn shapes(session: &Session) -> &[Shape] { + &session.doc().unwrap().wb.active().unwrap().shapes +} + +fn assert_anchor(actual: &Shape, expected: &Shape) { + assert_eq!(actual.anchor.cell, expected.anchor.cell); + assert_eq!(actual.anchor.mode, expected.anchor.mode); + for (name, actual, expected) in [ + ("dx", actual.anchor.dx, expected.anchor.dx), + ("dy", actual.anchor.dy, expected.anchor.dy), + ("width", actual.anchor.width, expected.anchor.width), + ("height", actual.anchor.height, expected.anchor.height), + ] { + assert!((actual - expected).abs() < 0.001, "anchor {name}: {actual} != {expected}"); + } +} + +fn assert_ink(actual: &Shape, expected: &Shape) { + assert_eq!(actual.kind, ShapeKind::Ink); + let actual_points = ink_points(actual); + let expected_points = ink_points(expected); + assert_eq!(actual_points.len(), expected_points.len()); + for (actual, expected) in actual_points.iter().zip(&expected_points) { + for axis in 0..2 { + // DrawingML quantizes coordinates to integer EMUs (9,525 per model unit). + assert!((actual[axis] - expected[axis]).abs() < 0.001, "point {actual:?} != {expected:?}"); + } + } + assert_eq!(actual.line, expected.line); + assert_eq!(ink_width(actual), ink_width(expected)); + assert_anchor(actual, expected); +} + +#[test] +fn ink_geometry_and_style_survive_two_saves_and_a_cell_edit() { + let mut session = workbook(); + session + .execute("draw.stroke", json!({"points": [[130.5, 45.25], [172.5, 86.25], [151.5, 112.25], [215.5, 59.25]], "color": "#A12BCD", "width": 3})) + .unwrap(); + session + .execute("draw.stroke", json!({"points": [[300, 180], [360, 180], [360, 230], [300, 230], [300, 180]], "color": "#16843B", "width": 7})) + .unwrap(); + let expected = shapes(&session).to_vec(); + assert_eq!(expected.len(), 2); + + for pass in 0..2 { + session = reopen(&mut session); + assert_eq!(shapes(&session).len(), expected.len()); + for (actual, expected) in shapes(&session).iter().zip(&expected) { + assert_ink(actual, expected); + } + if pass == 0 { + session.execute("cell.set", json!({"cell": "Z30", "input": "42"})).unwrap(); + } + } + assert_eq!(session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("Z30").unwrap()), Value::Number(42.0)); +} + +#[test] +fn reopened_ink_can_still_be_converted_to_a_shape() { + for (points, expected_kind, expected_name) in [ + (vec![[100, 100], [200, 100], [200, 180], [100, 180], [100, 100]], ShapeKind::Rectangle, "Rectangle"), + (vec![[100, 100], [140, 110], [220, 130]], ShapeKind::Line, "Line"), + ] { + let mut session = workbook(); + session.execute("draw.stroke", json!({"points": points, "color": "#A12BCD", "width": 5})).unwrap(); + let original = shapes(&session)[0].clone(); + session = reopen(&mut session); + let id = shapes(&session)[0].id; + let converted = session.execute("draw.inkToShape", json!({"id": id})).unwrap(); + assert_eq!(converted["kind"], expected_name); + let shape = &shapes(&session)[0]; + assert_eq!(shape.kind, expected_kind); + assert_eq!(shape.line, original.line); + assert!(shape.text.is_empty()); + assert_anchor(shape, &original); + } +} + +#[test] +fn ordinary_shape_text_is_not_mistaken_for_ink_points() { + let mut session = workbook(); + session + .execute( + "insert.shape", + json!({"kind": "rectangle", "at": "D6", "width": 120, "height": 60, "fill": "#112233", "line": "#A12BCD", "text": "0.0,0.0;50.0,20.0"}), + ) + .unwrap(); + let original = shapes(&session)[0].clone(); + for _ in 0..2 { + session = reopen(&mut session); + assert_eq!(shapes(&session).len(), 1); + let shape = &shapes(&session)[0]; + assert_eq!(shape.kind, ShapeKind::Rectangle); + assert_eq!(shape.text, original.text); + assert_eq!(shape.fill, original.fill); + assert_eq!(shape.line, original.line); + assert_anchor(shape, &original); + assert!(session.execute("draw.inkToShape", json!({})).is_err()); + } +} + +#[test] +fn horizontal_vertical_and_signed_highlighter_strokes_survive_reopening() { + for points in [vec![[25.1, 50.2], [140.3, 50.2]], vec![[40.2, 10.1], [40.2, 100.3]], vec![[-12.3, -4.2], [20.1, 10.2], [80.3, -1.4]]] { + let mut session = workbook(); + session.execute("draw.stroke", json!({"points": points, "color": "#16843B", "width": 12})).unwrap(); + let expected = shapes(&session)[0].clone(); + for _ in 0..2 { + session = reopen(&mut session); + assert_ink(&shapes(&session)[0], &expected); + } + } +} diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 076148a..6dbd17e 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -11,6 +11,8 @@ use crate::read::Ctx; use crate::tables::EMU_PER_PX; use crate::xml::{El, esc, esc_attr}; +mod ink; + // ---------------------------------------------------------------- reading fn emu_px(v: Option) -> f32 { @@ -195,6 +197,9 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( fn read_shape(obj: &El, anchor: Anchor) -> Option { let sp = obj.child("spPr")?; + if let Some(shape) = ink::read(obj, anchor) { + return Some(shape); + } let prst = sp.child("prstGeom").and_then(|g| g.attr("prst")).unwrap_or("rect"); let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false)); let ln = sp.child("ln"); @@ -358,6 +363,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { let (open, close) = anchor_open(sheet, &sh.anchor); close_tag = close; s.push_str(&open); + if let Some(xml) = ink::write(sh, theme, id) { + s.push_str(&xml); + s.push_str(""); + s.push_str(close_tag); + continue; + } let (prst, tx) = match sh.kind { ShapeKind::Rectangle => ("rect", false), ShapeKind::RoundedRectangle => ("roundRect", false), diff --git a/crates/xlsx/src/drawing/ink.rs b/crates/xlsx/src/drawing/ink.rs new file mode 100644 index 0000000..b48d99e --- /dev/null +++ b/crates/xlsx/src/drawing/ink.rs @@ -0,0 +1,207 @@ +//! The open, solid polylines the model can represent as editable ink. This is DrawingML +//! geometry, not Office InkML. Unsupported geometry keeps the existing shape-import fallback. + +use super::*; + +const MAX_POINTS: usize = 20_000; +const MAX_COORD: f64 = 1e7; + +fn bounded(v: f64) -> bool { + v.is_finite() && v.abs() <= MAX_COORD +} + +fn only_children(e: &El, names: &[&str]) -> bool { + e.children.iter().all(|c| names.contains(&c.name.as_str())) +} + +fn only_attrs(e: &El, names: &[&str]) -> bool { + e.attrs.iter().all(|(k, _)| names.contains(&k.as_str())) +} + +fn one<'a>(e: &'a El, name: &str) -> Option<&'a El> { + let mut found = e.children.iter().filter(|c| c.name == name); + let first = found.next()?; + found.next().is_none().then_some(first) +} + +/// Do not partially import a path: losing a curve, subpath, transform or line effect changes +/// the drawing. Accept only the explicit subset emitted below, including its line styling. +pub(super) fn read(obj: &El, anchor: Anchor) -> Option { + if obj.name != "sp" + || !only_children(obj, &["nvSpPr", "spPr"]) + || !only_attrs(obj, &["macro", "textlink"]) + || obj.attrs.iter().any(|(_, v)| !v.is_empty()) + || obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|e| e.flag("txBox", false)) + { + return None; + } + let sp = one(obj, "spPr")?; + if !only_children(sp, &["xfrm", "custGeom", "noFill", "ln"]) || !sp.attrs.is_empty() { + return None; + } + let no_fill = one(sp, "noFill")?; + if !no_fill.attrs.is_empty() || !no_fill.children.is_empty() { + return None; + } + let xf = one(sp, "xfrm")?; + if !xf.attrs.is_empty() || !only_children(xf, &["off", "ext"]) { + return None; + } + let off = one(xf, "off")?; + if off.attr_i64("x")? != 0 || off.attr_i64("y")? != 0 { + return None; + } + let ext = one(xf, "ext")?; + let ew = ext.attr_i64("cx")?; + let eh = ext.attr_i64("cy")?; + if ew <= 0 || eh <= 0 || anchor.width <= 0.0 || anchor.height <= 0.0 || !bounded(anchor.width as f64) || !bounded(anchor.height as f64) { + return None; + } + let same_extent = + |emu: i64, extent: f32| (emu as f64 / EMU_PER_PX - extent as f64).abs() <= 0.001_f64.max(extent as f64 * f32::EPSILON as f64 * 2.0); + if !same_extent(ew, anchor.width) || !same_extent(eh, anchor.height) { + return None; + } + let geom = one(sp, "custGeom")?; + if !geom.attrs.is_empty() || !only_children(geom, &["avLst", "gdLst", "ahLst", "cxnLst", "rect", "pathLst"]) { + return None; + } + // Text bounds have no effect on this unfilled geometry, which has no caption. + if geom.children.iter().any(|c| !matches!(c.name.as_str(), "pathLst" | "rect") && (!c.children.is_empty() || !c.attrs.is_empty())) { + return None; + } + if geom.kids("rect").count() > 1 || geom.kids("rect").any(|r| !r.children.is_empty() || !only_attrs(r, &["l", "t", "r", "b"])) { + return None; + } + let paths = one(geom, "pathLst")?; + if paths.children.len() != 1 { + return None; + } + let path = one(paths, "path")?; + let w = path.attr_i64("w")?; + let h = path.attr_i64("h")?; + if w <= 0 + || h <= 0 + || path.attr("fill") != Some("none") + || !only_attrs(path, &["w", "h", "fill", "stroke", "extrusionOk"]) + || path.attr("stroke").is_some_and(|_| path.attr_bool("stroke") != Some(true)) + || path.attr("extrusionOk").is_some_and(|_| path.attr_bool("extrusionOk") != Some(false)) + || !(2..=MAX_POINTS).contains(&path.children.len()) + { + return None; + } + let ln = one(sp, "ln")?; + if !only_attrs(ln, &["w", "cap", "cmpd", "algn"]) + || ln.attr("cap") != Some("flat") + || ln.attr("cmpd").is_some_and(|v| v != "sng") + || ln.attr("algn").is_some_and(|v| v != "ctr") + || !only_children(ln, &["solidFill", "miter"]) + { + return None; + } + let width_emu = ln.attr_i64("w")?; + let unit = EMU_PER_PX as i64; + if width_emu % unit != 0 || !(1..=40).contains(&(width_emu / unit)) { + return None; + } + let width = width_emu / unit; + let join = one(ln, "miter")?; + if !join.attrs.is_empty() || !join.children.is_empty() { + return None; + } + let fill = one(ln, "solidFill")?; + if fill.children.len() != 1 { + return None; + } + let color = one(fill, "srgbClr")?; + if !only_attrs(color, &["val"]) || !only_children(color, &["alpha"]) { + return None; + } + let expected_alpha = if width > 8 { 45_000 } else { 100_000 }; + if color.children.is_empty() { + if expected_alpha != 100_000 { + return None; + } + } else { + let alpha = one(color, "alpha")?; + if !only_attrs(alpha, &["val"]) || !alpha.children.is_empty() || alpha.attr_i64("val")? != expected_alpha { + return None; + } + } + let line = Color::from_hex(color.attr("val")?)?; + let mut text = String::new(); + for (i, command) in path.children.iter().enumerate() { + if command.name != if i == 0 { "moveTo" } else { "lnTo" } || !command.attrs.is_empty() || command.children.len() != 1 { + return None; + } + let pt = one(command, "pt")?; + if !only_attrs(pt, &["x", "y"]) || !pt.children.is_empty() { + return None; + } + let x = pt.attr_i64("x")? as f64 * anchor.width as f64 / w as f64; + let y = pt.attr_i64("y")? as f64 * anchor.height as f64 / h as f64; + if !bounded(x) || !bounded(y) { + return None; + } + if i != 0 { + text.push(';'); + } + let _ = write!(text, "{},{}", x as f32, y as f32); + } + Some(Shape { id: 0, kind: ShapeKind::Ink, anchor, fill: Color::Rgb((width as u32) << 16), line, text, format: Default::default() }) +} + +/// Invalid/native out-of-range data uses the existing rectangle fallback. A successful +/// result is complete before it is appended, so malformed points never yield half a stroke. +pub(super) fn write(shape: &Shape, theme: &Theme, id: usize) -> Option { + if shape.kind != ShapeKind::Ink || shape.format.no_line { + return None; + } + let width = match shape.fill { + Color::Rgb(rgb) => ((rgb >> 16) & 0xff).max(1), + _ => 2, + }; + if !(1..=40).contains(&width) { + return None; + } + let a = &shape.anchor; + if !bounded(a.width as f64) || !bounded(a.height as f64) || a.width <= 0.0 || a.height <= 0.0 || !a.dx.is_finite() || !a.dy.is_finite() { + return None; + } + let w = (a.width as f64 * EMU_PER_PX).round().max(1.0) as i64; + let h = (a.height as f64 * EMU_PER_PX).round().max(1.0) as i64; + let mut points = Vec::new(); + for point in shape.text.split(';') { + if points.len() == MAX_POINTS { + return None; + } + let (x, y) = point.split_once(',')?; + let (x, y) = (x.parse::().ok()?, y.parse::().ok()?); + if !bounded(x) || !bounded(y) { + return None; + } + points.push(((x * EMU_PER_PX).round() as i64, (y * EMU_PER_PX).round() as i64)); + } + if points.len() < 2 { + return None; + } + let [r, g, b] = shape.line.resolve(theme)?; + let mut xml = format!( + "{}", + xfrm(a, "a:xfrm") + ); + for (i, (x, y)) in points.iter().enumerate() { + let tag = if i == 0 { "moveTo" } else { "lnTo" }; + let _ = write!(xml, ""); + } + let _ = write!( + xml, + "", + width as i64 * EMU_PER_PX as i64 + ); + if width > 8 { + xml.push_str(""); + } + xml.push_str(""); + Some(xml) +} diff --git a/crates/xlsx/src/tests/ink_roundtrip.rs b/crates/xlsx/src/tests/ink_roundtrip.rs new file mode 100644 index 0000000..2882765 --- /dev/null +++ b/crates/xlsx/src/tests/ink_roundtrip.rs @@ -0,0 +1,194 @@ +//! Ink uses visible DrawingML geometry, and only representable polylines become editable ink. + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_model::{Anchor, AnchorMode, Color, Shape, ShapeKind, Sheet, Workbook}; + +use super::minimal; +use crate::xml::{self, El}; +use crate::{read_xlsx, write_xlsx}; + +const POINTS: &str = r#""#; +const PATH: &str = r#""#; + +fn drawing() -> String { + format!( + r#" + 01905000285750 + + + {PATH}{POINTS} + + "# + ) +} + +fn read_drawing(drawing: &str) -> Workbook { + let rels = r#""#; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/drawing1.xml", drawing)], + "", + "", + ); + read_xlsx(&bytes).unwrap().0 +} + +fn points(shape: &Shape) -> Vec<[f64; 2]> { + shape + .text + .split(';') + .map(|pair| { + let (x, y) = pair.split_once(',').unwrap(); + [x.parse().unwrap(), y.parse().unwrap()] + }) + .collect() +} + +fn has_caption(element: &El) -> bool { + (element.name == "t" && !element.text.is_empty()) || element.children.iter().any(has_caption) +} + +#[test] +fn exported_ink_has_a_visible_unfilled_path_and_correct_stroke() { + let mut sheet = Sheet::new("Ink"); + for (id, width) in [(1, 3), (2, 12)] { + sheet.shapes.push(Shape { + id, + kind: ShapeKind::Ink, + anchor: Anchor { width: 100.0, height: 50.0, mode: AnchorMode::Absolute, ..Default::default() }, + fill: Color::Rgb(width << 16), + line: Color::Rgb(0xA12BCD), + text: "-10.0,5.0;50.0,40.0;90.0,10.0".into(), + format: Default::default(), + }); + } + let wb = Workbook { sheets: vec![Arc::new(sheet)], ..Default::default() }; + let bytes = write_xlsx(&wb).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut drawing = String::new(); + archive.by_name("xl/drawings/drawing1.xml").unwrap().read_to_string(&mut drawing).unwrap(); + let root = xml::parse(drawing.as_bytes()).unwrap(); + let shapes: Vec<_> = root.children.iter().filter_map(|anchor| anchor.child("sp")).collect(); + assert_eq!(shapes.len(), 2); + for (shape, width) in shapes.iter().zip([3, 12]) { + assert!(!has_caption(shape), "stroke coordinates must not be visible text"); + let properties = shape.child("spPr").unwrap(); + assert!(properties.child("prstGeom").is_none()); + assert!(properties.child("noFill").is_some()); + let paths: Vec<_> = properties.path(&["custGeom", "pathLst"]).unwrap().kids("path").collect(); + assert_eq!(paths.len(), 1); + let path = paths[0]; + assert_eq!(path.attr("fill"), Some("none")); + let w = path.attr_i64("w").unwrap(); + let h = path.attr_i64("h").unwrap(); + assert!(w > 0 && h > 0); + assert_eq!(path.children.iter().map(|c| c.name.as_str()).collect::>(), ["moveTo", "lnTo", "lnTo"]); + for (command, [x, y]) in path.children.iter().zip([[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]) { + let point = command.child("pt").unwrap(); + let actual_x = point.attr_i64("x").unwrap() as f64 * 100.0 / w as f64; + let actual_y = point.attr_i64("y").unwrap() as f64 * 50.0 / h as f64; + assert!((actual_x - x).abs() < 0.001 && (actual_y - y).abs() < 0.001); + } + let line = properties.child("ln").unwrap(); + assert_eq!(line.attr_i64("w"), Some(9525 * width)); + assert_eq!(line.attr("cap"), Some("flat")); + assert!(line.child("miter").is_some()); + let color = line.path(&["solidFill", "srgbClr"]).unwrap(); + assert_eq!(color.attr("val"), Some("A12BCD")); + if width > 8 { + assert_eq!(color.child_val("alpha"), Some("45000")); + } else { + assert!(color.child("alpha").is_none_or(|a| a.attr("val") == Some("100000"))); + } + } +} + +#[test] +fn numeric_polyline_from_an_independent_package_is_scaled_into_editable_ink() { + for (width, alpha) in [(3, ""), (12, r#""#)] { + let xml = drawing() + .replace("w=\"28575\"", &format!("w=\"{}\"", width * 9525)) + .replace(r#""#, &format!(r#"{alpha}"#)); + let wb = read_drawing(&xml); + let shapes = &wb.sheet(0).unwrap().shapes; + assert_eq!(shapes.len(), 1); + let shape = &shapes[0]; + assert_eq!(shape.kind, ShapeKind::Ink); + assert_eq!(points(shape), [[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]); + assert_eq!(shape.line, Color::Rgb(0xA12BCD)); + assert_eq!(shape.fill, Color::Rgb(width << 16)); + assert_eq!((shape.anchor.dx, shape.anchor.dy), (20.0, 30.0)); + assert_eq!((shape.anchor.width, shape.anchor.height), (100.0, 50.0)); + } +} + +#[test] +fn imported_points_are_not_snapped_to_native_tenth_units() { + let xml = drawing().replace("w=\"200\"", "w=\"2000000\"").replace( + POINTS, + r#""#, + ); + let wb = read_drawing(&xml); + let shape = &wb.sheet(0).unwrap().shapes[0]; + assert_eq!(shape.kind, ShapeKind::Ink); + let original = points(shape); + assert!((original[0][0] - 0.10005).abs() < 0.00000001, "an imported coordinate is not necessarily a native tenth-unit point"); + let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let after = &reopened.sheet(0).unwrap().shapes[0]; + assert_eq!(after.kind, ShapeKind::Ink); + for (actual, expected) in points(after).iter().zip(original) { + for axis in 0..2 { + // A saved DrawingML coordinate is quantized to one EMU, plus f32 model precision. + assert!((actual[axis] - expected[axis]).abs() < 0.00006); + } + } +} + +#[test] +fn unsupported_geometry_or_appearance_is_not_reclassified_as_editable_ink() { + let original = drawing(); + let cases = [ + ("formula coordinate", original.replace("x=\"-20\"", "x=\"wd2\"")), + ("nonfinite coordinate", original.replace("x=\"-20\"", "x=\"NaN\"")), + ("overflow coordinate", original.replace("x=\"-20\"", "x=\"9223372036854775808\"")), + ("zero path width", original.replace("w=\"200\"", "w=\"0\"")), + ("missing point", original.replace(r#""#, "")), + ( + "curve", + original + .replace(r#""#, r#""#), + ), + ("closed path", original.replace("", "")), + ("multiple paths", original.replace("", &format!("{PATH}{POINTS}"))), + ("filled path", original.replace("fill=\"none\"", "fill=\"norm\"")), + ("filled shape", original.replace("", "")), + ("rotation", original.replace("", "")), + ("reflection", original.replace("", "")), + ("square caps", original.replace("cap=\"flat\"", "cap=\"sq\"")), + ("round join", original.replace("", "")), + ("dash", original.replace("", "")), + ("arrow", original.replace("", "")), + ( + "caption", + original.replace("", "Caption"), + ), + ("fractional width", original.replace("w=\"28575\"", "w=\"33338\"")), + ("unsupported opacity", original.replace(r#""#, r#""#)), + ]; + for (name, xml) in cases { + let wb = read_drawing(&xml); + assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink), "{name} was silently converted to editable ink"); + } +} + +#[test] +fn oversized_polyline_is_not_truncated_into_different_ink() { + let mut commands = String::from(r#""#); + for _ in 0..20_000 { + commands.push_str(r#""#); + } + let wb = read_drawing(&drawing().replace(POINTS, &commands)); + assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink)); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index d5d2f2f..e1d86fe 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -4,6 +4,7 @@ mod array_export; mod csv_tests; mod encrypted_tests; mod fixtures; +mod ink_roundtrip; mod malformed; mod ods_tests; mod passthrough_tests; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8510659..cf5c6d8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink round-trips as DrawingML geometry) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -13,6 +13,13 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and ## Workbook formats +Ink created with Draw is written as standard, unfilled DrawingML polylines, without visible point +captions or private metadata. Supported numeric polylines reopen as editable ink, retaining points, +colour, integer width and anchor. Curves, filled paths, transforms and unsupported line styles are +not interpreted as ink. This does not implement Office InkML or fix inserted icons (#551). +Command and package tests cover repeated saves and unrelated cell edits; external Excel rendering +and repair-free opening remain unverified. + | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | @@ -78,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Ink paths and editing survive XLSX save/reopen; strict polyline import and malformed-input controls | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b8..fdd7492 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (ink XLSX preservation; icons still pending) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -125,6 +125,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- conversion; cloud storage requested (#86). **Estimate:** 35–55 h (co-authoring scope needs the owner). ### 17. Insert objects (F) +- Ink strokes now survive XLSX save/reopen as editable polylines. Inserted icons still lose their + identity/artwork on save (#551); native Office InkML import is not implemented. - SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170; pictures in cells (#61, PR #105). **Estimate:** 25–40 h. @@ -160,6 +162,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | §17: ink geometry survives XLSX saves; the icon portion of #551 remains | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | From 1a49d18dd1aed1dbca22f37674a5fcb08524ae48 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:02:02 +0200 Subject: [PATCH 086/162] Comments pane: one Show Comments button, and the pane keeps its width (fixes #710) --- crates/ui-egui/src/panes.rs | 34 +++++---- crates/ui-egui/src/ribbon.rs | 3 - crates/ui-egui/tests/comments_pane.rs | 105 ++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/tests/comments_pane.rs diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 3100cfe..aaa9bec 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -17,7 +17,10 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { } let Some(pane) = app.grid.pane.clone().or_else(|| app.session.comments_pane.then(|| "comments".to_string())) else { return }; let t = Tokens::get(ui.ctx()); - egui::Panel::right("task_pane").default_size(300.0).resizable(true).frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| { + // Capped at half the window so a pane can never cover the sheet, whatever its content asks for. + let max = (ui.available_width() / 2.0).max(300.0); + let panel = egui::Panel::right("task_pane").default_size(300.0).max_size(max).resizable(true); + panel.frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| { ui.horizontal(|ui| { let title = match pane.as_str() { "comments" => "Comments", @@ -62,7 +65,7 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { .stroke(egui::Stroke::new(1.0, Color32::from_gray(215))) .show(ui, |ui| { ui.set_width(ui.available_width()); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { if ui.link(egui::RichText::new(&cell).strong()).clicked() { let _ = app.run("selection.set", json!({"cell": cell})); app.grid.ensure_visible = true; @@ -78,18 +81,23 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { } let key = egui::Id::new(("reply", &cell)); let mut draft: String = ui.ctx().data_mut(|m| m.get_temp(key)).unwrap_or_default(); + // Buttons first, right to left, so the reply field gets exactly the width they leave: + // a field sized by a guess wider than the row pushes the resizable pane wider every frame. ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut draft).hint_text("Reply…").desired_width(ui.available_width() - 120.0)); - if (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) || ui.small_button("Send").clicked()) && !draft.is_empty() { - app.run_or_alert("review.replyComment", json!({"cell": cell, "text": draft})); - draft.clear(); - } - if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { - app.run_or_alert("review.resolveComment", json!({"cell": cell, "resolved": !c.resolved})); - } - if ui.small_button("Delete").clicked() { - app.run_or_alert("review.deleteComment", json!({"cell": cell})); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Delete").clicked() { + app.run_or_alert("review.deleteComment", json!({"cell": cell})); + } + if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { + app.run_or_alert("review.resolveComment", json!({"cell": cell, "resolved": !c.resolved})); + } + let send = ui.small_button("Send").clicked(); + let r = ui.add(egui::TextEdit::singleline(&mut draft).hint_text("Reply…").desired_width(ui.available_width())); + if (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) || send) && !draft.is_empty() { + app.run_or_alert("review.replyComment", json!({"cell": cell, "text": draft})); + draft.clear(); + } + }); }); ui.ctx().data_mut(|m| m.insert_temp(key, draft)); }); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291ac..d25dfa1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2022,9 +2022,6 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.open_dialog("comment", json!({"threaded": true})); } - if big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false).clicked() { - app.grid.pane = Some("comments".into()); - } let __k = big_button(ui, Icon::Delete, "Delete", "Delete Comment", false); control_keytip(app, ui, &__k, "review.deleteComment"); if __k.clicked() { diff --git a/crates/ui-egui/tests/comments_pane.rs b/crates/ui-egui/tests/comments_pane.rs new file mode 100644 index 0000000..fe61508 --- /dev/null +++ b/crates/ui-egui/tests/comments_pane.rs @@ -0,0 +1,105 @@ +//! The Comments pane: one Show Comments button, and a pane that keeps its width (#710). + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const WIDTH: f32 = 1200.0; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(WIDTH, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn grid_right(h: &Harness) -> f32 { + h.state().grid.rect.unwrap().right() +} + +/// A comment whose author line and reply row are wide enough to push the pane if they don't wrap. +fn open_pane_on_long_comment(h: &mut Harness) { + let text = "A long comment that should wrap inside the pane instead of pushing it wider. ".repeat(6); + let author = "Someone With A Rather Long Display Name, Finance Department, Head Office"; + h.state_mut().run("review.newComment", json!({"cell": "B2", "text": text, "author": author})).unwrap(); + h.state_mut().run("review.showComments", json!({"on": true})).unwrap(); + h.run_steps(3); +} + +fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([Event::PointerMoved(from), press(from, true)]); + h.step(); + for i in 1..=6 { + let t = i as f32 / 6.0; + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * t)); + h.step(); + } + h.input_mut().events.push(press(to, false)); + h.run_steps(3); +} + +#[test] +fn the_pane_does_not_grow_on_its_own_and_keeps_a_manual_resize() { + let mut h = harness(); + // Between the grid and the right edge of the window, before any pane is open. + let gutter = WIDTH - grid_right(&h); + open_pane_on_long_comment(&mut h); + let opened = grid_right(&h); + assert!(WIDTH - opened < WIDTH / 2.0, "the pane opens at its default width, got {}", WIDTH - opened); + h.run_steps(60); + assert_eq!(grid_right(&h), opened, "the pane must not widen itself frame after frame"); + + // Drag the pane's left edge 150px to the left: the new width stays. + let edge = pos2(opened + gutter, 450.0); + drag(&mut h, edge, edge - vec2(150.0, 0.0)); + let resized = grid_right(&h); + // The grab point is a few pixels off the exact edge, so allow some slack on the 150px. + assert!(opened - resized > 120.0, "the drag widens the pane, grid edge {opened} -> {resized}"); + h.run_steps(60); + assert_eq!(grid_right(&h), resized, "a manual resize must hold"); + + // Dragged almost to the window's left edge, the pane stops at half the window. + let edge = pos2(resized + gutter, 450.0); + drag(&mut h, edge, pos2(60.0, 450.0)); + assert!(grid_right(&h) > WIDTH / 2.0 - gutter - 30.0, "the pane is capped at half the window, grid edge {}", grid_right(&h)); +} + +/// How many separate ribbon buttons show `tip` when hovered, scanning the big-button row. +fn buttons_with_tip(h: &mut Harness, tip: &str) -> usize { + h.ctx.all_styles_mut(|s| s.interaction.tooltip_delay = 0.0); + let (mut count, mut inside) = (0, false); + let mut x = 2.0; + while x < WIDTH { + h.input_mut().events.push(Event::PointerMoved(pos2(x, 120.0))); + h.run_steps(6); + let shown = h.query_all_by_value(tip).next().is_some(); + if shown && !inside { + count += 1; + } + inside = shown; + x += 8.0; + } + count +} + +#[test] +fn review_has_a_single_show_comments_button() { + let mut h = harness(); + h.state_mut().ui.ribbon_tab = "Review".into(); + h.run_steps(2); + // The scan has to find a known single button, or a count of one below would prove nothing. + assert_eq!(buttons_with_tip(&mut h, "New Comment"), 1); + assert_eq!(buttons_with_tip(&mut h, "Comments pane"), 1); +} From 1f7fd07c0d3a21fad43b0cdacb61744222ff15d2 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:24:23 -0400 Subject: [PATCH 087/162] =?UTF-8?q?PivotTables:=20values=20on=20rows=20(?= =?UTF-8?q?=CE=A3=20Values=20in=20Rows,=20dataOnRows)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With two or more value fields the Σ Values pseudo-field can sit in Rows as well as Columns, at any position among the row fields. The model records it (`PivotTable.values_on_rows`, `values_position`) and the engine lays it out like Excel in compact, outline and tabular form: one row item per value field at the Σ Values level, per-value subtotals ("East Sum of Sales") and grand totals ("Total Sum of Sales"). XLSX reads `dataOnRows` and the `-2` field's place in rowFields or colFields and writes them back; the "values on rows are shown on columns" warning is gone. `pivot.moveField` moves "Σ Values" between Rows and Columns or within one, axis field moves keep it in place, and the PivotTable Fields pane lists it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/pivot.rs | 206 +++++++++++++++++++++++--- crates/engine/src/pivot.rs | 208 ++++++++++++++++++++++----- crates/model/src/features.rs | 7 + crates/ui-egui/src/pivot_pane.rs | 20 ++- crates/xlsx/src/pivot.rs | 78 ++++++---- crates/xlsx/src/tests/pivot_tests.rs | 67 +++++++++ docs/gaps.md | 6 +- docs/target-app-parity.md | 7 +- 8 files changed, 513 insertions(+), 86 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 0d73b0a..5a35c10 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Move Field", [], None, - "{pivot?, field, from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}", + "{pivot?, field (\"Σ Values\" moves the value fields between rows and columns), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}", has_doc, move_field ), @@ -333,6 +333,70 @@ fn in_axes(pt: &PivotTable, field: &str) -> bool { pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field)) } +/// Name of the Σ Values pseudo-field (where the value fields sit when there are two or more). +const VALUES_FIELD: &str = "Σ Values"; + +/// Whether `field` names Σ Values: `Σ Values`, or `Values` unless a source field has that name. +fn is_values_field(src: &pv::Source, field: &str) -> bool { + let f = field.trim(); + f == VALUES_FIELD || (f.eq_ignore_ascii_case("values") && src.col(f).is_none()) +} + +/// Keeps Σ Values in place after a field left (`inserted` false) or arrived at index `i` of +/// Rows (`rows`) or Columns. +fn shift_values(pt: &mut PivotTable, rows: bool, i: usize, inserted: bool) { + if pt.values_on_rows != rows { + return; + } + let Some(p) = pt.values_position else { return }; + // Σ Values stays just ahead of the field it preceded. + let p = match inserted { + true if i <= p => p + 1, + false if i < p => p - 1, + _ => p, + }; + let n = if rows { pt.rows.len() } else { pt.columns.len() }; + pt.values_position = (p < n).then_some(p); +} + +/// Takes `field` out of Rows and Columns (Σ Values keeps its place); returns its first entry. +fn take_from_axes(pt: &mut PivotTable, field: &str) -> Option { + let mut first = None; + for rows in [true, false] { + loop { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let Some(i) = list.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)) else { break }; + let f = list.remove(i); + shift_values(pt, rows, i, false); + first.get_or_insert(f); + } + } + first +} + +/// Inserts `f` into Rows (`rows`) or Columns at `position` (default: the end). +fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: Option) { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let at = position.unwrap_or(list.len()).min(list.len()); + list.insert(at, f); + shift_values(pt, rows, at, true); +} + +/// Moves Σ Values between Rows and Columns, or within one, to `position` (default: the end). +fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option) -> Result { + let current = if pt.values_on_rows { "rows" } else { "columns" }; + if from != current { + return Err(bad(cmd, format!("`{VALUES_FIELD}` isn't in the {from} area"))); + } + if !matches!(to, "rows" | "columns") { + return Err(other("The field you are moving cannot be placed in that PivotTable area.")); + } + pt.values_on_rows = to == "rows"; + let n = if pt.values_on_rows { pt.rows.len() } else { pt.columns.len() }; + pt.values_position = position.filter(|p| *p < n); + Ok(json!({"field": VALUES_FIELD, "to": to})) +} + /// The canonical source field name for `field`. fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result { src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data"))) @@ -384,21 +448,11 @@ fn add_to_area( if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) { return Err(other(format!("A PivotTable can have at most {} row and column fields.", pv::MAX_AXIS_FIELDS))); } - let existing = pt - .rows - .iter() - .chain(pt.columns.iter()) - .find(|f| f.source_col.eq_ignore_ascii_case(&field)) - .cloned() - .unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() }); - pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); - pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); + let existing = take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() }); // A report filter that selects nothing in particular goes away; a selection stays // as the field's hidden items. pt.filters.retain(|f| !(f.source_col.eq_ignore_ascii_case(&field) && f.selected.is_none())); - let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns }; - let at = position.unwrap_or(list.len()).min(list.len()); - list.insert(at, existing); + insert_axis_field(pt, area == "rows", existing, position); } "values" => { if pt.values.len() >= pv::MAX_VALUE_FIELDS { @@ -421,8 +475,7 @@ fn add_to_area( pt.values.insert(at, v); } _ => { - pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); - pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field)); + take_from_axes(pt, &field); let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); let entry = match existing { Some(i) => pt.filters.remove(i), @@ -439,12 +492,14 @@ fn add_to_area( fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result { match area { "rows" | "columns" => { - let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns }; + let rows = area == "rows"; + let list = if rows { &mut pt.rows } else { &mut pt.columns }; let i = list .iter() .position(|f| f.source_col.eq_ignore_ascii_case(field)) .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the {area} area")))?; let f = list.remove(i); + shift_values(pt, rows, i, false); pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&f.source_col)); Ok(f.source_col) } @@ -699,6 +754,8 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { "style": pt.style, "showHeaders": pt.show_headers, "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), + "valuesOnRows": pt.values_on_rows, + "valuesPosition": pt.values_position, }) } @@ -890,18 +947,22 @@ fn move_field(s: &mut Session, p: &Json) -> Result { let to = area_param(C, str_param(p, "to"))?; let pos = position_param(p); update(s, p, |pt, src, _| { + if is_values_field(src, &field) { + return move_values_field(C, pt, from, to, pos); + } if from == to { // Reorder within the area. match from { "rows" | "columns" => { - let list = if from == "rows" { &mut pt.rows } else { &mut pt.columns }; + let rows = from == "rows"; + let list = if rows { &mut pt.rows } else { &mut pt.columns }; let i = list .iter() .position(|f| f.source_col.eq_ignore_ascii_case(&field)) .ok_or_else(|| bad(C, format!("`{field}` isn't in the {from} area")))?; let f = list.remove(i); - let at = pos.unwrap_or(list.len()).min(list.len()); - list.insert(at, f); + shift_values(pt, rows, i, false); + insert_axis_field(pt, rows, f, pos); } "values" => { let i = pt @@ -1722,6 +1783,113 @@ mod tests { assert_eq!(text(&s, "A5"), "Apples"); } + #[test] + fn values_on_rows_layouts() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap(); + assert_eq!(text(&s, "C3"), "Sum of Units"); + // Σ Values after Region: each region holds one line per value field. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + let p = pt(&s); + assert!(p.values_on_rows); + assert_eq!(p.values_position, None); + let r = p.last_range.unwrap(); + assert_eq!(r.width(), 2); + // header + 3 × (region + 2 values) + 2 grand totals + assert_eq!(r.height(), 1 + 9 + 2); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(val(&s, "B4"), Value::Empty); + assert_eq!(text(&s, "A5"), "Sum of Sales"); + assert_eq!(val(&s, "B5"), Value::Number(415.0)); + assert_eq!(text(&s, "A6"), "Sum of Units"); + assert_eq!(val(&s, "B6"), Value::Number(33.0)); + assert_eq!(text(&s, "A13"), "Total Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(TOTAL)); + assert_eq!(text(&s, "A14"), "Total Sum of Units"); + assert_eq!(val(&s, "B14"), Value::Number(72.0)); + // Σ Values before Region: the value fields are the outer items. + s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, Some(0)); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(val(&s, "B4"), Value::Empty); + assert_eq!(text(&s, "A5"), "East"); + assert_eq!(val(&s, "B5"), Value::Number(415.0)); + assert_eq!(text(&s, "A8"), "Sum of Units"); + assert_eq!(text(&s, "A9"), "East"); + assert_eq!(val(&s, "B9"), Value::Number(33.0)); + assert_eq!(text(&s, "A12"), "Total Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(72.0)); + // Adding a row field in front keeps Σ Values ahead of Region. + s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, Some(1)); + s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap(); + assert_eq!(pt(&s).values_position, Some(0)); + // No row field: one line per value field and no grand total. + s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap(); + let r = pt(&s).last_range.unwrap(); + assert_eq!(r.height(), 1 + 2); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(val(&s, "B4"), Value::Number(TOTAL)); + assert_eq!(text(&s, "A5"), "Sum of Units"); + assert_eq!(val(&s, "B5"), Value::Number(72.0)); + // Back to Columns. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap(); + assert!(!pt(&s).values_on_rows); + assert_eq!(text(&s, "A3"), "Sum of Sales"); + assert_eq!(text(&s, "B3"), "Sum of Units"); + assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err()); + assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err()); + } + + #[test] + fn values_on_rows_between_row_fields() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap(); + // Compact: East, then per value field its products, then per-value subtotals of East. + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "A5"), "Sum of Sales"); + assert_eq!(text(&s, "A6"), "Apples"); + assert_eq!(val(&s, "B6"), Value::Number(160.0)); + assert_eq!(text(&s, "A9"), "Sum of Units"); + assert_eq!(text(&s, "A10"), "Apples"); + assert_eq!(val(&s, "B10"), Value::Number(16.0)); + assert_eq!(text(&s, "A13"), "East Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(415.0)); + assert_eq!(text(&s, "A14"), "East Sum of Units"); + assert_eq!(val(&s, "B14"), Value::Number(33.0)); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2); + // Outline: one column per level, "Values" heads the Σ Values column. + s.execute("pivot.layout", json!({"layout": "outline"})).unwrap(); + assert_eq!(pt(&s).last_range.unwrap().width(), 4); + assert_eq!(text(&s, "A3"), "Region"); + assert_eq!(text(&s, "B3"), "Values"); + assert_eq!(text(&s, "C3"), "Product"); + assert_eq!(text(&s, "B5"), "Sum of Sales"); + assert_eq!(text(&s, "C6"), "Apples"); + assert_eq!(val(&s, "D6"), Value::Number(160.0)); + // Tabular: labels start on their parent's row; totals per value field below each group. + s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "B4"), "Sum of Sales"); + assert_eq!(text(&s, "C4"), "Apples"); + assert_eq!(val(&s, "D4"), Value::Number(160.0)); + assert_eq!(text(&s, "B7"), "Sum of Units"); + assert_eq!(text(&s, "C7"), "Apples"); + assert_eq!(val(&s, "D7"), Value::Number(16.0)); + assert_eq!(text(&s, "A10"), "East Sum of Sales"); + assert_eq!(val(&s, "D10"), Value::Number(415.0)); + assert_eq!(text(&s, "A11"), "East Sum of Units"); + let r = pt(&s).last_range.unwrap(); + let last = r.end.row + 1; + assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales"); + assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units"); + assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0)); + } + #[test] fn refresh_after_source_edits() { let mut s = sales(); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index c76bf4b..ed381a3 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -560,6 +560,8 @@ struct Line { values: bool, /// (column, value, indent, number format) labels: Vec<(usize, Value, u8, Option)>, + /// The value field of the line when Σ Values sits in Rows (`None`: one per data column). + vi: Option, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -579,6 +581,10 @@ struct Ctx<'a> { rows: Vec<&'a PivotField>, row_items: Vec, row_fmts: Vec, + /// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields). + vpos: Option, + /// Value field captions. + val_names: Vec, } impl Ctx<'_> { @@ -588,26 +594,102 @@ impl Ctx<'_> { (it.value.clone(), fmt, it.label.clone()) } - fn flatten(&self, nodes: &[Node], depth: usize, prefix: &mut Vec, out: &mut Vec) { + /// (column, indent) of a row level (Σ Values counts as a level) in Compact/Outline form. + fn level_cell(&self, level: usize) -> (usize, u8) { + if self.pt.layout == PivotLayout::Outline { (level, 0) } else { (0, level.min(250) as u8) } + } + + /// Σ Values comes right here, at row field depth `depth` (`vi`: the value field already chosen above). + fn values_here(&self, depth: usize, vi: Option) -> bool { + vi.is_none() && self.vpos == Some(depth) + } + + /// Per-value subtotal lines of an item with Σ Values below it ("East Sum of Sales"). + fn value_subtotals(&self, col: usize, indent: u8, label: &str, prefix: &[u32], out: &mut Vec) { + for (vi, name) in self.val_names.iter().enumerate() { + out.push(Line { + kind: LineKind::Subtotal, + leaf: false, + path: prefix.to_vec(), + values: true, + labels: vec![(col, Value::text(format!("{label} {name}")), indent, None)], + vi: Some(vi), + }); + } + } + + /// The Σ Values level at row field depth `depth`: one item per value field, each followed by + /// the row fields below it (`nodes`) or, innermost, holding that value. + fn flatten_values(&self, nodes: &[Node], depth: usize, prefix: &mut Vec, out: &mut Vec) { + let (col, indent) = self.level_cell(depth); + for (vi, name) in self.val_names.iter().enumerate() { + let labels = vec![(col, Value::text(name.as_str()), indent, None)]; + let leaf = nodes.is_empty(); + out.push(Line { kind: LineKind::Item(!leaf), leaf, path: prefix.clone(), values: leaf, labels, vi: Some(vi) }); + if !leaf { + self.flatten(nodes, depth, Some(vi), prefix, out); + } + } + } + + fn flatten(&self, nodes: &[Node], depth: usize, vi: Option, prefix: &mut Vec, out: &mut Vec) { + if self.values_here(depth, vi) { + self.flatten_values(nodes, depth, prefix, out); + return; + } let n = self.rows.len(); - let outline = self.pt.layout == PivotLayout::Outline; + // Σ Values still to come below this level: totals need one line per value field. + let values_below = vi.is_none() && self.vpos.is_some(); + let (col, indent) = self.level_cell(depth + usize::from(vi.is_some() && self.vpos.is_some())); for node in nodes { prefix.push(node.item); let (v, fmt, label) = self.label_cell(depth, node.item); - let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) }; let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); - if depth + 1 >= n || collapsed || node.children.is_empty() { + let leaf = depth + 1 >= n || collapsed || node.children.is_empty(); + if leaf && !values_below { out.push(Line { kind: LineKind::Item(depth + 1 < n), leaf: true, path: prefix.clone(), values: true, labels: vec![(col, v, indent, fmt)], + vi, }); + } else if leaf { + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: false, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten_values(&[], depth + 1, prefix, out); + } else if values_below { + // Subtotals per value field, below the group (one line can't hold them all). + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: false, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten(&node.children, depth + 1, None, prefix, out); + if self.pt.subtotals { + self.value_subtotals(col, indent, &label, prefix, out); + } } else { let top = self.pt.subtotals && self.pt.subtotals_top; - out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] }); - self.flatten(&node.children, depth + 1, prefix, out); + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: top, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten(&node.children, depth + 1, vi, prefix, out); if self.pt.subtotals && !self.pt.subtotals_top { out.push(Line { kind: LineKind::Subtotal, @@ -615,6 +697,7 @@ impl Ctx<'_> { path: prefix.clone(), values: true, labels: vec![(col, Value::text(format!("{label} Total")), indent, None)], + vi, }); } } @@ -622,7 +705,8 @@ impl Ctx<'_> { } } - fn flatten_tabular( + /// Tabular form of [`Self::flatten_values`]: the first line also carries the parents' labels. + fn flatten_tabular_values( &self, nodes: &[Node], depth: usize, @@ -630,24 +714,57 @@ impl Ctx<'_> { pending: &[(usize, Value, u8, Option)], out: &mut Vec, ) { + for (vi, name) in self.val_names.iter().enumerate() { + let mut labels: Vec<(usize, Value, u8, Option)> = if vi == 0 { pending.to_vec() } else { vec![] }; + labels.push((depth, Value::text(name.as_str()), 0, None)); + if nodes.is_empty() { + out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi: Some(vi) }); + } else { + self.flatten_tabular(nodes, depth, Some(vi), prefix, &labels, out); + } + } + } + + fn flatten_tabular( + &self, + nodes: &[Node], + depth: usize, + vi: Option, + prefix: &mut Vec, + pending: &[(usize, Value, u8, Option)], + out: &mut Vec, + ) { + if self.values_here(depth, vi) { + self.flatten_tabular_values(nodes, depth, prefix, pending, out); + return; + } let n = self.rows.len(); + let values_below = vi.is_none() && self.vpos.is_some(); + let col = depth + usize::from(vi.is_some() && self.vpos.is_some()); for (i, node) in nodes.iter().enumerate() { prefix.push(node.item); let (v, fmt, label) = self.label_cell(depth, node.item); let mut labels: Vec<(usize, Value, u8, Option)> = if i == 0 { pending.to_vec() } else { vec![] }; - labels.push((depth, v, 0, fmt)); + labels.push((col, v, 0, fmt)); let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); if depth + 1 >= n || collapsed || node.children.is_empty() { - out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels }); + if values_below { + self.flatten_tabular_values(&[], depth + 1, prefix, &labels, out); + } else { + out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi }); + } } else { - self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out); - if self.pt.subtotals { + self.flatten_tabular(&node.children, depth + 1, vi, prefix, &labels, out); + if self.pt.subtotals && values_below { + self.value_subtotals(col, 0, &label, prefix, out); + } else if self.pt.subtotals { out.push(Line { kind: LineKind::Subtotal, leaf: false, path: prefix.clone(), values: true, - labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)], + labels: vec![(col, Value::text(format!("{label} Total")), 0, None)], + vi, }); } } @@ -745,6 +862,14 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result = (pt.values_on_rows && k > 1).then(|| { + let p = pt.values_position.unwrap_or(pt.rows.len()); + pt.rows.iter().take(p).filter(|f| src.col(&f.source_col).is_some()).count() + }); + let on_rows = vpos.is_some(); + let row_levels = rows.len() + usize::from(on_rows); // ---- aggregate let mut row_items: Vec = rows.iter().map(|_| FieldItems::default()).collect(); @@ -806,24 +931,32 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result = row_tree.iter().map(|n| n.item).collect(); - let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() }; + let val_names: Vec = vals.iter().map(|(_, v)| v.name.clone()).collect(); + let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone(), vpos, val_names: val_names.clone() }; let mut lines: Vec = Vec::new(); - if rows.is_empty() { - lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] }); + if row_levels == 0 { + lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![], vi: None }); } else { if pt.layout == PivotLayout::Tabular { - ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines); + ctx.flatten_tabular(&row_tree, 0, None, &mut Vec::new(), &[], &mut lines); } else { - ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines); + ctx.flatten(&row_tree, 0, None, &mut Vec::new(), &mut lines); } - if pt.grand_totals_cols { - lines.push(Line { + // With only Σ Values in Rows its items are the totals already. + if pt.grand_totals_cols && !rows.is_empty() { + let grand = |text: String, vi: Option| Line { kind: LineKind::Grand, leaf: false, path: vec![], values: true, - labels: vec![(0, Value::text("Grand Total"), 0, None)], - }); + labels: vec![(0, Value::text(text), 0, None)], + vi, + }; + if on_rows { + lines.extend(val_names.iter().enumerate().map(|(vi, name)| grand(format!("Total {name}"), Some(vi)))); + } else { + lines.push(grand("Grand Total".into(), None)); + } } } let mut specs: Vec = Vec::new(); @@ -837,7 +970,10 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result)> = Vec::new(); for (si, _) in specs.iter().enumerate() { - if k == 0 { + if on_rows { + // Each line names its value field; one data column per column item. + data_cols.push((si, None)); + } else if k == 0 { if !cols.is_empty() { data_cols.push((si, None)); } @@ -852,12 +988,12 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result Result = row_fields.iter().map(|f| f.source_col.as_str()).collect(); + if let Some(p) = vpos { + names.insert(p.min(names.len()), "Values"); + } + for (i, name) in names.into_iter().enumerate() { + g.set(r, i, Value::text(name), CellKind::Header, 0, None); } } }; @@ -933,7 +1074,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result 1); + let levels = cols.len() + usize::from(k > 1 && !on_rows); let first_level = g.rows.len(); for _ in 0..levels { g.push_row(RowKind::Header)?; @@ -1048,17 +1189,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result 1 || parts.is_empty()) { parts.push(val_name(*vi)); + } else if parts.is_empty() { + parts.push("Total".into()); } Some(((l + j) as u32, parts.join(" - "))) }) .filter(|_| k > 0) .collect(); - let label_col = if rows.is_empty() { + let label_col = if row_levels == 0 { None } else if compact { Some(0) } else { - Some(rows.len().saturating_sub(1) as u32) + Some(row_levels.saturating_sub(1) as u32) }; for line in &lines { let rk = match line.kind { @@ -1082,8 +1225,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result, /// Calculated fields, usable in the Values area by name. pub calculated_fields: Vec, + /// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather + /// than Columns (Excel's "values on rows"). + pub values_on_rows: bool, + /// Position of Σ Values among the fields of its area (`None` = after the last). + pub values_position: Option, } /// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in @@ -1007,6 +1012,8 @@ impl Default for PivotTable { show_headers: true, last_range: None, calculated_fields: vec![], + values_on_rows: false, + values_position: None, } } } diff --git a/crates/ui-egui/src/pivot_pane.rs b/crates/ui-egui/src/pivot_pane.rs index 14d69db..0657026 100644 --- a/crates/ui-egui/src/pivot_pane.rs +++ b/crates/ui-egui/src/pivot_pane.rs @@ -64,13 +64,26 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }) .unwrap_or_default() }; + // Σ Values (two or more value fields) sits in Rows or Columns; it moves only between them. + const VALUES_FIELD: &str = "Σ Values"; + let values_key = if info["valuesOnRows"].as_bool().unwrap_or(false) { "rows" } else { "columns" }; + let values_at = info["valuesPosition"].as_u64().map(|p| p as usize); + let area_with_values = |key: &str| -> Vec { + let mut v = area(key); + if key == values_key && area("values").len() > 1 { + let at = values_at.unwrap_or(v.len()).min(v.len()); + v.insert(at, VALUES_FIELD.to_string()); + } + v + }; ui.columns(2, |cols| { for (i, (key, label)) in [("filters", "Filters"), ("columns", "Columns"), ("rows", "Rows"), ("values", "Values")].iter().enumerate() { let ui = &mut cols[i % 2]; ui.label(egui::RichText::new(*label).strong()); egui::Frame::NONE.fill(t.window).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| { ui.set_min_size(vec2(ui.available_width(), 60.0)); - for f in area(key) { + for f in area_with_values(key) { + let is_values = f == VALUES_FIELD; let b = ui.add( egui::Button::new(egui::RichText::new(format!("{f} ▾")).font(theme::ui_font(12.0))) .fill(Color32::WHITE) @@ -83,10 +96,13 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { ("filters", "Move to Report Filter"), ("values", "Move to Values"), ] { - if to != *key && ui.button(l).clicked() { + if to != *key && (!is_values || matches!(to, "rows" | "columns")) && ui.button(l).clicked() { app.run_or_alert("pivot.moveField", json!({"pivot": name, "field": f, "from": key, "to": to})); } } + if is_values { + return; + } if *key == "values" { ui.separator(); for (func, l) in [("sum", "Sum"), ("count", "Count"), ("average", "Average"), ("max", "Max"), ("min", "Min")] { diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 1d16ab3..0c480ed 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -810,6 +810,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn PivotLayout::Outline => s.push_str(" compact=\"0\" compactData=\"0\" outline=\"1\" outlineData=\"1\""), PivotLayout::Tabular => s.push_str(" compact=\"0\" compactData=\"0\""), } + if pt.values_on_rows { + s.push_str(" dataOnRows=\"1\""); + } if !pt.show_headers { s.push_str(" showHeaders=\"0\""); } @@ -826,13 +829,19 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn let end = pt.last_range.map(|r| r.end).unwrap_or(a); let loc = RangeRef::new(a, CellRef::new(end.row.max(a.row), end.col.max(a.col))); let k = vals.len(); - let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(k > 1) }; - let first_data_col = if row_idx.is_empty() && col_idx.is_empty() { + // Σ Values (two or more value fields): its index among the written fields of its axis. + let (row_map, col_map) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len())); + let values_at = |map: &[Option], n: usize| pt.values_position.map_or(n, |p| map.iter().take(p).filter(|x| x.is_some()).count()); + let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, row_idx.len())); + let col_values = (k > 1 && !pt.values_on_rows).then(|| values_at(col_map, col_idx.len())); + let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(col_values.is_some()) }; + let row_levels = row_idx.len() + usize::from(row_values.is_some()); + let first_data_col = if row_levels == 0 && col_idx.is_empty() { 0 } else if compact { 1 } else { - row_idx.len().max(1) + row_levels.max(1) }; let _ = write!(s, ""); - if !row_idx.is_empty() { - let _ = write!(s, "", row_idx.len()); - for i in &row_idx { - let _ = write!(s, ""); - } - s.push_str(""); - } - let values_field = k > 1; - if !col_idx.is_empty() || values_field { - let _ = write!(s, "", col_idx.len() + usize::from(values_field)); - for i in &col_idx { - let _ = write!(s, ""); - } - if values_field { - s.push_str(""); - } - s.push_str(""); - } + axis_fields_xml(&mut s, "rowFields", &row_idx, row_values); + axis_fields_xml(&mut s, "colFields", &col_idx, col_values); if !pages.is_empty() { let _ = write!(s, "", pages.len()); for (i, f) in &pages { @@ -1011,6 +1004,25 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn s } +/// ``/``: the axis's cache field indices, with Σ Values (`-2`) at `values_at`. +fn axis_fields_xml(s: &mut String, tag: &str, idx: &[usize], values_at: Option) { + let n = idx.len() + usize::from(values_at.is_some()); + if n == 0 { + return; + } + let _ = write!(s, "<{tag} count=\"{n}\">"); + for (j, i) in idx.iter().enumerate() { + if values_at == Some(j) { + s.push_str(""); + } + let _ = write!(s, ""); + } + if values_at.is_some_and(|p| p >= idx.len()) { + s.push_str(""); + } + let _ = write!(s, ""); +} + // ================================================================ reading /// A cache field as read. @@ -1253,18 +1265,21 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option = Vec::new(); - let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> Vec { + // (fields, position of Σ Values (`-2`) among them) + let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> (Vec, Option) { let mut v = Vec::new(); + let mut values_at = None; for &i in list { + if i == -2 { + values_at = Some(v.len()); + continue; + } if i < 0 { continue; } @@ -1290,7 +1305,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option, x: &El, cache: &CacheIn, id: u32) -> Option Workbook { show_headers: false, last_range: Some(rr("A2:H12")), calculated_fields: vec![], + values_on_rows: false, + values_position: None, }; // Pivot 2: table source, date grouping on two levels, show-values-as, multi-select page // filter and a hidden-items filter on a column field. @@ -166,6 +168,8 @@ fn workbook() -> Workbook { show_headers: true, last_range: Some(rr("K2:Z30")), calculated_fields: vec![], + values_on_rows: false, + values_position: None, }; // Pivot 3: defaults, defined-name source, compact. let p3 = PivotTable { @@ -508,3 +512,66 @@ fn calculated_fields_round_trip() { assert_eq!(a, b, "pivot {} differs", a.name); } } + +/// The `x` attributes of an axis list (`rowFields`/`colFields`) in a pivotTable part. +fn axis_xs(table: &str, tag: &str) -> Vec { + let Some(start) = table.find(&format!("<{tag} ")) else { return vec![] }; + let end = table[start..].find(&format!("")).map_or(table.len(), |e| start + e); + table[start..end].split(""#; + let (wb, report) = read_xlsx(&with_pivot(table, Some(GOOD_CACHE))).unwrap(); + assert!(report.warnings.iter().all(|w| !w.contains("values on rows")), "{:?}", report.warnings); + let pt = &wb.sheet(0).unwrap().pivots[0]; + assert!(pt.values_on_rows); + assert_eq!(pt.values_position, None); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert!(back.sheet(0).unwrap().pivots[0].values_on_rows); +} diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b8..b5614a4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (§8: PivotTable values on rows) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -79,7 +79,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, - `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields done). + `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields + and values on rows done). - **Impact:** pivots are the analyst's main tool. - **Estimate:** 20–30 h for beta (25–40 h full). @@ -160,6 +161,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | §8: values on rows (Σ Values in Rows, `dataOnRows`) laid out, moved and round-tripped | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8b82092..1c3f044 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (stability: command-sequence fuzzing against undo/redo, XLSX round-trip and full-recalc invariants) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (PivotTables: values on rows) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -84,7 +84,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), Insert Copied Cells missing (#21), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) | | Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes | | Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | -| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | +| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, values on rows or columns, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts). Missing: Solver, Forecast Sheet, Analyze Data | @@ -301,7 +301,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land average, colour), tables with totals rows, remove duplicates, text to columns, data validation with error alerts, grouping/outline, subtotals. - PivotTables: Insert/Recommended PivotTables, field list pane, compact/outline/tabular layouts, - date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip. + values on rows or columns, date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip. - Page layout & print: Page Break Preview, print settings, PDF export and printing. - What-if: Goal Seek, scenarios, data tables. Spelling (system word list) and thesaurus. - Draw: pen, highlighter, eraser, ink strokes and Ink to Shape. Task panes: Comments (threaded @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | | 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | | 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h | | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | From 857f538c08e3c18190b2fab53b691aef70935f74 Mon Sep 17 00:00:00 2001 From: dantebertuzzi Date: Mon, 12 Oct 2026 13:15:06 -0300 Subject: [PATCH 088/162] cell.set / set_cell: accept a number for `input` (#697) `input` is documented as "text, number or =formula", but only a string was read; a JSON number was dropped and the command failed (or, on 0.5.0, silently cleared the cell). The undocumented `value` alias already went through `json_to_input`. Both keys now share that converter, so numbers and booleans are entered as typed. The MCP `set_cell` schema types `input` as string, number or boolean to match its description. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 24 +++++++++++++++++++----- crates/mcp/src/tests.rs | 3 +++ crates/mcp/src/tools.rs | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3da230b..a2e8c47 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -219,11 +219,8 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { Some(c) => c, None => s.doc()?.selection.active, }; - let input = str_param(p, "input") - .or_else(|| str_param(p, "value")) - .map(str::to_string) - .or_else(|| p.get("value").map(json_to_input)) - .ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; + // Both spellings take a string, number or boolean, as typed (#697). + let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; let array = bool_param(p, "array").unwrap_or(false); let d = s.doc()?; if let Some(sh) = d.wb.sheet(sheet) @@ -1583,6 +1580,23 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result { mod tests { use super::*; + #[test] + fn cell_set_takes_numbers_for_input_and_value() { + // #697: a JSON number for `input` used to clear the cell instead of entering it. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap(); + s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!(123.5)); + assert_eq!(get(&mut s, "A2")["value"], json!(77.0)); + assert_eq!(get(&mut s, "A3")["value"], json!(true)); + assert_eq!(get(&mut s, "A4")["value"], json!(200.5)); + assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); + } + #[test] fn legacy_array_entry() { let mut s = Session::new(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index eb1f205..40f031f 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -202,6 +202,9 @@ fn basic_tools() { assert!(f.as_array().unwrap().iter().any(|x| x["name"] == "VLOOKUP")); assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "SUM(1,2,3)"}))["result"], json!(6.0)); ok(&mut s, "set_cell", json!({"cell": "A1", "input": "5"})); + // #697: a number for `input` is entered, not rejected or cleared. + ok(&mut s, "set_cell", json!({"cell": "B1", "input": 42.5})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B1"}))["value"], json!(42.5)); ok(&mut s, "execute_command", json!({"command": "cell.set", "params": {"cell": "A2", "input": "=A1*2"}})); assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A2"}))["value"], json!(10.0)); assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=A1+A2", "cell": "C1"}))["result"], json!(15.0)); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index eb741b8..eed4cd7 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -180,7 +180,7 @@ pub fn tool_definitions() -> Vec { "Enter one cell exactly as if typed: a number, text, or a formula starting with = (\"=AVERAGE(B2:B9)\"). \ Dependent cells recalculate. Returns the command result; use get_cell to read the computed value.", obj( - json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": string("Typed input: number, text or =formula")}), + json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": {"type": ["string", "number", "boolean"], "description": "Typed input: number, text or =formula"}}), &["cell", "input"], ), false, From de04cbe917017e480dba853960efe0db7536adc2 Mon Sep 17 00:00:00 2001 From: dantebertuzzi Date: Mon, 12 Oct 2026 13:29:17 -0300 Subject: [PATCH 089/162] i18n(pt-BR): translate 546 more labels, dialog strings and tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brazilian Portuguese had 377 catalog entries, so most dialogs, menus and tooltips showed in English with the interface set to Português (Brasil). This adds every key the other catalogs translate (counting the open Spanish-dialog #669, Russian #676, Czech #652, Polish #650, Turkish #663 and Italian #713 PRs), bringing pt-BR to 923 entries. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/pt.rs | 546 ++++++++++++++++++++++++++++++++++ docs/localization-parity.md | 5 +- 2 files changed, 549 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index b554ef9..a02fd07 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -394,6 +394,552 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Cálculo da pasta de trabalho", "Enable iterative calculation" => "Habilitar cálculo iterativo", "Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.", + "OK" => "OK", + "Cancel" => "Cancelar", + "Don't Save" => "Não Salvar", + "Open…" => "Abrir…", + "Show password" => "Mostrar senha", + "Start a new workbook" => "Criar uma pasta de trabalho", + "Household Budget" => "Orçamento Doméstico", + "Quarterly Sales Dashboard" => "Painel de Vendas Trimestrais", + "Gradebook" => "Boletim de Notas", + "this workbook" => "esta pasta de trabalho", + "This workbook" => "Esta pasta de trabalho", + "Do you want to save the changes you made to {title}?" => "Deseja salvar as alterações feitas em {title}?", + "Save a copy as .{format}" => "Salvar uma cópia como .{format}", + "Select a chart first." => "Selecione um gráfico primeiro.", + "Text Box" => "Caixa de Texto", + "Select Data" => "Selecionar Dados", + "Row Height" => "Altura da Linha", + "Column Width" => "Largura da Coluna", + "Standard Width" => "Largura Padrão", + "Rename Sheet" => "Renomear Planilha", + "Move or Copy" => "Mover ou Copiar", + "Protect Sheet" => "Proteger Planilha", + "Protect Structure and Windows" => "Proteger Estrutura e Janelas", + "Unprotect Workbook" => "Desproteger Pasta de Trabalho", + "Unprotect Sheet" => "Desproteger Planilha", + "Password" => "Senha", + "Unhide" => "Reexibir", + "Paste Special" => "Colar Especial", + "Series" => "Série", + "New Comment" => "Novo Comentário", + "Insert Hyperlink" => "Inserir Hiperlink", + "Create Sparklines" => "Criar Minigráficos", + "Page Setup" => "Configurar Página", + "Convert Text to Columns" => "Converter Texto em Colunas", + "Remove Duplicates" => "Remover Duplicatas", + "Goal Seek" => "Atingir Meta", + "Create Forecast Worksheet" => "Criar Planilha de Previsão", + "Solver Parameters" => "Parâmetros do Solver", + "Solver Results" => "Resultados do Solver", + "Edit Name" => "Editar Nome", + "New Name" => "Novo Nome", + "Format Cells" => "Formatar Células", + "Formula Builder" => "Construtor de Fórmulas", + "Find and Replace" => "Localizar e Substituir", + "Go To" => "Ir para", + "Insert Calculated Field" => "Inserir Campo Calculado", + "Go To Special" => "Ir para Especial", + "Name Manager" => "Gerenciador de Nomes", + "Conditional Formatting Rules Manager" => "Gerenciador de Regras de Formatação Condicional", + "Search Commands" => "Pesquisar Comandos", + "Agent Control" => "Controle por Agentes", + "About GridCraft" => "Sobre o GridCraft", + "Insert Chart" => "Inserir Gráfico", + "Select Data Source" => "Selecionar Fonte de Dados", + "Action Journal" => "Registro de Ações", + "Macros" => "Macros", + "Workbook Statistics" => "Estatísticas da Pasta de Trabalho", + "Accessibility" => "Acessibilidade", + "Chart Title" => "Título do Gráfico", + "Place Picture in Cell" => "Colocar Imagem na Célula", + "Place Picture over Cells" => "Colocar Imagem sobre as Células", + "Pick From List" => "Escolher na Lista", + "Save a Copy" => "Salvar uma Cópia", + "Save Changes?" => "Salvar Alterações?", + "Shift cells right" => "Deslocar células para a direita", + "Shift cells down" => "Deslocar células para baixo", + "Shift cells left" => "Deslocar células para a esquerda", + "Shift cells up" => "Deslocar células para cima", + "Entire row" => "Linha inteira", + "Entire column" => "Coluna inteira", + "Row height (pixels):" => "Altura da linha (pixels):", + "Column width (characters):" => "Largura da coluna (caracteres):", + "Standard column width (pixels):" => "Largura padrão da coluna (pixels):", + "Name:" => "Nome:", + "Before sheet (position, 0 = first):" => "Antes da planilha (posição, 0 = primeira):", + "Create a copy" => "Criar uma cópia", + "Sheets: {sheets}" => "Planilhas: {sheets}", + "Password (optional):" => "Senha (opcional):", + "Password:" => "Senha:", + "Allow all users of this sheet to:" => "Permitir que todos os usuários desta planilha possam:", + "Format cells" => "Formatar células", + "Format columns" => "Formatar colunas", + "Format rows" => "Formatar linhas", + "Insert rows" => "Inserir linhas", + "Delete rows" => "Excluir linhas", + "Use AutoFilter" => "Usar AutoFiltro", + " (password, unlocked)" => " (senha, desbloqueado)", + " (password)" => " (senha)", + "No ranges yet." => "Nenhum intervalo ainda.", + "Ranges that stay editable when the sheet is protected:" => "Intervalos que continuam editáveis quando a planilha está protegida:", + "Action" => "Ação", + "Modify" => "Modificar", + "Unlock with password" => "Desbloquear com senha", + "Title:" => "Título:", + "Refers to cells:" => "Refere-se às células:", + "Range password:" => "Senha do intervalo:", + "New title (Modify):" => "Novo título (Modificar):", + "Remove the password (Modify)" => "Remover a senha (Modificar)", + "'{file}' is protected." => "'{file}' está protegido.", + "Unhide sheet:" => "Reexibir planilha:", + "Hidden: {sheets}" => "Ocultas: {sheets}", + "All" => "Tudo", + "Formulas (F)" => "Fórmulas (F)", + "Values (V)" => "Valores (V)", + "Formats (T)" => "Formatos (T)", + "Comments and notes (C)" => "Comentários e notas (C)", + "Validation" => "Validação", + "All except borders" => "Tudo, exceto bordas", + "Column widths" => "Larguras da coluna", + "Formulas and number formats" => "Fórmulas e formatos de número", + "Values and number formats" => "Valores e formatos de número", + "Operation" => "Operação", + "Add" => "Adicionar", + "Subtract" => "Subtrair", + "Multiply" => "Multiplicar", + "Divide" => "Dividir", + "Skip blanks" => "Ignorar em branco", + "Transpose (E)" => "Transpor (E)", + "Series in" => "Série em", + "Columns" => "Colunas", + "Rows" => "Linhas", + "Type" => "Tipo", + "Linear" => "Linear", + "Growth" => "Crescimento", + "Date unit" => "Unidade de data", + "Day" => "Dia", + "Weekday" => "Dia da semana", + "Month" => "Mês", + "Year" => "Ano", + "Step value:" => "Incremento:", + "Stop value:" => "Limite:", + "Start a conversation:" => "Iniciar uma conversa:", + "Note:" => "Nota:", + "Display:" => "Texto para exibição:", + "Link to (web address, e-mail or Sheet!A1):" => "Vincular a (endereço da Web, e-mail ou Planilha!A1):", + "ScreenTip:" => "Dica de tela:", + "Data Range:" => "Intervalo de dados:", + "Location Range:" => "Intervalo de local:", + "Markers" => "Marcadores", + "Footer:" => "Rodapé:", + "Left margin (in):" => "Margem esquerda (pol.):", + "Right margin (in):" => "Margem direita (pol.):", + "Top margin (in):" => "Margem superior (pol.):", + "Bottom margin (in):" => "Margem inferior (pol.):", + "Header (in):" => "Cabeçalho (pol.):", + "Footer (in):" => "Rodapé (pol.):", + "Tab" => "Tabulação", + "Semicolon" => "Ponto e vírgula", + "Comma" => "Vírgula", + "Space" => "Espaço", + "Other:" => "Outros:", + "Treat consecutive delimiters as one" => "Considerar delimitadores consecutivos como um só", + "Destination:" => "Destino:", + "My data has headers" => "Meus dados contêm cabeçalhos", + "Columns (blank = all, e.g. A,C):" => "Colunas (em branco = todas, p. ex. A,C):", + "Timeline range (blank: first column of the selection):" => "Intervalo da linha do tempo (em branco: primeira coluna da seleção):", + "Values range (blank: second column of the selection):" => "Intervalo de valores (em branco: segunda coluna da seleção):", + "Confidence interval" => "Intervalo de confiança", + "Confidence level (%):" => "Nível de confiança (%):", + "Seasonality (auto, 0 for none, or a season length):" => "Sazonalidade (automática, 0 para nenhuma ou a duração de um período):", + "Fill missing points using" => "Preencher pontos ausentes usando", + "Interpolation" => "Interpolação", + "Zeros" => "Zeros", + "Aggregate duplicates using" => "Agregar duplicatas usando", + "Average" => "Média", + "Count" => "Contagem", + "CountA" => "Cont.Valores", + "Max" => "Máx", + "Median" => "Mediana", + "Min" => "Mín", + "Sum" => "Soma", + "Product" => "Produto", + "Include forecast statistics" => "Incluir estatísticas de previsão", + "Chart" => "Gráfico", + "Line chart" => "Gráfico de linhas", + "Column chart" => "Gráfico de colunas", + "At each change in (column letter):" => "A cada alteração em (letra da coluna):", + "Use function" => "Usar função", + "Add subtotal to (columns, e.g. C,D):" => "Adicionar subtotal a (colunas, p. ex. C,D):", + "Magnification" => "Ampliação", + "Custom (%):" => "Personalizado (%):", + "Scope (Workbook or sheet name):" => "Escopo (Pasta de Trabalho ou nome da planilha):", + "Comment:" => "Comentário:", + "Refers to:" => "Refere-se a:", + "Allow" => "Permitir", + "Any value" => "Qualquer valor", + "Whole number" => "Número inteiro", + "Decimal" => "Decimal", + "List" => "Lista", + "Text length" => "Comprimento do texto", + "between" => "está entre", + "not between" => "não está entre", + "equal to" => "é igual a", + "not equal to" => "é diferente de", + "greater than" => "é maior do que", + "less than" => "é menor do que", + "greater than or equal to" => "é maior ou igual a", + "less than or equal to" => "é menor ou igual a", + "Minimum / Source / Formula:" => "Mínimo / Fonte / Fórmula:", + "Maximum:" => "Máximo:", + "Ignore blank" => "Ignorar em branco", + "In-cell dropdown" => "Menu suspenso na célula", + "Input message:" => "Mensagem de entrada:", + "Error style" => "Estilo de erro", + "Stop" => "Parar", + "Warning" => "Aviso", + "Information" => "Informações", + "Error message:" => "Mensagem de erro:", + "Format cells that are (value):" => "Formatar células que são (valor):", + "and:" => "e:", + "Format cells that contain the text:" => "Formatar células que contêm o texto:", + "Format cells that contain a date occurring:" => "Formatar células que contêm uma data:", + "Yesterday" => "Ontem", + "Today" => "Hoje", + "Tomorrow" => "Amanhã", + "In the last 7 days" => "Nos últimos 7 dias", + "Last week" => "Semana passada", + "This week" => "Esta semana", + "Next week" => "Próxima semana", + "Last month" => "Mês passado", + "This month" => "Este mês", + "Next month" => "Próximo mês", + "Format values where this formula is true:" => "Formatar valores em que esta fórmula é verdadeira:", + "with" => "com", + "Light Red Fill with Dark Red Text" => "Preenchimento Vermelho Claro e Texto Vermelho Escuro", + "Yellow Fill with Dark Yellow Text" => "Preenchimento Amarelo e Texto Amarelo Escuro", + "Green Fill with Dark Green Text" => "Preenchimento Verde e Texto Verde Escuro", + "Light Red Fill" => "Preenchimento Vermelho Claro", + "Red Text" => "Texto Vermelho", + "Red Border" => "Borda Vermelha", + "Formula:" => "Fórmula:", + "Select" => "Selecionar", + "Blanks" => "Células em branco", + "Errors" => "Erros", + "Numbers" => "Números", + "Current region" => "Região atual", + "Last cell" => "Última célula", + "Visible cells only" => "Somente células visíveis", + "Conditional formats" => "Formatos condicionais", + "Data validation" => "Validação de dados", + "Horizontal axis title:" => "Título do eixo horizontal:", + "Vertical axis title:" => "Título do eixo vertical:", + "No picture selected" => "Nenhuma imagem selecionada", + "Picture file path (PNG or JPEG):" => "Caminho do arquivo de imagem (PNG ou JPEG):", + "Description (optional):" => "Descrição (opcional):", + "Fits inside the selected cell and moves with its content." => "Ajusta-se dentro da célula selecionada e move-se com o conteúdo dela.", + "Choose a PNG or JPEG no larger than 16 MB." => "Escolha um PNG ou JPEG de no máximo 16 MB.", + "About" => "Sobre", + "Contributors" => "Colaboradores", + "Models" => "Modelos", + "Version {version}" => "Versão {version}", + "Join the ArtCraft Discord" => "Participe do Discord da ArtCraft", + "This workbook has no scripts or macros." => "Esta pasta de trabalho não tem scripts nem macros.", + "(unnamed)" => "(sem nome)", + "— VBA project (preserved, not run)" => "— Projeto VBA (preservado, não executado)", + "— 1 step" => "— 1 etapa", + "— {steps} steps" => "— {steps} etapas", + "Alignment" => "Alinhamento", + "Font" => "Fonte", + "Border" => "Borda", + "Format Cells|Fill" => "Preenchimento", + "Protection" => "Proteção", + "Category:" => "Categoria:", + "Sample" => "Exemplo", + "Type (custom format code):" => "Tipo (código de formato personalizado):", + "Vertical" => "Vertical", + "Justify" => "Justificar", + "Center Across Selection" => "Centralizar Seleção", + "Distributed" => "Distribuído", + "Top" => "Superior", + "Bottom" => "Inferior", + "Indent" => "Recuo", + "Orientation (degrees)" => "Orientação (graus)", + "Wrap text" => "Quebrar texto automaticamente", + "Shrink to fit" => "Reduzir para caber", + "Bold" => "Negrito", + "Italic" => "Itálico", + "None" => "Nenhum", + "Single" => "Simples", + "Double" => "Duplo", + "Single Accounting" => "Simples (Contábil)", + "Double Accounting" => "Duplo (Contábil)", + "Superscript" => "Sobrescrito", + "Subscript" => "Subscrito", + "Color:" => "Cor:", + "Outline" => "Contorno", + "Diagonal ↘" => "Diagonal ↘", + "Diagonal ↗" => "Diagonal ↗", + "none" => "nenhuma", + "thin" => "fina", + "medium" => "média", + "thick" => "espessa", + "dashed" => "tracejada", + "dotted" => "pontilhada", + "double" => "dupla", + "Background Color:" => "Cor do plano de fundo:", + "Locked" => "Bloqueadas", + "Hidden" => "Ocultas", + "Search:" => "Pesquisar:", + "e.g. lookup, average, date" => "p. ex. pesquisa, média, data", + "Date & Time" => "Data e Hora", + "Lookup & Reference" => "Pesquisa e Referência", + "Math & Trig" => "Matemática e Trigonometria", + "Statistical" => "Estatística", + "Engineering" => "Engenharia", + "Database" => "Banco de Dados", + "Compatibility" => "Compatibilidade", + "Web" => "Web", + "Cube" => "Cubo", + "Add-in and Automation" => "Suplemento e Automação", + "Find" => "Localizar", + "Replace" => "Substituir", + "Find what:" => "Localizar:", + "Replace with:" => "Substituir por:", + "Match case" => "Diferenciar maiúsculas e minúsculas", + "Match entire cell contents" => "Coincidir conteúdo da célula inteira", + "Within workbook" => "Na pasta de trabalho", + "Replace All" => "Substituir Tudo", + "Find All" => "Localizar Tudo", + "Find Next" => "Localizar Próxima", + "All done. We made {count} replacements." => "Tudo pronto. Fizemos {count} substituições.", + "{count} cell(s) found" => "{count} célula(s) encontrada(s)", + "Reference:" => "Referência:", + "Special…" => "Especial…", + "My list has headers" => "Meus dados contêm cabeçalhos", + "Sort by" => "Classificar por", + "Then by" => "E depois por", + "Cell Values" => "Valores de Célula", + "Cell Color" => "Cor da Célula", + "A to Z" => "De A a Z", + "Z to A" => "De Z a A", + "+ Add Level" => "+ Adicionar Nível", + "New…" => "Novo…", + "Edit…" => "Editar…", + "Name" => "Nome", + "Value" => "Valor", + "Refers To" => "Refere-se a", + "Scope" => "Escopo", + "Workbook" => "Pasta de Trabalho", + "No names defined" => "Nenhum nome definido", + "Recommended Charts" => "Gráficos Recomendados", + "All Charts" => "Todos os Gráficos", + "Clustered Column" => "Colunas Agrupadas", + "Stacked Column" => "Colunas Empilhadas", + "100% Stacked Column" => "Colunas 100% Empilhadas", + "Clustered Bar" => "Barras Agrupadas", + "Stacked Bar" => "Barras Empilhadas", + "100% Stacked Bar" => "Barras 100% Empilhadas", + "Line" => "Linhas", + "Line with Markers" => "Linhas com Marcadores", + "Stacked Line" => "Linhas Empilhadas", + "Pie" => "Pizza", + "Doughnut" => "Rosca", + "Area" => "Área", + "Stacked Area" => "Área Empilhada", + "Scatter" => "Dispersão", + "Scatter with Lines" => "Dispersão com Linhas", + "Radar" => "Radar", + "Histogram" => "Histograma", + "Waterfall" => "Cascata", + "Funnel" => "Funil", + "Combo" => "Combinação", + "Chart data range:" => "Intervalo de dados do gráfico:", + "Legend Entries (Series)" => "Entradas de Legenda (Série)", + "Series{n}" => "Série{n}", + "Keep" => "Manter", + "Remove" => "Remover", + "Horizontal (Category) Axis Labels:" => "Rótulos do Eixo Horizontal (Categorias):", + "There are no conditional formatting rules on this sheet." => "Não há regras de formatação condicional nesta planilha.", + "applies to" => "aplica-se a", + "Type a command, e.g. freeze, chart, bold…" => "Digite um comando, p. ex. congelar, gráfico, negrito…", + "Set cell:" => "Definir célula:", + "To value:" => "Para valor:", + "By changing cell:" => "Alternando célula:", + "Goal Seeking found a solution: {value}" => "Atingir Meta encontrou uma solução: {value}", + "Analysis Tools" => "Ferramentas de Análise", + "Anova: Single Factor" => "Anova: fator único", + "Correlation" => "Correlação", + "Covariance" => "Covariância", + "Descriptive Statistics" => "Estatística descritiva", + "Exponential Smoothing" => "Ajuste exponencial", + "F-Test Two-Sample for Variances" => "Teste F: duas amostras para variâncias", + "Moving Average" => "Média móvel", + "Rank and Percentile" => "Ordem e percentil", + "Regression" => "Regressão", + "t-Test: Paired Two Sample for Means" => "Teste t: duas amostras em par para médias", + "t-Test: Two-Sample Assuming Equal Variances" => "Teste t: duas amostras presumindo variâncias equivalentes", + "t-Test: Two-Sample Assuming Unequal Variances" => "Teste t: duas amostras presumindo variâncias diferentes", + "Output options" => "Opções de saída", + "Output Range:" => "Intervalo de saída:", + "New Worksheet Ply:" => "Nova planilha:", + "Grouped By:" => "Agrupado por:", + "Labels in first row" => "Rótulos na primeira linha", + "Input Range:" => "Intervalo de entrada:", + "Variable 1 Range:" => "Intervalo da variável 1:", + "Variable 2 Range:" => "Intervalo da variável 2:", + "Summary statistics" => "Resumo estatístico", + "Confidence Level for Mean (%), blank for none:" => "Nível de confiabilidade para a média (%), em branco para nenhum:", + "Kth Largest (blank for none):" => "K-ésimo maior (em branco para nenhum):", + "Kth Smallest (blank for none):" => "K-ésimo menor (em branco para nenhum):", + "Bin Range (blank for automatic bins):" => "Intervalo do bloco (em branco para blocos automáticos):", + "Pareto (sorted histogram)" => "Pareto (histograma classificado)", + "Cumulative Percentage" => "Porcentagem cumulativa", + "Alpha:" => "Alfa:", + "Input Y Range:" => "Intervalo Y de entrada:", + "Input X Range:" => "Intervalo X de entrada:", + "Constant is Zero" => "Constante é zero", + "Confidence Level (%):" => "Nível de confiança (%):", + "Residuals" => "Resíduos", + "Standardized Residuals" => "Resíduos padronizados", + "Normal Probability" => "Plotagem de probabilidade normal", + "Interval:" => "Intervalo:", + "Standard Errors" => "Erros padrão", + "Damping factor:" => "Fator de amortecimento:", + "Hypothesized Mean Difference:" => "Hipótese da diferença de média:", + "Set Objective:" => "Definir Objetivo:", + "To:" => "Para:", + "Value Of:" => "Valor de:", + "By Changing Variable Cells:" => "Alterando Células Variáveis:", + "Subject to the Constraints:" => "Sujeito às Restrições:", + "Make Unconstrained Variables Non-Negative" => "Tornar Variáveis Irrestritas Não Negativas", + "Select a Solving Method:" => "Selecionar um Método de Solução:", + "GRG Nonlinear" => "GRG Não Linear", + "Solve" => "Resolver", + "Keep Solver Solution" => "Manter Solução do Solver", + "Restore Original Values" => "Restaurar Valores Originais", + "The spelling check is complete for the entire sheet." => "A verificação ortográfica da planilha inteira foi concluída.", + "Not in Dictionary ({cell}):" => "Não consta no dicionário ({cell}):", + "Suggestions:" => "Sugestões:", + "(No suggestions)" => "(Nenhuma sugestão)", + "Ignore Once" => "Ignorar uma vez", + "Add to Dictionary" => "Adicionar ao dicionário", + "Change" => "Alterar", + "Change All" => "Alterar todas", + "$ English (United States)" => "$ Inglês (Estados Unidos)", + "Add Chart Element" => "Adicionar Elemento Gráfico", + "Align Left" => "Alinhar à Esquerda", + "Align Right" => "Alinhar à Direita", + "Bottom Align" => "Alinhar Embaixo", + "Calculate Now (F9)" => "Calcular Agora (F9)", + "Calculate Sheet (⇧F9)" => "Calcular Planilha (⇧F9)", + "Calculation Options" => "Opções de Cálculo", + "Cell Styles" => "Estilos de Célula", + "Change Chart Type" => "Alterar Tipo de Gráfico", + "Check Accessibility" => "Verificar Acessibilidade", + "Clear Filter" => "Limpar Filtro", + "Column or Bar" => "Coluna ou Barra", + "Comments pane" => "Painel de comentários", + "Convert the last ink stroke to a shape" => "Converter o último traço de tinta em forma", + "Convert to Range" => "Converter em Intervalo", + "Copy" => "Copiar", + "Custom Sort" => "Classificação Personalizada", + "Cut" => "Recortar", + "Define Name…" => "Definir Nome…", + "Delete Comment" => "Excluir Comentário", + "Delete Sheet" => "Excluir Planilha", + "Delete chart" => "Excluir gráfico", + "Delete…" => "Excluir…", + "Dismiss" => "Fechar", + "Down" => "Para Baixo", + "Edit chart title" => "Editar título do gráfico", + "Erase ink strokes" => "Apagar traços de tinta", + "Every command run in this session (replayable)" => "Todos os comandos executados nesta sessão (podem ser repetidos)", + "Expand formula bar" => "Expandir barra de fórmulas", + "Filter (⌘⇧F)" => "Filtro (⌘⇧F)", + "Filter by Selected Cell's Value" => "Filtrar pelo Valor da Célula Selecionada", + "Find & Select" => "Localizar e Selecionar", + "Flash Fill (⌘E)" => "Preenchimento Relâmpago (⌘E)", + "Forecast Sheet" => "Planilha de Previsão", + "Forecast end (date or number; blank: a third of the history):" => "Fim da previsão (data ou número; em branco: um terço do histórico):", + "Format Chart pane" => "Painel Formatar Gráfico", + "Format as Table" => "Formatar como Tabela", + "Hide" => "Ocultar", + "Hide Sheet" => "Ocultar Planilha", + "Hierarchy & more" => "Hierarquia e mais", + "Highlighter" => "Marca-texto", + "How agents drive GridCraft (MCP / control channel)" => "Como os agentes controlam o GridCraft (MCP / canal de controle)", + "Import a text or CSV file" => "Importar um arquivo de texto ou CSV", + "Insert Function" => "Inserir Função", + "Insert Function (⇧F3)" => "Inserir Função (⇧F3)", + "Insert Link (⌘K)" => "Inserir Link (⌘K)", + "Insert Sheet" => "Inserir Planilha", + "Insert Slicer" => "Inserir Segmentação de Dados", + "Insert a picture" => "Inserir uma imagem", + "Insert a slicer to filter a table" => "Inserir uma segmentação de dados para filtrar uma tabela", + "Insert an icon" => "Inserir um ícone", + "Insert…" => "Inserir…", + "Line or Area" => "Linha ou Área", + "Link…" => "Link…", + "Macros and scripts in this workbook (Alt+F8)" => "Macros e scripts nesta pasta de trabalho (Alt+F8)", + "Middle Align" => "Alinhar no Meio", + "More commands" => "Mais comandos", + "Move or Copy Sheet…" => "Mover ou Copiar Planilha…", + "New sheet (⇧F11)" => "Nova planilha (⇧F11)", + "Next sheet" => "Próxima planilha", + "Page Breaks" => "Quebras", + "Paper Size" => "Tamanho", + "Paste (⌘V)" => "Colar (⌘V)", + "Pick From Drop-down List…" => "Escolher na Lista Suspensa…", + "Pie or Doughnut" => "Pizza ou Rosca", + "Previous sheet" => "Planilha anterior", + "Print Area" => "Área de Impressão", + "Print Titles" => "Imprimir Títulos", + "Protect Workbook structure" => "Proteger estrutura da pasta de trabalho", + "Redo (⌘Y)" => "Refazer (⌘Y)", + "Save (⌘S)" => "Salvar (⌘S)", + "Scatter or Bubble" => "Dispersão ou Bolhas", + "Search and run any command" => "Pesquisar e executar qualquer comando", + "Search commands (⌘⇧Space)" => "Pesquisar comandos (⌘⇧Space)", + "Select and move ink and shapes" => "Selecionar e mover tinta e formas", + "Series…" => "Série…", + "Share: export or save a copy" => "Compartilhar: exportar ou salvar uma cópia", + "Show Formulas (⌃`)" => "Mostrar Fórmulas (⌃`)", + "Slicer" => "Segmentação de Dados", + "Sort & Filter" => "Classificar e Filtrar", + "Spelling (F7)" => "Ortografia (F7)", + "Statistic" => "Estatístico", + "Switch Row/Column" => "Alternar Linha/Coluna", + "Table (⌘T)" => "Tabela (⌘T)", + "Table Styles" => "Estilos de Tabela", + "Text to Columns" => "Texto para Colunas", + "The output range already holds data. Replace it with the report?" => "O intervalo de saída já contém dados. Substituí-los pelo relatório?", + "Top Align" => "Alinhar em Cima", + "Undo (⌘Z)" => "Desfazer (⌘Z)", + "Undo list" => "Lista de desfazer", + "Unhide Sheet…" => "Reexibir Planilha…", + "Up" => "Para Cima", + "Version, contributors and the AI models that helped" => "Versão, colaboradores e os modelos de IA que ajudaram", + "What-If Analysis" => "Teste de Hipóteses", + "Zoom In" => "Ampliar", + "Zoom Out" => "Reduzir", + "Zoom to 100%" => "Zoom em 100%", + "Zoom to Selection" => "Zoom na Seleção", + "Zoom…" => "Zoom…", + "£ English (United Kingdom)" => "£ Inglês (Reino Unido)", + "¥ Japanese" => "¥ Japonês", + "€ Euro" => "€ Euro", + "Tab|Insert" => "Inserir", + "AutoSave" => "Salvamento Automático", + "Insert\nSlicer" => "Inserir\nSegmentação", + "Pen\nBlack" => "Caneta\nPreta", + "Pen\nBlue" => "Caneta\nAzul", + "Pen\nRed" => "Caneta\nVermelha", + "Protect\nSheet" => "Proteger\nPlanilha", + "Watch\nWindow" => "Janela de\nInspeção", + "Workbook\nStatistics" => "Estatísticas da\nPasta de Trabalho", _ => return None, }) } diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e7d1de9..d11b5fa 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German ribbon catalog added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Brazilian Portuguese catalog: dialogs, menus and tooltips) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -48,7 +48,7 @@ separators, number/date formats, machine translation with agent review, and scri | Hindi | hi | 0 (0%) | no | Devanagari: **no shaping** | no | none | 12–18 h | | Arabic | ar | 0 (0%) | no | **no shaping, no RTL UI**; Sheet Right-to-Left mirrors the grid (#146) | no | none | 25–35 h | | French | fr | 0 (0%) | no | Latin: yes | no | none | 6–10 h | -| Portuguese | pt-BR | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h | +| Portuguese | pt-BR | 923 (~42%) | dialogs and tooltips (dialogs take effect with #669) | Latin: yes | no | menus, File view, dialogs | 2–4 h | | Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h | | Japanese | ja | 268 (~12%) | no | CJK system-font fallback; IME unverified; no furigana display | no | menus only | 8–12 h | | German | de | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h | @@ -77,6 +77,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | | 2026-10-11 | minor | Sheet Right-to-Left mirrors the grid (#146) | | 2026-10-11 | minor | Czech (cs) ribbon table: 266 labels (~12%), menus only | From ea5db94fe320a131b75c39cc5483a4d98e3900bc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:35:01 +0200 Subject: [PATCH 090/162] Numfmt: a negative number that rounds to zero shows no minus sign (fixes #729) --- crates/numfmt/src/lib.rs | 3 ++- crates/numfmt/src/render.rs | 5 +++++ crates/numfmt/src/tests.rs | 27 ++++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 674e844..9fcc146 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -261,7 +261,8 @@ impl NumberFormat { let Some(mut out) = out else { return (Formatted { text: OVERFLOW.into(), color: sec.color, fill: None, numeric: true }, true); }; - if minus && sec.kind != SecKind::Text { + // Excel drops the minus sign when the first section shows a negative number as all zeros. + if minus && sec.kind != SecKind::Text && !out.zero { out.prepend("-"); } (Formatted { text: out.text, color: sec.color, fill: out.fill, numeric: true }, false) diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 7befe59..d6c7d67 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -11,6 +11,8 @@ use crate::parse::{DatePart, Section, Tok}; pub(crate) struct Out { pub text: String, pub fill: Option<(char, usize)>, + /// True when every digit shown is zero (a number section that rounded to zero). + pub zero: bool, } impl Out { @@ -247,6 +249,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let mut pieces: Vec = vec![String::new(); n]; let mut before: Vec = vec![String::new(); n + 1]; let mut lits: Vec> = Vec::new(); + let mut zero = false; if sec.fraction { lits = vec![None; n]; render_fraction(sec, x, &mut pieces, &mut lits); @@ -310,6 +313,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { } else { fixed(x, frac_idx.len()) }; + zero = int_s.chars().chain(frac_s.chars()).all(|c| c == '0'); fill_int(toks, &int_idx, &int_s, sec.thousands, false, &mut pieces); if int_idx.is_empty() && !int_s.is_empty() @@ -338,6 +342,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { _ => out.push(pieces.get(i).map(String::as_str).unwrap_or("")), } } + out.zero = zero; out } diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 6c2306c..1cd88ef 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -24,7 +24,7 @@ fn numbers() { assert_eq!(f("0", -2.5), "-3"); assert_eq!(f("0.00", 2.675), "2.68"); assert_eq!(f("0.00", 1.005), "1.01"); - assert_eq!(f("0.0", -0.04), "-0.0"); + assert_eq!(f("0.0", -0.04), "0.0"); assert_eq!(f("00000", 123.0), "00123"); assert_eq!(f("000-00-0000", 123456789.0), "123-45-6789"); assert_eq!(f("#.##", 0.5), ".5"); @@ -105,6 +105,29 @@ fn scientific() { assert_eq!(f("0.00E+00", -12345.0), "-1.23E+04"); } +/// A negative number that rounds to zero in the first section shows no minus sign; a negative +/// section of its own keeps its text (observed Excel results). +#[test] +fn negative_rounding_to_zero_drops_the_sign() { + let cases: &[(&str, f64, &str)] = &[ + ("0.00", -0.0033, "0.00"), + ("0.00", -2.89e-9, "0.00"), + ("0.00", -0.005, "-0.01"), + ("0", -0.4, "0"), + ("0", -0.5, "-1"), + ("0.0", -0.004, "0.0"), + ("#,##0.00", -0.004, "0.00"), + ("0.00%", -2.89e-9, "0.00%"), + ("0.00%", -0.0033, "-0.33%"), + ("0.00;(0.00)", -0.0033, "(0.00)"), + ("0.00;-0.00;\"zero\"", -0.0033, "-0.00"), + ("0.00E+00", -2.89e-9, "-2.89E-09"), + ]; + for &(code, n, want) in cases { + assert_eq!(f(code, n), want, "{code} with {n}"); + } +} + #[test] fn fractions() { assert_eq!(f("# ?/?", 1.5), "1 1/2"); @@ -340,6 +363,8 @@ fn text_fn() { assert_eq!(text_function(&Value::Number(5.0), "", S), Ok("".into())); assert_eq!(text_function(&Value::Number(-1.0), "m/d/yyyy", S), Err(CellError::Value)); assert_eq!(text_function(&Value::Bool(true), "0", S), Ok("TRUE".into())); + assert_eq!(text_function(&Value::Number(-0.0033), "0", S), Ok("0".into())); + assert_eq!(text_function(&Value::Number(-0.4), "0", S), Ok("0".into())); // Booleans go through the text section as TRUE/FALSE (observed Excel results). let cases: &[(bool, &str, &str)] = &[ (true, ";;;@", "TRUE"), From 15b346e6caf13bc749d7e89cbaab6b308dd8f607 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:36:00 +0200 Subject: [PATCH 091/162] macOS: register the Window menu as NSApp's windowsMenu, so the tiling shortcuts work (fixes #716) --- Cargo.lock | 2 ++ apps/gridcraft/Cargo.toml | 10 ++++++++ apps/gridcraft/src/native_menu.rs | 10 ++++++++ apps/gridcraft/tests/mac_menu_appkit.rs | 34 +++++++++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 apps/gridcraft/tests/mac_menu_appkit.rs diff --git a/Cargo.lock b/Cargo.lock index ade3a5a..1815ac7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1508,6 +1508,8 @@ dependencies = [ "image", "log", "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "pollster 1.0.1", "rfd", "serde_json", diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index c867d24..cd8311b 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -42,3 +42,13 @@ muda = { version = "0.21", default-features = false } # by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our # workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" + +# tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit); +# pinned exactly to the same versions. +[target.'cfg(target_os = "macos")'.dev-dependencies] +objc2 = "=0.6.5" +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSMenu", "NSMenuItem", "NSResponder"] } + +[[test]] +name = "mac_menu_appkit" +harness = false diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index b127543..1469242 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -215,6 +215,7 @@ impl NativeMenu { ]); let _ = menu.append(&app_menu); let mut theme_items = Vec::new(); + let mut window_menu = None; for (title, entries) in tree() { let sub = Submenu::new(title, true); for (label, id, acc) in entries { @@ -237,8 +238,17 @@ impl NativeMenu { let _ = sub.append(&item); } let _ = menu.append(&sub); + if title == "Window" { + window_menu = Some(sub); + } } menu.init_for_nsapp(); + // NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the + // system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks + // the submenu up in the installed main menu. + if let Some(window) = window_menu { + window.set_as_windows_menu_for_nsapp(); + } let (tx, rx) = channel(); let ctx = ctx.clone(); MenuEvent::set_event_handler(Some(move |e: MenuEvent| { diff --git a/apps/gridcraft/tests/mac_menu_appkit.rs b/apps/gridcraft/tests/mac_menu_appkit.rs new file mode 100644 index 0000000..e713f66 --- /dev/null +++ b/apps/gridcraft/tests/mac_menu_appkit.rs @@ -0,0 +1,34 @@ +//! The macOS menu bar as AppKit sees it, on the main thread (`harness = false`): install it through +//! the real muda backend (`src/native_menu.rs`) and read NSApp back. The Window menu must be NSApp's +//! `windowsMenu`: that is where macOS adds Move & Resize, Fill and Center, the targets of the system +//! window tiling shortcuts (Fn+Ctrl+arrows, Fn+F), and lists the open windows (#716). + +// The app's own module, built into this test (cargo builds harness = false tests with cfg(test), so its +// unit tests compile here too without a harness to run them). +#[cfg(target_os = "macos")] +#[path = "../src/native_menu.rs"] +#[allow(unused_imports, dead_code)] +mod native_menu; + +#[cfg(target_os = "macos")] +fn main() { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + + let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread"); + let nsapp = NSApplication::sharedApplication(mtm); + nsapp.finishLaunching(); + + let ctx = egui::Context::default(); + let _menu = native_menu::NativeMenu::install(&ctx); + + let bar = nsapp.mainMenu().expect("a main menu"); + let tops: Vec = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect(); + assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]); + assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu"); + + println!("mac_menu_appkit: ok"); +} + +#[cfg(not(target_os = "macos"))] +fn main() {} From 9e28655a8a0bcba6fc18a49db815ed99ac9e6fd3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:37:51 +0200 Subject: [PATCH 092/162] ROMAN: forms 2 and 3 subtract IC, XM and VM like Excel (fixes #733) --- crates/functions/src/math.rs | 47 +++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index bab219e..b9aebad 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -634,16 +634,18 @@ fn radians(a: &[Arg], _c: &mut dyn Ctx) -> R { const NUMERALS: [(char, u32); 7] = [('I', 1), ('V', 5), ('X', 10), ('L', 50), ('C', 100), ('D', 500), ('M', 1000)]; -/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; higher forms -/// allow subtracting progressively smaller numerals (max ratio 10, 20, 50, 100, 1000). +/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; each higher +/// form widens one of two limits on the subtracted numeral's ratio to the larger one, alternating: +/// the limit for I/X/C (10, 10, 100, 100, 1000) and for V/L (none, 20, 20, 200, 200). So form 2 +/// allows IC and XM but not VD, and form 3 adds VD and VM but not ID. fn roman_tokens(form: u32) -> Vec<(String, u32)> { - let max_ratio = [10, 20, 50, 100, 1000].get(form as usize).copied().unwrap_or(10); + let (pow10_max, five_max) = [(10, 0), (10, 20), (100, 20), (100, 200), (1000, 200)].get(form as usize).copied().unwrap_or((10, 0)); let mut toks: Vec<(String, u32)> = NUMERALS.iter().map(|(c, v)| (c.to_string(), *v)).collect(); for (i, (sc, sv)) in NUMERALS.iter().enumerate() { for (lc, lv) in NUMERALS.iter().skip(i + 1) { let ratio = lv / sv; let pow10 = matches!(sc, 'I' | 'X' | 'C'); - let ok = if form == 0 { pow10 && ratio <= 10 } else { ratio <= max_ratio }; + let ok = ratio <= if pow10 { pow10_max } else { five_max }; let value = lv - sv; // Skip pairs that equal a single numeral (VX = V) – they are never shorter. if ok && !NUMERALS.iter().any(|(_, v)| *v == value) { @@ -1553,6 +1555,43 @@ mod tests { is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num); } + /// Every number where Excel's forms 2 and 3 differ from a plain ratio limit (issue #733): + /// form 2 subtracts I from C and X from M, form 3 also V from M. + #[test] + fn roman_forms_two_and_three_match_excel() { + const HUNDREDS: [&str; 10] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"]; + const ONES: [&str; 10] = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; + let mut cases = Vec::new(); + for thousands in 0..4usize { + let m = "M".repeat(thousands); + for (h, hundreds) in HUNDREDS.iter().enumerate() { + if h != 4 && h != 9 { + cases.push((thousands * 1000 + h * 100 + 99, 2.0, format!("{m}{hundreds}IC"))); + } + } + for (o, ones) in ONES.iter().enumerate() { + cases.push((thousands * 1000 + 990 + o, 2.0, format!("{m}XM{ones}"))); + } + for (o, ones) in ONES.iter().enumerate().take(5) { + cases.push((thousands * 1000 + 995 + o, 3.0, format!("{m}VM{ones}"))); + } + } + assert_eq!(cases.len(), 92); + for (number, form, want) in &cases { + is_text(ev2("ROMAN", *number as f64, *form), want); + } + // Neighbours that already matched: form 2 still has no VD or ID, form 3 no ID. + is_text(ev2("ROMAN", 495.0, 2.0), "XDV"); + is_text(ev2("ROMAN", 495.0, 3.0), "VD"); + is_text(ev2("ROMAN", 1499.0, 2.0), "MXDIX"); + is_text(ev2("ROMAN", 999.0, 0.0), "CMXCIX"); + is_text(ev2("ROMAN", 999.0, 1.0), "LMVLIV"); + is_text(ev2("ROMAN", 999.0, 4.0), "IM"); + is_text(ev2("ROMAN", 995.0, 4.0), "VM"); + is_text(ev2("ROMAN", 99.0, 1.0), "VCIV"); + is_text(ev2("ROMAN", 99.0, 4.0), "IC"); + } + #[test] fn matrices() { let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]); From 0dc76090d7c435c1451fe41998ce860e65d07f7e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:38:01 +0200 Subject: [PATCH 093/162] functions: MODE skips typed text and logical arguments (fixes #736) --- crates/functions/src/stat.rs | 52 ++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 25449bd..4afa108 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -79,10 +79,11 @@ fn mode_sngl(v: &[f64]) -> R { } /// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers -/// count, like [`numbers`]. Scalar arguments are stricter than in SUM: Excel answers `#VALUE!` -/// for direct text (even numeric text such as `"1"`) and for a single-cell reference to text or -/// a blank cell (observed: `MODE(B8,"1",2,3)` and `MODE(B9,2,D7)` with D7 blank are `#VALUE!`). -/// Arguments are processed left to right, so the first error wins. +/// count, like [`numbers`]. Scalar arguments differ from SUM: text and logical values are skipped +/// whether typed or referenced (even numeric text such as `"1"`, so `MODE(1,"1",2,3)` is `#N/A`, +/// and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference to a blank cell is +/// `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to right, so the +/// first error wins. fn mode_numbers(args: &[Arg]) -> R> { let mut out = Vec::new(); for a in args { @@ -98,9 +99,8 @@ fn mode_numbers(args: &[Arg]) -> R> { } Value::Number(n) => out.push(*n), Value::Error(e) => return Err(*e), - Value::Text(_) | Value::Empty => return Err(CellError::Value), - Value::Bool(_) if a.from_ref => {} - v => out.push(v.to_number()?), + Value::Text(_) | Value::Bool(_) => {} + Value::Empty => return Err(CellError::Value), } } Ok(out) @@ -2279,9 +2279,9 @@ mod tests { assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); } - /// MODE argument semantics observed in Excel (FormulaEvalTestData, row "Mode"): multi-cell - /// ranges skip text and blanks, but direct text and single-cell references to text or blank - /// cells are `#VALUE!`; the first error from the left wins. + /// MODE argument semantics from the Excel-cached results in FormulaEvalTestData (row "Mode"): + /// text is skipped, typed or referenced, but a single-cell reference to a blank cell is + /// `#VALUE!`; the first error from the left wins. #[test] fn mode_scalar_arguments() { let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]); @@ -2290,9 +2290,9 @@ mod tests { (vec![rf(data)], Ok(1.0)), (vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)), // MODE(C9,E16,"1",B8): E16 holds the text "1.1". - (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::Value)), - (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::Value)), - (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Value)), + (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::NA)), + (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), // MODE(B9,2,D7) with D7 blank. (vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)), (vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)), @@ -2305,7 +2305,31 @@ mod tests { } } } - is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), n(1.0)]), CellError::Value); + is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), rf(Value::Empty)]), CellError::Value); + } + + /// Text and logical values typed as arguments are skipped, not coerced (Excel 365, #736). + #[test] + fn mode_skips_typed_text_and_logicals() { + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(1.0), n(1.0), t("x")], Ok(1.0)), + (vec![n(1.0), n(2.0), t("x")], Err(CellError::NA)), + (vec![n(1.0), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), + (vec![n(1.0), b(true), b(true), n(2.0)], Err(CellError::NA)), + (vec![n(1.0), n(1.0), b(true)], Ok(1.0)), + ]; + for name in ["MODE", "MODE.SNGL"] { + for (args, want) in &cases { + match want { + Ok(x) => close(ev(name, args.clone()), *x), + Err(err) => is_err(ev(name, args.clone()), *err), + } + } + } + assert_eq!(rows_of(&ev("MODE.MULT", vec![n(1.0), t("1"), n(1.0), t("x")])), vec![vec![nv(1.0)]]); + is_err(ev("MODE.MULT", vec![n(1.0), b(true), b(true), n(2.0)]), CellError::NA); } #[test] From 3d0ed97d71d627b37d792e452a2f05b7eda02c10 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:41:45 +0200 Subject: [PATCH 094/162] XLS: a FORMAT record shorter than its character count keeps the characters it holds (fixes #731) --- crates/xlsx/src/tests/xls_tests.rs | 36 ++++++++++++++++++++++++++++++ crates/xlsx/src/xls/mod.rs | 14 +++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795..b7e2c1f 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -476,3 +476,39 @@ fn sheets_share_one_record_budget() { let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); assert!(err.contains("same substream"), "{err}"); } + +#[test] +fn a_format_shorter_than_its_count_keeps_its_characters() { + // A FORMAT whose cch is one more than the characters in the record (seen in real files): + // Excel opens them, the record's characters are the format code. + let short = rec(0x041E, &cat(&[&165u16.to_le_bytes(), &6u16.to_le_bytes(), &[0], b"0.000"])); + let xf = |ifmt: u16| rec(0x00E0, &cat(&[&u16s(&[0, ifmt, 0]), &[0; 14]])); + let globals = |pos: u32| { + cat(&[ + &bof(0x0005), + &short, + &rec(0x041E, &cat(&[&164u16.to_le_bytes(), &xlstr("yyyy-mm-dd")])), + &xf(0), + &xf(164), + &xf(165), + &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), + &eof(), + ]) + }; + let sheet = cat(&[ + &bof(0x0010), + &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(1, 0, 1), &0.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(2, 0, 2), &1.25f64.to_le_bytes()])), + &eof(), + ]); + let stream = cat(&[&globals(globals(0).len() as u32), &sheet]); + let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap(); + let sh = &wb.sheets[0]; + let fmt = |a1: &str| wb.styles.get(sh.style_id(CellRef::parse(a1).unwrap())).num_fmt.as_str().to_string(); + assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0)); + assert_eq!(v(&wb, 0, "A3"), Value::Number(1.25)); + assert_eq!(fmt("A2"), "yyyy-mm-dd"); + assert_eq!(fmt("A3"), "0.000"); + assert!(report.warnings.iter().any(|w| w.contains("declared length")), "{:?}", report.warnings); +} diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66..78f7a42 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -143,7 +143,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo } }; let mut report = ReadReport::default(); - let g = globals(&stream, &mut budget)?; + let g = globals(&stream, &mut budget, &mut report)?; let mut wb = Workbook::new(); wb.sheets.clear(); @@ -205,7 +205,7 @@ fn bof(rec: &Record<'_>) -> Result<(u16, u16), IoError> { Ok((c.u16()?, c.u16()?)) } -fn globals(stream: &[u8], budget: &mut Budget) -> Result { +fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Result { let mut recs = Records::new(stream, 0); let first = recs.next_record()?.ok_or_else(|| invalid("empty Workbook stream"))?; let (vers, dt) = bof(&first)?; @@ -249,7 +249,15 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { 0x0022 => g.date1904 = c.u16()? == 1, 0x041E => { let ifmt = c.u16()?; - let code = c.string()?; + let cch = usize::from(c.u16()?); + let high = c.u8()? & 1 != 0; + // Some writers count one character more than the record holds; Excel opens + // those files, so keep the characters that are there. + let fit = c.remaining() / if high { 2 } else { 1 }; + if cch > fit { + report.warn("Some number formats were shorter than their declared length; the characters present were kept."); + } + let code = c.chars(cch.min(fit), high)?; budget.text(code.len())?; g.formats.insert(ifmt, code); } From dbfed89a36163dce5d6cb33b808fb260ea003ea2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:43:28 +0200 Subject: [PATCH 095/162] XLS import: read the iterative calculation settings (part of #724) CalcIter, CalcCount and CalcDelta repeat in every worksheet substream and were skipped, so a workbook saved with iterative calculation on opened with it off and its circular references showed #CIRC!. They now set wb.calc from the first worksheet, as does for .xlsx. --- crates/xlsx/src/tests/xls_tests.rs | 19 +++++++++++++++++++ crates/xlsx/src/xls/mod.rs | 23 ++++++++++++++++------- docs/file-format-parity.md | 5 +++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795..5f80734 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -476,3 +476,22 @@ fn sheets_share_one_record_budget() { let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); assert!(err.contains("same substream"), "{err}"); } + +#[test] +fn reads_iterative_calculation_settings() { + // CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only the first + // sheet carries them, so the sheet after it must not reset them to the defaults. + let calc = cat(&[&rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); + let sheet1 = cat(&[&bof(0x0010), &calc, &eof()]); + let sheet2 = cat(&[&bof(0x0010), &eof()]); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); + let p1 = globals(0, 0).len() as u32; + let p2 = p1 + sheet1.len() as u32; + let (wb, _) = read_xls(&compound("Workbook", &cat(&[&globals(p1, p2), &sheet1, &sheet2]))).unwrap(); + assert!(wb.calc.iterative); + assert_eq!((wb.calc.max_iterations, wb.calc.max_change), (50, 0.01)); + // Without the records: Excel's defaults, iteration off. + let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap(); + assert_eq!(wb.calc, gridcraft_model::CalcSettings::default()); +} diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66..df27a73 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -4,9 +4,9 @@ //! //! Read: worksheets, shared strings, cell values, formulas (text and cached results, including //! shared and array formulas), number formats, the 1904 date system, merged cells, column -//! widths, row heights, defined names, print areas and print titles. Everything else (fonts, -//! fills, borders, charts, pictures, comments, conditional formats, validation, macros) is -//! skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. +//! widths, row heights, defined names, print areas, print titles and the iterative calculation +//! settings. Everything else (fonts, fills, borders, charts, pictures, comments, conditional +//! formats, validation, macros) is skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. mod formula; mod ftab; @@ -16,7 +16,7 @@ use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; +use gridcraft_model::{CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; use crate::cfb::Compound; use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; @@ -175,12 +175,16 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo continue; } wb.check_sheet_name(&bs.name, None).map_err(invalid)?; - let Some(mut sheet) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { + let Some((mut sheet, calc)) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { report.warn("Dialog sheets and unreadable sheets were skipped."); model_index.push(None); continue; }; sheet.visibility = bs.visibility; + // Every worksheet repeats the workbook's iteration settings; Excel applies the first. + if wb.sheets.is_empty() { + wb.calc = calc; + } model_index.push(Some(wb.sheets.len())); wb.sheets.push(Arc::new(sheet)); } @@ -393,6 +397,7 @@ struct SheetState<'g> { pending_string: Option, dropped: usize, unsupported: Vec<&'static str>, + calc: CalcSettings, } impl SheetState<'_> { @@ -462,7 +467,7 @@ fn worksheet( styles: &[StyleId], budget: &mut Budget, report: &mut ReadReport, -) -> Result, IoError> { +) -> Result, IoError> { if bs.pos >= stream.len() { return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name))); } @@ -483,6 +488,7 @@ fn worksheet( pending_string: None, dropped: 0, unsupported: vec![], + calc: CalcSettings::default(), }; let mut depth = 1usize; while let Some(rec) = recs.next_record()? { @@ -525,7 +531,7 @@ fn worksheet( for why in &st.unsupported { report.warn(format!("Some formulas use {why}; their saved results were kept as values.")); } - Ok(Some(st.sheet)) + Ok(Some((st.sheet, st.calc))) } fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> { @@ -620,6 +626,9 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, st.arrays.insert(at, (range, f)); } } + 0x000C => st.calc.set_max_iterations(u32::from(c.u16()?)), + 0x0011 => st.calc.iterative = c.u16()? == 1, + 0x0010 => st.calc.set_max_change(c.f64()?), 0x0236 => report.warn("What-if data tables are not supported; their values were kept."), 0x00E5 => { let n = usize::from(c.u16()?); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8510659..ad458ca 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the iterative calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -22,7 +22,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles. Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, iterative calculation settings (enabled, maximum iterations, maximum change). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -78,6 +78,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | `.xls` import reads the iterative calculation settings (CalcIter, CalcCount, CalcDelta), so circular models calculate instead of showing `#CIRC!` (#724) | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | From a34f106e9327ab1872bbec9e6bf21326b01dfa6a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:44:23 +0200 Subject: [PATCH 096/162] XLS: open compound files whose last sector isn't padded (fixes #730) --- crates/xlsx/src/cfb.rs | 83 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/crates/xlsx/src/cfb.rs b/crates/xlsx/src/cfb.rs index 3a74718..5f85e43 100644 --- a/crates/xlsx/src/cfb.rs +++ b/crates/xlsx/src/cfb.rs @@ -209,13 +209,19 @@ impl<'a> Compound<'a> { } /// Follows a FAT chain from `start`, reading at most `limit` bytes. A chain longer than the - /// FAT (so a cycle) or pointing outside the file is an error. + /// FAT (so a cycle) or pointing outside the file is an error. The file may end inside the + /// chain's last sector (some writers don't pad it, and Excel opens those files): that sector + /// reads short, and the caller checks the stream's declared size against what was read. fn read_chain(&self, start: u32, limit: u64) -> Result, String> { let limit = usize::try_from(limit.min(MAX_STREAM)).unwrap_or(usize::MAX); let mut out = Vec::new(); let mut id = start; let mut seen = vec![false; self.fat.len()]; + let mut cut = false; while id != END_OF_CHAIN && out.len() < limit { + if cut { + return Err("damaged compound file: sector out of range (the file may be truncated)".into()); + } if id >= MAX_REG_SECT { return Err("damaged compound file: broken sector chain".into()); } @@ -226,20 +232,26 @@ impl<'a> Compound<'a> { } let start = (id as usize).checked_add(1).and_then(|i| i.checked_mul(self.sector_size)); let s = start - .and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?)) + .and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?.min(self.bytes.len()))) .ok_or("damaged compound file: sector out of range (the file may be truncated)")?; + cut = s.len() < self.sector_size; out.extend_from_slice(s); id = *self.fat.get(id as usize).ok_or("damaged compound file: sector missing from the FAT")?; } Ok(out) } - /// Follows a mini FAT chain inside the mini stream container. + /// Follows a mini FAT chain inside the mini stream container. Like `read_chain`, the + /// container may end inside the chain's last mini sector. fn read_mini_chain(&self, container: &[u8], start: u32, limit: usize) -> Result, String> { let mut out = Vec::new(); let mut id = start; let mut seen = vec![false; self.mini_fat.len()]; + let mut cut = false; while id != END_OF_CHAIN && out.len() < limit { + if cut { + return Err("damaged compound file: mini sector out of range".into()); + } if id >= MAX_REG_SECT { return Err("damaged compound file: broken mini sector chain".into()); } @@ -249,8 +261,10 @@ impl<'a> Compound<'a> { None => return Err("damaged compound file: mini sector missing from the mini FAT".into()), } let at = (id as usize).checked_mul(self.mini_sector_size); - let s = - at.and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?)).ok_or("damaged compound file: mini sector out of range")?; + let s = at + .and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?.min(container.len()))) + .ok_or("damaged compound file: mini sector out of range")?; + cut = s.len() < self.mini_sector_size; out.extend_from_slice(s); id = *self.mini_fat.get(id as usize).ok_or("damaged compound file: mini sector missing from the mini FAT")?; } @@ -379,4 +393,63 @@ pub(crate) mod tests { b[516..520].copy_from_slice(&1u32.to_le_bytes()); assert!(!is_encrypted(&b)); } + + /// `compound_file` with one regular-sector stream of `len` bytes (FAT in sector 0, directory + /// in sector 1, the stream from sector 2), cut right after the stream's last byte, the way + /// some writers leave the final sector unpadded. + fn unpadded(len: usize) -> (Vec, Vec) { + let data: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + let mut file = crate::testing::compound_file(&[("Workbook", &data)]); + file.truncate(512 * 3 + len); + (file, data) + } + + #[test] + fn an_unpadded_last_sector_still_reads() { + let (file, data) = unpadded(5000); + assert_ne!(file.len() % 512, 0); + let cf = Compound::open(&file).unwrap(); + assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data); + // Bytes the stream needs that the file doesn't hold are still damage. + let cf = Compound::open(&file[..file.len() - 1]).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } + + #[test] + fn an_unpadded_last_mini_sector_still_reads() { + let data: Vec = (0..1000u32).map(|i| (i % 251) as u8).collect(); + let mut file = crate::testing::compound_file(&[("Workbook", &data)]); + // Header, FAT, directory, mini FAT, then the mini stream holding the stream's bytes. + file.truncate(512 * 4 + data.len()); + let cf = Compound::open(&file).unwrap(); + assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data); + let cf = Compound::open(&file[..file.len() - 1]).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } + + #[test] + fn a_cut_sector_inside_a_chain_is_damage() { + let (mut file, _) = unpadded(5000); + // The cut sector (11) points on to the directory sector and the stream claims those + // bytes too: the partial sector would splice 392 bytes into the middle of the stream. + file[512 + 11 * 4..512 + 12 * 4].copy_from_slice(&1u32.to_le_bytes()); + file[1024 + 128 + 120..1024 + 128 + 124].copy_from_slice(&5512u32.to_le_bytes()); + let cf = Compound::open(&file).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } + + #[test] + fn a_cut_mini_sector_inside_a_chain_is_damage() { + let small = [7u8; 100]; + let data: Vec = (0..1000u32).map(|i| (i % 251) as u8).collect(); + let mut file = crate::testing::compound_file(&[("Small", &small), ("Workbook", &data)]); + // "Small" holds mini sectors 0-1, "Workbook" 2-17; the mini stream starts at sector 3. + file.truncate(512 * 4 + 128 + data.len()); + assert_eq!(Compound::open(&file).unwrap().root_stream("Workbook").unwrap().unwrap(), data); + // The cut mini sector (17) points on to sector 0 and the stream claims those bytes too. + file[1536 + 17 * 4..1536 + 18 * 4].copy_from_slice(&0u32.to_le_bytes()); + file[1024 + 2 * 128 + 120..1024 + 2 * 128 + 124].copy_from_slice(&1064u32.to_le_bytes()); + let cf = Compound::open(&file).unwrap(); + assert!(cf.root_stream("Workbook").is_err()); + } } From f3e05e0ad8b4fa9bc1cc5c5f4c6bc8f632c07f01 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:47:53 +0200 Subject: [PATCH 097/162] XLS import: read the calculation mode and precision as displayed (fixes #724) CalcMode repeats in every worksheet substream like the iteration records and sets wb.calc.mode from the first worksheet; CalcPrecision sits in the globals and sets precision_as_displayed, matching calcMode and fullPrecision on the .xlsx side. --- crates/xlsx/src/tests/xls_tests.rs | 11 +++++++---- crates/xlsx/src/xls/mod.rs | 21 ++++++++++++++++----- docs/file-format-parity.md | 6 +++--- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index 5f80734..f284188 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -479,18 +479,21 @@ fn sheets_share_one_record_budget() { #[test] fn reads_iterative_calculation_settings() { - // CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only the first - // sheet carries them, so the sheet after it must not reset them to the defaults. - let calc = cat(&[&rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); + // CalcMode, CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only + // the first sheet carries them, so the sheet after it must not reset them to the defaults. + // CalcPrecision sits in the globals. + let calc = cat(&[&rec(0x000D, &u16s(&[0])), &rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); let sheet1 = cat(&[&bof(0x0010), &calc, &eof()]); let sheet2 = cat(&[&bof(0x0010), &eof()]); let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); - let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); + let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &rec(0x000E, &u16s(&[0])), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); let p1 = globals(0, 0).len() as u32; let p2 = p1 + sheet1.len() as u32; let (wb, _) = read_xls(&compound("Workbook", &cat(&[&globals(p1, p2), &sheet1, &sheet2]))).unwrap(); assert!(wb.calc.iterative); assert_eq!((wb.calc.max_iterations, wb.calc.max_change), (50, 0.01)); + assert_eq!(wb.calc.mode, gridcraft_model::CalcMode::Manual); + assert!(wb.calc.precision_as_displayed); // Without the records: Excel's defaults, iteration off. let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap(); assert_eq!(wb.calc, gridcraft_model::CalcSettings::default()); diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index df27a73..407a4a4 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -4,9 +4,10 @@ //! //! Read: worksheets, shared strings, cell values, formulas (text and cached results, including //! shared and array formulas), number formats, the 1904 date system, merged cells, column -//! widths, row heights, defined names, print areas, print titles and the iterative calculation -//! settings. Everything else (fonts, fills, borders, charts, pictures, comments, conditional -//! formats, validation, macros) is skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. +//! widths, row heights, defined names, print areas, print titles and the calculation settings +//! (mode, iteration, precision as displayed). Everything else (fonts, fills, borders, charts, +//! pictures, comments, conditional formats, validation, macros) is skipped with a warning. +//! Encrypted workbooks and pre-BIFF8 versions are refused. mod formula; mod ftab; @@ -16,7 +17,7 @@ use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; +use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; use crate::cfb::Compound; use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; @@ -123,6 +124,7 @@ struct Globals { sst: Vec>, links: Links, names: Vec, + precision_as_displayed: bool, } /// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. @@ -181,7 +183,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo continue; }; sheet.visibility = bs.visibility; - // Every worksheet repeats the workbook's iteration settings; Excel applies the first. + // Every worksheet repeats the workbook's calculation settings; Excel applies the first. if wb.sheets.is_empty() { wb.calc = calc; } @@ -191,6 +193,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo if wb.sheets.is_empty() { return Err(invalid("the workbook has no worksheets GridCraft can read")); } + wb.calc.precision_as_displayed = g.precision_as_displayed; defined_names(&g, &model_index, &mut wb, &mut report); wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); // The overview comes first. @@ -251,6 +254,7 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { g.sheets.push(BoundSheet { pos, name, visibility, kind }); } 0x0022 => g.date1904 = c.u16()? == 1, + 0x000E => g.precision_as_displayed = c.u16()? == 0, 0x041E => { let ifmt = c.u16()?; let code = c.string()?; @@ -626,6 +630,13 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, st.arrays.insert(at, (range, f)); } } + 0x000D => { + st.calc.mode = match c.u16()? as i16 { + 0 => CalcMode::Manual, + -1 => CalcMode::AutomaticExceptTables, + _ => CalcMode::Automatic, + } + } 0x000C => st.calc.set_max_iterations(u32::from(c.u16()?)), 0x0011 => st.calc.iterative = c.u16()? == 1, 0x0010 => st.calc.set_max_change(c.f64()?), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ad458ca..0bc4813 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the iterative calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -22,7 +22,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, iterative calculation settings (enabled, maximum iterations, maximum change). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -78,7 +78,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-10-12 | minor | `.xls` import reads the iterative calculation settings (CalcIter, CalcCount, CalcDelta), so circular models calculate instead of showing `#CIRC!` (#724) | +| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | From 94014c5594058eb3dad64cbc9a4745a1c56e450f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:51:40 +0200 Subject: [PATCH 098/162] Tables: structured references keep the spaces at the edges of a column name (fixes #732) --- crates/engine/src/tests.rs | 30 ++++++++++++++++++++++++++++++ crates/formula/src/parser.rs | 5 ++++- crates/formula/src/printer.rs | 7 ++++++- crates/formula/src/tests.rs | 14 ++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..66ef9d7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1018,6 +1018,36 @@ fn tables_and_structured_refs() { assert_eq!(v(&s, "B4"), Value::Number(7.0)); } +#[test] +fn structured_refs_to_columns_with_edge_spaces() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty "], ["a", 2], ["b", 3]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B3", "hasHeaders": true})).unwrap(); + s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap(); + assert_eq!(v(&s, "B4"), Value::Number(5.0)); + for (cell, input) in [ + ("F1", "=SUM(Table1[Qty ])"), + ("F2", "=Table1[[#Totals],[Qty ]]"), + ("F3", "=SUBTOTAL(109,Table1[Qty ])"), + ("F4", "=SUM(Table1[[Qty ]])"), + ("F5", "=SUM(Table1[Qty])"), + ("C2", "=Table1[@[Qty ]]*10"), + ] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let n = Value::Number; + assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3"), v(&s, "F4")], [n(5.0), n(5.0), n(5.0), n(5.0)]); + assert_eq!(v(&s, "F5"), Value::Error(gridcraft_core::CellError::Ref)); + assert_eq!(v(&s, "C2"), n(20.0)); + assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])"); + + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let b64 = r["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])"); + assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3")], [n(5.0), n(5.0), n(5.0)]); +} + #[test] fn slicer_filters_table() { let mut s = s(); diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 4fd3b7b..8fc4de9 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -417,6 +417,9 @@ fn combine(op: BinOp, lhs: Expr, rhs: Expr) -> Expr { pub fn parse_struct(table: &str, body: &str) -> Result { let mut specifiers = Vec::new(); let mut cols: Vec = Vec::new(); + // Spaces at the edges of a column name are part of it (`Table1[Qty ]` names `Qty `), so the + // untrimmed body is what a lone column is read from. + let raw = body; let body = body.trim(); let unescape = |s: &str| { let mut out = String::new(); @@ -468,7 +471,7 @@ pub fn parse_struct(table: &str, body: &str) -> Result { } else if let Some(i) = item(body) { specifiers.push(i); } else if !body.is_empty() { - cols.push(column(body)?); + cols.push(column(raw)?); } } else { // [[#Headers],[Col1]:[Col3]], [[#This Row],[Col 1]] or [[#This Row],Col1] diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index a63599b..3673036 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -109,7 +109,12 @@ pub fn struct_ref(s: &StructRef) -> String { let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or("")); } (Some(c), None) if s.specifiers.is_empty() => { - let _ = write!(out, "[{}]", esc(c)); + // Excel writes a name with spaces at its edges in the double-bracket form. + if c.trim() == c { + let _ = write!(out, "[{}]", esc(c)); + } else { + let _ = write!(out, "[[{}]]", esc(c)); + } } (Some(c), None) if this_row => { if simple_col(c) { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c2..aef2649 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -248,6 +248,20 @@ fn unbracketed_this_row_column() { } } +#[test] +fn struct_column_keeps_edge_spaces() { + // The spaces are part of the column name: `Table1[Qty ]` names `Qty `, never `Qty`. + let Expr::Struct(s) = parse("Table1[Qty ]").unwrap() else { panic!() }; + assert_eq!(s.col_start.as_deref(), Some("Qty ")); + let Expr::Struct(s) = parse("Table1[ Qty]").unwrap() else { panic!() }; + assert_eq!(s.col_start.as_deref(), Some(" Qty")); + // Excel writes such a column in the double-bracket form. + assert_eq!(rt("Table1[Qty ]"), "Table1[[Qty ]]"); + for f in ["Table1[[Qty ]]", "Table1[[ Sum for Q2 ]]", "Table1[[#Totals],[Qty ]]", "Table1[@[Qty ]]", "Table1[Unit Price]"] { + assert_eq!(rt(f), f); + } +} + #[test] fn trim_references() { for f in [ From eb534b5cafb038f8b399154d5fb16a785a4d6ef1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:58:24 +0200 Subject: [PATCH 099/162] Functions: DGET skips empty result fields and "" criteria match any text (part of #737) --- crates/functions/src/database.rs | 77 +++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index 38e3729..bf0c611 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -4,7 +4,7 @@ //! The database and the criteria are arrays whose first row holds column labels. Each criteria //! row is a set of conditions that must all hold (AND); a record matches when any criteria row //! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that -//! begin with the text, as in Excel's advanced filter. +//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. use std::borrow::Cow; @@ -49,7 +49,6 @@ fn field_index(db: &Array, a: &[Arg]) -> R> { fn cell_criterion(v: &Value) -> Option { match v { Value::Empty => None, - Value::Text(t) if t.is_empty() => None, Value::Text(t) => { let s: &str = t; let has_op = s.starts_with(['=', '<', '>']); @@ -158,9 +157,12 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { let s = select(a)?; let f = s.field.ok_or(CellError::Value)?; - match s.records.as_slice() { - [] => Err(CellError::Value), - [r] => Ok(s.db.get(*r, f).cloned().unwrap_or(Value::Empty)), + // Records whose field is blank (or "") don't count, as in Excel. + let mut found = + s.records.iter().filter_map(|&r| s.db.get(r, f)).filter(|v| !matches!(v, Value::Empty) && !matches!(v, Value::Text(t) if t.is_empty())); + match (found.next(), found.next()) { + (None, _) => Err(CellError::Value), + (Some(v), None) => Ok(v.clone()), _ => Err(CellError::Num), } } @@ -371,4 +373,69 @@ mod tests { is_err(ev("DSUM", vec![e(CellError::Ref), t("Yield"), rf(c("=Cherry"))]), CellError::Ref); close(ev("DCOUNTA", vec![rf(db()), empty(), rf(c("=Pear"))]), 2.0); } + + /// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 74-86). + #[test] + fn empty_string_criterion() { + let db = arr(vec![ + vec![tv("Index"), tv("Value")], + vec![tv("a"), tv("=")], + vec![tv("b"), Value::Empty], + vec![tv("c"), tv("")], + vec![tv("d"), tv(" ")], + vec![tv("f"), Value::Error(CellError::Value)], + vec![tv("h"), tv("abc")], + vec![tv("i"), nv(0.0)], + ]); + let get = |index: &str| { + let crit = arr(vec![vec![tv("Index"), tv("Value")], vec![tv(index), tv("")]]); + ev("DGET", vec![rf(db.clone()), t("Index"), rf(crit)]) + }; + // Like any plain text, "" is a "begins with" pattern: it matches every text value. + is_text(get("c"), "c"); + is_text(get("h"), "h"); + is_err(get("b"), CellError::Value); + is_err(get("i"), CellError::Value); + is_err(get("f"), CellError::Value); + let all = arr(vec![vec![tv("Value")], vec![tv("")]]); + close(ev("DCOUNTA", vec![rf(db.clone()), t("Index"), rf(all)]), 4.0); + + // Under a label that names no field, "" is a condition that never holds. + let db = arr(vec![vec![tv("One"), tv("Two"), tv("Three")], vec![tv("a"), tv("c"), tv("x")], vec![tv("b"), tv("d"), tv("y")]]); + let get = |label: Value, value: Value| { + let crit = arr(vec![vec![tv("One"), label, tv("Three")], vec![tv("a"), value, tv("x")]]); + ev("DGET", vec![rf(db.clone()), t("One"), rf(crit)]) + }; + is_text(get(Value::Empty, Value::Empty), "a"); + is_text(get(tv(""), Value::Empty), "a"); + is_err(get(Value::Empty, tv("")), CellError::Value); + is_err(get(tv(""), tv("")), CellError::Value); + } + + /// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 108-119). + #[test] + fn dget_skips_empty_fields() { + let db = arr(vec![ + vec![tv("One"), tv("Two")], + vec![tv("a"), Value::Empty], + vec![tv("b"), tv(" ")], + vec![tv("c"), tv("")], + vec![tv("d"), Value::Error(CellError::NA)], + vec![tv("j"), Value::Empty], + vec![tv("j"), tv("Text")], + vec![tv("j"), Value::Empty], + vec![tv("k"), Value::Empty], + vec![tv("k"), tv("")], + ]); + let get = |one: &str| { + let crit = arr(vec![vec![tv("One")], vec![tv(one)]]); + ev("DGET", vec![rf(db.clone()), t("Two"), rf(crit)]) + }; + is_err(get("a"), CellError::Value); + is_text(get("b"), " "); + is_err(get("c"), CellError::Value); + is_err(get("d"), CellError::NA); + is_text(get("j"), "Text"); + is_err(get("k"), CellError::Value); + } } From f968bb32697c5f4ac02c6ae484704b402818c379 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:05:30 +0200 Subject: [PATCH 100/162] CELL("width") returns the Column Width dialog value, without the padding (#735) --- crates/calc/src/eval.rs | 2 +- crates/calc/src/tests.rs | 15 +++++++++++++++ crates/engine/src/cmd/format.rs | 4 +--- crates/model/src/lib.rs | 2 +- crates/model/src/sheet.rs | 6 ++++++ 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..6f0a44e 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1275,7 +1275,7 @@ impl<'h> Evaluator<'h> { _ => "v", })) } - "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| (s.col_width(c.col) as f64 / 7.0 * 100.0).round() / 100.0).round())), + "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())), "filename" => Ev::V(Value::text(String::new())), "sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())), "protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })), diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..6f1c8c7 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -968,3 +968,18 @@ fn trim_references() { t.set("K20", "=SUM(Q1:.Q9)"); assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); } + +#[test] +fn cell_width_is_the_column_width_dialog_value_rounded() { + // #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them + // (chars * 7 + 5): 10.71 -> 80, 12 -> 89, 10.57 -> 79, 2.86 -> 25; B keeps the default 8.43 (64). + let mut t = T::new(); + let sh = t.wb.sheet_mut(0).unwrap(); + for (col, px) in [(2, 80.0), (3, 89.0), (4, 79.0), (5, 25.0)] { + sh.cols.insert(col, gridcraft_model::LineInfo { size: Some(px), ..Default::default() }); + } + for (at, col) in [("H1", "B"), ("H2", "C"), ("H3", "D"), ("H4", "E"), ("H5", "F")] { + t.set(at, &format!("=CELL(\"width\",{col}1)")); + } + assert_eq!([t.num("H1"), t.num("H2"), t.num("H3"), t.num("H4"), t.num("H5")], [8.0, 11.0, 12.0, 11.0, 3.0]); +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index dde5fa1..a07715b 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -992,9 +992,7 @@ pub fn chars_to_points(c: f64) -> f64 { } } -pub fn points_to_chars(px: f64) -> f64 { - if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 } -} +pub use gridcraft_model::points_to_chars; /// Approximate text width in points for autofit (no font metrics in the engine: average glyph /// widths for the default font; the UI refines with real measurement when it autofits). diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 1bea842..fa64aad 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -19,7 +19,7 @@ pub use cell::{Cell, CellPicture, Formula}; pub use features::*; pub use password::PasswordHash; pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml}; -pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility}; +pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars}; pub use store::CellStore; pub use style::{ Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline, diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index e05d3bf..b404b89 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -117,6 +117,12 @@ pub struct Sheet { pub const DEFAULT_COL_WIDTH: f32 = 64.0; pub const DEFAULT_ROW_HEIGHT: f32 = 20.0; +/// Column width (points) → characters of the default font's digit width (7 px), without the +/// cell padding: the value the Column Width dialog shows. +pub fn points_to_chars(px: f64) -> f64 { + if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 } +} + impl Sheet { pub fn new(name: impl Into) -> Sheet { Sheet { From f63a437d5b9d0d26c718c96779637762ea71c181 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:07:32 +0200 Subject: [PATCH 101/162] Formulas: keep _xludf. so user-defined calls stay user-defined (fixes #726) The parser stripped _xludf. like _xlfn., but the two mean opposite things: _xludf. marks a name Excel must not treat as a built-in. Keeping it in the call name makes the call an unknown function (#NAME?, as in Excel) and the writer emits it unchanged, so _xludf.EOMONTH() no longer saves as an EOMONTH() call Excel repairs away. --- crates/calc/src/tests.rs | 5 +++++ crates/formula/src/parser.rs | 10 +++++++--- crates/formula/src/tests.rs | 1 + crates/functions/src/lib.rs | 3 ++- crates/xlsx/src/tests/fixtures.rs | 25 +++++++++++++++++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..6214206 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -167,6 +167,11 @@ fn errors_and_cycles() { assert!(t.get("B1").is_error()); t.set("C1", "=NOSUCHFN(1)"); assert_eq!(t.get("C1"), Value::Error(CellError::Name)); + // `_xludf.` marks a user-defined function, never the built-in of the same name (#726). + for f in ["=_xludf.CONCAT(\"a\",\"b\")", "=_xludf.SUM(1,2)", "=_xludf.EOMONTH()"] { + t.set("C3", f); + assert_eq!(t.get("C3"), Value::Error(CellError::Name), "{f}"); + } t.set("C2", "=1+"); assert_eq!(t.get("C2"), Value::Error(CellError::Name)); } diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 4fd3b7b..81b8fe0 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -88,15 +88,19 @@ fn word_is_cell(w: &str) -> Option { Some(Anchor { row, col, row_abs, col_abs }) } -/// `_xlfn.XLOOKUP` → `XLOOKUP`. +/// `_xlfn.XLOOKUP` → `XLOOKUP`. `_xludf.` is kept (`_xludf.SUM`): it marks a user-defined +/// function, never the built-in of the same name, and files need it back. pub fn normalize_function_name(name: &str) -> String { let u = name.to_ascii_uppercase(); - for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS.", "_XLUDF."] { + for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS."] { if let Some(r) = u.strip_prefix(p) { return r.to_string(); } } - u + match u.strip_prefix("_XLUDF.") { + Some(r) => format!("_xludf.{r}"), + None => u, + } } impl Parser { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c2..69491f6 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -48,6 +48,7 @@ fn normalises() { assert_eq!(rt("=sum(a1:b2)"), "SUM(A1:B2)"); assert_eq!(rt("= 1 + 2"), "1+2"); assert_eq!(rt("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); + assert_eq!(rt("_XLUDF.concat(\"a\",\"b\")"), "_xludf.CONCAT(\"a\",\"b\")"); assert_eq!(rt("true"), "TRUE"); assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1"); } diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 282b230..65612b4 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -197,7 +197,8 @@ fn registry() -> &'static (Vec, HashMap<&'static str, usize>) { }) } -/// Looks a function up by name (case-insensitive; `_xlfn.`, `_xlws.` and `_xludf.`-free names). +/// Looks a function up by name (case-insensitive; `_xlfn.`- and `_xlws.`-free names). A +/// `_xludf.` name is user-defined and never found. pub fn lookup(name: &str) -> Option<&'static FnSpec> { let mut n = name.trim().to_ascii_uppercase(); loop { diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 47dde5a..99221dd 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -495,3 +495,28 @@ fn text_box_formatting_is_read_and_written() { assert_eq!(rgb(g.text_outline.map(|o| o.0)), rgb(f.text_outline.map(|o| o.0))); assert_eq!(g.text_outline.map(|o| o.1), Some(1.5)); } + +/// `_xludf.` marks a user-defined call even when a built-in has the same name; dropping it turns +/// the call into the built-in, and `_xludf.EOMONTH()` into a call Excel repairs away (#726). +#[test] +fn user_defined_function_prefix_round_trips() { + let cases = + [("A1", "_xludf.EOMONTH()"), ("A2", "_xludf.CONCAT(\"a\",\"b\")"), ("A3", "_xludf.SUM(1,2)"), ("A4", "MYUDF(1)"), ("A5", "_xludf.MYUDF(1)")]; + let rows: String = cases + .iter() + .enumerate() + .map(|(i, (a, f))| format!(r#"{}"#, i + 1, f.replace('"', """))) + .collect(); + let (wb, _) = read_xlsx(&minimal(&format!("{rows}"), &[], "", "")).unwrap(); + let s = wb.sheet(0).unwrap(); + for (a, f) in cases { + assert_eq!(s.cell(at(a)).unwrap().formula.as_ref().unwrap().text, f, "read {a}"); + } + let bytes = crate::write_xlsx(&wb).unwrap(); + let mut pkg = crate::package::Package::open(&bytes).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + for (a, f) in cases { + let cell = sheet.child("sheetData").unwrap().kids("row").flat_map(|r| r.kids("c")).find(|c| c.attr("r") == Some(a)).unwrap(); + assert_eq!(cell.child("f").unwrap().text, f, "written {a}"); + } +} From 77a91a714a59c8d2d0869242ca385845e05a2024 Mon Sep 17 00:00:00 2001 From: msaulc <103976208+msaulc@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:07:43 -0600 Subject: [PATCH 102/162] Grid: extending the selection from the keyboard scrolls its moving end into view Ctrl+Shift+Arrow (and Shift+Arrow, Shift+PageUp/PageDown, Alt+Shift+PageUp/PageDown, Ctrl+Shift+Home/End, Shift+Home) extend the selection by moving its far corner while the active cell stays on the anchor. After the move the grid set `ensure_visible`, but the scroll-into-view step in `grid::show` always targeted `selection.active`, which is the anchor and is already on screen. So the view never followed the extension past the last visible row or column. The keyboard extension paths now also set `GridState::reveal_moving_end`, and the ensure-visible step then scrolls to `Selection::moving_end()`, the corner opposite the anchor. `selection.move` now uses that same helper (it had the logic inline) to find where an extension starts. Other ensure-visible callers (Go To, Name Box, plain moves) still reveal the active cell. Regression test: crates/ui-egui/tests/selection_scroll.rs drives Ctrl+Shift+Up/Down/ Left/Right, Shift+Down/Right and Ctrl+Shift+Home/End through real egui key events and checks that the moving end ends up on screen while the anchor and active cell stay put. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 9 +- crates/engine/src/selection.rs | 7 ++ crates/ui-egui/src/grid.rs | 9 +- crates/ui-egui/src/keymap.rs | 2 + crates/ui-egui/tests/selection_scroll.rs | 106 +++++++++++++++++++++++ 5 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/selection_scroll.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3da230b..2b9c2bd 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -420,14 +420,7 @@ fn selection_move(s: &mut Session, p: &Json) -> Result { let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; let sel = &mut d.selection; // Extension moves the far corner, not the active cell. - let from = if extend { - let r = sel.current(); - let far_row = if sel.anchor.row == r.start.row { r.end.row } else { r.start.row }; - let far_col = if sel.anchor.col == r.start.col { r.end.col } else { r.start.col }; - CellRef::new(far_row, far_col) - } else { - sel.active - }; + let from = if extend { sel.moving_end() } else { sel.active }; // Skip hidden rows/columns and step over merged areas. let mut to = if jmp { jump(sheet, from, dr.signum(), dc.signum()) diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs index 98f0c6c..27418a2 100644 --- a/crates/engine/src/selection.rs +++ b/crates/engine/src/selection.rs @@ -43,6 +43,13 @@ impl Selection { pub fn is_single_cell(&self) -> bool { self.ranges.len() == 1 && self.current().is_single() } + /// The corner of the current area opposite the anchor: the end that Shift+arrows move. + pub fn moving_end(&self) -> CellRef { + let r = self.current(); + let row = if self.anchor.row == r.start.row { r.end.row } else { r.start.row }; + let col = if self.anchor.col == r.start.col { r.end.col } else { r.start.col }; + CellRef::new(row, col) + } /// Extends the current area from the anchor to `to` (Shift+click / Shift+arrows). pub fn extend_to(&mut self, to: CellRef) { let r = RangeRef::new(self.anchor, to); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..4ee7895 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -68,6 +68,9 @@ pub enum Drag { pub struct GridState { pub drag: Drag, pub ensure_visible: bool, + /// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell + /// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves). + pub reveal_moving_end: bool, pub last_title: Option, pub drag_select: bool, pub rect: Option, @@ -533,10 +536,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { view.scroll.x = view.scroll.x.min(max_x); view.scroll.y = view.scroll.y.min(max_y); let mut geo = make_geo(app, view.scroll); - // Keep the active cell visible after keyboard moves. + // Keep the active cell (or the moving end of an extension) visible after keyboard moves. if app.grid.ensure_visible { app.grid.ensure_visible = false; - let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(sel.active); + let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active }; + let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); if paper { crate::paper_view::ensure_covers(app, &wb, si, target); geo = make_geo(app, view.scroll); @@ -2371,6 +2375,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let mv = |app: &mut SheetApp, dr: i64, dc: i64| { let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); app.grid.ensure_visible = true; + app.grid.reveal_moving_end = shift; }; match key { Key::ArrowUp => mv(app, -1, 0), diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 21bf41f..ef4b8ac 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -212,6 +212,7 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols }; let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift})); app.grid.ensure_visible = true; + app.grid.reveal_moving_end = m.shift; } Shortcut::Edit => app.begin_edit(None, false), Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()), @@ -243,4 +244,5 @@ fn extend(app: &mut SheetApp, anchor: CellRef, active: CellRef, to: CellRef) { d.selection.anchor = anchor; } app.grid.ensure_visible = true; + app.grid.reveal_moving_end = true; } diff --git a/crates/ui-egui/tests/selection_scroll.rs b/crates/ui-egui/tests/selection_scroll.rs new file mode 100644 index 0000000..06e0d18 --- /dev/null +++ b/crates/ui-egui/tests/selection_scroll.rs @@ -0,0 +1,106 @@ +//! Extending the selection from the keyboard scrolls its moving end into view, not the anchor. + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// Ctrl on Windows and Linux sets both `ctrl` and `command`. +const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true }; +const CTRL_SHIFT: Modifiers = Modifiers { shift: true, ..CTRL }; + +/// Column A filled down to A200 and row 1 filled across to CV1, far past one screen each way. +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + let down: Vec> = (1..=200).map(|r| vec![r]).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": down})).unwrap(); + let across: Vec = (1..=100).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": [across]})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +fn key(h: &mut Harness, k: Key, m: Modifiers) { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m }); + h.run_steps(2); +} + +/// Whether the cell's centre is on screen (a whole cell can sit a few pixels under the edge when +/// scrolling far down widens the row headings). +fn visible(h: &Harness, a1: &str) -> bool { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cells.contains(geo.cell_rect(sheet, CellRef::parse(a1).unwrap()).center()) +} + +fn assert_extended(h: &Harness, anchor: &str, moving_end: &str) { + let selection = &h.state().session.active().unwrap().selection; + let (anchor_cell, end_cell) = (CellRef::parse(anchor).unwrap(), CellRef::parse(moving_end).unwrap()); + assert_eq!(selection.current(), RangeRef::new(anchor_cell, end_cell)); + assert_eq!(selection.anchor, anchor_cell); + assert_eq!(selection.active, anchor_cell, "extending keeps the active cell on the anchor"); + assert!(visible(h, moving_end), "the moving end {moving_end} is scrolled into view"); +} + +#[test] +fn ctrl_shift_arrows_scroll_the_moving_end_into_view() { + // Down, then back Up past the top once the view has scrolled. + let mut h = harness(); + key(&mut h, Key::ArrowDown, CTRL_SHIFT); + assert_extended(&h, "A1", "A200"); + assert!(!visible(&h, "A1"), "the view follows the moving end, away from the anchor"); + let mut h = harness(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(visible(&h, "A200") && !visible(&h, "A1")); + key(&mut h, Key::ArrowUp, CTRL_SHIFT); + assert_extended(&h, "A200", "A1"); + // Right, then back Left. + let mut h = harness(); + key(&mut h, Key::ArrowRight, CTRL_SHIFT); + assert_extended(&h, "A1", "CV1"); + let mut h = harness(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(visible(&h, "CV1") && !visible(&h, "A1")); + key(&mut h, Key::ArrowLeft, CTRL_SHIFT); + assert_extended(&h, "CV1", "A1"); +} + +#[test] +fn shift_arrows_scroll_the_moving_end_into_view() { + let mut h = harness(); + for _ in 0..60 { + key(&mut h, Key::ArrowDown, Modifiers::SHIFT); + } + assert_extended(&h, "A1", "A61"); + for _ in 0..30 { + key(&mut h, Key::ArrowRight, Modifiers::SHIFT); + } + assert_extended(&h, "A1", "AE61"); +} + +#[test] +fn ctrl_shift_home_and_end_scroll_the_moving_end_into_view() { + let mut h = harness(); + key(&mut h, Key::End, CTRL_SHIFT); + assert_extended(&h, "A1", "CV200"); + let mut h = harness(); + key(&mut h, Key::End, CTRL); + assert!(!visible(&h, "A1")); + key(&mut h, Key::Home, CTRL_SHIFT); + assert_extended(&h, "CV200", "A1"); +} From 4d2e8e3455701857caae78162ad623740e53e0ee Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:16:00 +0200 Subject: [PATCH 103/162] Functions: database functions evaluate computed criteria (part of #737) A formula under a criteria label that names no field is evaluated for each record: its relative references move from the first record to that record, and the database labels name the record's fields, as in Excel. The calc evaluator passes the criteria reference to the database functions through Ctx::computed_criterion. --- crates/calc/src/eval.rs | 39 +++++++++++++-- crates/calc/src/tests.rs | 52 ++++++++++++++++++++ crates/functions/src/database.rs | 83 +++++++++++++++++--------------- crates/functions/src/lib.rs | 8 ++- 4 files changed, 137 insertions(+), 45 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..98c1179 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -88,6 +88,8 @@ pub struct Evaluator<'h> { struct FnCtx<'a, 'h> { ev: &'a mut Evaluator<'h>, + /// The criteria argument of a database function, when it is a reference. + criteria: Option, } impl Ctx for FnCtx<'_, '_> { @@ -100,6 +102,24 @@ impl Ctx for FnCtx<'_, '_> { fn random(&mut self) -> f64 { self.ev.host.random() } + /// Excel's "formula as criteria": the formula is written for the first record, so its relative + /// references move down to `record`'s row, and the database's labels name its fields. + fn computed_criterion(&mut self, row: usize, col: usize, db: &Array, record: usize) -> Option { + let area = self.criteria?; + let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?; + let expr = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?.expr_arc()?; + let shifted = gridcraft_formula::adjust::shift_relative((*expr).clone(), record as i64 - 1, 0); + let env = (0..db.cols) + .filter_map(|c| match db.get(0, c) { + Some(Value::Text(t)) if !t.trim().is_empty() => { + Some((t.trim().to_string(), Ev::V(db.get(record, c).cloned().unwrap_or(Value::Empty)))) + } + _ => None, + }) + .collect(); + let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth }; + Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) + } } impl<'h> Evaluator<'h> { @@ -579,7 +599,7 @@ impl<'h> Evaluator<'h> { let value = self.value(a); evaluated.push(Arg { value, from_ref: false }); } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria: None }; return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); } let i = match index.to_number() { @@ -762,8 +782,17 @@ impl<'h> Evaluator<'h> { let mut evaluated = Vec::with_capacity(args.len()); // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. let mut criteria_shape = None; + // Database functions: the criteria range (argument 2), whose formulas are computed criteria. + let mut criteria = None; for (i, a) in args.iter().enumerate() { let mut ev = self.eval(a); + if spec.category == gridcraft_functions::Category::Database + && i == 2 + && let Ev::R(areas) = &ev + && let [area] = areas.as_slice() + { + criteria = Some(*area); + } if resizes_sum_range(name, args.len()) { if i == 0 { criteria_shape = ev_shape(&ev); @@ -791,7 +820,7 @@ impl<'h> Evaluator<'h> { }; evaluated.push(Arg { value, from_ref }); } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria }; gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) } @@ -845,13 +874,13 @@ impl<'h> Evaluator<'h> { }; let args = [Arg { value, from_ref }]; if kind != Agg::Count { - match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) { Value::Number(n) => acc.sum += n, other => return Err(Ev::V(other)), } } if kind != Agg::Sum { - match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self }) { + match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) { Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), other => return Err(Ev::V(other)), } @@ -1221,7 +1250,7 @@ impl<'h> Evaluator<'h> { } } } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria: None }; return match gridcraft_functions::call(spec, &xargs, &mut ctx) { Value::Number(p) if p >= 1.0 => { let k = p as u32 - 1; diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..3eafc41 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -968,3 +968,55 @@ fn trim_references() { t.set("K20", "=SUM(Q1:.Q9)"); assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); } + +/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 46-49): a criteria cell holding +/// a formula under a label that names no field is evaluated for every record. +#[test] +fn database_functions_with_computed_criteria() { + let mut t = T::new(); + let db = [ + ["name", "age", "size", "headgear", "glasses"], + ["Huey", "6", "45", "cap~", "FALSE"], + ["Dewey", "7", "fourty-six", "cap?", "FALSE"], + ["Louie", "8", "47", "cap*", "FALSE"], + ["Donald", "32", "70", "sailor's hat", "FALSE"], + ["Scrooge", "62", "65", "top hat", "TRUE"], + ]; + for (r, row) in db.iter().enumerate() { + for (c, v) in row.iter().enumerate() { + t.set(&format!("{}{}", ["F", "G", "H", "I", "J"][c], r + 2), v); + } + } + t.set("G45", "7"); + let cases = [ + // Relative references are to the first record's row and move to each record's row; + // `$G$45` stays put. + ("G", "=G2<$G$45", "Dewey"), + // Column labels stand for the record's fields. + ("H", "=glasses=TRUE", "Scrooge"), + ("I", "=age<7", "Huey"), + ("J", "=size/7=10", "Donald"), + // An absolute reference to a cell that is not the first record's. + ("K", "=H3=$L$1", "Donald"), + ]; + t.set("L1", "70"); + for (i, (col, formula, _)) in cases.iter().enumerate() { + t.set(&format!("{col}46"), "Formula"); + t.set(&format!("{col}47"), formula); + t.set(&format!("B{}", 46 + i), &format!("=DGET($F$2:$J$7,\"name\",{col}46:{col}47)")); + } + for (i, (_, formula, want)) in cases.iter().enumerate() { + assert_eq!(t.get(&format!("B{}", 46 + i)), Value::text(*want), "{formula}"); + } + // Every database function takes them, ANDed with the other criteria of their row. + // Text sorts after numbers, so Dewey's "fourty-six" is > 40. + t.set("L47", "=size>40"); + t.set("M46", "age"); + t.set("M47", ">6"); + t.set("B51", "=DCOUNT($F$2:$J$7,\"age\",I46:I47)"); + t.set("B52", "=DSUM($F$2:$J$7,\"age\",G46:G47)"); + t.set("B53", "=DCOUNTA($F$2:$J$7,\"name\",L46:M47)"); + assert_eq!(t.num("B51"), 1.0); + assert_eq!(t.num("B52"), 7.0); + assert_eq!(t.num("B53"), 4.0); +} diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index bf0c611..67132e9 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -4,7 +4,9 @@ //! The database and the criteria are arrays whose first row holds column labels. Each criteria //! row is a set of conditions that must all hold (AND); a record matches when any criteria row //! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that -//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. +//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. A +//! formula under a label that names no field is a computed criterion, which the host evaluates +//! for each record ([`Ctx::computed_criterion`]). use std::borrow::Cow; @@ -64,9 +66,10 @@ fn cell_criterion(v: &Value) -> Option { } /// Rows (1-based within the database, excluding the header) of matching records. -fn matching_records(db: &Array, crit: &Array) -> Vec { - // Per criteria row: list of (database column or None when the label is unknown, criterion). - let mut rows: Vec, Criterion)>> = Vec::new(); +fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec { + // Per criteria row: its index and a list of (criteria column, database column or None when + // the label is unknown, criterion). + let mut rows: Vec<(usize, Vec<(usize, Option, Criterion)>)> = Vec::new(); for r in 1..crit.rows { let mut conds = Vec::new(); for c in 0..crit.cols { @@ -77,18 +80,20 @@ fn matching_records(db: &Array, crit: &Array) -> Vec { Some(Value::Number(n)) => find_column(db, &gridcraft_core::number_to_text(*n)), _ => None, }; - conds.push((col, cr)); + conds.push((c, col, cr)); } - rows.push(conds); + rows.push((r, conds)); } let all_match = rows.is_empty(); (1..db.rows) - .filter(|&r| { + .filter(|&rec| { all_match - || rows.iter().any(|conds| { - conds.iter().all(|(col, cr)| match col { - Some(c) => cr.matches(db.get(r, *c).unwrap_or(&Value::Empty)), - None => false, + || rows.iter().any(|(r, conds)| { + conds.iter().all(|(c, col, cr)| match col { + Some(f) => cr.matches(db.get(rec, *f).unwrap_or(&Value::Empty)), + // Under a label that names no field, only a formula (a computed + // criterion) can hold. + None => ctx.computed_criterion(*r, *c, db, rec).unwrap_or(false), }) }) }) @@ -101,7 +106,7 @@ struct Selection<'a> { records: Vec, } -fn select(a: &[Arg]) -> R> { +fn select<'a>(a: &'a [Arg], ctx: &mut dyn Ctx) -> R> { let dbv = &a.first().ok_or(CellError::Value)?.value; let critv = &a.get(2).ok_or(CellError::Value)?.value; if let Value::Error(e) = dbv { @@ -113,31 +118,31 @@ fn select(a: &[Arg]) -> R> { let db = as_array(dbv); let crit = as_array(critv); let field = field_index(&db, a)?; - let records = matching_records(&db, &crit); + let records = matching_records(&db, &crit, ctx); Ok(Selection { db, field, records }) } /// Numbers of the field among matching records (field required). -fn field_numbers(a: &[Arg]) -> R> { - let s = select(a)?; +fn field_numbers(a: &[Arg], c: &mut dyn Ctx) -> R> { + let s = select(a, c)?; let f = s.field.ok_or(CellError::Value)?; Ok(s.records.iter().filter_map(|&r| s.db.get(r, f).and_then(Value::as_f64)).collect()) } -fn dsum(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(field_numbers(a)?.iter().sum()) +fn dsum(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(field_numbers(a, c)?.iter().sum()) } -fn daverage(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn daverage(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; if v.is_empty() { return Err(CellError::Div0); } num_val(v.iter().sum::() / v.len() as f64) } -fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dcount(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let n = match s.field { Some(f) => s.records.iter().filter(|&&r| matches!(s.db.get(r, f), Some(Value::Number(_)))).count(), None => s.records.len(), @@ -145,8 +150,8 @@ fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(n as f64) } -fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dcounta(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let n = match s.field { Some(f) => s.records.iter().filter(|&&r| !matches!(s.db.get(r, f), None | Some(Value::Empty))).count(), None => s.records.len(), @@ -154,8 +159,8 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(n as f64) } -fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dget(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let f = s.field.ok_or(CellError::Value)?; // Records whose field is blank (or "") don't count, as in Excel. let mut found = @@ -167,18 +172,18 @@ fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { } } -fn dmax(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dmax(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0)) } -fn dmin(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dmin(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0)) } -fn dproduct(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dproduct(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; if v.is_empty() { return num_val(0.0); } @@ -196,20 +201,20 @@ fn variance(v: &[f64], sample: bool) -> R { Ok(ss / if sample { n - 1.0 } else { n }) } -fn dvar(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, true)?) +fn dvar(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, true)?) } -fn dvarp(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, false)?) +fn dvarp(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, false)?) } -fn dstdev(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, true)?.sqrt()) +fn dstdev(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, true)?.sqrt()) } -fn dstdevp(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, false)?.sqrt()) +fn dstdevp(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, false)?.sqrt()) } pub(crate) fn specs() -> Vec { diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 282b230..0c5ede3 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::sync::OnceLock; -use gridcraft_core::{CellError, DateSystem, Value}; +use gridcraft_core::{Array, CellError, DateSystem, Value}; /// Builds a [`FnSpec`] whose implementation returns `Result`. /// @@ -105,6 +105,12 @@ pub trait Ctx { fn now_serial(&self) -> f64; /// Uniform in [0, 1). fn random(&mut self) -> f64; + /// For the database functions: whether the formula in the criteria cell at (`row`, `col`) holds + /// for `record` (a row of `db` below its labels). `None` when that cell holds no formula or the + /// criteria didn't come from a reference. + fn computed_criterion(&mut self, _row: usize, _col: usize, _db: &Array, _record: usize) -> Option { + None + } } pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value; From 76e7f1de073ef993ee05dfd6e088acaf41de6b2f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 19:25:51 +0200 Subject: [PATCH 104/162] Grid: the selection outline stays visible when extended to the last row (#687) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/grid.rs | 48 +++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..1f8cede 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..861197c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1373,6 +1373,26 @@ fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool { r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0 } +/// The four sides of a `width` wide outline centred on `rect`, cut to `clip` (a little beyond it). +/// +/// A stroked rectangle that spans a whole column (20 million points tall) loses its precision in +/// the tessellator and its left and right sides vanish; sides filled from rectangles that stay +/// near the viewport do not. Sides wholly outside `clip` are left out. +pub(crate) fn outline_bands(rect: Rect, width: f32, clip: Rect) -> Vec { + if !rect.is_finite() || !clip.is_finite() { + return Vec::new(); + } + let h = width / 2.0; + let clip = clip.expand(width * 2.0); + let sides = [ + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.left() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.right() - h, rect.top() - h), pos2(rect.right() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.right() + h, rect.top() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.bottom() - h), pos2(rect.right() + h, rect.bottom() + h)), + ]; + sides.into_iter().map(|s| s.intersect(clip)).filter(|s| s.is_finite() && s.width() > 0.0 && s.height() > 0.0).collect() +} + /// `outline` is where the current area's border is drawn this frame (see `selection_glide`). #[allow(clippy::too_many_arguments)] pub(crate) fn paint_selection( @@ -1415,7 +1435,9 @@ pub(crate) fn paint_selection( } } if cur_here { - p.rect_stroke(outline.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); + for side in outline_bands(outline.expand(0.5), 2.0, p.clip_rect()) { + p.rect_filled(side, 0.0, t.sel_border); + } } if active_here && sel.ranges.len() > 1 { p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); @@ -3238,3 +3260,27 @@ mod pane_tests { assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle))); } } + +#[cfg(test)] +mod outline_band_tests { + use super::*; + + #[test] + fn whole_column_outline_keeps_left_and_right_sides_near_viewport() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + let col = Rect::from_min_max(pos2(100.0, 40.0), pos2(180.0, 20_971_540.0)); + let sides = outline_bands(col, 2.0, clip); + // Left, right and top (in view); the bottom is 20 million points away. + assert_eq!(sides.len(), 3); + assert!(sides.iter().all(|s| s.bottom() <= clip.bottom() + 4.0 + f32::EPSILON)); + assert!(sides.iter().any(|s| s.left() < 100.0 && s.right() > 100.0 && s.height() > 500.0)); + assert!(sides.iter().any(|s| s.left() < 180.0 && s.right() > 180.0 && s.height() > 500.0)); + } + + #[test] + fn outline_outside_the_view_and_non_finite_draw_nothing() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + assert!(outline_bands(Rect::from_min_max(pos2(900.0, 0.0), pos2(950.0, 20.0)), 2.0, clip).is_empty()); + assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty()); + } +} From 1767a28371232df6871e1f6bc926548759c76eac Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:43:33 +0200 Subject: [PATCH 105/162] =?UTF-8?q?Numfmt:=20scientific=20formats=20with?= =?UTF-8?q?=20a=20literal=20after=20E=C2=B1,=20a=20%=20or=20a=20comma=20ma?= =?UTF-8?q?tch=20Excel=20(fixes=20#768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exponent sign goes right before the exponent digits, after any literal that follows the E; ? pads exponent digits with spaces; % and commas never scale a scientific section; zero shows 0 in every integer placeholder. Checked against the 248 TEXT() rows of Apache POI's NumberFormatApproxTests.xlsx, copied as data into a test. --- crates/numfmt/src/parse.rs | 5 + crates/numfmt/src/render.rs | 34 ++- .../numfmt/tests/poi_number_format_approx.rs | 272 ++++++++++++++++++ 3 files changed, 298 insertions(+), 13 deletions(-) create mode 100644 crates/numfmt/tests/poi_number_format_approx.rs diff --git a/crates/numfmt/src/parse.rs b/crates/numfmt/src/parse.rs index e54f04c..6df22ad 100644 --- a/crates/numfmt/src/parse.rs +++ b/crates/numfmt/src/parse.rs @@ -513,6 +513,7 @@ fn analyze(raw: Raw) -> Section { let mut seen_exp = false; let mut seen_digit = false; let mut fraction_slash = false; + let scientific = toks.iter().skip_while(|t| !matches!(t, Tok::Digit(_))).any(|t| matches!(t, Tok::Exp { .. })); for i in 0..toks.len() { let Some(t) = toks.get(i).cloned() else { break }; let replace = match t { @@ -547,6 +548,10 @@ fn analyze(raw: Raw) -> Section { if seen_digit && next_digit && !seen_point && !seen_exp { sec.thousands = true; Some(Tok::Lit(String::new())) + } else if seen_digit && scientific { + // Observed: a scientific section never scales; any other comma after the + // first digit shows nothing (`#,e+#`, `#e+#,`, `#e+000,00`). + Some(Tok::Lit(String::new())) } else if seen_digit && !next_digit { sec.scale += 1; Some(Tok::Lit(String::new())) diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 7befe59..6635d3a 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -231,7 +231,9 @@ fn digit_indices(toks: &[Tok], range: std::ops::Range) -> Vec { /// Renders a non-negative number through a number section. pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let mut x = v.abs(); - if sec.percent > 0 { + // Observed: `%` in a scientific section is only a literal (`#%e+#` shows 123456.789 as + // `1%e+5`). + if sec.percent > 0 && !sec.exp { x *= 100f64.powi(sec.percent.min(20)); } if sec.scale > 0 { @@ -264,7 +266,9 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let exp_idx = digit_indices(toks, e_at + 1..n); let step = int_idx.len().max(1) as i32; let (mut e, int_s, frac_s) = if x == 0.0 { - (0i32, String::new(), "0".repeat(frac_idx.len())) + // Observed: zero shows `0` in every integer placeholder, `#` and `?` too + // (`#.#E+#` gives `0.E+0`, `####.####E+#` gives `0000.E+0`). + (0i32, "0".repeat(int_idx.len()), "0".repeat(frac_idx.len())) } else { let (digits, xe) = decimal_digits(x); let mut e = xe - xe.rem_euclid(step); @@ -283,26 +287,30 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { if int_s.is_empty() && frac_s.chars().all(|c| c == '0') { e = 0; } - let mut head = String::from(if upper { "E" } else { "e" }); - if e < 0 { - head.push('-'); + let head = if upper { "E" } else { "e" }; + let sign = if e < 0 { + "-" } else if plus { - head.push('+'); - } - let width = exp_idx.iter().filter(|&&i| matches!(toks.get(i), Some(Tok::Digit('0')))).count().min(20); - let digits = format!("{:0w$}", e.unsigned_abs(), w = width); + "+" + } else { + "" + }; + let digits = e.unsigned_abs().to_string(); + // The sign sits right before the exponent digits, after any literal that follows the + // `E` (observed: `|0|e+|0|` shows `|1|e|+5|`); `?` pads the digits with spaces. match exp_idx.first() { Some(&first) => { if let Some(p) = pieces.get_mut(e_at) { - *p = head; + *p = head.to_string(); } - if let Some(p) = pieces.get_mut(first) { - *p = digits; + if let Some(b) = before.get_mut(first) { + *b = sign.to_string(); } + fill_int(toks, &exp_idx, &digits, false, false, &mut pieces); } None => { if let Some(p) = pieces.get_mut(e_at) { - *p = head + &digits; + *p = format!("{head}{sign}{digits}"); } } } diff --git a/crates/numfmt/tests/poi_number_format_approx.rs b/crates/numfmt/tests/poi_number_format_approx.rs new file mode 100644 index 0000000..0f99321 --- /dev/null +++ b/crates/numfmt/tests/poi_number_format_approx.rs @@ -0,0 +1,272 @@ +//! `TEXT(value, format)` against the results Excel cached in Apache POI's +//! `test-data/spreadsheet/NumberFormatApproxTests.xlsx` (sheet `Tests`: B = format, C = value, +//! A = `=TEXT(C, B)`). The workbook is not committed; its rows are copied here as data. + +use gridcraft_core::{DateSystem, Value}; +use gridcraft_numfmt::text_function; + +/// (format, value, Excel's result). +// The last row's 3.14159 is the workbook's value, not an approximation of pi. +#[allow(clippy::approx_constant)] +const CASES: &[(&str, f64, &str)] = &[ + (r"|#,e-#|", 123456.789, r"|1e5|"), + (r"|#e-#,|", 123456.789, r"|1e5|"), + (r"|#%e-#|", 123456.789, r"|1%e5|"), + (r"|#e-#%|", 123456.789, r"|1e5%|"), + (r"|#|e-|#|", 123456.789, r"|1|e|5|"), + (r"|#.#|e-|#|", 123456.789, r"|1.2|e|5|"), + (r"|####.####|e-|#|", 123456.789, r"|12.3457|e|4|"), + (r"|#######.####|e-|#|", 123456.789, r"|123456.789|e|0|"), + (r"|#,######.####|e-|#|", 123456.789, r"|123,456.789|e|0|"), + (r"|0|e-|0|", 123456.789, r"|1|e|5|"), + (r"|0.0|e-|0|", 123456.789, r"|1.2|e|5|"), + (r"|0000.0000|e-|0|", 123456.789, r"|0012.3457|e|4|"), + (r"|0000000.0000|e-|0|", 123456.789, r"|0123456.7890|e|0|"), + (r"|0,000000.0000|e-|0|", 123456.789, r"|0,123,456.7890|e|0|"), + (r"|?|e-|?|", 123456.789, r"|1|e|5|"), + (r"|?.?|e-|?|", 123456.789, r"|1.2|e|5|"), + (r"|????.????|e-|?|", 123456.789, r"| 12.3457|e|4|"), + (r"|???????.????|e-|?|", 123456.789, r"| 123456.789 |e|0|"), + (r"|?,??????.????|e-|?|", 123456.789, r"| 123,456.789 |e|0|"), + (r"|#,e+#|", 123456.789, r"|1e+5|"), + (r"|#e+#,|", 123456.789, r"|1e+5|"), + (r"|#%e+#|", 123456.789, r"|1%e+5|"), + (r"|#e+#%|", 123456.789, r"|1e+5%|"), + (r"|#|e+|#|", 123456.789, r"|1|e|+5|"), + (r"|#.#|e+|#|", 123456.789, r"|1.2|e|+5|"), + (r"|####.####|e+|#|", 123456.789, r"|12.3457|e|+4|"), + (r"|#######.####|e+|#|", 123456.789, r"|123456.789|e|+0|"), + (r"|#,######.####|e+|#|", 123456.789, r"|123,456.789|e|+0|"), + (r"|0|e+|0|", 123456.789, r"|1|e|+5|"), + (r"|0.0|e+|0|", 123456.789, r"|1.2|e|+5|"), + (r"|0000.0000|e+|0|", 123456.789, r"|0012.3457|e|+4|"), + (r"|0000000.0000|e+|0|", 123456.789, r"|0123456.7890|e|+0|"), + (r"|0,000000.0000|e+|0|", 123456.789, r"|0,123,456.7890|e|+0|"), + (r"|?|e+|?|", 123456.789, r"|1|e|+5|"), + (r"|?.?|e+|?|", 123456.789, r"|1.2|e|+5|"), + (r"|????.????|e+|?|", 123456.789, r"| 12.3457|e|+4|"), + (r"|???????.????|e+|?|", 123456.789, r"| 123456.789 |e|+0|"), + (r"|?,??????.????|e+|?|", 123456.789, r"| 123,456.789 |e|+0|"), + (r"|#.#|E-|#|", 123456.789, r"|1.2|E|5|"), + (r"|#.#|E+|#|", 123456.789, r"|1.2|E|+5|"), + (r"|#,e-#|", 1.23456789e-05, r"|1e-5|"), + (r"|#e-#,|", 1.23456789e-05, r"|1e-5|"), + (r"|#%e-#|", 1.23456789e-05, r"|1%e-5|"), + (r"|#e-#%|", 1.23456789e-05, r"|1e-5%|"), + (r"|#|e-|#|", 1.23456789e-05, r"|1|e|-5|"), + (r"|#.#|e-|#|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|####.####|e-|#|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|#######.####|e-|#|", 1.23456789e-05, r"|123.4568|e|-7|"), + (r"|#,######.####|e-|#|", 1.23456789e-05, r"|123.4568|e|-7|"), + (r"|0|e-|0|", 1.23456789e-05, r"|1|e|-5|"), + (r"|0.0|e-|0|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|0000.0000|e-|0|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|0000000.0000|e-|0|", 1.23456789e-05, r"|0000123.4568|e|-7|"), + (r"|0,000000.0000|e-|0|", 1.23456789e-05, r"|0,000,123.4568|e|-7|"), + (r"|?|e-|?|", 1.23456789e-05, r"|1|e|-5|"), + (r"|?.?|e-|?|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|????.????|e-|?|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|???????.????|e-|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|?,??????.????|e-|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|#,e+#|", 1.23456789e-05, r"|1e-5|"), + (r"|#e+#,|", 1.23456789e-05, r"|1e-5|"), + (r"|#%e+#|", 1.23456789e-05, r"|1%e-5|"), + (r"|#e+#%|", 1.23456789e-05, r"|1e-5%|"), + (r"|#|e+|#|", 1.23456789e-05, r"|1|e|-5|"), + (r"|#.#|e+|#|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|#.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"), + (r"|##.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"), + (r"|###.####|e+|#|", 0.000123456789, r"|123.4568|e|-6|"), + (r"|####.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"), + (r"|#####.####|e+|#|", 0.000123456789, r"|12.3457|e|-5|"), + (r"|#######.####|e+|#|", 0.000123456789, r"|1234.5679|e|-7|"), + (r"|#.####|e+|#|", 1.23456789e-05, r"|1.2346|e|-5|"), + (r"|##.####|e+|#|", 1.23456789e-05, r"|12.3457|e|-6|"), + (r"|###.####|e+|#|", 1.23456789e-05, r"|12.3457|e|-6|"), + (r"|####.####|e+|#|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|#####.####|e+|#|", 1.23456789e-05, r"|1.2346|e|-5|"), + (r"|#######.####|e+|#|", 1.23456789e-05, r"|123.4568|e|-7|"), + (r"|#.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"), + (r"|##.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"), + (r"|###.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"), + (r"|####.####|e+|#|", 1.23456789e-06, r"|123.4568|e|-8|"), + (r"|#####.####|e+|#|", 1.23456789e-06, r"|12345.6789|e|-10|"), + (r"|#######.####|e+|#|", 1.23456789e-06, r"|12.3457|e|-7|"), + (r"|#,######.####|e+|#|", 1.23456789e-06, r"|12.3457|e|-7|"), + (r"|0|e+|0|", 1.23456789e-05, r"|1|e|-5|"), + (r"|0.0|e+|0|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|0000.0000|e+|0|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|0000000.0000|e+|0|", 1.23456789e-05, r"|0000123.4568|e|-7|"), + (r"|0,000000.0000|e+|0|", 1.23456789e-05, r"|0,000,123.4568|e|-7|"), + (r"|?|e+|?|", 1.23456789e-05, r"|1|e|-5|"), + (r"|?.?|e+|?|", 1.23456789e-05, r"|1.2|e|-5|"), + (r"|????.????|e+|?|", 1.23456789e-05, r"|1234.5679|e|-8|"), + (r"|???????.????|e+|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|?,??????.????|e+|?|", 1.23456789e-05, r"| 123.4568|e|-7|"), + (r"|#.#|E-|#|", 1.23456789e-05, r"|1.2|E|-5|"), + (r"|#.#|E+|#|", 1.23456789e-05, r"|1.2|E|-5|"), + (r"|#,e-#|", 0.0, r"|0e0|"), + (r"|#e-#,|", 0.0, r"|0e0|"), + (r"|#%e-#|", 0.0, r"|0%e0|"), + (r"|#e-#%|", 0.0, r"|0e0%|"), + (r"|#|e-|#|", 0.0, r"|0|e|0|"), + (r"|#.#|e-|#|", 0.0, r"|0.|e|0|"), + (r"|####.####|e-|#|", 0.0, r"|0000.|e|0|"), + (r"|#######.####|e-|#|", 0.0, r"|0000000.|e|0|"), + (r"|#,######.####|e-|#|", 0.0, r"|0,000,000.|e|0|"), + (r"|0|e-|0|", 0.0, r"|0|e|0|"), + (r"|0.0|e-|0|", 0.0, r"|0.0|e|0|"), + (r"|0000.0000|e-|0|", 0.0, r"|0000.0000|e|0|"), + (r"|0000000.0000|e-|0|", 0.0, r"|0000000.0000|e|0|"), + (r"|0,000000.0000|e-|0|", 0.0, r"|0,000,000.0000|e|0|"), + (r"|?|e-|?|", 0.0, r"|0|e|0|"), + (r"|?.?|e-|?|", 0.0, r"|0. |e|0|"), + (r"|????.????|e-|?|", 0.0, r"|0000. |e|0|"), + (r"|???????.????|e-|?|", 0.0, r"|0000000. |e|0|"), + (r"|?,??????.????|e-|?|", 0.0, r"|0,000,000. |e|0|"), + (r"|#,e+#|", 0.0, r"|0e+0|"), + (r"|#e+#,|", 0.0, r"|0e+0|"), + (r"|#%e+#|", 0.0, r"|0%e+0|"), + (r"|#e+#%|", 0.0, r"|0e+0%|"), + (r"|#|e+|#|", 0.0, r"|0|e|+0|"), + (r"|#.#|e+|#|", 0.0, r"|0.|e|+0|"), + (r"|####.####|e+|#|", 0.0, r"|0000.|e|+0|"), + (r"|#######.####|e+|#|", 0.0, r"|0000000.|e|+0|"), + (r"|#,######.####|e+|#|", 0.0, r"|0,000,000.|e|+0|"), + (r"|0|e+|0|", 0.0, r"|0|e|+0|"), + (r"|0.0|e+|0|", 0.0, r"|0.0|e|+0|"), + (r"|0000.0000|e+|0|", 0.0, r"|0000.0000|e|+0|"), + (r"|0000000.0000|e+|0|", 0.0, r"|0000000.0000|e|+0|"), + (r"|0,000000.0000|e+|0|", 0.0, r"|0,000,000.0000|e|+0|"), + (r"|?|e+|?|", 0.0, r"|0|e|+0|"), + (r"|?.?|e+|?|", 0.0, r"|0. |e|+0|"), + (r"|????.????|e+|?|", 0.0, r"|0000. |e|+0|"), + (r"|???????.????|e+|?|", 0.0, r"|0000000. |e|+0|"), + (r"|?,??????.????|e+|?|", 0.0, r"|0,000,000. |e|+0|"), + (r"|#.#|E-|#|", 0.0, r"|0.|E|0|"), + (r"|#.#|E+|#|", 0.0, r"|0.|E|+0|"), + (r"|#,e-#|", -123456.789, r"-|1e5|"), + (r"|#e-#,|", -123456.789, r"-|1e5|"), + (r"|#%e-#|", -123456.789, r"-|1%e5|"), + (r"|#e-#%|", -123456.789, r"-|1e5%|"), + (r"|#|e-|#|", -123456.789, r"-|1|e|5|"), + (r"|#.#|e-|#|", -123456.789, r"-|1.2|e|5|"), + (r"|####.####|e-|#|", -123456.789, r"-|12.3457|e|4|"), + (r"|#######.####|e-|#|", -123456.789, r"-|123456.789|e|0|"), + (r"|#,######.####|e-|#|", -123456.789, r"-|123,456.789|e|0|"), + (r"|0|e-|0|", -123456.789, r"-|1|e|5|"), + (r"|0.0|e-|0|", -123456.789, r"-|1.2|e|5|"), + (r"|0000.0000|e-|0|", -123456.789, r"-|0012.3457|e|4|"), + (r"|0000000.0000|e-|0|", -123456.789, r"-|0123456.7890|e|0|"), + (r"|0,000000.0000|e-|0|", -123456.789, r"-|0,123,456.7890|e|0|"), + (r"|?|e-|?|", -123456.789, r"-|1|e|5|"), + (r"|?.?|e-|?|", -123456.789, r"-|1.2|e|5|"), + (r"|????.????|e-|?|", -123456.789, r"-| 12.3457|e|4|"), + (r"|???????.????|e-|?|", -123456.789, r"-| 123456.789 |e|0|"), + (r"|?,??????.????|e-|?|", -123456.789, r"-| 123,456.789 |e|0|"), + (r"|#,e+#|", -123456.789, r"-|1e+5|"), + (r"|#e+#,|", -123456.789, r"-|1e+5|"), + (r"|#%e+#|", -123456.789, r"-|1%e+5|"), + (r"|#e+#%|", -123456.789, r"-|1e+5%|"), + (r"|#|e+|#|", -123456.789, r"-|1|e|+5|"), + (r"|#.#|e+|#|", -123456.789, r"-|1.2|e|+5|"), + (r"|####.####|e+|#|", -123456.789, r"-|12.3457|e|+4|"), + (r"|#######.####|e+|#|", -123456.789, r"-|123456.789|e|+0|"), + (r"|#,######.####|e+|#|", -123456.789, r"-|123,456.789|e|+0|"), + (r"|0|e+|0|", -123456.789, r"-|1|e|+5|"), + (r"|0.0|e+|0|", -123456.789, r"-|1.2|e|+5|"), + (r"|0000.0000|e+|0|", -123456.789, r"-|0012.3457|e|+4|"), + (r"|0000000.0000|e+|0|", -123456.789, r"-|0123456.7890|e|+0|"), + (r"|0,000000.0000|e+|0|", -123456.789, r"-|0,123,456.7890|e|+0|"), + (r"|?|e+|?|", -123456.789, r"-|1|e|+5|"), + (r"|?.?|e+|?|", -123456.789, r"-|1.2|e|+5|"), + (r"|????.????|e+|?|", -123456.789, r"-| 12.3457|e|+4|"), + (r"|???????.????|e+|?|", -123456.789, r"-| 123456.789 |e|+0|"), + (r"|?,??????.????|e+|?|", -123456.789, r"-| 123,456.789 |e|+0|"), + (r"|#.#|E-|#|", -123456.789, r"-|1.2|E|5|"), + (r"|#.#|E+|#|", -123456.789, r"-|1.2|E|+5|"), + (r"|#,e-#|", -1.23456789e-05, r"-|1e-5|"), + (r"|#e-#,|", -1.23456789e-05, r"-|1e-5|"), + (r"|#%e-#|", -1.23456789e-05, r"-|1%e-5|"), + (r"|#e-#%|", -1.23456789e-05, r"-|1e-5%|"), + (r"|#|e-|#|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|#.#|e-|#|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|####.####|e-|#|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|#######.####|e-|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|#,######.####|e-|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|0|e-|0|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|0.0|e-|0|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|0000.0000|e-|0|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|0000000.0000|e-|0|", -1.23456789e-05, r"-|0000123.4568|e|-7|"), + (r"|0,000000.0000|e-|0|", -1.23456789e-05, r"-|0,000,123.4568|e|-7|"), + (r"|?|e-|?|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|?.?|e-|?|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|????.????|e-|?|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|???????.????|e-|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|?,??????.????|e-|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|#,e+#|", -1.23456789e-05, r"-|1e-5|"), + (r"|#e+#,|", -1.23456789e-05, r"-|1e-5|"), + (r"|#%e+#|", -1.23456789e-05, r"-|1%e-5|"), + (r"|#e+#%|", -1.23456789e-05, r"-|1e-5%|"), + (r"|#|e+|#|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|#.#|e+|#|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|####.####|e+|#|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|#######.####|e+|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|#,######.####|e+|#|", -1.23456789e-05, r"-|123.4568|e|-7|"), + (r"|0|e+|0|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|0.0|e+|0|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|0000.0000|e+|0|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|0000000.0000|e+|0|", -1.23456789e-05, r"-|0000123.4568|e|-7|"), + (r"|0,000000.0000|e+|0|", -1.23456789e-05, r"-|0,000,123.4568|e|-7|"), + (r"|?|e+|?|", -1.23456789e-05, r"-|1|e|-5|"), + (r"|?.?|e+|?|", -1.23456789e-05, r"-|1.2|e|-5|"), + (r"|????.????|e+|?|", -1.23456789e-05, r"-|1234.5679|e|-8|"), + (r"|???????.????|e+|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|?,??????.????|e+|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"), + (r"|#.#|E-|#|", -1.23456789e-05, r"-|1.2|E|-5|"), + (r"|#.#|E+|#|", -1.23456789e-05, r"-|1.2|E|-5|"), + (r"|#.#|e+|#|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|#####|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|0|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|000|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|0000|", 1.2345678e+142, r"|1.2|e|+0142|"), + (r"|#.#|e+|00000|", 1.2345678e+142, r"|1.2|e|+00142|"), + (r"|#.#|e+|?|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|???|", 1.2345678e+142, r"|1.2|e|+142|"), + (r"|#.#|e+|????|", 1.2345678e+142, r"|1.2|e|+ 142|"), + (r"|#.#|e+|?????|", 1.2345678e+142, r"|1.2|e|+ 142|"), + (r"|#.#|e+|#|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|#####|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|0|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|000|", 0.0, r"|0.|e|+000|"), + (r"|#.#|e+|0000|", 0.0, r"|0.|e|+0000|"), + (r"|#.#|e+|00000|", 0.0, r"|0.|e|+00000|"), + (r"|#.#|e+|?|", 0.0, r"|0.|e|+0|"), + (r"|#.#|e+|???|", 0.0, r"|0.|e|+ 0|"), + (r"|#.#|e+|????|", 0.0, r"|0.|e|+ 0|"), + (r"|#.#|e+|?????|", 0.0, r"|0.|e|+ 0|"), + (r"|#.#|e+|#|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|#####|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|0|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|000|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|0000|", 1.2345678e-142, r"|1.2|e|-0142|"), + (r"|#.#|e+|00000|", 1.2345678e-142, r"|1.2|e|-00142|"), + (r"|#.#|e+|000,00|", 1.2345678e-142, r"|1.2|e|-00142|"), + (r"|#.#|e+|?|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|???|", 1.2345678e-142, r"|1.2|e|-142|"), + (r"|#.#|e+|????|", 1.2345678e-142, r"|1.2|e|- 142|"), + (r"|#.#|e+|?????|", 1.2345678e-142, r"|1.2|e|- 142|"), + (r"#,##0.00e+00", 3.14159, r"3.14e+00"), +]; + +#[test] +fn poi_number_format_approx_tests() { + let wrong: Vec = CASES + .iter() + .filter_map(|&(code, v, want)| { + let got = text_function(&Value::Number(v), code, DateSystem::D1900); + (got.as_deref() != Ok(want)).then(|| format!("TEXT({v}, {code:?}) = {got:?}, Excel {want:?}")) + }) + .collect(); + assert!(wrong.is_empty(), "{} of {} cases differ:\n{}", wrong.len(), CASES.len(), wrong.join("\n")); +} From 5524e2520a2f9d303834c56455add045c247c58b Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:36:37 -0400 Subject: [PATCH 106/162] Functions: COUNTIF family returns #VALUE! for criteria over 255 characters Excel rejects a text criterion longer than 255 characters in COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS with #VALUE!; GridCraft matched it anyway. Found by the calc oracle on an Excel-saved report where a 268-character concatenated criterion caches #VALUE!. Co-Authored-By: Claude Opus 5.5 --- crates/functions/src/criteria.rs | 13 +++++++++++++ crates/functions/src/math.rs | 18 +++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 20410c8..db010fc 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -5,6 +5,9 @@ use std::cmp::Ordering; use gridcraft_core::parse::parse_number_text; use gridcraft_core::{CellError, Value, compare_numbers, compare_text}; +/// Longest text criterion COUNTIF and its relatives accept; a longer one is `#VALUE!`. +const MAX_CRITERION_CHARS: usize = 255; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Op { Eq, @@ -48,6 +51,16 @@ impl Criterion { } } + /// Parses the criteria argument of COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS: + /// like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + pub(crate) fn parse_arg(v: &Value) -> Result { + match v { + Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), + Value::Text(t) if t.chars().nth(MAX_CRITERION_CHARS).is_some() => Err(CellError::Value), + other => Ok(Criterion::parse(other)), + } + } + fn parse_text(t: &str) -> Criterion { let (op, rest) = if let Some(r) = t.strip_prefix(">=") { (Op::Ge, r) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index bab219e..1233a40 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -957,7 +957,7 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R R { fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; num_val(range.iter().filter(|v| c.matches(v)).count() as f64) } @@ -1026,7 +1026,7 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { /// the evaluator does that resizing (it has the references), so cells line up one to one here. fn if_numbers(a: &[Arg]) -> R> { let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None }; let mut out = Vec::new(); for r in 0..range.rows { @@ -1643,4 +1643,16 @@ mod tests { is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); } + + #[test] + fn criteria_longer_than_255_chars_are_value_errors() { + let ok = "x".repeat(255); + let long = "x".repeat(256); + let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]); + let qty = col(&[1.0, 2.0]); + close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("SUMIFS", vec![rf(qty), rf(cells), t(&long)]), CellError::Value); + } } From caf31932611bc2e774c294a1c9809836c5c77882 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:19:29 -0400 Subject: [PATCH 107/162] Functions: *IFS with two or more criteria pairs accept criteria over 255 characters, as in Excel --- crates/functions/src/criteria.rs | 5 +++-- crates/functions/src/math.rs | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index db010fc..2c87a9a 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -51,8 +51,9 @@ impl Criterion { } } - /// Parses the criteria argument of COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS: - /// like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + /// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS + /// functions: like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + /// The *IFS functions with two or more criteria pairs use `parse`: Excel doesn't limit them. pub(crate) fn parse_arg(v: &Value) -> Result { match v { Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 1233a40..75cea93 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -950,6 +950,10 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R R0")]), 1.0); + close(ev("COUNTIFS", vec![rf(qty.clone()), t(">0"), rf(cells.clone()), t(&long)]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty), t(">0")]), 2.0); } } From 531dc5a5aa243ad0eaba439233d39f75d0fc3fd1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 20:21:19 +0200 Subject: [PATCH 108/162] functions: MODE comments name Excel 365 as the reference, not POI's cached values (#736) --- crates/functions/src/stat.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 4afa108..2fc11ac 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -79,11 +79,12 @@ fn mode_sngl(v: &[f64]) -> R { } /// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers -/// count, like [`numbers`]. Scalar arguments differ from SUM: text and logical values are skipped -/// whether typed or referenced (even numeric text such as `"1"`, so `MODE(1,"1",2,3)` is `#N/A`, -/// and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference to a blank cell is -/// `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to right, so the -/// first error wins. +/// count, like [`numbers`]. Scalar arguments differ from SUM: in Excel 365, text and logical +/// values are skipped whether typed or referenced (even numeric text such as `"1"`, so +/// `MODE(1,"1",2,3)` is `#N/A`, and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference +/// to a blank cell is `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to +/// right, so the first error wins. The results cached in POI's FormulaEvalTestData come from an +/// older Excel that answered `#VALUE!` for typed text; they are not the reference here. fn mode_numbers(args: &[Arg]) -> R> { let mut out = Vec::new(); for a in args { @@ -2279,9 +2280,11 @@ mod tests { assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); } - /// MODE argument semantics from the Excel-cached results in FormulaEvalTestData (row "Mode"): + /// MODE argument semantics in Excel 365, on the inputs of FormulaEvalTestData (row "Mode"): /// text is skipped, typed or referenced, but a single-cell reference to a blank cell is - /// `#VALUE!`; the first error from the left wins. + /// `#VALUE!`; the first error from the left wins. The values cached in POI's file come from an + /// older Excel and say `#VALUE!` for `MODE(C9,E16,"1",B8)`, `MODE(B8,"1",2,3)` and + /// `MODE("1",1,#DIV/0!,2,3)`; Excel 365 gives `#N/A`, `#N/A` and `#DIV/0!`. #[test] fn mode_scalar_arguments() { let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]); From 47c0af3f1b8887c6ace710b8a1fc047bb41e6b91 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:34:07 +0200 Subject: [PATCH 109/162] Data: Forecast Sheet Forecast Start option (#785) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/forecast.rs | 110 ++++++++++++++++++++++++------ crates/ui-egui/src/dialogs.rs | 1 + docs/gaps.md | 3 +- 4 files changed, 94 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..a928c68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Forecast Sheet gets Forecast Start: begin the forecast inside the history to compare it with the actuals (#785) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 13c120c..9795ea6 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Forecast Sheet", ["Data", "Forecast"], None, - "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", + "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", has_doc, forecast_sheet )] @@ -223,13 +223,39 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) { return Err(EngineError::Other("There are no values to forecast.".into())); } - let end = match p.get("end") { - Some(Json::Number(n)) => n.as_f64(), - Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { - Value::Number(end) => Some(end), - _ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))), - }, - _ => None, + let time_param = |key: &str| -> Result> { + match p.get(key) { + Some(Json::Number(n)) => Ok(n.as_f64()), + Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { + Value::Number(x) => Ok(Some(x)), + _ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))), + }, + None | Some(Json::Null) => Ok(None), + Some(Json::String(_)) => Ok(None), + Some(_) => Err(bad(CMD, format!("`{key}` is not a number or a date"))), + } + }; + let end = time_param("end")?; + // Forecast Start: the index (into `rows`) of the first forecast point, or `rows.len()` for the + // usual forecast after the history. + let fit = match time_param("forecastStart")? { + None => rows.len(), + Some(start) => { + let last_t = times.last().copied().unwrap_or(f64::NAN); + let tol = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0); + if tol(start, last_t) || timeline.after_last(1).is_some_and(|next| tol(start, next)) { + rows.len() + } else { + let i = times + .iter() + .position(|t| tol(*t, start)) + .ok_or_else(|| bad(CMD, "`forecastStart` is not on the timeline (it is a point of the history, before its last point)"))?; + if rows.get(..i).is_none_or(|h| h.iter().filter(|r| matches!(r.1, Value::Number(_))).count() < 2) { + return Err(EngineError::Other("The forecast start needs at least two values before it to fit the forecast.".into())); + } + i + } + } }; let cap = max_periods(rows.len()); let periods = match end { @@ -252,8 +278,9 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { // Columns: A timeline, B values, C forecast, D/E bounds. let n = rows.len() as u32; let last = n + 1; // 1-based row of the last history point + let fit_last = fit as u32 + 1; // 1-based row of the last point the model is fit to let end_row = last + periods; // 1-based row of the last forecast - let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col)); + let hist = |col: u32| format!("${0}$2:${0}${fit_last}", col_letters(col)); let mut tail = format!("{},{}", opts.seasonality, opts.fill); if let Some(a) = opts.aggregate { tail.push_str(&format!(",{a}")); @@ -305,22 +332,30 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { put(&mut sheet, r, 1, v.clone(), vstyle); } } - // The last history point starts the forecast lines, so they join the values. - formula(&mut sheet, n, 2, format!("=B{last}"), vstyle); + // The last point the model is fit to starts the forecast lines, so they join the values. + let join = fit_last - 1; // 0-based row + formula(&mut sheet, join, 2, format!("=B{fit_last}"), vstyle); if opts.confidence.is_some() { - formula(&mut sheet, n, 3, format!("=C{last}"), vstyle); - formula(&mut sheet, n, 4, format!("=C{last}"), vstyle); + formula(&mut sheet, join, 3, format!("=C{fit_last}"), vstyle); + formula(&mut sheet, join, 4, format!("=C{fit_last}"), vstyle); } let (tl, vl) = (hist(0), hist(1)); + let forecast_row = |sheet: &mut Sheet, r: u32| { + formula(sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle); + if let Some(c) = opts.confidence { + let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})"); + formula(sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle); + formula(sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle); + } + }; + // Hindcast: forecasts over the actuals from the start to the last history point. + for r in (fit_last + 1)..=last { + forecast_row(&mut sheet, r); + } for (k, t) in future.iter().enumerate() { let r = last + k as u32 + 1; // 1-based put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle); - formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle); - if let Some(c) = opts.confidence { - let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})"); - formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle); - formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle); - } + forecast_row(&mut sheet, r); } if opts.statistics { put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold); @@ -407,7 +442,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { if let Ok(d) = s.doc_mut() { d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default()); } - Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods})) + Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods, "fitted": fit})) } /// Forecast rows needed to reach `end` (rounded up to whole steps). @@ -545,4 +580,39 @@ mod tests { s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap(); assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err()); } + + #[test] + fn forecast_start_hindcasts_over_the_actuals() { + let mut s = session_with_history(); // t = 1..16 in A2:A17 + let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": 13, "periods": 2, "confidenceInterval": false})).unwrap(); + assert_eq!(r["fitted"], 12); + // Fit to t = 1..12 (rows 2..13); the line joins at row 13 and forecasts rows 14..19. + assert_eq!(formula(&s, 0, "C13"), "B13"); + assert_eq!(value(&s, 0, "C13"), value(&s, 0, "B13")); + assert_eq!(formula(&s, 0, "C14"), "FORECAST.ETS(A14,$B$2:$B$13,$A$2:$A$13,1,1)"); + assert_eq!(formula(&s, 0, "C19"), "FORECAST.ETS(A19,$B$2:$B$13,$A$2:$A$13,1,1)"); + // The exact seasonal series is recovered, and the actual is still shown next to it. + let c14 = value(&s, 0, "C14").as_f64().unwrap(); + assert!((c14 - 39.0).abs() < 1e-6, "{c14}"); // t = 13: 10 + 26 + 3 + assert!(value(&s, 0, "B14").as_f64().is_some()); + // Start at the last point or one step later: as without forecastStart. + for start in [json!(16), json!(17)] { + let r = s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "forecastStart": start, "periods": 2})).unwrap(); + assert_eq!(r["fitted"], 16); + } + assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)"); + } + + #[test] + fn forecast_start_invalid_values_are_errors() { + let mut s = session_with_history(); + let sheets = s.doc().unwrap().wb.sheets.len(); + for bad in [json!(0), json!(2), json!(3.5), json!(18), json!(1000), json!("abc"), json!([1]), json!(1e308)] { + assert!(s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets); + // Blank or null behaves like no start; empty params unchanged. + let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": ""})).unwrap(); + assert_eq!(r["fitted"], 16); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c6..1aabd9d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -415,6 +415,7 @@ impl Dialog { Text { key: "timeline", label: "Timeline range (blank: first column of the selection):" }, Text { key: "values", label: "Values range (blank: second column of the selection):" }, Text { key: "end", label: "Forecast end (date or number; blank: a third of the history):" }, + Text { key: "forecastStart", label: "Forecast start (a date or number in the history; blank: after the last point):" }, Check { key: "confidenceInterval", label: "Confidence interval" }, Text { key: "confidence", label: "Confidence level (%):" }, Text { key: "seasonality", label: "Seasonality (auto, 0 for none, or a season length):" }, diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b8..eefab42 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -106,7 +106,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) - `data.solver`, `data.analysisToolPak` missing. Forecast Sheet (`data.forecastSheet`) and the - FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 15–25 h. + FORECAST.ETS family are in, including Forecast Start (hindcast, #785). **Estimate:** 15–25 h. ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. @@ -160,6 +160,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | From 4c2bcd637c97a6b065404edf44b4b042d2315cd9 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:34:18 -0400 Subject: [PATCH 110/162] Functions: *IFS with two or more criteria pairs match the first 255 characters of a longer criterion, as in Excel --- crates/functions/src/criteria.rs | 16 +++++++++++++++- crates/functions/src/math.rs | 21 ++++++++++++--------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 2c87a9a..aa57177 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -53,7 +53,6 @@ impl Criterion { /// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS /// functions: like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. - /// The *IFS functions with two or more criteria pairs use `parse`: Excel doesn't limit them. pub(crate) fn parse_arg(v: &Value) -> Result { match v { Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), @@ -62,6 +61,21 @@ impl Criterion { } } + /// Parses a criteria argument of an *IFS function with two or more criteria pairs. Excel + /// doesn't reject a longer text criterion there: it matches its first 255 characters + /// (checked on Excel 2016 and Microsoft 365 with COUNTIFS, SUMIFS, AVERAGEIFS, MAXIFS and + /// MINIFS). + pub(crate) fn parse_arg_truncated(v: &Value) -> Criterion { + match v { + Value::Array(a) => Criterion::parse_arg_truncated(a.data.first().unwrap_or(&Value::Empty)), + Value::Text(t) => match t.char_indices().nth(MAX_CRITERION_CHARS) { + Some((end, _)) => Criterion::parse_text(&t[..end]), + None => Criterion::parse_text(t), + }, + other => Criterion::parse(other), + } + } + fn parse_text(t: &str) -> Criterion { let (op, rest) = if let Some(r) = t.strip_prefix(">=") { (Op::Ge, r) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 75cea93..8c67aa8 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -950,10 +950,9 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R R0")]), 1.0); - close(ev("COUNTIFS", vec![rf(qty.clone()), t(">0"), rf(cells.clone()), t(&long)]), 1.0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty), t(">0")]), 2.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0); + close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); } } From 26ed66cfe1cd8de9f936e1c4512994cf95c5b90f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Marcin=20Brzuchalski?= Date: Mon, 12 Oct 2026 20:35:10 +0200 Subject: [PATCH 111/162] i18n: look up the Format Cells dialog labels in the catalog The Format Cells dialog drew English literals in every language. Its tabs, labels, options and protection note now go through Language::tr, as do dialog titles and the shared OK/Cancel buttons. The Fill tab is keyed Format Cells|Fill because the Alignment tab's Fill is a different word in some languages. d.tab keeps the English name. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 91 +++++++++++++++++------------ crates/ui-egui/tests/interaction.rs | 19 ++++++ docs/localization-parity.md | 6 +- 3 files changed, 78 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c6..ade0968 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -891,7 +891,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "solver" => 460.0, _ => 380.0, }; - let window = egui::Window::new(d.title.clone()) + let window = egui::Window::new(app.ui.language.tr(&d.title)) .id(egui::Id::new("dialog").with(&d.name)) .collapsible(false) .resizable(false) @@ -1223,11 +1223,15 @@ fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent)); + let lang = crate::i18n::current(ui.ctx()); + let ok = ui.add( + egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)) + .fill(crate::theme::Tokens::get(ui.ctx()).accent), + ); if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { *confirm = true; } - if ui.button(" Cancel ").clicked() { + if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { *open = false; } }); @@ -1362,9 +1366,20 @@ fn macros(ui: &mut egui::Ui, d: &mut Dialog) { } fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn + // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a + // different word in some languages. + let lang = app.ui.language; ui.horizontal(|ui| { - for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] { - if ui.selectable_label(d.tab == tab, tab).clicked() { + for (tab, key) in [ + ("Number", "Number"), + ("Alignment", "Alignment"), + ("Font", "Font"), + ("Border", "Border"), + ("Fill", "Format Cells|Fill"), + ("Protection", "Protection"), + ] { + if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() { d.tab = tab.to_string(); } } @@ -1376,20 +1391,20 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: "Number" => { let mut code = st.num_fmt.as_str().to_string(); ui.columns(2, |cols| { - cols[0].label(egui::RichText::new("Category:").strong()); + cols[0].label(egui::RichText::new(lang.tr("Category:")).strong()); for name in ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] { let c = gridcraft_engine::cmd::format::format_code_for(name).to_string(); - if cols[0].selectable_label(code == c, name).clicked() { + if cols[0].selectable_label(code == c, lang.tr(name)).clicked() { code = c; } } - cols[1].label(egui::RichText::new("Sample").strong()); + cols[1].label(egui::RichText::new(lang.tr("Sample")).strong()); let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); cols[1].add_space(8.0); - cols[1].label("Type (custom format code):"); + cols[1].label(lang.tr("Type (custom format code):")); cols[1].text_edit_singleline(&mut code); for c in [ "0", @@ -1418,7 +1433,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } "Alignment" => { use gridcraft_engine::model::{HAlign, VAlign}; - ui.label(egui::RichText::new("Horizontal").strong()); + ui.label(egui::RichText::new(lang.tr("Horizontal")).strong()); ui.horizontal_wrapped(|ui| { for (h, n) in [ (HAlign::General, "General"), @@ -1430,10 +1445,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (HAlign::CenterAcross, "Center Across Selection"), (HAlign::Distributed, "Distributed"), ] { - ui.radio_value(&mut st.align.h, h, n); + ui.radio_value(&mut st.align.h, h, lang.tr(n)); } }); - ui.label(egui::RichText::new("Vertical").strong()); + ui.label(egui::RichText::new(lang.tr("Vertical")).strong()); ui.horizontal_wrapped(|ui| { for (v, n) in [ (VAlign::Top, "Top"), @@ -1442,29 +1457,29 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (VAlign::Justify, "Justify"), (VAlign::Distributed, "Distributed"), ] { - ui.radio_value(&mut st.align.v, v, n); + ui.radio_value(&mut st.align.v, v, lang.tr(n)); } }); let mut indent = st.align.indent as f32; - ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text("Indent")); + ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent"))); st.align.indent = indent as u8; let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 }; - ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text("Orientation (degrees)")); + ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)"))); st.align.rotation = rot as i16; - ui.checkbox(&mut st.align.wrap, "Wrap text"); - ui.checkbox(&mut st.align.shrink, "Shrink to fit"); + ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); + ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); } "Font" => { - egui::ComboBox::from_label("Font").selected_text(st.font.name.clone()).show_ui(ui, |ui| { + egui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| { for f in crate::fonts::families() { ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); } }); - ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text("Size")); + ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size"))); ui.horizontal(|ui| { - ui.checkbox(&mut st.font.bold, "Bold"); - ui.checkbox(&mut st.font.italic, "Italic"); - ui.checkbox(&mut st.font.strike, "Strikethrough"); + ui.checkbox(&mut st.font.bold, lang.tr("Bold")); + ui.checkbox(&mut st.font.italic, lang.tr("Italic")); + ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough")); }); use gridcraft_engine::model::{Underline, VertAlign}; ui.horizontal(|ui| { @@ -1475,17 +1490,17 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (Underline::SingleAccounting, "Single Accounting"), (Underline::DoubleAccounting, "Double Accounting"), ] { - ui.radio_value(&mut st.font.underline, u, n); + ui.radio_value(&mut st.font.underline, u, lang.tr(n)); } }); ui.horizontal(|ui| { - ui.radio_value(&mut st.font.vert, VertAlign::Baseline, "Normal"); - ui.radio_value(&mut st.font.vert, VertAlign::Superscript, "Superscript"); - ui.radio_value(&mut st.font.vert, VertAlign::Subscript, "Subscript"); + ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); + ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); + ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); }); - ui.label("Color:"); + ui.label(lang.tr("Color:")); let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "Automatic") { + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) { st.font.color = if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() }; } @@ -1494,10 +1509,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: use gridcraft_engine::model::{BorderLine, BorderStyle}; let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto }; ui.horizontal(|ui| { - if ui.button("None").clicked() { + if ui.button(lang.tr("None")).clicked() { st.borders = Default::default(); } - if ui.button("Outline").clicked() { + if ui.button(lang.tr("Outline")).clicked() { st.borders.top = line; st.borders.bottom = line; st.borders.left = line; @@ -1512,6 +1527,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: ("Diagonal ↘", &mut st.borders.diag_down), ("Diagonal ↗", &mut st.borders.diag_up), ] { + let n = lang.tr(n); ui.horizontal(|ui| { ui.label(format!("{n}:")); for (s, label) in [ @@ -1523,6 +1539,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (BorderStyle::Dotted, "dotted"), (BorderStyle::Double, "double"), ] { + let label = lang.tr(label); let response = ui.selectable_label(slot.style == s, label); response .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}"))); @@ -1537,8 +1554,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } "Fill" => { let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - ui.label("Background Color:"); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") { + ui.label(lang.tr("Background Color:")); + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) { st.fill = if c == "none" { Default::default() } else { @@ -1547,11 +1564,13 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } } "Protection" => { - ui.checkbox(&mut st.protection.locked, "Locked"); - ui.checkbox(&mut st.protection.hidden, "Hidden"); + ui.checkbox(&mut st.protection.locked, lang.tr("Locked")); + ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); ui.label( - egui::RichText::new("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet).") - .small(), + egui::RichText::new( + lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."), + ) + .small(), ); } _ => {} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 7093933..8e8eaaa 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -279,6 +279,25 @@ fn language_switch_translates_the_ribbon_and_persists() { assert_eq!(h.state().ui.language, Language::En); } +#[test] +fn format_cells_draws_its_labels_in_the_interface_language() { + let mut h = harness(blank()); + h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap(); + h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); + h.run_steps(2); + assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated"); + assert!(h.query_by_label("Scientific").is_none(), "no English category is left"); + h.get_by_label("Wissenschaftlich").click(); + h.run_steps(2); + let style = &h.state().dialog.as_ref().unwrap().values["style"]; + assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code"); + h.state_mut().dialog = None; + h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap(); + h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); + h.run_steps(2); + assert!(h.query_by_label("Scientific").is_some(), "English is unchanged"); +} + #[test] fn name_manager_new_and_edit_open_the_name_dialog_and_come_back() { // #238: New… in the Name Manager did nothing (the manager put itself back over the dialog it diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e7d1de9..6f05a60 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German ribbon catalog added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -14,7 +14,8 @@ right-to-left sheets and full complex-script support. Part of system language (closes #20) and a View-tab switcher, with English-keyed lookup tables: Japanese 268 labels, Simplified Chinese, Korean, Russian, Brazilian Portuguese, German and Czech 266 each. The tables are used by - the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`); dialogs, menus, + the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`), the Format Cells + dialog, every dialog title and the shared OK/Cancel buttons; the other dialogs, menus, context menus, task panes, status messages, command labels and function descriptions are still English literals. Untranslated labels fall back to English. - **About 2,200 user-visible strings** in all (heuristic source scan: ~1,700 UI and command @@ -77,6 +78,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | | 2026-10-11 | minor | Sheet Right-to-Left mirrors the grid (#146) | | 2026-10-11 | minor | Czech (cs) ribbon table: 266 labels (~12%), menus only | From 339f8ed35b0eab58068ffaa6be31c37ef9a54a83 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:57:02 +0200 Subject: [PATCH 112/162] Page Layout view: drag the margins on the rulers (#792) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 + crates/ui-egui/src/grid.rs | 3 + crates/ui-egui/src/i18n.rs | 4 + crates/ui-egui/src/i18n/cs.rs | 4 + crates/ui-egui/src/i18n/de.rs | 4 + crates/ui-egui/src/i18n/es.rs | 4 + crates/ui-egui/src/i18n/fr.rs | 4 + crates/ui-egui/src/i18n/ko.rs | 4 + crates/ui-egui/src/i18n/pt.rs | 4 + crates/ui-egui/src/i18n/ru.rs | 4 + crates/ui-egui/src/i18n/zh.rs | 4 + crates/ui-egui/src/paper_view.rs | 221 +++++++++++++++++++++++ crates/ui-egui/tests/page_layout_view.rs | 54 ++++++ docs/gaps.md | 4 +- docs/ui-parity.md | 3 +- 15 files changed, 320 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..6907953 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Page Layout view: drag the margin edges on the rulers to set the page margins (resize cursor, live preview, value tooltip, one undo step, Esc cancels) (#792) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..04c924d 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -100,6 +100,8 @@ pub struct GridState { pub paper: Option, /// Header or footer being edited in place (Page Layout view). pub hf_edit: Option, + /// A page margin being dragged on a ruler (Page Layout view); committed on release. + pub ruler_drag: Option, /// Insert › Header & Footer: start editing the first page's header once Page Layout view is up. pub hf_start: bool, /// The grid geometry of the last frame (hit-testing for agents and tests). @@ -493,6 +495,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if paper { let probe = Geo::new(sh, grid_rect, scroll); if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { + let g = crate::paper_view::with_dragged_margin(app, si, g); return Geo::paged(sh, grid_rect, scroll, g); } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 406cc3d..27230b1 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -290,6 +290,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Select\nObjects", "オブジェクトの選択"), ("Themes", "テーマ"), ("Margins", "余白"), + ("Left Margin", "左余白"), + ("Right Margin", "右余白"), + ("Top Margin", "上余白"), + ("Bottom Margin", "下余白"), ("Size", "サイズ"), ("Print\nArea", "印刷範囲"), ("Set Print Area", "印刷範囲の設定"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index ca2e559..96a5a11 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -116,6 +116,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Vybrat\nobjekty", "Themes" => "Motivy", "Margins" => "Okraje", + "Left Margin" => "Levý okraj", + "Right Margin" => "Pravý okraj", + "Top Margin" => "Horní okraj", + "Bottom Margin" => "Dolní okraj", "Size" => "Velikost", "Print\nArea" => "Oblast\ntisku", "Set Print Area" => "Nastavit oblast tisku", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2ad37f6..30bff11 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -116,6 +116,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Objekte\nauswählen", "Themes" => "Designs", "Margins" => "Seitenränder", + "Left Margin" => "Linker Seitenrand", + "Right Margin" => "Rechter Seitenrand", + "Top Margin" => "Oberer Seitenrand", + "Bottom Margin" => "Unterer Seitenrand", "Size" => "Größe", "Print\nArea" => "Druck-\nbereich", "Set Print Area" => "Druckbereich festlegen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index da41bd7..b97cce8 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -117,6 +117,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Seleccionar\nobjetos", "Themes" => "Temas", "Margins" => "Márgenes", + "Left Margin" => "Margen izquierdo", + "Right Margin" => "Margen derecho", + "Top Margin" => "Margen superior", + "Bottom Margin" => "Margen inferior", "Size" => "Tamaño", "Print\nArea" => "Área de\nimpresión", "Set Print Area" => "Establecer área de impresión", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 7bf9fd1..1ecddfa 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -124,6 +124,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Sélection d’objets", "Themes" => "Thèmes", "Margins" => "Marges", + "Left Margin" => "Marge gauche", + "Right Margin" => "Marge droite", + "Top Margin" => "Marge haute", + "Bottom Margin" => "Marge basse", "Size" => "Taille", "Print\nArea" => "Zone d’impression", "Set Print Area" => "Définir la zone d’impression", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index b684c78..1642739 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "개체\n선택", "Themes" => "테마", "Margins" => "여백", + "Left Margin" => "왼쪽 여백", + "Right Margin" => "오른쪽 여백", + "Top Margin" => "위쪽 여백", + "Bottom Margin" => "아래쪽 여백", "Size" => "용지 크기", "Print\nArea" => "인쇄\n영역", "Set Print Area" => "인쇄 영역 설정", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index b554ef9..9d6a128 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -122,6 +122,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Selecionar\nObjetos", "Themes" => "Temas", "Margins" => "Margens", + "Left Margin" => "Margem esquerda", + "Right Margin" => "Margem direita", + "Top Margin" => "Margem superior", + "Bottom Margin" => "Margem inferior", "Size" => "Tamanho", "Print\nArea" => "Área de\nImpressão", "Set Print Area" => "Definir Área de Impressão", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index c8f0445..221b870 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Выбрать\nобъекты", "Themes" => "Темы", "Margins" => "Поля", + "Left Margin" => "Левое поле", + "Right Margin" => "Правое поле", + "Top Margin" => "Верхнее поле", + "Bottom Margin" => "Нижнее поле", "Size" => "Размер", "Print\nArea" => "Область\nпечати", "Set Print Area" => "Задать область печати", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index d1cf704..477ab82 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "选择\n对象", "Themes" => "主题", "Margins" => "页边距", + "Left Margin" => "左边距", + "Right Margin" => "右边距", + "Top Margin" => "上边距", + "Bottom Margin" => "下边距", "Size" => "纸张大小", "Print\nArea" => "打印\n区域", "Set Print Area" => "设置打印区域", diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 110193a..69784de 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -284,6 +284,7 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo commit(app); } let Some(pg) = geo.paper.clone() else { return }; + ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet); if std::mem::take(&mut app.grid.hf_start) { begin(app, wb.active_sheet, sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 }); } @@ -594,6 +595,197 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, ); } +/// A page edge that can be dragged on a ruler. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Edge { + Left, + Right, + Top, + Bottom, +} + +impl Edge { + /// Index into `PrintSettings::margins` / `PageGrid::margins`. + fn index(self) -> usize { + match self { + Edge::Left => 0, + Edge::Right => 1, + Edge::Top => 2, + Edge::Bottom => 3, + } + } + /// The `pageLayout.pageSetup` margin key. + fn key(self) -> &'static str { + ["left", "right", "top", "bottom"][self.index()] + } + fn label(self) -> &'static str { + ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin"][self.index()] + } + fn horizontal(self) -> bool { + matches!(self, Edge::Left | Edge::Right) + } +} + +/// A margin being dragged on a ruler: the page it started on and the previewed value (inches). +#[derive(Clone, Debug)] +pub struct RulerDrag { + pub sheet: usize, + pub edge: Edge, + /// Page column (horizontal ruler) or row (vertical ruler) the drag started on. + pub page: usize, + pub start_inches: f32, + pub inches: f32, +} + +/// Content that must stay between the margins, in points. +const MIN_CONTENT_PT: f64 = 36.0; +/// Screen distance within which a ruler margin edge is grabbed. +const EDGE_GRAB: f32 = 4.0; + +/// Margin (inches, rounded to 0.01) for an edge dragged to `rel_pt` points from the page's +/// left/top edge. The margin stays on the page and leaves [`MIN_CONTENT_PT`] between the two +/// margins of its axis. `None` for NaN/infinite input or a page without a size. +pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 4]) -> Option { + let page = f64::from(if edge.horizontal() { page_pt.0 } else { page_pt.1 }); + if !rel_pt.is_finite() || !page.is_finite() || page <= 0.0 { + return None; + } + let other = f64::from(margins_in.get(edge.index() ^ 1).copied().filter(|v| v.is_finite())?).max(0.0) * 72.0; + let raw = match edge { + Edge::Left | Edge::Top => rel_pt, + Edge::Right | Edge::Bottom => page - rel_pt, + }; + let max = (page - other - MIN_CONTENT_PT).max(0.0); + let pt = raw.clamp(0.0, max); + Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) +} + +/// The grid with a margin being dragged applied, so the page content follows the pointer. +pub(crate) fn with_dragged_margin(app: &SheetApp, si: usize, pg: Arc) -> Arc { + let Some(d) = app.grid.ruler_drag.as_ref().filter(|d| d.sheet == si) else { return pg }; + // Layout units per point (the print scale and pixel/point ratio). + let per_pt = pg.page_w / f64::from(pg.paper_pt.0); + let mut g = (*pg).clone(); + let v = f64::from(d.inches) * 72.0 * per_pt; + if let Some(m) = g.margins.get_mut(d.edge.index()).filter(|_| v.is_finite() && per_pt.is_finite()) { + *m = v; + } + Arc::new(g) +} + +/// Points per layout unit of the grid (paper size over the layout page size). +fn pt_per_unit(pg: &PageGrid) -> Option { + let k = f64::from(pg.paper_pt.0) / pg.page_w; + (k.is_finite() && k > 0.0).then_some(k) +} + +/// The margin edge of a visible page under `pos` on the horizontal (`h`) or vertical ruler. +fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize)> { + let ((i0, i1), (j0, j1)) = visible_pages(geo, pg); + let [ml, mr, mt, mb, ..] = pg.margins; + let mut best: Option<(f32, Edge, usize)> = None; + let mut consider = |d: f32, e: Edge, page: usize| { + if d <= EDGE_GRAB && best.is_none_or(|b| d < b.0) { + best = Some((d, e, page)); + } + }; + if h { + for j in j0..=j1.min(j0.saturating_add(200)) { + let (x, _, w, _) = pg.page_rect(0, j); + consider((screen(geo, x + ml, 0.0).x - pos.x).abs(), Edge::Left, j); + consider((screen(geo, x + w - mr, 0.0).x - pos.x).abs(), Edge::Right, j); + } + } else { + for i in i0..=i1.min(i0.saturating_add(200)) { + let (_, y, _, hh) = pg.page_rect(i, 0); + consider((screen(geo, 0.0, y + mt).y - pos.y).abs(), Edge::Top, i); + consider((screen(geo, 0.0, y + hh - mb).y - pos.y).abs(), Edge::Bottom, i); + } + } + best.map(|(_, e, p)| (e, p)) +} + +/// Dragging the margin edges on the rulers sets the page margins: a resize cursor and a tooltip +/// on hover, a live preview while dragging, one `pageLayout.pageSetup` (one undo step) on +/// release, Esc cancels. +fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid, sh: &Sheet, si: usize) { + let Some(outer) = app.grid.rect else { return }; + if geo.rect.top() - outer.top() < RULER - 0.5 { + app.grid.ruler_drag = None; + return; + } + // Leaving the sheet or the view drops the drag. + if app.grid.ruler_drag.as_ref().is_some_and(|d| d.sheet != si) { + app.grid.ruler_drag = None; + } + let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); + let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); + let margins_in = |sh: &Sheet| -> [f32; 4] { + let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); + [m(0), m(1), m(2), m(3)] + }; + let current = margins_in(sh); + let metric = metric(app); + let tip = |ui: &egui::Ui, app: &SheetApp, edge: Edge, inches: f32| { + let value = if metric { format!("{:.2} cm", inches * 2.54) } else { format!("{inches:.2} in") }; + let text = format!("{}: {value}", app.ui.language.tr(edge.label())); + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("ruler_margin_tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(text); + }); + }; + let cursor = |ui: &egui::Ui, edge: Edge| { + ui.ctx().set_cursor_icon(if edge.horizontal() { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + }; + if let Some(d) = app.grid.ruler_drag.clone() { + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { + app.grid.ruler_drag = None; + return; + } + cursor(ui, d.edge); + let down = ui.input(|i| i.pointer.primary_down()); + let pos = ui.input(|i| i.pointer.latest_pos()); + let mut cur = d.clone(); + if let (Some(pos), Some(k)) = (pos, pt_per_unit(pg)) { + let rel = if d.edge.horizontal() { + let page_x = pg.page_x(d.page); + f64::from(geo.scroll.x) + f64::from((pos.x - geo.cells.left()) / geo.z) - page_x + } else { + let page_y = pg.page_y(d.page); + f64::from(geo.scroll.y) + f64::from((pos.y - geo.cells.top()) / geo.z) - page_y + }; + if let Some(v) = margin_from_drag(d.edge, rel * k, pg.paper_pt, current) { + cur.inches = v; + } + } + if down { + tip(ui, app, cur.edge, cur.inches); + app.grid.ruler_drag = Some(cur); + ui.ctx().request_repaint(); + } else { + app.grid.ruler_drag = None; + if (cur.inches - cur.start_inches).abs() > f32::EPSILON { + app.run_or_alert("pageLayout.pageSetup", json!({"sheet": si, "margins": {cur.edge.key(): cur.inches}})); + } + } + return; + } + for (resp, h) in [(&hresp, true), (&vresp, false)] { + let Some(pos) = resp.hover_pos().or_else(|| resp.interact_pointer_pos()) else { continue }; + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(pos) } else { pos }; + let Some((edge, page)) = edge_at(geo, pg, origin, h) else { continue }; + let value = current.get(edge.index()).copied().unwrap_or(0.5); + if resp.drag_started() { + app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value }); + cursor(ui, edge); + } else if resp.hovered() { + cursor(ui, edge); + tip(ui, app, edge, value); + } + } +} + /// The Header & Footer contextual ribbon tab. pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { use crate::widgets::big_button; @@ -667,3 +859,32 @@ fn sep(ui: &mut egui::Ui) { let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), egui::Sense::hover()); ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator)); } + +#[cfg(test)] +mod tests { + use super::*; + + const A4: (f32, f32) = (595.0, 842.0); + + #[test] + fn drag_maps_to_margins_and_clamps_inside_the_page() { + let m = [0.7, 0.7, 0.75, 0.75]; + // 72 pt from the left edge is one inch; from the right edge the margin is measured inward. + assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); + assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); + assert_eq!(margin_from_drag(Edge::Top, 108.0, A4, m), Some(1.5)); + // Dragged off the page: never negative. + assert_eq!(margin_from_drag(Edge::Left, -50.0, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.0)); + // Dragged across the opposite margin: stops a minimum content width short of it. + let max = margin_from_drag(Edge::Left, 10_000.0, A4, m).unwrap(); + assert!((f64::from(max) * 72.0 - (f64::from(A4.0) - 0.7 * 72.0 - MIN_CONTENT_PT)).abs() < 1.0, "{max}"); + // Hostile input is ignored, not clamped to something arbitrary. + assert_eq!(margin_from_drag(Edge::Left, f64::NAN, A4, m), None); + assert_eq!(margin_from_drag(Edge::Left, f64::INFINITY, A4, m), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, (0.0, 0.0), m), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7]), None); + // Oversized opposite margin leaves nothing to drag into. + assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0]), Some(0.0)); + } +} diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index f8850b3..632b9e2 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -162,3 +162,57 @@ fn insert_header_and_footer_switches_to_page_layout_view() { let e = h.state().grid.hf_edit.as_ref().expect("the header is being edited"); assert_eq!((e.footer, e.section, e.texts[0].as_str()), (false, 1, "Left")); } + +fn margins(h: &Harness) -> [f32; 6] { + h.state().session.doc().unwrap().wb.active().unwrap().print.margins +} + +fn mouse(h: &mut Harness, ev: Event) { + h.input_mut().events.push(ev); + h.step(); +} + +#[test] +fn dragging_a_ruler_edge_previews_commits_on_release_as_one_undo_step_and_esc_cancels() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start_margins = margins(&h); + let ruler_y = h.state().grid.rect.unwrap().top() + 9.0; + let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y); + // One inch further in = 96 layout pixels at 100% print scale; use the screen scale to be exact. + let per_inch = (72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z; + let target = pos2(edge.x + per_inch, ruler_y); + h.run_steps(40); + mouse(&mut h, Event::PointerMoved(edge)); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerMoved(pos2(edge.x + 5.0, ruler_y))); + mouse(&mut h, Event::PointerMoved(target)); + h.run_steps(2); + // Live preview: the page content moved, the document did not change yet. + let live = geo(&h).paper.unwrap().margins[0]; + assert!(live > pg.margins[0] + 1.0, "{live} vs {}", pg.margins[0]); + assert_eq!(margins(&h), start_margins); + let want = (start_margins[0] + 1.0 * 1.0).min(10.0); + mouse(&mut h, Event::PointerButton { pos: target, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let after = margins(&h); + assert!((after[0] - want).abs() < 0.03, "{after:?} want left {want}"); + assert_eq!(after[1..], start_margins[1..]); + // One undo step restores it. + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(margins(&h), start_margins); + // Esc during a drag cancels: nothing is committed. + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y); + h.run_steps(40); + mouse(&mut h, Event::PointerMoved(edge)); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerMoved(pos2(edge.x + per_inch, ruler_y))); + mouse(&mut h, Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(margins(&h), start_margins); +} diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b8..c595c1c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -110,8 +110,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. - Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging - margins on the ruler. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. + Margins are dragged on the rulers (#792). Remaining: pictures in headers/footers, Different First Page / Odd & Even pages. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. ### 15. Remaining functions and an oracle suite (F) - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. @@ -160,6 +159,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..52dd3a3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -28,7 +28,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | header/footer pictures, Different First Page / Odd & Even, ruler margin dragging missing (1–3 h) | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction @@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | From c4ad8e3a9bfea58156bb92df10b68c12a653dfa9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:01:09 +0200 Subject: [PATCH 113/162] Page Layout view: header/footer margins on the ruler, live re-pagination, right-to-left rulers (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/grid.rs | 2 +- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/paper_view.rs | 90 +++++++++++++------ crates/ui-egui/tests/page_layout_view.rs | 108 +++++++++++++++++++++++ docs/ui-parity.md | 2 +- 13 files changed, 191 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 04c924d..80af151 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -495,7 +495,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if paper { let probe = Geo::new(sh, grid_rect, scroll); if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { - let g = crate::paper_view::with_dragged_margin(app, si, g); + let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); return Geo::paged(sh, grid_rect, scroll, g); } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 27230b1..e2566a6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -294,6 +294,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Right Margin", "右余白"), ("Top Margin", "上余白"), ("Bottom Margin", "下余白"), + ("Header Margin", "ヘッダーの余白"), + ("Footer Margin", "フッターの余白"), ("Size", "サイズ"), ("Print\nArea", "印刷範囲"), ("Set Print Area", "印刷範囲の設定"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 96a5a11..bdcedbf 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -120,6 +120,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Pravý okraj", "Top Margin" => "Horní okraj", "Bottom Margin" => "Dolní okraj", + "Header Margin" => "Okraj záhlaví", + "Footer Margin" => "Okraj zápatí", "Size" => "Velikost", "Print\nArea" => "Oblast\ntisku", "Set Print Area" => "Nastavit oblast tisku", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 30bff11..75653e5 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -120,6 +120,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Rechter Seitenrand", "Top Margin" => "Oberer Seitenrand", "Bottom Margin" => "Unterer Seitenrand", + "Header Margin" => "Kopfzeilenabstand", + "Footer Margin" => "Fußzeilenabstand", "Size" => "Größe", "Print\nArea" => "Druck-\nbereich", "Set Print Area" => "Druckbereich festlegen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index b97cce8..0582110 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -121,6 +121,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Margen derecho", "Top Margin" => "Margen superior", "Bottom Margin" => "Margen inferior", + "Header Margin" => "Margen del encabezado", + "Footer Margin" => "Margen del pie de página", "Size" => "Tamaño", "Print\nArea" => "Área de\nimpresión", "Set Print Area" => "Establecer área de impresión", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 1ecddfa..0db9df4 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -128,6 +128,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Marge droite", "Top Margin" => "Marge haute", "Bottom Margin" => "Marge basse", + "Header Margin" => "Marge de l'en-tête", + "Footer Margin" => "Marge du pied de page", "Size" => "Taille", "Print\nArea" => "Zone d’impression", "Set Print Area" => "Définir la zone d’impression", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 1642739..f3e1688 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "오른쪽 여백", "Top Margin" => "위쪽 여백", "Bottom Margin" => "아래쪽 여백", + "Header Margin" => "머리글 여백", + "Footer Margin" => "바닥글 여백", "Size" => "용지 크기", "Print\nArea" => "인쇄\n영역", "Set Print Area" => "인쇄 영역 설정", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 9d6a128..d6185a5 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -126,6 +126,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Margem direita", "Top Margin" => "Margem superior", "Bottom Margin" => "Margem inferior", + "Header Margin" => "Margem do cabeçalho", + "Footer Margin" => "Margem do rodapé", "Size" => "Tamanho", "Print\nArea" => "Área de\nImpressão", "Set Print Area" => "Definir Área de Impressão", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 221b870..868f653 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Правое поле", "Top Margin" => "Верхнее поле", "Bottom Margin" => "Нижнее поле", + "Header Margin" => "Поле верхнего колонтитула", + "Footer Margin" => "Поле нижнего колонтитула", "Size" => "Размер", "Print\nArea" => "Область\nпечати", "Set Print Area" => "Задать область печати", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 477ab82..a9989f2 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "右边距", "Top Margin" => "上边距", "Bottom Margin" => "下边距", + "Header Margin" => "页眉边距", + "Footer Margin" => "页脚边距", "Size" => "纸张大小", "Print\nArea" => "打印\n区域", "Set Print Area" => "设置打印区域", diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 69784de..f560f13 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -524,17 +524,21 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let minor = if metric { 2.0 } else { 4.0 }; let label_every = ((22.0 / unit).ceil() as usize).max(1); let font = theme::ui_font(9.5); - let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); + let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + // Right-to-left sheets: the horizontal ruler is mirrored with the pages (ticks run from the + // right margin leftwards). + let mx = |x: f32| geo.mirror_x(x); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window); let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg); - let [ml, mr, mt, mb, ..] = pg.margins; + let [ml, mr, mt, mb, mh, mf] = pg.margins; // One ruler span per page: margins shaded, the printable part light, ticks from the margin. let span = |p: &Painter, from: f32, to: f32, m0: f32, m1: f32, horizontal: bool, area: Rect| { let band = |a: f32, b: f32| { if horizontal { - Rect::from_min_max(pos2(a, area.top() + 2.0), pos2(b, area.bottom() - 2.0)) + let (a, b) = (mx(a), mx(b)); + Rect::from_min_max(pos2(a.min(b), area.top() + 2.0), pos2(a.max(b), area.bottom() - 2.0)) } else { Rect::from_min_max(pos2(area.left() + 2.0, a), pos2(area.right() - 2.0, b)) } @@ -551,6 +555,7 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, if pos > m1 + 0.5 { break; } + let pos = if horizontal { mx(pos) } else { pos }; let major = s.is_multiple_of(minor as usize); let len = if major { 6.0 @@ -587,6 +592,12 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let a = screen(geo, 0.0, y).y; let b = screen(geo, 0.0, y + h).y; span(p, a, b, screen(geo, 0.0, y + mt).y, screen(geo, 0.0, y + h - mb).y, false, vr); + // Header and footer margins: a small marker at each distance, grabbable like the margins. + let pc = p.with_clip_rect(vr); + for at in [screen(geo, 0.0, y + mh).y, screen(geo, 0.0, y + h - mf).y] { + let x = vr.left() + 2.0; + pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + 6.0, at)], t.header_text, Stroke::NONE)); + } } p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line)); p.line_segment( @@ -602,6 +613,10 @@ pub enum Edge { Right, Top, Bottom, + /// Distance of the header from the top of the page. + Header, + /// Distance of the footer from the bottom of the page. + Footer, } impl Edge { @@ -612,14 +627,16 @@ impl Edge { Edge::Right => 1, Edge::Top => 2, Edge::Bottom => 3, + Edge::Header => 4, + Edge::Footer => 5, } } /// The `pageLayout.pageSetup` margin key. fn key(self) -> &'static str { - ["left", "right", "top", "bottom"][self.index()] + ["left", "right", "top", "bottom", "header", "footer"][self.index()] } fn label(self) -> &'static str { - ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin"][self.index()] + ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin", "Header Margin", "Footer Margin"][self.index()] } fn horizontal(self) -> bool { matches!(self, Edge::Left | Edge::Right) @@ -635,6 +652,9 @@ pub struct RulerDrag { pub page: usize, pub start_inches: f32, pub inches: f32, + /// The sheet re-paginated with `inches` (page breaks and fit-to-page scale as a commit would + /// give them), rebuilt when the value changes. + preview: Option<(f32, Arc)>, } /// Content that must stay between the margins, in points. @@ -645,32 +665,40 @@ const EDGE_GRAB: f32 = 4.0; /// Margin (inches, rounded to 0.01) for an edge dragged to `rel_pt` points from the page's /// left/top edge. The margin stays on the page and leaves [`MIN_CONTENT_PT`] between the two /// margins of its axis. `None` for NaN/infinite input or a page without a size. -pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 4]) -> Option { +pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 6]) -> Option { let page = f64::from(if edge.horizontal() { page_pt.0 } else { page_pt.1 }); if !rel_pt.is_finite() || !page.is_finite() || page <= 0.0 { return None; } let other = f64::from(margins_in.get(edge.index() ^ 1).copied().filter(|v| v.is_finite())?).max(0.0) * 72.0; let raw = match edge { - Edge::Left | Edge::Top => rel_pt, - Edge::Right | Edge::Bottom => page - rel_pt, + Edge::Left | Edge::Top | Edge::Header => rel_pt, + Edge::Right | Edge::Bottom | Edge::Footer => page - rel_pt, }; let max = (page - other - MIN_CONTENT_PT).max(0.0); let pt = raw.clamp(0.0, max); Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) } -/// The grid with a margin being dragged applied, so the page content follows the pointer. -pub(crate) fn with_dragged_margin(app: &SheetApp, si: usize, pg: Arc) -> Arc { - let Some(d) = app.grid.ruler_drag.as_ref().filter(|d| d.sheet == si) else { return pg }; - // Layout units per point (the print scale and pixel/point ratio). - let per_pt = pg.page_w / f64::from(pg.paper_pt.0); - let mut g = (*pg).clone(); - let v = f64::from(d.inches) * 72.0 * per_pt; - if let Some(m) = g.margins.get_mut(d.edge.index()).filter(|_| v.is_finite() && per_pt.is_finite()) { - *m = v; +/// The grid while a margin is dragged: the sheet is re-paginated with the previewed margin, so +/// the content area, page breaks and the fit-to-page scale are those a commit would give. +pub(crate) fn with_dragged_margin(app: &mut SheetApp, wb: &Workbook, si: usize, pg: Arc) -> Arc { + let Some(d) = app.grid.ruler_drag.as_mut().filter(|d| d.sheet == si) else { return pg }; + if let Some((v, g)) = &d.preview + && (*v - d.inches).abs() < f32::EPSILON + { + return g.clone(); } - Arc::new(g) + let mut w = wb.clone(); + let Some(sheet) = w.sheets.get_mut(si) else { return pg }; + let Some(slot) = Arc::make_mut(sheet).print.margins.get_mut(d.edge.index()) else { return pg }; + *slot = d.inches; + let rows = pg.row_bands.last().map_or(0, |b| b.1); + let cols = pg.col_bands.last().map_or(0, |b| b.1); + let Some(g) = PageGrid::build(&w, si, rows, cols) else { return pg }; + let g = Arc::new(g); + d.preview = Some((d.inches, g.clone())); + g } /// Points per layout unit of the grid (paper size over the layout page size). @@ -682,7 +710,7 @@ fn pt_per_unit(pg: &PageGrid) -> Option { /// The margin edge of a visible page under `pos` on the horizontal (`h`) or vertical ruler. fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize)> { let ((i0, i1), (j0, j1)) = visible_pages(geo, pg); - let [ml, mr, mt, mb, ..] = pg.margins; + let [ml, mr, mt, mb, mh, mf] = pg.margins; let mut best: Option<(f32, Edge, usize)> = None; let mut consider = |d: f32, e: Edge, page: usize| { if d <= EDGE_GRAB && best.is_none_or(|b| d < b.0) { @@ -700,6 +728,8 @@ fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize) let (_, y, _, hh) = pg.page_rect(i, 0); consider((screen(geo, 0.0, y + mt).y - pos.y).abs(), Edge::Top, i); consider((screen(geo, 0.0, y + hh - mb).y - pos.y).abs(), Edge::Bottom, i); + consider((screen(geo, 0.0, y + mh).y - pos.y).abs(), Edge::Header, i); + consider((screen(geo, 0.0, y + hh - mf).y - pos.y).abs(), Edge::Footer, i); } } best.map(|(_, e, p)| (e, p)) @@ -720,11 +750,12 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid } let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); - let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + // Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical. + let hresp = ui.interact(geo.mirror_rect(hr), egui::Id::new("ruler_margin_h"), egui::Sense::drag()); let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); - let margins_in = |sh: &Sheet| -> [f32; 4] { + let margins_in = |sh: &Sheet| -> [f32; 6] { let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); - [m(0), m(1), m(2), m(3)] + [m(0), m(1), m(2), m(3), m(4), m(5)] }; let current = margins_in(sh); let metric = metric(app); @@ -745,7 +776,7 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid } cursor(ui, d.edge); let down = ui.input(|i| i.pointer.primary_down()); - let pos = ui.input(|i| i.pointer.latest_pos()); + let pos = ui.input(|i| i.pointer.latest_pos()).map(|p| geo.mirror_pos(p)); let mut cur = d.clone(); if let (Some(pos), Some(k)) = (pos, pt_per_unit(pg)) { let rel = if d.edge.horizontal() { @@ -774,10 +805,11 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid for (resp, h) in [(&hresp, true), (&vresp, false)] { let Some(pos) = resp.hover_pos().or_else(|| resp.interact_pointer_pos()) else { continue }; let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(pos) } else { pos }; + let origin = geo.mirror_pos(origin); let Some((edge, page)) = edge_at(geo, pg, origin, h) else { continue }; let value = current.get(edge.index()).copied().unwrap_or(0.5); if resp.drag_started() { - app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value }); + app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value, preview: None }); cursor(ui, edge); } else if resp.hovered() { cursor(ui, edge); @@ -868,7 +900,7 @@ mod tests { #[test] fn drag_maps_to_margins_and_clamps_inside_the_page() { - let m = [0.7, 0.7, 0.75, 0.75]; + let m = [0.7, 0.7, 0.75, 0.75, 0.3, 0.3]; // 72 pt from the left edge is one inch; from the right edge the margin is measured inward. assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); @@ -883,8 +915,12 @@ mod tests { assert_eq!(margin_from_drag(Edge::Left, f64::NAN, A4, m), None); assert_eq!(margin_from_drag(Edge::Left, f64::INFINITY, A4, m), None); assert_eq!(margin_from_drag(Edge::Left, 10.0, (0.0, 0.0), m), None); - assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7]), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7, 0.3, 0.3]), None); // Oversized opposite margin leaves nothing to drag into. - assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0]), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0, 0.3, 0.3]), Some(0.0)); + // Header/footer distances are measured from the top / bottom edge. + assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); + assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); + assert_eq!(margin_from_drag(Edge::Footer, -1e9, A4, m), Some(0.0)); } } diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index 632b9e2..35028a7 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -12,8 +12,20 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn fixture() -> Harness { + fixture_with(false, None) +} + +/// `rtl` mirrors the sheet; `setup` is extra `pageLayout.pageSetup` params. +fn fixture_with(rtl: bool, setup: Option) -> Harness { let mut session = Session::new(); session.new_workbook(); + if rtl { + session.execute("view.rightToLeft", json!({"on": true})).unwrap(); + } + if let Some(p) = setup { + session.execute("cell.set", json!({"cell": "Z1", "input": "wide"})).unwrap(); + session.execute("pageLayout.pageSetup", p).unwrap(); + } for r in 1..=60 { session.execute("cell.set", json!({"cell": format!("A{r}"), "input": r.to_string()})).unwrap(); } @@ -216,3 +228,99 @@ fn dragging_a_ruler_edge_previews_commits_on_release_as_one_undo_step_and_esc_ca h.run_steps(3); assert_eq!(margins(&h), start_margins); } + +fn press(h: &mut Harness, p: Pos2) { + h.run_steps(40); + mouse(h, Event::PointerMoved(p)); + mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); +} + +fn release(h: &mut Harness, p: Pos2) { + mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Screen pixels per inch of the page. +fn per_inch(g: &Geo) -> f32 { + let pg = g.paper.clone().unwrap(); + (72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z +} + +#[test] +fn header_and_footer_margins_are_dragged_on_the_vertical_ruler() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let ruler_x = h.state().grid.rect.unwrap().left() + 9.0; + // Header: distance from the top of the page, 0.2 in further down. + let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y); + let to = pos2(ruler_x, at.y + 0.2 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[4] - (start[4] + 0.2)).abs() < 0.03, "{after:?} vs {start:?}"); + assert_eq!((after[..4].to_vec(), after[5]), (start[..4].to_vec(), start[5])); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(margins(&h), start); + // Footer: distance from the bottom, dragged up by 0.2 in grows it (zoom out so the page + // bottom is on screen). + h.state_mut().run("view.zoom", json!({"percent": 50})).unwrap(); + h.run_steps(4); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let (_, py, _, ph) = pg.page_rect(0, 0); + let at = pos2(ruler_x, screen(&g, 0.0, py + ph - pg.margins[5]).y); + let to = pos2(ruler_x, at.y - 0.2 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y - 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + assert!((margins(&h)[5] - (start[5] + 0.2)).abs() < 0.03, "{:?}", margins(&h)); +} + +#[test] +fn right_to_left_sheets_mirror_the_horizontal_ruler_drag() { + let mut h = fixture_with(true, None); + let g = geo(&h); + assert!(g.rtl); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let y = h.state().grid.rect.unwrap().top() + 9.0; + // The left margin edge is drawn on the right of the mirrored page; dragging it towards the + // page centre (screen-left) grows the left margin. + let at = pos2(g.mirror_x(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x), y); + let to = pos2(at.x - per_inch(&g), y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x - 4.0, y))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[0] - (start[0] + 1.0)).abs() < 0.03, "{after:?} vs {start:?}"); + assert_eq!(after[1], start[1]); +} + +#[test] +fn preview_repaginates_so_fit_to_width_matches_the_commit() { + let mut h = fixture_with(false, Some(json!({"fitWidth": 1, "fitHeight": 0}))); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let y = h.state().grid.rect.unwrap().top() + 9.0; + let at = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, y); + let to = pos2(at.x + 2.0 * per_inch(&g), y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x + 4.0, y))); + mouse(&mut h, Event::PointerMoved(to)); + h.run_steps(2); + let live_scale = geo(&h).paper.unwrap().scale; + assert_eq!(margins(&h), start); + assert!(live_scale < pg.scale, "live scale {live_scale} should shrink below {}", pg.scale); + release(&mut h, to); + h.run_steps(2); + let committed = geo(&h).paper.unwrap().scale; + assert!((committed - live_scale).abs() < 0.01, "committed {committed} vs preview {live_scale}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 52dd3a3..04a7750 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -28,7 +28,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction From 600c25098f4396a641b20a17f0bf02f664e96493 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:01:22 +0200 Subject: [PATCH 114/162] Fix footer clamp expectation in the drag test (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/paper_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index f560f13..89a567e 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -921,6 +921,6 @@ mod tests { // Header/footer distances are measured from the top / bottom edge. assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); - assert_eq!(margin_from_drag(Edge::Footer, -1e9, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Footer, 1e9, A4, m), Some(0.0)); } } From bd105a569bbee8a11b5fca9ab3c0203c66e2589f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:03:37 +0200 Subject: [PATCH 115/162] Page Layout view: vertical ruler on the right for RTL sheets, header/footer distance clamped to the margins (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/grid.rs | 7 ++- crates/ui-egui/src/paper_view.rs | 60 +++++++++++++++++++----- crates/ui-egui/tests/page_layout_view.rs | 48 +++++++++++++++++++ 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 80af151..10321d1 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -490,7 +490,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Page Layout view: pages on a desk, rulers above and left of the headings. let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; - let grid_rect = Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max); + // Right-to-left sheets carry the vertical ruler on the right, beside the row headings. + let grid_rect = if rtl { + Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom())) + } else { + Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) + }; let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { if paper { let probe = Geo::new(sh, grid_rect, scroll); diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 89a567e..621a979 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -511,6 +511,14 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { app.grid.hf_edit = Some(e); } +/// Screen rectangles of the horizontal and vertical rulers. Right-to-left sheets mirror both: +/// the vertical ruler sits on the right, beside the row headings. +fn ruler_rects(outer: Rect, geo: &Geo) -> (Rect, Rect) { + let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); + let (x0, x1) = if geo.rtl { (outer.right() - RULER, outer.right()) } else { (outer.left(), outer.left() + RULER) }; + (hr, Rect::from_min_max(pos2(x0, geo.cells.top()), pos2(x1, geo.cells.bottom()))) +} + /// Horizontal and vertical rulers (`outer` is the grid area including the rulers). pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, t: &Tokens) { let Some(pg) = geo.paper.clone() else { return }; @@ -524,13 +532,12 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let minor = if metric { 2.0 } else { 4.0 }; let label_every = ((22.0 / unit).ceil() as usize).max(1); let font = theme::ui_font(9.5); - let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); - let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let (hr, vr) = ruler_rects(outer, geo); // Right-to-left sheets: the horizontal ruler is mirrored with the pages (ticks run from the // right margin leftwards). let mx = |x: f32| geo.mirror_x(x); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window); - p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window); + p.rect_filled(Rect::from_min_max(pos2(vr.left(), outer.top()), pos2(vr.right(), outer.bottom())), 0.0, t.window); let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg); let [ml, mr, mt, mb, mh, mf] = pg.margins; // One ruler span per page: margins shaded, the printable part light, ticks from the margin. @@ -575,6 +582,8 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, } let (a, b) = if horizontal { (pos2(pos, area.bottom() - 2.0 - len), pos2(pos, area.bottom() - 2.0)) + } else if geo.rtl { + (pos2(area.left() + 2.0, pos), pos2(area.left() + 2.0 + len, pos)) } else { (pos2(area.right() - 2.0 - len, pos), pos2(area.right() - 2.0, pos)) }; @@ -595,13 +604,17 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, // Header and footer margins: a small marker at each distance, grabbable like the margins. let pc = p.with_clip_rect(vr); for at in [screen(geo, 0.0, y + mh).y, screen(geo, 0.0, y + h - mf).y] { - let x = vr.left() + 2.0; - pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + 6.0, at)], t.header_text, Stroke::NONE)); + let (x, d) = if geo.rtl { (vr.right() - 2.0, -6.0) } else { (vr.left() + 2.0, 6.0) }; + pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + d, at)], t.header_text, Stroke::NONE)); } } p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line)); p.line_segment( - [pos2(outer.left() + RULER - 0.5, outer.top()), pos2(outer.left() + RULER - 0.5, outer.bottom())], + if geo.rtl { + [pos2(vr.left() + 0.5, outer.top()), pos2(vr.left() + 0.5, outer.bottom())] + } else { + [pos2(vr.right() - 0.5, outer.top()), pos2(vr.right() - 0.5, outer.bottom())] + }, Stroke::new(1.0, t.header_line), ); } @@ -675,8 +688,19 @@ pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in Edge::Left | Edge::Top | Edge::Header => rel_pt, Edge::Right | Edge::Bottom | Edge::Footer => page - rel_pt, }; - let max = (page - other - MIN_CONTENT_PT).max(0.0); - let pt = raw.clamp(0.0, max); + let mut max = (page - other - MIN_CONTENT_PT).max(0.0); + let mut min = 0.0_f64; + let pt_of = |i: usize| f64::from(margins_in.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).max(0.0) * 72.0; + match edge { + // The header/footer sits within its page margin (conservative: never past the margin edge). + Edge::Header => max = max.min(pt_of(2)), + Edge::Footer => max = max.min(pt_of(3)), + // ... so the top/bottom margin can't be dragged inside it. + Edge::Top => min = pt_of(4), + Edge::Bottom => min = pt_of(5), + _ => {} + } + let pt = raw.clamp(min.min(max), max); Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) } @@ -748,10 +772,9 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid if app.grid.ruler_drag.as_ref().is_some_and(|d| d.sheet != si) { app.grid.ruler_drag = None; } - let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); - let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let (hr, vr) = ruler_rects(outer, geo); // Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical. - let hresp = ui.interact(geo.mirror_rect(hr), egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); let margins_in = |sh: &Sheet| -> [f32; 6] { let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); @@ -905,9 +928,9 @@ mod tests { assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Top, 108.0, A4, m), Some(1.5)); - // Dragged off the page: never negative. + // Dragged off the page: never negative (top/bottom keep the header/footer distance). assert_eq!(margin_from_drag(Edge::Left, -50.0, A4, m), Some(0.0)); - assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.3)); // Dragged across the opposite margin: stops a minimum content width short of it. let max = margin_from_drag(Edge::Left, 10_000.0, A4, m).unwrap(); assert!((f64::from(max) * 72.0 - (f64::from(A4.0) - 0.7 * 72.0 - MIN_CONTENT_PT)).abs() < 1.0, "{max}"); @@ -922,5 +945,16 @@ mod tests { assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); assert_eq!(margin_from_drag(Edge::Footer, 1e9, A4, m), Some(0.0)); + // Header stays within the top margin (0.75 in), footer within the bottom margin. + assert_eq!(margin_from_drag(Edge::Header, 500.0, A4, m), Some(0.75)); + assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 500.0, A4, m), Some(0.75)); + // The top/bottom margin can't be dragged inside the header/footer distance (0.3 in). + assert_eq!(margin_from_drag(Edge::Top, 2.0, A4, m), Some(0.3)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) - 2.0, A4, m), Some(0.3)); + // Hostile header/footer values (NaN, negative, huge) never panic or escape the page. + assert_eq!(margin_from_drag(Edge::Header, 10.0, A4, [0.7, 0.7, 0.75, 0.75, f32::NAN, 0.3]), Some(0.14)); + assert_eq!(margin_from_drag(Edge::Top, 300.0, A4, [0.7, 0.7, 0.75, 0.75, -5.0, f32::INFINITY]), Some(4.17)); + assert_eq!(margin_from_drag(Edge::Header, f64::NAN, A4, m), None); + assert!(margin_from_drag(Edge::Footer, 10.0, A4, [0.0, 0.0, 1e9, 1e9, 0.3, 1e9]).is_some_and(|v| (0.0..=11.7).contains(&v))); } } diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index 35028a7..b2e80d0 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -324,3 +324,51 @@ fn preview_repaginates_so_fit_to_width_matches_the_commit() { let committed = geo(&h).paper.unwrap().scale; assert!((committed - live_scale).abs() < 0.01, "committed {committed} vs preview {live_scale}"); } + +#[test] +fn right_to_left_sheets_carry_the_vertical_ruler_on_the_right() { + let mut h = fixture_with(true, None); + let g = geo(&h); + assert!(g.rtl); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let outer = h.state().grid.rect.unwrap(); + // The grid leaves the ruler's width free on the right, not on the left. + assert!((g.rect.right() - (outer.right() - gridcraft_ui_egui::paper_view::RULER)).abs() < 0.5, "{:?} {outer:?}", g.rect); + assert!((g.rect.left() - outer.left()).abs() < 0.5); + let x = outer.right() - 9.0; + let at = pos2(x, screen(&g, 0.0, pg.page_y(0) + pg.margins[2]).y); + let to = pos2(x, at.y + 0.25 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[2] - (start[2] + 0.25)).abs() < 0.03, "{after:?} vs {start:?}"); + // The left ruler strip is not a handle any more: nothing happens there. + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let at = pos2(outer.left() + 9.0, screen(&g, 0.0, pg.page_y(0) + pg.margins[3]).y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x, at.y - 30.0))); + release(&mut h, pos2(at.x, at.y - 30.0)); + assert_eq!(margins(&h)[3], start[3]); +} + +#[test] +fn header_distance_cannot_be_dragged_past_the_top_margin() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let ruler_x = h.state().grid.rect.unwrap().left() + 9.0; + let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y); + let to = pos2(ruler_x, at.y + 3.0 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[4] - start[2]).abs() < 0.011, "header {} should stop at the top margin {}", after[4], start[2]); + assert_eq!(after[2], start[2]); +} From aa2a58062664daeb94789a286ab6b35c25c43109 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:05:59 -0400 Subject: [PATCH 116/162] =?UTF-8?q?PivotTables:=20values=20on=20rows=20rev?= =?UTF-8?q?iew=20fixes=20(Total=20header,=20=CE=A3=20Values=20last=20in=20?= =?UTF-8?q?Columns)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With values on rows and no column field, the header cell above the data column reads "Total", as Excel saves it (tabular checked against a real file). Σ Values in Columns always comes after the last column field: the column layout doesn't place it anywhere else, so `pivot.moveField` no longer stores a position there, the reader ignores where `-2` sits in colFields and the writer puts it last. `pivot.changeSource` removes dropped row and column fields one by one so Σ Values keeps its place ahead of the field it preceded. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/pivot.rs | 61 +++++++++++++++++++++++++--- crates/engine/src/pivot.rs | 7 +++- crates/model/src/features.rs | 3 +- crates/xlsx/src/pivot.rs | 11 ++--- crates/xlsx/src/tests/pivot_tests.rs | 16 ++++++-- 5 files changed, 81 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 5a35c10..38ea4bf 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Move Field", [], None, - "{pivot?, field (\"Σ Values\" moves the value fields between rows and columns), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}", + "{pivot?, field (\"Σ Values\" moves the value fields between rows and columns; in columns it stays after the last field), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}", has_doc, move_field ), @@ -382,7 +382,9 @@ fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: O shift_values(pt, rows, at, true); } -/// Moves Σ Values between Rows and Columns, or within one, to `position` (default: the end). +/// Moves Σ Values between Rows and Columns, or within Rows, to `position` (default: the end). +/// In Columns it always comes after the last column field: the column layout doesn't place it +/// anywhere else yet. fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option) -> Result { let current = if pt.values_on_rows { "rows" } else { "columns" }; if from != current { @@ -392,8 +394,7 @@ fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, posit return Err(other("The field you are moving cannot be placed in that PivotTable area.")); } pt.values_on_rows = to == "rows"; - let n = if pt.values_on_rows { pt.rows.len() } else { pt.columns.len() }; - pt.values_position = position.filter(|p| *p < n); + pt.values_position = position.filter(|p| pt.values_on_rows && *p < pt.rows.len()); Ok(json!({"field": VALUES_FIELD, "to": to})) } @@ -1331,8 +1332,20 @@ fn change_source(s: &mut Session, p: &Json) -> Result { } ok }; - pt.rows.retain(|f| keep(&f.source_col, &mut dropped)); - pt.columns.retain(|f| keep(&f.source_col, &mut dropped)); + // Axis fields leave one by one so Σ Values keeps its place among the rest. + for rows in [true, false] { + let mut i = 0; + loop { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let Some(f) = list.get(i) else { break }; + if keep(&f.source_col, &mut dropped) { + i += 1; + } else { + list.remove(i); + shift_values(pt, rows, i, false); + } + } + } pt.values.retain(|f| keep(&f.source_col, &mut dropped)); pt.filters.retain(|f| keep(&f.source_col, &mut dropped)); Ok(json!({"source": source, "dropped": dropped})) @@ -1807,6 +1820,12 @@ mod tests { assert_eq!(val(&s, "B13"), Value::Number(TOTAL)); assert_eq!(text(&s, "A14"), "Total Sum of Units"); assert_eq!(val(&s, "B14"), Value::Number(72.0)); + // Tabular, no column field: "Total" heads the data column, as Excel saves it. + s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); + assert_eq!(text(&s, "A3"), "Region"); + assert_eq!(text(&s, "B3"), "Values"); + assert_eq!(text(&s, "C3"), "Total"); + s.execute("pivot.layout", json!({"layout": "compact"})).unwrap(); // Σ Values before Region: the value fields are the outer items. s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap(); assert_eq!(pt(&s).values_position, Some(0)); @@ -1841,6 +1860,36 @@ mod tests { assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err()); } + #[test] + fn values_stay_last_in_columns() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap(); + let p = pt(&s); + assert!(!p.values_on_rows); + assert_eq!(p.values_position, None); + // Σ Values to Rows and back with a position: still last in Columns. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, None); + assert_eq!(text(&s, "B5"), "Sum of Sales"); + assert_eq!(text(&s, "C5"), "Sum of Units"); + } + + #[test] + fn change_source_keeps_values_ahead_of_its_field() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap(); + // The new source has no Region: Σ Values stays ahead of Product. + s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap(); + let p = pt(&s); + assert_eq!(p.rows.len(), 1); + assert_eq!(p.values_position, Some(0)); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(text(&s, "A5"), "Apples"); + } + #[test] fn values_on_rows_between_row_fields() { let mut s = sales(); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index ed381a3..41b7f40 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -1061,8 +1061,11 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None), + // Values on rows, no column field: the one data column is the grand total. + None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None), + None => {} } } } else { diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index fdebd2d..498d513 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -978,7 +978,8 @@ pub struct PivotTable { /// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather /// than Columns (Excel's "values on rows"). pub values_on_rows: bool, - /// Position of Σ Values among the fields of its area (`None` = after the last). + /// Position of Σ Values among the row fields (`None` = after the last). Only used with + /// `values_on_rows`: in Columns Σ Values always comes after the last column field. pub values_position: Option, } diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 0c480ed..609ae53 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -830,10 +830,11 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn let loc = RangeRef::new(a, CellRef::new(end.row.max(a.row), end.col.max(a.col))); let k = vals.len(); // Σ Values (two or more value fields): its index among the written fields of its axis. - let (row_map, col_map) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len())); + let (row_map, _) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len())); let values_at = |map: &[Option], n: usize| pt.values_position.map_or(n, |p| map.iter().take(p).filter(|x| x.is_some()).count()); let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, row_idx.len())); - let col_values = (k > 1 && !pt.values_on_rows).then(|| values_at(col_map, col_idx.len())); + // In Columns Σ Values comes after the last field: the column layout doesn't place it elsewhere. + let col_values = (k > 1 && !pt.values_on_rows).then_some(col_idx.len()); let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(col_values.is_some()) }; let row_levels = row_idx.len() + usize::from(row_values.is_some()); let first_data_col = if row_levels == 0 && col_idx.is_empty() { @@ -1334,10 +1335,10 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option"#; + let (wb, _) = read_xlsx(&with_pivot(table, Some(GOOD_CACHE))).unwrap(); + let pt = &wb.sheet(0).unwrap().pivots[0]; + assert_eq!((pt.columns.len(), pt.values.len()), (1, 2)); + assert!(!pt.values_on_rows); + assert_eq!(pt.values_position, None); } #[test] From a5f531838ba2341eee61a3ab59573ef3eb032ccf Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 21:08:20 +0200 Subject: [PATCH 117/162] Functions: note the dependency gap of computed criteria (part of #737) --- crates/calc/src/eval.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 98c1179..b22c48a 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -104,6 +104,10 @@ impl Ctx for FnCtx<'_, '_> { } /// Excel's "formula as criteria": the formula is written for the first record, so its relative /// references move down to `record`'s row, and the database's labels name its fields. + /// Known gap: the shifted references are not dependencies of the calling cell. One that lands + /// outside both the database and the criteria cell's own precedents (say `=G2 Option { let area = self.criteria?; let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?; From 2be04524b3a4fcfecfd28a274c30067c3d488aca Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:09:27 +0200 Subject: [PATCH 118/162] Calc: SUBTOTAL(10x) recalculates when Row Height, AutoFit Row Height or Ungroup reveal rows home.rowHeight, home.autofitRowHeight and data.ungroup change row hidden state without marking the edit structural, so SUBTOTAL(101-111) kept a stale value. Found by the command-sequence fuzz (seed 3). Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/data.rs | 1 + crates/engine/src/cmd/format.rs | 2 ++ crates/engine/src/tests.rs | 16 ++++++++++++++++ 4 files changed, 20 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..fafa759 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | SUBTOTAL(109) over hidden rows recalculates after Row Height, AutoFit Row Height and Ungroup reveal rows (found by the command-sequence fuzz) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3ba..d2b3612 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -1129,6 +1129,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result { { e.collapsed = false; } + cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index dde5fa1..8d46010 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -953,6 +953,7 @@ fn row_height(s: &mut Session, p: &Json) -> Result { e.custom = true; } } + cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } @@ -1069,6 +1070,7 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result { } cx.fit_rows.extend((a..=b).map(|r| (si, r))); } + cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..1e78b37 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2795,3 +2795,19 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() { assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched"); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [4], [8]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=SUBTOTAL(109,A1:A4)"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(9.0)); + // A non-zero height unhides the rows (the fuzz found the stale SUBTOTAL). + s.execute("home.rowHeight", json!({"rows": "2:3", "height": 60.0})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(15.0)); + s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(9.0)); + s.execute("home.autofitRowHeight", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(15.0)); +} From fd53de7c46bf399c4979184444b37bb1d10eec9e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:09:09 +0200 Subject: [PATCH 119/162] Numfmt: TEXT() and chart labels parse each format code once (#806) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/chart/src/axis.rs | 2 +- crates/chart/src/resolve.rs | 2 +- crates/numfmt/src/lib.rs | 76 +++++++++++++++- crates/numfmt/src/tests.rs | 171 ++++++++++++++++++++++++++++++++++++ 5 files changed, 249 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..a751272 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | `TEXT()` over a range and chart labels parse each number format code once instead of once per value (#806) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/chart/src/axis.rs b/crates/chart/src/axis.rs index eb4f733..8686bf7 100644 --- a/crates/chart/src/axis.rs +++ b/crates/chart/src/axis.rs @@ -122,7 +122,7 @@ impl Fmt { pub fn new(code: &str) -> Fmt { let t = code.trim(); let general = t.is_empty() || t.eq_ignore_ascii_case("general"); - Fmt { general, fmt: NumberFormat::parse(if general { "General" } else { t }) } + Fmt { general, fmt: NumberFormat::parse_cached(if general { "General" } else { t }) } } /// Formats `v`; `step` (axis labels) fixes the decimals for General. pub fn format(&self, v: f64, step: Option) -> String { diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 2c17eff..b3de1ca 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -145,7 +145,7 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { if let Some(c) = at { let code = &wb.styles.get(s.style_id(c)).num_fmt.0; if !code.eq_ignore_ascii_case("general") { - return format_value(v, &NumberFormat::parse(code), wb.date_system).text; + return format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text; } } } diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 674e844..b4f259f 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -101,6 +101,8 @@ impl Default for NumberFormat { impl NumberFormat { /// Parses a format code. Never fails: unknown or malformed parts degrade to literal text. pub fn parse(code: &str) -> NumberFormat { + #[cfg(test)] + parse_cache::count_parse(); let trimmed = code.trim(); // `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other // value displays as General. @@ -294,6 +296,78 @@ pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted } } +/// A small per-thread cache of parsed format codes for callers that format many values with the +/// same code (`TEXT` over a range, chart labels). A parsed format depends on the code text only +/// (the date system and values are applied when formatting), so the code is the whole key. +mod parse_cache { + use std::cell::RefCell; + use std::collections::HashMap; + + use super::NumberFormat; + + /// Most codes kept per thread. A full cache is emptied, so memory stays bounded. + pub(super) const CAPACITY: usize = 64; + /// Longer codes are parsed every time and never stored. + pub(super) const MAX_CODE_LEN: usize = 256; + + thread_local! { + static CACHE: RefCell> = RefCell::new(HashMap::new()); + } + + pub(super) fn get(code: &str) -> NumberFormat { + if code.len() > MAX_CODE_LEN { + return NumberFormat::parse(code); + } + // Not usable (thread shutting down, re-entered): parse without the cache. + let cached = CACHE + .try_with(|c| { + let mut c = c.try_borrow_mut().ok()?; + if let Some(f) = c.get(code) { + return Some(f.clone()); + } + let f = NumberFormat::parse(code); + if c.len() >= CAPACITY { + c.clear(); + } + c.insert(code.to_string(), f.clone()); + Some(f) + }) + .ok() + .flatten(); + cached.unwrap_or_else(|| NumberFormat::parse(code)) + } + + #[cfg(test)] + thread_local! { + static PARSES: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + #[cfg(test)] + pub(super) fn count_parse() { + let _ = PARSES.try_with(|p| p.set(p.get() + 1)); + } + + /// Parses done on this thread so far (tests only). + #[cfg(test)] + pub(super) fn parses() -> usize { + PARSES.try_with(std::cell::Cell::get).unwrap_or(0) + } + + /// Empties this thread's cache (tests only). + #[cfg(test)] + pub(super) fn clear() { + let _ = CACHE.try_with(|c| c.borrow_mut().clear()); + } +} + +impl NumberFormat { + /// Like [`NumberFormat::parse`], but remembers recently parsed codes (per thread, bounded) so + /// formatting many values with one code parses it once. Same result as `parse`. + pub fn parse_cached(code: &str) -> NumberFormat { + parse_cache::get(code) + } +} + /// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is /// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text /// section, or are returned as is. @@ -301,7 +375,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result return Err(e), Value::Empty => 0.0, diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 6c2306c..ca61418 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -582,3 +582,174 @@ fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() { let (d, e) = decimal_digits(999999999999999.6); assert_eq!((d, e), ([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15)); } + +// ---- parse cache ---- + +/// `TEXT()` as it was before the cache: parse the code, then format. +fn text_uncached(v: &Value, code: &str, sys: DateSystem) -> Result { + if code.is_empty() { + return Ok(String::new()); + } + let fmt = NumberFormat::parse(code); + let n = match v.scalar() { + Value::Error(e) => return Err(e), + Value::Empty => 0.0, + Value::Number(n) => n, + Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text), + Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) { + Some(n) => n, + None => return Ok(fmt.format_text(&t).text), + }, + Value::Array(_) => return Err(CellError::Value), + }; + if !n.is_finite() { + return Err(CellError::Num); + } + let (f, overflow) = fmt.format_number(n, sys); + if overflow { Err(CellError::Value) } else { Ok(f.text) } +} + +const CACHE_CODES: &[&str] = &[ + "0", + "0.00", + "#,##0.00", + "#,##0.00;[Red](#,##0.00)", + "$#,##0.00", + "0%", + "0.0%", + "0.00E+00", + "##0.0E+0", + "General", + "general", + "checkbox", + "@", + "0;@", + "0;-0;;@", + "00000", + "# ?/?", + "# ??/16", + "?/?", + "m/d/yyyy", + "yyyy-mm-dd hh:mm:ss", + "dddd, mmmm d, yyyy", + "h:mm AM/PM", + "[h]:mm:ss", + "mm:ss.000", + "[Red]0;[Blue]-0", + "[>100]0.0;[<5]0.000;0", + "[<=9999999]###-####;(###) ###-####", + "[$-409]d mmm yy", + "\"abc\"0", + "\\x0", + "*-0", + "_(* #,##0_)", + "0.0.0", + "[", + "\"abc", + "0;0;0;0;0", + "#,##0.00;[Red", + "[$-]0", + " 0.00 ", + "[Color99]0", + "0.00_);(0.00)", + "#,,\"M\"", + "yyyy", + "", +]; + +fn cache_values() -> Vec { + vec![ + Value::Number(0.0), + Value::Number(1234.5678), + Value::Number(-42.5), + Value::Number(0.5), + Value::Number(45000.75), + Value::Number(1e15), + Value::Number(1e-7), + Value::Number(-0.0001), + Value::Number(f64::INFINITY), + Value::from("abc"), + Value::from("12.5"), + Value::Bool(true), + Value::Empty, + Value::Error(CellError::NA), + ] +} + +#[test] +fn parse_cached_gives_the_same_results_cold_and_warm() { + assert!(CACHE_CODES.len() >= 40); + parse_cache::clear(); + let values = cache_values(); + for sys in [DateSystem::D1900, DateSystem::D1904] { + // Cold on the first system, warm on the second. + for code in CACHE_CODES { + let direct = NumberFormat::parse(code); + let cached = NumberFormat::parse_cached(code); + assert_eq!(cached.code(), direct.code(), "{code:?}"); + assert_eq!( + (cached.kind(), cached.is_date(), cached.is_text(), cached.is_general()), + (direct.kind(), direct.is_date(), direct.is_text(), direct.is_general()) + ); + for v in &values { + let want = text_uncached(v, code, sys); + assert_eq!(text_function(v, code, sys), want, "TEXT({v:?}, {code:?})"); + // Again, now certainly warm. + assert_eq!(text_function(v, code, sys), want, "warm TEXT({v:?}, {code:?})"); + assert_eq!(format_value(v, &cached, sys), format_value(v, &direct, sys), "{v:?} {code:?}"); + } + } + } +} + +#[test] +fn parse_cached_parses_each_code_once() { + parse_cache::clear(); + let before = parse_cache::parses(); + for i in 0..10_000 { + assert!(text_function(&Value::Number(f64::from(i) + 0.5), "0.00", S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 1); + + let before = parse_cache::parses(); + for i in 0..10_000 { + let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" }; + assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok()); + } + // "0.00" is still cached from above: only the second code is new. + assert_eq!(parse_cache::parses() - before, 1); + + parse_cache::clear(); + let before = parse_cache::parses(); + for i in 0..10_000 { + let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" }; + assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 2); +} + +#[test] +fn parse_cache_is_bounded() { + parse_cache::clear(); + // More distinct codes than the capacity: all still format correctly... + let n = parse_cache::CAPACITY + 10; + for i in 0..n { + let c = format!("0.00\"{i}\""); + assert_eq!(text_function(&Value::Number(1.0), &c, S), Ok(format!("1.00{i}"))); + } + // ...the first ones were evicted (parsed again), the newest is still cached. + let before = parse_cache::parses(); + let newest = format!("0.00\"{}\"", n - 1); + assert!(text_function(&Value::Number(1.0), &newest, S).is_ok()); + assert_eq!(parse_cache::parses() - before, 0); + assert!(text_function(&Value::Number(1.0), "0.00\"0\"", S).is_ok()); + assert_eq!(parse_cache::parses() - before, 1); + + // A very long code is parsed on every call and not stored. + let long = format!("0\"{}\"", "x".repeat(parse_cache::MAX_CODE_LEN)); + let before = parse_cache::parses(); + for _ in 0..3 { + assert!(text_function(&Value::Number(1.0), &long, S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 3); +} From e5b12dab06b0bff899b72d900022f6012fe6c516 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:09:52 -0400 Subject: [PATCH 120/162] Functions: COUNTIF family counts the 255 criterion limit in UTF-16 units, as in Excel --- crates/functions/src/criteria.rs | 93 ++++++++++++++++++++++---------- crates/functions/src/math.rs | 52 ++++++++++++++++++ 2 files changed, 117 insertions(+), 28 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index aa57177..2b4c488 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -5,8 +5,9 @@ use std::cmp::Ordering; use gridcraft_core::parse::parse_number_text; use gridcraft_core::{CellError, Value, compare_numbers, compare_text}; -/// Longest text criterion COUNTIF and its relatives accept; a longer one is `#VALUE!`. -const MAX_CRITERION_CHARS: usize = 255; +/// Longest text criterion COUNTIF and its relatives accept, in UTF-16 code units as Excel +/// counts them (😀 is two units); a longer one is `#VALUE!`. +const MAX_CRITERION_UTF16_UNITS: usize = 255; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Op { @@ -25,6 +26,15 @@ enum Operand { Error(CellError), /// Text (lower-cased chars kept for wildcard matching). Text(String), + /// A criterion cut to its first 255 UTF-16 units in the middle of a surrogate pair: the + /// complete characters before the cut, followed (in Excel) by a lone high surrogate. + /// + /// No cell text holds a lone surrogate, so `=` matches nothing and `<>` everything, as + /// Excel gives 0 for SUMIFS with a criterion of 128 emoji. For `<`, `<=`, `>` and `>=` the + /// criterion sorts just above the kept text: a cell matches `>`/`>=` when it sorts above + /// that text, and `<`/`<=` otherwise. This is an approximation (Excel wasn't checked) and + /// only matters for comparison criteria over 255 units. + CutText(String), /// Nothing after the operator (`"="`, `"<>"`) or an empty criterion. Blank, } @@ -52,49 +62,51 @@ impl Criterion { } /// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS - /// functions: like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + /// functions: like `parse`, but text longer than 255 UTF-16 code units is `#VALUE!`, as in + /// Excel (checked on Excel 2016 and Microsoft 365: `"a"×254 & "😀"` is 255 characters but + /// 256 units, and is `#VALUE!`). pub(crate) fn parse_arg(v: &Value) -> Result { match v { Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), - Value::Text(t) if t.chars().nth(MAX_CRITERION_CHARS).is_some() => Err(CellError::Value), + // A UTF-8 string has at least as many bytes as UTF-16 units, so short text skips the count. + Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS && t.encode_utf16().nth(MAX_CRITERION_UTF16_UNITS).is_some() => { + Err(CellError::Value) + } other => Ok(Criterion::parse(other)), } } /// Parses a criteria argument of an *IFS function with two or more criteria pairs. Excel - /// doesn't reject a longer text criterion there: it matches its first 255 characters + /// doesn't reject a longer text criterion there: it matches its first 255 UTF-16 code units /// (checked on Excel 2016 and Microsoft 365 with COUNTIFS, SUMIFS, AVERAGEIFS, MAXIFS and - /// MINIFS). + /// MINIFS). A cut through a surrogate pair matches nothing; see `Operand::CutText`. pub(crate) fn parse_arg_truncated(v: &Value) -> Criterion { match v { Value::Array(a) => Criterion::parse_arg_truncated(a.data.first().unwrap_or(&Value::Empty)), - Value::Text(t) => match t.char_indices().nth(MAX_CRITERION_CHARS) { - Some((end, _)) => Criterion::parse_text(&t[..end]), - None => Criterion::parse_text(t), - }, + Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS => { + let mut units = 0; + for (i, c) in t.char_indices() { + if units + c.len_utf16() > MAX_CRITERION_UTF16_UNITS { + if units == MAX_CRITERION_UTF16_UNITS { + return Criterion::parse_text(&t[..i]); + } + // The cut falls between the two halves of `c`. + let (op, rest) = split_op(&t[..i]); + return Criterion { op, operand: Operand::CutText(rest.to_string()), bare_empty: false }; + } + units += c.len_utf16(); + } + Criterion::parse_text(t) + } other => Criterion::parse(other), } } fn parse_text(t: &str) -> Criterion { - let (op, rest) = if let Some(r) = t.strip_prefix(">=") { - (Op::Ge, r) - } else if let Some(r) = t.strip_prefix("<=") { - (Op::Le, r) - } else if let Some(r) = t.strip_prefix("<>") { - (Op::Ne, r) - } else if let Some(r) = t.strip_prefix('=') { - (Op::Eq, r) - } else if let Some(r) = t.strip_prefix('>') { - (Op::Gt, r) - } else if let Some(r) = t.strip_prefix('<') { - (Op::Lt, r) - } else { - if t.is_empty() { - return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true }; - } - (Op::Eq, t) - }; + if t.is_empty() { + return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true }; + } + let (op, rest) = split_op(t); let operand = if rest.is_empty() { Operand::Blank } else if let Some(n) = parse_number_text(rest) { @@ -168,10 +180,35 @@ impl Criterion { _ => false, }, }, + Operand::CutText(kept) => match self.op { + Op::Eq => false, + Op::Ne => true, + Op::Lt | Op::Le => matches!(&v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater), + Op::Gt | Op::Ge => matches!(&v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater), + }, } } } +/// Splits a leading comparison operator (`=` when there is none) off a text criterion. +fn split_op(t: &str) -> (Op, &str) { + if let Some(r) = t.strip_prefix(">=") { + (Op::Ge, r) + } else if let Some(r) = t.strip_prefix("<=") { + (Op::Le, r) + } else if let Some(r) = t.strip_prefix("<>") { + (Op::Ne, r) + } else if let Some(r) = t.strip_prefix('=') { + (Op::Eq, r) + } else if let Some(r) = t.strip_prefix('>') { + (Op::Gt, r) + } else if let Some(r) = t.strip_prefix('<') { + (Op::Lt, r) + } else { + (Op::Eq, t) + } +} + fn cmp_op(op: Op, o: Ordering) -> bool { match op { Op::Eq => o == Ordering::Equal, diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 8c67aa8..505255c 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -1668,4 +1668,56 @@ mod tests { close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); } + + #[test] + fn criteria_over_255_utf16_units_follow_excel() { + // The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these + // strings, B is the row number, and each row uses its own string as the criterion. + let e = "😀"; + let strings = + [e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)]; + let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect()); + let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + // COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`. + let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None]; + for (s, want) in strings.iter().zip(expected) { + let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]); + let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]); + let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]); + let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]); + match want { + Some([a, b, c, d]) => { + close(countif, a); + close(sumif, b); + close(countifs, c); + close(sumifs, d); + } + // Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which + // matches no cell (for emoji it splits a surrogate pair). + None => { + is_err(countif, CellError::Value); + is_err(sumif, CellError::Value); + is_err(countifs, CellError::Value); + close(sumifs, 0.0); + } + } + } + } + + #[test] + fn criteria_cut_inside_a_surrogate_pair() { + let e = "😀"; + let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]); + let rows = col(&[1.0, 2.0, 3.0, 4.0]); + let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]); + // "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji. + close(sumifs(format!("={}", e.repeat(128))), 1.0); + // With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion + // equals no cell text: "=" matches nothing and "<>" everything, blanks included. + close(sumifs(format!("=a{}", e.repeat(128))), 0.0); + close(sumifs(format!("<>{}", e.repeat(128))), 10.0); + // Comparisons treat it as sorting just above the 126 kept emoji. + close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0); + close(sumifs(format!("<={}", e.repeat(128))), 3.0); + } } From 0042240c0582da1213c1c68653343648af045532 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:18:41 +0200 Subject: [PATCH 121/162] PDF: title and author are written as UTF-16BE when they are not ASCII (#813) Info strings were WinAnsi; PDF text strings are PDFDocEncoding or UTF-16BE. Printable ASCII stays a literal string; anything else is a hex string. Info strings are capped at 1024 characters. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/pdf/src/lib.rs | 19 +++++++++++++++++-- crates/pdf/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..43805ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | PDF export writes the document title and author as UTF-16BE when they are not ASCII, so viewers show accents, currency signs and non-Latin text correctly (#813) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 51c2df1..5cc4a83 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -516,8 +516,23 @@ fn run_length(data: &[u8]) -> Vec { out } -fn pdf_text_string(s: &str) -> String { - pdf_string(s) +/// Longest Info string written, in characters; longer values are cut on a character boundary. +const MAX_INFO_CHARS: usize = 1024; + +/// A PDF text string (document Info entries) for `text`: a literal `( … )` for printable ASCII, +/// otherwise UTF-16BE with a byte-order mark as a hex string (ISO 32000-1 7.9.2.2). +fn pdf_text_string(text: &str) -> String { + let text: String = text.chars().take(MAX_INFO_CHARS).collect(); + if text.chars().all(|c| matches!(c, ' '..='~')) { + return pdf_string(&text); + } + let mut s = String::with_capacity(text.len() * 4 + 6); + s.push_str("'); + s } impl PdfDoc { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e7b488a..4ea9170 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -157,3 +157,43 @@ fn hostile_inputs_never_panic() { let pdf = d.finish(); check_xref(&pdf); } + +/// Decodes the `` hex string following `key` in the file bytes. +fn utf16_entry(pdf: &[u8], key: &str) -> String { + let at = find(pdf, key.as_bytes()).unwrap() + key.len(); + let rest = std::str::from_utf8(&pdf[at..at + 9000.min(pdf.len() - at)]).unwrap_or(""); + let hex = rest.trim_start().strip_prefix("').unwrap()]; + let units: Vec = (0..hex.len()).step_by(4).map(|i| u16::from_str_radix(&hex[i..i + 4], 16).unwrap()).collect(); + String::from_utf16(&units).unwrap() +} + +#[test] +fn info_strings_with_non_ascii_are_utf16be() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = "Übersicht €5 – “Q1” 日本 😀".into(); + doc.info.author = "Müller & Søn".into(); + let pdf = doc.finish(); + assert_eq!(utf16_entry(&pdf, "/Title"), "Übersicht €5 – “Q1” 日本 😀"); + assert_eq!(utf16_entry(&pdf, "/Author"), "Müller & Søn"); +} + +#[test] +fn ascii_info_strings_stay_literal_and_escaped() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = r"Q1 (draft) a\b".into(); + let pdf = doc.finish(); + assert!(find(&pdf, br"/Title (Q1 \(draft\) a\\b)").is_some()); + assert!(find(&pdf, b"/Producer (GridCraft PDF)").is_some()); +} + +#[test] +fn info_strings_are_capped() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = "日".repeat(100_000); + let pdf = doc.finish(); + assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS); +} From a540a7138a87e7db2faa19ccc91cef9bb585fd44 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:18:58 +0200 Subject: [PATCH 122/162] PDF: text width cannot overflow; dash, stroke width and NaN sizes are sanitised (#812) text_width sums in u64; an all-zero or non-finite dash array is written as solid ([] 0 d); stroke widths are clamped to >= 0 (NaN -> 0); drawing calls with non-finite or non-positive geometry return before storing anything. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/pdf/src/lib.rs | 69 +++++++++++++++++++++++++++-------- crates/pdf/src/tests.rs | 80 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..ac5d315 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | PDF writer hardening: text width cannot overflow, all-zero dash arrays and negative or NaN stroke widths are sanitised, non-finite sizes and positions draw nothing (#812) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 51c2df1..828f8fb 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -145,7 +145,8 @@ impl Font { /// Width of `text` in points when set in `font` at `size` points. pub fn text_width(font: Font, size: f32, text: &str) -> f32 { - let units: u32 = text.chars().map(|c| font.char_width(c) as u32).sum(); + // u64: a u32 sum overflows for ~8 million characters. + let units: u64 = text.chars().map(|c| u64::from(font.char_width(c))).sum(); units as f32 * size / 1000.0 } @@ -221,6 +222,32 @@ fn n(v: f32) -> String { } } +/// True when every value is finite (NaN and infinities are rejected). +fn finite(v: &[f32]) -> bool { + v.iter().all(|x| x.is_finite()) +} + +/// A positive, finite extent (a width, height or radius). +fn extent(v: f32) -> bool { + v > 0.0 && v.is_finite() +} + +/// A stroke width: negative, NaN and infinite become 0 (the thinnest line). +fn stroke_width(w: f32) -> f32 { + if w.is_finite() { w.max(0.0) } else { 0.0 } +} + +/// The entries of a dash array (`[on, off, …]`, at most 8). A pattern the PDF specification +/// forbids (any non-finite entry, or no entry that survives rounding to a positive number) is +/// replaced by an empty array, i.e. a solid line. +fn dash_values(dash: &[f32]) -> String { + let d: Vec = dash.iter().take(8).map(|v| if v.is_finite() { v.max(0.0) } else { f32::NAN }).collect(); + if !finite(&d) || !d.iter().any(|v| (v.min(1.0e7) * 1000.0).round() > 0.0) { + return String::new(); + } + d.iter().map(|v| n(*v)).collect::>().join(" ") +} + fn rgb_op(c: Rgb, stroke: bool) -> String { let f = |v: u8| n(v as f32 / 255.0); format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" }) @@ -288,7 +315,7 @@ impl Page { } pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) { - if w <= 0.0 || h <= 0.0 { + if !(extent(w) && extent(h) && finite(&[x, y])) { return; } let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h)); @@ -296,7 +323,10 @@ impl Page { } pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) { - let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h)); + if !finite(&[x, y, w, h]) { + return; + } + let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(stroke_width(width)), n(x), n(self.y(y + h)), n(w), n(h)); self.op(&s); } @@ -306,19 +336,28 @@ impl Page { /// A line with a dash pattern (`[on, off, …]` in points; empty = solid). pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) { - let d: Vec = dash.iter().take(8).map(|v| n(v.max(0.0))).collect(); - let s = - format!("{}\n{} w [{}] 0 d\n{} {} m {} {} l S", rgb_op(color, true), n(width), d.join(" "), n(x0), n(self.y(y0)), n(x1), n(self.y(y1))); + if !finite(&[x0, y0, x1, y1]) { + return; + } + let s = format!( + "{}\n{} w [{}] 0 d\n{} {} m {} {} l S", + rgb_op(color, true), + n(stroke_width(width)), + dash_values(dash), + n(x0), + n(self.y(y0)), + n(x1), + n(self.y(y1)) + ); self.op(&s); } /// An open polyline. pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) { - if pts.len() < 2 { + if pts.len() < 2 || !pts.iter().all(|p| finite(p)) { return; } - let d: Vec = dash.iter().take(8).map(|v| n(v.max(0.0))).collect(); - let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(width), d.join(" ")); + let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash)); self.path(&mut s, pts, false); s.push_str(" S"); self.op(&s); @@ -335,7 +374,7 @@ impl Page { /// A closed polygon, filled and/or stroked. pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option, stroke: Option<(Rgb, f32)>) { - if pts.len() < 2 || (fill.is_none() && stroke.is_none()) { + if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) { return; } let mut s = String::new(); @@ -344,7 +383,7 @@ impl Page { s.push('\n'); } if let Some((c, w)) = stroke { - let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(w)); + let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w))); } self.path(&mut s, pts, true); s.push_str(match (fill.is_some(), stroke.is_some()) { @@ -357,7 +396,7 @@ impl Page { /// A circle approximated by four cubic Béziers. pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option, stroke: Option<(Rgb, f32)>) { - if r <= 0.0 || (fill.is_none() && stroke.is_none()) { + if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) { return; } const K: f32 = 0.552_284_8; @@ -369,7 +408,7 @@ impl Page { s.push('\n'); } if let Some((c, w)) = stroke { - let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(w)); + let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w))); } let _ = write!( s, @@ -417,7 +456,7 @@ impl Page { /// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate /// system) about its baseline origin. pub fn text_rotated(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str, angle: f32) { - if text.is_empty() || !(size.is_finite() && size > 0.0) { + if text.is_empty() || !extent(size) || !finite(&[x, y]) { return; } let fi = font.index(); @@ -446,7 +485,7 @@ impl Page { /// `(x, y, w, h)`. Malformed or oversized buffers are ignored. pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) { let px = pw as u64 * ph as u64; - if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || w <= 0.0 || h <= 0.0 { + if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) { return; } let px = px as usize; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e7b488a..176176b 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -157,3 +157,83 @@ fn hostile_inputs_never_panic() { let pdf = d.finish(); check_xref(&pdf); } + +fn page_content(d: &PdfDoc) -> &str { + &d.pages[0].content +} + +#[test] +fn text_width_does_not_overflow() { + // 8,000,000 x 556 units exceeds u32::MAX: a u32 sum panics in debug builds. + let long = "a".repeat(8_000_000); + let w = text_width(Font::Helvetica, 10.0, &long); + assert!(w.is_finite() && w > 0.0); + assert!(w > text_width(Font::Helvetica, 10.0, &"a".repeat(7_000_000))); +} + +#[test] +fn all_zero_dash_is_drawn_solid() { + let mut d = PdfDoc::new(); + let p = d.add_page(100.0, 100.0); + p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[0.0, 0.0]); + p.polyline(&[[0.0, 0.0], [5.0, 5.0]], 1.0, [0, 0, 0], &[0.0, 0.0001, f32::NAN]); + p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[3.0, 2.0]); + let c = page_content(&d); + assert_eq!(c.matches("[] 0 d").count(), 2); + assert!(!c.contains("[0 0]")); + assert!(c.contains("[3 2] 0 d")); +} + +#[test] +fn negative_and_nan_stroke_widths_become_zero() { + let mut d = PdfDoc::new(); + let p = d.add_page(100.0, 100.0); + p.rect_stroke(1.0, 1.0, 10.0, 10.0, -3.0, [0, 0, 0]); + p.stroke_line(0.0, 0.0, 5.0, 5.0, f32::NAN, [0, 0, 0]); + p.polygon(&[[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]], None, Some(([0, 0, 0], -1.0))); + p.circle(20.0, 20.0, 5.0, None, Some(([0, 0, 0], f32::NEG_INFINITY))); + let c = page_content(&d); + assert_eq!(c.matches("\n0 w ").count(), 4); + assert!(!c.contains("-1 w") && !c.contains("-3 w")); +} + +#[test] +fn non_finite_image_size_stores_nothing() { + let mut d = PdfDoc::new(); + let p = d.add_page(200.0, 200.0); + let px = [255u8; 16]; + for (w, h) in [(f32::NAN, 10.0), (10.0, f32::NAN), (f32::INFINITY, 10.0), (-1.0, 10.0)] { + p.image_rgba(0.0, 0.0, w, h, &px, 2, 2); + } + p.image_rgba(f32::NAN, 0.0, 10.0, 10.0, &px, 2, 2); + assert!(p.images.is_empty()); + assert!(p.content.is_empty()); +} + +#[test] +fn every_draw_method_survives_hostile_numbers() { + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -7.0, 0.0, 1.0e30, -1.0e30]; + let px = [200u8; 16]; + let mut d = PdfDoc::new(); + let p = d.add_page(300.0, 300.0); + for &a in &bad { + for &b in &bad { + p.clip_rect(a, b, a, b); + p.fill_rect(a, b, b, a, [1, 2, 3]); + p.rect_stroke(a, b, a, b, a, [1, 2, 3]); + p.stroke_line(a, b, b, a, a, [1, 2, 3]); + p.stroke_line_dashed(a, b, b, a, b, [1, 2, 3], &[a, b, 0.0]); + p.polyline(&[[a, b], [b, a], [1.0, 1.0]], a, [1, 2, 3], &[b, a]); + p.polygon(&[[a, b], [b, a], [1.0, 1.0]], Some([1, 2, 3]), Some(([4, 5, 6], a))); + p.circle(a, b, b, Some([1, 2, 3]), Some(([4, 5, 6], a))); + p.text(a, b, a, Font::Helvetica, [0, 0, 0], "x"); + p.text_rotated(a, b, 10.0, Font::TimesRoman, [0, 0, 0], "y", b); + p.image_rgba(a, b, b, a, &px, 2, 2); + } + } + p.stroke_line_dashed(0.0, 0.0, 1.0, 1.0, 1.0, [0, 0, 0], &[0.0; 100]); + let c = page_content(&d); + assert!(!c.contains("NaN") && !c.contains("inf")); + let pdf = d.finish(); + check_xref(&pdf); +} From ef1cd55d24af4e714f32bf9919c938b7d8f3b2c5 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:29:02 -0400 Subject: [PATCH 123/162] PivotTables: a collapsed item puts its value captions in the Values column (outline, tabular) --- crates/engine/src/cmd/pivot.rs | 18 ++++++++++++++++++ crates/engine/src/pivot.rs | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 38ea4bf..7026486 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -1860,6 +1860,24 @@ mod tests { assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err()); } + #[test] + fn collapsed_item_puts_values_in_the_values_column() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap(); + for layout in ["tabular", "outline"] { + s.execute("pivot.layout", json!({"layout": layout})).unwrap(); + assert_eq!(text(&s, "C3"), "Values", "{layout}"); + let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap(); + let first = if layout == "tabular" { east } else { east + 1 }; + assert_eq!(text(&s, &format!("B{first}")), "", "{layout}"); + assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}"); + assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}"); + assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}"); + } + } + #[test] fn values_stay_last_in_columns() { let mut s = sales(); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 41b7f40..a0fb268 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -664,7 +664,10 @@ impl Ctx<'_> { labels: vec![(col, v, indent, fmt)], vi, }); - self.flatten_values(&[], depth + 1, prefix, out); + // Outline gives each level its own column, so the captions go in the Values + // column even when the levels between are collapsed away. + let level = if self.pt.layout == PivotLayout::Outline { self.vpos.unwrap_or(depth + 1) } else { depth + 1 }; + self.flatten_values(&[], level, prefix, out); } else if values_below { // Subtotals per value field, below the group (one line can't hold them all). out.push(Line { @@ -749,7 +752,7 @@ impl Ctx<'_> { let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); if depth + 1 >= n || collapsed || node.children.is_empty() { if values_below { - self.flatten_tabular_values(&[], depth + 1, prefix, &labels, out); + self.flatten_tabular_values(&[], self.vpos.unwrap_or(depth + 1), prefix, &labels, out); } else { out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi }); } From 6e2caa9f865a91cb6843493941ce87e53430305e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:51:34 -0400 Subject: [PATCH 124/162] Web: opt-in embedding bridge for host pages (?host=parent) A page that frames GridCraft on the same origin can now open workbooks in it and store what it saves, through postMessage, instead of Save and Export becoming browser downloads. Built for a Nextcloud integration, but nothing in it is host-specific. Only the web shell changes: - gridcraft:open from the parent goes through the inbox, like a picked or dropped file - Services::download sends the bytes to the parent (gridcraft:save); an .xlsx workbook handed over counts as saved under that name - gridcraft:dirty reports unsaved changes; gridcraft:ready and gridcraft:failed report start-up - in host mode no sample opens, and ?author= names the user for notes and comments Enabled only by ?host=parent inside a frame; only the parent window on the page's own origin is heard or answered. Without it nothing changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G6GtUCaPiCMEQCN4QcdQnp --- apps/gridcraft-web/Cargo.toml | 2 +- apps/gridcraft-web/src/host.rs | 166 +++++++++++++++++++++++++++++++++ apps/gridcraft-web/src/main.rs | 7 +- apps/gridcraft-web/src/web.rs | 32 ++++++- 4 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 apps/gridcraft-web/src/host.rs diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 0f69e38..eac6c13 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", "HtmlIFrameElement", "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", "MessageEvent", "UrlSearchParams"] } diff --git a/apps/gridcraft-web/src/host.rs b/apps/gridcraft-web/src/host.rs new file mode 100644 index 0000000..2286023 --- /dev/null +++ b/apps/gridcraft-web/src/host.rs @@ -0,0 +1,166 @@ +//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed +//! GridCraft in an `