From f6546156408fd4809689b3305358b47281e22b7b Mon Sep 17 00:00:00 2001 From: justinjohn0306 <34035011+justinjohn0306@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:04:14 +0530 Subject: [PATCH 01/54] Fix: list and render installed system fonts (#4) The font dropdown showed a hardcoded 17-name list and cells always rendered in the built-in Calibri/role font, so a font installed on the machine could not be listed or chosen. Both trace to one constraint: egui panics when asked for a FontFamily::Name that was never bound to font data, so only the 8 built-in roles were ever bound. Scan the platform font folders for installed families (a bounded read of each file's name table via skrifa, already compiled transitively through epaint), bind the faces a workbook actually uses into egui as fnt:{family}:{b|n}{i|n} roles chained to the defaults, and offer the installed families in the font combos after the standard set. cell_family returns the installed role when it is bound, else the previous bucketing, so it never names an unbound family. The scan runs in the background and the first frame that rebinds skips painting, like the existing startup gate. Native only; wasm keeps the standard list. Incidental: newer local clippy flagged pre-existing lints in untouched crates (no toolchain is pinned); fixed behavior-preservingly so the gate passes. --- Cargo.lock | 2 + Cargo.toml | 4 + crates/core/src/date.rs | 2 +- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/spelling.rs | 20 +- crates/functions/src/math.rs | 2 +- crates/functions/src/stat.rs | 2 +- crates/functions/src/util.rs | 2 +- crates/numfmt/src/render.rs | 2 +- crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/fonts.rs | 423 ++++++++++++++++++++++++++++++ crates/ui-egui/src/formula_bar.rs | 2 +- crates/ui-egui/src/grid.rs | 9 +- crates/ui-egui/src/lib.rs | 52 +++- crates/ui-egui/src/ribbon.rs | 6 +- crates/ui-egui/src/theme.rs | 71 ++++- 17 files changed, 577 insertions(+), 30 deletions(-) create mode 100644 crates/ui-egui/src/fonts.rs diff --git a/Cargo.lock b/Cargo.lock index f3b1e6426..0855bf06e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1522,12 +1522,14 @@ version = "0.1.0" dependencies = [ "egui", "egui_kittest", + "epaint_default_fonts", "gridcraft-chart", "gridcraft-engine", "image", "log", "serde", "serde_json", + "skrifa", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index a7f99e0cb..e87627dda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,10 @@ eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" arboard = { version = "3.6", default-features = false } +# Font name-table reading. The same version epaint already depends on, so it adds no new build. +skrifa = "0.44" +# Real font bytes for tests (Ubuntu Light, compiled into epaint). +epaint_default_fonts = "0.36" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 7fba23fe5..d27ee15f3 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -97,7 +97,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() || serial < 0.0 || serial >= 2_958_466.0 { + if !(0.0..2_958_466.0).contains(&serial) { return None; } let mut day = serial.floor() as i64; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 37a58dd63..34b957cce 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -48,7 +48,7 @@ fn open(s: &mut Session, p: &Json) -> Result { }; let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?; let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string(); - let path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true); + let path = str_param(p, "path").map(str::to_string); // Replace an untouched blank Book1 like Excel does. if s.documents().len() == 1 && s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty())) diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 10892bc31..e14912061 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -21,17 +21,23 @@ pub fn specs() -> Vec { fn dictionary() -> &'static HashSet { static DICT: OnceLock> = OnceLock::new(); DICT.get_or_init(|| { - let mut set = HashSet::new(); + #[cfg(target_arch = "wasm32")] + { + HashSet::new() + } #[cfg(not(target_arch = "wasm32"))] - for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] { - if let Ok(text) = std::fs::read_to_string(path) { - for w in text.lines().take(2_000_000) { - set.insert(w.trim().to_lowercase()); + { + let mut set = HashSet::new(); + for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] { + if let Ok(text) = std::fs::read_to_string(path) { + for w in text.lines().take(2_000_000) { + set.insert(w.trim().to_lowercase()); + } + break; } - break; } + set } - set }) } diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 3a0bab385..654395f4b 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -487,7 +487,7 @@ fn integer_args(a: &[Arg]) -> R> { v.iter() .map(|x| { let t = x.trunc(); - if t < 0.0 || t >= 9.007_199_254_740_992e15 { Err(CellError::Num) } else { Ok(t as u64) } + if !(0.0..9.007_199_254_740_992e15).contains(&t) { Err(CellError::Num) } else { Ok(t as u64) } }) .collect() } diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index abdb4e731..51a5f3272 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -1185,7 +1185,7 @@ fn binom_dist_range(a: &[Arg], _: C) -> R { let p = num(a, 1)?; let s1 = num(a, 2)?.trunc(); let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 }; - if n < 0.0 || n > 1e15 || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { + if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { return Err(CellError::Num); } if s2 - s1 > 10_000.0 { diff --git a/crates/functions/src/util.rs b/crates/functions/src/util.rs index fca5dc42a..fbd664931 100644 --- a/crates/functions/src/util.rs +++ b/crates/functions/src/util.rs @@ -51,7 +51,7 @@ pub(crate) fn arg(args: &[Arg], i: usize) -> R<&Arg> { /// Whether argument `i` was supplied (present and not an empty literal like `F(1,)`). pub(crate) fn has(args: &[Arg], i: usize) -> bool { - matches!(args.get(i), Some(a) if !(matches!(a.value, Value::Empty) && !a.from_ref)) + matches!(args.get(i), Some(a) if !matches!(a.value, Value::Empty) || a.from_ref) } /// Number coercion for a scalar parameter: empty = 0, TRUE = 1, numeric text parses. diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 363dec1d3..523b77d53 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -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() || v < 0.0 || v >= 2_958_466.0 { + if !(0.0..2_958_466.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/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 01d7c0070..283d53fbe 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -16,9 +16,11 @@ serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } image = { workspace = true } +skrifa = { workspace = true } [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +epaint_default_fonts = { workspace = true } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b6c186d2d..e921cdcfa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1020,8 +1020,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } "Font" => { egui::ComboBox::from_label("Font").selected_text(st.font.name.clone()).show_ui(ui, |ui| { - for f in crate::ribbon::FONTS { - ui.selectable_value(&mut st.font.name, f.to_string(), *f); + for f in crate::fonts::families() { + ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); } }); ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text("Size")); diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs new file mode 100644 index 000000000..940bca9ee --- /dev/null +++ b/crates/ui-egui/src/fonts.rs @@ -0,0 +1,423 @@ +//! The fonts installed on the machine, so the font list can offer them and cells can render with +//! them (#4). +//! +//! egui has no font discovery of its own and panics when asked for a family that was never bound +//! to font data. So we do both halves: scan the platform's font folders for family names (a read +//! of each font file's `name` table — damaged or unreadable files yield nothing, never a panic), +//! and bind the files a workbook actually uses into the families +//! [`theme::font_definitions`](crate::theme::font_definitions) assembles. +//! +//! Native only: on wasm there are no system fonts, every function here reports "nothing", and the +//! font list is the standard [`crate::ribbon::FONTS`] set. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::OnceLock; + +/// A font file is skipped by the scan past this size: a catalog only needs each face's name, and a +/// damaged or enormous file must not turn startup into a long read. +#[cfg(not(target_arch = "wasm32"))] +const MAX_FONT_FILE: u64 = 64 << 20; + +/// One face of one installed font file. +#[derive(Clone, Debug)] +pub struct Face { + /// Typographic family name, e.g. `"Segoe UI"`. + pub family: String, + /// The file the face lives in. + pub path: PathBuf, + /// Face index within the file (collections hold several, e.g. a family's Regular and Bold). + pub index: u32, + /// Weight implied by the face's style name (400 = regular). + pub weight: f32, + /// Whether the style name reads as italic or oblique. + pub italic: bool, +} + +/// The font folders of the platform (the system's and the user's). Empty on wasm. +pub fn system_font_dirs() -> Vec { + #[cfg(target_arch = "wasm32")] + { + Vec::new() + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut dirs: Vec = Vec::new(); + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from); + if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join("Library/Fonts")); + } + } else if cfg!(windows) { + let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows")); + dirs.push(root.join("Fonts")); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } + } else { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join(".fonts")); + dirs.push(h.join(".local/share/fonts")); + } + } + dirs + } +} + +/// Weight implied by a style name (`"Bold"` → 700, `"Light"` → 300). +#[cfg(not(target_arch = "wasm32"))] +fn style_weight(style: &str) -> f32 { + let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect(); + const TABLE: &[(&str, f32)] = &[ + ("hairline", 100.0), + ("thin", 100.0), + ("extralight", 200.0), + ("ultralight", 200.0), + ("light", 300.0), + ("medium", 500.0), + ("demibold", 600.0), + ("semibold", 600.0), + ("extrabold", 800.0), + ("ultrabold", 800.0), + ("bold", 700.0), + ("black", 900.0), + ("heavy", 900.0), + ]; + TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0) +} + +/// Whether a style name reads as italic or oblique. +#[cfg(not(target_arch = "wasm32"))] +fn style_italic(style: &str) -> bool { + let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect(); + s.contains("italic") || s.contains("oblique") +} + +/// Every face in `data` as `(index, family, style)`. Damaged data yields nothing, never a panic. +#[cfg(not(target_arch = "wasm32"))] +fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> { + use skrifa::raw::FileRef; + use skrifa::{MetadataProvider, string::StringId}; + let count = match FileRef::new(data) { + Ok(FileRef::Font(_)) => 1, + Ok(FileRef::Collection(c)) => c.len(), + Err(_) => return Vec::new(), + }; + let name = |f: &skrifa::FontRef<'_>, ids: &[StringId]| -> Option { + ids.iter().find_map(|id| f.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) + }; + (0..count) + .filter_map(|i| { + let f = skrifa::FontRef::from_index(data, i).ok()?; + let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; + let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + Some((i, family, style)) + }) + .collect() +} + +/// Catalog every face under `dirs`, recursing into subfolders. A folder reached twice (through a +/// symlink) is read once, so a link back to a parent ends the walk. +#[cfg(not(target_arch = "wasm32"))] +pub fn scan_dirs(dirs: &[PathBuf]) -> Vec { + let mut out: Vec = Vec::new(); + let mut stack: Vec = dirs.to_vec(); + let mut seen_dirs: HashSet = HashSet::new(); + while let Some(dir) = stack.pop() { + let key = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone()); + if !seen_dirs.insert(key) { + continue; + } + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + let is_font = + path.extension().and_then(|e| e.to_str()).is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")); + if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) { + continue; + } + let Ok(data) = std::fs::read(&path) else { continue }; + for (index, family, style) in file_faces(&data) { + out.push(Face { family, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) }); + } + } + } + out +} + +#[cfg(target_arch = "wasm32")] +pub fn scan_dirs(_dirs: &[PathBuf]) -> Vec { + Vec::new() +} + +static CATALOG: OnceLock> = OnceLock::new(); + +/// The catalog of installed faces, scanned on first use. Blocks until the scan finishes. +pub fn catalog() -> &'static [Face] { + CATALOG.get_or_init(|| scan_dirs(&system_font_dirs())) +} + +/// The catalog if the scan has finished, else `None` — never blocks, for use while painting. +fn catalog_now() -> Option<&'static [Face]> { + CATALOG.get().map(Vec::as_slice) +} + +/// Start cataloging the installed fonts on a background thread, so the first frame doesn't wait for +/// it. A no-op on wasm and after the scan has started. +pub fn start_scan() { + static STARTED: OnceLock<()> = OnceLock::new(); + STARTED.get_or_init(|| { + #[cfg(not(target_arch = "wasm32"))] + if CATALOG.get().is_none() { + // A failed spawn leaves the scan to the first blocking lookup. + let _ = std::thread::Builder::new().name("font-scan".into()).spawn(|| { + catalog(); + }); + } + }); +} + +/// Whether the background scan has finished. +pub fn scan_done() -> bool { + CATALOG.get().is_some() +} + +/// The faces of one installed family, regular-weight first (the order roles pick from). +pub fn faces_of(family: &str) -> Vec { + let Some(cat) = catalog_now() else { return Vec::new() }; + let mut v: Vec = cat.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).cloned().collect(); + v.sort_by(|a, b| a.weight.total_cmp(&b.weight).then(a.italic.cmp(&b.italic)).then(a.index.cmp(&b.index))); + v +} + +/// The subset of `names` that is installed, sorted and deduped. These are the families whose files +/// get bound for a workbook. +pub fn installed_among(names: &[String]) -> Vec { + let Some(cat) = catalog_now() else { return Vec::new() }; + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for wanted in names { + if !seen.insert(wanted.to_lowercase()) { + continue; + } + if let Some(f) = cat.iter().find(|f| f.family.eq_ignore_ascii_case(wanted)) { + out.push(f.family.clone()); + } + } + out.sort_by_key(|a| a.to_lowercase()); + out +} + +/// The font list a combo box shows: the standard [`crate::ribbon::FONTS`] names first (what Excel +/// offers, and what we substitute), then every other installed family A–Z. Never blocks: until the +/// background scan finishes it returns just the standard names. +pub fn families() -> Vec { + let mut out: Vec = crate::ribbon::FONTS.iter().map(|s| s.to_string()).collect(); + let Some(cat) = catalog_now() else { return out }; + let mut seen: HashSet = out.iter().map(|s| s.to_lowercase()).collect(); + let mut installed: Vec = cat.iter().map(|f| f.family.clone()).collect(); + installed.sort_by_key(|a| a.to_lowercase()); + installed.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + for f in installed { + if seen.insert(f.to_lowercase()) { + out.push(f); + } + } + out +} + +/// The egui family name for one role of one family: `fnt:{family}:{b|n}{i|n}`. +pub fn role_key(family: &str, bold: bool, italic: bool) -> String { + format!("fnt:{family}:{}{}", if bold { 'b' } else { 'n' }, if italic { 'i' } else { 'n' }) +} + +/// The face to use for a role, or `None` when the family has no faces. Missing bold/italic faces +/// fall back to the family's regular face, so every bound role has data behind it. +fn pick_face(faces: &[Face], bold: bool, italic: bool) -> Option<&Face> { + let regular = || faces.iter().find(|f| !f.italic && (f.weight - 400.0).abs() < 1.0); + let exact = |wanted_bold: bool, wanted_italic: bool| { + faces.iter().find(|f| f.italic == wanted_italic && if wanted_bold { f.weight >= 600.0 } else { f.weight < 600.0 }) + }; + (match (bold, italic) { + (true, true) => exact(true, true).or_else(|| exact(true, false)).or_else(|| exact(false, true)), + (true, false) => exact(true, false), + (false, true) => exact(false, true), + (false, false) => None, + }) + .or_else(regular) + .or_else(|| faces.first()) +} + +/// A stable key for a face's font data, unique per file/index within the process. +fn face_key(family: &str, face: &Face) -> String { + let file = face.path.file_name().and_then(|s| s.to_str()).unwrap_or("font"); + format!("fnt-src:{family}:{file}:{}", face.index) +} + +/// Bind one installed family's faces into `fonts`, one egui family per cell role, and return the +/// role keys that are now bound. A role with no exact face chains to the family's regular face, so +/// no role is ever left pointing at missing data (egui panics on that). A family with no faces +/// binds nothing. +/// +/// Each role's chain ends with egui's default proportional fonts, so glyphs the installed font +/// lacks (emoji, a stray script) still render. +pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face]) -> Vec { + if faces.is_empty() { + return Vec::new(); + } + let base: Vec = fonts.families.get(&egui::FontFamily::Proportional).cloned().unwrap_or_default(); + let mut bound: Vec = Vec::new(); + for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] { + let Some(primary) = pick_face(faces, bold, italic) else { continue }; + let mut chain: Vec = Vec::new(); + for face in [Some(primary), pick_face(faces, false, false)] { + let Some(face) = face else { continue }; + let key = face_key(family, face); + if chain.contains(&key) { + continue; + } + let Ok(bytes) = std::fs::read(&face.path) else { continue }; + let mut fd = egui::FontData::from_owned(bytes); + fd.index = face.index; + fonts.font_data.entry(key.clone()).or_insert_with(|| std::sync::Arc::new(fd)); + chain.push(key); + } + if chain.is_empty() { + continue; + } + chain.extend(base.iter().cloned()); + let role = role_key(family, bold, italic); + fonts.families.insert(egui::FontFamily::Name(role.clone().into()), chain); + bound.push(role); + } + bound +} + +/// Bind every installed face of `family` (see [`bind_faces`]). +pub fn bind_family(fonts: &mut egui::FontDefinitions, family: &str) -> Vec { + bind_faces(fonts, family, &faces_of(family)) +} + +/// The role families currently bound to font data. `cell_family` consults this, so it never names a +/// family egui hasn't been given. +static BOUND: OnceLock>> = OnceLock::new(); + +fn bound() -> &'static std::sync::RwLock> { + BOUND.get_or_init(|| std::sync::RwLock::new(HashSet::new())) +} + +/// Record the role families a freshly built [`egui::FontDefinitions`] bound. +pub fn set_bound(keys: impl IntoIterator) { + *bound().write().unwrap_or_else(|e| e.into_inner()) = keys.into_iter().collect(); +} + +/// Is this exact role family bound to font data? +pub fn is_bound(role: &str) -> bool { + bound().read().unwrap_or_else(|e| e.into_inner()).contains(role) +} + +/// Serializes tests that mutate the process-wide bound-family registry. +#[cfg(test)] +pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn style_names_map_to_weight_and_slant() { + assert_eq!(style_weight("Bold"), 700.0); + assert_eq!(style_weight("SemiBold Italic"), 600.0); + assert_eq!(style_weight("Regular"), 400.0); + assert_eq!(style_weight("Light Oblique"), 300.0); + assert!(style_italic("Bold Italic") && style_italic("Oblique")); + assert!(!style_italic("Bold")); + // The weight table is checked in order, so "extralight" wins over "light". + assert_eq!(style_weight("ExtraLight"), 200.0); + } + + #[test] + fn role_keys_are_stable_and_distinct() { + assert_eq!(role_key("Segoe UI", false, false), "fnt:Segoe UI:nn"); + assert_eq!(role_key("Segoe UI", true, false), "fnt:Segoe UI:bn"); + assert_eq!(role_key("Segoe UI", false, true), "fnt:Segoe UI:ni"); + assert_eq!(role_key("Segoe UI", true, true), "fnt:Segoe UI:bi"); + } + + #[test] + fn nothing_is_bound_until_font_definitions_says_so() { + let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // A role key is only safe to name after font_definitions bound it. + let k = role_key("Some Installed Font", true, false); + assert!(!is_bound(&k)); + set_bound([k.clone()]); + assert!(is_bound(&k)); + set_bound([]); + assert!(!is_bound(&k)); + } + + #[test] + fn damaged_or_non_font_files_contribute_nothing() { + assert!(file_faces(b"ttcf\x00\x01\x00\x00\xff\xff\xff\xff").is_empty()); + assert!(file_faces(b"not a font at all").is_empty()); + assert!(file_faces(&[]).is_empty()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn the_scan_catalogs_fonts_and_reads_each_folder_once() { + let dir = std::env::temp_dir().join(format!("gridcraft-fonts-scan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Sub")).expect("temp dir"); + std::fs::write(dir.join("Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).expect("write"); + std::fs::write(dir.join("damaged.ttf"), b"ttcf\x00\x01\x00\x00\xff\xff\xff\xff").expect("write"); + std::fs::write(dir.join("readme.txt"), b"not a font").expect("write"); + // The same folder listed twice (once through `Sub/..`) is read once. + let faces = scan_dirs(&[dir.clone(), dir.join("Sub/..")]); + let families: Vec<&str> = faces.iter().map(|f| f.family.as_str()).collect(); + assert_eq!(families, ["Ubuntu"], "one face, family read from the name table"); + assert_eq!(faces[0].weight, 300.0, "“Light” reads as weight 300"); + assert!(!faces[0].italic); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn a_link_back_to_a_parent_folder_ends_the_scan() { + let dir = std::env::temp_dir().join(format!("gridcraft-fonts-loop-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Sub")).expect("temp dir"); + std::fs::write(dir.join("Sub/Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).expect("write"); + std::os::unix::fs::symlink(&dir, dir.join("Sub/loop")).expect("symlink"); + assert_eq!(scan_dirs(&[dir.clone()]).len(), 1, "the loop doesn't multiply faces"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn binding_a_family_binds_all_four_roles_and_never_stores_missing_data() { + let dir = std::env::temp_dir().join(format!("gridcraft-fonts-bind-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("Ubuntu-Light.ttf"); + std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write"); + let face = Face { family: "Ubuntu".into(), path, index: 0, weight: 300.0, italic: false }; + + let mut fonts = egui::FontDefinitions::default(); + let bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&face)); + assert_eq!(bound.len(), 4, "regular, bold, italic and bold-italic roles"); + for key in &bound { + let chain = fonts.families.get(&egui::FontFamily::Name(key.as_str().into())).expect("role family"); + assert!(chain.iter().all(|k| fonts.font_data.contains_key(k)), "every chained key has data"); + } + // A family with no faces binds nothing and never touches the map. + assert!(bind_faces(&mut fonts, "No Such Font", &[]).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 753585f10..48141c346 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -166,7 +166,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.caret_to_end = false; ed.sync_caret = true; } - if !ed.autocomplete.is_empty() && (tab || (enter && false)) { + if !ed.autocomplete.is_empty() && tab { ed.accept_autocomplete(); } else if enter { action = Some((if shift { -1 } else { 1 }, 0, false, false)); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc36..4faf43a55 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -577,7 +577,7 @@ fn paint_quadrant( }; let indent = st.align.indent as f32 * 9.0 * z; let pad = 2.5 * z; - if st.align.wrap || st.align.h == HAlign::Justify || text.contains('\n') && st.align.wrap { + if st.align.wrap || st.align.h == HAlign::Justify { let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); job.halign = match halign { @@ -1651,12 +1651,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { let Some(sh) = wb.sheet(si) else { return }; let _ = geo; // Tables get an autofilter on demand. - let table_range = sh - .tables - .iter() - .find(|t| t.range.start.row <= at.y as u32 || true) - .filter(|t| col >= t.range.start.col && col <= t.range.end.col) - .map(|t| t.range); + let table_range = sh.tables.first().filter(|t| col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range); if sh.autofilter.is_none() && let Some(r) = table_range { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca1a..fd08ba30d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -11,6 +11,7 @@ pub mod control; pub mod credits; pub mod dialogs; pub mod editor; +pub mod fonts; pub mod formula_bar; pub mod grid; pub mod icons; @@ -104,6 +105,14 @@ pub struct SheetApp { pub perf: Perf, pub fonts_ready: bool, fonts_set: bool, + /// Workbook fonts bound into the current font set; a change rebinds (installed fonts only). + fonts_bound: Vec, + /// The (doc uid, revision) `fonts_bound` was computed for, so the style table is walked only + /// when the workbook changes. + fonts_seen: (u64, u64), + /// Set while a font-set change is pending activation: skip this frame's paint, like the + /// startup `fonts_ready` gate (egui applies new definitions at the next pass). + fonts_rebind: bool, pub name_box: Option, pub(crate) shots: control::Shots, /// Chart selected on the sheet (id). @@ -133,6 +142,9 @@ impl SheetApp { perf: Perf::default(), fonts_ready: false, fonts_set: false, + fonts_bound: Vec::new(), + fonts_seen: (0, 0), + fonts_rebind: false, name_box: None, shots: control::Shots::default(), selected_chart: None, @@ -140,12 +152,28 @@ impl SheetApp { } } - /// One-time context setup: fonts and visuals. + /// One-time context setup: start cataloging the machine's fonts, then install the fonts and + /// visuals. Workbook-specific fonts are bound later, once the scan is done (see [`logic`]). + /// + /// [`logic`]: SheetApp::logic pub fn setup_context(ctx: &egui::Context, dark: bool) { - ctx.set_fonts(theme::font_definitions()); + fonts::start_scan(); + ctx.set_fonts(theme::font_definitions(&[])); theme::apply(ctx, dark); } + /// The font names the active workbook uses: every style's font plus the theme's heading/body + /// fonts. These are the families [`fonts`] binds an installed file for. + pub fn used_fonts(&self) -> Vec { + let mut names: Vec = Vec::new(); + if let Some(d) = self.session.active() { + names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); + names.push(d.wb.theme.major_font.clone()); + names.push(d.wb.theme.minor_font.clone()); + } + names + } + /// Runs an engine command (or a UI command) and handles UI requests it makes. pub fn run(&mut self, id: &str, params: Json) -> Result { if let Some(r) = self.run_ui_command(id, ¶ms) { @@ -361,6 +389,24 @@ impl SheetApp { self.fonts_set = true; ctx.request_repaint(); } + } else if self.fonts_rebind { + // A font-set change made last frame is active now. + self.fonts_rebind = false; + } else if fonts::scan_done() { + // Bind the workbook's installed fonts once the background scan has finished. Adding + // fonts re-applies the font set, which egui activates at the next pass, so skip this + // frame's paint (same one-frame cost as startup). Only when the workbook changed. + let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0)); + if seen != self.fonts_seen { + let used = self.used_fonts(); + self.fonts_seen = seen; + if used != self.fonts_bound { + ctx.set_fonts(theme::font_definitions(&used)); + self.fonts_bound = used; + self.fonts_rebind = true; + ctx.request_repaint(); + } + } } if ctx.global_style().visuals.dark_mode != self.ui.dark { theme::apply(ctx, self.ui.dark); @@ -394,7 +440,7 @@ impl SheetApp { /// Lays out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); - if !self.fonts_ready { + if !self.fonts_ready || self.fonts_rebind { ctx.request_repaint(); return; } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432b4..6abe3f31e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -688,9 +688,9 @@ fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { let mut name = st.font.name.clone(); let before = name.clone(); egui::ComboBox::from_id_salt("font_name").width(150.0).selected_text(egui::RichText::new(&name).font(theme::ui_font(12.5))).show_ui(ui, |ui| { - for f in FONTS { - let fam = theme::cell_family(f, false, false); - ui.selectable_value(&mut name, f.to_string(), egui::RichText::new(*f).font(egui::FontId::new(14.0, fam))); + for f in crate::fonts::families() { + let fam = theme::cell_family(&f, false, false); + ui.selectable_value(&mut name, f.clone(), egui::RichText::new(&f).font(egui::FontId::new(14.0, fam))); } }); if name != before { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2dbd8df7b..1b5155928 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -142,7 +142,16 @@ pub fn ui_bold(size: f32) -> FontId { } /// The font family to use for a cell font name and weight. +/// +/// When the name is an installed font whose faces were bound by [`font_definitions`], that family +/// is used; otherwise the name is bucketed to one of the built-in roles (mono/serif/cell). egui +/// panics on a family that was never bound, so the installed branch is gated on +/// [`crate::fonts::is_bound`] and never names a family egui hasn't been given. pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily { + let role = crate::fonts::role_key(name, bold, italic); + if crate::fonts::is_bound(&role) { + return FontFamily::Name(role.into()); + } let lower = name.to_ascii_lowercase(); if lower.contains("courier") || lower.contains("mono") || lower.contains("consolas") { return FontFamily::Name(MONO.into()); @@ -288,10 +297,16 @@ fn candidates(role: &str) -> Vec<(String, u32)> { } /// Builds the font set from system fonts (with egui's defaults as fallback). -pub fn font_definitions() -> FontDefinitions { +/// +/// `used` names the workbook's fonts (its styles' fonts and the theme's heading/body fonts). Any of +/// them that is installed is bound by name, so cells set in it render with it; the rest fall back +/// to the roles above. The set of bound role families is recorded in [`crate::fonts`] so +/// [`cell_family`] only ever names a family egui has data for. +pub fn font_definitions(used: &[String]) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); + let mut bound: Vec = Vec::new(); for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { let paths = candidates(role); let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); @@ -308,11 +323,17 @@ pub fn font_definitions() -> FontDefinitions { chain.extend(if role == MONO { base_mono.clone() } else { base_prop.clone() }); chain.retain(|k| fonts.font_data.contains_key(k)); fonts.families.insert(FontFamily::Name(role.into()), chain); + bound.push(role.to_string()); + } + // Installed workbook fonts, so a cell's chosen font actually renders in it. + for family in crate::fonts::installed_among(used) { + bound.extend(crate::fonts::bind_family(&mut fonts, &family)); } // Default proportional text in widgets uses the UI font. if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() { fonts.families.insert(FontFamily::Proportional, ui); } + crate::fonts::set_bound(bound); fonts } @@ -351,3 +372,51 @@ pub fn apply(ctx: &egui::Context, dark: bool) { pub fn color32(rgb: [u8; 3]) -> Color32 { Color32::from_rgb(rgb[0], rgb[1], rgb[2]) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The families [`font_definitions`] always binds, whatever is installed. + fn always_bound() -> Vec { + [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO].iter().map(|s| s.to_string()).collect() + } + + #[test] + fn a_name_that_is_not_an_installed_font_gets_a_built_in_role() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Nothing installed: every name buckets to a role that font_definitions bound. + crate::fonts::set_bound(always_bound()); + for name in ["", "Calibri", "Courier New", "Times New Roman", "Grossly Missing Font", "\u{1F600}"] { + for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { + let FontFamily::Name(fam) = cell_family(name, b, i) else { panic!("{name:?} should name a family") }; + assert!(crate::fonts::is_bound(&fam), "{name:?} {b} {i} → unbound {fam:?}"); + } + } + } + + #[test] + fn a_bound_installed_font_is_used_by_name() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let role = crate::fonts::role_key("Ubuntu", true, false); + crate::fonts::set_bound(always_bound().into_iter().chain([role.clone()])); + assert!(matches!(cell_family("Ubuntu", true, false), FontFamily::Name(f) if *f == *role)); + // The same family in a style that wasn't bound falls back to a built-in role that is. + let FontFamily::Name(fallback) = cell_family("Ubuntu", false, true) else { panic!("should name a family") }; + assert!(crate::fonts::is_bound(&fallback), "the fallback is a bound role"); + crate::fonts::set_bound(always_bound()); + } + + #[test] + fn arbitrary_names_never_yield_an_unbound_family() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // A workbook can hold any font name; none may name a family egui has no data for. + crate::fonts::set_bound(always_bound()); + for seed in 0..500u32 { + let name: String = + (0..(seed % 40)).map(|k| char::from_u32(0x20 + (seed.wrapping_mul(31).wrapping_add(k) % 0x5F)).unwrap_or('x')).collect(); + let FontFamily::Name(fam) = cell_family(&name, seed % 2 == 0, seed % 3 == 0) else { continue }; + assert!(crate::fonts::is_bound(&fam), "{name:?} → unbound {fam:?}"); + } + } +} From d9fd1b7305a1e5cda8a2d0a688950d86a642ab8b Mon Sep 17 00:00:00 2001 From: justinjohn0306 <34035011+justinjohn0306@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:53:54 +0530 Subject: [PATCH 02/54] CI: satisfy newer clippy lints (toolchain drift, behavior-preserving) Local clippy (1.96) flags pre-existing code that an older toolchain accepted; these lints fire on clean main and block `cargo xtask ci`. All rewrites are behavior-preserving: - core/date.rs, numfmt/render.rs, functions/math.rs, functions/stat.rs: `!x.is_finite() || x < a || x >= b` -> `!(a..b).contains(&x)` (Range::contains is false for NaN/inf, matching the existing is_finite guard). - functions/util.rs: `!(A && !B)` -> `!A || B`. - engine/cmd/file.rs: drop a dead `.filter(|| true)`. - ui-egui/grid.rs: `find(|| true)` was always the first table (use `.first()`); `wrap || Justify || (nl && wrap)` -> `wrap || Justify`. - ui-egui/formula_bar.rs: `tab || (enter && false)` -> `tab`. - engine/cmd/spelling.rs: split the wasm/non-wasm dictionary init so the non-wasm `mut` no longer warns under wasm check. --- crates/core/src/date.rs | 2 +- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/spelling.rs | 20 +++++++++++++------- crates/functions/src/math.rs | 2 +- crates/functions/src/stat.rs | 2 +- crates/functions/src/util.rs | 2 +- crates/numfmt/src/render.rs | 2 +- crates/ui-egui/src/formula_bar.rs | 2 +- crates/ui-egui/src/grid.rs | 9 ++------- 9 files changed, 22 insertions(+), 21 deletions(-) diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 7fba23fe5..167522ba8 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -97,7 +97,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() || serial < 0.0 || serial >= 2_958_466.0 { + if !serial.is_finite() || !(0.0..2_958_466.0).contains(&serial) { return None; } let mut day = serial.floor() as i64; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 37a58dd63..34b957cce 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -48,7 +48,7 @@ fn open(s: &mut Session, p: &Json) -> Result { }; let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?; let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string(); - let path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true); + let path = str_param(p, "path").map(str::to_string); // Replace an untouched blank Book1 like Excel does. if s.documents().len() == 1 && s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty())) diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 10892bc31..e14912061 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -21,17 +21,23 @@ pub fn specs() -> Vec { fn dictionary() -> &'static HashSet { static DICT: OnceLock> = OnceLock::new(); DICT.get_or_init(|| { - let mut set = HashSet::new(); + #[cfg(target_arch = "wasm32")] + { + HashSet::new() + } #[cfg(not(target_arch = "wasm32"))] - for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] { - if let Ok(text) = std::fs::read_to_string(path) { - for w in text.lines().take(2_000_000) { - set.insert(w.trim().to_lowercase()); + { + let mut set = HashSet::new(); + for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] { + if let Ok(text) = std::fs::read_to_string(path) { + for w in text.lines().take(2_000_000) { + set.insert(w.trim().to_lowercase()); + } + break; } - break; } + set } - set }) } diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 3a0bab385..654395f4b 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -487,7 +487,7 @@ fn integer_args(a: &[Arg]) -> R> { v.iter() .map(|x| { let t = x.trunc(); - if t < 0.0 || t >= 9.007_199_254_740_992e15 { Err(CellError::Num) } else { Ok(t as u64) } + if !(0.0..9.007_199_254_740_992e15).contains(&t) { Err(CellError::Num) } else { Ok(t as u64) } }) .collect() } diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index abdb4e731..51a5f3272 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -1185,7 +1185,7 @@ fn binom_dist_range(a: &[Arg], _: C) -> R { let p = num(a, 1)?; let s1 = num(a, 2)?.trunc(); let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 }; - if n < 0.0 || n > 1e15 || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { + if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { return Err(CellError::Num); } if s2 - s1 > 10_000.0 { diff --git a/crates/functions/src/util.rs b/crates/functions/src/util.rs index fca5dc42a..fbd664931 100644 --- a/crates/functions/src/util.rs +++ b/crates/functions/src/util.rs @@ -51,7 +51,7 @@ pub(crate) fn arg(args: &[Arg], i: usize) -> R<&Arg> { /// Whether argument `i` was supplied (present and not an empty literal like `F(1,)`). pub(crate) fn has(args: &[Arg], i: usize) -> bool { - matches!(args.get(i), Some(a) if !(matches!(a.value, Value::Empty) && !a.from_ref)) + matches!(args.get(i), Some(a) if !matches!(a.value, Value::Empty) || a.from_ref) } /// Number coercion for a scalar parameter: empty = 0, TRUE = 1, numeric text parses. diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 363dec1d3..79fe490f1 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -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() || v < 0.0 || v >= 2_958_466.0 { + if !v.is_finite() || !(0.0..2_958_466.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/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 753585f10..48141c346 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -166,7 +166,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.caret_to_end = false; ed.sync_caret = true; } - if !ed.autocomplete.is_empty() && (tab || (enter && false)) { + if !ed.autocomplete.is_empty() && tab { ed.accept_autocomplete(); } else if enter { action = Some((if shift { -1 } else { 1 }, 0, false, false)); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc36..4faf43a55 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -577,7 +577,7 @@ fn paint_quadrant( }; let indent = st.align.indent as f32 * 9.0 * z; let pad = 2.5 * z; - if st.align.wrap || st.align.h == HAlign::Justify || text.contains('\n') && st.align.wrap { + if st.align.wrap || st.align.h == HAlign::Justify { let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); job.halign = match halign { @@ -1651,12 +1651,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { let Some(sh) = wb.sheet(si) else { return }; let _ = geo; // Tables get an autofilter on demand. - let table_range = sh - .tables - .iter() - .find(|t| t.range.start.row <= at.y as u32 || true) - .filter(|t| col >= t.range.start.col && col <= t.range.end.col) - .map(|t| t.range); + let table_range = sh.tables.first().filter(|t| col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range); if sh.autofilter.is_none() && let Some(r) = table_range { From 726a31740e63cb65de3d0c4cbdf8147d297224d4 Mon Sep 17 00:00:00 2001 From: justinjohn0306 <34035011+justinjohn0306@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:54:10 +0530 Subject: [PATCH 03/54] Preserve VBA macros on round-trip; never destroy a macro-enabled workbook (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening an .xlsm and saving it silently stripped xl/vbaProject.bin, turning a macro-enabled workbook into a macro-free one — data loss for anyone whose work depends on macros. GridCraft still does not run VBA, but it now carries the project through unchanged. - model: `Workbook.vba_project: Option>` (base64 in JSON via a new `b64::opt` helper). `None` = no macros. - xlsx read: capture the `vbaProject` relationship's bytes instead of warning and dropping them; warn that macros are kept but not executed. - xlsx write: when present, emit `xl/vbaProject.bin` byte-identical, add the `.../relationships/vbaProject` relationship, switch the main part to the macro-enabled content type (`...sheet.macroEnabled.main+xml`) and add the `application/vnd.ms-office.vbaProject` override. - engine: `view.macros` lists the preserved project alongside GridCraft's own scripts (`{"kind":"vba","executed":false}`), so callers can see it is there. - ROADMAP: scope now "preserve, don't execute" (was "out of scope"). Tests: round-trip byte-identical blob + correct content types/relationship; capture from a foreign-produced .xlsm; a macro-free workbook stays clean. --- ROADMAP.md | 10 +++--- crates/engine/src/cmd/extra.rs | 14 ++++++--- crates/model/src/b64.rs | 20 ++++++++++++ crates/model/src/workbook.rs | 6 ++++ crates/xlsx/src/read.rs | 14 +++++++-- crates/xlsx/src/tests/roundtrip.rs | 50 ++++++++++++++++++++++++++++++ crates/xlsx/src/write.rs | 14 ++++++++- 7 files changed, 117 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1b0cd3468..99c12375a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,10 +44,12 @@ without losing data, and not hit a crash in a normal session. The gates: weeks running continuously with 3–4 parallel agents), so **≈ 215 hours in total from today**. Breakdown under "What's left" below. -Scope decisions that affect the count: VBA is out of scope (GridCraft has its own recorded and -editable scripts instead), and features that need Microsoft cloud services (Copilot, live Stocks/ -Geography data types, co-authoring through OneDrive, Smart Lookup, Translate) will be replaced -by open equivalents or left out, not cloned. +Scope decisions that affect the count: VBA macro projects in opened workbooks are **preserved** +(GridCraft carries `xl/vbaProject.bin` through unchanged so saving a macro-enabled workbook never +destroys its macros) but **not executed** — GridCraft's own recorded and editable scripts are the +supported automation path. A VBA interpreter is not planned. Features that need Microsoft cloud +services (Copilot, live Stocks/Geography data types, co-authoring through OneDrive, Smart Lookup, +Translate) will be replaced by open equivalents or left out, not cloned. ### What's left diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 5ebc8b8ce..b6908661c 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -786,10 +786,16 @@ fn new_script(s: &mut Session, p: &Json) -> Result { } fn all_scripts(s: &mut Session, _: &Json) -> Result { - let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME); - Ok(Json::Array( - all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(), - )) + let wb = &s.doc()?.wb; + let all = stash_get(wb, SCRIPTS_NAME); + let mut list: Vec = + all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(); + // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on + // save but never executed here, so it has no step count. + if let Some(bytes) = &wb.vba_project { + list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false})); + } + Ok(Json::Array(list)) } fn run_script(s: &mut Session, p: &Json) -> Result { diff --git a/crates/model/src/b64.rs b/crates/model/src/b64.rs index 6354952f7..30db4b43d 100644 --- a/crates/model/src/b64.rs +++ b/crates/model/src/b64.rs @@ -49,6 +49,26 @@ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> decode(&s).ok_or_else(|| serde::de::Error::custom("invalid base64")) } +/// Base64 for optional byte blobs (VBA projects): `null`/missing stays `None`. +pub mod opt { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(v: &Option>, s: S) -> Result { + match v { + Some(data) => s.serialize_str(&super::encode(data)), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + let s = Option::::deserialize(d)?; + match s { + None => Ok(None), + Some(s) => super::decode(&s).map(Some).ok_or_else(|| serde::de::Error::custom("invalid base64")), + } + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 8652b20b3..ea404bae2 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -69,6 +69,11 @@ pub struct Workbook { pub custom_lists: Vec>, /// Named cell styles (Cell Styles gallery): name → style. pub cell_styles: Vec<(String, crate::style::Style)>, + /// VBA project (`xl/vbaProject.bin`) carried through unchanged. GridCraft does not run + /// macros; the blob is preserved so that saving a macro-enabled workbook never destroys the + /// macros it arrived with. base64 in JSON. `None` = the workbook has no macros. + #[serde(default, with = "crate::b64::opt")] + pub vba_project: Option>, } impl Default for Workbook { @@ -92,6 +97,7 @@ impl Workbook { protected_structure: false, custom_lists: vec![], cell_styles: vec![], + vba_project: None, } } pub fn sheet(&self, i: usize) -> Option<&Sheet> { diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 915d8e4f9..017658c0d 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -273,8 +273,18 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { wb.props.company = x.child("Company").map(|e| e.text.trim().to_string()).unwrap_or_default(); } - if wb_rels.iter().any(|r| r.kind == "vbaProject") { - cx.warn("macros (VBA project) are not supported and were dropped"); + // A VBA project is carried through unchanged: GridCraft never runs macros, but preserving the + // binary keeps macro-enabled workbooks from being silently stripped on save. + if let Some(r) = find_rel(&wb_rels, "vbaProject").cloned() { + match cx.pkg.read(&r.target) { + Ok(Some(data)) => { + wb.vba_project = Some(data); + cx.warn("macros (VBA project) were kept but not executed"); + } + Ok(None) => cx.warn("macros (VBA project) reference a missing part and were dropped"), + Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)), + Err(e) => cx.warn(format!("macros (VBA project) could not be read and were dropped: {e}")), + } } if wb_rels.iter().any(|r| r.kind == "externalLink") { cx.warn("links to external workbooks are not supported"); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index bfc83b92e..b747cbb2a 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -563,3 +563,53 @@ fn writing_repairs_invalid_models() { assert_eq!(s.tables[0].name, "Table1"); assert_eq!(s.cell(at("B2")).unwrap().style, StyleId::DEFAULT); } + +#[test] +fn vba_project_roundtrip() { + // A VBA project blob is preserved byte-identically through write and read, and the package + // carries the macro-enabled content type plus the vbaProject relationship. + let vba = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec(); + let mut wb = Workbook::new(); + wb.vba_project = Some(vba.clone()); + let bytes = write_xlsx(&wb).unwrap(); + assert_eq!(write_xlsx(&wb).unwrap(), bytes, "output is deterministic"); + + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap(); + let names: Vec = (0..z.len()).map(|i| z.name_for_index(i).unwrap().to_string()).collect(); + assert!(names.iter().any(|x| x == "xl/vbaProject.bin"), "missing vbaProject.bin"); + let mut bin = vec![]; + z.by_name("xl/vbaProject.bin").unwrap().read_to_end(&mut bin).unwrap(); + assert_eq!(bin, vba, "vbaProject.bin written byte-identically"); + let mut ct = String::new(); + z.by_name("[Content_Types].xml").unwrap().read_to_string(&mut ct).unwrap(); + assert!(ct.contains("macroEnabled.main+xml"), "macro-enabled main content type: {ct}"); + assert!(ct.contains("/xl/vbaProject.bin"), "vbaProject content type override: {ct}"); + let mut rels = String::new(); + z.by_name("xl/_rels/workbook.xml.rels").unwrap().read_to_string(&mut rels).unwrap(); + assert!(rels.contains("relationships/vbaProject"), "vbaProject relationship: {rels}"); + + let (back, rep) = read_xlsx(&bytes).unwrap(); + assert_eq!(back.vba_project.as_deref(), Some(vba.as_slice()), "vbaProject survives the read"); + assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings); + + // A workbook without macros stays macro-free and free of VBA warnings. + let (plain, rp) = read_xlsx(&write_xlsx(&Workbook::new()).unwrap()).unwrap(); + assert_eq!(plain.vba_project, None); + assert!(rp.warnings.iter().all(|w| !w.contains("VBA")), "{:?}", rp.warnings); +} + +#[test] +fn vba_project_read_from_foreign_file() { + // An .xlsm built by another producer is captured on read (not dropped with a plain warning). + let vba: &[u8] = b"PK\x03\x04-not-really-but-opaque-bytes"; + let bytes = super::minimal( + "", + &[("xl/vbaProject.bin", "ignored-as-text")], + r#""#, + "", + ); + let (wb, rep) = read_xlsx(&bytes).unwrap(); + assert!(wb.vba_project.is_some(), "VBA blob captured"); + assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings); + let _ = vba; +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 073dd36d0..c20c55427 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -20,6 +20,11 @@ pub const CT_DRAWING: &str = "application/vnd.openxmlformats-officedocument.draw pub const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; pub const CT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"; pub const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"; +/// Main workbook part: macro-free and macro-enabled variants (Excel refuses a `.xlsm` whose main +/// part uses the macro-free content type, and vice versa). +pub const CT_WORKBOOK_MAIN: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"; +pub const CT_WORKBOOK_MAIN_MACRO: &str = "application/vnd.ms-excel.sheet.macroEnabled.main+xml"; +pub const CT_VBA: &str = "application/vnd.ms-office.vbaProject"; /// A part's relationships. #[derive(Default)] @@ -33,6 +38,9 @@ impl Rels { let id = format!("rId{}", self.items.len() + 1); let ty = match kind { "core-properties" => "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties".to_string(), + // The VBA project relationship lives in the Microsoft-documented namespace, not the + // OpenXML one; Excel rejects the file if this type is wrong. + "vbaProject" => "http://schemas.microsoft.com/office/2006/relationships/vbaProject".to_string(), k => format!("{NS_REL}/{k}"), }; self.items.push((id.clone(), ty, target.to_string(), false)); @@ -273,7 +281,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { ); wb_rels.add("sheetMetadata", "metadata.xml"); } - out.part("xl/workbook.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), w.into_bytes()); + out.part("xl/workbook.xml", Some(if wb.vba_project.is_some() { CT_WORKBOOK_MAIN_MACRO } else { CT_WORKBOOK_MAIN }), w.into_bytes()); + if let Some(data) = &wb.vba_project { + wb_rels.add("vbaProject", "vbaProject.bin"); + out.part("xl/vbaProject.bin", Some(CT_VBA), data.clone()); + } out.part("xl/_rels/workbook.xml.rels", None, wb_rels.xml().into_bytes()); // Document properties. From 9c1d2c3b3a94a5c0a903fda3f00d8e76cae83ae8 Mon Sep 17 00:00:00 2001 From: justinjohn0306 <34035011+justinjohn0306@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:58:29 +0530 Subject: [PATCH 04/54] Add scripts/check-vba-roundtrip.sh: verify VBA preservation on a real file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds a macro-enabled .xlsm with a fake xl/vbaProject.bin, runs it through `gridcraft-cli run`, and asserts the blob comes back byte-identical with the macro-enabled content type and vbaProject relationship intact — the open → edit → save path a user would exercise by hand. --- scripts/check-vba-roundtrip.sh | 86 ++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 scripts/check-vba-roundtrip.sh diff --git a/scripts/check-vba-roundtrip.sh b/scripts/check-vba-roundtrip.sh new file mode 100644 index 000000000..5aef3a5f8 --- /dev/null +++ b/scripts/check-vba-roundtrip.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Verify that GridCraft preserves VBA macros (xl/vbaProject.bin) across a round-trip. +# +# bash scripts/check-vba-roundtrip.sh +# +# What it does: builds a macro-enabled .xlsm containing a fake vbaProject.bin, runs it +# through `gridcraft-cli run`, and checks the blob comes back byte-identical with the +# macro-enabled content type + relationship intact. Requires python3 (zip) and a +# release build of gridcraft-cli. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CLI="$ROOT/target/release/gridcraft-cli.exe" +[ -x "$CLI" ] || CLI="$ROOT/target/release/gridcraft-cli" +PYTHON="${PYTHON:-python3}" + +# A fresh temp dir. Under Git Bash (MSYS) the shell's /tmp is not a path the native +# Windows python.exe or the engine understand when embedded in JSON, so convert to the +# platform-native form when cygpath is available. +TMP="$(mktemp -d)" +if command -v cygpath >/dev/null 2>&1; then + NATIVE_TMP="$(cygpath -w "$TMP")" # for python (native Windows path) + JSON_TMP="$(cygpath -m "$TMP")" # for JSON params (forward slashes: no escaping) +else + NATIVE_TMP="$TMP" + JSON_TMP="$TMP" +fi + +# 1. Build a macro-enabled .xlsm with a fake OLE/CFB vbaProject.bin. +"$PYTHON" - "$NATIVE_TMP" <<'PY' +import zipfile, os, sys +tmp = sys.argv[1] +main_ct = "application/vnd.ms-excel.sheet.macroEnabled.main+xml" +ct = ('' + '' + '' + f'' + '' + '') +root = ('' + '') +wb = ('' + '') +rel = ('' + '' + '') +sheet = '' +vba = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"HELLO-VBA-PROJECT-BYTES" + bytes(range(32)) +with zipfile.ZipFile(os.path.join(tmp, "macro_demo.xlsm"), "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("[Content_Types].xml", ct) + z.writestr("_rels/.rels", root) + z.writestr("xl/workbook.xml", wb) + z.writestr("xl/_rels/workbook.xml.rels", rel) + z.writestr("xl/worksheets/sheet1.xml", sheet) + z.writestr("xl/vbaProject.bin", vba) +open(os.path.join(tmp, "vba_expected.bin"), "wb").write(vba) +print("built", os.path.join(tmp, "macro_demo.xlsm")) +PY + +# 2. Round-trip through the engine. +echo "--- view.macros on the input (what the app sees) ---" +"$CLI" run --in "$NATIVE_TMP/macro_demo.xlsm" --cmd 'view.macros={}' | tail -1 +"$CLI" run --in "$NATIVE_TMP/macro_demo.xlsm" --cmd "file.save={\"path\":\"$JSON_TMP/macro_out.xlsm\"}" | tail -1 + +# 3. Assert the blob survived byte-identical with correct package wiring. +"$PYTHON" - "$NATIVE_TMP" <<'PY' +import zipfile, os, sys +tmp = sys.argv[1] +exp = open(os.path.join(tmp, "vba_expected.bin"), "rb").read() +with zipfile.ZipFile(os.path.join(tmp, "macro_out.xlsm")) as z: + got = z.read("xl/vbaProject.bin") + ct = z.read("[Content_Types].xml").decode() + rels = z.read("xl/_rels/workbook.xml.rels").decode() +ok = (got == exp + and "macroEnabled.main+xml" in ct + and "/xl/vbaProject.bin" in ct + and "relationships/vbaProject" in rels) +print("blob byte-identical:", got == exp) +print("macro-enabled main content type:", "macroEnabled.main+xml" in ct) +print("vbaProject content-type override:", "/xl/vbaProject.bin" in ct) +print("vbaProject relationship:", "relationships/vbaProject" in rels) +sys.exit(0 if ok else 1) +PY + +echo "PASS: the VBA project survived the round-trip." \ No newline at end of file From 596b465bb1b149e2bf7603565e6e40b122a230ea Mon Sep 17 00:00:00 2001 From: justinjohn0306 <34035011+justinjohn0306@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:20:04 +0530 Subject: [PATCH 05/54] Macros UX: Excel-style info bar, Macros dialog, status badge (issue #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine now preserves a workbook's VBA project, but the UI had no surface for it and silently dropped every open-time warning. Give preserved macros an honest, Excel-shaped home without a false "Enable Content": - Info bar under the ribbon (Excel's message-bar slot) showing the warning a `file.open` result carries. A VBA project gets an amber bar reading "This workbook contains VBA macros. GridCraft keeps them intact when you save, but does not run them." with a "What this means" button; anything else becomes a blue informational bar. Dismissable; a clean open clears a stale notice. - Macros dialog (Automate ▸ Macros, Alt+F8): lists GridCraft's own scripts and the preserved VBA project (presence + size, not module names — the blob is opaque). Mirrors Excel's Developer ▸ Macros without claiming to run VBA. - Status-bar badge "Macros preserved (not executed)" whenever the workbook carries a VBA project. New panel `notice_bar` reuses theme tokens (notice_warn_*/notice_info_*), so it follows light/dark. Two new UI commands, `ui.notice` and `ui.notice.dismiss`, reach it from the control channel. Tests: info bar appears for a macro workbook and clears on dismiss; a non-VBA warning becomes an info bar; clean open clears the notice; the macros dialog lists the VBA entry; status bar renders with a VBA project; Alt+F8 opens the dialog. Snapshot-verified in light and dark. --- crates/ui-egui/src/dialogs.rs | 36 +++++++++++- crates/ui-egui/src/grid.rs | 3 + crates/ui-egui/src/lib.rs | 72 +++++++++++++++++++++++- crates/ui-egui/src/ribbon.rs | 3 + crates/ui-egui/src/tabs.rs | 4 ++ crates/ui-egui/src/theme.rs | 13 +++++ crates/ui-egui/src/widgets.rs | 48 ++++++++++++++++ crates/ui-egui/tests/interaction.rs | 85 +++++++++++++++++++++++++++++ 8 files changed, 260 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b6c186d2d..9fa6d15c4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -512,6 +512,11 @@ impl Dialog { d } "journal" => Dialog::custom("journal", "Action Journal", json!({})), + "macros" => { + let mut d = Dialog::custom("macros", "Macros", json!({})); + d.result = app.session.run("view.macros", json!({})).ok(); + d + } "statistics" => { let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({})); d.result = app.session.run("review.workbookStatistics", json!({})).ok(); @@ -638,7 +643,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let width = match d.name.as_str() { "formatCells" => 560.0, "about" => 660.0, - "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0, + "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0, _ => 380.0, }; egui::Window::new(d.title.clone()) @@ -676,6 +681,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } }); } + "macros" => macros(ui, &mut d), "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { let r = if d.name == "comments" { app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) @@ -925,6 +931,34 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) { } } +/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the +/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is +/// preserved and not executed. +fn macros(ui: &mut egui::Ui, d: &mut Dialog) { + ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); + ui.add_space(6.0); + let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); + if items.is_empty() { + ui.weak("This workbook has no scripts or macros."); + return; + } + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for it in &items { + let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); + let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba"); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).strong()); + if is_vba { + ui.weak("— VBA project (preserved, not run)"); + } else { + let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); + ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" })); + } + }); + } + }); +} + fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { ui.horizontal(|ui| { for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4faf43a55..5683f64d6 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1603,6 +1603,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})), Key::Space if cmd && !shift => app.run_or_alert("selection.column", json!({})), Key::Space if shift && !cmd => app.run_or_alert("selection.row", json!({})), + Key::F8 if m.alt => { + app.open_dialog("macros", json!({})); + } Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), Key::F4 if !cmd => {} _ if cmd => crate::ribbon::shortcut(app, key, m), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca1a..f907aa24e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,23 @@ pub struct SheetView { pub scroll: egui::Vec2, } +/// Severity of a notice shown in the info bar under the ribbon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoticeKind { + Info, + Warning, +} + +/// A dismissible bar under the ribbon, in the spirit of Excel's message bar. Used to tell the +/// user what an opened file carried that GridCraft handled differently (e.g. preserved macros). +#[derive(Clone, Debug, PartialEq)] +pub struct Notice { + pub text: String, + pub kind: NoticeKind, + /// Optional button: `(label, dialog name)` — opens that dialog when clicked. + pub action: Option<(String, String)>, +} + /// Host services the platform shell provides (file pickers, opening URLs). #[derive(Default)] pub struct Services { @@ -96,6 +113,8 @@ pub struct SheetApp { pub dialog: Option, pub message: Option<(String, String)>, pub toast: Option<(String, f64)>, + /// Dismissible info bar under the ribbon (open-time notices). + pub notice: Option, pub services: Services, /// Synthetic input from agents, one batch per frame (drags need several frames). pub synthetic: std::collections::VecDeque>, @@ -121,6 +140,7 @@ impl SheetApp { dialog: None, message: None, toast: None, + notice: None, services, synthetic: Default::default(), control_rx: None, @@ -196,6 +216,18 @@ impl SheetApp { self.message = None; Ok(Json::Null) } + "ui.notice" => Ok(match &self.notice { + Some(n) => json!({ + "text": n.text, + "kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" }, + "action": n.action, + }), + None => Json::Null, + }), + "ui.notice.dismiss" => { + self.notice = None; + Ok(Json::Null) + } _ => return None, }; Some(r) @@ -249,16 +281,48 @@ impl SheetApp { pub fn open_path(&mut self, path: &str) { match self.session.run("file.open", json!({"path": path})) { - Ok(_) => { + Ok(r) => { self.ui.recent.retain(|p| p != path); self.ui.recent.insert(0, path.to_string()); self.ui.recent.truncate(20); + self.show_open_warnings(&r); } Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); } + /// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the + /// most important one (a preserved VBA project outranks, say, a skipped optional part). + /// Kept deliberately generic: any open-time warning can reach the user this way. + pub fn show_open_warnings(&mut self, r: &Json) { + let Some(list) = r.get("warnings").and_then(Json::as_array) else { + self.notice = None; + return; + }; + let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect(); + if msgs.is_empty() { + // A clean open clears any notice left over from the previous document. + self.notice = None; + return; + } + let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect(); + if !vba.is_empty() { + self.notice = Some(Notice { + text: "This workbook contains VBA macros. GridCraft keeps them intact when you save, but does not run them.".into(), + kind: NoticeKind::Warning, + action: Some(("What this means".into(), "macros".into())), + }); + } else { + let text = if msgs.len() == 1 { + msgs[0].to_string() + } else { + format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0]) + }; + self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None }); + } + } + pub fn view_key(&self) -> Option<(u64, usize)> { self.session.active().map(|d| (d.uid, d.wb.active_sheet)) } @@ -375,8 +439,9 @@ impl SheetApp { .unwrap_or_default(); for (name, bytes) in arrived { let b64 = gridcraft_engine::io::base64_encode(&bytes); - if let Err(e) = self.session.run("file.open", json!({"name": name, "base64": b64})) { - self.message = Some(("GridCraft".into(), clean_error(&e))); + match self.session.run("file.open", json!({"name": name, "base64": b64})) { + Ok(r) => self.show_open_warnings(&r), + Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); } @@ -402,6 +467,7 @@ impl SheetApp { let t = theme::Tokens::get(&ctx); ribbon::title_bar(self, ui); ribbon::show(self, ui); + widgets::notice_bar(self, ui); if self.ui.formula_bar { formula_bar::show(self, ui); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432b4..d4b0a0b6f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1574,6 +1574,9 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { } fn automate(app: &mut SheetApp, ui: &mut Ui) { + if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { + app.open_dialog("macros", json!({})); + } if big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false).clicked() { app.open_dialog("commandSearch", json!({})); } diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 0258169de..df1e3f17f 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -192,6 +192,10 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { }; ui.label(egui::RichText::new(mode).font(theme::ui_font(12.5)).color(t.text_dim)); if let Some(d) = app.session.active() { + if d.wb.vba_project.is_some() { + ui.add_space(12.0); + ui.label(egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger)); + } if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual { ui.add_space(12.0); ui.label(egui::RichText::new("Calculate").font(theme::ui_font(12.5)).color(t.text_dim)); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2dbd8df7b..36c262183 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -43,6 +43,11 @@ pub struct Tokens { pub menu_bg: Color32, pub shadow: Color32, pub danger: Color32, + /// Info-bar (notice strip) fills, by severity. + pub notice_warn_bg: Color32, + pub notice_warn_border: Color32, + pub notice_info_bg: Color32, + pub notice_info_border: Color32, } impl Tokens { @@ -80,6 +85,10 @@ impl Tokens { menu_bg: Color32::WHITE, shadow: Color32::from_black_alpha(40), danger: Color32::from_rgb(0xC4, 0x2B, 0x1C), + notice_warn_bg: Color32::from_rgb(0xFF, 0xF4, 0xCE), + notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E), + notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB), + notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC), } } @@ -117,6 +126,10 @@ impl Tokens { menu_bg: Color32::from_rgb(0x30, 0x30, 0x30), shadow: Color32::from_black_alpha(90), danger: Color32::from_rgb(0xF1, 0x70, 0x5F), + notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E), + notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30), + notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44), + notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72), } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 139256ffc..503288cea 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -198,3 +198,51 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) { ); ctx.request_repaint_after(std::time::Duration::from_millis(200)); } + +/// The info bar under the ribbon (Excel's message-bar slot). Shows an open-time notice with an +/// optional action button and a dismiss ✕. Returns nothing; dismissing clears `app.notice`. +pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) { + let Some(notice) = app.notice.clone() else { return }; + let t = Tokens::get(ui.ctx()); + let (bg, border) = match notice.kind { + crate::NoticeKind::Warning => (t.notice_warn_bg, t.notice_warn_border), + crate::NoticeKind::Info => (t.notice_info_bg, t.notice_info_border), + }; + let mut dismiss = false; + let mut action: Option<(String, String)> = None; + egui::Panel::top("notice_bar").frame(egui::Frame::NONE.fill(bg).inner_margin(egui::Margin::symmetric(12, 0))).show(ui, |ui| { + // Bottom border as a 1px strip spanning the bar. + let bar = ui.max_rect(); + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, border), StrokeKind::Inside); + ui.horizontal_centered(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + icons::paint( + ui.painter(), + r, + match notice.kind { + crate::NoticeKind::Warning => Icon::Lock, + crate::NoticeKind::Info => Icon::Note, + }, + border, + ); + ui.add_space(6.0); + ui.label(egui::RichText::new(¬ice.text).font(theme::ui_font(12.5)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icon_button(ui, Icon::Close, t.text_dim, "Dismiss", vec2(20.0, 20.0)).clicked() { + dismiss = true; + } + if let Some((label, dialog)) = ¬ice.action + && ui.button(egui::RichText::new(label).font(theme::ui_font(12.5))).clicked() + { + action = Some((label.clone(), dialog.clone())); + } + }); + }); + }); + if let Some((_, dialog)) = action { + app.open_dialog(&dialog, serde_json::json!({})); + } + if dismiss { + app.notice = None; + } +} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 74bbeb495..302a2f92f 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -113,6 +113,7 @@ fn dialogs_open_and_close() { "spelling", "goTo", "about", + "macros", ] { h.state_mut().open_dialog(name, json!({})); h.run_steps(2); @@ -146,3 +147,87 @@ fn column_autocomplete_completes_on_enter() { key(&mut h, Key::Enter, Modifiers::NONE); assert_eq!(value(&h, "A5"), Value::from("Ea")); } + +/// An .xlsm carrying a VBA project, saved to bytes. +fn macro_workbook_bytes() -> Vec { + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec()); + gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap() +} + +#[test] +fn opening_a_macro_workbook_shows_a_notice() { + // The reader's "macros kept but not executed" warning must reach a dismissible info bar, + // not be silently dropped (issue #6 follow-up: Excel-style message bar). + let mut h = harness(blank()); + let b64 = gridcraft_engine::io::base64_encode(¯o_workbook_bytes()); + let res = h.state_mut().session.run("file.open", json!({"name": "macros.xlsm", "base64": b64})).unwrap(); + assert!( + res.get("warnings").and_then(|w| w.as_array()).is_some_and(|a| a.iter().any(|m| m.as_str().is_some_and(|s| s.contains("macros")))), + "engine reports the macro warning: {res}" + ); + // The UI-side handler turns that open result into the notice (open_path does this in the app). + h.state_mut().show_open_warnings(&res); + let n = h.state().notice.clone().expect("a notice was shown"); + assert!(n.text.contains("VBA"), "{}", n.text); + assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Warning); + assert_eq!(n.action.as_ref().map(|a| a.1.as_str()), Some("macros"), "action opens the Macros dialog"); + + // The notice bar renders without panic; `ui.notice` surfaces it; dismissing clears it. + h.run_steps(2); + let got = h.state_mut().run("ui.notice", json!({})).unwrap(); + assert_eq!(got.get("kind").and_then(|k| k.as_str()), Some("warning")); + h.state_mut().run("ui.notice.dismiss", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().notice.is_none()); +} + +#[test] +fn plain_open_warning_becomes_an_info_notice() { + let mut h = harness(blank()); + h.state_mut().show_open_warnings(&json!({"warnings": ["links to external workbooks are not supported"]})); + let n = h.state().notice.clone().unwrap(); + assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Info); + assert!(n.action.is_none()); + assert!(n.text.contains("external")); + // No warnings → no notice, no panic. + h.state_mut().show_open_warnings(&json!({"warnings": []})); + assert!(h.state().notice.is_none()); +} + +#[test] +fn macros_dialog_lists_vba_and_scripts() { + let mut s = blank(); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]); + s.active_mut().unwrap().wb = std::sync::Arc::new(wb); + let mut h = harness(s); + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(3); + let d = h.state().dialog.as_ref().expect("macros dialog open"); + assert_eq!(d.name(), "macros"); + let items = d.result.as_ref().and_then(|r| r.as_array()).cloned().unwrap_or_default(); + assert!(items.iter().any(|i| i.get("kind").and_then(|k| k.as_str()) == Some("vba")), "{items:?}"); + h.state_mut().dialog = None; +} + +#[test] +fn status_bar_shows_macro_badge() { + let mut s = blank(); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]); + s.active_mut().unwrap().wb = std::sync::Arc::new(wb); + // Rendering the status bar must not panic with a VBA project present. + let mut h = harness(s); + h.run_steps(3); + h.state_mut().ui.dark = true; + h.run_steps(2); +} + +#[test] +fn alt_f8_opens_the_macros_dialog() { + let mut h = harness(blank()); + key(&mut h, Key::F8, Modifiers::ALT); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("macros")); + h.state_mut().dialog = None; +} From fd2243df95ce2d967e90deefbf10f6dd32b0a82a Mon Sep 17 00:00:00 2001 From: Zaid Shurbaji Date: Sat, 10 Oct 2026 21:27:13 +0300 Subject: [PATCH 06/54] Add table slicers (insert.slicer / table.insertSlicer) Implements the two slicer commands from the parity catalog as first-class floating objects that filter a table column. - model: Slicer struct on Sheet (#[serde(default)]), included in Workbook::next_object_id; slicer anchors use AnchorMode::MoveOnly. - engine: cmd/slicer.rs with insert.slicer, table.insertSlicer, slicer.toggle (single + multi), slicer.selectAll, slicer.clear, slicer.delete. Selection is written as a FilterCriterion::Values into the sheet AutoFilter and applied via the existing apply_filter. object.move / object.delete handle "slicer". - ui-egui: slicers rendered, hit-tested, selected and dragged like charts; value tiles toggle on click (Ctrl/Cmd = multi), funnel button clears. Ribbon buttons under Insert > Filters and Table Design > Tools. - tests: slicer_filters_table; the every_command_survives_empty_params fuzz covers the new commands. Native-only for now; .xlsx slicer-cache round-trip is a follow-up. Co-Authored-By: Claude Opus 4.8 --- crates/engine/src/cmd/insert.rs | 2 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/slicer.rs | 229 ++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 53 ++++++++ crates/model/src/features.rs | 20 +++ crates/model/src/sheet.rs | 4 + crates/model/src/workbook.rs | 3 + crates/ui-egui/src/chartview.rs | 112 ++++++++++++++++ crates/ui-egui/src/grid.rs | 21 ++- crates/ui-egui/src/ribbon.rs | 8 ++ 10 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/slicer.rs diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 8e08ea66d..6f9d3699b 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -869,6 +869,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result { match kind.as_str() { "chart" => sh.charts.retain(|c| c.id != id), "image" => sh.images.retain(|c| c.id != id), + "slicer" => sh.slicers.retain(|c| c.id != id), _ => sh.shapes.retain(|c| c.id != id), } Ok(Json::Null) @@ -884,6 +885,7 @@ fn move_object(s: &mut Session, p: &Json) -> Result { let anchor = match kind.as_str() { "chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), "image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + "slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), "shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), _ => None, }; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d238ef752..7172bcb55 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ pub mod pivot; pub mod print; pub mod review; pub mod sheet; +pub mod slicer; pub mod spelling; pub mod view; pub mod whatif; @@ -119,6 +120,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(draw::specs()); v.extend(spelling::specs()); v.extend(pivot::specs()); + v.extend(slicer::specs()); v.extend(whatif::specs()); v.extend(print::specs()); v diff --git a/crates/engine/src/cmd/slicer.rs b/crates/engine/src/cmd/slicer.rs new file mode 100644 index 000000000..907ac7e3f --- /dev/null +++ b/crates/engine/src/cmd/slicer.rs @@ -0,0 +1,229 @@ +//! Slicers: on-sheet widgets that filter one column of a table by clicking value tiles. +//! +//! A slicer is a thin front-end over the existing filter machinery: its selection is written as a +//! [`FilterCriterion::Values`] into the sheet's [`AutoFilter`] (over the table's range) and +//! [`super::data::apply_filter`] hides the rows. Creating a slicer (`insert.slicer` / +//! `table.insertSlicer`) selects everything, i.e. applies no filter. + +use gridcraft_core::CellRef; +use gridcraft_model::*; +use serde_json::{Value as Json, json}; + +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "insert.slicer", + "Slicer", + ["Insert", "Filters"], + None, + "{table?, column?: \"Region\", columns?: 1, at?: \"H2\", width?, height?}", + has_doc, + insert_slicer + ), + cmd!( + "table.insertSlicer", + "Insert Slicer", + ["Table", "Tools"], + None, + "{table?, column?: \"Region\", columns?: 1, at?, width?, height?}", + has_doc, + insert_slicer + ), + cmd!("slicer.toggle", "Toggle Slicer Value", [], None, "{slicer?: id, value: \"East\", multi?: false}", has_doc, slicer_toggle), + cmd!("slicer.selectAll", "Clear Slicer Filter", [], None, "{slicer?: id}", has_doc, slicer_select_all), + cmd!("slicer.clear", "Clear Slicer Filter", [], None, "{slicer?: id}", has_doc, slicer_select_all), + cmd!("slicer.delete", "Delete Slicer", [], None, "{slicer?: id}", has_doc, slicer_delete), + ] +} + +/// Distinct, non-blank display values of a table column, in first-seen order. Used to populate a +/// slicer's tiles and to detect "everything selected". +pub fn column_values(wb: &Workbook, sheet: usize, table: &str, column: &str) -> Vec { + let Some(sh) = wb.sheet(sheet) else { return vec![] }; + let Some(t) = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(table)) else { return vec![] }; + let Some(ci) = t.column_index(column) else { return vec![] }; + let Some(dr) = t.data_range() else { return vec![] }; + let col = dr.start.col + ci as u32; + let mut seen = std::collections::BTreeSet::new(); + let mut out = Vec::new(); + for row in dr.start.row..=dr.end.row { + let text = crate::display::cell_text(wb, sh, CellRef::new(row, col)); + if text.is_empty() { + continue; + } + if seen.insert(text.to_lowercase()) { + out.push(text); + } + } + out +} + +/// A slicer's tiles as `(value, selected)`, for the UI. `selected` is true for every tile when the +/// slicer has no filter (`selected == None`). +pub fn slicer_items(wb: &Workbook, sheet: usize, sl: &Slicer) -> Vec<(String, bool)> { + column_values(wb, sheet, &sl.table, &sl.column) + .into_iter() + .map(|v| { + let on = match &sl.selected { + None => true, + Some(set) => set.iter().any(|x| x.eq_ignore_ascii_case(&v)), + }; + (v, on) + }) + .collect() +} + +/// Writes the slicer's current selection into the sheet filter and re-applies it. +fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) { + let info = wb.sheet(sheet).and_then(|sh| { + let sl = sh.slicers.iter().find(|s| s.id == id)?; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; + let ci = t.column_index(&sl.column)?; + Some((t.range, t.range.start.col + ci as u32, sl.selected.clone())) + }); + let Some((table_range, abs_col, selected)) = info else { return }; + { + let Some(sh) = wb.sheet_mut(sheet) else { return }; + let af = sh.autofilter.get_or_insert_with(|| AutoFilter { range: table_range, criteria: vec![] }); + let off = abs_col.saturating_sub(af.range.start.col); + af.criteria.retain(|(o, _)| *o != off); + if let Some(values) = selected { + af.criteria.push((off, FilterCriterion::Values { values, blanks: false })); + } + } + super::data::apply_filter(wb, sheet); +} + +fn insert_slicer(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let t = match str_param(p, "table") { + Some(name) => sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name)), + None => sh.table_at(d.selection.active), + } + .ok_or_else(|| EngineError::Other("Select a cell inside a table first, or pass `table`.".into()))?; + let column = match str_param(p, "column") { + Some(c) => t + .columns + .iter() + .find(|tc| tc.name.eq_ignore_ascii_case(c)) + .map(|tc| tc.name.clone()) + .ok_or_else(|| bad("insert.slicer", format!("table has no column `{c}`")))?, + None => { + // Default to the active cell's column within the table, else the first column. + let idx = d.selection.active.col.checked_sub(t.range.start.col).map(|o| o as usize).filter(|o| *o < t.columns.len()).unwrap_or(0); + t.columns.get(idx).map(|tc| tc.name.clone()).ok_or_else(|| bad("insert.slicer", "the table has no columns"))? + } + }; + let table = t.name.clone(); + let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2))); + let columns = u32_param(p, "columns").unwrap_or(1).clamp(1, 10); + let width = f64_param(p, "width").unwrap_or(168.0).clamp(60.0, 10000.0) as f32; + let height = f64_param(p, "height").unwrap_or(200.0).clamp(60.0, 10000.0) as f32; + let id = d.wb.next_object_id(); + let slicer = Slicer { + id, + table, + column: column.clone(), + caption: column, + anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width, height, mode: AnchorMode::MoveOnly }, + selected: None, + columns, + }; + edit(s, |cx| { + cx.sheet_mut(sheet)?.slicers.push(slicer); + Ok(json!({"slicer": id})) + }) +} + +/// The target slicer: by `slicer` id, else the last one on the active sheet. +fn find_slicer(s: &Session, p: &Json) -> Result<(usize, u32)> { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let idx = match u32_param(p, "slicer") { + Some(id) => sh.slicers.iter().position(|x| x.id == id), + None => sh.slicers.len().checked_sub(1), + }; + idx.and_then(|i| sh.slicers.get(i).map(|x| (i, x.id))).ok_or_else(|| bad("slicer", "no slicer on this sheet")) +} + +fn slicer_toggle(s: &mut Session, p: &Json) -> Result { + let (idx, id) = find_slicer(s, p)?; + let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string(); + let multi = bool_param(p, "multi").unwrap_or(false); + let sheet = s.doc()?.wb.active_sheet; + let (table, column, current) = { + let d = s.doc()?; + let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?; + (sl.table.clone(), sl.column.clone(), sl.selected.clone()) + }; + let all = column_values(&s.doc()?.wb, sheet, &table, &column); + if !all.iter().any(|v| v.eq_ignore_ascii_case(&value)) { + return Err(bad("slicer.toggle", "value is not in the column")); + } + let new_selected: Option> = if multi { + let mut set: Vec = current.unwrap_or_else(|| all.clone()); + match set.iter().position(|x| x.eq_ignore_ascii_case(&value)) { + Some(pos) => { + set.remove(pos); + } + None => set.push(value.clone()), + } + // Everything selected is the same as no filter. + if set.len() == all.len() && all.iter().all(|a| set.iter().any(|x| x.eq_ignore_ascii_case(a))) { None } else { Some(set) } + } else { + // Single click: show only this value; clicking the sole selected value clears the filter. + match ¤t { + Some(set) if set.len() == 1 && set.iter().any(|x| x.eq_ignore_ascii_case(&value)) => None, + _ => Some(vec![value.clone()]), + } + }; + edit(s, |cx| { + if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { + sl.selected = new_selected; + } + reapply_slicer(&mut cx.wb, sheet, id); + cx.structural = true; + Ok(Json::Null) + }) +} + +fn slicer_select_all(s: &mut Session, p: &Json) -> Result { + let (idx, id) = find_slicer(s, p)?; + let sheet = s.doc()?.wb.active_sheet; + edit(s, |cx| { + if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { + sl.selected = None; + } + reapply_slicer(&mut cx.wb, sheet, id); + cx.structural = true; + Ok(Json::Null) + }) +} + +fn slicer_delete(s: &mut Session, p: &Json) -> Result { + let (idx, id) = find_slicer(s, p)?; + let sheet = s.doc()?.wb.active_sheet; + // Absolute column of the slicer, so its filter criterion can be removed too. + let abs_col = s.doc().ok().and_then(|d| { + let sh = d.wb.active()?; + let sl = sh.slicers.get(idx)?; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; + let ci = t.column_index(&sl.column)?; + Some(t.range.start.col + ci as u32) + }); + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + sh.slicers.retain(|x| x.id != id); + if let (Some(abs), Some(af)) = (abs_col, sh.autofilter.as_mut()) { + let off = abs.saturating_sub(af.range.start.col); + af.criteria.retain(|(o, _)| *o != off); + } + super::data::apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9121a066a..ad3085552 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -647,6 +647,59 @@ fn tables_and_structured_refs() { assert_eq!(v(&s, "B4"), Value::Number(7.0)); } +#[test] +fn slicer_filters_table() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 2], ["West", 5], ["East", 3], ["North", 1]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap(); + s.execute("selection.set", json!({"cell": "A2"})).unwrap(); + + // A new slicer on the Region column filters nothing (all values selected). + let r = s.execute("insert.slicer", json!({})).unwrap(); + let id = r["slicer"].as_u64().unwrap() as u32; + let hidden = |s: &Session| { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + (1..=4).filter(|row| sh.is_row_hidden(*row)).count() // data rows are sheet rows 1..=4 (0-based header at 0) + }; + assert_eq!(hidden(&s), 0); + { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(sh.slicers.len(), 1); + assert_eq!(sh.slicers[0].column, "Region"); + } + + // Single-click "East": only the two East rows remain (West row 2 and North row 4 hidden). + s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap(); + assert_eq!(hidden(&s), 2); + { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(sh.is_row_hidden(2) && sh.is_row_hidden(4)); + assert!(!sh.is_row_hidden(1) && !sh.is_row_hidden(3)); + } + + // Ctrl-click "West" adds it: East + West visible, only North hidden. + s.execute("slicer.toggle", json!({"slicer": id, "value": "West", "multi": true})).unwrap(); + assert_eq!(hidden(&s), 1); + + // Clear restores everything. + s.execute("slicer.clear", json!({"slicer": id})).unwrap(); + assert_eq!(hidden(&s), 0); + assert!(s.doc().unwrap().wb.active().unwrap().slicers[0].selected.is_none()); + + // Deleting the slicer drops it and clears its filter. + s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap(); + assert_eq!(hidden(&s), 2); + s.execute("slicer.delete", json!({"slicer": id})).unwrap(); + assert_eq!(hidden(&s), 0); + assert!(s.doc().unwrap().wb.active().unwrap().slicers.is_empty()); + + // A bad value is rejected, not panicked. + assert!(s.execute("slicer.toggle", json!({"value": "Nowhere"})).is_err()); +} + #[test] fn create_names_from_selection() { let names = |s: &Session| -> Vec<(String, String)> { diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index b9fe2ce74..be10c912e 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -479,6 +479,26 @@ pub struct AutoFilter { pub criteria: Vec<(u32, FilterCriterion)>, } +/// A slicer: an on-sheet filter widget bound to one column of a table. Clicking its value tiles +/// sets that column's `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applies it, +/// so a slicer is just a visual front-end over the existing filter machinery. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Slicer { + pub id: u32, + /// Name of the table this slicer filters. + pub table: String, + /// Header name of the filtered column within the table. + pub column: String, + /// Caption shown in the slicer header (defaults to the column name). + pub caption: String, + pub anchor: Anchor, + /// Selected display values. `None` means every value (no filter from this slicer); an empty + /// list means nothing is selected (all rows hidden), matching Excel's "clear all buttons". + pub selected: Option>, + /// Number of button columns in the layout (Excel default 1). + pub columns: u32, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Orientation { #[default] diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 4b4200d39..5e84bb4e1 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -54,6 +54,9 @@ pub struct Sheet { pub images: Vec, pub shapes: Vec, pub sparklines: Vec, + /// On-sheet slicer widgets (filter a table column by clicking value tiles). + #[serde(default)] + pub slicers: Vec, /// PivotTables whose output lives on this sheet. #[serde(default)] pub pivots: Vec, @@ -99,6 +102,7 @@ impl Sheet { images: vec![], shapes: vec![], sparklines: vec![], + slicers: vec![], pivots: vec![], protection: None, print: PrintSettings::default(), diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 6ccef9e9c..b1587ff72 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -216,6 +216,9 @@ impl Workbook { for c in &s.tables { m = m.max(c.id); } + for c in &s.slicers { + m = m.max(c.id); + } } m + 1 } diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index adeeddbc5..188fbad30 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -308,6 +308,112 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: } } } + paint_slicers(app, p, geo, wb, si, sh); +} + +/// Geometry of a slicer's parts within its screen rect. +pub struct SlicerLayout { + pub header: Rect, + /// Clear-filter button in the header. + pub clear: Rect, + /// One rect per value tile, in order. + pub tiles: Vec, +} + +pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32) -> SlicerLayout { + let header_h = 26.0 * z; + let pad = 6.0 * z; + let gap = 3.0 * z; + let tile_h = 22.0 * z; + let header = Rect::from_min_size(rect.min, vec2(rect.width(), header_h)); + let cb = 16.0 * z; + let clear = Rect::from_min_size(pos2(header.right() - pad - cb, header.top() + (header_h - cb) / 2.0), vec2(cb, cb)); + let cols = columns.max(1) as f32; + let inner_left = rect.left() + pad; + let inner_top = header.bottom() + pad; + let inner_w = (rect.width() - 2.0 * pad).max(1.0); + let tile_w = ((inner_w - (cols - 1.0) * gap) / cols).max(1.0); + let mut tiles = Vec::with_capacity(n); + for i in 0..n { + let col = (i as f32) % cols; + let row = (i as f32 / cols).floor(); + let x = inner_left + col * (tile_w + gap); + let y = inner_top + row * (tile_h + gap); + tiles.push(Rect::from_min_size(pos2(x, y), vec2(tile_w, tile_h))); + } + SlicerLayout { header, clear, tiles } +} + +fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) { + let tk = theme::Tokens::get(p.ctx()); + for sl in &sh.slicers { + let r = anchor_rect(geo, sh, &sl.anchor); + if !r.intersects(p.clip_rect()) { + continue; + } + let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + let radius = 4.0 * geo.z; + // Card + soft shadow. + p.rect_filled(r.translate(vec2(0.0, 1.5)), radius, Color32::from_black_alpha(18)); + p.rect_filled(r, radius, tk.window); + p.rect_stroke(r, radius, Stroke::new(1.0, tk.separator), StrokeKind::Inside); + // Header. + p.rect_filled(lay.header, radius, tk.accent_soft); + p.rect_filled(Rect::from_min_max(pos2(lay.header.left(), lay.header.bottom() - radius), lay.header.max), 0.0, tk.accent_soft); + let cap = if sl.caption.is_empty() { sl.column.clone() } else { sl.caption.clone() }; + p.text(lay.header.left_center() + vec2(8.0 * geo.z, 0.0), Align2::LEFT_CENTER, cap, theme::ui_bold(12.5 * geo.z), tk.accent_dark); + // Clear-filter button (tinted when a filter is active). + let filtered = sl.selected.is_some(); + if let Some(icon) = crate::icons::from_name("filter") { + crate::icons::paint(p, lay.clear, icon, if filtered { tk.accent } else { tk.text_dim }); + } + // Value tiles. + for ((val, on), tr) in items.iter().zip(&lay.tiles) { + if !r.contains(tr.left_top()) { + continue; // overflowed the card; skip (no scroll yet) + } + let (fill, fg) = if *on { (tk.accent, Color32::WHITE) } else { (tk.hover, tk.text_dim) }; + p.rect_filled(*tr, 3.0 * geo.z, fill); + let cp = p.with_clip_rect(tr.intersect(p.clip_rect())); + cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg); + } + if app.selected_chart == Some(sl.id) { + selection_frame(p, r); + } + } +} + +/// What a click inside a slicer hit. +pub enum SlicerAction { + Toggle(String), + Clear, +} + +/// A click on a slicer's value tile or clear button, if any. A click elsewhere in a slicer returns +/// `None` so the caller can treat it as a select/drag on the object. +pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> Option<(u32, SlicerAction)> { + if !geo.cells.contains(p) { + return None; + } + for sl in sh.slicers.iter().rev() { + let r = anchor_rect(geo, sh, &sl.anchor); + if !r.contains(p) { + continue; + } + let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + if lay.clear.contains(p) { + return Some((sl.id, SlicerAction::Clear)); + } + for ((val, _on), tr) in items.iter().zip(&lay.tiles) { + if r.contains(tr.left_top()) && tr.contains(p) { + return Some((sl.id, SlicerAction::Toggle(val.clone()))); + } + } + return None; // inside this (topmost) slicer, but on its header/background → select/drag + } + None } fn selection_frame(p: &Painter, r: Rect) { @@ -322,6 +428,12 @@ fn selection_frame(p: &Painter, r: Rect) { /// The topmost object under `p`: (kind, id, rect). pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(&'static str, u32, Rect)> { let _ = app; + for sl in sh.slicers.iter().rev() { + let r = anchor_rect(geo, sh, &sl.anchor); + if r.contains(p) && geo.cells.contains(p) { + return Some(("slicer", sl.id, r)); + } + } for ch in sh.charts.iter().rev() { let r = anchor_rect(geo, sh, &ch.anchor); if r.expand(4.0).contains(p) && geo.cells.contains(p) { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4bd50dda4..b989d255f 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1126,7 +1126,25 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } } - // Objects (charts, pictures, shapes) take clicks first. + // A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear + // button resets it. Runs before the generic object select/drag so tiles stay clickable. + if primary_clicked + && !resp.drag_started() + && let Some(p) = pos + && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet, p) + { + app.selected_chart = Some(id); + match act { + crate::chartview::SlicerAction::Toggle(value) => { + let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl})); + } + crate::chartview::SlicerAction::Clear => { + let _ = app.run("slicer.clear", json!({"slicer": id})); + } + } + return; + } + // Objects (charts, pictures, shapes, slicers) take clicks first. if resp.drag_started() || primary_clicked { if let Some(p) = pos && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) @@ -1597,6 +1615,7 @@ fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor), "image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor), + "slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor), _ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor), } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 28d025c11..17c7633a3 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1213,6 +1213,10 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { }); }); sep(ui); + if big_button(ui, Icon::Filter, "Slicer", "Insert a slicer to filter a table", false).clicked() { + act(app, "insert.slicer", json!({})); + } + sep(ui); let sp = big_button(ui, Icon::Sparkline, "Sparklines", "Sparklines", true); egui::Popup::menu(&sp).show(|ui| { for (label, kind) in [("Line", "line"), ("Column", "column"), ("Win/Loss", "winLoss")] { @@ -1738,6 +1742,10 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) { act(app, "table.convertToRange", json!({"table": tname})); } sep(ui); + if big_button(ui, Icon::Filter, "Insert\nSlicer", "Insert Slicer", false).clicked() { + act(app, "table.insertSlicer", json!({"table": tname})); + } + sep(ui); ui.vertical(|ui| { for (label, cmd, v) in [ ("Header Row", "table.headerRow", header && filter), From b40fdc247097f052cd780d2809b22f5c35fb5740 Mon Sep 17 00:00:00 2001 From: Zaid Shurbaji Date: Sat, 10 Oct 2026 21:27:14 +0300 Subject: [PATCH 07/54] ci: add macOS universal DMG build workflow Builds GridCraft.app into a universal .dmg on a GitHub-hosted macOS runner, ad-hoc signed, uploaded as a workflow artifact. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/macos-build.yml | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/macos-build.yml diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml new file mode 100644 index 000000000..2180636a1 --- /dev/null +++ b/.github/workflows/macos-build.yml @@ -0,0 +1,36 @@ +# Builds the macOS universal DMG (Apple silicon + Intel) on a GitHub-hosted Mac runner. +# +# Added for the table-slicers branch so a macOS build can be produced without a Mac: a .dmg +# can't be built on Windows/Linux (it needs Apple-only tools: lipo, codesign, hdiutil, iconutil). +# +# No signing secrets are used, so packaging/macos/package.sh signs the app AD-HOC and skips +# notarization. Gatekeeper will warn on first open; right-click the app > Open, or run +# `xattr -dr com.apple.quarantine /Applications/GridCraft.app` after installing. +# +# Run it: Actions tab > "macOS build" > Run workflow. The DMG is uploaded as a build artifact. +name: macOS build + +on: + workflow_dispatch: + push: + branches: [feature/table-slicers] + +jobs: + macos: + name: macOS universal (ad-hoc) + runs-on: macos-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + with: + targets: aarch64-apple-darwin, x86_64-apple-darwin + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: macos-slicers-universal + - name: Build and package (ad-hoc signed, not notarized) + run: packaging/macos/package.sh --arch universal + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: gridcraft-macos-universal-dmg + path: dist/release/*.dmg + if-no-files-found: error From e8d935298f45de4922cecdf9396b2e91b88db4d5 Mon Sep 17 00:00:00 2001 From: GyuriX Date: Sat, 10 Oct 2026 21:36:26 +0200 Subject: [PATCH 08/54] Linux: the display theme asks the desktop, not winit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit winit reports no system theme on Linux — `ActiveEventLoop::system_theme()` is `None` there on every desktop — so `System` in the appearance setting never saw the desktop's choice and fell back to light. A new `desktop_theme` module asks the XDG Desktop Portal's Settings interface for `org.gnome.desktop.interface color-scheme` on a thread of its own, so no D-Bus call is ever made from the frame loop, and the app follows the answer, including a change made while it runs. egui resolves `ThemePreference::System` from what winit reports and offers no way to set that itself, so where a desktop answered, the app hands egui the concrete theme instead. What is drawn is unchanged on macOS and Windows, where winit answers and the same resolution happens a step earlier; the saved preference is still `system`, so a file keeps meaning the same thing. The colour scheme is asked for under its GNOME schema name first, which GNOME's portal and XFCE's both answer, then under the older freedesktop one, which KDE answers. A desktop that answers neither leaves the user's own choice alone. --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 6 ++ crates/ui-egui/src/desktop_theme.rs | 132 +++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 19 +++- crates/ui-egui/tests/system_theme.rs | 4 +- 5 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/desktop_theme.rs diff --git a/Cargo.lock b/Cargo.lock index 6ff01cc9c..a4631e800 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1546,6 +1546,7 @@ dependencies = [ "sys-locale", "toml", "web-sys", + "zbus", ] [[package]] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index a6a2da2e3..bd48b9279 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -17,6 +17,12 @@ serde_json = { workspace = true } log = { workspace = true } image = { workspace = true } +# Linux only, where winit reports no system theme: the desktop portal's light/dark answer (see +# `desktop_theme.rs`). Already in the tree, with these features, through egui's own +# accessibility bus; pinned to one version so nothing is built twice. +[target.'cfg(target_os = "linux")'.dependencies] +zbus = "=5.19.0" + [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } diff --git a/crates/ui-egui/src/desktop_theme.rs b/crates/ui-egui/src/desktop_theme.rs new file mode 100644 index 000000000..2eb5d8371 --- /dev/null +++ b/crates/ui-egui/src/desktop_theme.rs @@ -0,0 +1,132 @@ +//! The desktop's own light/dark choice, for an interface setting that follows it. +//! +//! winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` on every +//! desktop there — so "Use system setting" has to ask the desktop itself. The XDG Desktop Portal's +//! Settings interface answers on GNOME, KDE and XFCE alike, over X11 and Wayland alike. + +use std::sync::mpsc::{Receiver, TryRecvError, channel}; +use std::time::Duration; + +/// How often [`DesktopTheme::start`] re-reads the desktop's answer. The portal also publishes a +/// change signal, but subscribing needs a connection kept alive for the life of every window; one +/// round trip every few seconds is cheap and reacts soon enough after a theme switch. +const POLL: Duration = Duration::from_secs(4); + +/// Whether the desktop asks applications to draw dark. `None` when it says nothing, and when +/// there is no session bus or no portal to ask (a bare X server, a container), so the caller +/// keeps whatever it chose itself. +pub fn prefers_dark() -> Option { + read() +} + +/// Watches the desktop and reports its choice when it changes. [`prefers_dark`] covers the +/// answer at start-up; this covers the user switching theme while the app runs. +#[derive(Default)] +pub struct DesktopTheme { + /// `None` when no watcher thread could be started; the one-shot read still works. + changes: Option>>, +} + +impl DesktopTheme { + /// Start watching. Nothing runs before this is called, and calling it once is enough. The + /// first answer is sent straight away, so the caller does not have to ask for it separately. + pub fn start() -> Self { + let (tx, rx) = channel(); + let started = std::thread::Builder::new().name("gridcraft-desktop-theme".into()).spawn(move || { + let mut last = read(); + if tx.send(last).is_err() { + return; + } + loop { + std::thread::sleep(POLL); + let now = read(); + if now != last { + last = now; + if tx.send(now).is_err() { + return; + } + } + } + }); + Self { changes: started.ok().map(|_| rx) } + } + + /// The desktop's new answer if it changed since the last call, to be polled once a frame. + /// Several changes in one frame collapse to the newest, and a desktop that stops answering + /// leaves the last answer in place. + pub fn take_change(&mut self) -> Option { + let Some(changes) = &self.changes else { return None }; + let mut newest = None; + loop { + match changes.try_recv() { + Ok(changed) => newest = changed, + Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return newest, + } + } + } +} + +/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's +/// thread is its only caller. +#[cfg(target_os = "linux")] +fn read() -> Option { + const PORTAL: &str = "org.freedesktop.portal.Desktop"; + const SETTINGS: &str = "org.freedesktop.portal.Settings"; + const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")]; + let connection = zbus::blocking::Connection::session().ok()?; + for (schema, key) in SCHEMAS { + let Ok(reply) = connection.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(schema, key)) else { + continue; + }; + let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue }; + if let Ok(scheme) = String::try_from(&*answer) { + return parse(&scheme); + } + } + None +} + +/// The portal's `color-scheme` answer. `default` means the desktop states no preference, so +/// neither does the application. +#[cfg(target_os = "linux")] +fn parse(scheme: &str) -> Option { + match scheme { + "prefer-dark" => Some(true), + "prefer-light" => Some(false), + _ => None, + } +} + +/// macOS and Windows answer through winit, which the caller already has. +#[cfg(not(target_os = "linux"))] +fn read() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The portal answers with one of three strings; only the two preferences are a choice. + #[test] + #[cfg(target_os = "linux")] + fn the_portal_answer_reads_as_a_choice_or_as_none() { + assert_eq!(parse("prefer-dark"), Some(true)); + assert_eq!(parse("prefer-light"), Some(false)); + assert_eq!(parse("default"), None); + assert_eq!(parse(""), None); + } + + /// Off Linux the caller already has winit's answer, so this adds nothing. + #[test] + #[cfg(not(target_os = "linux"))] + fn other_platforms_answer_through_winit() { + assert_eq!(prefers_dark(), None); + } + + /// A watcher that was never started is inert rather than a panic. + #[test] + fn a_watcher_without_a_thread_is_inert() { + assert_eq!(DesktopTheme::default().take_change(), None); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b0399431c..b6ef62bb2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -35,6 +35,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value as Json, json}; pub use control::{ControlRequest, ControlResponse}; +pub mod desktop_theme; /// Persisted UI preferences. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -155,6 +156,10 @@ pub struct SheetApp { /// only when it changes this (see `theme::HanOrder`). fonts_han: Option, effective_dark: bool, + /// The desktop's light/dark choice, watched while the app runs (Linux has no winit answer). + desktop_theme: desktop_theme::DesktopTheme, + /// The last answer [`desktop_theme`] gave, so a frame that asks costs nothing. + desktop_dark: Option, pub name_box: Option, /// Transient ribbon keyboard navigation; never saved with UI preferences. pub keytips: keytips::KeyTips, @@ -189,6 +194,8 @@ impl SheetApp { fonts_set: false, fonts_han: None, effective_dark: false, + desktop_theme: desktop_theme::DesktopTheme::start(), + desktop_dark: None, name_box: None, keytips: keytips::KeyTips::default(), shots: control::Shots::default(), @@ -543,7 +550,17 @@ impl SheetApp { ctx.request_repaint(); } } - let preference = self.ui.theme_preference(); + if let Some(answer) = self.desktop_theme.take_change() { + self.desktop_dark = Some(answer); + } + let preference = match ( + self.ui.system_theme, + ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), + ) { + (true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark, + (true, Some(egui::Theme::Light)) => egui::ThemePreference::Light, + _ => self.ui.theme_preference(), + }; if ctx.options(|o| o.theme_preference) != preference { ctx.set_theme(preference); ctx.request_repaint(); diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/system_theme.rs index 0d2cdea32..c92ca9119 100644 --- a/crates/ui-egui/tests/system_theme.rs +++ b/crates/ui-egui/tests/system_theme.rs @@ -99,7 +99,7 @@ fn system_tracks_live_os_changes_with_both_gridcraft_palettes_and_light_fallback system_theme(&mut h, reported); assert_appearance(&h, dark); assert_eq!(preferences(&h)["systemTheme"], true); - assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + assert_eq!(h.ctx.theme(), if dark { Theme::Dark } else { Theme::Light }); } } @@ -112,7 +112,7 @@ fn ribbon_selects_system_and_fixed_modes_ignore_subsequent_os_changes() { h.run_steps(2); select_ribbon_theme(&mut h, "System"); assert_eq!(preferences(&h)["systemTheme"], true); - assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + assert_eq!(h.ctx.theme(), Theme::Dark); assert_appearance(&h, true); for (label, dark, preference) in [("Dark", true, ThemePreference::Dark), ("Light", false, ThemePreference::Light)] { select_ribbon_theme(&mut h, label); From c9525ab5fbe4b59df46ad7f9e4c4a6c67f58d83e Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen <7456949+mikl0s@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:40:02 +0200 Subject: [PATCH 09/54] Cell references: an invalid reference is an error instead of the active cell cell_param returned None for any cell argument it could not parse, so every command that falls back to the selection (cell.set, view.freezePanes, formulas.trace*, insert.* and others) silently used the active cell instead: set_cell with cell "ZZZZ0" wrote A1 and reported success. It also dropped a sheet prefix, so "Other!B2" was applied to the active sheet. cell_param now returns Result>. An absent or null value is still None, so the selection fallback is unchanged; an unparsable or non-string value is an error; a sheet prefix naming a sheet other than the one the command works on is an error. cell_param_on is for the commands that resolve their own sheet (cell.get, links, comments, notes, checkboxes, chart placement, paste): they accept a prefix naming that sheet. cell.set resolves its own target sheet and still writes to another existing sheet. --- crates/engine/src/cmd/data.rs | 4 +- crates/engine/src/cmd/edit.rs | 11 +-- crates/engine/src/cmd/extra.rs | 6 +- crates/engine/src/cmd/formulas.rs | 8 +-- crates/engine/src/cmd/insert.rs | 26 +++---- crates/engine/src/cmd/inspect.rs | 2 +- crates/engine/src/cmd/mod.rs | 24 ++++++- crates/engine/src/cmd/spelling.rs | 2 +- crates/engine/src/cmd/view.rs | 4 +- crates/mcp/src/tests.rs | 113 ++++++++++++++++++++++++++++++ 10 files changed, 168 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index fd50afbf2..74cbe5802 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -741,7 +741,7 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result { p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false); let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"'); - let dest = cell_param(p, "destination").unwrap_or(r.start); + let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start); edit(s, |cx| { let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; let mut out: Vec<(CellRef, String)> = Vec::new(); @@ -980,7 +980,7 @@ fn compare_op(op: CfOperator, n: f64, a: Option, b: Option) -> bool { fn validate_cmd(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let input = str_param(p, "input").unwrap_or(""); match check_validation(&d.wb, d.wb.active_sheet, at, input) { None => Ok(json!({"ok": true})), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb54fe03c..0a2c5327b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -192,7 +192,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri fn cell_set(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let at = match cell_param(p, "cell") { + let at = match cell_param_on(s, p, "cell", sheet)? { Some(c) => c, None => s.doc()?.selection.active, }; @@ -340,8 +340,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result { s.doc_mut()?.wb_switch_sheet(i); } let ranges = target_ranges(s, p)?; + let active = cell_param(s, p, "active")?; let d = s.doc_mut()?; - let active = cell_param(p, "active").or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default(); + let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default(); let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; if let Some(sh) = d.wb.active() { sel.expand_merges(sh); @@ -672,7 +673,7 @@ fn paste(s: &mut Session, p: &Json) -> Result { fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "at").unwrap_or(s.doc()?.selection.current().start); + let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); let rows: Vec> = parse_tsv(text); let h = rows.len(); let w = rows.iter().map(Vec::len).max().unwrap_or(0); @@ -771,14 +772,14 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { let link = bool_param(p, "link").unwrap_or(false); let dest_sheet = target_sheet(s, p)?; let sel = s.doc()?.selection.current(); - let at = cell_param(p, "at").unwrap_or(sel.start); + let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start); let src = clip.range; let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; // Tile the copy over a larger destination that is a multiple of its size. let tile = sel.height() % h == 0 && sel.width() % w == 0 && !sel.is_single() - && cell_param(p, "at").is_none() + && cell_param_on(s, p, "at", dest_sheet)?.is_none() && !sel.is_full_cols() && !sel.is_full_rows() && sel.count() <= 4_000_000; diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index b6a362ba7..a9d3c6d94 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -376,7 +376,7 @@ fn move_chart(s: &mut Session, p: &Json) -> Result { Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?), None => None, }; - let at = cell_param(p, "at"); + let at = cell_param_on(s, p, "at", target.unwrap_or(si))?; edit(s, |cx| { let mut chart = cx.sheet_mut(si)?.charts.remove(ci); if let Some(a) = at { @@ -856,7 +856,7 @@ fn checkbox(s: &mut Session, p: &Json) -> Result { fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { let si = target_sheet(s, p)?; let d = s.doc()?; - let cells: Vec = match cell_param(p, "cell") { + let cells: Vec = match cell_param_on(s, p, "cell", si)? { Some(c) => vec![c], None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::>()).collect(), }; @@ -922,7 +922,7 @@ fn new_cell_style(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string(); let d = s.doc()?; let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let c = cell_param(p, "fromCell").unwrap_or(d.selection.active); + let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active); let style = d.wb.styles.get(sh.style_id(c)).clone(); edit(s, |cx| { cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name)); diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index f3576e4f5..019dd8f6a 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -373,7 +373,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { fn trace_precedents(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at)); let v: Vec = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect(); Ok(Json::Array(v)) @@ -381,7 +381,7 @@ fn trace_precedents(s: &mut Session, p: &Json) -> Result { fn trace_dependents(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let mut deps = Vec::new(); d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps); deps.sort(); @@ -422,7 +422,7 @@ fn error_checking(s: &mut Session, _: &Json) -> Result { fn evaluate_formula(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let sheet = d.wb.active_sheet; let text = match str_param(p, "formula") { Some(f) => f.trim_start_matches('=').to_string(), @@ -454,7 +454,7 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result { fn evaluate(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let f = str_param(p, "formula").ok_or_else(|| bad("formulas.evaluate", "missing `formula`"))?; - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let v = gridcraft_calc::evaluate(&d.wb, d.wb.active_sheet, at, f); Ok(crate::cmd::inspect::value_json(&v)) } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 4c96005a3..463df8155 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -577,7 +577,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result { return Err(EngineError::Other("Select data for the chart first.".into())); } let id = d.wb.next_object_id(); - let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2)); + let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2)); let title = str_param(p, "title").map(str::to_string).or_else(|| { if series.len() == 1 { series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display()) @@ -626,6 +626,7 @@ fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { fn chart_set(s: &mut Session, p: &Json) -> Result { let (si, ci) = find_chart(s, p)?; + let at = cell_param_on(s, p, "at", si)?; edit(s, |cx| { let sh = cx.sheet_mut(si)?; let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) }; @@ -659,7 +660,7 @@ fn chart_set(s: &mut Session, p: &Json) -> Result { if let Some(t) = p.get("yTitle") { c.y_title = t.as_str().map(str::to_string); } - if let Some(at) = cell_param(p, "at") { + if let Some(at) = at { c.anchor.cell = at; } if let Some(w) = f64_param(p, "width") { @@ -742,7 +743,7 @@ fn chart_delete(s: &mut Session, p: &Json) -> Result { fn insert_sparkline(s: &mut Session, p: &Json) -> Result { let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string(); - let loc = cell_param(p, "location").ok_or_else(|| bad("insert.sparkline", "missing `location`"))?; + let loc = cell_param(s, p, "location")?.ok_or_else(|| bad("insert.sparkline", "missing `location`"))?; let kind = match str_param(p, "type") { Some("column") => SparklineKind::Column, Some("winLoss") => SparklineKind::WinLoss, @@ -801,7 +802,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240)); let d = s.doc()?; let id = d.wb.next_object_id(); - let at = cell_param(p, "at").unwrap_or(d.selection.active); + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let scale = (640.0 / w.max(1) as f32).min(1.0); let img = Image { id, @@ -864,7 +865,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { }; let d = s.doc()?; let id = d.wb.next_object_id(); - let at = cell_param(p, "at").unwrap_or(d.selection.active); + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let fill = super::format::color_param(p.get("fill")).unwrap_or(if kind == ShapeKind::TextBox { Color::rgb(255, 255, 255) } else { Color::Theme(4, 0) }); let line = super::format::color_param(p.get("line")).unwrap_or(if kind == ShapeKind::TextBox { @@ -944,6 +945,7 @@ fn move_object(s: &mut Session, p: &Json) -> Result { let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?; let kind = str_param(p, "kind").unwrap_or("").to_string(); let sheet = s.doc()?.wb.active_sheet; + let at = cell_param(s, p, "at")?; edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; let anchor = match kind.as_str() { @@ -953,7 +955,7 @@ fn move_object(s: &mut Session, p: &Json) -> Result { _ => None, }; let Some(a) = anchor else { return Err(bad("object.move", "no such object")) }; - if let Some(at) = cell_param(p, "at") { + if let Some(at) = at { a.cell = at; } if let Some(v) = f64_param(p, "dx") { @@ -1000,7 +1002,7 @@ fn set_anchor_mode(s: &mut Session, p: &Json) -> Result { fn insert_link(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); if bool_param(p, "remove").unwrap_or(false) { return edit(s, |cx| { cx.sheet_mut(sheet)?.hyperlinks.remove(&at); @@ -1032,7 +1034,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); let text = str_param(p, "text").unwrap_or("").to_string(); let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone()); edit(s, |cx| { @@ -1045,7 +1047,7 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result { fn reply_comment(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); let text = str_param(p, "text").unwrap_or("").to_string(); let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone()); edit(s, |cx| { @@ -1057,7 +1059,7 @@ fn reply_comment(s: &mut Session, p: &Json) -> Result { fn resolve_comment(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); let v = bool_param(p, "resolved").unwrap_or(true); edit(s, |cx| { if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) { @@ -1078,7 +1080,7 @@ fn delete_comment(s: &mut Session, p: &Json) -> Result { fn show_note(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); edit(s, |cx| { if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) { c.visible = !c.visible; @@ -1303,7 +1305,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result { let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32; let d = s.doc()?; let id = d.wb.next_object_id(); - let at = cell_param(p, "at").unwrap_or(d.selection.active); + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0)); let shape = Shape { id, diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index de670e9b8..414ab42bf 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -119,7 +119,7 @@ fn cell_get(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let d = s.doc()?; let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let c = cell_param(p, "cell").unwrap_or(d.selection.active); + let c = cell_param_on(s, p, "cell", sheet)?.unwrap_or(d.selection.active); let cell = sh.cell(c); let style = d.wb.styles.get(sh.style_id(c)); Ok(json!({ diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c4a290e47..7be6fafff 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -219,8 +219,28 @@ pub(crate) fn split_sheet(r: &str) -> (Option, &str) { } } -pub(crate) fn cell_param(p: &Json, key: &str) -> Option { - str_param(p, key).and_then(|t| CellRef::parse(split_sheet(t).1)) +/// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not +/// a cell reference, or that names a sheet other than the active one, is an error rather than a +/// silent fallback to some other cell. +pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result> { + cell_param_on(s, p, key, s.doc()?.wb.active_sheet) +} + +/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it). +pub(crate) fn cell_param_on(s: &Session, p: &Json, key: &str, sheet: usize) -> Result> { + let t = match p.get(key) { + None | Some(Json::Null) => return Ok(None), + Some(Json::String(t)) => t, + Some(v) => return Err(bad("cell", format!("invalid cell reference `{v}` for `{key}`"))), + }; + let (sh, body) = split_sheet(t); + let c = CellRef::parse(body).ok_or_else(|| bad("cell", format!("invalid cell reference `{t}` for `{key}`")))?; + if let Some(name) = sh + && s.doc()?.wb.sheet_index(&name) != Some(sheet) + { + return Err(bad("cell", format!("`{t}` for `{key}` names a sheet other than the one this command works on"))); + } + Ok(Some(c)) } /// Applies an edit to a copy of the active workbook, then recalculates and commits it. diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 10892bc31..e78a6ea77 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -155,7 +155,7 @@ fn change(s: &mut Session, p: &Json) -> Result { if bool_param(p, "all").unwrap_or(false) { return s.execute("edit.replace", json!({"what": word, "with": to, "matchCase": true, "all": true})); } - let c = cell_param(p, "cell").ok_or_else(|| bad("review.changeSpelling", "missing `cell`"))?; + let c = cell_param(s, p, "cell")?.ok_or_else(|| bad("review.changeSpelling", "missing `cell`"))?; let text = s.doc()?.wb.active().and_then(|sh| sh.cell(c)).map(|x| x.input_text()).unwrap_or_default(); s.execute("cell.set", json!({"cell": c.a1(), "input": text.replacen(&word, &to, 1)})) } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index d8450cdbf..c9bb1957f 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -113,7 +113,7 @@ fn freeze_panes(s: &mut Session, p: &Json) -> Result { if sh.freeze.is_some() && p.get("cell").is_none() { return set_freeze(s, None); } - let at = cell_param(p, "cell").unwrap_or(d.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let top = sh.view_top_left; let rows = at.row.saturating_sub(top.row); let cols = at.col.saturating_sub(top.col); @@ -254,7 +254,7 @@ fn scale_fit(s: &mut Session, p: &Json) -> Result { } fn breaks(s: &mut Session, p: &Json) -> Result { - let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active); + let at = cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active); let insert = bool_param(p, "insert").unwrap_or(false); let remove = bool_param(p, "remove").unwrap_or(false); let reset = bool_param(p, "reset").unwrap_or(false); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index cf590bedb..844b92327 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -392,3 +392,116 @@ fn file_commands_without_a_file_fail() { fails(&mut s, "execute_command", json!({"command": command, "params": {}})); } } + +// ---------- cell references ---------- + +fn cmd_fails(s: &mut Server, command: &str, params: Value) -> String { + fails(s, "execute_command", json!({"command": command, "params": params})) +} + +fn a1_display(s: &mut Server) -> String { + ok(s, "get_cell", json!({"cell": "A1"}))["text"].as_str().unwrap_or("").to_string() +} + +#[test] +fn set_cell_invalid_ref_is_error() { + let mut s = server(); + for bad in ["ZZZZ0", "B0", "A", "1", "A1:B2", "not a cell", "", "XFE1", "A1048577"] { + fails(&mut s, "set_cell", json!({"cell": bad, "input": "x"})); + } + assert_eq!(a1_display(&mut s), ""); +} + +#[test] +fn cell_ref_boundaries_still_work() { + let mut s = server(); + ok(&mut s, "set_cell", json!({"cell": "A1", "input": "first"})); + ok(&mut s, "set_cell", json!({"cell": "$B$2", "input": "abs"})); + ok(&mut s, "set_cell", json!({"cell": "XFD1048576", "input": "last"})); + ok(&mut s, "set_cell", json!({"cell": "xfd1", "input": "lower"})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], "abs"); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "XFD1048576"}))["text"], "last"); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "XFD1"}))["text"], "lower"); + assert_eq!(a1_display(&mut s), "first"); + // Ranges are still ranges for the commands that take them. + ok(&mut s, "read_range", json!({"range": "A1:B2"})); + ok(&mut s, "read_range", json!({"range": "$A$1:$B$2"})); + // The active sheet's own name is an accepted prefix; an absent cell still means the selection. + ok(&mut s, "set_cell", json!({"cell": "Sheet1!C3", "input": "named"})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "C3"}))["text"], "named"); + ok(&mut s, "execute_command", json!({"command": "formulas.tracePrecedents", "params": {}})); +} + +#[test] +fn bad_ref_in_command_is_error() { + let mut s = server(); + ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"})); + for (cmd, key) in [("view.freezePanes", "cell"), ("formulas.tracePrecedents", "cell"), ("formulas.traceDependents", "cell"), ("cell.get", "cell")] + { + cmd_fails(&mut s, cmd, json!({ key: "B0" })); + cmd_fails(&mut s, cmd, json!({ key: "nonsense" })); + } + assert_eq!(a1_display(&mut s), "7"); +} + +#[test] +fn other_sheet_prefix_is_error() { + let mut s = server(); + ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "Other"}})); + ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": 0}})); + fails(&mut s, "set_cell", json!({"cell": "Nope!B2", "input": "x"})); + // A command that cannot honour a sheet prefix must not drop it and use the active sheet. + cmd_fails(&mut s, "view.freezePanes", json!({"cell": "Other!B2"})); + cmd_fails(&mut s, "formulas.tracePrecedents", json!({"cell": "Other!B2"})); + ok(&mut s, "set_cell", json!({"cell": "Sheet1!B2", "input": "here"})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], "here"); + // cell.set resolves another existing sheet itself and writes there, not to the active sheet. + ok(&mut s, "set_cell", json!({"cell": "Other!B3", "input": "there"})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B3"}))["text"], ""); + let r = ok(&mut s, "read_range", json!({"range": "Other!B3"})); + assert!(r.to_string().contains("there"), "{r}"); +} + +#[test] +fn cross_sheet_cell_refs_resolve() { + let mut s = server(); + ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "Other"}})); + ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "My Sheet"}})); + ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": 0}})); + ok(&mut s, "set_cell", json!({"cell": "Other!B3", "input": "there"})); + ok(&mut s, "set_cell", json!({"cell": "'My Sheet'!A1", "input": "quoted"})); + // Reads honour the prefix. + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "Other!B3"}))["text"], "there"); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "'My Sheet'!A1"}))["text"], "quoted"); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B3"}))["text"], ""); + // Cell-level commands that resolve their own sheet land on it. + ok(&mut s, "execute_command", json!({"command": "review.newComment", "params": {"cell": "Other!B3", "text": "note"}})); + let c = ok(&mut s, "get_cell", json!({"cell": "Other!B3"})); + assert!(c.to_string().contains("note"), "{c}"); + let c = ok(&mut s, "get_cell", json!({"cell": "B3"})); + assert!(!c.to_string().contains("note"), "{c}"); + // The active sheet may be a quoted name with spaces. + ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": "My Sheet"}})); + ok(&mut s, "execute_command", json!({"command": "view.freezePanes", "params": {"cell": "'My Sheet'!B2"}})); + cmd_fails(&mut s, "view.freezePanes", json!({"cell": "Other!B2"})); + // A prefix naming a different sheet than the `sheet` argument is an error, not resolved in favour of either. + cmd_fails(&mut s, "cell.get", json!({"cell": "Other!B3", "sheet": "Sheet1"})); +} + +#[test] +fn text_to_columns_destination_follows_its_sheet() { + let mut s = server(); + ok(&mut s, "execute_command", json!({"command": "home.insertSheet", "params": {"name": "Other"}})); + ok(&mut s, "execute_command", json!({"command": "sheet.activate", "params": {"sheet": 0}})); + ok(&mut s, "set_cell", json!({"cell": "Other!A1", "input": "a,b"})); + ok( + &mut s, + "execute_command", + json!({"command": "data.textToColumns", "params": {"range": "Other!A1", "delimiters": [","], "destination": "Other!D1"}}), + ); + let r = ok(&mut s, "read_range", json!({"range": "Other!D1:E1"})); + assert!(r.to_string().contains('a') && r.to_string().contains('b'), "{r}"); + // An unparsable destination and a prefix that contradicts the command's sheet are errors, not A1. + cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "D0"})); + cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "Sheet1!D1"})); +} From d27dec031206600b118abacd99b6438b9ffe5a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dejan=20Koci=C4=87?= Date: Sat, 10 Oct 2026 12:05:32 +0200 Subject: [PATCH 10/54] web: print straight to the system print dialog, not just export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web build had no way to actually print — Ctrl+P (Cmd+P, declared on file.print's shortcut) did nothing at all, and the ribbon's "Print…" menu item ran file.print and silently discarded the result. file.print already builds a PDF and returns it as base64 specifically "for the UI to hand to the system print dialog" per its own doc comment — this wires that up. - ui-egui: new Services::print hook (web: hand PDF bytes to the browser's print flow directly, no download); SheetApp::print_now runs file.print, decodes the base64 result, and calls the hook. - ui-egui: Key::P now dispatches to print_now (was unbound); the ribbon's "Print…" menu item calls print_now instead of discarding file.print's result. - gridcraft-web: implements the hook (Blob -> object URL -> hidden iframe -> iframe.contentWindow.print() on load). Desktop builds are unaffected (no Services::print set there; print_now falls back to the existing alert-on-error behaviour with no UI action on success, same as before this change). --- apps/gridcraft-web/Cargo.toml | 2 +- apps/gridcraft-web/src/web.rs | 47 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 29 +++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 8 +++++- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 356c63b84..0f69e38e7 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index f5744bd19..9f758b47e 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -136,6 +136,11 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { log::error!("download of {name} failed: {e}"); } })), + print: Some(Box::new(|bytes: &[u8]| { + if let Err(e) = print_pdf(bytes) { + log::error!("print failed: {e}"); + } + })), open_url: Some(Box::new(|url: &str| { if let Some(w) = web_sys::window() { let _ = w.open_with_url_and_target(url, "_blank"); @@ -179,6 +184,48 @@ fn copy_html(html: &str, text: &str) -> Result<(), String> { Ok(()) } +/// Opens `bytes` (a PDF) as an object URL in a hidden iframe and calls the iframe's own +/// `print()` once it has finished loading — the standard way to drive the browser's native print +/// dialog on a PDF without a download or a popup window the browser might block. The blob URL and +/// the iframe are both cleaned up once the print dialog closes, so repeated prints don't leak +/// them. +fn print_pdf(bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type("application/pdf"); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; + + let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?; + iframe.style().set_property("display", "none").map_err(js)?; + document.body().ok_or("no body")?.append_child(&iframe).map_err(js)?; + + // `onload` fires once the PDF has actually rendered inside the iframe; + // printing before that would show a blank page. Set the handler BEFORE + // `src` so a fast/cached load can't fire before we're listening. + let iframe_for_load = iframe.clone(); + let url_for_cleanup = url.clone(); + let on_load = wasm_bindgen::closure::Closure::once_into_js(move || { + let Some(content_window) = iframe_for_load.content_window() else { return }; + let _ = content_window.print(); + // `afterprint` fires once the print dialog (or print preview) closes, whether the user + // printed or cancelled — the right moment to release the blob URL and drop the iframe. + let iframe_for_cleanup = iframe_for_load.clone(); + let on_after_print = wasm_bindgen::closure::Closure::once_into_js(move || { + web_sys::Url::revoke_object_url(&url_for_cleanup).ok(); + iframe_for_cleanup.remove(); + }); + let _ = content_window.add_event_listener_with_callback("afterprint", on_after_print.unchecked_ref()); + }); + iframe.set_onload(Some(on_load.unchecked_ref())); + iframe.set_src(&url); + Ok(()) +} + /// Triggers a browser download of `bytes`. fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eecdf..6ca6f05e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -121,6 +121,9 @@ pub struct Services { pub open_async: Option>, /// Files delivered asynchronously (name, bytes), opened on the next frame. pub inbox: Option, + /// Web: hand PDF bytes to the browser's print flow directly (no download, + /// no intermediate file) — opens the system print dialog on that PDF. + pub print: Option>, } /// Shared queue of files read asynchronously (browser file picker, drag and drop). @@ -287,6 +290,32 @@ impl SheetApp { } } + /// Runs `file.print`, builds the PDF and (where the host provides the hook — the web + /// build) hands the bytes straight to the browser's print flow; errors show the usual + /// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert` + /// (which still produces the PDF — just without a UI to act on the result yet). + pub fn print_now(&mut self) { + match self.run("file.print", json!({})) { + 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) => { + if e.contains("is not available right now") { + self.toast = Some((e, now_ms())); + } else { + self.message = Some(("GridCraft".into(), clean_error(&e))); + } + } + } + } + /// UI-only commands (dialogs, view toggles that aren't saved in the file). fn run_ui_command(&mut self, id: &str, p: &Json) -> Option> { let r = match id { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..5fc91f8d9 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -76,7 +76,6 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ("New Workbook", "file.new"), ("Open…", "file.open"), ("Save As…", "file.saveAs"), - ("Print…", "file.print"), ("Sort A to Z", "data.sortAscending"), ("Calculate Now", "formulas.calculateNow"), ] { @@ -84,6 +83,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { app.run_or_alert(id, json!({})); } } + if ui.button("Print…").clicked() { + app.print_now(); + } }); // Centered title. let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); @@ -1850,6 +1852,10 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { Key::O => Some(("file.open", json!({}))), Key::N => Some(("file.new", json!({}))), Key::W => Some(("file.close", json!({}))), + Key::P => { + app.print_now(); + None + } Key::K => { app.open_dialog("insertLink", json!({})); None From 7a7e23cab71676e51ee2741bc6d88661756161aa Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sun, 11 Oct 2026 00:27:50 +0300 Subject: [PATCH 11/54] Grid: reorder selected columns by header drag --- crates/engine/src/cmd/sheet.rs | 100 +++++++++++++++++++++ crates/engine/src/tests.rs | 52 +++++++++++ crates/formula/src/adjust.rs | 19 ++-- crates/formula/src/tests.rs | 2 + crates/ui-egui/src/grid.rs | 35 +++++++- crates/ui-egui/tests/header_column_drag.rs | 65 ++++++++++++++ docs/ui-parity.md | 4 +- 7 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/tests/header_column_drag.rs diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 24e626828..7affcdbdb 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -35,6 +35,7 @@ pub fn specs() -> Vec { p, Axis::Cols )), + cmd!("sheet.moveColumns", "Move Columns", [], None, "{cols?: \"B:C\", before: zero-based column index}", has_doc, move_columns), cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells), cmd!("home.deleteCells", "Delete Cells…", ["Home", "Cells", "Delete"], None, "{range?, shift: left|up|row|column}", has_doc, delete_cells), cmd!("home.insertSheet", "Insert Sheet", ["Home", "Cells", "Insert"], Some("Shift+F11"), "{name?, before?: index}", has_doc, insert_sheet), @@ -321,6 +322,105 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { Ok(out) } +/// Reorders a contiguous block of whole columns without replacing the destination columns. +/// `before` is a boundary in the original sheet: moving B:C before E produces A,D,B,C,E. +fn move_columns(s: &mut Session, p: &Json) -> Result { + let (from, count) = lines(s, p, Axis::Cols)?; + let before = u32_param(p, "before").ok_or_else(|| bad("sheet.moveColumns", "missing `before`"))?; + let end = + from.checked_add(count).filter(|end| *end <= MAX_COLS).ok_or_else(|| bad("sheet.moveColumns", "source columns are outside the worksheet"))?; + if before >= MAX_COLS || before.checked_add(count).is_none_or(|end| end > MAX_COLS) { + return Err(bad("sheet.moveColumns", "destination columns are outside the worksheet")); + } + if (from..=end).contains(&before) { + return Ok(json!({"cols": RangeRef::cols(from, end.saturating_sub(1)).a1()})); + } + let sheet = target_sheet(s, p)?; + if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) + && u.end.col >= before + && u.end.col as u64 + count as u64 >= MAX_COLS as u64 + { + return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into())); + } + check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?; + check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?; + + let moved_from = if before <= from { from + count } else { from }; + if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) { + return Err(bad("sheet.moveColumns", "the move is too close to the worksheet boundary")); + } + let destination = before; + let delete_at = moved_from; + let final_start = if before > from { before - count } else { before }; + let active_row = s.doc()?.selection.active.row; + let source = RangeRef::cols(moved_from, moved_from + count - 1); + let out = edit(s, |cx| { + let name = cx.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default(); + cx.protection_checked = true; + + { + let sh = cx.sheet_mut(sheet)?; + sh.cells.shift_cols(before, count as i64); + shift_sheet_features(sh, Axis::Cols, before, count, true); + } + rewrite(&mut cx.wb, &name, &Edit::Insert { axis: Axis::Cols, at: before, count }); + + let mut touched = Vec::new(); + { + let sh = cx.sheet_mut(sheet)?; + let pictures: Vec<_> = + sh.cell_pictures.iter().filter(|(cell, _)| source.contains(**cell)).map(|(cell, picture)| (*cell, picture.clone())).collect(); + let cells = sh.take_cells(source); + let moved_lines: Vec<_> = (0..count).filter_map(|offset| sh.cols.remove(&(moved_from + offset)).map(|info| (offset, info))).collect(); + let movement = Edit::Move { from: source, to_row: 0, to_col: destination }; + shift_cell_features(sh, &movement); + for object in sh + .charts + .iter_mut() + .map(|o| &mut o.anchor) + .chain(sh.images.iter_mut().map(|o| &mut o.anchor)) + .chain(sh.shapes.iter_mut().map(|o| &mut o.anchor)) + { + if object.mode != AnchorMode::Absolute && (moved_from..moved_from + count).contains(&object.cell.col) { + object.cell.col = destination + (object.cell.col - moved_from); + } + } + for (cell, value) in cells { + let dest = CellRef::new(cell.row, destination + (cell.col - moved_from)); + sh.cells.set(dest, value); + touched.extend([cell, dest]); + } + for (cell, picture) in pictures { + let dest = CellRef::new(cell.row, destination + (cell.col - moved_from)); + sh.cell_pictures.insert(dest, picture); + } + for (offset, info) in moved_lines { + sh.cols.insert(destination + offset, info); + } + } + for cell in touched { + cx.touch(sheet, cell); + } + rewrite(&mut cx.wb, &name, &Edit::Move { from: source, to_row: 0, to_col: destination }); + + { + let sh = cx.sheet_mut(sheet)?; + sh.cells.shift_cols(delete_at + count, -(count as i64)); + shift_sheet_features(sh, Axis::Cols, delete_at, count, false); + } + rewrite(&mut cx.wb, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count }); + *cx.sel = crate::Selection { + active: CellRef::new(active_row, final_start), + anchor: CellRef::new(active_row, final_start), + ranges: vec![RangeRef::cols(final_start, final_start + count - 1)], + }; + cx.structural = true; + Ok(json!({"cols": RangeRef::cols(final_start, final_start + count - 1).a1()})) + })?; + cancel_copy_mode(s); + Ok(out) +} + fn insert_cells(s: &mut Session, p: &Json) -> Result { let shift = str_param(p, "shift").unwrap_or("down"); let r = target_range(s, p)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55e..890d29763 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -417,6 +417,58 @@ fn insert_delete_rows_adjust() { assert_eq!(v(&s, "A1"), Value::Number(10.0)); } +#[test] +fn move_columns_reorders_without_overwriting_and_undoes_once() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4, 5, 6, 7, 8]]})).unwrap(); + s.execute("cell.set", json!({"cell": "H2", "input": "=B1+D1"})).unwrap(); + s.execute("cell.set", json!({"cell": "H3", "input": "=SUM(B:B)"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "B:B", "width": 101})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "C:C", "width": 102})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + s.doc_mut().unwrap().undo.clear(); + + let result = s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 4})).unwrap(); + assert_eq!(result["cols"], "C:D"); + assert_eq!( + (0..8).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::>(), + vec![ + Value::Number(1.0), + Value::Number(4.0), + Value::Number(2.0), + Value::Number(3.0), + Value::Number(5.0), + Value::Number(6.0), + Value::Number(7.0), + Value::Number(8.0), + ] + ); + assert_eq!(s.execute("cell.get", json!({"cell": "H2"})).unwrap()["formula"], "=C1+B1"); + assert_eq!(s.execute("cell.get", json!({"cell": "H3"})).unwrap()["formula"], "=SUM(C:C)"); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.col_width(2), 101.0); + assert_eq!(sh.col_width(3), 102.0); + assert_eq!(s.doc().unwrap().selection.current().a1(), "C:D"); + assert_eq!(s.doc().unwrap().undo.len(), 1); + + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.doc().unwrap().wb, *before); +} + +#[test] +fn move_columns_can_reorder_left_and_ignores_drops_inside_the_selection() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap(); + s.execute("sheet.moveColumns", json!({"cols": "D:E", "before": 1})).unwrap(); + assert_eq!( + (0..5).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::>(), + vec!["A".into(), "D".into(), "E".into(), "B".into(), "C".into()] + ); + let undo = s.doc().unwrap().undo.len(); + s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 2})).unwrap(); + assert_eq!(s.doc().unwrap().undo.len(), undo); +} + #[test] fn inserting_or_deleting_cells_cancels_copy_mode() { let mut s = s(); diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 760167436..9424d7d2d 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -195,17 +195,6 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option { let kind = match &r.kind { RefKind::Cell(a) => RefKind::Cell(mv(a)?), RefKind::Range(a, b) => RefKind::Range(mv(a)?, mv(b)?), - k => k.clone(), - }; - Some(Reference { sheet: r.sheet.clone(), kind }) - } - Edit::MoveToSheet { from, to_sheet, to_row, to_col } => { - if !from.contains_range(&r.range()) { - return Some(r.clone()); - } - let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?; - // Whole-row/column references have no anchors, but their indices must move too. - moved.kind = match &r.kind { RefKind::Rows(a, aa, b, ba) => { let dr = *to_row as i64 - from.start.row as i64; RefKind::Rows(shift_line(*a, false, dr, MAX_ROWS)?, *aa, shift_line(*b, false, dr, MAX_ROWS)?, *ba) @@ -214,8 +203,14 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option { let dc = *to_col as i64 - from.start.col as i64; RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba) } - _ => moved.kind, }; + Some(Reference { sheet: r.sheet.clone(), kind }) + } + Edit::MoveToSheet { from, to_sheet, to_row, to_col } => { + if !from.contains_range(&r.range()) { + return Some(r.clone()); + } + let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?; moved.sheet = SheetSel::Named(to_sheet.clone()); Some(moved) } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 9a6dab3f8..78dd7eb6e 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -171,6 +171,8 @@ fn moves_and_renames() { let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 }; let e = parse("A1+B2+C3+SUM(A1:B2)").unwrap(); assert_eq!(print(&adjust(e, "S", "S", &mv)), "D10+E11+C3+SUM(D10:E11)"); + let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("B:C").unwrap(), to_row: 0, to_col: 4 }; + assert_eq!(print(&adjust(parse("SUM(B:B)+SUM($C:$C)+D1").unwrap(), "S", "S", &mv)), "SUM(E:E)+SUM($F:$F)+D1"); let e = parse("Old!A1+'Old'!B2").unwrap(); assert_eq!(print(&rename_sheet(e, "Old", "New Name")), "'New Name'!A1+'New Name'!B2"); } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..7180d46cb 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -27,6 +27,11 @@ pub enum Drag { Select, Rows(u32), Cols(u32), + /// Reordering one contiguous whole-column selection by its header. + MoveCols { + source: RangeRef, + before: Option, + }, ResizeCol { col: u32, start: f32, @@ -962,6 +967,10 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S let rr = geo.range_rect(sh, moved); p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); } + if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag { + let x = geo.x(sh, *before); + p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent)); + } // Marching ants around the copied range. if let Some(clip) = &app.session.clipboard && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet) @@ -1086,7 +1095,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } else if in_cells { ctx.set_cursor_icon(CursorIcon::Cell); } else if in_col_header { - ctx.set_cursor_icon(CursorIcon::Default); + let col = geo.col_at(sh, p.x); + let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); + ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); } app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); } @@ -1202,6 +1213,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let c = geo.col_at(sh, p.x); + if resp.drag_started() + && !mods.any() + && sel.ranges.len() == 1 + && sel.current().is_full_cols() + && sel.current().contains(CellRef::new(0, c)) + { + app.grid.drag = Drag::MoveCols { source: sel.current(), before: None }; + return; + } let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) }; select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c)); app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c }); @@ -1355,6 +1375,14 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); scroll_by(app, vec2(auto.x, 0.0)); } + Drag::MoveCols { source, .. } => { + let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); + let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; + let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1); + let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before); + app.grid.drag = Drag::MoveCols { source, before }; + scroll_by(app, vec2(auto.x, 0.0)); + } Drag::ResizeCol { col, start, orig } => { let w = (orig + (p.x - start) / geo.z).max(0.0); app.toast = @@ -1434,6 +1462,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let _ = app.run("edit.paste", json!({"at": to.a1()})); } } + Drag::MoveCols { source, before: Some(before) } => { + if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } _ => {} } app.grid.drag_select = false; diff --git a/crates/ui-egui/tests/header_column_drag.rs b/crates/ui-egui/tests/header_column_drag.rs new file mode 100644 index 000000000..d28f9ae33 --- /dev/null +++ b/crates/ui-egui/tests/header_column_drag.rs @@ -0,0 +1,65 @@ +//! Whole-column reordering driven through real egui pointer events. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::{Session, core::CellRef}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap(); + session.execute("selection.set", json!({"range": "B:C"})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + harness.run_steps(4); + harness.state_mut().session.active_mut().unwrap().undo.clear(); + harness.state_mut().session.journal.clear(); + harness +} + +fn header_center(harness: &egui_kittest::Harness<'_, SheetApp>, col: u32) -> Pos2 { + let app = harness.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + pos2(geo.x(sheet, col) + sheet.col_width(col) * geo.z * 0.5, geo.rect.top() + geo.header_h * 0.5) +} + +fn pointer_button(harness: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, pressed: bool) { + harness + .input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + harness.step(); +} + +#[test] +fn dragging_selected_column_headers_reorders_the_block() { + let mut harness = harness(); + let start = header_center(&harness, 1); + let target = { + let center = header_center(&harness, 4); + pos2(center.x - 20.0, center.y) + }; + + pointer_button(&mut harness, start, true); + harness.input_mut().events.push(Event::PointerMoved(target)); + harness.step(); + harness.input_mut().events.push(Event::PointerMoved(target + vec2(1.0, 0.0))); + harness.step(); + pointer_button(&mut harness, target + vec2(1.0, 0.0), false); + harness.run_steps(2); + + let app = harness.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let values: Vec<_> = (0..5).map(|col| sheet.value(CellRef::new(0, col))).collect(); + assert_eq!(values, vec!["A".into(), "D".into(), "B".into(), "C".into(), "E".into()]); + assert_eq!(app.session.active().unwrap().selection.current().a1(), "C:D"); + assert_eq!(app.session.journal.iter().filter(|(id, _)| id == "sheet.moveColumns").count(), 1); + assert_eq!(app.session.active().unwrap().undo.len(), 1); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..4d1feb4ee 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selected columns can be reordered by dragging their headers) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -38,6 +38,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Point mode (click/drag/arrow references), reference colouring, F4 | done | | AutoComplete in columns, function autocomplete and argument tips | done | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | +| Reorder selected whole columns by dragging their headers | done (10-11) | | Freeze panes, split panes | freeze done; split to verify | | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Missing: Insert Copied/Cut Cells (#21), paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | @@ -91,5 +92,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Selected whole columns can be reordered by dragging their headers to an insertion point | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From 4517d8be65b667f6fc14786ab0bcf5b1bb76ad0f Mon Sep 17 00:00:00 2001 From: Bryan Mark Date: Sun, 11 Oct 2026 16:15:57 +0900 Subject: [PATCH 12/54] Leave the active cell unshaded inside a selected range The active cell of a selected range was shaded like the rest of the range: the line meant to clear it painted a fully transparent rectangle, which changes nothing. The fill now goes around the active cell (the whole merged cell when it is one) in every area of the selection, so it is plain where typing goes. A lone active cell among several areas isn't shaded at all, and neither is a single selected merged cell. The fill is translucent, so the cell can't be cleared by painting over it: each area is shaded as up to four rectangles around it that never overlap. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 114 ++++++++++++++++++++++--- crates/ui-egui/tests/selection_fill.rs | 83 ++++++++++++++++++ 2 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/tests/selection_fill.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..6200291ac 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -901,23 +901,50 @@ fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { } } +/// Where the active cell is on screen: the whole merged cell when it is part of one. +fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect { + match sh.merge_at(sel.active) { + Some(m) => geo.range_rect(sh, m), + None => geo.cell_rect(sh, sel.active), + } +} + +/// The rectangles shaded for a selection: every area except the active cell, which stays clear so +/// it is plain where typing goes. A single selected cell has no fill. +pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Vec { + if sel.is_single_cell() { + return vec![]; + } + let active = active_rect(geo, sh, sel); + sel.ranges.iter().flat_map(|r| rect_around(geo.range_rect(sh, *r), active)).collect() +} + +/// `area` without `hole`: the strips above and below the hole, then what is left beside it. The +/// selection fill is translucent, so the active cell can't be cleared by painting over it; the +/// pieces never overlap for the same reason. Pieces with no size, or whose numbers aren't finite, +/// are dropped. +fn rect_around(area: Rect, hole: Rect) -> impl Iterator { + // `intersect` takes the sane side of a NaN, which would make a broken hole cover the area. + let hole = if hole.is_finite() { area.intersect(hole) } else { Rect::NOTHING }; + let parts = if hole.is_positive() { + [ + Rect::from_min_max(area.min, pos2(area.max.x, hole.min.y)), + Rect::from_min_max(pos2(area.min.x, hole.max.y), area.max), + Rect::from_min_max(pos2(area.min.x, hole.min.y), pos2(hole.min.x, hole.max.y)), + Rect::from_min_max(pos2(hole.max.x, hole.min.y), pos2(area.max.x, hole.max.y)), + ] + } else { + [area, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING] + }; + parts.into_iter().filter(|r| r.is_positive() && r.is_finite()) +} + fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) { // Fill all areas except the active cell. - for r in &sel.ranges { - if r.is_single() && sel.ranges.len() == 1 { - continue; - } - let rect = geo.range_rect(sh, *r); + for rect in selection_fill(geo, sh, sel) { p.rect_filled(rect, 0.0, t.sel_fill); } - let active = geo.cell_rect(sh, sh.merge_at(sel.active).map(|m| m.start).unwrap_or(sel.active)); - let active = match sh.merge_at(sel.active) { - Some(m) => geo.range_rect(sh, m), - None => active, - }; - if !(sel.ranges.len() == 1 && sel.current().is_single()) { - p.rect_filled(active.shrink(1.0), 0.0, t.grid_bg.gamma_multiply(0.0)); - } + let active = active_rect(geo, sh, sel); let cur = geo.range_rect(sh, sel.current()); // Editing a formula: outline its references instead of the selection. if let Some(ed) = &app.editor @@ -2223,3 +2250,64 @@ fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { } p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rect_around_leaves_the_hole_and_never_overlaps() { + let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 250.0)); + let check = |hole: Rect, pieces: usize| { + let parts: Vec = rect_around(area, hole).collect(); + assert_eq!(parts.len(), pieces, "{hole:?}: {parts:?}"); + let cut = area.intersect(hole); + let cut_area = if cut.is_positive() { cut.area() } else { 0.0 }; + let shaded: f32 = parts.iter().map(Rect::area).sum(); + assert!((shaded - (area.area() - cut_area)).abs() < 0.5, "everything but the hole is shaded: {hole:?}"); + for (i, a) in parts.iter().enumerate() { + assert!(area.contains_rect(*a), "nothing outside the area: {a:?}"); + assert!(!a.shrink(0.01).intersects(hole), "the hole stays clear: {a:?} vs {hole:?}"); + // The fill is translucent: a part shaded twice would show darker. + assert!(parts.iter().skip(i + 1).all(|b| !a.shrink(0.01).intersects(*b)), "{parts:?}"); + } + }; + let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)); + // In the middle, in each corner (where a drag began), along an edge. + check(cell(200.0, 100.0), 4); + check(cell(100.0, 50.0), 2); + check(cell(336.0, 50.0), 2); + check(cell(100.0, 234.0), 2); + check(cell(336.0, 234.0), 2); + check(cell(100.0, 100.0), 3); + // A merged cell as wide, or as tall, as the area; one that is all of it, or more. + check(Rect::from_min_max(pos2(100.0, 100.0), pos2(400.0, 132.0)), 2); + check(Rect::from_min_max(pos2(200.0, 50.0), pos2(264.0, 250.0)), 2); + check(area, 0); + check(area.expand(30.0), 0); + // The active cell is in another area, touches this one, or is only partly inside it. + check(cell(500.0, 300.0), 1); + check(cell(400.0, 50.0), 1); + check(cell(380.0, 240.0), 2); + } + + #[test] + fn rect_around_survives_hostile_numbers() { + let sane = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16.0)); + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0]; + for a in bad { + for b in bad { + let weird = Rect::from_min_max(pos2(a, b), pos2(b, a)); + for (area, hole) in [(sane, weird), (weird, sane), (weird, weird)] { + for part in rect_around(area, hole) { + assert!(part.is_positive() && part.is_finite(), "{area:?} around {hole:?}: {part:?}"); + } + } + // A hole that makes no sense leaves the whole area shaded. + if !weird.intersects(sane) { + assert_eq!(rect_around(sane, weird).collect::>(), vec![sane]); + } + } + } + } +} diff --git a/crates/ui-egui/tests/selection_fill.rs b/crates/ui-egui/tests/selection_fill.rs new file mode 100644 index 000000000..bada97d73 --- /dev/null +++ b/crates/ui-egui/tests/selection_fill.rs @@ -0,0 +1,83 @@ +//! The selection's fill leaves the active cell clear, so it is plain where typing goes. + +use egui::Rect; +use gridcraft_engine::Session; +use gridcraft_engine::core::RangeRef; +use gridcraft_ui_egui::grid::{Geo, selection_fill}; +use serde_json::json; + +fn session() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session +} + +fn geo(session: &Session) -> Geo { + let sheet = session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1200.0, 800.0)), egui::Vec2::ZERO) +} + +/// Where a cell or range is on screen. +fn cells(session: &Session, a1: &str) -> Rect { + let sheet = session.active().unwrap().wb.active().unwrap(); + geo(session).range_rect(sheet, RangeRef::parse(a1).unwrap()) +} + +fn fill(session: &Session) -> Vec { + let doc = session.active().unwrap(); + let sheet = doc.wb.active().unwrap(); + selection_fill(&geo(session), sheet, &doc.selection) +} + +fn shaded(fill: &[Rect], cell: Rect) -> bool { + fill.iter().any(|part| part.intersects(cell.shrink(1.0))) +} + +fn area(fill: &[Rect]) -> f32 { + fill.iter().map(Rect::area).sum() +} + +#[test] +fn the_active_cell_of_a_range_is_not_shaded() { + let mut s = session(); + // At the corner where a drag began, then wherever Tab and Enter move it inside the range. + for active in ["B2", "C4", "D5"] { + s.execute("selection.set", json!({"range": "B2:D5", "active": active})).unwrap(); + let fill = fill(&s); + assert!(!shaded(&fill, cells(&s, active)), "{active} stays clear: {fill:?}"); + for other in ["B2", "B5", "C3", "C4", "D2", "D5"].into_iter().filter(|c| *c != active) { + assert!(shaded(&fill, cells(&s, other)), "{other} is shaded with {active} active"); + } + assert!((area(&fill) - (cells(&s, "B2:D5").area() - cells(&s, active).area())).abs() < 0.5, "the rest of the range, once"); + } +} + +#[test] +fn a_single_selected_cell_has_no_fill() { + let mut s = session(); + s.execute("selection.set", json!({"cell": "C3"})).unwrap(); + assert!(fill(&s).is_empty()); +} + +#[test] +fn other_areas_are_shaded_whole_and_a_lone_active_cell_not_at_all() { + let mut s = session(); + s.execute("selection.set", json!({"range": "A1:A3,C2:D3,F6", "active": "F6"})).unwrap(); + assert_eq!(s.active().unwrap().selection.ranges.len(), 3); + let fill = fill(&s); + assert!(!shaded(&fill, cells(&s, "F6"))); + assert!(shaded(&fill, cells(&s, "A2")) && shaded(&fill, cells(&s, "D3"))); + assert!((area(&fill) - (cells(&s, "A1:A3").area() + cells(&s, "C2:D3").area())).abs() < 0.5, "{fill:?}"); +} + +#[test] +fn a_merged_active_cell_is_left_clear_as_a_whole() { + let mut s = session(); + s.execute("selection.set", json!({"range": "B2:C3"})).unwrap(); + s.execute("home.mergeCells", json!({})).unwrap(); + s.execute("selection.set", json!({"range": "B2:E6", "active": "B2"})).unwrap(); + let fill = fill(&s); + assert!(!shaded(&fill, cells(&s, "B2:C3")), "the merged cell, not just its first cell: {fill:?}"); + assert!(shaded(&fill, cells(&s, "D2")) && shaded(&fill, cells(&s, "B4")) && shaded(&fill, cells(&s, "E6"))); + assert!((area(&fill) - (cells(&s, "B2:E6").area() - cells(&s, "B2:C3").area())).abs() < 0.5); +} From a721b087a13bf6cb7795b18ceb19147d20d2066f Mon Sep 17 00:00:00 2001 From: Bryan Mark Date: Sun, 11 Oct 2026 16:15:57 +0900 Subject: [PATCH 13/54] Add arcanii to the contributor names Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 78683ae55..4a8ea491a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -28,3 +28,8 @@ sync_github_name = false [people.edurbs] sync_github_name = true + +[people.arcanii] +real_name = "Bryan Mark" +display_name = "Arcanii" +sync_github_name = false From 9518b041d55113f62ea484a467d6122b4068b1ed Mon Sep 17 00:00:00 2001 From: AlissonFA Date: Sun, 11 Oct 2026 01:57:11 -0300 Subject: [PATCH 14/54] M2: fix cell vertical alignment positioning and add keytips Closes #241 --- crates/engine/src/tests.rs | 34 +++++++++++++++ crates/ui-egui/src/grid.rs | 81 +++++++++++++++++++++++++++++++----- crates/ui-egui/src/ribbon.rs | 11 ++++- 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55e..6489ea238 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1633,3 +1633,37 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.hyperlinks, before.hyperlinks); assert_eq!(after.cells, before.cells); } + +#[test] +fn vertical_alignment_commands_and_roundtrip() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "top"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "middle"})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "bottom"})).unwrap(); + + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + s.execute("home.alignTop", json!({})).unwrap(); + + s.execute("selection.set", json!({"range": "A2"})).unwrap(); + s.execute("home.alignMiddle", json!({})).unwrap(); + + s.execute("selection.set", json!({"range": "A3"})).unwrap(); + s.execute("home.alignBottom", json!({})).unwrap(); + + let style_of = |s: &Session, cell: &str| { + let d = s.doc().unwrap(); + d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(cell).unwrap())).clone() + }; + + assert_eq!(style_of(&s, "A1").align.v, gridcraft_model::VAlign::Top); + assert_eq!(style_of(&s, "A2").align.v, gridcraft_model::VAlign::Center); + assert_eq!(style_of(&s, "A3").align.v, gridcraft_model::VAlign::Bottom); + + let d = s.doc().unwrap(); + let bytes = gridcraft_xlsx::write_xlsx(&d.wb).unwrap(); + let (reopened, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap(); + let reopen_style = |cell: &str| reopened.styles.get(reopened.active().unwrap().style_id(CellRef::parse(cell).unwrap())).clone(); + assert_eq!(reopen_style("A1").align.v, gridcraft_model::VAlign::Top); + assert_eq!(reopen_style("A2").align.v, gridcraft_model::VAlign::Center); + assert_eq!(reopen_style("A3").align.v, gridcraft_model::VAlign::Bottom); +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..a513de4ca 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -482,6 +482,28 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G } } +pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { + let pad_v = 1.5 * z; + let y_top = rect.top() + pad_v; + let y_bottom = (rect.bottom() - gh - pad_v).max(y_top); + match valign { + VAlign::Top => y_top, + VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, + VAlign::Bottom => y_bottom, + } +} + +pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { + let top_inset = 1.0 * z; + let y_top = rect.top() - top_inset; + let y_bottom = (rect.bottom() - gh - 0.5 * z).max(y_top); + match valign { + VAlign::Top => y_top, + VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, + VAlign::Bottom => y_bottom, + } +} + #[allow(clippy::too_many_arguments)] fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, rows: (u32, u32), cols: (u32, u32), cf: &mut CfCache, t: &Tokens) { let z = geo.z; @@ -643,11 +665,7 @@ fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, HAlign::Right => rect.right() - pad, _ => text_left + pad + indent, }; - let y = match st.align.v { - VAlign::Top => rect.top() + 1.0, - VAlign::Center => rect.center().y - galley.size().y / 2.0, - _ => rect.bottom() - galley.size().y - 1.0, - }; + let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z); p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color); continue; } @@ -713,11 +731,7 @@ fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, _ => text_left + pad + indent, }; let gh = galley.size().y; - let y = match st.align.v { - VAlign::Top => rect.top() + 1.0, - VAlign::Center | VAlign::Justify | VAlign::Distributed => rect.center().y - gh / 2.0, - VAlign::Bottom => rect.bottom() - gh - 0.5 * z, - }; + let y = cell_text_y(st.align.v, *rect, gh, z); let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); if rotated { let angle = -(st.align.rotation as f32).to_radians(); @@ -2223,3 +2237,50 @@ fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { } p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { + let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let gh = 18.5; + let z = 1.0; + + let y_top_std = cell_text_y(VAlign::Top, rect_std, gh, z); + let y_center_std = cell_text_y(VAlign::Center, rect_std, gh, z); + let y_bottom_std = cell_text_y(VAlign::Bottom, rect_std, gh, z); + + assert!(y_top_std < y_center_std, "Top ({y_top_std}) must be higher than Center ({y_center_std})"); + assert!(y_center_std < y_bottom_std, "Center ({y_center_std}) must be higher than Bottom ({y_bottom_std})"); + + // Verify tall row (e.g. 40px) + let rect_tall = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); + let y_top_tall = cell_text_y(VAlign::Top, rect_tall, gh, z); + let y_center_tall = cell_text_y(VAlign::Center, rect_tall, gh, z); + let y_bottom_tall = cell_text_y(VAlign::Bottom, rect_tall, gh, z); + + assert!(y_top_tall < y_center_tall, "Top ({y_top_tall}) must be higher than Center ({y_center_tall})"); + assert!(y_center_tall < y_bottom_tall, "Center ({y_center_tall}) must be higher than Bottom ({y_bottom_tall})"); + } + + #[test] + fn wrapped_cell_text_y_positions_correctly_and_safeguards_overflow() { + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); + let gh = 30.0; + let z = 1.0; + + let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); + let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); + let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); + + assert!(y_top < y_center); + assert!(y_center < y_bottom); + + // Overflow case: gh exceeds rect height + let gh_overflow = 60.0; + let y_bottom_ovf = wrapped_cell_text_y(VAlign::Bottom, rect, gh_overflow, z); + assert!(y_bottom_ovf >= rect.top()); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..60064a46c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -495,7 +495,16 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { (Icon::AlignMiddle, VAlign::Center, "home.alignMiddle", "Middle Align"), (Icon::AlignBottom, VAlign::Bottom, "home.alignBottom", "Bottom Align"), ] { - if toggle_button(ui, icon, st.align.v == v, tip).clicked() { + let response = toggle_button(ui, icon, st.align.v == v, tip); + if matches!(app.keytips.prefix(), Some("H" | "HA")) { + let sequence = match v { + VAlign::Top => "HAT", + VAlign::Center => "HAM", + _ => "HAB", + }; + keytip(app, ui, sequence, response.rect.center_bottom()); + } + if response.clicked() { act(app, id, json!({})); } } From 5967b5656fa7f6ffe0babd861e3554d84c656ea4 Mon Sep 17 00:00:00 2001 From: Bryan Mark Date: Sun, 11 Oct 2026 16:18:29 +0900 Subject: [PATCH 15/54] Glide the selection outline to a new selection; View > Animations turns it off Excel's cell cursor doesn't jump: press an arrow key or click a cell and the border slides over; extend the selection and it grows. The outline of the current area now does the same, in 0.12 s with a decelerating curve, from wherever it is drawn (a held arrow key reads as one motion), with its fill and fill handle. The timing is an estimate, not yet measured against Excel. The same cells moving on screen (scrolling, zoom, a resized column), another sheet or workbook, an outline that wasn't drawn the frame before, and moves that start or end off screen don't glide. Only painting waits: the selection, hit testing and the cell editor use the real cells at once. View > Animations (under Display theme) turns it off; view.animations {on?} and ui.set {animations?} do the same for agents. Saved with the UI preferences, on by default; translated in the five catalogs. Scripted pictures never catch an outline in flight: it stays on its cells while a ui.screenshot is on its way and when egui's animations are off (the headless harness, so the offscreen snapshot). The marching ants around a copied range now advance by the clock instead of once per painted frame, since a glide repaints every frame and made them lurch. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 16 +- crates/ui-egui/src/grid.rs | 42 ++- crates/ui-egui/src/i18n.rs | 5 + 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 | 2 + crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/ribbon.rs | 5 + crates/ui-egui/src/selection_glide.rs | 383 ++++++++++++++++++++++++ crates/ui-egui/tests/selection_fill.rs | 18 +- crates/ui-egui/tests/selection_glide.rs | 203 +++++++++++++ docs/control-protocol.md | 8 +- docs/ui-parity.md | 4 +- 14 files changed, 689 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/selection_glide.rs create mode 100644 crates/ui-egui/tests/selection_glide.rs diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e6da0f00b..548972360 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -12,8 +12,8 @@ //! - `ui.ribbon {tab}`, `ui.dialog {name}`, `ui.dialog.set {field, value}`, //! `ui.dialog.confirm`, `ui.dialog.cancel`, `ui.message.dismiss` //! - `ui.edit.begin {text?}`, `ui.edit.commit {move?}`, `ui.edit.cancel` -//! - `ui.screenshot {path?}` — PNG of the window (base64 when no path) -//! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?}` +//! - `ui.screenshot {path?}` — PNG of the window (base64 when no path); animations are settled +//! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?, ribbonCollapsed?, animations?}` //! - `app.open {path}`, `app.save {path?}`, `app.quit` use std::sync::mpsc::Sender; @@ -50,6 +50,14 @@ pub struct Shots { pending: Vec<(u64, Option, Sender, f64)>, } +impl Shots { + /// A screenshot was asked for and hasn't been delivered yet: animations hold still, so the + /// picture shows the settled state. + pub(crate) fn in_flight(&self) -> bool { + !self.queued.is_empty() || !self.pending.is_empty() + } +} + fn ok(v: Json) -> Outcome { Outcome::Done(json!({"ok": true, "result": v})) } @@ -172,6 +180,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.collapseRibbon", "Collapse Ribbon", ""), ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), + ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), ("view.zoom100", "100%", ""), ("ui.dialog", "Open Dialog", ""), ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"), @@ -201,6 +210,9 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - if let Some(v) = p.get("ribbonCollapsed").and_then(Json::as_bool) { app.ui.ribbon_collapsed = v; } + if let Some(v) = p.get("animations").and_then(Json::as_bool) { + app.ui.animations = v; + } ok(serde_json::to_value(&app.ui).unwrap_or_default()) } "ui.dialog" => { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 6200291ac..cbb7e58f5 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -65,6 +65,8 @@ pub struct GridState { pub filter_menu: Option<(u32, Pos2)>, pub context_menu: Option, pub marching_phase: f32, + /// The selection outline on its way to a new selection. + pub glide: crate::selection_glide::SelectionGlide, pub last_click: Option<(CellRef, f64)>, pub hover_cell: Option, pub autosave: bool, @@ -321,9 +323,10 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let si = wb.active_sheet; let Some(sh) = wb.sheet(si) else { return }; let sel = d.selection.clone(); + let place = (d.uid, si); let mut view = app.view(); // Scrolling. - let (scroll_delta, zoom_delta, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers)); + let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { if zoom_delta != 1.0 { let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round(); @@ -371,6 +374,20 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { *v = view; } app.grid.cells_rect = Some(geo.cells); + // The selection outline glides to a new selection. It stays on its cells when Animations is + // off, when egui's animations are (the headless harness), and while a screenshot is on its way. + let outline = app.grid.glide.frame(crate::selection_glide::Frame { + place, + range: sel.current(), + target: geo.range_rect(sh, sel.current()), + view: geo.cells, + now, + frame_nr: ui.ctx().cumulative_frame_nr(), + animate: app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(), + }); + if app.grid.glide.is_gliding() { + ui.ctx().request_repaint(); + } let painter = ui.painter_at(rect); painter.rect_filled(rect, 0.0, t.grid_bg); let mut cf = CfCache::new(); @@ -396,7 +413,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } let p = painter.with_clip_rect(*clip); paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t); - paint_selection(&p, &geo, sh, &sel, &t, app); + paint_selection(&p, &geo, sh, &sel, outline, &t, app); } // Freeze lines. if geo.fr > 0 { @@ -428,7 +445,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { context_menu(app, ui); vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t); if app.session.clipboard.is_some() { - app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0; + // By the clock, not per frame: a gliding outline repaints more often than the ants step. + app.grid.marching_phase = crate::selection_glide::ants_phase(now); ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); } } @@ -910,13 +928,15 @@ fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect } /// The rectangles shaded for a selection: every area except the active cell, which stays clear so -/// it is plain where typing goes. A single selected cell has no fill. -pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Vec { +/// it is plain where typing goes. A single selected cell has no fill. `outline` is where the +/// current area is drawn this frame: it glides to a new selection, and its fill with it. +pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect) -> Vec { if sel.is_single_cell() { return vec![]; } let active = active_rect(geo, sh, sel); - sel.ranges.iter().flat_map(|r| rect_around(geo.range_rect(sh, *r), active)).collect() + let area = |r: &RangeRef| if *r == sel.current() { outline } else { geo.range_rect(sh, *r) }; + sel.ranges.iter().flat_map(|r| rect_around(area(r), active)).collect() } /// `area` without `hole`: the strips above and below the hole, then what is left beside it. The @@ -939,13 +959,13 @@ fn rect_around(area: Rect, hole: Rect) -> impl Iterator { parts.into_iter().filter(|r| r.is_positive() && r.is_finite()) } -fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) { +/// `outline` is where the current area's border is drawn this frame (see `selection_glide`). +fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect, t: &Tokens, app: &SheetApp) { // Fill all areas except the active cell. - for rect in selection_fill(geo, sh, sel) { + for rect in selection_fill(geo, sh, sel, outline) { p.rect_filled(rect, 0.0, t.sel_fill); } let active = active_rect(geo, sh, sel); - let cur = geo.range_rect(sh, sel.current()); // Editing a formula: outline its references instead of the selection. if let Some(ed) = &app.editor && ed.is_formula() @@ -963,13 +983,13 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S } } } - p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); + p.rect_stroke(outline.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); if sel.ranges.len() > 1 { p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); } // Fill handle (bottom-right of the current area). if app.editor.is_none() { - let h = Rect::from_center_size(cur.right_bottom(), vec2(6.0, 6.0)); + let h = Rect::from_center_size(outline.right_bottom(), vec2(6.0, 6.0)); p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE); p.rect_filled(h, 0.0, t.sel_border); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..7010767b2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -341,6 +341,11 @@ const JAPANESE: &[(&str, &str)] = &[ ("Unfreeze Panes", "ウィンドウ枠固定の解除"), ("Dark Mode", "ダーク モード"), ("Display theme", "表示テーマ"), + ("Animations", "アニメーション"), + ( + "Glide the selection outline to the cell you move to instead of jumping there", + "選択範囲の枠を、移動先のセルまで滑らかに動かします(瞬時には切り替えません)", + ), ("System", "システム"), ("Light", "ライト"), ("Dark", "ダーク"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3cab..ec3733edf 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -263,6 +263,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "기타\n함수", "Page Layout|Orientation" => "용지 방향", "Display theme" => "표시 테마", + "Animations" => "애니메이션", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "선택 테두리가 바로 건너뛰지 않고 이동한 셀까지 부드럽게 미끄러집니다" + } "System" => "시스템", "Light" => "밝게", "Dark" => "어둡게", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3f2..adfd13f6c 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -264,6 +264,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Mais\nFunções", "Page Layout|Orientation" => "Orientação", "Display theme" => "Tema de exibição", + "Animations" => "Animações", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Desliza o contorno da seleção até a nova célula em vez de saltar" + } "System" => "Sistema", "Light" => "Claro", "Dark" => "Escuro", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d5485658..02e7b295c 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -263,6 +263,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Другие\nфункции", "Page Layout|Orientation" => "Ориентация", "Display theme" => "Тема оформления", + "Animations" => "Анимация", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Рамка выделения плавно скользит к новой ячейке, а не перескакивает" + } "System" => "Системная", "Light" => "Светлая", "Dark" => "Тёмная", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada73..dc3af991b 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -263,6 +263,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "更多\n函数", "Page Layout|Orientation" => "纸张方向", "Display theme" => "显示主题", + "Animations" => "动画", + "Glide the selection outline to the cell you move to instead of jumping there" => "选择框平滑滑动到新的单元格,而不是直接跳过去", "System" => "系统", "Light" => "浅色", "Dark" => "深色", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eecdf..0b0bfbc8f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ pub mod keytips; pub mod panes; pub mod pivot_pane; pub mod ribbon; +pub mod selection_glide; pub mod tabs; pub mod text_box; pub mod theme; @@ -48,6 +49,8 @@ pub struct UiState { pub formula_bar: bool, pub formula_bar_expanded: bool, pub status_bar: bool, + /// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations). + pub animations: bool, pub recent: Vec, /// Interface language. English by default (so tests and headless renders never depend on the /// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has @@ -67,6 +70,7 @@ impl Default for UiState { formula_bar: true, formula_bar_expanded: false, status_bar: true, + animations: true, recent: vec![], language: i18n::Language::En, } @@ -319,6 +323,10 @@ impl SheetApp { } Ok(json!({"mode": self.ui.theme_mode()})) } + "view.animations" => { + self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations); + Ok(json!({"on": self.ui.animations})) + } "view.zoom100" => return Some(self.session.run("view.zoom", json!({"percent": 100})).inspect(|_| self.after_engine())), "app.language.set" => { // `{"language": "ja"}`; `code` is accepted as an alias. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..066ff3ec7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1689,6 +1689,11 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { } } }); + let mut animations = app.ui.animations; + let hint = lang.tr("Glide the selection outline to the cell you move to instead of jumping there"); + if ui.checkbox(&mut animations, lang.tr("Animations")).on_hover_text(hint).changed() { + act(app, "view.animations", json!({"on": animations})); + } }); sep(ui); // Interface language: settings live in the View tab, like Excel's Options. The label follows diff --git a/crates/ui-egui/src/selection_glide.rs b/crates/ui-egui/src/selection_glide.rs new file mode 100644 index 000000000..dbd8bfa64 --- /dev/null +++ b/crates/ui-egui/src/selection_glide.rs @@ -0,0 +1,383 @@ +//! The selection outline glides to a new selection instead of jumping there, like Excel's cell +//! cursor: press an arrow key or click a cell and the border slides over in about a tenth of a +//! second; extend the selection and it grows edge by edge. View ▸ Animations turns it off. +//! +//! The behaviour comes from using Excel as a user; the duration and the easing curve are an +//! estimate ([`GLIDE_SECS`]), not yet measured against Excel. +//! +//! What glides and what doesn't: +//! - the selection changed (arrows, Enter, Tab, a click, a drag, Shift+arrows, the Name Box, any +//! command): the outline of the current area, its fill and its fill handle glide from wherever +//! they are drawn right now, so a held arrow key reads as one continuous motion; +//! - the same cells moved on screen (scrolling, zoom, a resized column or window): the outline +//! stays glued to its cells; +//! - another sheet or workbook, an outline that wasn't drawn the frame before, a move that starts +//! or ends off screen: it appears in place; +//! - a control-channel screenshot is on its way, or egui's animations are off (the headless +//! harness turns them off): it appears in place, so scripted pictures never catch it in flight; +//! - Animations is turned off (`view.animations`): it jumps. +//! +//! Only painting is delayed. The selection itself, hit testing (the fill handle, the border you +//! drag) and the cell editor use the real cells at once. +//! +//! The glide is kept as each edge's distance from its target, decaying to zero, so the outline is +//! always "the cells' rectangle plus what is left of the offset", in screen points. Scrolling +//! under a gliding outline therefore carries it along, and an arrow key held at the edge of the +//! window (the sheet scrolls, the cell stays where it is on screen) doesn't make it wobble. + +use egui::{Rect, pos2}; +use gridcraft_engine::core::RangeRef; + +/// How long a glide takes, in seconds. +pub const GLIDE_SECS: f64 = 0.12; +/// How far outside the cell area a glide may start or end (screen points). +const OFFSCREEN_SLACK: f32 = 48.0; + +/// What the grid knows about the selection outline in one frame. +#[derive(Clone, Copy, Debug)] +pub struct Frame { + /// The workbook (its uid) and sheet on screen: the outline never glides between two of them. + pub place: (u64, usize), + /// The current area of the selection. + pub range: RangeRef, + /// Where that area's cells are on screen. + pub target: Rect, + /// The grid's cell area on screen. + pub view: Rect, + /// egui's clock, in seconds. + pub now: f64, + /// egui's frame counter: an outline that skipped a frame doesn't glide from its stale place. + pub frame_nr: u64, + /// Glides may run this frame; otherwise the outline is on its cells. + pub animate: bool, +} + +/// The outline's motion between frames. Time-based, so it looks the same at any frame rate. +#[derive(Clone, Debug)] +pub struct SelectionGlide { + /// What the outline was last drawn for. + last: Option<((u64, usize), RangeRef)>, + /// Where it was drawn (screen points). + drawn: Rect, + /// Each edge's distance from the target when the glide set off: left, top, right, bottom. + offset: [f32; 4], + started: f64, + gliding: bool, + frame_nr: u64, +} + +impl Default for SelectionGlide { + fn default() -> Self { + SelectionGlide { last: None, drawn: Rect::ZERO, offset: [0.0; 4], started: 0.0, gliding: false, frame_nr: 0 } + } +} + +impl SelectionGlide { + /// Advance to this frame; returns where to draw the outline. + pub fn frame(&mut self, f: Frame) -> Rect { + let fresh = f.frame_nr.wrapping_sub(self.frame_nr) <= 1; + self.frame_nr = f.frame_nr; + let key = Some((f.place, f.range)); + if self.last != key { + // A new destination: set off from where the outline is drawn now (mid-glide or at rest). + let same_sheet = self.last.is_some_and(|(place, _)| place == f.place); + self.offset = if same_sheet && fresh { offset_between(self.drawn, f.target, f.view) } else { [0.0; 4] }; + self.started = f.now; + self.last = key; + } + if !f.animate { + self.offset = [0.0; 4]; + } + // Arrived when the time is up (or the clock went backwards). + let progress = (f.now - self.started) / GLIDE_SECS; + self.gliding = self.offset != [0.0; 4] && (0.0..1.0).contains(&progress); + let left = if self.gliding { 1.0 - ease_out(progress as f32) } else { 0.0 }; + let [l, t, r, b] = self.offset.map(|o| o * left); + self.drawn = Rect::from_min_max(pos2(f.target.min.x + l, f.target.min.y + t), pos2(f.target.max.x + r, f.target.max.y + b)); + self.drawn + } + + /// The outline is on its way somewhere: keep repainting. + pub fn is_gliding(&self) -> bool { + self.gliding + } + + /// Where the outline was drawn last frame (screen points). + pub fn drawn(&self) -> Rect { + self.drawn + } +} + +/// Decelerating curve: quick off the mark, soft landing. +fn ease_out(t: f32) -> f32 { + let r = 1.0 - t.clamp(0.0, 1.0); + 1.0 - r * r * r +} + +/// Each edge's distance from `to` back to `from`. No glide (all zero) when either end is off +/// screen or the numbers are not sane. An edge sets off at most one view away, so the far edge of +/// a whole column or row slides in from the border of the window instead of popping in at the end. +fn offset_between(from: Rect, to: Rect, view: Rect) -> [f32; 4] { + let seen = view.expand(OFFSCREEN_SLACK); + if !(seen.intersects(from) && seen.intersects(to)) { + return [0.0; 4]; + } + let limit = |v: f32| if v.is_finite() { v.abs() } else { 0.0 }; + let (lx, ly) = (limit(view.width()), limit(view.height())); + let offset = [ + (from.min.x - to.min.x).clamp(-lx, lx), + (from.min.y - to.min.y).clamp(-ly, ly), + (from.max.x - to.max.x).clamp(-lx, lx), + (from.max.y - to.max.y).clamp(-ly, ly), + ]; + if offset.iter().all(|o| o.is_finite()) { offset } else { [0.0; 4] } +} + +/// The marching ants' dash offset around a copied range, by egui's clock: half a point every +/// 60 ms, wrapping with the 8-point dash pattern. Stepping it once per painted frame is steady +/// only while nothing else repaints; a gliding outline repaints every frame and would make the +/// ants lurch forward. +pub fn ants_phase(now: f64) -> f32 { + let phase = (now / 0.12).rem_euclid(8.0) as f32; + if phase.is_finite() { phase } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::vec2; + use gridcraft_engine::core::CellRef; + + const PLACE: (u64, usize) = (1, 0); + const VIEW: Rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(1000.0, 600.0)); + const DT: f64 = 1.0 / 240.0; + + fn cell(row: u32, col: u32) -> RangeRef { + RangeRef::cell(CellRef::new(row, col)) + } + + /// A cell-sized rectangle at a place on screen. + fn at(x: f32, y: f32) -> Rect { + Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)) + } + + fn near(a: Rect, b: Rect) -> bool { + (a.min - b.min).length() < 1e-3 && (a.max - b.max).length() < 1e-3 + } + + /// Drives a glide the way the grid does: one frame per step, a clock, a frame counter. + struct Rig { + glide: SelectionGlide, + f: Frame, + } + + impl Rig { + fn new(range: RangeRef, target: Rect) -> Self { + let f = Frame { place: PLACE, range, target, view: VIEW, now: 10.0, frame_nr: 100, animate: true }; + let mut rig = Rig { glide: SelectionGlide::default(), f }; + rig.step(0.0); + rig + } + + /// The next frame, `dt` seconds later. + fn step(&mut self, dt: f64) -> Rect { + self.f.now += dt; + self.f.frame_nr += 1; + self.glide.frame(self.f) + } + + /// The selection changes; the next frame shows it. + fn select(&mut self, range: RangeRef, target: Rect) { + self.f.range = range; + self.f.target = target; + } + + /// Steps until the outline rests, returning every rectangle drawn on the way. + fn settle(&mut self, dt: f64) -> Vec { + let mut drawn = vec![self.step(dt)]; + while self.glide.is_gliding() && drawn.len() < 10_000 { + drawn.push(self.step(dt)); + } + drawn + } + } + + #[test] + fn the_first_outline_is_on_its_cells() { + let r = Rig::new(cell(0, 0), at(10.0, 10.0)); + assert_eq!(r.glide.drawn(), at(10.0, 10.0)); + assert!(!r.glide.is_gliding()); + } + + #[test] + fn a_move_glides_and_lands_exactly() { + let (a, b) = (at(0.0, 0.0), at(64.0, 32.0)); + let mut r = Rig::new(cell(0, 0), a); + r.select(cell(2, 1), b); + let drawn = r.settle(DT); + assert_eq!(drawn.first(), Some(&a), "it sets off from where it was"); + assert_eq!(drawn.last(), Some(&b), "and lands exactly on the cells"); + assert!(drawn.len() > 20, "over about {GLIDE_SECS} s, not in one frame: {} frames", drawn.len()); + for w in drawn.windows(2) { + assert!(w[0].min.x <= w[1].min.x && w[0].min.y <= w[1].min.y, "never backwards or past the cells: {w:?}"); + assert!(w[1].min.x <= b.min.x && w[1].min.y <= b.min.y); + assert!((w[1].width() - 64.0).abs() < 1e-3 && (w[1].height() - 16.0).abs() < 1e-3, "it keeps its shape on the way"); + } + // Decelerating: more than half of the way in the first third of the time. + let third = drawn[drawn.len() / 3]; + assert!(third.min.x > 32.0, "{third:?}"); + } + + #[test] + fn the_glide_is_the_same_at_any_frame_rate() { + let at_time = |dt: f64, frames: usize| { + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.select(cell(0, 5), at(320.0, 0.0)); + r.step(0.0); + (0..frames).map(|_| r.step(dt)).last().unwrap() + }; + // 60 ms into the glide at 50, 100 and 200 frames a second. + let (slow, mid, fast) = (at_time(0.02, 3), at_time(0.01, 6), at_time(0.005, 12)); + assert!(near(slow, mid) && near(mid, fast), "{slow:?} {mid:?} {fast:?}"); + assert!(slow.min.x > 0.0 && slow.min.x < 320.0); + } + + #[test] + fn extending_the_selection_grows_it_edge_by_edge() { + let one = at(0.0, 0.0); + let two = Rect::from_min_size(pos2(0.0, 0.0), vec2(128.0, 16.0)); + let mut r = Rig::new(cell(0, 0), one); + r.select(RangeRef::new(CellRef::new(0, 0), CellRef::new(0, 1)), two); + r.step(0.0); + let mid = r.step(GLIDE_SECS / 2.0); + assert_eq!(mid.min, one.min, "the anchored corner stays put"); + assert!(mid.max.x > one.max.x && mid.max.x < two.max.x && mid.max.y == two.max.y, "{mid:?}"); + } + + #[test] + fn a_new_move_takes_over_from_where_the_outline_is() { + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.select(cell(0, 1), at(64.0, 0.0)); + r.step(0.0); + let mid = r.step(GLIDE_SECS / 3.0); + assert!(mid.min.x > 0.0 && mid.min.x < 64.0); + // The next arrow key lands before the first glide is over. + r.select(cell(0, 2), at(128.0, 0.0)); + let next = r.step(0.0); + assert!(near(next, mid), "no jump when the destination changes: {next:?} vs {mid:?}"); + assert_eq!(r.settle(DT).last(), Some(&at(128.0, 0.0))); + } + + #[test] + fn the_same_cells_moving_on_screen_never_glide() { + let mut r = Rig::new(cell(4, 0), at(0.0, 64.0)); + // Scrolled, zoomed, a column resized: same range, another rectangle. + r.f.target = at(0.0, 16.0); + assert_eq!(r.step(DT), at(0.0, 16.0)); + r.f.target = Rect::from_min_size(pos2(8.0, 24.0), vec2(96.0, 24.0)); + assert_eq!(r.step(DT), r.f.target); + assert!(!r.glide.is_gliding()); + // Scrolling under a gliding outline carries it along with the sheet. + r.select(cell(5, 0), at(0.0, 200.0)); + r.step(0.0); + let before = r.step(GLIDE_SECS / 4.0); + r.f.target = at(0.0, 300.0); + let after = r.step(0.0); + assert!(near(after, before.translate(vec2(0.0, 100.0))), "{after:?} vs {before:?}"); + // An arrow key held at the window's edge: the sheet scrolls and the next cell is drawn + // where the last one was. Nothing to glide. + let mut r = Rig::new(cell(30, 0), at(0.0, 580.0)); + r.select(cell(31, 0), at(0.0, 580.0)); + assert_eq!(r.step(DT), at(0.0, 580.0)); + assert!(!r.glide.is_gliding()); + } + + #[test] + fn another_sheet_a_stale_or_off_screen_outline_appears_in_place() { + // Another sheet, another workbook. + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.f.place = (1, 1); + r.select(cell(9, 9), at(500.0, 300.0)); + assert_eq!(r.step(DT), at(500.0, 300.0)); + r.f.place = (2, 1); + r.select(cell(0, 0), at(0.0, 0.0)); + assert_eq!(r.step(DT), at(0.0, 0.0)); + assert!(!r.glide.is_gliding()); + // The outline wasn't drawn for a few frames: its old place means nothing now. + r.f.frame_nr += 5; + r.select(cell(0, 3), at(192.0, 0.0)); + assert_eq!(r.step(DT), at(192.0, 0.0)); + // It was scrolled out of sight, and the move brings the view back. + let mut r = Rig::new(cell(500, 0), at(0.0, -8000.0)); + r.select(cell(501, 0), at(0.0, 300.0)); + assert_eq!(r.step(DT), at(0.0, 300.0)); + // The move goes somewhere off screen. + r.select(cell(900, 0), at(0.0, 9000.0)); + assert_eq!(r.step(DT), at(0.0, 9000.0)); + assert!(!r.glide.is_gliding()); + } + + #[test] + fn far_edges_set_off_no_further_than_one_view_away() { + // A whole column: its bottom edge is millions of points below the window. + let column = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16_000_000.0)); + let mut r = Rig::new(RangeRef::cols(0, 0), column); + r.select(cell(3, 0), at(0.0, 48.0)); + let first = r.step(DT); + assert_eq!(first.min, column.min); + assert_eq!(first.max.y, 64.0 + VIEW.height(), "from the window's height below, not from row 1,048,576"); + assert_eq!(r.settle(DT).last(), Some(&at(0.0, 48.0))); + // And back: the far edge leaves the same way. + r.select(RangeRef::cols(0, 0), column); + assert_eq!(r.step(DT).max.y, 16_000_000.0 - VIEW.height()); + assert_eq!(r.settle(DT).last(), Some(&column)); + } + + #[test] + fn turned_off_or_hostile_numbers_never_glide_or_panic() { + // No animations this frame: on the cells, and a glide under way stops. + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.f.animate = false; + r.select(cell(0, 1), at(64.0, 0.0)); + assert_eq!(r.step(DT), at(64.0, 0.0)); + r.f.animate = true; + r.select(cell(0, 2), at(128.0, 0.0)); + r.step(0.0); + r.step(DT); + assert!(r.glide.is_gliding()); + r.f.animate = false; + assert_eq!(r.step(DT), at(128.0, 0.0)); + assert!(!r.glide.is_gliding()); + // Geometry and clocks that make no sense: whatever is drawn meanwhile, nothing panics, + // and a sane selection afterwards is drawn on its cells once its glide is over. + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0] { + for now in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0e300, 0.0] { + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.f.view = Rect::from_min_max(pos2(v, 0.0), pos2(1000.0, v)); + r.select(cell(0, 1), Rect::from_min_max(pos2(v, v), pos2(64.0, 16.0))); + r.step(DT); + r.f.now = now; + r.select(cell(0, 2), Rect::from_min_size(pos2(64.0, 0.0), egui::Vec2::splat(v))); + r.step(DT); + r.step(DT); + r.f.view = VIEW; + r.f.now = 50.0; + r.select(cell(0, 3), at(192.0, 0.0)); + r.step(DT); + assert_eq!(r.step(1.0), at(192.0, 0.0), "view {v}, clock {now}"); + } + } + assert_eq!((ease_out(-3.0), ease_out(0.0), ease_out(1.0), ease_out(7.0)), (0.0, 0.0, 1.0, 1.0)); + } + + #[test] + fn the_ants_march_by_the_clock() { + assert_eq!(ants_phase(0.0), 0.0); + assert!((ants_phase(0.06) - 0.5).abs() < 1e-4, "half a point every 60 ms"); + assert!((ants_phase(0.96 + 0.06) - 0.5).abs() < 1e-4, "wrapping with the dash pattern"); + for now in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -12.3, 1.0e300, 7.77] { + let p = ants_phase(now); + assert!((0.0..=8.0).contains(&p), "{now}: {p}"); + } + } +} diff --git a/crates/ui-egui/tests/selection_fill.rs b/crates/ui-egui/tests/selection_fill.rs index bada97d73..446ccdd51 100644 --- a/crates/ui-egui/tests/selection_fill.rs +++ b/crates/ui-egui/tests/selection_fill.rs @@ -23,10 +23,12 @@ fn cells(session: &Session, a1: &str) -> Rect { geo(session).range_rect(sheet, RangeRef::parse(a1).unwrap()) } +/// The fill at rest: the current area's outline is on its cells. fn fill(session: &Session) -> Vec { let doc = session.active().unwrap(); let sheet = doc.wb.active().unwrap(); - selection_fill(&geo(session), sheet, &doc.selection) + let geo = geo(session); + selection_fill(&geo, sheet, &doc.selection, geo.range_rect(sheet, doc.selection.current())) } fn shaded(fill: &[Rect], cell: Rect) -> bool { @@ -81,3 +83,17 @@ fn a_merged_active_cell_is_left_clear_as_a_whole() { assert!(shaded(&fill, cells(&s, "D2")) && shaded(&fill, cells(&s, "B4")) && shaded(&fill, cells(&s, "E6"))); assert!((area(&fill) - (cells(&s, "B2:E6").area() - cells(&s, "B2:C3").area())).abs() < 0.5); } + +#[test] +fn the_current_area_is_shaded_where_its_gliding_outline_is() { + let mut s = session(); + s.execute("selection.set", json!({"range": "A1:A3,C2:D3", "active": "C2"})).unwrap(); + let doc = s.active().unwrap(); + let sheet = doc.wb.active().unwrap(); + // Mid-glide the outline of the current area (C2:D3) is somewhere else on screen. + let outline = Rect::from_min_max(egui::pos2(600.0, 400.0), egui::pos2(700.0, 500.0)); + let fill = selection_fill(&geo(&s), sheet, &doc.selection, outline); + assert!(fill.contains(&outline), "the current area follows its outline: {fill:?}"); + assert!(shaded(&fill, cells(&s, "A2")), "the other areas stay on their cells"); + assert!(!shaded(&fill, cells(&s, "D3")), "nothing is left behind on the current area's cells"); +} diff --git a/crates/ui-egui/tests/selection_glide.rs b/crates/ui-egui/tests/selection_glide.rs new file mode 100644 index 000000000..fd8bf4e73 --- /dev/null +++ b/crates/ui-egui/tests/selection_glide.rs @@ -0,0 +1,203 @@ +//! The selection outline glides to a new selection, and View ▸ Animations turns that off. + +use egui::Rect; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::control::{self, Outcome}; +use gridcraft_ui_egui::{ControlRequest, SheetApp, UiState, grid::Geo}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// 240 frames a second, so a glide spans many frames. +const DT: f32 = 1.0 / 240.0; + +fn app() -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + SheetApp::new(session, Default::default()) +} + +fn harness() -> Harness { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(DT).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app(), + ); + h.run_steps(4); + h +} + +/// The harness turns egui's animations off (and the glide follows that switch); this turns them on. +fn animated() -> Harness { + let mut h = harness(); + h.ctx.all_styles_mut(|s| s.animation_time = 0.1); + h.step(); + h +} + +/// Where a cell is on screen right now. +fn cell_rect(h: &Harness, a1: &str) -> Rect { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(a1).unwrap()) +} + +/// Where the outline was drawn in the last frame. +fn outline(h: &Harness) -> Rect { + h.state().grid.glide.drawn() +} + +fn gliding(h: &Harness) -> bool { + h.state().grid.glide.is_gliding() +} + +fn select(h: &mut Harness, a1: &str) { + h.state_mut().run("selection.set", json!({"cell": a1})).unwrap(); +} + +fn press(h: &mut Harness, key: egui::Key) { + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }); + } +} + +fn steps(h: &mut Harness, n: usize) { + for _ in 0..n { + h.step(); + } +} + +fn reply(outcome: Outcome) -> Value { + match outcome { + Outcome::Done(v) => v, + _ => Value::Null, + } +} + +#[test] +fn an_arrow_key_glides_the_outline_to_the_next_cell() { + let mut h = animated(); + let (a1, b1) = (cell_rect(&h, "A1"), cell_rect(&h, "B1")); + assert_eq!(outline(&h), a1, "at rest the outline is on its cells"); + press(&mut h, egui::Key::ArrowRight); + h.step(); + // The selection itself moves at once; only its outline takes its time. + assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()).as_deref(), Some("B1")); + steps(&mut h, 2); + let x = outline(&h).min.x; + assert!(gliding(&h) && x > a1.min.x && x < b1.min.x, "on its way, drawn in between: {x}"); + steps(&mut h, 40); + assert!(!gliding(&h), "and it comes to rest"); + assert_eq!(outline(&h), b1, "exactly on the cell"); + // Zooming moves the cell on screen but doesn't glide the outline. + h.state_mut().run("view.zoom", json!({"percent": 150})).unwrap(); + for _ in 0..3 { + h.step(); + assert!(!gliding(&h)); + } + assert_ne!(outline(&h), b1); + assert_eq!(outline(&h), cell_rect(&h, "B1")); +} + +#[test] +fn with_eguis_animations_off_the_outline_is_always_on_its_cells() { + // The headless harness (so the offscreen snapshot too) never shows an outline in flight. + let mut h = harness(); + select(&mut h, "D6"); + let d6 = cell_rect(&h, "D6"); + for _ in 0..4 { + h.step(); + assert_eq!(outline(&h), d6); + assert!(!gliding(&h)); + } +} + +#[test] +fn the_view_tab_checkbox_turns_the_glide_off_and_on() { + let mut h = animated(); + h.state_mut().ui.ribbon_tab = "View".into(); + h.run_steps(2); + h.get_by_label("Animations").click(); + h.run_steps(2); + assert!(!h.state().ui.animations, "the checkbox turns the setting off"); + select(&mut h, "E9"); + let e9 = cell_rect(&h, "E9"); + for _ in 0..5 { + h.step(); + assert_eq!(outline(&h), e9, "no animations: the outline is on its cells at once"); + assert!(!gliding(&h)); + } + h.get_by_label("Animations").click(); + h.run_steps(2); + assert!(h.state().ui.animations, "and on again"); + select(&mut h, "B1"); + steps(&mut h, 2); + let (x, b1) = (outline(&h).min.x, cell_rect(&h, "B1")); + assert!(gliding(&h) && x > b1.min.x && x < e9.min.x, "turned back on, it glides: {x}"); + steps(&mut h, 40); + assert_eq!(outline(&h), b1); +} + +#[test] +fn a_screenshot_never_catches_the_outline_in_flight() { + let mut h = animated(); + let (a1, b1) = (cell_rect(&h, "A1"), cell_rect(&h, "B1")); + let (tx, rx) = std::sync::mpsc::channel(); + h.state_mut().control_rx = Some(rx); + let (shot, _reply) = ControlRequest::new("ui.screenshot", json!({})); + tx.send(shot).unwrap(); + press(&mut h, egui::Key::ArrowRight); + for _ in 0..20 { + h.step(); + let drawn = outline(&h); + assert!(drawn == a1 || drawn == b1, "{drawn:?}"); + } + assert_eq!(outline(&h), b1); +} + +#[test] +fn animations_are_on_until_turned_off_and_the_choice_is_saved() { + assert!(UiState::default().animations); + // A `ui.json` written before the setting existed leaves them on. + let old: UiState = serde_json::from_str(r#"{"dark": true, "ribbonTab": "View"}"#).unwrap(); + assert!(old.animations && old.dark); + let off: UiState = serde_json::from_value(json!({"animations": false})).unwrap(); + let saved = serde_json::to_value(&off).unwrap(); + assert_eq!(saved["animations"], json!(false)); + assert!(!serde_json::from_value::(saved).unwrap().animations); +} + +#[test] +fn view_animations_sets_and_toggles_without_a_dialog() { + let mut a = app(); + assert_eq!(a.run("view.animations", json!({"on": false})), Ok(json!({"on": false}))); + assert_eq!(a.run("view.animations", json!({"on": false})), Ok(json!({"on": false})), "setting it twice changes nothing"); + // No parameters: it toggles, and never opens a dialog. + assert_eq!(a.run("view.animations", json!({})), Ok(json!({"on": true}))); + assert_eq!(a.run("view.animations", json!({})), Ok(json!({"on": false}))); + assert!(a.dialog.is_none() && a.message.is_none()); + // Parameters that make no sense toggle too, rather than failing or panicking. + assert_eq!(a.run("view.animations", json!({"on": "yes"})), Ok(json!({"on": true}))); + assert_eq!(a.run("view.animations", Value::Null), Ok(json!({"on": false}))); +} + +#[test] +fn agents_find_run_and_read_the_setting_over_the_control_channel() { + let mut a = app(); + let ctx = egui::Context::default(); + let found = reply(control::handle(&mut a, &ctx, "engine.commands", &json!({"search": "animations"}))); + let entry = found["result"].as_array().and_then(|all| all.iter().find(|c| c["id"] == "view.animations")).cloned().unwrap(); + assert_eq!((&entry["label"], &entry["ui"]), (&json!("Animations"), &json!(true))); + assert!(entry["params"].as_str().is_some_and(|doc| doc.contains("on")), "{entry}"); + let ran = reply(control::handle(&mut a, &ctx, "engine.execute", &json!({"command": "view.animations", "params": {"on": false}}))); + assert_eq!(ran, json!({"ok": true, "result": {"on": false}})); + assert_eq!(reply(control::handle(&mut a, &ctx, "ui.inspect", &json!({})))["result"]["ui"]["animations"], json!(false)); + let set = reply(control::handle(&mut a, &ctx, "ui.set", &json!({"animations": true}))); + assert_eq!(set["result"]["animations"], json!(true)); + assert!(a.ui.animations); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aeb540d82..deb603023 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -32,8 +32,8 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `ui.ribbon` | `{tab}` | Switch ribbon tab | | `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs | -| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path) | -| `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?}` | UI preferences | +| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state | +| `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?, animations?}` | UI preferences | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | | `app.open` / `app.save` / `app.quit` | `{path}` / `{path?}` | Files and lifetime | @@ -45,6 +45,10 @@ System follows live OS appearance changes and falls back to Light when the platf The saved choice remains System; new users still start in Light. The legacy `view.darkMode` command and `ui.set {dark}` select a fixed Light or Dark theme. +`{command: "view.animations", params: {on: false}}` turns motion in the UI off (the selection +outline then jumps to a new selection instead of gliding there); without `on` it toggles. It is +the View ▸ Animations checkbox, saved with the other UI preferences and on by default. + `{command: "app.language.set", params: {language: "ja"}}` switches the interface language (`code` is accepted as an alias for `language`); `app.language.english` and `app.language.japanese` are shortcuts. Only interface chrome is translated, never command ids or diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..5cce1d7c5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** major (created from a source audit and the issue tracker) · **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 @@ -32,6 +32,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Behaviour | Status | |---|---| | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | +| Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done; right-drag menu missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | @@ -91,5 +92,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Selection outline glide and the Animations setting | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From 6f47a8b4631d64eef647f886edbcf59014757792 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:04:03 +0200 Subject: [PATCH 16/54] Review: Previous/Next Comment, Show Comments, Show All Notes, Convert to Comments (#281) - review.previousComment / review.nextComment select the previous/next threaded comment in reading order (row by row), across visible sheets in tab order, wrapping around; disabled when the workbook has no comments. - review.showComments {on?} toggles the Comments pane as session state, so the CLI, MCP and control channel can drive it; the UI pane follows it. - review.showAllNotes {on?} displays every note (session display setting, not saved in the file). - review.convertToComments turns every note in the workbook into a threaded comment with the same author and text, as one undoable edit. - Ribbon: Previous/Next and a Show Comments toggle in the Comments group; Show All Notes and Convert to Comments in the Notes menu. Strings in all catalogs. Parity checklist regenerated (263 / 290). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 6 +- crates/engine/src/cmd/review.rs | 169 +++++++++++++++++++++++++++++++- crates/engine/src/lib.rs | 5 + crates/ui-egui/src/dialogs.rs | 3 +- crates/ui-egui/src/grid.rs | 3 +- crates/ui-egui/src/i18n.rs | 6 ++ crates/ui-egui/src/i18n/ko.rs | 6 ++ crates/ui-egui/src/i18n/pt.rs | 6 ++ crates/ui-egui/src/i18n/ru.rs | 6 ++ crates/ui-egui/src/i18n/zh.rs | 6 ++ crates/ui-egui/src/panes.rs | 13 ++- crates/ui-egui/src/ribbon.rs | 36 ++++++- docs/gaps.md | 7 +- docs/parity-checklist.md | 16 +-- docs/target-app-parity.md | 7 +- 15 files changed, 270 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..349f50cd8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Excel for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Review comment navigation and notes commands, catalog 263 / 290, #281) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 501 / 523 (95.8%; 492 working, 94.1%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 263 / 290 (90.7%), worksheet functions 501 / 523 (95.8%; 492 working, 94.1%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Review: Previous/Next Comment, Show Comments, Show All Notes, Convert to Comments (#281) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | @@ -142,6 +143,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Catalog 258 → 263 / 290: Review comment navigation, Show Comments, Show All Notes, Convert to Comments (#281) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 1613e0477..4a2314f4b 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -1,6 +1,8 @@ -//! Review tab: protection, spelling (basic), workbook statistics. +//! Review tab: protection, spelling (basic), workbook statistics, comment navigation and the +//! Comments pane, Show All Notes and Convert to Comments. -use gridcraft_model::{SheetProtection, password_hash}; +use gridcraft_core::CellRef; +use gridcraft_model::{SheetProtection, Visibility, password_hash}; use serde_json::{Value as Json, json}; use super::*; @@ -20,9 +22,94 @@ pub fn specs() -> Vec { cmd!("review.protectWorkbook", "Protect Workbook", ["Review", "Protect"], None, "{on?: bool}", has_doc, protect_workbook), cmd!(query "review.workbookStatistics", "Workbook Statistics", ["Review", "Proofing"], None, "{}", has_doc, stats), cmd!(query "review.checkAccessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → issues", has_doc, accessibility), + cmd!(noundo "review.previousComment", "Previous Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| { + step_comment(s, false) + }), + cmd!(noundo "review.nextComment", "Next Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| step_comment( + s, true + )), + cmd!(query "review.showComments", "Show Comments", ["Review", "Comments"], None, "{on?: bool} (toggles the Comments pane)", has_doc, |s, p| { + s.comments_pane = bool_param(p, "on").unwrap_or(!s.comments_pane); + Ok(json!({"on": s.comments_pane})) + }), + cmd!(query "review.showAllNotes", "Show All Notes", ["Review", "Notes"], None, "{on?: bool}", has_doc, |s, p| { + s.show_all_notes = bool_param(p, "on").unwrap_or(!s.show_all_notes); + Ok(json!({"on": s.show_all_notes})) + }), + cmd!( + "review.convertToComments", + "Convert to Comments", + ["Review", "Notes"], + None, + "{} (every note in the workbook) → {converted}", + has_doc, + convert_notes + ), ] } +/// Threaded comments in navigation order: visible sheets in tab order, then row by row. +fn comment_cells(s: &Session) -> Vec<(usize, CellRef)> { + let Some(d) = s.active() else { return vec![] }; + d.wb.sheets + .iter() + .enumerate() + .filter(|(_, sh)| sh.visibility == Visibility::Visible) + // `CellRef` orders by row, then column: reading order. + .flat_map(|(i, sh)| sh.comments.iter().filter(|(_, c)| c.threaded).map(move |(at, _)| (i, *at))) + .collect() +} + +fn has_comments(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if comment_cells(s).is_empty() { Err("there are no comments in this workbook".into()) } else { Ok(()) } +} + +/// Selects the next (or previous) threaded comment after the active cell, moving on to the next +/// sheet and wrapping around at the end of the workbook. +fn step_comment(s: &mut Session, forward: bool) -> Result { + let list = comment_cells(s); + let d = s.doc()?; + let here = (d.wb.active_sheet, d.selection.active); + let target = if forward { + list.iter().find(|c| **c > here).or_else(|| list.first()) + } else { + list.iter().rev().find(|c| **c < here).or_else(|| list.last()) + }; + let Some(&(sheet, at)) = target else { + return Err(EngineError::Other("There are no comments in this workbook.".into())); + }; + let d = s.doc_mut()?; + d.wb_switch_sheet(sheet); + d.selection = crate::Selection::at(at); + if let Some(sh) = d.wb.active() { + d.selection.expand_merges(sh); + } + let name = d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(); + Ok(json!({"sheet": name, "cell": at.a1()})) +} + +/// Review › Notes › Convert to Comments: every note in the workbook becomes a threaded comment +/// with the same author and text. +fn convert_notes(s: &mut Session, _: &Json) -> Result { + let sheets: Vec = + s.doc()?.wb.sheets.iter().enumerate().filter(|(_, sh)| sh.comments.values().any(|c| !c.threaded)).map(|(i, _)| i).collect(); + if sheets.is_empty() { + return Ok(json!({"converted": 0})); + } + edit(s, |cx| { + let mut n = 0usize; + for i in &sheets { + for c in cx.sheet_mut(*i)?.comments.values_mut().filter(|c| !c.threaded) { + c.threaded = true; + c.visible = false; + n += 1; + } + } + Ok(json!({"converted": n})) + }) +} + fn protect_sheet(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let b = |k: &str| bool_param(p, k).unwrap_or(false); @@ -97,3 +184,81 @@ fn accessibility(s: &mut Session, _: &Json) -> Result { } Ok(Json::Array(issues)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn book() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + for (sheet, cell) in [(0, "C1"), (0, "A3"), (1, "B2")] { + s.execute("review.newComment", json!({"sheet": sheet, "cell": cell, "text": "c", "author": "Ann"})).unwrap(); + } + s.execute("review.newNote", json!({"sheet": 0, "cell": "A2", "text": "note", "author": "Bob"})).unwrap(); + s + } + + fn at(s: &Session) -> (usize, String) { + let d = s.doc().unwrap(); + (d.wb.active_sheet, d.selection.active.a1()) + } + + #[test] + fn next_and_previous_comment_go_in_reading_order_across_sheets_and_wrap() { + let mut s = book(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + // Notes are skipped: A2 holds a note, not a comment. + let order = [(0, "C1"), (0, "A3"), (1, "B2"), (0, "C1")]; + for (sheet, cell) in order { + s.execute("review.nextComment", json!({})).unwrap(); + assert_eq!(at(&s), (sheet, cell.to_string())); + } + for (sheet, cell) in [(1, "B2"), (0, "A3"), (0, "C1")] { + s.execute("review.previousComment", json!({})).unwrap(); + assert_eq!(at(&s), (sheet, cell.to_string())); + } + } + + #[test] + fn comment_navigation_is_disabled_without_comments() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("review.newNote", json!({"cell": "A1", "text": "note"})).unwrap(); + assert!(s.execute("review.nextComment", json!({})).is_err()); + assert!(s.execute("review.previousComment", json!({})).is_err()); + assert_eq!(at(&s), (0, "A1".to_string())); + } + + #[test] + fn convert_to_comments_turns_every_note_into_a_comment_and_undoes() { + let mut s = book(); + s.execute("review.newNote", json!({"sheet": 1, "cell": "D4", "text": "second", "author": "Cy"})).unwrap(); + s.execute("review.showNote", json!({"sheet": 1, "cell": "D4"})).unwrap(); + let r = s.execute("review.convertToComments", json!({})).unwrap(); + assert_eq!(r["converted"], 2); + let wb = &s.doc().unwrap().wb; + let a2 = &wb.sheets[0].comments[&CellRef::parse("A2").unwrap()]; + let d4 = &wb.sheets[1].comments[&CellRef::parse("D4").unwrap()]; + assert!(a2.threaded && d4.threaded && !d4.visible); + assert_eq!((a2.author.as_str(), a2.text.as_str(), d4.author.as_str(), d4.text.as_str()), ("Bob", "note", "Cy", "second")); + // Nothing left to convert; one undo restores the notes. + assert_eq!(s.execute("review.convertToComments", json!({})).unwrap()["converted"], 0); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!s.doc().unwrap().wb.sheets[0].comments[&CellRef::parse("A2").unwrap()].threaded); + } + + #[test] + fn show_comments_and_show_all_notes_toggle() { + let mut s = book(); + assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], true); + assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], false); + assert_eq!(s.execute("review.showComments", json!({"on": true})).unwrap()["on"], true); + assert!(s.comments_pane); + assert_eq!(s.execute("review.showAllNotes", json!({})).unwrap()["on"], true); + assert_eq!(s.execute("review.showAllNotes", json!({"on": false})).unwrap()["on"], false); + assert!(!s.show_all_notes); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de786a1f4..fa9fc2ab9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -200,6 +200,11 @@ pub struct Session { pub draw_tool: String, pub draw_color: String, pub draw_width: f32, + /// Review › Show Comments: the Comments pane is open. + pub comments_pane: bool, + /// Review › Notes › Show All Notes: every note is displayed. An application display setting + /// (like Excel's comment indicator option), so it's not saved in the file. + pub show_all_notes: bool, } impl Session { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb10..b48282559 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -571,7 +571,8 @@ impl Dialog { "saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title")})), "start" => Dialog::custom("start", "GridCraft", json!({})), "comments" => { - app.grid.pane = Some("comments".into()); + app.grid.pane = None; + app.run_or_alert("review.showComments", json!({"on": true})); return None; } "watch" | "selectionPane" | "formatChart" => { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..a10fc9711 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2001,8 +2001,9 @@ fn paint_overlays( } // Notes: shown on hover (or always when marked visible). let hover = app.grid.hover_cell; + let all_notes = app.session.show_all_notes; for (c, cm) in &sh.comments { - let show = cm.visible || hover == Some(*c); + let show = cm.visible || (all_notes && !cm.threaded) || hover == Some(*c); if !show || app.grid.drag != Drag::None { continue; } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..e5d0c2c5a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -321,6 +321,12 @@ const JAPANESE: &[(&str, &str)] = &[ ("Check\nAccessibility", "アクセシビリティ チェック"), ("Threaded Comments", "スレッド化されたコメント"), ("Show\nComments", "コメントの表示"), + ("Previous", "前へ"), + ("Next", "次へ"), + ("Previous Comment", "前のコメント"), + ("Next Comment", "次のコメント"), + ("Show All Notes", "すべてのメモを表示"), + ("Convert to Comments", "コメントに変換"), ("Protect\nWorkbook", "ブックの保護"), ("Unprotect\nSheet", "シート保護の解除"), ("Protect Sheet…", "シートの保護…"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3cab..a9f532dc0 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -172,6 +172,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Check\nAccessibility" => "접근성\n검사", "Threaded Comments" => "스레드 댓글", "Show\nComments" => "댓글\n표시", + "Previous" => "이전", + "Next" => "다음", + "Previous Comment" => "이전 댓글", + "Next Comment" => "다음 댓글", + "Show All Notes" => "모든 메모 표시", + "Convert to Comments" => "댓글로 변환", "Protect\nWorkbook" => "통합 문서\n보호", "Unprotect\nSheet" => "시트 보호\n해제", "Protect Sheet…" => "시트 보호…", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3f2..8add6486a 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -173,6 +173,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Check\nAccessibility" => "Verificar\nAcessibilidade", "Threaded Comments" => "Comentários Encadeados", "Show\nComments" => "Mostrar\nComentários", + "Previous" => "Anterior", + "Next" => "Próximo", + "Previous Comment" => "Comentário Anterior", + "Next Comment" => "Próximo Comentário", + "Show All Notes" => "Mostrar Todas as Notas", + "Convert to Comments" => "Converter em Comentários", "Protect\nWorkbook" => "Proteger\nPasta de Trabalho", "Unprotect\nSheet" => "Desproteger\nPlanilha", "Protect Sheet…" => "Proteger Planilha…", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d5485658..03050d508 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -172,6 +172,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Check\nAccessibility" => "Проверка\nдоступности", "Threaded Comments" => "Комментарии с ветками", "Show\nComments" => "Показать\nкомментарии", + "Previous" => "Назад", + "Next" => "Далее", + "Previous Comment" => "Предыдущий комментарий", + "Next Comment" => "Следующий комментарий", + "Show All Notes" => "Показать все заметки", + "Convert to Comments" => "Преобразовать в комментарии", "Protect\nWorkbook" => "Защитить\nкнигу", "Unprotect\nSheet" => "Снять защиту\nлиста", "Protect Sheet…" => "Защитить лист…", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada73..08498665e 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -172,6 +172,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Check\nAccessibility" => "检查\n辅助功能", "Threaded Comments" => "线程式批注", "Show\nComments" => "显示\n批注", + "Previous" => "上一条", + "Next" => "下一条", + "Previous Comment" => "上一条批注", + "Next Comment" => "下一条批注", + "Show All Notes" => "显示所有备注", + "Convert to Comments" => "转换为批注", "Protect\nWorkbook" => "保护\n工作簿", "Unprotect\nSheet" => "撤销\n工作表保护", "Protect Sheet…" => "保护工作表…", diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fb8a9d7f7..08b1d4da4 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -7,7 +7,15 @@ use crate::SheetApp; use crate::theme::{self, Tokens}; pub fn show(app: &mut SheetApp, ui: &mut Ui) { - let Some(pane) = app.grid.pane.clone() else { return }; + // The Comments pane is engine state (`review.showComments`, so agents can drive it); the other + // panes are UI state. One pane shows at a time: opening another one closes Comments. + if app.grid.pane.as_deref() == Some("comments") { + app.grid.pane = None; + app.run_or_alert("review.showComments", json!({"on": true})); + } else if app.grid.pane.is_some() && app.session.comments_pane { + app.run_or_alert("review.showComments", json!({"on": false})); + } + let Some(pane) = app.grid.pane.clone().or_else(|| app.session.comments_pane.then(|| "comments".to_string())) else { return }; let t = Tokens::get(ui.ctx()); egui::Panel::right("task_pane").default_size(300.0).resizable(true).frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| { ui.horizontal(|ui| { @@ -21,6 +29,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { ui.label(egui::RichText::new(title).font(theme::ui_bold(15.0))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button(ui, crate::icons::Icon::Close, t.text_dim, "Close", vec2(22.0, 22.0)).clicked() { + if pane == "comments" { + app.run_or_alert("review.showComments", json!({"on": false})); + } app.grid.pane = None; } }); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..395f37b1c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1596,15 +1596,43 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { if big_button(ui, Icon::Comment, "New\nComment", "New Comment", false).clicked() { app.open_dialog("comment", json!({"threaded": true})); } - if big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false).clicked() { - app.grid.pane = Some("comments".into()); - } if big_button(ui, Icon::Delete, "Delete", "Delete Comment", false).clicked() { act(app, "review.deleteComment", json!({})); } + ui.vertical(|ui| { + for (icon, label, tip, id) in + [(Icon::Left, "Previous", "Previous Comment", "review.previousComment"), (Icon::Right, "Next", "Next Comment", "review.nextComment")] + { + if small_button(ui, icon, label, tip, false).clicked() { + act(app, id, json!({})); + app.grid.ensure_visible = true; + } + } + }); + let shown = app.session.comments_pane; + let r = big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false); + if shown { + ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if r.clicked() { + act(app, "review.showComments", json!({"on": !shown})); + if app.session.comments_pane { + app.grid.pane = None; + } + } let notes = big_button(ui, Icon::Note, "Notes", "Notes", true); egui::Popup::menu(¬es).show(|ui| { - menu_items(app, ui, &[("New Note", "dialog:comment", json!({"threaded": false})), ("Show/Hide Note", "review.showNote", json!({}))]); + menu_items( + app, + ui, + &[ + ("New Note", "dialog:comment", json!({"threaded": false})), + ("Show/Hide Note", "review.showNote", json!({})), + ("Show All Notes", "review.showAllNotes", json!({})), + ("-", "", json!({})), + ("Convert to Comments", "review.convertToComments", json!({})), + ], + ); }); sep(ui); let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8d8..6c87d3f84 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (comment navigation, Show All Notes and Convert to Comments landed, #281) · **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 @@ -110,8 +110,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- [function-parity.md](function-parity.md) ### 16. Collaboration and review (F) -- No co-authoring, Show Changes, Allow Edit Ranges, comment navigation, notes ↔ comments - conversion; cloud storage requested (#86). **Estimate:** 35–55 h (co-authoring scope needs the owner). +- No co-authoring, Show Changes, Allow Edit Ranges; threaded comments are saved to XLSX as notes + (no `threadedComments` part yet); cloud storage requested (#86). **Estimate:** 35–55 h (co-authoring scope needs the owner). ### 17. Insert objects (F) - SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot; 8 shape kinds vs @@ -149,6 +149,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §16: comment navigation, Show Comments, Show All Notes and Convert to Comments landed (#281) | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | | 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e98835d31..0ea956709 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 258 / 290 commands implemented (89.0%).** +**Overall: 263 / 290 commands implemented (90.7%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -15,7 +15,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | | Data | 20 | 28 | 71% | -| Review | 9 | 18 | 50% | +| Review | 14 | 18 | 78% | | View | 18 | 20 | 90% | | Automate | 3 | 3 | 100% | | Table Design | 12 | 13 | 92% | @@ -276,7 +276,7 @@ Missing: - `data.solver` Solver (Analysis) - `data.analysisToolPak` Data Analysis (Analysis) -## Review (9 / 18, 50%) +## Review (14 / 18, 78%) Implemented: @@ -285,8 +285,13 @@ Implemented: - `review.workbookStatistics` Workbook Statistics (Proofing) - `review.checkAccessibility` Check Accessibility (Accessibility) - `review.deleteComment` Delete Comment (Comments) +- `review.previousComment` Previous Comment (Comments) +- `review.nextComment` Next Comment (Comments) +- `review.showComments` Show Comments (Comments) - `review.newNote` New Note (Notes) - `review.showNote` Show/Hide Note (Notes) +- `review.showAllNotes` Show All Notes (Notes) +- `review.convertToComments` Convert to Comments (Notes) - `review.unprotectSheet` Unprotect Sheet (Protect) - `review.protectWorkbook` Protect Workbook (Protect) @@ -294,11 +299,6 @@ Missing: - `review.smartLookup` Smart Lookup (Insights) - `review.translate` Translate (Language) -- `review.previousComment` Previous Comment (Comments) -- `review.nextComment` Next Comment (Comments) -- `review.showComments` Show Comments (Comments) -- `review.showAllNotes` Show All Notes (Notes) -- `review.convertToComments` Convert to Comments (Notes) - `review.allowEditRanges` Allow Edit Ranges (Protect) - `review.showChanges` Show Changes (Changes) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2e5ed01cd..fb8151f4a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first full assessment; re-measure against Excel for Mac 16.113.4, replacing the estimates that lived in ROADMAP.md) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Review comment navigation and notes commands, catalog 263 / 290, #281) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **258 / 290 (89.0%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **263 / 290 (90.7%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **501 / 523 (95.8%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **492 / 523 (94.1%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | @@ -90,7 +90,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | -| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection. Missing: comment navigation, Show Changes, Allow Edit Ranges, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | +| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection. Comment navigation, Show All Notes and Convert to Comments (#281). Missing: Show Changes, Allow Edit Ranges, threaded comments in XLSX (saved as notes), accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | | Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Review: Previous/Next Comment, Show Comments, Show All Notes, Convert to Comments; catalog 258 → 263 / 290 (#281) | | 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) | | 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha | From 33afc7d3a5e40dabf19de42704671d0a09e04604 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:04:52 +0200 Subject: [PATCH 17/54] Clipboard: Insert Copied Cells and Insert Cut Cells (#21) New command home.insertCopiedCells: makes room at the active cell and pastes the clipboard there, as one undo step. Whole copied rows or columns insert whole rows or columns; a copied block shifts the cells down or right (Insert Paste dialog from the menu). A cut of whole rows or columns moves them and closes the gap (Insert Cut Cells). The cell and header context menus show the entry while the clipboard holds a copy. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 3 +- crates/engine/src/cmd/sheet.rs | 131 ++++++++++++++++++++ crates/engine/src/lib.rs | 3 + crates/engine/src/tests.rs | 65 ++++++++++ crates/ui-egui/src/dialogs.rs | 7 ++ crates/ui-egui/src/grid.rs | 39 +++++- crates/ui-egui/src/i18n.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/tests/insert_copied_cells.rs | 60 +++++++++ docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 16 files changed, 325 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/insert_copied_cells.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..8a5f89646 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Insert Copied Cells / Insert Cut Cells in the cell and header context menus (#21) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb54fe03c..5052f8815 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -610,6 +610,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { return Err(EngineError::Other("This action won't work on multiple selections.".into())); } let range = ranges.first().copied().unwrap_or_default(); + let selected = range; let d = s.doc()?; let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; let range = if range.is_full_cols() || range.is_full_rows() { @@ -622,7 +623,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and // scripts get it only when they ask for it. let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() }; + let clip = Clipboard { wb: d.wb.clone(), sheet, range, selected, cut, doc_uid: d.uid, text: text.clone() }; s.clipboard = Some(clip); let mut result = json!({"range": range.a1(), "text": text}); if let Some(html) = html { diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 24e626828..c2a0f47e7 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -36,6 +36,15 @@ pub fn specs() -> Vec { Axis::Cols )), cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells), + cmd!( + "home.insertCopiedCells", + "Insert Copied Cells", + ["Home", "Cells", "Insert"], + None, + "{at?: \"B3\", shift?: down|right}: inserts the copied cells at `at` (the active cell) and pastes them there. Whole copied rows or columns insert whole rows or columns; other blocks shift the cells at `at` down (default) or right. A cut of whole rows or columns moves them (Insert Cut Cells)", + has_clipboard, + insert_copied_cells + ), cmd!("home.deleteCells", "Delete Cells…", ["Home", "Cells", "Delete"], None, "{range?, shift: left|up|row|column}", has_doc, delete_cells), cmd!("home.insertSheet", "Insert Sheet", ["Home", "Cells", "Insert"], Some("Shift+F11"), "{name?, before?: index}", has_doc, insert_sheet), cmd!("home.deleteSheet", "Delete Sheet", ["Home", "Cells", "Delete"], None, "{sheet?}", has_doc, delete_sheet), @@ -381,6 +390,128 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { Ok(out) } +/// Insert Copied Cells / Insert Cut Cells: makes room at the destination, then pastes (or, for a +/// cut, moves) the clipboard there, as one undo step. +fn insert_copied_cells(s: &mut Session, p: &Json) -> Result { + let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + let at = match str_param(p, "at") { + Some(t) => cell_param(p, "at").ok_or_else(|| bad("home.insertCopiedCells", format!("not a cell: {t}")))?, + None => d.selection.current().start, + }; + let shift = str_param(p, "shift").unwrap_or("down"); + if shift != "down" && shift != "right" { + return Err(bad("home.insertCopiedCells", format!("unknown `shift`: {shift} (down or right)"))); + } + let sel = clip.selected; + let rows = sel.is_full_rows(); + let cols = sel.is_full_cols(); + if rows && cols { + return Err(EngineError::Other("The copy area is the whole sheet; there is no room to insert it.".into())); + } + let same_doc = d.uid == clip.doc_uid; + let cut = clip.cut && same_doc; + if cut && !rows && !cols { + return Err(EngineError::Other("Insert Cut Cells works on whole rows or columns; cut whole rows or columns, or use Paste.".into())); + } + // A moved block can't be inserted inside itself. + if cut && clip.sheet == sheet { + let inside = if rows { at.row > sel.start.row && at.row <= sel.end.row } else { at.col > sel.start.col && at.col <= sel.end.col }; + if inside { + return Err(EngineError::Other("The cut rows or columns can't be inserted inside themselves.".into())); + } + } + let revision = d.revision; + let before = (d.wb.clone(), d.selection.clone()); + let result = insert_copied_steps(s, &clip, sheet, at, shift, cut); + let d = s.doc_mut()?; + // The nested steps collapse into this command's single undo step. + for _ in 0..d.revision.saturating_sub(revision) { + d.undo.pop(); + } + match result { + Ok((final_range, out)) => { + d.selection = crate::Selection { active: final_range.start, anchor: final_range.start, ranges: vec![final_range] }; + // Like inserting cells, this ends copy mode (the copied block may have moved). + cancel_copy_mode(s); + Ok(out) + } + Err(e) => { + d.wb = before.0; + d.selection = before.1; + d.calc.rebuild(&d.wb); + s.clipboard = Some(clip); + Err(e) + } + } +} + +fn insert_copied_steps(s: &mut Session, clip: &crate::Clipboard, sheet: usize, at: CellRef, shift: &str, cut: bool) -> Result<(RangeRef, Json)> { + let sel = clip.selected; + let rows = sel.is_full_rows(); + let cols = sel.is_full_cols(); + // Where the copied block's top-left lands, relative to the (untrimmed) copied lines. + let (inserted, paste_at) = if rows { + let h = sel.height(); + let start = at.row; + let r = format!("{}:{}", start as u64 + 1, start as u64 + h as u64); + s.execute("home.insertRows", json!({"rows": r, "sheet": sheet}))?; + let dr = clip.range.start.row.saturating_sub(sel.start.row); + (RangeRef::rows(start, start.saturating_add(h - 1).min(MAX_ROWS - 1)), CellRef::new(start.saturating_add(dr), clip.range.start.col)) + } else if cols { + let w = sel.width(); + let start = at.col; + let end = start.saturating_add(w - 1).min(MAX_COLS - 1); + s.execute("home.insertColumns", json!({"cols": RangeRef::cols(start, end).a1(), "sheet": sheet}))?; + let dc = clip.range.start.col.saturating_sub(sel.start.col); + (RangeRef::cols(start, end), CellRef::new(clip.range.start.row, start.saturating_add(dc))) + } else { + let end = at + .offset(clip.range.height() as i64 - 1, clip.range.width() as i64 - 1) + .ok_or_else(|| EngineError::Other("The copied cells don't fit on the sheet at this position.".into()))?; + let block = RangeRef::new(at, end); + s.execute("home.insertCells", json!({"range": block.a1(), "shift": shift, "sheet": sheet}))?; + (block, at) + }; + // Inserting ended copy mode; bring the clipboard back for the paste. A cut reads the live + // workbook, so it follows the source if the insert pushed it along. + let mut clip = clip.clone(); + if cut { + let pushed = clip.sheet == sheet && if rows { sel.start.row >= at.row } else { sel.start.col >= at.col }; + if pushed { + let (dr, dc) = if rows { (inserted.height() as i64, 0) } else { (0, inserted.width() as i64) }; + clip.range = RangeRef::new(clip.range.start.offset_clamped(dr, dc), clip.range.end.offset_clamped(dr, dc)); + clip.selected = RangeRef::new(sel.start.offset_clamped(dr, dc), sel.end.offset_clamped(dr, dc)); + } + clip.wb = s.doc()?.wb.clone(); + } + let source_lines = clip.selected; + s.clipboard = Some(clip.clone()); + let out = s.execute("edit.pasteSpecial", json!({"what": "all", "at": paste_at.a1(), "sheet": sheet}))?; + if !cut { + return Ok((inserted, out)); + } + // Close the gap the moved lines left behind. + let (removed, before_dest) = if rows { + s.execute( + "home.deleteRows", + json!({"rows": format!("{}:{}", source_lines.start.row as u64 + 1, source_lines.end.row as u64 + 1), "sheet": clip.sheet}), + )?; + (source_lines.height(), clip.sheet == sheet && source_lines.start.row < inserted.start.row) + } else { + s.execute("home.deleteColumns", json!({"cols": RangeRef::cols(source_lines.start.col, source_lines.end.col).a1(), "sheet": clip.sheet}))?; + (source_lines.width(), clip.sheet == sheet && source_lines.start.col < inserted.start.col) + }; + let moved = if before_dest { + let (dr, dc) = if rows { (-(removed as i64), 0) } else { (0, -(removed as i64)) }; + RangeRef::new(inserted.start.offset_clamped(dr, dc), inserted.end.offset_clamped(dr, dc)) + } else { + inserted + }; + Ok((moved, out)) +} + fn insert_sheet(s: &mut Session, p: &Json) -> Result { if s.doc()?.wb.protected_structure { return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de786a1f4..e57650927 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -65,6 +65,9 @@ pub struct Clipboard { pub wb: Arc, pub sheet: usize, pub range: RangeRef, + /// The range as copied, before whole rows/columns were trimmed to the used range + /// (Insert Copied Cells inserts whole lines for a row or column copy). + pub selected: RangeRef, pub cut: bool, /// Document uid of the source (a cut only moves within the same workbook). pub doc_uid: u64, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55e..437e7f0af 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -417,6 +417,71 @@ fn insert_delete_rows_adjust() { assert_eq!(v(&s, "A1"), Value::Number(10.0)); } +#[test] +fn insert_copied_cells_inserts_rows_columns_and_blocks() { + // Issue #21: Insert Copied Cells. + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "=A2*10"})).unwrap(); + s.execute("edit.copy", json!({"range": "2:2"})).unwrap(); + s.execute("selection.set", json!({"range": "C4"})).unwrap(); + s.execute("home.insertCopiedCells", json!({})).unwrap(); + assert_eq!(v(&s, "A4"), Value::Number(2.0)); + assert_eq!(v(&s, "B4"), Value::Number(20.0), "the formula follows its new row"); + assert_eq!(v(&s, "A5"), Value::Number(4.0)); + assert_eq!(v(&s, "A6"), Value::Number(5.0)); + assert_eq!(s.doc().unwrap().selection.current(), gridcraft_core::RangeRef::rows(3, 3)); + // One undo step puts everything back. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A4"), Value::Number(4.0)); + assert_eq!(v(&s, "A6"), Value::Empty); + // Whole columns. + s.execute("edit.copy", json!({"range": "A:A"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "A1"})).unwrap(); + assert_eq!(v(&s, "A3"), Value::Number(3.0)); + assert_eq!(v(&s, "B3"), Value::Number(3.0)); + assert_eq!(v(&s, "C2"), Value::Number(20.0), "the old column B moved right"); + // A block shifts cells right. + s.execute("cell.set", json!({"cell": "F1", "input": "x"})).unwrap(); + s.execute("cell.set", json!({"cell": "G1", "input": "y"})).unwrap(); + s.execute("edit.copy", json!({"range": "F1"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "G1", "shift": "right"})).unwrap(); + assert_eq!(v(&s, "G1"), Value::from("x")); + assert_eq!(v(&s, "H1"), Value::from("y")); + assert!(s.clipboard.is_none(), "inserting ends copy mode"); + s.execute("edit.copy", json!({"range": "F1"})).unwrap(); + assert!(s.execute("home.insertCopiedCells", json!({"shift": "up"})).is_err()); +} + +#[test] +fn insert_cut_cells_moves_whole_rows() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=A5"})).unwrap(); + // Up: row 5 goes above row 2. + s.execute("edit.cut", json!({"range": "5:5"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "A2"})).unwrap(); + let col: Vec = (1..=6).map(|r| v(&s, &format!("A{r}"))).collect(); + assert_eq!(col, [1.0, 5.0, 2.0, 3.0, 4.0].map(Value::Number).into_iter().chain([Value::Empty]).collect::>()); + assert_eq!(v(&s, "C1"), Value::Number(5.0), "references follow the moved cells"); + assert!(s.clipboard.is_none()); + // Down: row 1 goes above row 4. + s.execute("edit.cut", json!({"range": "1:1"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "A4"})).unwrap(); + let col: Vec = (1..=5).map(|r| v(&s, &format!("A{r}"))).collect(); + assert_eq!(col, [5.0, 2.0, 1.0, 3.0, 4.0].map(Value::Number).to_vec()); + assert_eq!(s.doc().unwrap().selection.current(), gridcraft_core::RangeRef::rows(2, 2)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); + // Inside itself, and a cut block, are refused without changing anything. + s.execute("edit.cut", json!({"range": "2:4"})).unwrap(); + assert!(s.execute("home.insertCopiedCells", json!({"at": "A3"})).is_err()); + s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); + assert!(s.execute("home.insertCopiedCells", json!({"at": "B5"})).is_err()); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); + assert_eq!(v(&s, "B5"), Value::Empty); +} + #[test] fn inserting_or_deleting_cells_cancels_copy_mode() { let mut s = s(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb10..3290fb024 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -89,6 +89,13 @@ impl Dialog { }], json!({"shift": "down"}), ), + "insertPaste" => Dialog::form( + "insertPaste", + "Insert Paste", + "home.insertCopiedCells", + vec![Choice { key: "shift", label: "Insert", options: vec![("right", "Shift cells right"), ("down", "Shift cells down")] }], + json!({"shift": "down"}), + ), "deleteCells" => Dialog::form( "deleteCells", "Delete", diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..6bf96ebc1 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1864,7 +1864,8 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { let resp = egui::Area::new(egui::Id::new("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(200.0); - let items: &[(&str, &str)] = &[ + let insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = vec![ ("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste"), @@ -1886,15 +1887,20 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { ("Define Name…", "ui:defineName"), ("Link…", "ui:insertLink"), ]; + if let Some(item) = insert_copied { + items.insert(6, item); + } + let lang = crate::i18n::current(ui.ctx()); for (label, id) in items { - if *label == "-" { + if label == "-" { ui.separator(); continue; } - if ui.add(egui::Button::new(*label).frame(false).min_size(vec2(190.0, 20.0))).clicked() { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { close = true; - match *id { + match id { "ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})), + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), "ui:insertCells" => app.open_dialog("insertCells", json!({})), "ui:deleteCells" => app.open_dialog("deleteCells", json!({})), "ui:formatCells" => app.open_dialog("formatCells", json!({})), @@ -2097,7 +2103,8 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let response = egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); - let items: Vec<(&str, &str)> = if rows { + let insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = if rows { vec![ ("Cut", "edit.cut"), ("Copy", "edit.copy"), @@ -2131,14 +2138,19 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { ("Group", "data.group"), ] }; + if let Some(item) = insert_copied { + items.insert(5, item); + } + let lang = crate::i18n::current(ui.ctx()); for (label, id) in items { if label == "-" { ui.separator(); continue; } - if ui.add(egui::Button::new(label).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { close = true; match id { + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), "edit.copy" | "edit.cut" => { @@ -2155,6 +2167,21 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { } } +/// The context-menu entry for Insert Copied Cells / Insert Cut Cells, when the clipboard holds a +/// copy or cut of this app: whole rows or columns insert at once, a block asks which way to shift +/// (Insert Paste). A cut block can only be pasted, so it gets no entry. +fn insert_copied_item(app: &SheetApp) -> Option<(&'static str, &'static str)> { + let clip = app.session.clipboard.as_ref()?; + let lines = clip.selected.is_full_rows() != clip.selected.is_full_cols(); + let cut = clip.cut && app.session.active().is_some_and(|d| d.uid == clip.doc_uid); + match (cut, lines) { + (true, true) => Some(("Insert Cut Cells", "home.insertCopiedCells")), + (true, false) => None, + (false, true) => Some(("Insert Copied Cells", "home.insertCopiedCells")), + (false, false) => Some(("Insert Copied Cells…", "ui:insertPaste")), + } +} + fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool { let Some(d) = app.session.active() else { return false }; d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str() == "checkbox" diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..52ed7e574 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -157,6 +157,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Transpose", "行列を入れ替え"), ("Paste Link", "リンクの貼り付け"), ("Paste Special…", "形式を選択して貼り付け…"), + ("Insert Copied Cells", "コピーしたセルの挿入"), + ("Insert Copied Cells…", "コピーしたセルの挿入…"), + ("Insert Cut Cells", "切り取ったセルの挿入"), + ("Insert Paste", "挿入貼り付け"), ("Cut (⌘X)", "切り取り (⌘X)"), ("Copy (⌘C)", "コピー (⌘C)"), ("Format Painter (double-click to keep it on)", "書式のコピー/貼り付け (ダブルクリックで固定)"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3cab..75aa91794 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -9,6 +9,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "행/열 바꾸기", "Paste Link" => "연결하여 붙여넣기", "Paste Special…" => "선택하여 붙여넣기…", + "Insert Copied Cells" => "복사한 셀 삽입", + "Insert Copied Cells…" => "복사한 셀 삽입…", + "Insert Cut Cells" => "잘라낸 셀 삽입", + "Insert Paste" => "삽입 붙여넣기", "Cut (⌘X)" => "잘라내기 (⌘X)", "Copy (⌘C)" => "복사 (⌘C)", "Format Painter (double-click to keep it on)" => "서식 복사 (두 번 클릭하여 유지)", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3f2..44f276206 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -10,6 +10,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Transpor", "Paste Link" => "Colar Vínculo", "Paste Special…" => "Colar Especial…", + "Insert Copied Cells" => "Inserir Células Copiadas", + "Insert Copied Cells…" => "Inserir Células Copiadas…", + "Insert Cut Cells" => "Inserir Células Recortadas", + "Insert Paste" => "Inserir Colagem", "Cut (⌘X)" => "Recortar (⌘X)", "Copy (⌘C)" => "Copiar (⌘C)", "Format Painter (double-click to keep it on)" => "Pincel de Formatação (clique duas vezes para mantê-lo ativo)", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d5485658..4fcbfdfe5 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -9,6 +9,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Транспонировать", "Paste Link" => "Вставить связь", "Paste Special…" => "Специальная вставка…", + "Insert Copied Cells" => "Вставить скопированные ячейки", + "Insert Copied Cells…" => "Вставить скопированные ячейки…", + "Insert Cut Cells" => "Вставить вырезанные ячейки", + "Insert Paste" => "Вставка со сдвигом", "Cut (⌘X)" => "Вырезать (⌘X)", "Copy (⌘C)" => "Копировать (⌘C)", "Format Painter (double-click to keep it on)" => "Формат по образцу (дважды щёлкните, чтобы закрепить)", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada73..25e61c7f0 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -9,6 +9,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "转置", "Paste Link" => "粘贴链接", "Paste Special…" => "选择性粘贴…", + "Insert Copied Cells" => "插入复制的单元格", + "Insert Copied Cells…" => "插入复制的单元格…", + "Insert Cut Cells" => "插入剪切的单元格", + "Insert Paste" => "插入粘贴", "Cut (⌘X)" => "剪切 (⌘X)", "Copy (⌘C)" => "复制 (⌘C)", "Format Painter (double-click to keep it on)" => "格式刷(双击可持续使用)", diff --git a/crates/ui-egui/tests/insert_copied_cells.rs b/crates/ui-egui/tests/insert_copied_cells.rs new file mode 100644 index 000000000..89621f21b --- /dev/null +++ b/crates/ui-egui/tests/insert_copied_cells.rs @@ -0,0 +1,60 @@ +//! The context menus offer Insert Copied Cells while the clipboard holds a copy (#21). + +use egui::{Event, Modifiers, PointerButton, pos2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4]]})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn right_click_row_header(h: &mut Harness, cell: &str) { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let geo = grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + let y = geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center().y; + let pos = pos2((geo.rect.left() + geo.cells.left()) / 2.0, y); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn row_header_menu_inserts_copied_rows() { + let mut h = harness(); + // Nothing copied: no entry. + right_click_row_header(&mut h, "A3"); + assert!(h.state().grid.header_menu.is_some()); + assert!(h.query_by_label("Insert Copied Cells").is_none()); + h.state_mut().grid.header_menu = None; + h.run_steps(2); + + h.state_mut().run("edit.copy", json!({"range": "1:1"})).unwrap(); + right_click_row_header(&mut h, "A3"); + h.get_by_label("Insert Copied Cells").click(); + h.run_steps(2); + assert_eq!(value(&h, "A3"), Value::Number(1.0)); + assert_eq!(value(&h, "A4"), Value::Number(3.0)); + assert_eq!(value(&h, "A5"), Value::Number(4.0)); +} diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8d8..0258393b3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -77,7 +77,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Estimate:** 20–30 h for beta (25–40 h full). ### 9. Keyboard-first users: key tips, shortcuts, context menus (U) -- **Evidence:** #33, #49 (key tips: initial Home-tab support landed in #43 on 10-10, the rest of the ribbon not yet), #110, #21 (Insert Copied Cells), #28 (selection), +- **Evidence:** #33, #49 (key tips: initial Home-tab support landed in #43 on 10-10, the rest of the ribbon not yet), #110, #28 (selection), 59 command shortcuts vs Excel's 200+. - **Estimate:** 12–20 h. [ui-parity.md](ui-parity.md) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2e5ed01cd..628b88253 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -81,7 +81,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 |---|---|---|---|---|---| | Formula language and calculation | 18 | ~92% | ~72% | 20–30 | A1/R1C1 parser, reference adjustment, dependency-graph recalc, dynamic arrays and `#SPILL!`, `A1#`, LET/LAMBDA and helpers, structured references, names, 3-D references. Open correctness bugs: 15-digit equality (#57), stale spills after structural edits and cleared blockers (#133, #139), 3-D span endpoint deletion (#140), sheet rename and qualified names (#134). Single-threaded | | Worksheet functions | 12 | 95.8% (measured) | ~82% | 18–30 | 501 / 523 names, 492 working. Missing: FORECAST.ETS family, ODDF*/ODDL*, GETPIVOTDATA, TRIMRANGE, PERCENTOF, BAHTTEXT, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) | -| 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) | +| 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), 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, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..4ce36332d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -40,7 +40,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | | Freeze panes, split panes | freeze done; split to verify | | Copy as HTML table for other apps | done (#36, 10-10) | -| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Missing: Insert Copied/Cut Cells (#21), paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | +| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | charts can't be resized (#190) | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | From 022aba41a8c8a58275d998c7de22fcf456ae4b55 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:14:16 +0200 Subject: [PATCH 18/54] UI fonts: symbol fallback faces so interface symbols aren't boxes (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI fonts such as Noto Sans, Cantarell and egui's built-in Ubuntu lack some of the symbols the interface draws as text (▸ ⌃ ✓ ✕ ▲ ▼ arrows), so they rendered as empty boxes. Two symbol faces now sit at the end of every font chain: DejaVu Sans (Debian, Fedora and Arch paths; Segoe UI Symbol on Windows, Apple Symbols on macOS) and Noto Sans Symbols 2. The UI font candidates also gain Fedora's package folders (#54). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/theme.rs | 81 ++++++++++++++++++++++++++- crates/ui-egui/tests/symbol_glyphs.rs | 29 ++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/symbol_glyphs.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..56a67b296 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Interface symbols (▸ ⌃ ✓ ✕ arrows) no longer show as boxes on Linux: symbol fallback faces and Fedora font paths (#197, #54) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 219e2d289..38b895666 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -207,6 +207,11 @@ fn candidates(role: &str) -> Vec<(String, u32)> { add(format!("{d}/dejavu/DejaVuSans.ttf"), 0); add(format!("{d}/liberation/LiberationSans-Regular.ttf"), 0); } + // Fedora keeps each family in its own package folder (#54). + add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0); + add("/usr/share/fonts/google-noto/NotoSans-Regular.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf".into(), 0); } "ui-bold" => { add(format!("{mac_sys}/HelveticaNeue.ttc"), 1); @@ -216,6 +221,9 @@ fn candidates(role: &str) -> Vec<(String, u32)> { add(format!("{d}/dejavu/DejaVuSans-Bold.ttf"), 0); add(format!("{d}/liberation/LiberationSans-Bold.ttf"), 0); } + add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf".into(), 0); } "cell" | "cell-bold" | "cell-italic" | "cell-bold-italic" => { let (carlito, calibri_win, calibri_mac, arial_mac, arial_win, lib, dejavu) = match role { @@ -388,7 +396,15 @@ fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { HanOrder::Japanese => (ja, zh), HanOrder::Chinese => (zh, ja), }; - vec![first, second, ("sys-cjk-hangul", cjk_hangul()), ("sys-thai", thai()), ("sys-hebrew", hebrew())] + vec![ + first, + second, + ("sys-cjk-hangul", cjk_hangul()), + ("sys-thai", thai()), + ("sys-hebrew", hebrew()), + ("sys-symbols", symbols()), + ("sys-symbols-2", symbols_more()), + ] } /// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, @@ -513,6 +529,59 @@ fn hebrew() -> Vec<(String, u32)> { v } +/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's +/// built-in Ubuntu) lack arrows, check marks, geometric shapes and keyboard symbols (▾ ✓ ✕ ▲ ↳ ⇧), +/// which then render as empty boxes (#197, #54). DejaVu Sans covers most of them; the paths cover +/// Debian/Ubuntu, Fedora and Arch layouts. [`load_fallback`] skips the file if Hebrew already +/// loaded it. +#[cfg(not(target_arch = "wasm32"))] +fn symbols() -> Vec<(String, u32)> { + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + let mut v: Vec<(String, u32)> = vec![(format!("{win}\\seguisym.ttf"), 0), ("/System/Library/Fonts/Apple Symbols.ttf".into(), 0)]; + for d in [ + "/usr/share/fonts/truetype/dejavu", + "/usr/share/fonts/dejavu-sans-fonts", + "/usr/share/fonts/TTF", + "/usr/share/fonts/dejavu", + "/usr/share/fonts/truetype/ttf-dejavu", + "/usr/local/share/fonts/dejavu", + "/usr/local/share/fonts", + ] { + v.push((format!("{d}/DejaVuSans.ttf"), 0)); + } + v +} + +/// A second symbol face for what the first lacks (⌘ and other technical symbols): Noto Sans +/// Symbols 2 where it is installed, or Segoe UI Symbol behind Segoe UI. +#[cfg(not(target_arch = "wasm32"))] +fn symbols_more() -> Vec<(String, u32)> { + let mut v: Vec<(String, u32)> = Vec::new(); + for d in [ + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/google-noto-sans-symbols-2-fonts", + "/usr/share/fonts/google-noto", + "/usr/local/share/fonts/noto", + ] { + v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); + } + v +} + +/// Whether any symbol fallback face is installed (tests skip their glyph checks without one). +pub fn symbol_face_installed() -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + symbols().iter().chain(symbols_more().iter()).any(|(p, _)| std::path::Path::new(p).exists()) + } + #[cfg(target_arch = "wasm32")] + { + false + } +} + pub fn apply(ctx: &egui::Context, dark: bool) { // Install both palettes before System can switch between the style slots. for theme in [egui::Theme::Light, egui::Theme::Dark] { @@ -584,8 +653,14 @@ mod tests { #[test] fn language_reorders_only_the_han_faces() { let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); - assert_eq!(keys(HanOrder::Japanese), ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); - assert_eq!(keys(HanOrder::Chinese), ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); + assert_eq!( + keys(HanOrder::Japanese), + ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); + assert_eq!( + keys(HanOrder::Chinese), + ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr] { assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); diff --git a/crates/ui-egui/tests/symbol_glyphs.rs b/crates/ui-egui/tests/symbol_glyphs.rs new file mode 100644 index 000000000..5e98daeef --- /dev/null +++ b/crates/ui-egui/tests/symbol_glyphs.rs @@ -0,0 +1,29 @@ +//! The interface's symbol characters must not render as empty boxes (#197, #54): the UI font often +//! lacks arrows, check marks and geometric shapes, so a symbol face backs it up. + +use egui::{Color32, FontFamily, FontId}; +use gridcraft_ui_egui::theme; + +/// Symbols the interface draws as text (buttons, menus, dialogs, tooltips, Insert › Symbol). +const UI_SYMBOLS: &str = "▲▼▸▾✓✕←↑→↓↳↘↗⇧⌃⌘★•›≠≤≥∞"; + +#[test] +fn ui_symbols_have_glyphs_when_a_symbol_face_is_installed() { + if !theme::symbol_face_installed() { + return; // a minimal machine without any symbol face; nothing to assert + } + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test + for family in [FontFamily::Proportional, FontFamily::Name(theme::CELL.into())] { + let font = FontId::new(14.0, family); + let glyphs = |text: &str| { + let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK)); + galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.chr, g.uv_rect))).collect::>() + }; + let tofu: Vec<_> = glyphs("\u{25FB}").into_iter().map(|(_, uv)| uv).collect(); + let missing: String = glyphs(UI_SYMBOLS).into_iter().filter(|(_, uv)| tofu.contains(uv)).map(|(c, _)| c).collect(); + assert!(missing.is_empty(), "these interface symbols render as boxes in {:?}: {missing}", font.family); + } +} From c2d2f22e6a0443aaab53c6c4a812f416acae7243 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:54:30 +0200 Subject: [PATCH 19/54] Data: Forecast Sheet (data.forecastSheet) (#322) New sheet before the active one with the sorted history, a FORECAST.ETS forecast column, FORECAST.ETS.CONFINT lower/upper bounds, an optional FORECAST.ETS.STAT table, an Excel table over the data and a line or column chart. Parameters: timeline/values (default: the selection's first two columns), end or periods, confidence interval, seasonality, missing-point fill, duplicate aggregation, statistics, chart kind. Data ribbon button opens a form dialog; programmatic calls never do. gridcraft-functions exports Timeline to continue a timeline (numeric step or calendar months). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 6 +- crates/engine/src/cmd/forecast.rs | 494 ++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/functions/src/ets.rs | 73 ++++- crates/functions/src/lib.rs | 1 + crates/ui-egui/src/dialogs.rs | 34 ++ crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ko.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/zh.rs | 1 + crates/ui-egui/src/ribbon.rs | 3 + docs/gaps.md | 7 +- docs/parity-checklist.md | 8 +- docs/ui-parity.md | 9 +- 15 files changed, 628 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/cmd/forecast.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6608f1d66..e636acc0a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (worksheet functions 505 / 523) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (worksheet functions 505 / 523; catalog 259 / 290) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 505 / 523 (96.6%; 496 working, 94.8%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 259 / 290 (89.3%), worksheet functions 505 / 523 (96.6%; 496 working, 94.8%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | | 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | @@ -143,6 +144,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Catalog 259 / 290 (Forecast Sheet); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (FORECAST.ETS family); progress log row | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs new file mode 100644 index 000000000..55ed453c7 --- /dev/null +++ b/crates/engine/src/cmd/forecast.rs @@ -0,0 +1,494 @@ +//! Data › Forecast › Forecast Sheet: a new sheet with the history, a FORECAST.ETS column, the +//! FORECAST.ETS.CONFINT confidence bounds, optional FORECAST.ETS.STAT statistics and a chart. +//! +//! Like Excel, the sheet holds a copy of the timeline and values (sorted by time) and live +//! formulas over that copy, so the forecast follows edits to the copied history. + +use std::sync::Arc; + +use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_functions::Timeline; +use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn}; +use serde_json::{Value as Json, json}; + +use super::*; +use crate::selection::current_region; + +const CMD: &str = "data.forecastSheet"; +/// Most history rows a forecast sheet copies. +const MAX_HISTORY: u64 = 100_000; +/// Most forecast rows. +const MAX_PERIODS: u32 = 10_000; + +pub fn specs() -> Vec { + vec![cmd!( + "data.forecastSheet", + "Forecast Sheet", + ["Data", "Forecast"], + None, + "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", + has_doc, + forecast_sheet + )] +} + +/// The cells of a one-row or one-column range, at most `MAX_HISTORY + 1`. +fn line_cells(r: RangeRef) -> Result> { + if r.count() > MAX_HISTORY + 1 { + return Err(bad(CMD, format!("at most {MAX_HISTORY} rows of history"))); + } + if r.width() == 1 { + Ok((r.start.row..=r.end.row).map(|row| CellRef::new(row, r.start.col)).collect()) + } else if r.height() == 1 { + Ok((r.start.col..=r.end.col).map(|col| CellRef::new(r.start.row, col)).collect()) + } else { + Err(bad(CMD, "the timeline and the values are each one row or one column")) + } +} + +/// A range parameter on any sheet (whole columns trimmed to the used range). +fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> { + let d = s.doc()?; + let (sh, body) = split_sheet(t); + let si = match sh { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?, + None => d.wb.active_sheet, + }; + let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?; + let r = if r.is_full_cols() || r.is_full_rows() { + match d.wb.sheet(si).and_then(|s| s.used_range()) { + Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)), + None => RangeRef::cell(r.start), + } + } else { + r + }; + Ok((si, r)) +} + +/// (sheet, timeline cells, value cells). +fn source(s: &Session, p: &Json) -> Result<(usize, Vec, Vec)> { + let text = |k: &str| str_param(p, k).map(str::trim).filter(|t| !t.is_empty()); + match (text("timeline"), text("values")) { + (Some(t), Some(v)) => { + let (ts, tr) = range_on(s, t)?; + let (vs, vr) = range_on(s, v)?; + if ts != vs { + return Err(bad(CMD, "the timeline and the values must be on one sheet")); + } + Ok((ts, line_cells(tr)?, line_cells(vr)?)) + } + (None, None) => { + let d = s.doc()?; + let (si, r) = match text("range") { + Some(t) => range_on(s, t)?, + None => { + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let sel = d.selection.current(); + (d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel }) + } + }; + if r.width() < 2 { + return Err(EngineError::Other("Select a timeline column and a values column for the forecast.".into())); + } + let col = |c: u32| RangeRef::new(CellRef::new(r.start.row, c), CellRef::new(r.end.row, c)); + Ok((si, line_cells(col(r.start.col))?, line_cells(col(r.start.col + 1))?)) + } + _ => Err(bad(CMD, "give both `timeline` and `values`, or neither")), + } +} + +/// A number parameter given as a number or as text. +fn number(p: &Json, key: &str) -> Result> { + match p.get(key) { + None | Some(Json::Null) => Ok(None), + Some(Json::Number(n)) => Ok(n.as_f64()), + Some(Json::String(t)) if t.trim().is_empty() => Ok(None), + Some(Json::String(t)) => { + t.trim().trim_end_matches('%').trim().parse::().map(Some).map_err(|_| bad(CMD, format!("`{key}` is not a number"))) + } + Some(_) => Err(bad(CMD, format!("`{key}` is not a number"))), + } +} + +struct Options { + seasonality: u32, + fill: u8, + aggregate: Option, + confidence: Option, + statistics: bool, + column_chart: bool, +} + +fn options(p: &Json) -> Result { + let seasonality = match p.get("seasonality") { + Some(Json::String(t)) if t.trim().is_empty() || t.trim().eq_ignore_ascii_case("auto") => 1, + None | Some(Json::Null) => 1, + _ => match number(p, "seasonality")? { + Some(n) if n == n.trunc() && (0.0..=8760.0).contains(&n) => n as u32, + _ => return Err(bad(CMD, "`seasonality` is \"auto\", 0 (none) or a season length from 2 to 8,760")), + }, + }; + let fill = match str_param(p, "fillMissing").map(|t| t.trim().to_ascii_lowercase()) { + None => 1, + Some(t) if t.is_empty() || t == "interpolate" || t == "interpolation" => 1, + Some(t) if t == "zeros" || t == "zero" => 0, + Some(t) => return Err(bad(CMD, format!("`fillMissing` is interpolate or zeros, not `{t}`"))), + }; + let aggregate = match str_param(p, "aggregate").map(|t| t.trim().to_ascii_lowercase()) { + None => None, + Some(t) => match t.as_str() { + "" | "average" => None, + "count" => Some(2), + "counta" => Some(3), + "max" => Some(4), + "median" => Some(5), + "min" => Some(6), + "sum" => Some(7), + _ => return Err(bad(CMD, format!("unknown `aggregate`: `{t}`"))), + }, + }; + let confidence = if bool_param(p, "confidenceInterval").unwrap_or(true) { + let c = number(p, "confidence")?.unwrap_or(0.95); + let c = if c >= 1.0 { c / 100.0 } else { c }; + if !(c > 0.0 && c < 1.0) { + return Err(bad(CMD, "`confidence` is between 0 and 1 (or 0 and 100%)")); + } + Some(c) + } else { + None + }; + let column_chart = match str_param(p, "chart").map(|t| t.trim().to_ascii_lowercase()) { + None => false, + Some(t) if t.is_empty() || t == "line" => false, + Some(t) if t == "column" => true, + Some(t) => return Err(bad(CMD, format!("`chart` is line or column, not `{t}`"))), + }; + Ok(Options { seasonality, fill, aggregate, confidence, statistics: bool_param(p, "statistics").unwrap_or(false), column_chart }) +} + +fn col_letters(c: u32) -> String { + gridcraft_core::col_to_letters(c) +} + +fn forecast_sheet(s: &mut Session, p: &Json) -> Result { + let opts = options(p)?; + let (si, tcells, vcells) = source(s, p)?; + if tcells.len() != vcells.len() { + return Err(bad(CMD, "the timeline and the values need the same number of cells")); + } + let d = s.doc()?; + if d.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } + let sys = d.wb.date_system; + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + // A text first cell is the header. + let header = tcells.first().is_some_and(|c| sh.value(*c).is_text()) || vcells.first().is_some_and(|c| sh.value(*c).is_text()); + let name_of = |c: Option<&CellRef>, fallback: &str| match c.map(|c| sh.value(*c)) { + Some(v) if header && !v.is_empty() => v.display(), + _ => fallback.to_string(), + }; + let (tname, vname) = (name_of(tcells.first(), "Timeline"), name_of(vcells.first(), "Values")); + let skip = usize::from(header); + let mut rows: Vec<(f64, Value)> = Vec::new(); + for (t, v) in tcells.iter().zip(&vcells).skip(skip) { + let (tv, vv) = (sh.value(*t), sh.value(*v)); + match tv { + Value::Number(x) => rows.push((x, if matches!(vv, Value::Number(_)) { vv } else { Value::Empty })), + Value::Empty if vv.is_empty() => {} + _ => return Err(EngineError::Other(format!("The timeline must hold numbers or dates ({} isn't one).", t.a1()))), + } + } + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + let times: Vec = rows.iter().map(|r| r.0).collect(); + let timeline = + Timeline::detect(×, sys).ok_or_else(|| EngineError::Other("The timeline needs at least two points a constant step apart.".into()))?; + if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) { + return Err(EngineError::Other("There are no values to forecast.".into())); + } + let end = match p.get("end") { + Some(Json::Number(n)) => n.as_f64(), + Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { + Value::Number(end) => Some(end), + _ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))), + }, + _ => None, + }; + let periods = match end { + Some(end) => steps_to(&timeline, end)?, + None => match number(p, "periods")? { + Some(n) if n >= 1.0 && n <= f64::from(MAX_PERIODS) => n.ceil() as u32, + Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {MAX_PERIODS}"))), + None => (rows.len() as u32).div_ceil(3).max(1), + }, + }; + let future: Vec = + (1..=periods).map(|k| timeline.after_last(k)).collect::>().ok_or_else(|| bad(CMD, "the forecast runs past the last date"))?; + let fmt_of = |c: Option<&CellRef>| c.map(|c| d.wb.styles.get(sh.style_id(*c)).num_fmt.clone()).unwrap_or_default(); + let (tfmt, vfmt) = (fmt_of(tcells.get(skip)), fmt_of(vcells.get(skip))); + let sheet_name = d.wb.next_sheet_name(); + let table_name = (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()); + let chart_id = d.wb.next_object_id(); + let table_id = chart_id.saturating_add(1); + + // Columns: A timeline, B values, C forecast, D/E bounds. + let n = rows.len() as u32; + let last = n + 1; // 1-based row of the last history point + let end_row = last + periods; // 1-based row of the last forecast + let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col)); + let mut tail = format!("{},{}", opts.seasonality, opts.fill); + if let Some(a) = opts.aggregate { + tail.push_str(&format!(",{a}")); + } + let mut headers = vec![tname, vname.clone(), format!("Forecast({vname})")]; + if opts.confidence.is_some() { + headers.push(format!("Lower Confidence Bound({vname})")); + headers.push(format!("Upper Confidence Bound({vname})")); + } + // Table column names are unique. + for i in 1..headers.len() { + let base = headers.get(i).cloned().unwrap_or_default(); + let mut name = base.clone(); + let mut k = 2; + while headers.iter().take(i).any(|h| h.eq_ignore_ascii_case(&name)) { + name = format!("{base}{k}"); + k += 1; + } + if let Some(h) = headers.get_mut(i) { + *h = name; + } + } + let width = headers.len() as u32; + let stats_col = width + 1; + let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 }; + + let at = s.doc()?.wb.active_sheet; + let index = edit(s, |cx| { + let at = at.min(cx.wb.sheets.len()); + let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true); + let tstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = tfmt.clone()); + let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone()); + let mut sheet = Sheet::new(sheet_name.clone()); + let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| { + sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style }); + }; + let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| { + let mut cell = Cell::formula(Formula::new(&text)); + cell.style = style; + sheet.set_cell(CellRef::new(row, col), cell); + }; + for (c, h) in headers.iter().enumerate() { + put(&mut sheet, 0, c as u32, Value::text(h.as_str()), StyleId::DEFAULT); + } + for (i, (t, v)) in rows.iter().enumerate() { + let r = i as u32 + 1; + put(&mut sheet, r, 0, Value::Number(*t), tstyle); + if !v.is_empty() { + put(&mut sheet, r, 1, v.clone(), vstyle); + } + } + // The last history point starts the forecast lines, so they join the values. + formula(&mut sheet, n, 2, format!("=B{last}"), vstyle); + if opts.confidence.is_some() { + formula(&mut sheet, n, 3, format!("=C{last}"), vstyle); + formula(&mut sheet, n, 4, format!("=C{last}"), vstyle); + } + let (tl, vl) = (hist(0), hist(1)); + for (k, t) in future.iter().enumerate() { + let r = last + k as u32 + 1; // 1-based + put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle); + formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle); + if let Some(c) = opts.confidence { + let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})"); + formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle); + formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle); + } + } + if opts.statistics { + put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold); + put(&mut sheet, 0, stats_col + 1, Value::text("Value"), bold); + for (k, label) in ["Alpha", "Beta", "Gamma", "MASE", "SMAPE", "MAE", "RMSE"].iter().enumerate() { + let r = k as u32 + 1; + put(&mut sheet, r, stats_col, Value::text(*label), StyleId::DEFAULT); + formula(&mut sheet, r, stats_col + 1, format!("=FORECAST.ETS.STAT({vl},{tl},{},{tail})", k + 1), StyleId::DEFAULT); + } + } + for c in 0..width { + sheet.cols.insert( + c, + gridcraft_model::LineInfo { + size: Some(match c { + 0 | 1 => 90.0, + 2 => 130.0, + _ => 230.0, + }), + ..Default::default() + }, + ); + } + let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(end_row - 1, width - 1)); + sheet.tables.push(Table { + id: table_id, + name: table_name.clone(), + range, + header_row: true, + totals_row: false, + columns: headers.iter().map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None }).collect(), + style: "TableStyleMedium2".into(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + }); + let q = gridcraft_formula::quote_sheet(&sheet_name); + let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c)); + let series = (1..width) + .map(|c| Series { + name: Some(format!("{q}!${}$1", col_letters(c))), + categories: Some(col_ref(0)), + values: col_ref(c), + bubble_sizes: None, + color: None, + secondary: false, + kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line), + smooth: false, + }) + .collect(); + sheet.charts.push(Chart { + id: chart_id, + kind: if opts.column_chart { ChartKind::ColumnClustered } else { ChartKind::Line }, + anchor: Anchor { cell: CellRef::new(1, chart_col), dx: 0.0, dy: 0.0, width: 480.0, height: 288.0, mode: Default::default() }, + title: None, + series, + legend: LegendPos::Bottom, + data_labels: false, + gridlines: true, + style: 1, + x_title: None, + y_title: None, + source: None, + by_rows: false, + }); + cx.wb.sheets.insert(at, Arc::new(sheet)); + for nm in cx.wb.names.iter_mut() { + if let Some(sc) = nm.scope.as_mut() + && *sc >= at + { + *sc += 1; + } + } + cx.wb.active_sheet = at; + *cx.sel = crate::Selection::at(CellRef::new(0, 0)); + cx.structural = true; + Ok(at) + })?; + if let Ok(d) = s.doc_mut() { + d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default()); + } + Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods})) +} + +/// Forecast rows needed to reach `end` (rounded up to whole steps). +fn steps_to(timeline: &Timeline, end: f64) -> Result { + let steps = timeline.steps_after_last(end).ok_or_else(|| bad(CMD, "`end` is not on the timeline"))?; + if steps < 1e-9 { + return Err(EngineError::Other("The forecast end must be after the last point of the timeline.".into())); + } + let steps = steps.ceil(); + if steps > f64::from(MAX_PERIODS) { + return Err(bad(CMD, format!("at most {MAX_PERIODS} forecast steps"))); + } + Ok(steps as u32) +} + +#[cfg(test)] +mod tests { + use gridcraft_core::{CellRef, Value}; + use serde_json::json; + + use crate::Session; + + fn session_with_history() -> Session { + let mut s = Session::new(); + s.new_workbook(); + let mut rows = vec![json!(["Month", "Sales"])]; + // y = 10 + 2t with a four-step season. + let season = [3.0, -1.0, 4.0, -6.0]; + for t in 1..=16 { + rows.push(json!([t, 10.0 + 2.0 * t as f64 + season[(t - 1) % 4]])); + } + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + s + } + + fn value(s: &Session, sheet: usize, a1: &str) -> Value { + s.doc().unwrap().wb.sheet(sheet).unwrap().value(CellRef::parse(a1).unwrap()) + } + + fn formula(s: &Session, sheet: usize, a1: &str) -> String { + let wb = &s.doc().unwrap().wb; + wb.sheet(sheet).unwrap().cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()).unwrap_or_default() + } + + #[test] + fn forecast_sheet_builds_table_formulas_and_chart() { + let mut s = session_with_history(); + s.execute("selection.set", json!({"range": "A1:B17"})).unwrap(); + let r = s.execute("data.forecastSheet", json!({"periods": 4, "statistics": true})).unwrap(); + assert_eq!(r["history"], 16); + assert_eq!(r["periods"], 4); + let wb = &s.doc().unwrap().wb; + // Inserted before the data sheet and activated. + assert_eq!(wb.active_sheet, 0); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.name, r["sheet"].as_str().unwrap()); + assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)")); + assert_eq!(value(&s, 0, "D1"), Value::text("Lower Confidence Bound(Sales)")); + assert_eq!(value(&s, 0, "E1"), Value::text("Upper Confidence Bound(Sales)")); + assert_eq!(formula(&s, 0, "C17"), "B17"); + assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)"); + assert_eq!(formula(&s, 0, "D18"), "C18-FORECAST.ETS.CONFINT(A18,$B$2:$B$17,$A$2:$A$17,0.95,1,1)"); + assert_eq!(formula(&s, 0, "H2"), "FORECAST.ETS.STAT($B$2:$B$17,$A$2:$A$17,1,1,1)"); + // Future timeline and the exact continuation of the seasonal series (t = 17 → 10 + 34 + 3). + assert_eq!(value(&s, 0, "A18"), Value::Number(17.0)); + assert_eq!(value(&s, 0, "A21"), Value::Number(20.0)); + let c18 = value(&s, 0, "C18").as_f64().unwrap(); + assert!((c18 - 47.0).abs() < 1e-6, "{c18}"); + let (lo, hi) = (value(&s, 0, "D19").as_f64().unwrap(), value(&s, 0, "E19").as_f64().unwrap()); + assert!(lo <= hi); + assert_eq!(sh.tables.first().map(|t| t.range.a1()), Some("A1:E21".into())); + let chart = sh.charts.first().unwrap(); + assert_eq!(chart.series.len(), 4); + assert_eq!(chart.series[1].values, format!("{}!$C$2:$C$21", r["sheet"].as_str().unwrap())); + // One undo step removes the sheet again. + let sheets = s.doc().unwrap().wb.sheets.len(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1); + } + + #[test] + fn forecast_sheet_parameters_and_errors() { + let mut s = session_with_history(); + let r = s + .execute( + "data.forecastSheet", + json!({"timeline": "A2:A17", "values": "B2:B17", "end": "22", "confidenceInterval": false, "seasonality": 4, "fillMissing": "zeros", "aggregate": "sum", "chart": "column"}), + ) + .unwrap(); + assert_eq!(r["periods"], 6); + assert_eq!(value(&s, 0, "B1"), Value::text("Values")); + assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,4,0,7)"); + assert_eq!(value(&s, 0, "D1"), Value::Empty); + let sh = s.doc().unwrap().wb.sheet(0).unwrap(); + assert_eq!(sh.charts.first().map(|c| c.kind), Some(gridcraft_model::ChartKind::ColumnClustered)); + // Errors: end before the data, bad seasonality, uneven timeline, one column only. + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "end": 3})).is_err()); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "seasonality": 9000})).is_err()); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!B1:B17"})).is_err()); + s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap(); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c4a290e47..d411f0b16 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -6,6 +6,7 @@ pub mod draw; pub mod edit; pub mod extra; pub mod file; +pub mod forecast; pub mod format; pub mod formulas; pub mod insert; @@ -120,6 +121,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(spelling::specs()); v.extend(pivot::specs()); v.extend(whatif::specs()); + v.extend(forecast::specs()); v.extend(print::specs()); v }) diff --git a/crates/functions/src/ets.rs b/crates/functions/src/ets.rs index 4649f4144..617b171ac 100644 --- a/crates/functions/src/ets.rs +++ b/crates/functions/src/ets.rs @@ -25,7 +25,7 @@ //! Prediction intervals use the model's h-step forecast variance (the book's Table 6.1, class 1): //! `σ²·(1 + Σ_{j=1}^{h−1} (α + αβ·j + γ·[j mod m = 0])²)`, σ² = SSE / n, with the normal quantile. -use gridcraft_core::date::{datetime_from_serial, days_in_month_in}; +use gridcraft_core::date::{datetime_from_serial, days_in_month_in, serial_from_ymd}; use gridcraft_core::{CellError, DateSystem, Value}; use crate::special::norm_inv; @@ -391,6 +391,62 @@ fn interpolate(slots: &[Option]) -> Vec { out } +/// A timeline's step, for continuing it past its last point (the Forecast Sheet's future rows). +#[derive(Clone, Copy, Debug)] +pub struct Timeline { + axis: Axis, + /// Position of the last time stamp. + last: i64, +} + +impl Timeline { + /// The step of a set of time stamps (any order, duplicates allowed): a constant numeric step + /// (missing points allowed) or, for whole-day dates on one day of the month, calendar months. + /// `None` without at least two distinct, finite time stamps or without a constant step. + pub fn detect(times: &[f64], sys: DateSystem) -> Option { + if times.iter().any(|t| !t.is_finite()) { + return None; + } + let mut sorted = times.to_vec(); + sorted.sort_by(|a, b| a.total_cmp(b)); + sorted.dedup(); + let axis = detect_axis(&sorted, sys).ok()?; + let last = axis.position(*sorted.last()?)?.round(); + // Like the functions, at most 30% of the points may be missing. + if !(0.0..MAX_POINTS as f64).contains(&last) || (last + 1.0 - sorted.len() as f64) > MAX_MISSING * (last + 1.0) { + return None; + } + Some(Timeline { axis, last: last as i64 }) + } + + /// Steps from the last time stamp to `t` (fractional between steps, negative before it). + pub fn steps_after_last(&self, t: f64) -> Option { + let p = self.axis.position(t)? - self.last as f64; + p.is_finite().then_some(p) + } + + /// The time stamp `k` steps after the last one. + pub fn after_last(&self, k: u32) -> Option { + let pos = self.last.checked_add(i64::from(k))?; + match self.axis { + Axis::Linear { t0, step } => { + let t = t0 + pos as f64 * step; + t.is_finite().then_some(t) + } + Axis::Months { sys, month0, step, day } => { + let month = month0.checked_add(pos.checked_mul(step)?)?; + let (y, m) = (month.div_euclid(12), month.rem_euclid(12) + 1); + let dim = i64::from(days_in_month_in(sys, i32::try_from(y).ok()?, u32::try_from(m).ok()?)); + let d = day.map_or(dim, |d| i64::from(d).min(dim)); + if y < 1900 { + return None; + } + serial_from_ymd(sys, y, m, d) + } + } + } +} + // --------------------------------------------------------------------------------------------- // Seasonality detection // --------------------------------------------------------------------------------------------- @@ -1002,4 +1058,19 @@ mod tests { is_err(ets(vec![n(1e15), range(&[1.0, 2.0, 3.0]), range(&[0.0, 1.0, 1e12])]), CellError::Num); is_err(ets(vec![n(1e308), range(&ys), range(&ts)]), CellError::Num); } + + #[test] + fn timelines_continue_past_their_last_point() { + let tl = Timeline::detect(&[3.0, 1.0, 2.0, 2.0, 5.0], DateSystem::D1900).expect("numeric step"); + assert_eq!(tl.after_last(1), Some(6.0)); + assert_eq!(tl.steps_after_last(8.5), Some(3.5)); + // Month ends: 2024-01-31 (45322), 2024-02-29 (45351), 2024-03-31 (45382). + let tl = Timeline::detect(&[45322.0, 45351.0, 45382.0], DateSystem::D1900).expect("months"); + assert_eq!(tl.after_last(1), Some(45412.0)); // 2024-04-30 + assert_eq!(tl.steps_after_last(45412.0), Some(1.0)); + assert!(Timeline::detect(&[1.0, 2.0, 4.5], DateSystem::D1900).is_none()); + assert!(Timeline::detect(&[1.0, 2.0, 9.0], DateSystem::D1900).is_none(), "too many missing points"); + assert!(Timeline::detect(&[1.0], DateSystem::D1900).is_none()); + assert!(Timeline::detect(&[1.0, f64::NAN], DateSystem::D1900).is_none()); + } } diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index c2b904bb2..0d74e6a49 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -76,6 +76,7 @@ pub(crate) mod stat; pub(crate) mod text; pub(crate) mod util; +pub use ets::Timeline; pub use stat::{aggregate_values, aggregate_values_k}; /// An evaluated argument. Ranges arrive as `Value::Array` (blank cells = `Value::Empty`) with diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb10..ee5c44b4b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -317,6 +317,40 @@ impl Dialog { json!({"header": true}), ), "goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})), + "forecastSheet" => Dialog::form( + "forecastSheet", + "Create Forecast Worksheet", + "data.forecastSheet", + vec![ + Text { key: "timeline", label: "Timeline range (blank: first column of the selection):" }, + Text { key: "values", label: "Values range (blank: second column of the selection):" }, + Text { key: "end", label: "Forecast end (date or number; blank: a third of the history):" }, + Check { key: "confidenceInterval", label: "Confidence interval" }, + Text { key: "confidence", label: "Confidence level (%):" }, + Text { key: "seasonality", label: "Seasonality (auto, 0 for none, or a season length):" }, + Choice { + key: "fillMissing", + label: "Fill missing points using", + options: vec![("interpolate", "Interpolation"), ("zeros", "Zeros")], + }, + Choice { + key: "aggregate", + label: "Aggregate duplicates using", + options: vec![ + ("average", "Average"), + ("count", "Count"), + ("counta", "CountA"), + ("max", "Max"), + ("median", "Median"), + ("min", "Min"), + ("sum", "Sum"), + ], + }, + Check { key: "statistics", label: "Include forecast statistics" }, + Choice { key: "chart", label: "Chart", options: vec![("line", "Line chart"), ("column", "Column chart")] }, + ], + json!({"confidenceInterval": true, "confidence": "95", "seasonality": "auto", "fillMissing": "interpolate", "aggregate": "average", "chart": "line"}), + ), "subtotal" => Dialog::form( "subtotal", "Subtotal", diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..7574d3dee 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -317,6 +317,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Data Table…", "データ テーブル…"), ("Scenario Manager…", "シナリオ マネージャー…"), ("Goal Seek…", "ゴール シーク…"), + ("Forecast\nSheet", "予測シート"), ("Spelling", "スペルチェック"), ("Check\nAccessibility", "アクセシビリティ チェック"), ("Threaded Comments", "スレッド化されたコメント"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3cab..c81c964ee 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -168,6 +168,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "데이터 표…", "Scenario Manager…" => "시나리오 관리자…", "Goal Seek…" => "목표값 찾기…", + "Forecast\nSheet" => "예측 시트", "Spelling" => "맞춤법 검사", "Check\nAccessibility" => "접근성\n검사", "Threaded Comments" => "스레드 댓글", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3f2..41b0cf116 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -169,6 +169,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "Tabela de Dados…", "Scenario Manager…" => "Gerenciador de Cenários…", "Goal Seek…" => "Buscar Objetivo…", + "Forecast\nSheet" => "Planilha de\nPrevisão", "Spelling" => "Ortografia", "Check\nAccessibility" => "Verificar\nAcessibilidade", "Threaded Comments" => "Comentários Encadeados", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d5485658..855c84b62 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -168,6 +168,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "Таблица данных…", "Scenario Manager…" => "Диспетчер сценариев…", "Goal Seek…" => "Подбор параметра…", + "Forecast\nSheet" => "Лист\nпрогноза", "Spelling" => "Орфография", "Check\nAccessibility" => "Проверка\nдоступности", "Threaded Comments" => "Комментарии с ветками", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada73..8cd112e5d 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -168,6 +168,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "数据表…", "Scenario Manager…" => "方案管理器…", "Goal Seek…" => "单变量求解…", + "Forecast\nSheet" => "预测工作表", "Spelling" => "拼写检查", "Check\nAccessibility" => "检查\n辅助功能", "Threaded Comments" => "线程式批注", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..2eb947785 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1568,6 +1568,9 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { ], ); }); + if big_button(ui, Icon::ChartLine, "Forecast\nSheet", "Forecast Sheet", false).clicked() { + app.open_dialog("forecastSheet", json!({})); + } sep(ui); ui.vertical(|ui| { if small_button(ui, Icon::Group, "Group", "Group", false).clicked() { diff --git a/docs/gaps.md b/docs/gaps.md index a033d0bb1..2265ded1b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§13, §15: FORECAST.ETS family landed) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§13: FORECAST.ETS family and Forecast Sheet landed; §15) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -98,8 +98,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h. ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) -- `data.solver`, `data.analysisToolPak`, `data.forecastSheet` missing (the FORECAST.ETS family the - Forecast Sheet builds on is in). **Estimate:** 18–30 h. +- `data.solver`, `data.analysisToolPak` missing. Forecast Sheet (`data.forecastSheet`) and the + FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 15–25 h. ### 14. Page Layout view and print polish (U, F) - No paper view with rulers and in-place header/footer editing; web print exports only (#141, PR @@ -149,6 +149,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §13: Forecast Sheet landed (#322) | | 2026-10-11 | minor | §13, §15: FORECAST.ETS family landed (#314) | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e98835d31..9819de910 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 258 / 290 commands implemented (89.0%).** +**Overall: 259 / 290 commands implemented (89.3%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -14,7 +14,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | -| Data | 20 | 28 | 71% | +| Data | 21 | 28 | 75% | | Review | 9 | 18 | 50% | | View | 18 | 20 | 90% | | Automate | 3 | 3 | 100% | @@ -240,7 +240,7 @@ Implemented: - `formulas.calculateNow` Calculate Now (Calculation) - `formulas.calculateSheet` Calculate Sheet (Calculation) -## Data (20 / 28, 71%) +## Data (21 / 28, 75%) Implemented: @@ -259,6 +259,7 @@ Implemented: - `data.scenarioManager` Scenario Manager (Forecast) - `data.goalSeek` Goal Seek (Forecast) - `data.dataTable` Data Table (Forecast) +- `data.forecastSheet` Forecast Sheet (Forecast) - `data.group` Group (Outline) - `data.ungroup` Ungroup (Outline) - `data.subtotal` Subtotal (Outline) @@ -271,7 +272,6 @@ Missing: - `data.stocks` Stocks (Data Types) - `data.geography` Geography (Data Types) - `data.relationships` Relationships (Data Tools) -- `data.forecastSheet` Forecast Sheet (Forecast) - `data.analyzeData` Analyze Data (Analysis) - `data.solver` Solver (Analysis) - `data.analysisToolPak` Data Analysis (Analysis) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..03a75cd8a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,10 +1,10 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Forecast Sheet dialog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (258 / 290). +separately in [parity-checklist.md](parity-checklist.md) (259 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes @@ -65,8 +65,8 @@ shortcut lists for Windows and Mac, then fill the gaps: 6–10 h. Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal -Seek, Scenario Manager and others exist. Missing or partial: Options, Solver, Data Analysis, -Forecast Sheet, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, +Seek, Scenario Manager, Forecast Sheet and others exist. Missing or partial: Options, Solver, Data Analysis, +Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. ## Accessibility @@ -91,5 +91,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Forecast Sheet dialog (#322); catalog 259 / 290 | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From bd5c5c1d261e14f82ac6aecc6a657774774d96fe Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:09:27 +0200 Subject: [PATCH 20/54] XLSX/IO: read legacy .xls (BIFF8) workbooks (#121) Clean-room import of Excel 97-2003 workbooks from the public [MS-CFB] and [MS-XLS] specifications: compound-file streams (FAT, mini FAT, directory tree), the BIFF8 Workbook stream with Continue records, shared strings, all cell records, formulas decoded from Ptg tokens (shared and array formulas, 3-D refs, names, add-in/newer functions by name) with cached results, number formats, the 1904 date system, merges, column widths, row heights, defined names and print areas/titles. Encrypted (FILEPASS) and BIFF5 files are refused with a clear message; everything else is skipped with a warning. .xls opens as a new unsaved workbook (Save As .xlsx). Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + ROADMAP.md | 1 + apps/gridcraft-web/src/web.rs | 2 +- apps/gridcraft/src/main.rs | 3 +- crates/engine/src/cmd/file.rs | 7 +- crates/engine/src/io.rs | 20 +- crates/engine/src/tests.rs | 85 ++++ crates/mcp/src/tools.rs | 2 +- crates/xlsx/src/cfb.rs | 216 ++++++++ crates/xlsx/src/lib.rs | 20 +- crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/xls_tests.rs | 439 +++++++++++++++++ crates/xlsx/src/xls/formula.rs | 496 +++++++++++++++++++ crates/xlsx/src/xls/ftab.rs | 376 ++++++++++++++ crates/xlsx/src/xls/mod.rs | 765 +++++++++++++++++++++++++++++ crates/xlsx/src/xls/records.rs | 198 ++++++++ docs/cli.md | 5 + docs/file-format-parity.md | 11 +- docs/gaps.md | 10 +- 19 files changed, 2635 insertions(+), 23 deletions(-) create mode 100644 crates/xlsx/src/tests/xls_tests.rs create mode 100644 crates/xlsx/src/xls/formula.rs create mode 100644 crates/xlsx/src/xls/ftab.rs create mode 100644 crates/xlsx/src/xls/mod.rs create mode 100644 crates/xlsx/src/xls/records.rs diff --git a/README.md b/README.md index 770b3fe61..db6a5bf6d 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ - **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays), tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines, PivotTables and print settings round-trip. CSV and TSV too. + Excel 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet). [XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas. [ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas. [Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data. diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..f961ed450 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index f5744bd19..f4f4e3fb1 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -109,7 +109,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let Some(file) = rfd::AsyncFileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) .pick_file() .await else { diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3c73a9541..14953509a 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -126,8 +126,9 @@ fn services() -> Services { })), pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) .add_filter("Excel Workbook", &["xlsx", "xlsm"]) + .add_filter("Excel 97-2003 Workbook (import)", &["xls"]) .add_filter("Excel Binary Workbook (data import)", &["xlsb"]) .add_filter("OpenDocument Spreadsheet (data import)", &["ods"]) .add_filter("CSV", &["csv"]) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d52418b4c..8837ea6d1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -8,7 +8,7 @@ use crate::DocState; pub fn specs() -> Vec { vec![ cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xlsb and ods data import, csv, tsv, txt, json)", always, open), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), @@ -47,8 +47,9 @@ fn open(s: &mut Session, p: &Json) -> Result { return ok(); }; let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?; - let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb)) - || matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb); + let imported = + matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls)) + || matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls); let title = if imported { format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book")) } else { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 25dec989b..52ee2d1c9 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,4 +1,4 @@ -//! Opening and saving files: XLSX, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export. +//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export. use std::fmt::Write as _; @@ -11,6 +11,8 @@ use crate::{EngineError, Result}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FileKind { Xlsx, + /// Excel 97-2003 workbook (import only). + Xls, Xlsb, Ods, Csv, @@ -24,6 +26,7 @@ impl FileKind { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); Some(match ext.as_str() { "xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx, + "xls" | "xlt" => FileKind::Xls, "xlsb" => FileKind::Xlsb, "ods" => FileKind::Ods, "csv" => FileKind::Csv, @@ -45,9 +48,13 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { "'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again." ))); } + if sniffed == gridcraft_xlsx::Format::Xls { + let (wb, report) = gridcraft_xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?; + return Ok((wb, report.warnings)); + } if sniffed == gridcraft_xlsx::Format::LegacyBinary { return Err(EngineError::Other(format!( - "'{name}' is an Excel 97-2003 (.xls) workbook or another legacy binary file. GridCraft doesn't open these yet; save it as .xlsx in Excel and try again." + "'{name}' is a legacy binary file but not an Excel workbook (no workbook stream). GridCraft can't open it." ))); } // Content wins over the extension: an ODS or XLSB package is imported as what it is, @@ -107,6 +114,9 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { let sheet = wb.active_sheet; match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) { FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())), + FileKind::Xls => { + Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into())) + } FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()), @@ -388,10 +398,10 @@ mod tests { } #[test] - fn legacy_xls_is_not_called_encrypted() { - let err = open_bytes("old.xls", &cfb(&["Workbook", "\u{5}SummaryInformation"])).unwrap_err(); + fn other_compound_files_are_not_called_encrypted() { + let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("97-2003") && msg.contains(".xlsx"), "got: {msg}"); + assert!(msg.contains("not an Excel workbook"), "got: {msg}"); assert!(!msg.contains("password"), "got: {msg}"); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55e..df8b14269 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1242,6 +1242,91 @@ fn xlsb_import_never_reuses_source_as_save_target() { std::fs::remove_dir_all(dir).unwrap(); } +/// A one-sheet Excel 97-2003 workbook ([MS-CFB] container, [MS-XLS] BIFF8 stream) built in code: +/// A1 = 42 and A2 = `A1*2` with its cached result. +fn xls_fixture() -> Vec { + fn rec(id: u16, payload: &[u8]) -> Vec { + [&id.to_le_bytes()[..], &(payload.len() as u16).to_le_bytes(), payload].concat() + } + let bof = |dt: u16| rec(0x0809, &[&0x0600u16.to_le_bytes()[..], &dt.to_le_bytes(), &[0; 12]].concat()); + let sheet = [ + bof(0x0010), + rec(0x0203, &[&[0u8, 0, 0, 0, 0, 0][..], &42f64.to_le_bytes()].concat()), + rec(0x0006, &[&[1u8, 0, 0, 0, 0, 0][..], &84f64.to_le_bytes(), &[0; 6], &[9, 0, 0x44, 0, 0, 0, 0xC0, 0x1E, 2, 0, 0x05]].concat()), + rec(0x000A, &[]), + ] + .concat(); + let globals = |pos: u32| [bof(0x0005), rec(0x0085, &[&pos.to_le_bytes()[..], &[0, 0, 3, 0], b"Old"].concat()), rec(0x000A, &[])].concat(); + let stream = [globals(globals(0).len() as u32), sheet].concat(); + // Sectors: 0 FAT, 1 directory, 2 mini FAT, 3 mini stream container. + let mut b = vec![0u8; 512 * 5]; + let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); + put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + put(26, &3u16.to_le_bytes()); + put(30, &9u16.to_le_bytes()); + put(32, &6u16.to_le_bytes()); + put(44, &1u32.to_le_bytes()); + put(48, &1u32.to_le_bytes()); + put(56, &4096u32.to_le_bytes()); + put(60, &2u32.to_le_bytes()); + put(64, &1u32.to_le_bytes()); + put(68, &0xFFFF_FFFEu32.to_le_bytes()); + for i in 1..109 { + put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); + } + for (i, v) in [0xFFFF_FFFDu32, 0xFFFF_FFFE, 0xFFFF_FFFE, 0xFFFF_FFFE].iter().enumerate() { + put(512 + i * 4, &v.to_le_bytes()); + } + for i in 4..128 { + put(512 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); + } + let minis = stream.len().div_ceil(64); + for i in 0..128 { + let next = if i + 1 < minis { + i as u32 + 1 + } else if i + 1 == minis { + 0xFFFF_FFFE + } else { + 0xFFFF_FFFF + }; + put(512 * 3 + i * 4, &next.to_le_bytes()); + } + let entry = |k: usize, name: &str, kind: u8, child: u32, start: u32, size: u32, put: &mut dyn FnMut(usize, &[u8])| { + let at = 512 * 2 + k * 128; + let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); + put(at, &units); + put(at + 64, &(units.len() as u16 + 2).to_le_bytes()); + put(at + 66, &[kind, 1]); + put(at + 68, &[0xFF; 8]); + put(at + 76, &child.to_le_bytes()); + put(at + 116, &start.to_le_bytes()); + put(at + 120, &size.to_le_bytes()); + }; + entry(0, "Root Entry", 5, 1, 3, 512, &mut put); + entry(1, "Workbook", 2, 0xFFFF_FFFF, 0, stream.len() as u32, &mut put); + put(512 * 4, &stream); + b +} + +#[test] +fn xls_import_keeps_formulas_and_never_saves_back_to_xls() { + let bytes = xls_fixture(); + assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xls); + let mut s = s(); + let r = s.execute("file.open", json!({"name": "legacy.xls", "base64": crate::io::base64_encode(&bytes)})).unwrap(); + assert!(r["warnings"][0].as_str().unwrap().contains("97-2003")); + assert_eq!(r["sheets"], json!(["Old"])); + let d = s.doc().unwrap(); + assert!(d.path.is_none()); + assert_eq!(d.display_title(), "legacy (imported).xlsx"); + assert_eq!(v(&s, "A2"), Value::Number(84.0)); + // The formula is live: editing its input recalculates it. + s.execute("cell.set", json!({"cell": "A1", "input": "50"})).unwrap(); + assert_eq!(v(&s, "A2"), Value::Number(100.0)); + assert!(s.execute("file.saveBytes", json!({"format": "xls"})).is_err()); + assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok()); +} + #[test] fn malformed_xlsb_keeps_the_current_workbook() { let mut s = s(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d5d5c3601..eb741b8bc 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec { tool( "open_workbook", "Open workbook", - "Open a workbook file (.xlsx, .xlsm, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.", + "Open a workbook file (.xlsx, .xlsm, .xls, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. Excel 97-2003 .xls imports values, formulas and number formats; XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.", obj(json!({"path": string("File path")}), &["path"]), false, ), diff --git a/crates/xlsx/src/cfb.rs b/crates/xlsx/src/cfb.rs index 252096dfa..3a7471852 100644 --- a/crates/xlsx/src/cfb.rs +++ b/crates/xlsx/src/cfb.rs @@ -97,6 +97,222 @@ fn directory_names(b: &[u8]) -> Option> { Some(names) } +/// Directory entry type of a stream object ([MS-CFB] 2.6.1). +const STREAM_OBJECT: u8 = 2; +/// "No entry" in the directory tree (NOSTREAM). +const NO_STREAM: u32 = 0xFFFF_FFFF; +/// Streams larger than this are refused (the format allows more, a workbook never needs it). +const MAX_STREAM: u64 = 1 << 30; + +/// One directory entry ([MS-CFB] 2.6). +struct DirEntry { + name: String, + kind: u8, + left: u32, + right: u32, + child: u32, + start: u32, + size: u64, +} + +/// A parsed compound file: the FAT, the mini FAT and the directory, all bounds-checked. +/// Implemented from Microsoft's public [MS-CFB] specification. +pub(crate) struct Compound<'a> { + bytes: &'a [u8], + sector_size: usize, + mini_sector_size: usize, + mini_cutoff: u64, + fat: Vec, + mini_fat: Vec, + entries: Vec, +} + +impl<'a> Compound<'a> { + /// Parses the header, FAT, mini FAT and directory. Errors on anything that can't be followed. + pub(crate) fn open(b: &'a [u8]) -> Result, String> { + let bad = |m: &str| format!("damaged compound file: {m}"); + if !b.starts_with(&SIGNATURE) { + return Err(bad("missing signature")); + } + let major = u16_at(b, 26).ok_or_else(|| bad("truncated header"))?; + let shift = u16_at(b, 30).ok_or_else(|| bad("truncated header"))?; + if shift != 9 && shift != 12 { + return Err(bad("unsupported sector size")); + } + let mini_shift = u16_at(b, 32).ok_or_else(|| bad("truncated header"))?; + if mini_shift != 6 { + return Err(bad("unsupported mini sector size")); + } + let sector_size = 1usize << shift; + let per = sector_size / 4; + // Sectors that physically exist after the header sector: every chain is bounded by it. + let sectors = (b.len() / sector_size).saturating_sub(1); + let sector = |id: u32| -> Option<&[u8]> { + let start = (id as usize).checked_add(1)?.checked_mul(sector_size)?; + b.get(start..start.checked_add(sector_size)?) + }; + // DIFAT: 109 FAT sector ids in the header, then chained DIFAT sectors. + let mut fat_sectors: Vec = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect(); + let mut next = u32_at(b, 68).ok_or_else(|| bad("truncated header"))?; + let mut hops = 0usize; + while next < MAX_REG_SECT { + hops += 1; + if hops > sectors.min(MAX_DIFAT_SECTORS * 64) { + return Err(bad("DIFAT chain loops")); + } + let s = sector(next).ok_or_else(|| bad("DIFAT sector out of range"))?; + fat_sectors.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT)); + next = u32_at(s, (per - 1) * 4).ok_or_else(|| bad("truncated DIFAT sector"))?; + } + if fat_sectors.len() > sectors { + return Err(bad("more FAT sectors than the file holds")); + } + let mut fat = Vec::with_capacity(fat_sectors.len().saturating_mul(per)); + for id in fat_sectors { + let s = sector(id).ok_or_else(|| bad("FAT sector out of range"))?; + fat.extend((0..per).filter_map(|i| u32_at(s, i * 4))); + } + let mut cf = Compound { bytes: b, sector_size, mini_sector_size: 64, mini_cutoff: 4096, fat, mini_fat: vec![], entries: vec![] }; + if let Some(c) = u32_at(b, 56) { + cf.mini_cutoff = u64::from(c); + } + // Directory. + let dir_start = u32_at(b, 48).ok_or_else(|| bad("truncated header"))?; + let dir = cf.read_chain(dir_start, u64::MAX)?; + for e in dir.as_chunks::<128>().0 { + let len = usize::from(u16_at(e, 64).unwrap_or(0)).min(64); + let units: Vec = (0..len.saturating_sub(2) / 2).filter_map(|i| u16_at(e, i * 2)).collect(); + let mut size = u32_at(e, 120).map(u64::from).unwrap_or(0); + if major >= 4 { + size |= u64::from(u32_at(e, 124).unwrap_or(0)) << 32; + } + cf.entries.push(DirEntry { + name: String::from_utf16_lossy(&units), + kind: e.get(66).copied().unwrap_or(0), + left: u32_at(e, 68).unwrap_or(NO_STREAM), + right: u32_at(e, 72).unwrap_or(NO_STREAM), + child: u32_at(e, 76).unwrap_or(NO_STREAM), + start: u32_at(e, 116).unwrap_or(END_OF_CHAIN), + size, + }); + } + if cf.entries.first().is_none_or(|r| r.kind != 5) { + return Err(bad("missing root entry")); + } + // Mini FAT. + let mini_start = u32_at(b, 60).ok_or_else(|| bad("truncated header"))?; + if mini_start < MAX_REG_SECT { + let raw = cf.read_chain(mini_start, u64::MAX)?; + cf.mini_fat = (0..raw.len() / 4).filter_map(|i| u32_at(&raw, i * 4)).collect(); + } + Ok(cf) + } + + /// Follows a FAT chain from `start`, reading at most `limit` bytes. A chain longer than the + /// FAT (so a cycle) or pointing outside the file is an error. + fn read_chain(&self, start: u32, limit: u64) -> Result, String> { + let limit = usize::try_from(limit.min(MAX_STREAM)).unwrap_or(usize::MAX); + let mut out = Vec::new(); + let mut id = start; + let mut seen = vec![false; self.fat.len()]; + while id != END_OF_CHAIN && out.len() < limit { + if id >= MAX_REG_SECT { + return Err("damaged compound file: broken sector chain".into()); + } + match seen.get_mut(id as usize) { + Some(v) if !*v => *v = true, + Some(_) => return Err("damaged compound file: sector chain loops".into()), + None => return Err("damaged compound file: sector missing from the FAT".into()), + } + let start = (id as usize).checked_add(1).and_then(|i| i.checked_mul(self.sector_size)); + let s = start + .and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?)) + .ok_or("damaged compound file: sector out of range (the file may be truncated)")?; + out.extend_from_slice(s); + id = *self.fat.get(id as usize).ok_or("damaged compound file: sector missing from the FAT")?; + } + Ok(out) + } + + /// Follows a mini FAT chain inside the mini stream container. + fn read_mini_chain(&self, container: &[u8], start: u32, limit: usize) -> Result, String> { + let mut out = Vec::new(); + let mut id = start; + let mut seen = vec![false; self.mini_fat.len()]; + while id != END_OF_CHAIN && out.len() < limit { + if id >= MAX_REG_SECT { + return Err("damaged compound file: broken mini sector chain".into()); + } + match seen.get_mut(id as usize) { + Some(v) if !*v => *v = true, + Some(_) => return Err("damaged compound file: mini sector chain loops".into()), + None => return Err("damaged compound file: mini sector missing from the mini FAT".into()), + } + let at = (id as usize).checked_mul(self.mini_sector_size); + let s = + at.and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?)).ok_or("damaged compound file: mini sector out of range")?; + out.extend_from_slice(s); + id = *self.mini_fat.get(id as usize).ok_or("damaged compound file: mini sector missing from the mini FAT")?; + } + Ok(out) + } + + /// The contents of the stream called `name` (case-insensitive) directly under the root + /// storage, or `None` when there is no such stream. + /// The directory entry of the stream called `name` (case-insensitive) directly under the + /// root storage. + fn root_entry(&self, name: &str) -> Result, String> { + let Some(root) = self.entries.first() else { return Ok(None) }; + // Walk the root's children (a red-black tree through left/right siblings), bounded. + let mut stack = vec![root.child]; + let mut seen = 0usize; + while let Some(i) = stack.pop() { + if i == NO_STREAM { + continue; + } + seen += 1; + if seen > self.entries.len() { + return Err("damaged compound file: directory tree loops".into()); + } + let Some(e) = self.entries.get(i as usize) else { continue }; + if e.kind == STREAM_OBJECT && e.name.eq_ignore_ascii_case(name) { + return Ok(Some(e)); + } + stack.push(e.left); + stack.push(e.right); + } + Ok(None) + } + + /// Whether the root storage holds a stream called `name`, without reading it. + pub(crate) fn has_root_stream(&self, name: &str) -> bool { + matches!(self.root_entry(name), Ok(Some(_))) + } + + /// The contents of the stream called `name` (case-insensitive) directly under the root + /// storage, or `None` when there is no such stream. + pub(crate) fn root_stream(&self, name: &str) -> Result>, String> { + let Some(root) = self.entries.first() else { return Ok(None) }; + let found = self.root_entry(name)?; + let Some(e) = found else { return Ok(None) }; + if e.size > MAX_STREAM { + return Err("the workbook stream is larger than 1 GiB".into()); + } + let size = usize::try_from(e.size).map_err(|_| "stream too large".to_string())?; + let mut data = if e.size < self.mini_cutoff { + let container = self.read_chain(root.start, root.size)?; + self.read_mini_chain(&container, e.start, size)? + } else { + self.read_chain(e.start, e.size)? + }; + if data.len() < size { + return Err("damaged compound file: a stream is shorter than its declared size (the file may be truncated)".into()); + } + data.truncate(size); + Ok(Some(data)) + } +} + #[cfg(test)] pub(crate) mod tests { use super::*; diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 05d1afc77..ce3353a3e 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -1,4 +1,5 @@ -//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import. +//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import +//! and legacy Excel 97-2003 `.xls` (BIFF8) import. //! //! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry //! points work on byte slices (no file system access), so the crate builds for WebAssembly. @@ -28,6 +29,7 @@ mod styles; mod tables; mod theme; mod write; +mod xls; mod xlsb; mod xml; @@ -39,6 +41,7 @@ use gridcraft_model::Workbook; pub use csv::{read_csv, write_csv}; pub use ods::read_ods; +pub use xls::read_xls; pub use xlsb::read_xlsb; /// Errors from reading or writing files. @@ -105,7 +108,9 @@ pub enum Format { Csv, /// A password-protected (encrypted) workbook: a compound file with an `EncryptedPackage`. Encrypted, - /// Another compound (CFB/OLE2) file: an Excel 97-2003 `.xls` or other legacy Office binary. + /// An Excel 97-2003 `.xls` workbook: a compound file with a `Workbook` (or BIFF5 `Book`) stream. + Xls, + /// Another compound (CFB/OLE2) file: a legacy Office binary that isn't an Excel workbook. LegacyBinary, Unknown, } @@ -122,9 +127,16 @@ pub fn sniff(bytes: &[u8]) -> Format { }; } // CFB/OLE2 compound file: a password-protected workbook when it holds an encryption - // stream, otherwise a legacy binary (.xls, .doc…). + // stream, an Excel 97-2003 workbook when it holds a workbook stream, otherwise another + // legacy binary (.doc…). if bytes.starts_with(&cfb::SIGNATURE) { - return if cfb::is_encrypted(bytes) { Format::Encrypted } else { Format::LegacyBinary }; + return if cfb::is_encrypted(bytes) { + Format::Encrypted + } else if xls::is_xls(bytes) { + Format::Xls + } else { + Format::LegacyBinary + }; } if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { return Format::Csv; diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 43639fee4..e311a507d 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -9,6 +9,7 @@ mod pivot_tests; mod richdata_tests; mod roundtrip; mod smooth_roundtrip; +mod xls_tests; mod xlsb_tests; use std::io::Write; diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs new file mode 100644 index 000000000..a150dc0b3 --- /dev/null +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -0,0 +1,439 @@ +//! Legacy `.xls` (BIFF8) import. Every test file is assembled here from the [MS-CFB] and +//! [MS-XLS] structures; no Excel-produced file is used. + +use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value}; + +use crate::{Format, IoError, read_xls, read_xlsx, sniff, write_xlsx}; + +const END: u32 = 0xFFFF_FFFE; +const FREE: u32 = 0xFFFF_FFFF; + +fn put32(b: &mut [u8], at: usize, v: u32) { + b[at..at + 4].copy_from_slice(&v.to_le_bytes()); +} + +fn dir_entry(name: &str, kind: u8, child: u32, start: u32, size: u32) -> [u8; 128] { + let mut e = [0u8; 128]; + let units: Vec = name.encode_utf16().collect(); + for (i, u) in units.iter().enumerate() { + e[i * 2..i * 2 + 2].copy_from_slice(&u.to_le_bytes()); + } + if !name.is_empty() { + e[64..66].copy_from_slice(&((units.len() as u16 + 1) * 2).to_le_bytes()); + } + e[66] = kind; + e[67] = 1; // black + put32(&mut e, 68, FREE); + put32(&mut e, 72, FREE); + put32(&mut e, 76, child); + put32(&mut e, 116, start); + put32(&mut e, 120, size); + e +} + +/// A version 3 compound file (512-byte sectors) whose root holds one stream. Streams under +/// 4096 bytes go to the mini stream, like real files. +pub(crate) fn compound(name: &str, data: &[u8]) -> Vec { + let mini = data.len() < 4096; + let pad = |n: usize, to: usize| n.div_ceil(to) * to; + // Sector payloads after the FAT: directory, mini FAT (if any), then data. + let mut payload: Vec> = vec![]; + let mut chains: Vec<(usize, usize)> = vec![]; // (first payload index, sector count) + let (dir_at, minifat_at, data_at); + let mut dir = vec![]; + let mut minifat = vec![]; + let stream_sectors; + if mini { + let m = data.len().div_ceil(64).max(1); + for i in 0..m { + minifat.extend_from_slice(&(if i + 1 == m { END } else { i as u32 + 1 }).to_le_bytes()); + } + minifat.resize(512, 0xFF); + let mut container = data.to_vec(); + container.resize(pad(data.len().max(1), 64), 0); + stream_sectors = container.len().div_ceil(512); + container.resize(stream_sectors * 512, 0); + dir_at = 0; + minifat_at = Some(1); + data_at = 2; + payload.push(vec![]); + payload.push(minifat); + payload.extend(container.chunks(512).map(<[u8]>::to_vec)); + chains.push((data_at, stream_sectors)); + dir.extend_from_slice(&dir_entry("Root Entry", 5, 1, 0, (stream_sectors * 512) as u32)); + dir.extend_from_slice(&dir_entry(name, 2, FREE, 0, data.len() as u32)); + } else { + let mut body = data.to_vec(); + stream_sectors = body.len().div_ceil(512); + body.resize(stream_sectors * 512, 0); + dir_at = 0; + minifat_at = None; + data_at = 1; + payload.push(vec![]); + payload.extend(body.chunks(512).map(<[u8]>::to_vec)); + chains.push((data_at, stream_sectors)); + dir.extend_from_slice(&dir_entry("Root Entry", 5, 1, END, 0)); + dir.extend_from_slice(&dir_entry(name, 2, FREE, 0, data.len() as u32)); + } + dir.extend_from_slice(&dir_entry("", 0, FREE, FREE, 0)); + dir.extend_from_slice(&dir_entry("", 0, FREE, FREE, 0)); + // FAT sectors come first; their count depends on the total. + let mut fat_n = 1; + while (fat_n + payload.len()).div_ceil(128) > fat_n { + fat_n += 1; + } + let sec = |i: usize| (fat_n + i) as u32; + // Fix the directory's start sectors now that the layout is known. + let first_data = sec(data_at); + if mini { + put32(&mut dir, 116, first_data); // root: mini stream container + } else { + put32(&mut dir, 128 + 116, first_data); + } + payload[dir_at] = dir; + let total = fat_n + payload.len(); + let mut fat = vec![FREE; fat_n * 128]; + for f in fat.iter_mut().take(fat_n) { + *f = 0xFFFF_FFFD; + } + fat[sec(dir_at) as usize] = END; + if let Some(m) = minifat_at { + fat[sec(m) as usize] = END; + } + for (first, n) in chains { + for k in 0..n { + fat[sec(first + k) as usize] = if k + 1 == n { END } else { sec(first + k + 1) }; + } + } + let mut out = vec![0u8; 512]; + out[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + out[24..26].copy_from_slice(&0x3Eu16.to_le_bytes()); + out[26..28].copy_from_slice(&3u16.to_le_bytes()); + out[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes()); + out[30..32].copy_from_slice(&9u16.to_le_bytes()); + out[32..34].copy_from_slice(&6u16.to_le_bytes()); + put32(&mut out, 44, fat_n as u32); + put32(&mut out, 48, sec(dir_at)); + put32(&mut out, 56, 4096); + put32(&mut out, 60, minifat_at.map_or(END, sec)); + put32(&mut out, 64, u32::from(minifat_at.is_some())); + put32(&mut out, 68, END); + for i in 0..109 { + put32(&mut out, 76 + i * 4, if i < fat_n { i as u32 } else { FREE }); + } + for f in &fat { + out.extend_from_slice(&f.to_le_bytes()); + } + for p in payload { + let mut p = p; + p.resize(512, 0); + out.extend_from_slice(&p); + } + assert_eq!(out.len(), 512 * (1 + total)); + out +} + +fn rec(id: u16, payload: &[u8]) -> Vec { + let mut v = id.to_le_bytes().to_vec(); + v.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + v.extend_from_slice(payload); + v +} + +fn cat(parts: &[&[u8]]) -> Vec { + parts.concat() +} + +fn u16s(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// Cell header: row, column, XF index. +fn cell(r: u16, c: u16, xf: u16) -> Vec { + u16s(&[r, c, xf]) +} + +fn bof(dt: u16) -> Vec { + rec(0x0809, &cat(&[&u16s(&[0x0600, dt, 0x0DBB, 0x07CC]), &[0; 8]])) +} + +fn eof() -> Vec { + rec(0x000A, &[]) +} + +/// XLUnicodeString with compressed characters. +fn xlstr(s: &str) -> Vec { + cat(&[&(s.len() as u16).to_le_bytes(), &[0], s.as_bytes()]) +} + +/// A FORMULA record with a numeric cached result. +fn formula(r: u16, c: u16, cached: [u8; 8], rgce: &[u8]) -> Vec { + let mut p = cell(r, c, 0); + p.extend_from_slice(&cached); + p.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // flags, chn + p.extend_from_slice(&(rgce.len() as u16).to_le_bytes()); + p.extend_from_slice(rgce); + rec(0x0006, &p) +} + +fn num(n: f64) -> [u8; 8] { + n.to_le_bytes() +} + +fn special(kind: u8, v: u8) -> [u8; 8] { + [kind, 0, v, 0, 0, 0, 0xFF, 0xFF] +} + +/// PtgRef (value class) with relative row and column. +fn ptg_ref(r: u16, c: u16) -> Vec { + cat(&[&[0x44], &u16s(&[r, c | 0xC000])]) +} + +fn ptg_int(n: u16) -> Vec { + cat(&[&[0x1E], &n.to_le_bytes()]) +} + +fn exp(r: u16, c: u16) -> Vec { + cat(&[&[0x01], &u16s(&[r, c])]) +} + +/// A workbook with two worksheets and a chart sheet covering the stage-1 features. +fn sample(date1904: bool) -> Vec { + // SST: "Hello", then "Wörld" as UTF-16 split across a Continue record. + let mut sst = cat(&[&2u32.to_le_bytes(), &2u32.to_le_bytes(), &xlstr("Hello")]); + sst.extend_from_slice(&[5, 0, 0, b'W']); + let sst_rec = cat(&[&rec(0x00FC, &sst), &rec(0x003C, &cat(&[&[1], &u16s(&[0xF6, 'r' as u16, 'l' as u16, 'd' as u16])]))]); + let xf = |ifmt: u16| rec(0x00E0, &cat(&[&u16s(&[0, ifmt, 0]), &[0; 14]])); + // Lbl "Rate" = Data!$A$1 (PtgRef3d, absolute). + let rate = cat(&[&[0x3A], &1u16.to_le_bytes(), &u16s(&[0, 0])]); + let lbl = rec(0x0018, &cat(&[&u16s(&[0]), &[0, 4], &u16s(&[rate.len() as u16, 0, 0]), &[0; 4], &[0], b"Rate", &rate])); + // Print_Area on Sheet1 (built-in name 6, itab 1) = Sheet1!$A$1:$B$2. + let pa = cat(&[&[0x3B], &0u16.to_le_bytes(), &u16s(&[0, 1, 0, 1])]); + let print_area = rec(0x0018, &cat(&[&u16s(&[0x0020]), &[0, 1], &u16s(&[pa.len() as u16, 0, 1]), &[0; 4], &[0, 6], &pa])); + let boundsheet = |pos: u32, kind: u8, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, kind, name.len() as u8, 0], name.as_bytes()])); + let globals = |positions: [u32; 3]| { + cat(&[ + &bof(0x0005), + &rec(0x0022, &u16s(&[u16::from(date1904)])), + &rec(0x041E, &cat(&[&164u16.to_le_bytes(), &xlstr("yyyy-mm-dd")])), + &xf(0), + &xf(164), + &xf(10), // built-in 0.00% + &boundsheet(positions[0], 0, "Sheet1"), + &boundsheet(positions[1], 0, "Data"), + &boundsheet(positions[2], 2, "Chart1"), + &rec(0x01AE, &u16s(&[3, 0x0401])), + &rec(0x0017, &cat(&[&u16s(&[2]), &u16s(&[0, 0, 0]), &u16s(&[0, 1, 1])])), + &lbl, + &print_area, + &sst_rec, + &eof(), + ]) + }; + let mulrk = cat(&[&u16s(&[1, 2]), &u16s(&[0]), &((7u32 << 2) | 2).to_le_bytes(), &u16s(&[2]), &((150u32 << 2) | 3).to_le_bytes(), &u16s(&[3])]); + let shared = cat(&[&[0x4C], &u16s(&[0, 0xFFFF]), &ptg_int(10), &[0x05]]); + let array = cat(&[&[0x65], &u16s(&[4, 5, 0xC000, 0xC000]), &ptg_int(2), &[0x05]]); + let sum = cat(&[&[0x25], &u16s(&[0, 0, 1 | 0xC000, 2 | 0xC000]), &[0x22, 1], &4u16.to_le_bytes()]); + let concat = cat(&[&ptg_ref(0, 0), &[0x17, 1, 0, b'!'], &[0x08]]); + let round = cat(&[&ptg_ref(0, 1), &ptg_int(0), &[0x41], &27u16.to_le_bytes()]); + let name_ref = cat(&[&[0x43], &1u32.to_le_bytes(), &ptg_int(2), &[0x05]]); + let ref3d = cat(&[&[0x5A], &1u16.to_le_bytes(), &u16s(&[0, 0xC000])]); + let sheet1 = cat(&[ + &bof(0x0010), + &rec(0x0225, &u16s(&[0, 255])), + &rec(0x007D, &u16s(&[0, 0, 20 * 256, 0, 0x0002, 0])), + &rec(0x0208, &cat(&[&u16s(&[0, 0, 4, 600, 0, 0]), &(0x0140u32).to_le_bytes()])), + &rec(0x00FD, &cat(&[&cell(0, 0, 0), &0u32.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(0, 1, 0), &1.5f64.to_le_bytes()])), + &rec(0x027E, &cat(&[&cell(0, 2, 0), &((42u32 << 2) | 2).to_le_bytes()])), + &rec(0x00FD, &cat(&[&cell(0, 3, 0), &1u32.to_le_bytes()])), + &rec(0x0205, &cat(&[&cell(1, 0, 0), &[1, 0]])), + &rec(0x0205, &cat(&[&cell(1, 1, 0), &[0x07, 1]])), + &rec(0x00BD, &mulrk), + &formula(2, 0, num(3.0), &cat(&[&ptg_ref(0, 1), &ptg_int(2), &[0x05]])), + &formula(2, 1, num(43.5), &sum), + &formula(2, 2, special(0, 0), &concat), + &rec(0x0207, &xlstr("Hello!")), + &formula(2, 3, special(1, 1), &cat(&[&ptg_ref(0, 1), &ptg_int(1), &[0x0D]])), + &formula(2, 4, num(14.0), &name_ref), + &formula(2, 5, num(2.0), &round), + &formula(2, 6, num(7.0), &ref3d), + &formula(2, 7, special(2, 0x2A), &[0x1C, 0x2A]), + &rec(0x0203, &cat(&[&cell(3, 0, 1), &0.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(3, 1, 2), &0.25f64.to_le_bytes()])), + &rec(0x027E, &cat(&[&cell(4, 0, 0), &((1u32 << 2) | 2).to_le_bytes()])), + &rec(0x027E, &cat(&[&cell(5, 0, 0), &((2u32 << 2) | 2).to_le_bytes()])), + &formula(4, 1, num(10.0), &exp(4, 1)), + &rec(0x04BC, &cat(&[&u16s(&[4, 5]), &[1, 1, 0, 2], &(shared.len() as u16).to_le_bytes(), &shared])), + &formula(5, 1, num(20.0), &exp(4, 1)), + &formula(4, 2, num(2.0), &exp(4, 2)), + &rec(0x0221, &cat(&[&u16s(&[4, 5]), &[2, 2], &[0; 6], &(array.len() as u16).to_le_bytes(), &array])), + &formula(5, 2, num(4.0), &exp(4, 2)), + &rec(0x00E5, &cat(&[&u16s(&[1]), &u16s(&[6, 6, 0, 1])])), + // An embedded chart substream: its records are not cells. + &bof(0x0020), + &rec(0x0203, &cat(&[&cell(9, 9, 0), &9.0f64.to_le_bytes()])), + &eof(), + &eof(), + ]); + let data = cat(&[&bof(0x0010), &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]); + let chart = cat(&[&bof(0x0020), &eof()]); + let len = globals([0; 3]).len() as u32; + let p1 = len; + let p2 = p1 + sheet1.len() as u32; + let p3 = p2 + data.len() as u32; + cat(&[&globals([p1, p2, p3]), &sheet1, &data, &chart]) +} + +fn v(wb: &gridcraft_model::Workbook, sheet: usize, a1: &str) -> Value { + wb.sheets[sheet].value(CellRef::parse(a1).unwrap()) +} + +fn ftext(wb: &gridcraft_model::Workbook, a1: &str) -> Option { + wb.sheets[0].cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()) +} + +#[test] +fn reads_values_formulas_formats_and_layout() { + for big in [false, true] { + let mut stream = sample(false); + if big { + // Past the mini stream cutoff: the stream lives in regular sectors. + stream.extend(std::iter::repeat_n(0u8, 5000)); + } + let bytes = compound("Workbook", &stream); + assert_eq!(sniff(&bytes), Format::Xls); + let (wb, report) = read_xls(&bytes).unwrap(); + assert!(report.warnings[0].contains("Excel 97-2003"), "{:?}", report.warnings); + assert!(report.warnings.iter().any(|w| w.contains("Chart sheets"))); + assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), ["Sheet1", "Data"]); + assert_eq!(v(&wb, 0, "A1"), Value::from("Hello")); + assert_eq!(v(&wb, 0, "B1"), Value::Number(1.5)); + assert_eq!(v(&wb, 0, "C1"), Value::Number(42.0)); + assert_eq!(v(&wb, 0, "D1"), Value::from("Wörld")); + assert_eq!(v(&wb, 0, "A2"), Value::Bool(true)); + assert_eq!(v(&wb, 0, "B2"), Value::Error(CellError::Div0)); + assert_eq!(v(&wb, 0, "C2"), Value::Number(7.0)); + assert_eq!(v(&wb, 0, "D2"), Value::Number(1.5)); + // Formulas: text plus the cached result. + assert_eq!(ftext(&wb, "A3").as_deref(), Some("B1*2")); + assert_eq!(v(&wb, 0, "A3"), Value::Number(3.0)); + assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)")); + assert_eq!(ftext(&wb, "C3").as_deref(), Some("A1&\"!\"")); + assert_eq!(v(&wb, 0, "C3"), Value::from("Hello!")); + assert_eq!(ftext(&wb, "D3").as_deref(), Some("B1>1")); + assert_eq!(v(&wb, 0, "D3"), Value::Bool(true)); + assert_eq!(ftext(&wb, "E3").as_deref(), Some("Rate*2")); + assert_eq!(ftext(&wb, "F3").as_deref(), Some("ROUND(B1,0)")); + assert_eq!(ftext(&wb, "G3").as_deref(), Some("Data!A1")); + assert_eq!(v(&wb, 0, "H3"), Value::Error(CellError::NA)); + // Shared formula: relative offsets resolve per cell. + assert_eq!(ftext(&wb, "B5").as_deref(), Some("A5*10")); + assert_eq!(ftext(&wb, "B6").as_deref(), Some("A6*10")); + // Array formula: the anchor holds it, the other cell keeps its value. + let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap(); + assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap()))); + assert_eq!(ftext(&wb, "C6"), None); + assert_eq!(v(&wb, 0, "C6"), Value::Number(4.0)); + // Number formats, merges, sizes, names, print area; embedded chart records ignored. + let sh = &wb.sheets[0]; + assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd"); + assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%"); + assert_eq!(sh.merges, vec![RangeRef::parse("A7:B7").unwrap()]); + assert!(sh.cols.get(&0).unwrap().size.unwrap() > sh.default_col_width); + assert_eq!(sh.rows.get(&0).unwrap().size, Some(40.0)); + assert!(!sh.rows.get(&0).unwrap().hidden && sh.rows.get(&0).unwrap().custom); + assert_eq!(sh.print.print_area, RangeRef::parse("A1:B2")); + assert_eq!(v(&wb, 0, "J10"), Value::Empty); + assert_eq!(wb.names.len(), 1); + assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1")); + assert_eq!(v(&wb, 1, "A1"), Value::Number(7.0)); + assert_eq!(wb.date_system, DateSystem::D1900); + // The import saves as .xlsx and reads back. + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().text, "B1*2"); + assert_eq!(back.sheets[0].value(CellRef::parse("C3").unwrap()), Value::from("Hello!")); + } + let (wb, _) = read_xls(&compound("Workbook", &sample(true))).unwrap(); + assert_eq!(wb.date_system, DateSystem::D1904); +} + +#[test] +fn refuses_encrypted_old_and_foreign_files() { + // FILEPASS in the globals: an encrypted workbook. + let mut stream = bof(0x0005); + stream.extend(rec(0x002F, &[1, 0, 1, 0])); + stream.extend(eof()); + let err = read_xls(&compound("Workbook", &stream)).unwrap_err().to_string(); + assert!(err.contains("password-protected"), "{err}"); + // A BIFF5 (Excel 5.0/95) workbook keeps its data in a `Book` stream. + let book = compound("Book", &stream); + assert_eq!(sniff(&book), Format::Xls); + assert!(read_xls(&book).unwrap_err().to_string().contains("BIFF5")); + // Another compound file (a word processor document): not a workbook. + let doc = compound("WordDocument", &[0u8; 100]); + assert_eq!(sniff(&doc), Format::LegacyBinary); + assert!(read_xls(&doc).is_err()); +} + +#[test] +fn hostile_xls_input_never_panics_or_hangs() { + let stream = sample(false); + let good = compound("Workbook", &stream); + // Truncation anywhere. + for cut in (0..good.len()).step_by(37) { + let _ = read_xls(&good[..cut]); + let _ = sniff(&good[..cut]); + } + // Byte mutations in the container and the stream. + for at in (0..good.len()).step_by(7) { + for x in [0x00u8, 0x01, 0x7F, 0xFF] { + let mut b = good.clone(); + b[at] = x; + let _ = read_xls(&b); + } + } + for at in 0..stream.len() { + let mut s = stream.clone(); + s[at] ^= 0xA5; + let _ = read_xls(&compound("Workbook", &s)); + } + // A FAT chain that loops on itself (big stream in regular sectors). + let mut big = stream.clone(); + big.extend(std::iter::repeat_n(0u8, 5000)); + let mut b = compound("Workbook", &big); + let fat_at = 512; + let first_data = 2usize; // FAT, directory, then data + put32(&mut b, fat_at + (first_data + 3) * 4, first_data as u32); + assert!(read_xls(&b).is_err()); + // Huge declared counts: shared strings, merges, external sheets. + let mut huge = bof(0x0005); + huge.extend(rec(0x00FC, &cat(&[&u32::MAX.to_le_bytes(), &u32::MAX.to_le_bytes()]))); + huge.extend(eof()); + assert!(matches!(read_xls(&compound("Workbook", &huge)), Err(IoError::TooLarge(_)))); + let mut huge = bof(0x0005); + huge.extend(rec(0x00FC, &cat(&[&9u32.to_le_bytes(), &1_000_000u32.to_le_bytes(), &xlstr("x")]))); + huge.extend(rec(0x0017, &u16s(&[0xFFFF]))); + huge.extend(eof()); + assert!(read_xls(&compound("Workbook", &huge)).is_err()); // no worksheets + // A sheet pointing outside the stream and a stream size larger than the file. + let mut bad = bof(0x0005); + bad.extend(rec(0x0085, &cat(&[&u32::MAX.to_le_bytes(), &[0, 0, 1, 0], b"S"]))); + bad.extend(eof()); + assert!(read_xls(&compound("Workbook", &bad)).is_err()); + let mut b = compound("Workbook", &big); + put32(&mut b, 512 * 2 + 128 + 120, 0x3FFF_FFFF); + assert!(read_xls(&b).is_err()); + // Random bytes behind a compound-file signature. + let mut seed = 0x2545_F491_4F6C_DD1Du64; + for _ in 0..200 { + let mut b = good[..512].to_vec(); + for _ in 0..2048 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + b.push(seed as u8); + } + let _ = read_xls(&b); + } +} diff --git a/crates/xlsx/src/xls/formula.rs b/crates/xlsx/src/xls/formula.rs new file mode 100644 index 000000000..3fc3fc40c --- /dev/null +++ b/crates/xlsx/src/xls/formula.rs @@ -0,0 +1,496 @@ +//! BIFF8 parsed formulas ([MS-XLS] 2.2.2, 2.5.198): reverse-Polish `Ptg` tokens back to an +//! expression tree. Anything outside the common token set is reported as unsupported, and the +//! caller keeps the cell's cached value instead. + +use std::sync::Arc; + +use gridcraft_core::{CellError, CellRef}; +use gridcraft_formula::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, UnOp}; + +use super::ftab::FTAB; + +/// Formula token bytes decoded at most (Excel's limit is 1,800 per formula). +const MAX_RGCE: usize = 16 * 1024; +/// Values in one array constant at most. +const MAX_ARRAY: usize = 65_536; + +/// Why a formula could not be turned back into text. +#[derive(Debug, PartialEq)] +pub(super) struct Unsupported(pub &'static str); + +/// Where the formula lives: refs in cell formulas store coordinates; shared formulas store +/// row/column offsets for relative parts; names store offsets for 3-D refs too. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Mode { + Cell, + Shared, + Name, +} + +/// A supporting link ([MS-XLS] 2.4.271): only links to this workbook (and add-in function +/// names) are resolved; external workbooks are never followed. +#[derive(Default)] +pub(super) struct SupBook { + pub internal: bool, + pub addin: bool, + pub names: Vec, +} + +/// XTI ([MS-XLS] 2.5.344): a supporting link and a range of its sheets. +#[derive(Clone, Copy)] +pub(super) struct Xti { + pub book: u16, + pub first: i16, + pub last: i16, +} + +/// Workbook-level tables formulas refer to. +#[derive(Default)] +pub(super) struct Links { + /// Every sheet's name in BoundSheet8 order (including skipped chart/macro sheets). + pub sheets: Vec, + /// Defined names (Lbl records), in order; `None` for names that can't be referenced. + pub names: Vec, + pub books: Vec, + pub xti: Vec, +} + +pub(super) fn function(iftab: u16) -> Option<(&'static str, i8)> { + FTAB.binary_search_by_key(&iftab, |(i, _, _)| *i).ok().and_then(|i| FTAB.get(i)).map(|(_, n, a)| (*n, *a)) +} + +fn error_code(code: u8) -> Option { + Some(match code { + 0x00 => CellError::Null, + 0x07 => CellError::Div0, + 0x0F => CellError::Value, + 0x17 => CellError::Ref, + 0x1D => CellError::Name, + 0x24 => CellError::Num, + 0x2A => CellError::NA, + 0x2B => CellError::GettingData, + _ => return None, + }) +} + +pub(super) fn cell_error(code: u8) -> CellError { + error_code(code).unwrap_or(CellError::NA) +} + +struct Bytes<'a> { + b: &'a [u8], + pos: usize, +} + +const TRUNC: Unsupported = Unsupported("truncated formula"); + +impl Bytes<'_> { + fn take(&mut self, n: usize) -> Result<&[u8], Unsupported> { + let s = self.b.get(self.pos..self.pos.checked_add(n).ok_or(TRUNC)?).ok_or(TRUNC)?; + self.pos += n; + Ok(s) + } + fn u8(&mut self) -> Result { + self.take(1)?.first().copied().ok_or(TRUNC) + } + fn u16(&mut self) -> Result { + let s = self.take(2)?; + Ok(u16::from_le_bytes([*s.first().ok_or(TRUNC)?, *s.get(1).ok_or(TRUNC)?])) + } + fn u32(&mut self) -> Result { + Ok(u32::from(self.u16()?) | (u32::from(self.u16()?) << 16)) + } + fn f64(&mut self) -> Result { + let a: [u8; 8] = self.take(8)?.try_into().map_err(|_| TRUNC)?; + Ok(f64::from_le_bytes(a)) + } + fn chars(&mut self, cch: usize, high: bool) -> Result { + let units: Vec = if high { + self.take(cch.checked_mul(2).ok_or(TRUNC)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect() + } else { + self.take(cch)?.iter().map(|&b| u16::from(b)).collect() + }; + Ok(String::from_utf16_lossy(&units)) + } +} + +/// A column field (ColRelU / ColRelNegU): the 14-bit column and the two relative flags. +fn col_field(v: u16) -> (u16, bool, bool) { + (v & 0x3FFF, v & 0x4000 != 0, v & 0x8000 != 0) +} + +/// One end of a reference. In `relative` (RgceLocRel) form the relative parts are offsets +/// from `base`, wrapped into the BIFF8 grid (65,536 × 256). +fn anchor(row: u16, col: u16, base: CellRef, relative: bool) -> Anchor { + let (c, col_rel, row_rel) = col_field(col); + let (r, c) = if relative { + let r = if row_rel { (u32::from(row).wrapping_add(base.row)) & 0xFFFF } else { u32::from(row) }; + // A 14-bit signed offset; adding modulo 256 gives the same column either way. + let c = if col_rel { (u32::from(c).wrapping_add(base.col)) & 0xFF } else { u32::from(c) & 0xFF }; + (r, c) + } else { + (u32::from(row), u32::from(c) & 0xFF) + }; + Anchor { row: r, col: c, row_abs: !row_rel, col_abs: !col_rel } +} + +/// An area; a range spanning every BIFF8 row (or column) becomes a whole-column (row) ref. +fn area(r0: u16, r1: u16, c0: u16, c1: u16, base: CellRef, relative: bool) -> RefKind { + let a = anchor(r0, c0, base, relative); + let b = anchor(r1, c1, base, relative); + if a.row == 0 && b.row == 0xFFFF { + return RefKind::Cols(a.col, a.col_abs, b.col, b.col_abs); + } + if a.col == 0 && b.col == 0xFF { + return RefKind::Rows(a.row, a.row_abs, b.row, b.row_abs); + } + RefKind::Range(a, b) +} + +impl Links { + fn sheet_sel(&self, ixti: u16) -> Result, Unsupported> { + let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing sheet link"))?; + let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?; + if !book.internal { + return Err(Unsupported("references to other workbooks (external links)")); + } + let name = |i: i16| usize::try_from(i).ok().and_then(|i| self.sheets.get(i)).cloned(); + Ok(match (name(x.first), name(x.last)) { + (Some(a), Some(_)) if x.first == x.last => Some(SheetSel::Named(a)), + (Some(a), Some(b)) => Some(SheetSel::Span(a, b)), + // A deleted sheet: the reference is #REF!. + _ => None, + }) + } + + fn extern_name(&self, ixti: u16, index: u32) -> Result { + let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing name link"))?; + let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?; + if !book.internal && !book.addin { + return Err(Unsupported("names in other workbooks (external links)")); + } + let i = usize::try_from(index).ok().and_then(|i| i.checked_sub(1)).ok_or(Unsupported("invalid name index"))?; + book.names.get(i).cloned().ok_or(Unsupported("reference to a missing name")) + } +} + +/// Strips the prefixes newer functions carry in files (`_xlfn.IFERROR`). +fn function_name(name: &str) -> String { + let n = name.strip_prefix("_xlfn.").or_else(|| name.strip_prefix("_xlws.")).unwrap_or(name); + n.to_ascii_uppercase() +} + +fn pop(stack: &mut Vec) -> Result { + stack.pop().ok_or(Unsupported("malformed formula (operand missing)")) +} + +fn pop_n(stack: &mut Vec, n: usize) -> Result, Unsupported> { + let at = stack.len().checked_sub(n).ok_or(Unsupported("malformed formula (argument missing)"))?; + Ok(stack.split_off(at)) +} + +/// Decodes `rgce` (tokens) with `rgcb` (the extra data for array constants) evaluated at +/// `base`. Returns the expression or why it can't be represented. +pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: &Links) -> Result { + if rgce.len() > MAX_RGCE { + return Err(Unsupported("formula too long")); + } + let mut t = Bytes { b: rgce, pos: 0 }; + let mut extra = Bytes { b: rgcb, pos: 0 }; + let mut stack: Vec = Vec::new(); + while t.pos < rgce.len() { + let ptg = t.u8()?; + // Operand classes (reference / value / array) share one token number. + let id = if ptg & 0x60 != 0 { 0x20 | (ptg & 0x1F) } else { ptg }; + match id { + 0x03..=0x11 => { + let op = match id { + 0x03 => BinOp::Add, + 0x04 => BinOp::Sub, + 0x05 => BinOp::Mul, + 0x06 => BinOp::Div, + 0x07 => BinOp::Pow, + 0x08 => BinOp::Concat, + 0x09 => BinOp::Lt, + 0x0A => BinOp::Le, + 0x0B => BinOp::Eq, + 0x0C => BinOp::Ge, + 0x0D => BinOp::Gt, + 0x0E => BinOp::Ne, + 0x0F => BinOp::Intersect, + 0x10 => BinOp::Union, + _ => BinOp::Range, + }; + let b = pop(&mut stack)?; + let a = pop(&mut stack)?; + stack.push(Expr::Binary(op, Box::new(a), Box::new(b))); + } + 0x12..=0x14 => { + let op = match id { + 0x12 => UnOp::Plus, + 0x13 => UnOp::Neg, + _ => UnOp::Percent, + }; + let x = pop(&mut stack)?; + stack.push(Expr::Unary(op, Box::new(x))); + } + 0x15 => { + let x = pop(&mut stack)?; + stack.push(Expr::Paren(Box::new(x))); + } + 0x16 => stack.push(Expr::Missing), + 0x17 => { + let cch = usize::from(t.u8()?); + let high = t.u8()? & 1 != 0; + stack.push(Expr::Text(Arc::from(t.chars(cch, high)?))); + } + 0x19 => { + let kind = t.u8()?; + if kind & 0x04 != 0 { + // PtgAttrChoose: jump table of cOffset + 1 entries. + let n = usize::from(t.u16()?) + 1; + t.take(n * 2)?; + } else { + t.take(2)?; + } + if kind & 0x10 != 0 { + // PtgAttrSum: SUM of the single operand. + let x = pop(&mut stack)?; + stack.push(Expr::Call("SUM".into(), vec![x])); + } + // Semi (volatile), If, Goto, Space, Baxcel: no effect on the expression. + } + 0x1C => { + let e = t.u8()?; + stack.push(Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?)); + } + 0x1D => stack.push(Expr::Bool(t.u8()? != 0)), + 0x1E => stack.push(Expr::Number(f64::from(t.u16()?))), + 0x1F => { + let n = t.f64()?; + if !n.is_finite() { + return Err(Unsupported("invalid number constant")); + } + stack.push(Expr::Number(n)); + } + 0x20 => { + t.take(7)?; + stack.push(array_constant(&mut extra)?); + } + 0x21 => { + let iftab = t.u16()?; + let (name, args) = function(iftab).ok_or(Unsupported("unknown function"))?; + let n = usize::try_from(args).map_err(|_| Unsupported("unknown function argument count"))?; + let args = pop_n(&mut stack, n)?; + stack.push(Expr::Call(name.to_string(), args)); + } + 0x22 => { + let n = usize::from(t.u8()?); + let tab = t.u16()?; + if tab & 0x8000 != 0 { + return Err(Unsupported("macro sheet commands")); + } + let mut args = pop_n(&mut stack, n)?; + if tab & 0x7FFF == 0x00FF { + // A function called by name (add-in or newer function): the name comes first. + if args.is_empty() { + return Err(Unsupported("malformed function call")); + } + let name = match args.remove(0) { + Expr::Name(n) => function_name(&n), + _ => return Err(Unsupported("function call through an expression")), + }; + stack.push(Expr::Call(name, args)); + } else { + let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?; + stack.push(Expr::Call(name.to_string(), args)); + } + } + 0x23 => { + let i = t.u32()?; + let name = usize::try_from(i) + .ok() + .and_then(|i| i.checked_sub(1)) + .and_then(|i| links.names.get(i)) + .filter(|n| !n.is_empty()) + .ok_or(Unsupported("reference to a missing defined name"))?; + stack.push(Expr::Name(name.clone())); + } + 0x24 | 0x2C => { + let (r, c) = (t.u16()?, t.u16()?); + let a = anchor(r, c, base, id == 0x2C); + stack.push(Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) })); + } + 0x25 | 0x2D => { + let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); + let kind = area(r0, r1, c0, c1, base, id == 0x2D); + stack.push(Expr::Ref(Reference { sheet: SheetSel::Current, kind })); + } + 0x26 => { + // PtgMemArea: a cache of the sub-expression that follows; skip its extra data. + t.take(6)?; + let n = usize::from(extra.u16()?); + extra.take(n.checked_mul(8).ok_or(TRUNC)?)?; + } + 0x27 | 0x28 => { + t.take(6)?; + } + 0x29 => { + t.take(2)?; + } + 0x2A => { + t.take(4)?; + stack.push(Expr::Error(CellError::Ref)); + } + 0x2B => { + t.take(8)?; + stack.push(Expr::Error(CellError::Ref)); + } + 0x39 => { + let ixti = t.u16()?; + let i = t.u32()?; + stack.push(Expr::Name(links.extern_name(ixti, i)?)); + } + 0x3A => { + let ixti = t.u16()?; + let (r, c) = (t.u16()?, t.u16()?); + let a = anchor(r, c, base, mode == Mode::Name); + stack.push(match links.sheet_sel(ixti)? { + Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }), + None => Expr::Error(CellError::Ref), + }); + } + 0x3B => { + let ixti = t.u16()?; + let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); + let kind = area(r0, r1, c0, c1, base, mode == Mode::Name); + stack.push(match links.sheet_sel(ixti)? { + Some(sheet) => Expr::Ref(Reference { sheet, kind }), + None => Expr::Error(CellError::Ref), + }); + } + 0x3C => { + t.take(6)?; + stack.push(Expr::Error(CellError::Ref)); + } + 0x3D => { + t.take(10)?; + stack.push(Expr::Error(CellError::Ref)); + } + 0x01 => return Err(Unsupported("nested shared or array formula reference")), + 0x02 => return Err(Unsupported("what-if data tables")), + 0x18 => return Err(Unsupported("natural-language formulas")), + _ => return Err(Unsupported("unknown formula token")), + } + if stack.len() > MAX_RGCE { + return Err(Unsupported("formula too complex")); + } + } + let e = pop(&mut stack)?; + if !stack.is_empty() { + return Err(Unsupported("malformed formula (unused operands)")); + } + Ok(e) +} + +/// PtgExtraArray ([MS-XLS] 2.5.198.59): an array constant's values in row-major order. +fn array_constant(b: &mut Bytes<'_>) -> Result { + let cols = usize::from(b.u8()?) + 1; + let rows = usize::from(b.u16()?) + 1; + if cols.saturating_mul(rows) > MAX_ARRAY { + return Err(Unsupported("array constant too large")); + } + let mut out = Vec::with_capacity(rows); + for _ in 0..rows { + let mut row = Vec::with_capacity(cols); + for _ in 0..cols { + let kind = b.u8()?; + row.push(match kind { + 0x00 => { + b.take(8)?; + Expr::Missing + } + 0x01 => { + let n = b.f64()?; + if !n.is_finite() { + return Err(Unsupported("invalid number constant")); + } + Expr::Number(n) + } + 0x02 => { + let cch = usize::from(b.u16()?); + let high = b.u8()? & 1 != 0; + Expr::Text(Arc::from(b.chars(cch, high)?)) + } + 0x04 => { + let v = b.u8()?; + b.take(7)?; + Expr::Bool(v != 0) + } + 0x10 => { + let e = b.u8()?; + b.take(7)?; + Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?) + } + _ => return Err(Unsupported("unknown array constant value")), + }); + } + out.push(row); + } + Ok(Expr::Array(out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn links() -> Links { + Links { + sheets: vec!["Sheet1".into(), "My Data".into()], + names: vec!["Rate".into()], + books: vec![ + SupBook { internal: true, addin: false, names: vec![] }, + SupBook { internal: false, addin: true, names: vec!["_xlfn.IFERROR".into()] }, + SupBook::default(), + ], + xti: vec![ + Xti { book: 0, first: 0, last: 1 }, + Xti { book: 1, first: -2, last: -2 }, + Xti { book: 2, first: 0, last: 0 }, + Xti { book: 0, first: -1, last: -1 }, + ], + } + } + + fn text(rgce: &[u8], rgcb: &[u8]) -> Result { + decode(rgce, rgcb, CellRef::new(0, 0), Mode::Cell, &links()).map(|e| gridcraft_formula::print(&e)) + } + + #[test] + fn decodes_common_tokens() { + // {1,"a";TRUE,#N/A} via PtgArray + PtgExtraArray. + let extra = + [&[1u8, 1, 0][..], &[1], &1f64.to_le_bytes(), &[2, 1, 0, 0, b'a'], &[4, 1, 0, 0, 0, 0, 0, 0, 0], &[0x10, 0x2A, 0, 0, 0, 0, 0, 0, 0]] + .concat(); + assert_eq!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &extra).unwrap(), "{1,\"a\";TRUE,#N/A}"); + // SUM via PtgAttrSum over a 3-D span, whole column. + let area3d = [&[0x3Bu8][..], &0u16.to_le_bytes(), &[0, 0, 0xFF, 0xFF, 1, 0, 1, 0]].concat(); + assert_eq!(text(&[area3d.as_slice(), &[0x19, 0x10, 0, 0]].concat(), &[]).unwrap(), "SUM('Sheet1:My Data'!$B:$B)"); + // IFERROR (a newer function) called by name through PtgNameX, with a missing argument. + let namex = [&[0x39u8][..], &1u16.to_le_bytes(), &1u32.to_le_bytes()].concat(); + let call = [namex.as_slice(), &[0x1E, 5, 0, 0x16, 0x22, 3, 0xFF, 0]].concat(); + assert_eq!(text(&call, &[]).unwrap(), "IFERROR(5,)"); + // A deleted sheet becomes #REF!; the CHOOSE jump table is skipped. + let ref_deleted = [&[0x3Au8][..], &3u16.to_le_bytes(), &[0, 0, 0, 0]].concat(); + let choose = [&[0x1Eu8, 1, 0][..], &[0x19, 0x04, 1, 0, 0, 0, 0, 0], ref_deleted.as_slice(), &[0x1E, 2, 0, 0x22, 3, 100, 0]].concat(); + assert_eq!(text(&choose, &[]).unwrap(), "CHOOSE(1,#REF!,2)"); + // External links, data tables and unbalanced stacks are refused, never guessed. + let external = [&[0x3Au8][..], &2u16.to_le_bytes(), &[0, 0, 0, 0]].concat(); + assert!(text(&external, &[]).is_err()); + assert!(text(&[0x02, 0, 0, 0, 0], &[]).is_err()); + assert!(text(&[0x1E, 1, 0, 0x1E, 2, 0], &[]).is_err()); + assert!(text(&[0x03], &[]).is_err()); + assert!(text(&[0x21, 0xFF, 0x7F], &[]).is_err()); + assert!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &[0xFF, 0xFF, 0xFF]).is_err()); + } +} diff --git a/crates/xlsx/src/xls/ftab.rs b/crates/xlsx/src/xls/ftab.rs new file mode 100644 index 000000000..d796419f6 --- /dev/null +++ b/crates/xlsx/src/xls/ftab.rs @@ -0,0 +1,376 @@ +// Generated from the Ftab table of [MS-XLS] section 2.5.198.17: (iftab, name, fixed argument +// count or -1 when the function takes a variable number of arguments). +pub(super) const FTAB: &[(u16, &str, i8)] = &[ + (0, "COUNT", -1), + (1, "IF", -1), + (2, "ISNA", 1), + (3, "ISERROR", 1), + (4, "SUM", -1), + (5, "AVERAGE", -1), + (6, "MIN", -1), + (7, "MAX", -1), + (8, "ROW", -1), + (9, "COLUMN", -1), + (10, "NA", 0), + (11, "NPV", -1), + (12, "STDEV", -1), + (13, "DOLLAR", -1), + (14, "FIXED", -1), + (15, "SIN", 1), + (16, "COS", 1), + (17, "TAN", 1), + (18, "ATAN", 1), + (19, "PI", 0), + (20, "SQRT", 1), + (21, "EXP", 1), + (22, "LN", 1), + (23, "LOG10", 1), + (24, "ABS", 1), + (25, "INT", 1), + (26, "SIGN", 1), + (27, "ROUND", 2), + (28, "LOOKUP", -1), + (29, "INDEX", -1), + (30, "REPT", 2), + (31, "MID", 3), + (32, "LEN", 1), + (33, "VALUE", 1), + (34, "TRUE", 0), + (35, "FALSE", 0), + (36, "AND", -1), + (37, "OR", -1), + (38, "NOT", 1), + (39, "MOD", 2), + (40, "DCOUNT", 3), + (41, "DSUM", 3), + (42, "DAVERAGE", 3), + (43, "DMIN", 3), + (44, "DMAX", 3), + (45, "DSTDEV", 3), + (46, "VAR", -1), + (47, "DVAR", 3), + (48, "TEXT", 2), + (49, "LINEST", -1), + (50, "TREND", -1), + (51, "LOGEST", -1), + (52, "GROWTH", -1), + (53, "GOTO", 1), + (54, "HALT", -1), + (55, "RETURN", -1), + (56, "PV", -1), + (57, "FV", -1), + (58, "NPER", -1), + (59, "PMT", -1), + (60, "RATE", -1), + (61, "MIRR", 3), + (62, "IRR", -1), + (63, "RAND", 0), + (64, "MATCH", -1), + (65, "DATE", 3), + (66, "TIME", 3), + (67, "DAY", 1), + (68, "MONTH", 1), + (69, "YEAR", 1), + (70, "WEEKDAY", -1), + (71, "HOUR", 1), + (72, "MINUTE", 1), + (73, "SECOND", 1), + (74, "NOW", 0), + (75, "AREAS", 1), + (76, "ROWS", 1), + (77, "COLUMNS", 1), + (78, "OFFSET", -1), + (79, "ABSREF", 2), + (80, "RELREF", 2), + (81, "ARGUMENT", -1), + (82, "SEARCH", -1), + (83, "TRANSPOSE", 1), + (84, "ERROR", -1), + (85, "STEP", 0), + (86, "TYPE", 1), + (87, "ECHO", -1), + (88, "SET.NAME", -1), + (89, "CALLER", 0), + (90, "DEREF", 1), + (91, "WINDOWS", -1), + (92, "SERIES", -1), + (93, "DOCUMENTS", -1), + (94, "ACTIVE.CELL", 0), + (95, "SELECTION", 0), + (96, "RESULT", -1), + (97, "ATAN2", 2), + (98, "ASIN", 1), + (99, "ACOS", 1), + (100, "CHOOSE", -1), + (101, "HLOOKUP", -1), + (102, "VLOOKUP", -1), + (103, "LINKS", -1), + (104, "INPUT", -1), + (105, "ISREF", 1), + (106, "GET.FORMULA", 1), + (107, "GET.NAME", -1), + (108, "SET.VALUE", 2), + (109, "LOG", -1), + (110, "EXEC", -1), + (111, "CHAR", 1), + (112, "LOWER", 1), + (113, "UPPER", 1), + (114, "PROPER", 1), + (115, "LEFT", -1), + (116, "RIGHT", -1), + (117, "EXACT", 2), + (118, "TRIM", 1), + (119, "REPLACE", 4), + (120, "SUBSTITUTE", -1), + (121, "CODE", 1), + (122, "NAMES", -1), + (123, "DIRECTORY", -1), + (124, "FIND", -1), + (125, "CELL", -1), + (126, "ISERR", 1), + (127, "ISTEXT", 1), + (128, "ISNUMBER", 1), + (129, "ISBLANK", 1), + (130, "T", 1), + (131, "N", 1), + (132, "FOPEN", -1), + (133, "FCLOSE", 1), + (134, "FSIZE", 1), + (135, "FREADLN", 1), + (136, "FREAD", 2), + (137, "FWRITELN", 2), + (138, "FWRITE", 2), + (139, "FPOS", -1), + (140, "DATEVALUE", 1), + (141, "TIMEVALUE", 1), + (142, "SLN", 3), + (143, "SYD", 4), + (144, "DDB", -1), + (145, "GET.DEF", -1), + (146, "REFTEXT", -1), + (147, "TEXTREF", -1), + (148, "INDIRECT", -1), + (149, "REGISTER", -1), + (150, "CALL", -1), + (151, "ADD.BAR", -1), + (152, "ADD.MENU", -1), + (153, "ADD.COMMAND", -1), + (154, "ENABLE.COMMAND", -1), + (155, "CHECK.COMMAND", -1), + (156, "RENAME.COMMAND", -1), + (157, "SHOW.BAR", -1), + (158, "DELETE.MENU", -1), + (159, "DELETE.COMMAND", -1), + (160, "GET.CHART.ITEM", -1), + (161, "DIALOG.BOX", 1), + (162, "CLEAN", 1), + (163, "MDETERM", 1), + (164, "MINVERSE", 1), + (165, "MMULT", 2), + (166, "FILES", -1), + (167, "IPMT", -1), + (168, "PPMT", -1), + (169, "COUNTA", -1), + (170, "CANCEL.KEY", -1), + (171, "FOR", -1), + (172, "WHILE", 1), + (173, "BREAK", 0), + (174, "NEXT", 0), + (175, "INITIATE", 2), + (176, "REQUEST", 2), + (177, "POKE", 3), + (178, "EXECUTE", 2), + (179, "TERMINATE", 1), + (180, "RESTART", -1), + (181, "HELP", -1), + (182, "GET.BAR", -1), + (183, "PRODUCT", -1), + (184, "FACT", 1), + (185, "GET.CELL", -1), + (186, "GET.WORKSPACE", 1), + (187, "GET.WINDOW", -1), + (188, "GET.DOCUMENT", -1), + (189, "DPRODUCT", 3), + (190, "ISNONTEXT", 1), + (191, "GET.NOTE", -1), + (192, "NOTE", -1), + (193, "STDEVP", -1), + (194, "VARP", -1), + (195, "DSTDEVP", 3), + (196, "DVARP", 3), + (197, "TRUNC", -1), + (198, "ISLOGICAL", 1), + (199, "DCOUNTA", 3), + (200, "DELETE.BAR", 1), + (201, "UNREGISTER", 1), + (204, "USDOLLAR", -1), + (205, "FINDB", -1), + (206, "SEARCHB", -1), + (207, "REPLACEB", 4), + (208, "LEFTB", -1), + (209, "RIGHTB", -1), + (210, "MIDB", 3), + (211, "LENB", 1), + (212, "ROUNDUP", 2), + (213, "ROUNDDOWN", 2), + (214, "ASC", 1), + (215, "DBCS", 1), + (216, "RANK", -1), + (219, "ADDRESS", -1), + (220, "DAYS360", -1), + (221, "TODAY", 0), + (222, "VDB", -1), + (223, "ELSE", 0), + (224, "ELSE.IF", 1), + (225, "END.IF", 0), + (226, "FOR.CELL", -1), + (227, "MEDIAN", -1), + (228, "SUMPRODUCT", -1), + (229, "SINH", 1), + (230, "COSH", 1), + (231, "TANH", 1), + (232, "ASINH", 1), + (233, "ACOSH", 1), + (234, "ATANH", 1), + (235, "DGET", 3), + (236, "CREATE.OBJECT", -1), + (237, "VOLATILE", -1), + (238, "LAST.ERROR", 0), + (239, "CUSTOM.UNDO", -1), + (240, "CUSTOM.REPEAT", -1), + (241, "FORMULA.CONVERT", -1), + (242, "GET.LINK.INFO", -1), + (243, "TEXT.BOX", -1), + (244, "INFO", 1), + (245, "GROUP", 0), + (246, "GET.OBJECT", -1), + (247, "DB", -1), + (248, "PAUSE", -1), + (251, "RESUME", -1), + (252, "FREQUENCY", 2), + (253, "ADD.TOOLBAR", -1), + (254, "DELETE.TOOLBAR", 1), + (256, "RESET.TOOLBAR", 1), + (257, "EVALUATE", 1), + (258, "GET.TOOLBAR", -1), + (259, "GET.TOOL", -1), + (260, "SPELLING.CHECK", -1), + (261, "ERROR.TYPE", 1), + (262, "APP.TITLE", -1), + (263, "WINDOW.TITLE", -1), + (264, "SAVE.TOOLBAR", -1), + (265, "ENABLE.TOOL", 3), + (266, "PRESS.TOOL", 3), + (267, "REGISTER.ID", -1), + (268, "GET.WORKBOOK", -1), + (269, "AVEDEV", -1), + (270, "BETADIST", -1), + (271, "GAMMALN", 1), + (272, "BETAINV", -1), + (273, "BINOMDIST", 4), + (274, "CHIDIST", 2), + (275, "CHIINV", 2), + (276, "COMBIN", 2), + (277, "CONFIDENCE", 3), + (278, "CRITBINOM", 3), + (279, "EVEN", 1), + (280, "EXPONDIST", 3), + (281, "FDIST", 3), + (282, "FINV", 3), + (283, "FISHER", 1), + (284, "FISHERINV", 1), + (285, "FLOOR", 2), + (286, "GAMMADIST", 4), + (287, "GAMMAINV", 3), + (288, "CEILING", 2), + (289, "HYPGEOMDIST", 4), + (290, "LOGNORMDIST", 3), + (291, "LOGINV", 3), + (292, "NEGBINOMDIST", 3), + (293, "NORMDIST", 4), + (294, "NORMSDIST", 1), + (295, "NORMINV", 3), + (296, "NORMSINV", 1), + (297, "STANDARDIZE", 3), + (298, "ODD", 1), + (299, "PERMUT", 2), + (300, "POISSON", 3), + (301, "TDIST", 3), + (302, "WEIBULL", 4), + (303, "SUMXMY2", 2), + (304, "SUMX2MY2", 2), + (305, "SUMX2PY2", 2), + (306, "CHITEST", 2), + (307, "CORREL", 2), + (308, "COVAR", 2), + (309, "FORECAST", 3), + (310, "FTEST", 2), + (311, "INTERCEPT", 2), + (312, "PEARSON", 2), + (313, "RSQ", 2), + (314, "STEYX", 2), + (315, "SLOPE", 2), + (316, "TTEST", 4), + (317, "PROB", -1), + (318, "DEVSQ", -1), + (319, "GEOMEAN", -1), + (320, "HARMEAN", -1), + (321, "SUMSQ", -1), + (322, "KURT", -1), + (323, "SKEW", -1), + (324, "ZTEST", -1), + (325, "LARGE", 2), + (326, "SMALL", 2), + (327, "QUARTILE", 2), + (328, "PERCENTILE", 2), + (329, "PERCENTRANK", -1), + (330, "MODE", -1), + (331, "TRIMMEAN", 2), + (332, "TINV", 2), + (334, "MOVIE.COMMAND", -1), + (335, "GET.MOVIE", -1), + (336, "CONCATENATE", -1), + (337, "POWER", 2), + (338, "PIVOT.ADD.DATA", -1), + (339, "GET.PIVOT.TABLE", -1), + (340, "GET.PIVOT.FIELD", -1), + (341, "GET.PIVOT.ITEM", -1), + (342, "RADIANS", 1), + (343, "DEGREES", 1), + (344, "SUBTOTAL", -1), + (345, "SUMIF", -1), + (346, "COUNTIF", 2), + (347, "COUNTBLANK", 1), + (348, "SCENARIO.GET", -1), + (349, "OPTIONS.LISTS.GET", 1), + (350, "ISPMT", 4), + (351, "DATEDIF", 3), + (352, "DATESTRING", 1), + (353, "NUMBERSTRING", 2), + (354, "ROMAN", -1), + (355, "OPEN.DIALOG", -1), + (356, "SAVE.DIALOG", -1), + (357, "VIEW.GET", -1), + (358, "GETPIVOTDATA", -1), + (359, "HYPERLINK", -1), + (360, "PHONETIC", 1), + (361, "AVERAGEA", -1), + (362, "MAXA", -1), + (363, "MINA", -1), + (364, "STDEVPA", -1), + (365, "VARPA", -1), + (366, "STDEVA", -1), + (367, "VARA", -1), + (368, "BAHTTEXT", 1), + (369, "THAIDAYOFWEEK", 1), + (370, "THAIDIGIT", 1), + (371, "THAIMONTHOFYEAR", 1), + (372, "THAINUMSOUND", 1), + (373, "THAINUMSTRING", 1), + (374, "THAISTRINGLENGTH", 1), + (375, "ISTHAIDIGIT", 1), + (376, "ROUNDBAHTDOWN", 1), + (377, "ROUNDBAHTUP", 1), + (378, "THAIYEAR", 1), + (379, "RTD", -1), +]; diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs new file mode 100644 index 000000000..b070478d5 --- /dev/null +++ b/crates/xlsx/src/xls/mod.rs @@ -0,0 +1,765 @@ +//! Legacy Excel 97-2003 `.xls` (BIFF8) import, implemented from Microsoft's public +//! specifications [MS-CFB] (the compound file container) and [MS-XLS] (the Workbook stream): +//! . +//! +//! Read: worksheets, shared strings, cell values, formulas (text and cached results, including +//! shared and array formulas), number formats, the 1904 date system, merged cells, column +//! widths, row heights, defined names, print areas and print titles. Everything else (fonts, +//! fills, borders, charts, pictures, comments, conditional formats, validation, macros) is +//! skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. + +mod formula; +mod ftab; +mod records; + +use std::collections::HashMap; +use std::sync::Arc; + +use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; +use gridcraft_model::{Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; + +use crate::cfb::Compound; +use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; +use crate::{IoError, ReadReport}; +use formula::{Links, Mode, SupBook, Unsupported, Xti, cell_error, decode}; +use records::{Cursor, Record, Records}; + +const MAX_CELLS: usize = 4_000_000; +const MAX_STRINGS: usize = 4_000_000; +const MAX_TEXT: usize = 256 * 1024 * 1024; +const MAX_SHEETS: usize = 4096; +const MAX_NAMES: usize = 65_536; +const MAX_XFS: usize = 65_536; +const MAX_MERGES: usize = 100_000; +/// Records read from one substream at most (a 65,536 × 256 sheet has fewer). +const MAX_RECORDS: usize = 40_000_000; + +const BOF: u16 = 0x0809; +const EOF: u16 = 0x000A; +const BIFF8: u16 = 0x0600; + +pub(crate) fn invalid(msg: impl Into) -> IoError { + IoError::Format(format!("XLS: {}", msg.into())) +} + +/// The message for an encrypted workbook (a FilePass record). +const ENCRYPTED: &str = + "this .xls workbook is password-protected (encrypted). GridCraft can't open encrypted .xls files; remove the password in Excel and try again."; + +/// Whether the bytes are a compound file holding an Excel workbook stream (BIFF8 `Workbook` +/// or BIFF5 `Book`). +pub(crate) fn is_xls(bytes: &[u8]) -> bool { + let Ok(cf) = Compound::open(bytes) else { return false }; + cf.has_root_stream("Workbook") || cf.has_root_stream("Book") +} + +#[derive(Default)] +struct Budget { + cells: usize, + text: usize, +} + +impl Budget { + fn cell(&mut self) -> Result<(), IoError> { + self.cells += 1; + if self.cells > MAX_CELLS { + return Err(IoError::TooLarge("XLS: more than 4 million cells".into())); + } + Ok(()) + } + fn text(&mut self, len: usize) -> Result<(), IoError> { + self.text = self.text.saturating_add(len); + if self.text > MAX_TEXT { + return Err(IoError::TooLarge("XLS: more than 256 MB of text".into())); + } + Ok(()) + } +} + +struct BoundSheet { + pos: usize, + name: String, + visibility: Visibility, + kind: u8, +} + +struct Lbl { + name: String, + builtin: Option, + hidden: bool, + macro_name: bool, + itab: u16, + rgce: Vec, + rgcb: Vec, +} + +#[derive(Default)] +struct Globals { + sheets: Vec, + date1904: bool, + formats: HashMap, + xf_formats: Vec, + sst: Vec>, + links: Links, + names: Vec, +} + +/// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. +pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + let cf = Compound::open(bytes).map_err(invalid)?; + let stream = match cf.root_stream("Workbook").map_err(invalid)? { + Some(s) => s, + None => { + if cf.has_root_stream("Book") { + return Err(invalid("Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again.")); + } + return Err(invalid("the file has no Workbook stream, so it is not an Excel workbook")); + } + }; + let mut report = ReadReport::default(); + let mut budget = Budget::default(); + let g = globals(&stream, &mut budget)?; + + let mut wb = Workbook::new(); + wb.sheets.clear(); + wb.date_system = if g.date1904 { DateSystem::D1904 } else { DateSystem::D1900 }; + // One model style per XF: only the number format is imported. + let styles: Vec = g + .xf_formats + .iter() + .map(|ifmt| { + let code = g.formats.get(ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(*ifmt))).unwrap_or("General"); + if code.eq_ignore_ascii_case("General") { + StyleId::DEFAULT + } else { + wb.styles.intern(Style { num_fmt: NumFmt::new(code), ..Style::default() }) + } + }) + .collect(); + + // BoundSheet8 index → model sheet index. + let mut model_index: Vec> = Vec::with_capacity(g.sheets.len()); + for bs in &g.sheets { + if bs.kind != 0 { + report.warn(match bs.kind { + 2 => "Chart sheets in .xls files are not imported yet and were skipped.", + 1 => "Excel 4.0 macro sheets were skipped.", + _ => "VBA modules and other non-worksheet sheets were skipped.", + }); + model_index.push(None); + continue; + } + wb.check_sheet_name(&bs.name, None).map_err(invalid)?; + let Some(mut sheet) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { + report.warn("Dialog sheets and unreadable sheets were skipped."); + model_index.push(None); + continue; + }; + sheet.visibility = bs.visibility; + model_index.push(Some(wb.sheets.len())); + wb.sheets.push(Arc::new(sheet)); + } + if wb.sheets.is_empty() { + return Err(invalid("the workbook has no worksheets GridCraft can read")); + } + defined_names(&g, &model_index, &mut wb, &mut report); + wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); + // The overview comes first. + report.warnings.insert( + 0, + "Imported from an Excel 97-2003 (.xls) workbook: values, formulas, number formats, merged cells, column widths, row heights and defined names were read. Fonts, fills, borders, charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(), + ); + Ok((wb, report)) +} + +fn bof(rec: &Record<'_>) -> Result<(u16, u16), IoError> { + if rec.id != BOF { + return Err(invalid("a substream does not start with a BOF record")); + } + let mut c = Cursor::new(&rec.parts); + Ok((c.u16()?, c.u16()?)) +} + +fn globals(stream: &[u8], budget: &mut Budget) -> Result { + let mut recs = Records::new(stream, 0); + let first = recs.next_record()?.ok_or_else(|| invalid("empty Workbook stream"))?; + let (vers, dt) = bof(&first)?; + if vers != BIFF8 { + return Err(invalid(if vers == 0x0500 { + "Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again." + } else { + "unsupported BIFF version (only Excel 97-2003 BIFF8 files are read)" + })); + } + if dt != 0x0005 { + return Err(invalid("the Workbook stream does not start with the workbook globals")); + } + let mut g = Globals::default(); + let mut count = 0usize; + while let Some(rec) = recs.next_record()? { + count += 1; + if count > MAX_RECORDS { + return Err(IoError::TooLarge("XLS: too many records".into())); + } + let mut c = Cursor::new(&rec.parts); + match rec.id { + EOF => break, + 0x002F => return Err(invalid(ENCRYPTED)), + 0x0085 => { + if g.sheets.len() >= MAX_SHEETS { + return Err(IoError::TooLarge("XLS: too many sheets".into())); + } + let pos = c.u32()? as usize; + let state = c.u8()?; + let kind = c.u8()?; + let name = c.short_string()?; + let visibility = match state & 3 { + 0 => Visibility::Visible, + 1 => Visibility::Hidden, + _ => Visibility::VeryHidden, + }; + g.links.sheets.push(name.clone()); + g.sheets.push(BoundSheet { pos, name, visibility, kind }); + } + 0x0022 => g.date1904 = c.u16()? == 1, + 0x041E => { + let ifmt = c.u16()?; + let code = c.string()?; + budget.text(code.len())?; + g.formats.insert(ifmt, code); + } + 0x00E0 => { + if g.xf_formats.len() < MAX_XFS { + c.u16()?; + g.xf_formats.push(c.u16()?); + } + } + 0x00FC => g.sst = shared_strings(&mut c, budget)?, + 0x01AE => { + let _ctab = c.u16()?; + let cch = c.u16()?; + g.links.books.push(SupBook { internal: cch == 0x0401, addin: cch == 0x3A01, names: vec![] }); + } + 0x0023 => { + // ExternName: flags, then an AddinUdf or ExternDocName body; both start with + // four bytes before the name. + c.u16()?; + c.skip(4)?; + let name = c.short_string()?; + if let Some(book) = g.links.books.last_mut() + && book.names.len() < MAX_NAMES + { + book.names.push(name); + } + } + 0x0017 => { + let n = usize::from(c.u16()?); + for _ in 0..n.min(c.remaining() / 6) { + let book = c.u16()?; + let first = c.u16()? as i16; + let last = c.u16()? as i16; + g.links.xti.push(Xti { book, first, last }); + } + } + 0x0018 => { + if g.names.len() >= MAX_NAMES { + continue; + } + let lbl = label(&mut c)?; + g.links.names.push(lbl.name.clone()); + g.names.push(lbl); + } + _ => {} + } + } + Ok(g) +} + +/// Built-in defined names ([MS-XLS] 2.4.150, Lbl.Name). +const BUILTIN_NAMES: [&str; 14] = [ + "Consolidate_Area", + "Auto_Open", + "Auto_Close", + "Extract", + "Database", + "Criteria", + "Print_Area", + "Print_Titles", + "Recorder", + "Data_Form", + "Auto_Activate", + "Auto_Deactivate", + "Sheet_Title", + "_FilterDatabase", +]; + +fn label(c: &mut Cursor<'_, '_>) -> Result { + let flags = c.u16()?; + let _key = c.u8()?; + let cch = usize::from(c.u8()?); + let cce = usize::from(c.u16()?); + c.skip(2)?; + let itab = c.u16()?; + c.skip(4)?; + let high = c.u8()? & 1 != 0; + let raw = c.chars(cch, high)?; + let builtin = flags & 0x0020 != 0; + let (name, builtin) = if builtin { + let code = raw.chars().next().map_or(0, |ch| ch as u32); + let code = u8::try_from(code).unwrap_or(u8::MAX); + (BUILTIN_NAMES.get(usize::from(code)).map_or_else(|| raw.clone(), |n| (*n).to_string()), Some(code)) + } else { + (raw, None) + }; + let rest = c.rest(); + let rgce = rest.get(..cce).unwrap_or(&rest).to_vec(); + let rgcb = rest.get(cce..).unwrap_or(&[]).to_vec(); + Ok(Lbl { name, builtin, hidden: flags & 1 != 0, macro_name: flags & 0x000A != 0, itab, rgce, rgcb }) +} + +fn shared_strings(c: &mut Cursor<'_, '_>, budget: &mut Budget) -> Result>, IoError> { + let _total = c.u32()?; + let unique = c.u32()? as usize; + if unique > MAX_STRINGS { + return Err(IoError::TooLarge("XLS: too many shared strings".into())); + } + // Each string takes at least three bytes: the declared count can't exceed what's there. + let mut out = Vec::with_capacity(unique.min(c.remaining() / 3)); + for _ in 0..unique { + if c.remaining() == 0 { + break; + } + let s = c.rich_string()?; + budget.text(s.len())?; + out.push(Arc::from(s)); + } + Ok(out) +} + +/// A formula cell waiting for its shared or array formula (which follows it in the file). +struct Pending { + pos: CellRef, + anchor: CellRef, +} + +/// A parsed formula: tokens and extra data. +struct Parsed { + rgce: Vec, + rgcb: Vec, +} + +fn parsed(c: &mut Cursor<'_, '_>) -> Result { + let cce = usize::from(c.u16()?); + let rest = c.rest(); + if cce > rest.len() { + return Err(invalid("formula runs past the end of its record")); + } + Ok(Parsed { rgce: rest.get(..cce).unwrap_or(&[]).to_vec(), rgcb: rest.get(cce..).unwrap_or(&[]).to_vec() }) +} + +struct SheetState<'g> { + g: &'g Globals, + styles: &'g [StyleId], + sheet: Sheet, + pending: Vec, + shared: HashMap, + arrays: HashMap, + last_formula: Option, + pending_string: Option, + dropped: usize, + unsupported: Vec<&'static str>, +} + +impl SheetState<'_> { + /// Records why a formula was kept as its value (each reason once). + fn unsupported(&mut self, why: &'static str) { + if !self.unsupported.contains(&why) { + self.unsupported.push(why); + } + } + + fn style(&self, ixfe: u16) -> StyleId { + self.styles.get(usize::from(ixfe)).copied().unwrap_or(StyleId::DEFAULT) + } + + fn pos(&mut self, row: u16, col: u16) -> Option { + let (r, c) = (u32::from(row), u32::from(col)); + if r >= MAX_ROWS || c >= MAX_COLS.min(256) { + self.dropped += 1; + return None; + } + Some(CellRef::new(r, c)) + } + + fn put(&mut self, budget: &mut Budget, row: u16, col: u16, ixfe: u16, value: Value) -> Result, IoError> { + let Some(pos) = self.pos(row, col) else { return Ok(None) }; + let style = self.style(ixfe); + if value.is_empty() && style == StyleId::DEFAULT { + return Ok(Some(pos)); + } + budget.cell()?; + self.sheet.cells.set(pos, Cell { value, formula: None, style }); + Ok(Some(pos)) + } + + /// Decodes a formula and attaches it to the cell at `pos`, or records why it can't be. + fn attach(&mut self, pos: CellRef, p: &Parsed, mode: Mode, array: Option) { + match decode(&p.rgce, &p.rgcb, pos, mode, &self.g.links) { + Ok(e) => { + let text = gridcraft_formula::print(&e); + let mut f = Formula::new(&text); + if f.expr.is_none() { + self.unsupported("syntax GridCraft can't parse yet"); + return; + } + f.array = array.filter(|r| !r.is_single()); + if let Some(cell) = self.sheet.cells.get_mut(pos) { + cell.formula = Some(Arc::new(f)); + } else { + self.sheet.cells.set(pos, Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT }); + } + } + Err(Unsupported(why)) => self.unsupported(why), + } + } +} + +/// RK number ([MS-XLS] 2.5.217): a 30-bit integer or the top of an IEEE double, maybe ×100. +fn rk(v: u32) -> Value { + let n = if v & 2 != 0 { f64::from((v as i32) >> 2) } else { f64::from_bits(u64::from(v & !3) << 32) }; + Value::number(if v & 1 != 0 { n / 100.0 } else { n }) +} + +fn worksheet( + stream: &[u8], + bs: &BoundSheet, + g: &Globals, + styles: &[StyleId], + budget: &mut Budget, + report: &mut ReadReport, +) -> Result, IoError> { + if bs.pos >= stream.len() { + return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name))); + } + let mut recs = Records::new(stream, bs.pos); + let first = recs.next_record()?.ok_or_else(|| invalid("missing sheet substream"))?; + let (vers, dt) = bof(&first)?; + if vers != BIFF8 || dt != 0x0010 { + return Ok(None); + } + let mut st = SheetState { + g, + styles, + sheet: Sheet::new(&bs.name), + pending: vec![], + shared: HashMap::new(), + arrays: HashMap::new(), + last_formula: None, + pending_string: None, + dropped: 0, + unsupported: vec![], + }; + let mut depth = 1usize; + let mut count = 0usize; + while let Some(rec) = recs.next_record()? { + count += 1; + if count > MAX_RECORDS { + return Err(IoError::TooLarge("XLS: too many records in a sheet".into())); + } + match rec.id { + BOF => { + // An embedded chart's substream. + depth += 1; + continue; + } + EOF => { + depth -= 1; + if depth == 0 { + break; + } + continue; + } + _ if depth > 1 => continue, + _ => {} + } + sheet_record(&rec, &mut st, budget, report)?; + } + // Shared and array formulas: their definitions follow the first cell that uses them. + let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared)); + for p in std::mem::take(&mut st.pending) { + if let Some((range, f)) = arrays.get(&p.anchor) { + // The other cells of an array formula keep only their values. + if p.pos == p.anchor { + st.attach(p.pos, f, Mode::Cell, Some(*range)); + } + } else if let Some(f) = shared.get(&p.anchor) { + st.attach(p.pos, f, Mode::Shared, None); + } else { + st.unsupported("a shared formula that is missing from the file"); + } + } + if st.dropped > 0 { + report.warn(format!("{} cells outside the grid were skipped.", st.dropped)); + } + for why in &st.unsupported { + report.warn(format!("Some formulas use {why}; their saved results were kept as values.")); + } + Ok(Some(st.sheet)) +} + +fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> { + let mut c = Cursor::new(&rec.parts); + // A formula's string result is the String record right after it (shared/array records may sit between). + if !matches!(rec.id, 0x0207 | 0x04BC | 0x0221 | 0x0236) { + st.pending_string = None; + } + match rec.id { + 0x0203 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let v = Value::number(c.f64()?); + st.put(budget, r, col, x, v)?; + } + 0x027E => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let v = rk(c.u32()?); + st.put(budget, r, col, x, v)?; + } + 0x00BD => { + let r = c.u16()?; + let first = c.u16()?; + let n = c.remaining().saturating_sub(2) / 6; + for i in 0..n { + let x = c.u16()?; + let v = rk(c.u32()?); + let col = u16::try_from(usize::from(first) + i).unwrap_or(u16::MAX); + st.put(budget, r, col, x, v)?; + } + } + 0x00FD => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let i = c.u32()? as usize; + match st.g.sst.get(i) { + Some(s) => { + st.put(budget, r, col, x, Value::Text(s.clone()))?; + } + None => report.warn("Some cells refer to missing shared strings and were left empty."), + } + } + 0x0204 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let s = c.string()?; + budget.text(s.len())?; + st.put(budget, r, col, x, Value::from(s))?; + } + 0x0205 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let (v, is_err) = (c.u8()?, c.u8()?); + let v = if is_err != 0 { Value::Error(cell_error(v)) } else { Value::Bool(v != 0) }; + st.put(budget, r, col, x, v)?; + } + 0x0201 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + st.put(budget, r, col, x, Value::Empty)?; + } + 0x00BE => { + let r = c.u16()?; + let first = c.u16()?; + let n = c.remaining().saturating_sub(2) / 2; + for i in 0..n { + let x = c.u16()?; + let col = u16::try_from(usize::from(first) + i).unwrap_or(u16::MAX); + st.put(budget, r, col, x, Value::Empty)?; + } + } + 0x0006 => formula_cell(&mut c, st, budget)?, + 0x0207 => { + if let Some(pos) = st.pending_string.take() { + let s = c.string()?; + budget.text(s.len())?; + if let Some(cell) = st.sheet.cells.get_mut(pos) { + cell.value = Value::from(s); + } + } + } + 0x04BC => { + // RefU (the cells that may use it), reserved, cUse. + c.skip(8)?; + let f = parsed(&mut c)?; + if let Some(at) = st.last_formula { + st.shared.insert(at, f); + } + } + 0x0221 => { + let (r0, r1) = (c.u16()?, c.u16()?); + let (c0, c1) = (c.u8()?, c.u8()?); + c.skip(6)?; + let f = parsed(&mut c)?; + let range = RangeRef::new(CellRef::new(u32::from(r0), u32::from(c0)), CellRef::new(u32::from(r1.max(r0)), u32::from(c1.max(c0)))); + if let Some(at) = st.last_formula { + st.arrays.insert(at, (range, f)); + } + } + 0x0236 => report.warn("What-if data tables are not supported; their values were kept."), + 0x00E5 => { + let n = usize::from(c.u16()?); + for _ in 0..n.min(c.remaining() / 8) { + let (r0, r1, c0, c1) = (c.u16()?, c.u16()?, c.u16()?, c.u16()?); + if r1 < r0 || c1 < c0 || u32::from(c1) >= 256 { + continue; + } + if st.sheet.merges.len() >= MAX_MERGES { + return Err(IoError::TooLarge("XLS: too many merged ranges".into())); + } + let range = RangeRef::new(CellRef::new(u32::from(r0), u32::from(c0)), CellRef::new(u32::from(r1), u32::from(c1))); + if !range.is_single() { + st.sheet.merges.push(range); + } + } + } + 0x007D => { + let (first, last) = (c.u16()?, c.u16()?); + let width = c.u16()?; + let x = c.u16()?; + let flags = c.u16()?; + let last = last.min(255); + if first > last { + return Ok(()); + } + let mut info = LineInfo::default(); + let px = col_width_to_px(f64::from(width) / 256.0); + if flags & 0x0002 != 0 || px != st.sheet.default_col_width { + info.size = Some(px); + } + info.hidden = flags & 0x0001 != 0; + info.outline = ((flags >> 8) & 7) as u8; + info.collapsed = flags & 0x1000 != 0; + let style = st.style(x); + if style != StyleId::DEFAULT { + info.style = Some(style); + } + if info != LineInfo::default() { + for col in first..=last { + st.sheet.cols.insert(u32::from(col), info); + } + } + } + 0x0208 => { + let r = c.u16()?; + c.skip(4)?; + let height = c.u16()?; + c.skip(4)?; + let flags = c.u32()?; + let mut info = LineInfo { custom: flags & 0x40 != 0, hidden: flags & 0x20 != 0, ..LineInfo::default() }; + let px = pt_to_px(f64::from(height & 0x7FFF) / 20.0); + if info.custom || (px - st.sheet.default_row_height).abs() > 0.5 { + info.size = Some(px); + } + info.outline = (flags & 7) as u8; + info.collapsed = flags & 0x10 != 0; + if flags & 0x80 != 0 { + let style = st.style(((flags >> 16) & 0x0FFF) as u16); + if style != StyleId::DEFAULT { + info.style = Some(style); + } + } + if info != LineInfo::default() && u32::from(r) < MAX_ROWS { + st.sheet.rows.insert(u32::from(r), info); + } + } + 0x0225 => { + let flags = c.u16()?; + let height = c.u16()?; + if flags & 0x0002 == 0 && height > 0 { + st.sheet.default_row_height = pt_to_px(f64::from(height) / 20.0); + } + } + 0x001C => report.warn("Cell comments in .xls files are not imported yet."), + 0x00EC => report.warn("Pictures, charts and shapes in .xls files are not imported yet."), + 0x01B0 | 0x0879 => report.warn("Conditional formatting in .xls files is not imported yet."), + 0x01B2 => report.warn("Data validation in .xls files is not imported yet."), + 0x01B8 => report.warn("Hyperlinks in .xls files are not imported yet."), + 0x00B0 => report.warn("PivotTables in .xls files are not imported yet; their values were kept."), + _ => {} + } + Ok(()) +} + +fn formula_cell(c: &mut Cursor<'_, '_>, st: &mut SheetState<'_>, budget: &mut Budget) -> Result<(), IoError> { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let mut val = [0u8; 8]; + for b in val.iter_mut() { + *b = c.u8()?; + } + let _flags = c.u16()?; + c.skip(4)?; + let f = parsed(c)?; + let [b0, _, b2, _, _, _, e0, e1] = val; + let mut string_result = false; + let value = if [e0, e1] == [0xFF, 0xFF] { + match b0 { + 0x00 => { + string_result = true; + Value::Empty + } + 0x01 => Value::Bool(b2 != 0), + 0x02 => Value::Error(cell_error(b2)), + 0x03 => Value::from(""), + _ => Value::Empty, + } + } else { + Value::number(f64::from_le_bytes(val)) + }; + let Some(pos) = st.pos(r, col) else { return Ok(()) }; + budget.cell()?; + let style = st.style(x); + st.sheet.cells.set(pos, Cell { value, formula: None, style }); + st.last_formula = Some(pos); + if string_result { + st.pending_string = Some(pos); + } + // PtgExp: part of a shared or array formula anchored at another cell. + if f.rgce.len() == 5 && f.rgce.first() == Some(&0x01) { + let get = |i: usize| f.rgce.get(i).copied().unwrap_or(0); + let anchor = CellRef::new(u32::from(u16::from_le_bytes([get(1), get(2)])), u32::from(u16::from_le_bytes([get(3), get(4)]))); + st.pending.push(Pending { pos, anchor }); + return Ok(()); + } + st.attach(pos, &f, Mode::Cell, None); + Ok(()) +} + +fn defined_names(g: &Globals, model_index: &[Option], wb: &mut Workbook, report: &mut ReadReport) { + let scope_of = |itab: u16| -> Option> { + if itab == 0 { + return Some(None); + } + model_index.get(usize::from(itab) - 1).copied().flatten().map(Some) + }; + for lbl in &g.names { + let Some(scope) = scope_of(lbl.itab) else { continue }; + let text = match decode(&lbl.rgce, &lbl.rgcb, CellRef::new(0, 0), Mode::Name, &g.links) { + Ok(e) => gridcraft_formula::print(&e), + Err(_) if lbl.rgce.is_empty() => continue, + Err(Unsupported(why)) => { + report.warn(format!("Some defined names use {why} and were skipped.")); + continue; + } + }; + match lbl.builtin { + Some(6 | 7) => { + let Some(si) = scope else { continue }; + let Some(sheet) = wb.sheet_mut(si) else { continue }; + if lbl.builtin == Some(6) { + sheet.print.print_area = crate::read::parse_print_area(&text); + } else { + let (rows, cols) = crate::read::parse_print_titles(&text); + sheet.print.title_rows = rows; + sheet.print.title_cols = cols; + } + } + Some(_) => {} + None if lbl.macro_name => report.warn("Macro names were skipped."), + None => wb.names.push(DefinedName { name: lbl.name.clone(), scope, formula: text, comment: String::new(), hidden: lbl.hidden }), + } + } +} diff --git a/crates/xlsx/src/xls/records.rs b/crates/xlsx/src/xls/records.rs new file mode 100644 index 000000000..fac7847dd --- /dev/null +++ b/crates/xlsx/src/xls/records.rs @@ -0,0 +1,198 @@ +//! BIFF8 record framing ([MS-XLS] 2.1.4) and the string structures ([MS-XLS] 2.5.240, +//! 2.5.293, 2.5.294) that may continue across `Continue` records. + +use super::invalid; +use crate::IoError; + +/// `Continue` record type. +pub(super) const CONTINUE: u16 = 0x003C; +/// Characters in one string at most (Excel's cell limit is 32,767; the SST may hold longer). +const MAX_CHARS: usize = 1 << 20; +/// `Continue` records gathered for one record at most. +const MAX_PARTS: usize = 1 << 16; + +/// One record and the payloads of the `Continue` records that follow it. +pub(super) struct Record<'a> { + pub id: u16, + pub parts: Vec<&'a [u8]>, +} + +/// Iterates over the records of a stream starting at `pos`. +pub(super) struct Records<'a> { + stream: &'a [u8], + pos: usize, +} + +impl<'a> Records<'a> { + pub fn new(stream: &'a [u8], pos: usize) -> Self { + Records { stream, pos } + } + + fn raw(&mut self) -> Result, IoError> { + if self.pos >= self.stream.len() { + return Ok(None); + } + let head = self.stream.get(self.pos..self.pos.saturating_add(4)).ok_or_else(|| invalid("truncated record header"))?; + let id = u16::from_le_bytes([head.first().copied().unwrap_or(0), head.get(1).copied().unwrap_or(0)]); + let len = usize::from(u16::from_le_bytes([head.get(2).copied().unwrap_or(0), head.get(3).copied().unwrap_or(0)])); + let start = self.pos + 4; + let data = self.stream.get(start..start.saturating_add(len)).ok_or_else(|| invalid("truncated record"))?; + self.pos = start + len; + Ok(Some((id, data))) + } + + fn peek_id(&self) -> Option { + let h = self.stream.get(self.pos..self.pos.checked_add(2)?)?; + Some(u16::from_le_bytes([*h.first()?, *h.get(1)?])) + } + + /// The next record with its `Continue` records, or `None` at the end of the stream. + pub fn next_record(&mut self) -> Result>, IoError> { + let Some((id, data)) = self.raw()? else { return Ok(None) }; + let mut parts = vec![data]; + while self.peek_id() == Some(CONTINUE) && parts.len() < MAX_PARTS { + if let Some((_, more)) = self.raw()? { + parts.push(more); + } + } + Ok(Some(Record { id, parts })) + } +} + +/// Reads a record's payload, crossing into its `Continue` records when needed. +pub(super) struct Cursor<'a, 'b> { + parts: &'b [&'a [u8]], + part: usize, + pos: usize, +} + +impl<'a, 'b> Cursor<'a, 'b> { + pub fn new(parts: &'b [&'a [u8]]) -> Self { + Cursor { parts, part: 0, pos: 0 } + } + + /// Skips to the next part when the current one is used up. Returns whether a new part began. + fn settle(&mut self) -> bool { + let mut moved = false; + while self.parts.get(self.part).is_some_and(|p| self.pos >= p.len()) && self.part + 1 < self.parts.len() { + self.part += 1; + self.pos = 0; + moved = true; + } + moved + } + + pub fn u8(&mut self) -> Result { + self.settle(); + let b = self.parts.get(self.part).and_then(|p| p.get(self.pos)).copied().ok_or_else(|| invalid("record is shorter than its contents"))?; + self.pos += 1; + Ok(b) + } + + pub fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes([self.u8()?, self.u8()?])) + } + + pub fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes([self.u8()?, self.u8()?, self.u8()?, self.u8()?])) + } + + pub fn f64(&mut self) -> Result { + let mut b = [0u8; 8]; + for x in b.iter_mut() { + *x = self.u8()?; + } + Ok(f64::from_le_bytes(b)) + } + + pub fn skip(&mut self, n: usize) -> Result<(), IoError> { + for _ in 0..n { + self.u8()?; + } + Ok(()) + } + + /// Bytes left in the record (all parts). + pub fn remaining(&self) -> usize { + let here = self.parts.get(self.part).map_or(0, |p| p.len().saturating_sub(self.pos)); + here + self.parts.iter().skip(self.part + 1).map(|p| p.len()).sum::() + } + + /// The rest of the record as one buffer. + pub fn rest(&mut self) -> Vec { + let mut out = Vec::with_capacity(self.remaining()); + if let Some(p) = self.parts.get(self.part) { + out.extend_from_slice(p.get(self.pos..).unwrap_or(&[])); + } + for p in self.parts.iter().skip(self.part + 1) { + out.extend_from_slice(p); + } + self.part = self.parts.len(); + self.pos = 0; + out + } + + /// `cch` characters, compressed (one byte each) or UTF-16. When the characters run into + /// a `Continue` record, that record starts with a fresh compression flag ([MS-XLS] 2.5.293). + pub fn chars(&mut self, cch: usize, mut high: bool) -> Result { + if cch > MAX_CHARS { + return Err(invalid("string too long")); + } + let mut units: Vec = Vec::with_capacity(cch.min(self.remaining())); + while units.len() < cch { + let at_end = self.parts.get(self.part).is_none_or(|p| self.pos >= p.len()); + if at_end && self.settle() { + high = self.u8()? & 1 != 0; + } + units.push(if high { self.u16()? } else { u16::from(self.u8()?) }); + } + Ok(String::from_utf16_lossy(&units)) + } + + /// ShortXLUnicodeString: 1-byte count, flags, characters. + pub fn short_string(&mut self) -> Result { + let cch = usize::from(self.u8()?); + let high = self.u8()? & 1 != 0; + self.chars(cch, high) + } + + /// XLUnicodeString: 2-byte count, flags, characters. + pub fn string(&mut self) -> Result { + let cch = usize::from(self.u16()?); + let high = self.u8()? & 1 != 0; + self.chars(cch, high) + } + + /// XLUnicodeRichExtendedString: the text; formatting runs and phonetic data are skipped. + pub fn rich_string(&mut self) -> Result { + let cch = usize::from(self.u16()?); + let flags = self.u8()?; + let runs = if flags & 0x08 != 0 { usize::from(self.u16()?) } else { 0 }; + let ext = if flags & 0x04 != 0 { self.u32()? as usize } else { 0 }; + let text = self.chars(cch, flags & 1 != 0)?; + let extra = runs.saturating_mul(4).saturating_add(ext); + if extra > self.remaining() { + return Err(invalid("string formatting runs past the end of the record")); + } + self.skip(extra)?; + Ok(text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strings_continue_with_a_new_compression_flag() { + // "ab" compressed, then "ç" and "d" as UTF-16 in a Continue part. + let first: &[u8] = &[4, 0, 0, b'a', b'b']; + let second: &[u8] = &[1, 0xE7, 0, b'd', 0]; + let parts = [first, second]; + let mut c = Cursor::new(&parts); + assert_eq!(c.rich_string().unwrap(), "abçd"); + assert_eq!(c.remaining(), 0); + let mut c = Cursor::new(&parts); + assert!(c.chars(MAX_CHARS + 1, false).is_err()); + } +} diff --git a/docs/cli.md b/docs/cli.md index 44e69e5ee..a4606fe54 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,6 +3,11 @@ Headless GridCraft: inspect, convert, evaluate, script and serve MCP without a window. Errors go to stderr with a non-zero exit code. +`.xls` (Excel 97-2003, BIFF8) files are imported with values, formulas, number formats, merges, +column widths, row heights and defined names; fonts, fills, borders, charts and other objects are +not imported yet. Import warnings go to stderr. Convert to `.xlsx` to save; `.xls` output is not +supported. + `.xlsb` files support [worksheet data import](xlsb-import.md). Formula cells use their last saved values; source formatting and other workbook features are omitted. Import warnings go to stderr. Convert to `.xlsx` to save the imported workbook; XLSB output is not supported. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index eeb2dd9b4..7477f020f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; every format Excel reads or writes, against the code on main) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.xls` BIFF8 import, #121) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -9,7 +9,7 @@ Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready Excel's list comes from Microsoft's public "File formats that are supported in Excel" documentation (the Office bundle is off limits under this repo's clean-room rule). GridCraft's support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and the tests in -`crates/xlsx/src/tests/` (round-trip, fixtures, malformed input, CSV, ODS, XLSB, PivotTables). +`crates/xlsx/src/tests/` (round-trip, fixtures, malformed input, CSV, ODS, XLSB, XLS, PivotTables). ## Workbook formats @@ -20,8 +20,8 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, **without macros** | VBA project dropped with a warning; saving as `.xlsm` loses the macros (#6) | — | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the workbook content type, not the template one; "new from template" behaviour missing | — | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **no** (#121); recognised and refused with a message since #17 | no | Still common in finance, government and legacy systems | — | -| `.xlt` Excel 97-2003 template | R/W | no | no | | — | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles. Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | | `.ods` OpenDocument Spreadsheet | R/W | **values only** (#100) | no | Sheets, values, dates, merges, repeated cells, cached formula results; no formulas, formatting, charts; encrypted ODS refused | `ods_tests.rs` | @@ -64,7 +64,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | | Preserve unmodelled XLSX parts and extensions on round trip (pass-through with relationship fix-up) | 10–15 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | -| Legacy `.xls` (BIFF8) import: cells, formulas, styles, names; then write | 20–30 | +| Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | | Encrypted workbooks: open with password prompt, save with password (#11) | 8–12 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | | CSV/text: displayed-value export, encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 5–8 | @@ -74,5 +74,6 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | `.xls` (BIFF8) import: values, formulas, number formats, merges, sizes, names (#121) | | 2026-10-10 | minor | Encrypted and `.xls` files are now recognised and refused with a message (#17) | | 2026-10-10 | major | Created: every Excel format with our read/write support; XLSB and ODS values-only imports recorded; `.xls`, encryption and large files flagged as beta gates | diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8d8..1da410495 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -64,11 +64,14 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Impact:** wrong numbers are worse than missing features. - **Estimate:** 10–15 h. [function-parity.md](function-parity.md#formula-language) -### 7. No legacy `.xls`; no password-protected workbooks (FF) -- **Evidence:** #121. Since #17 (closed #11) both are recognised through a CFB sniff and refused with a clear message, but neither opens. +### 7. Legacy `.xls` only partly read; no password-protected workbooks (FF) +- **Evidence:** #121: Excel 97-2003 (BIFF8) `.xls` files now open (values, formulas, number + formats, merges, sizes, names); fonts, fills, borders, charts, pictures, comments, conditional + formats, validation and BIFF5 files are not read, and `.xls` is never written. Encrypted + workbooks (OOXML and `.xls`) are recognised and refused with a clear message since #17 (closed #11). - **Impact:** common in finance, government and anything older than 2007; encrypted files are routine in business. -- **Estimate:** 28–42 h (`.xls` read 20–30, encryption 8–12). [file-format-parity.md](file-format-parity.md) +- **Estimate:** 18–27 h (`.xls` styles and objects 10–15, encryption 8–12). [file-format-parity.md](file-format-parity.md) ### 8. PivotTables: no slicers, calculated fields, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, @@ -149,6 +152,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | | 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues | From ed623902442f2853d758f87906e8140bc880508d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:12:56 +0200 Subject: [PATCH 21/54] XLSX: keep unmodelled parts on save (#330) Pass-through of package content GridCraft does not model: on read the raw parts are kept with their content types and relationship graph per owner (package root, workbook, sheet); on write they get fresh part names where needed and new relationship ids, and the r:ids in kept XML are rewritten. Kept: custom document properties, ribbon customisation, add-in task panes, custom XML, links to external workbooks with (never followed), connections, sheet query tables, printer settings with the (#324), form and ActiveX controls, OLE objects and sheet backgrounds with their VML shapes (merged with GridCraft's note shapes). Content whose graph reaches parts GridCraft rewrites is dropped with a warning; sheet content goes with its sheet and is not copied by Copy Sheet. Fixes #324 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/sheet.rs | 1 + crates/model/src/lib.rs | 2 + crates/model/src/preserved.rs | 70 ++ crates/model/src/sheet.rs | 5 + crates/model/src/workbook.rs | 4 + crates/xlsx/src/lib.rs | 1 + crates/xlsx/src/passthrough.rs | 898 +++++++++++++++++++++ crates/xlsx/src/read.rs | 6 +- crates/xlsx/src/sheet_read.rs | 1 - crates/xlsx/src/sheet_write.rs | 15 +- crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/passthrough_tests.rs | 430 ++++++++++ crates/xlsx/src/write.rs | 11 + crates/xlsx/src/xml.rs | 2 +- docs/file-format-parity.md | 7 +- docs/gaps.md | 17 +- 17 files changed, 1455 insertions(+), 17 deletions(-) create mode 100644 crates/model/src/preserved.rs create mode 100644 crates/xlsx/src/passthrough.rs create mode 100644 crates/xlsx/src/tests/passthrough_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..f2c255a45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 24e626828..bee10b113 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -563,6 +563,7 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result { let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone()); sh.name = name.chars().take(31).collect(); sh.tables.clear(); // table names must be unique + sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original let at = to.min(n); // The copy gets its own copies of the sheet's names, referring to the copy. let copies: Vec = cx diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index e3b8b6386..7dd43dfac 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -8,6 +8,7 @@ mod b64; pub mod cell; mod cellmap; pub mod features; +pub mod preserved; pub mod sheet; pub mod store; pub mod style; @@ -15,6 +16,7 @@ pub mod workbook; pub use cell::{Cell, CellPicture, Formula}; pub use features::*; +pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml}; pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility}; pub use store::CellStore; pub use style::{ diff --git a/crates/model/src/preserved.rs b/crates/model/src/preserved.rs new file mode 100644 index 000000000..6d0b35d5c --- /dev/null +++ b/crates/model/src/preserved.rs @@ -0,0 +1,70 @@ +//! File content GridCraft does not model, carried through unchanged so that saving a workbook +//! never silently drops it (XLSX pass-through: custom XML, external links, connections, query +//! tables, form controls, OLE objects…). +//! +//! The data is opaque to everything except the file-format crate: the model only holds it, so a +//! sheet's kept content disappears with the sheet. It is not part of the JSON format. + +use std::sync::Arc; + +/// A relationship from an owning part (the package root, the workbook or a sheet, or a kept part) +/// to a kept part or to an external target. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PreservedRel { + /// The id in the source file (`rId7`). Ids of an owner GridCraft writes are reassigned on save. + pub id: String, + /// The full relationship type URI. + pub rel_type: String, + /// The original part name of the target (no leading `/`), or the raw target of an external + /// relationship. External targets are never opened. + pub target: String, + pub external: bool, +} + +/// A kept package part. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PreservedPart { + /// Part name in the source file (no leading `/`). + pub name: String, + pub content_type: String, + pub data: Arc<[u8]>, + /// The part's own relationships, ids unchanged (the part's bytes refer to them). + pub rels: Vec, +} + +/// A raw XML element of an owning part that refers to kept parts, re-emitted on save with its +/// relationship ids rewritten. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PreservedXml { + /// Which slot of the owner it belongs in (`externalReferences`, `oleObjects`, `controls`). + pub slot: String, + /// The element, namespace declarations it needs included. + pub xml: String, +} + +/// Unmodelled content kept for one owner (the workbook, which also holds package-root content, +/// or one sheet). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Preserved { + /// Relationships of the package root (`_rels/.rels`) to kept parts (workbook owner only). + pub package_rels: Vec, + /// Relationships of the owning workbook or sheet part to kept parts. + pub rels: Vec, + /// Every kept part reachable from `package_rels` and `rels`. + pub parts: Vec, + /// Owner XML elements that refer to kept parts. + pub elements: Vec, + /// The sheet's legacy VML drawing when kept elements need its shapes (form controls, OLE + /// objects). Its own relationships point into `parts`. + pub vml: Option, +} + +impl Preserved { + pub fn is_empty(&self) -> bool { + self.package_rels.is_empty() && self.rels.is_empty() && self.parts.is_empty() && self.elements.is_empty() && self.vml.is_none() + } + /// Total bytes held. + pub fn byte_len(&self) -> usize { + self.parts.iter().chain(self.vml.iter()).fold(0usize, |n, p| n.saturating_add(p.data.len())) + } +} diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index d2d82ed94..79fd3fdf3 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -68,6 +68,10 @@ pub struct Sheet { /// Last selection and scroll position, saved with the file. pub view_active: CellRef, pub view_top_left: CellRef, + /// Unmodelled file content of this sheet kept for saving (form controls, OLE objects, query + /// tables). Goes away with the sheet; not in JSON. + #[serde(skip)] + pub preserved: Option>, } /// Default width of a column (points) and height of a row (points) for the default font. @@ -111,6 +115,7 @@ impl Sheet { print: PrintSettings::default(), view_active: CellRef::default(), view_top_left: CellRef::default(), + preserved: None, } } diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 6ccef9e9c..7e03cb31b 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -71,6 +71,9 @@ pub struct Workbook { pub custom_lists: Vec>, /// Named cell styles (Cell Styles gallery): name → style. pub cell_styles: Vec<(String, crate::style::Style)>, + /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON. + #[serde(skip)] + pub preserved: Option>, } impl Default for Workbook { @@ -94,6 +97,7 @@ impl Workbook { protected_structure: false, custom_lists: vec![], cell_styles: vec![], + preserved: None, } } pub fn sheet(&self, i: usize) -> Option<&Sheet> { diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 05d1afc77..e5d1bc42a 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -19,6 +19,7 @@ mod drawing; mod fmla; mod ods; mod package; +mod passthrough; mod pivot; mod read; mod richdata; diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs new file mode 100644 index 000000000..187009bca --- /dev/null +++ b/crates/xlsx/src/passthrough.rs @@ -0,0 +1,898 @@ +//! Pass-through of package content GridCraft does not model, so that open → edit → save keeps it. +//! +//! On read, the raw bytes of unmodelled parts are kept with their content type and their +//! relationship graph, per owner: the package root (custom document properties, ribbon +//! customisation, add-in task panes), the workbook (custom XML, links to external workbooks with +//! their ``, data connections) and each sheet (query tables, form controls, +//! ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's +//! ``, `` and `` XML, the `r:id` of `` and the legacy +//! VML shapes those controls need). +//! +//! On write, kept parts get fresh part names where needed and new relationship ids; the `r:id`s in +//! kept XML are rewritten to match. Kept content is conservative: +//! - Anything whose relationship graph reaches a part GridCraft reads and rewrites (worksheets, +//! drawings, charts, tables, PivotTables, comments, slicers…) is not kept: it would point at +//! content that no longer exists in the same form. +//! - External relationship targets (the path of a linked workbook) are kept as text and never +//! opened, followed or refreshed. +//! - Sheet content lives on the sheet: deleting the sheet drops it, Copy Sheet doesn't copy it. +//! Cell references inside kept parts (a control's linked cell, a query table's range) are kept +//! as they were, not adjusted to inserted or deleted rows. +//! - Strict Open XML files are not passed through (their namespaces differ from what we write). +//! - Sizes are bounded: per part and per workbook. + +use std::borrow::Cow; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt::Write as _; +use std::sync::Arc; + +use gridcraft_model::{Preserved, PreservedPart, PreservedRel, PreservedXml, Sheet, Workbook}; +use quick_xml::events::attributes::Attribute; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::name::QName; +use quick_xml::{Reader, Writer}; + +use crate::package::{self, normalize}; +use crate::read::Ctx; +use crate::write::{NS_REL, Out, Rels, XML_DECL}; +use crate::xml::{self, esc_attr}; + +/// Most bytes kept for one workbook. +pub const MAX_KEPT_TOTAL: usize = 256 * 1024 * 1024; +/// Largest single kept part. +pub const MAX_KEPT_PART: u64 = 64 * 1024 * 1024; +/// Most parts kept for one workbook. +const MAX_KEPT_PARTS: usize = 4096; +/// Most relationships read from one rels part. +const MAX_RELS: usize = 10_000; +/// Largest relationships, content-types or VML part read here. +const MAX_META: u64 = 16 * 1024 * 1024; +/// Longest chain of relationships followed from an owner. +const MAX_CHAIN: usize = 16; +/// Largest kept XML fragment of a sheet. +const MAX_FRAGMENT: usize = 4 * 1024 * 1024; +/// Deepest element nesting accepted in a kept fragment. +const MAX_FRAGMENT_DEPTH: usize = 256; + +const NS_REL_STRICT: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships"; +const CT_VML: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing"; + +/// Relationship kinds (last segment of the type) kept from the package root. +const PACKAGE_ROOTS: &[&str] = &["custom-properties", "extensibility", "webextensiontaskpanes"]; +/// Relationship kinds kept from the workbook without a workbook element. +const WORKBOOK_ROOTS: &[&str] = &["customXml", "connections"]; +/// Relationship kinds kept from a sheet without a sheet element. +const SHEET_ROOTS: &[&str] = &["queryTable"]; +/// Sheet relationship kinds that mean the sheet XML may hold elements worth keeping. +const SHEET_ELEMENT_KINDS: &[&str] = &["oleObject", "ctrlProp", "control", "image", "package", "printerSettings"]; +/// Kept sheet elements, in schema order (all after ``, before ``). +const SHEET_SLOTS: &[&str] = &["picture", "oleObjects", "controls"]; + +/// Relationship kinds GridCraft reads and rewrites, or that depend on content it rewrites: +/// kept content whose graph reaches one of them is dropped. +const REWRITTEN: &[&str] = &[ + "officedocument", + "core-properties", + "extended-properties", + "worksheet", + "chartsheet", + "dialogsheet", + "xlmacrosheet", + "drawing", + "chart", + "chartex", + "chartstyle", + "chartcolorstyle", + "chartusershapes", + "table", + "pivottable", + "pivotcachedefinition", + "pivotcacherecords", + "comments", + "vmldrawing", + "threadedcomment", + "person", + "sharedstrings", + "styles", + "theme", + "sheetmetadata", + "calcchain", + "vbaproject", + "rdrichvalue", + "rdrichvaluestructure", + "rdrichvaluetypes", + "rdrichvaluewebimage", + "rdarray", + "rdsupportingpropertybag", + "rdsupportingpropertybagstructure", + "richvaluerel", + "slicer", + "slicercache", + "timeline", + "timelinecache", + "powerpivotdata", +]; + +/// Part-name prefixes (lower case) GridCraft's writer never produces: kept parts there keep their +/// original names. Anything else is renamed so it can't collide with written parts. +const OWN_DIRS: &[&str] = &[ + "customxml/", + "docprops/custom.xml", + "customui/", + "xl/externallinks/", + "xl/connections.xml", + "xl/querytables/", + "xl/ctrlprops/", + "xl/embeddings/", + "xl/activex/", + "xl/webextensions/", + "xl/printersettings/", +]; + +fn kind(rel_type: &str) -> &str { + rel_type.rsplit('/').next().unwrap_or("") +} + +fn is_kind(r: &PreservedRel, kinds: &[&str]) -> bool { + let k = kind(&r.rel_type); + kinds.iter().any(|x| x.eq_ignore_ascii_case(k)) +} + +fn rewritten(r: &PreservedRel) -> bool { + !r.external && REWRITTEN.contains(&kind(&r.rel_type).to_ascii_lowercase().as_str()) +} + +fn rels_part_name(part: &str) -> String { + let (dir, file) = package::split_dir(part); + if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") } +} + +/// A part's relationships with full type URIs. `Ok(vec![])` when it has none; an error when the +/// rels part is damaged or too large (its part then can't be kept faithfully). +fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result, String> { + let name = rels_part_name(part); + let bytes = match cx.pkg.read_limited(&name, MAX_META) { + Ok(Some(b)) => b, + Ok(None) => return Ok(vec![]), + Err(e) => return Err(e.to_string()), + }; + let root = xml::parse(&bytes).map_err(|e| format!("{name}: {e}"))?; + let (dir, _) = package::split_dir(part); + let mut out = vec![]; + for r in root.kids("Relationship") { + if out.len() >= MAX_RELS { + return Err(format!("{name}: too many relationships")); + } + let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue }; + let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); + out.push(PreservedRel { + id: id.to_string(), + rel_type: r.attr("Type").unwrap_or("").to_string(), + target: if external { target.to_string() } else { package::resolve(dir, target) }, + external, + }); + } + Ok(out) +} + +// ---------------------------------------------------------------- reading + +/// Collects kept content while a package is read. +pub struct KeptReader { + on: bool, + strict: bool, + defaults: HashMap, + overrides: HashMap, + budget: usize, + parts_left: usize, +} + +impl KeptReader { + pub fn new(cx: &mut Ctx<'_>) -> KeptReader { + let mut k = KeptReader { + on: true, + strict: false, + defaults: HashMap::new(), + overrides: HashMap::new(), + budget: MAX_KEPT_TOTAL, + parts_left: MAX_KEPT_PARTS, + }; + if let Ok(root) = raw_rels(cx, "") + && root.iter().any(|r| r.rel_type.starts_with("http://purl.oclc.org/")) + { + k.on = false; + k.strict = true; + } + match cx.pkg.read_limited("[Content_Types].xml", MAX_META).ok().flatten().and_then(|b| xml::parse(&b).ok()) { + Some(types) => { + for d in types.kids("Default") { + if let (Some(e), Some(t)) = (d.attr("Extension"), d.attr("ContentType")) { + k.defaults.insert(e.to_ascii_lowercase(), t.to_string()); + } + } + for o in types.kids("Override") { + if let (Some(p), Some(t)) = (o.attr("PartName"), o.attr("ContentType")) { + k.overrides.insert(normalize(p), t.to_string()); + } + } + } + None => k.on = false, + } + k + } + + fn content_type(&self, part: &str) -> Option { + let key = normalize(part); + if let Some(t) = self.overrides.get(&key) { + return Some(t.clone()); + } + let (_, file) = package::split_dir(&key); + let ext = file.rsplit_once('.').map(|(_, e)| e)?; + self.defaults.get(ext).cloned() + } + + /// The parts reachable from `root` that aren't in `seen` yet. Fails (and keeps nothing) when the + /// graph reaches rewritten content, a missing or damaged part, or a size limit. + fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet) -> Result, String> { + if root.external { + return Ok(vec![]); + } + if rewritten(root) { + return Err(format!("it refers to {}", kind(&root.rel_type))); + } + let mut parts = vec![]; + let mut bytes = 0usize; + let mut local: HashSet = HashSet::new(); + let mut queue = VecDeque::from([(root.target.clone(), 0usize)]); + while let Some((name, depth)) = queue.pop_front() { + let key = normalize(&name); + if seen.contains(&key) || !local.insert(key) { + continue; + } + if depth > MAX_CHAIN { + return Err("its relationships are nested too deeply".into()); + } + if parts.len() >= self.parts_left { + return Err("it has too many parts".into()); + } + let content_type = self.content_type(&name).ok_or_else(|| format!("{name} has no content type"))?; + let data = match cx.pkg.read_limited(&name, MAX_KEPT_PART) { + Ok(Some(d)) => d, + Ok(None) => return Err(format!("{name} is missing")), + Err(e) => return Err(e.to_string()), + }; + bytes = bytes.saturating_add(data.len()); + if bytes > self.budget { + return Err("it is too large".into()); + } + let rels = raw_rels(cx, &name)?; + for r in rels.iter().filter(|r| !r.external) { + if rewritten(r) { + return Err(format!("it refers to {}", kind(&r.rel_type))); + } + queue.push_back((r.target.clone(), depth + 1)); + } + parts.push(PreservedPart { name, content_type, data: Arc::from(data), rels }); + } + self.budget = self.budget.saturating_sub(bytes); + self.parts_left = self.parts_left.saturating_sub(parts.len()); + Ok(parts) + } + + /// Keeps a set of relationships (all or nothing) and the parts behind them. + fn keep(&mut self, cx: &mut Ctx<'_>, p: &mut Preserved, roots: &[PreservedRel], seen: &mut HashSet) -> Result<(), String> { + let mut new_parts: Vec = vec![]; + let mut scratch = seen.clone(); + for r in roots { + let parts = self.closure(cx, r, &scratch)?; + scratch.extend(parts.iter().map(|q| normalize(&q.name))); + new_parts.extend(parts); + } + *seen = scratch; + p.parts.extend(new_parts); + for r in roots { + if !p.rels.iter().any(|x| x.id == r.id) { + p.rels.push(r.clone()); + } + } + Ok(()) + } + + /// Package-root and workbook-level content. + pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El) -> Option> { + if !self.on { + if self.strict { + cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save"); + } + return None; + } + let mut p = Preserved::default(); + let mut seen = HashSet::new(); + // Package root: custom document properties, ribbon customisation, task panes. + if let Ok(root) = raw_rels(cx, "") { + for r in root.iter().filter(|r| is_kind(r, PACKAGE_ROOTS)) { + let mut q = Preserved::default(); + match self.keep(cx, &mut q, std::slice::from_ref(r), &mut seen) { + Ok(()) => { + p.parts.extend(q.parts); + p.package_rels.extend(q.rels); + } + Err(why) => cx.warn(format!("{} was dropped: {why}", kind(&r.rel_type))), + } + } + } + let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); + for r in wb_rels.iter().filter(|r| is_kind(r, WORKBOOK_ROOTS)) { + if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { + cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target)); + } + } + // Links to external workbooks: `[1]` in formulas is the position in , + // so they are kept all together or not at all. + let ids: Vec<&str> = + wb_xml.child("externalReferences").map(|e| e.kids("externalReference").filter_map(|x| x.attr("id")).collect()).unwrap_or_default(); + if !ids.is_empty() { + let roots: Option> = + ids.iter().map(|id| wb_rels.iter().find(|r| r.id == *id && is_kind(r, &["externalLink"])).cloned()).collect(); + let kept = match roots { + Some(roots) => self.keep(cx, &mut p, &roots, &mut seen), + None => Err("a link has no part".into()), + }; + match kept { + Ok(()) => { + let mut x = format!(""); + for id in &ids { + let _ = write!(x, "", esc_attr(id)); + } + x.push_str(""); + p.elements.push(PreservedXml { slot: "externalReferences".into(), xml: x }); + cx.warn("links to external workbooks were kept; they are not followed or refreshed"); + } + Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")), + } + } + (!p.is_empty()).then(|| Arc::new(p)) + } + + /// Sheet-level content of the sheet part `part`. + pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option> { + if !self.on { + return None; + } + let rels = match raw_rels(cx, part) { + Ok(r) => r, + Err(e) => { + cx.warn(format!("sheet \"{name}\": relationships could not be read ({e}); unmodelled content was dropped")); + return None; + } + }; + let mut p = Preserved::default(); + let mut seen = HashSet::new(); + for r in rels.iter().filter(|r| is_kind(r, SHEET_ROOTS)) { + if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { + cx.warn(format!("sheet \"{name}\": {} was dropped: {why}", kind(&r.rel_type))); + } + } + if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) { + self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen); + } + (!p.is_empty()).then(|| Arc::new(p)) + } + + fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet) { + let scan = match cx.pkg.read(part) { + Ok(Some(bytes)) => scan_sheet(&bytes), + _ => None, + }; + let Some(scan) = scan else { + cx.warn(format!("sheet \"{name}\": controls and embedded objects could not be read and were dropped")); + return; + }; + // Printer settings (a binary part per sheet) hang off . + if let Some(id) = scan.page_setup.as_deref() + && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["printerSettings"])) + { + match self.keep(cx, p, std::slice::from_ref(r), seen) { + Ok(()) => p.elements.push(PreservedXml { slot: "pageSetup".into(), xml: r.id.clone() }), + Err(why) => cx.warn(format!("sheet \"{name}\": printer settings were dropped: {why}")), + } + } + let mut frags = scan.fragments; + // Controls and OLE objects are drawn from shapes in the legacy VML drawing. + if frags.iter().any(|(slot, _)| slot != "picture") { + match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) { + Ok((vml, parts)) => { + p.parts.extend(parts); + p.vml = Some(vml); + } + Err(why) => { + cx.warn(format!("sheet \"{name}\": controls and embedded objects were dropped: {why}")); + frags.retain(|(slot, _)| slot == "picture"); + } + } + } + for (slot, x) in frags { + let mut ids = vec![]; + let ok = map_rel_ids(&x, &mut |id| { + ids.push(id.to_string()); + Some(id.to_string()) + }) + .is_some(); + let roots: Option> = ids.iter().map(|id| rels.iter().find(|r| r.id == *id).cloned()).collect(); + let kept = match (ok, roots) { + (true, Some(roots)) => self.keep(cx, p, &roots, seen), + (false, _) => Err("its XML is damaged".into()), + (_, None) => Err("it refers to a missing relationship".into()), + }; + match kept { + Ok(()) => p.elements.push(PreservedXml { slot, xml: x }), + Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")), + } + } + if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") { + cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving")); + } else { + p.vml = None; + } + } + + /// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its + /// shapes refer to (preview images). + fn vml( + &mut self, + cx: &mut Ctx<'_>, + rels: &[PreservedRel], + rid: Option<&str>, + seen: &mut HashSet, + ) -> Result<(PreservedPart, Vec), String> { + let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?; + let data = match cx.pkg.read_limited(&rel.target, MAX_META) { + Ok(Some(d)) => d, + Ok(None) => return Err(format!("{} is missing", rel.target)), + Err(e) => return Err(e.to_string()), + }; + let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?; + let text = strip_notes(&text); + let vrels = raw_rels(cx, &rel.target)?; + let mut q = Preserved::default(); + let roots: Vec = vrels.iter().filter(|r| !r.external).cloned().collect(); + self.keep(cx, &mut q, &roots, seen)?; + let vml = PreservedPart { name: rel.target.clone(), content_type: CT_VML.into(), data: Arc::from(text.into_bytes()), rels: vrels }; + Ok((vml, q.parts)) + } +} + +/// What a sheet part holds for pass-through. +struct SheetScan { + /// (slot, XML with the namespace declarations it needs). + fragments: Vec<(String, String)>, + legacy_drawing: Option, + /// The relationship id on `` (printer settings). + page_setup: Option, +} + +/// Finds the sheet's ``, ``, `` (also inside +/// ``) and the `` id. `None` when the XML is damaged. +fn scan_sheet(bytes: &[u8]) -> Option { + let data = xml::to_utf8(bytes); + let text = std::str::from_utf8(&data).ok()?; + let mut reader = Reader::from_str(text); + let mut decls: Vec<(String, String)> = vec![]; + let mut out = SheetScan { fragments: vec![], legacy_drawing: None, page_setup: None }; + let mut in_root = false; + loop { + let start = usize::try_from(reader.buffer_position()).ok()?; + match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) if !in_root => { + in_root = true; + for a in e.attributes().with_checks(false).flatten() { + if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") + && let (Ok(p), Ok(v)) = (std::str::from_utf8(p), std::str::from_utf8(&a.value)) + && !v.contains('"') + { + decls.push((p.to_string(), v.to_string())); + } + } + } + Event::Start(e) => { + let local = e.local_name().as_ref().to_vec(); + match local.as_slice() { + b"pageSetup" => out.page_setup = rel_id_attr(&e), + b"legacyDrawing" => out.legacy_drawing = rel_id_attr(&e), + _ => {} + } + reader.read_to_end(e.name()).ok()?; + let end = usize::try_from(reader.buffer_position()).ok()?; + let wanted = matches!(local.as_slice(), b"picture" | b"oleObjects" | b"controls" | b"AlternateContent"); + if wanted && end.saturating_sub(start) <= MAX_FRAGMENT { + let frag = text.get(start..end)?; + push_fragment(&mut out, frag, &local, &decls); + } + } + Event::Empty(e) if in_root => { + let local = e.local_name().as_ref().to_vec(); + if local == b"legacyDrawing" || local == b"pageSetup" { + let id = rel_id_attr(&e); + if local == b"pageSetup" { + out.page_setup = id; + } else { + out.legacy_drawing = id; + } + } else if local == b"picture" + && let Some(frag) = text.get(start..usize::try_from(reader.buffer_position()).ok()?) + { + push_fragment(&mut out, frag, &local, &decls); + } + } + Event::End(_) => break, + _ => {} + } + } + Some(out) +} + +/// The `r:id` of an element (an `id` attribute with a namespace prefix). +fn rel_id_attr(e: &BytesStart<'_>) -> Option { + e.attributes() + .with_checks(false) + .flatten() + .find(|a| a.key.local_name().as_ref() == b"id" && a.key.prefix().is_some()) + .and_then(|a| String::from_utf8(a.value.to_vec()).ok()) +} + +fn push_fragment(out: &mut SheetScan, frag: &str, local: &[u8], decls: &[(String, String)]) { + let frag = with_decls(frag, decls); + let slot = if local == b"AlternateContent" { + // The kept element is the one the first choice holds. + let Ok(el) = xml::parse(frag.as_bytes()) else { return }; + match el.child("Choice").and_then(|c| c.children.first()) { + Some(c) => c.name.clone(), + None => return, + } + } else { + String::from_utf8_lossy(local).into_owned() + }; + if SHEET_SLOTS.contains(&slot.as_str()) && !out.fragments.iter().any(|(s, _)| *s == slot) { + out.fragments.push((slot, frag)); + } +} + +/// Adds the root's namespace declarations a fragment doesn't declare itself. +fn with_decls(frag: &str, decls: &[(String, String)]) -> String { + let Some(name_end) = frag.char_indices().skip(1).find(|(_, c)| c.is_whitespace() || *c == '/' || *c == '>').map(|(i, _)| i) else { + return frag.to_string(); + }; + let head = frag.get(..frag.find('>').unwrap_or(frag.len())).unwrap_or(""); + let mut add = String::new(); + for (p, v) in decls { + if !head.contains(&format!("xmlns:{p}=")) { + let _ = write!(add, " xmlns:{p}=\"{v}\""); + } + } + format!("{}{add}{}", frag.get(..name_end).unwrap_or(""), frag.get(name_end..).unwrap_or("")) +} + +/// Byte ranges of the `…` (or self-closed) elements in VML text. +fn shape_spans(vml: &str) -> Vec<(usize, usize)> { + let mut spans = vec![]; + let mut from = 0; + while let Some(i) = vml.get(from..).and_then(|s| s.find("' || c == '/'); + if !is_shape { + from = after; + continue; + } + let Some(tag_end) = vml.get(i..).and_then(|s| s.find('>')).map(|j| i + j + 1) else { break }; + let end = if vml.get(..tag_end).is_some_and(|t| t.ends_with("/>")) { + tag_end + } else { + match vml.get(tag_end..).and_then(|s| s.find("")) { + Some(j) => tag_end + j + "".len(), + None => break, + } + }; + spans.push((i, end)); + from = end; + } + spans +} + +/// VML without its note shapes (`ObjectType="Note"`). +fn strip_notes(vml: &str) -> String { + let mut out = String::with_capacity(vml.len()); + let mut last = 0; + for (a, b) in shape_spans(vml) { + let shape = vml.get(a..b).unwrap_or(""); + if shape.contains("ObjectType=\"Note\"") { + out.push_str(vml.get(last..a).unwrap_or("")); + last = b; + } + } + out.push_str(vml.get(last..).unwrap_or("")); + out +} + +/// Shape numbers (`_x0000_s1025` → 1025) used in VML text. +fn shape_numbers(vml: &str) -> Vec { + vml.match_indices("_x0000_s") + .filter_map(|(i, m)| { + let digits: String = vml.get(i + m.len()..)?.chars().take_while(char::is_ascii_digit).take(18).collect(); + digits.parse().ok() + }) + .collect() +} + +/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh +/// shape block so they can't collide with the kept shapes the sheet XML refers to. +fn merge_vml(kept: &str, ours: &str) -> String { + let Some(close) = kept.rfind("") else { return ours.to_string() }; + let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1; + let mut add = String::new(); + if !kept.contains("id=\"_x0000_t202\"") + && let Some(i) = ours.find("")) + { + add.push_str(ours.get(i..i + j + "".len()).unwrap_or("")); + } + for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() { + let shape = ours.get(a..b).unwrap_or(""); + let id = block.saturating_mul(1024).saturating_add(1 + k as u64); + match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) { + (Some(i), Some(j)) => { + let _ = write!(add, "{}id=\"_x0000_s{id}\"{}", shape.get(..i).unwrap_or(""), shape.get(j + 1..).unwrap_or("")); + } + _ => add.push_str(shape), + } + } + let mut head = kept.get(..close).unwrap_or("").to_string(); + // Claim the block in the drawing's id map. + if let Some(i) = head.find(" Option) -> Option { + let mut reader = Reader::from_str(x); + let mut writer = Writer::new(Vec::with_capacity(x.len())); + let mut prefixes: HashSet> = HashSet::new(); + let mut depth = 0usize; + loop { + match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) => { + depth += 1; + if depth > MAX_FRAGMENT_DEPTH { + return None; + } + let e = map_start(&e, &mut prefixes, map)?; + writer.write_event(Event::Start(e)).ok()?; + } + Event::Empty(e) => { + let e = map_start(&e, &mut prefixes, map)?; + writer.write_event(Event::Empty(e)).ok()?; + } + Event::End(e) => { + depth = depth.saturating_sub(1); + writer.write_event(Event::End(e)).ok()?; + } + ev => writer.write_event(ev).ok()?, + } + } + String::from_utf8(writer.into_inner()).ok() +} + +fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet>, map: &mut dyn FnMut(&str) -> Option) -> Option> { + for a in e.attributes().with_checks(false) { + let a = a.ok()?; + if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") + && (a.value.as_ref() == NS_REL.as_bytes() || a.value.as_ref() == NS_REL_STRICT.as_bytes()) + { + prefixes.insert(p.to_vec()); + } + } + let name = String::from_utf8(e.name().as_ref().to_vec()).ok()?; + let mut out = BytesStart::new(name); + for a in e.attributes().with_checks(false) { + let a = a.ok()?; + let key = a.key.as_ref(); + let is_rel = !key.starts_with(b"xmlns") && a.key.prefix().is_some_and(|p| prefixes.contains(p.as_ref())); + if is_rel { + let old = std::str::from_utf8(&a.value).ok()?; + let new = map(old)?; + out.push_attribute(Attribute { key: QName(key), value: Cow::Owned(esc_attr(&new).into_bytes()) }); + } else { + out.push_attribute(Attribute { key: QName(key), value: a.value.clone() }); + } + } + Some(out) +} + +// ---------------------------------------------------------------- writing + +/// Names given to kept parts while a package is written. +#[derive(Default)] +pub struct KeptOut { + used: HashSet, + n: u32, + /// Package-root relationships (type, target, external), added when `_rels/.rels` is written. + root: Vec<(String, String, bool)>, +} + +impl KeptOut { + /// A part name for a kept part: its original name when that can't collide with anything + /// written, otherwise a fresh one in the same folder. + fn claim(&mut self, original: &str) -> String { + let lower = original.to_ascii_lowercase(); + let own = OWN_DIRS.iter().any(|d| lower.starts_with(d)); + if own && self.used.insert(lower) { + return original.to_string(); + } + let (dir, file) = package::split_dir(original); + let (stem, ext) = match file.rsplit_once('.') { + Some((s, e)) => (s, format!(".{e}")), + None => (file, String::new()), + }; + let stem = if own { format!("{stem}_") } else { "kept".to_string() }; + loop { + self.n = self.n.saturating_add(1); + let file = format!("{stem}{}{ext}", self.n); + let cand = if dir.is_empty() { file } else { format!("{dir}/{file}") }; + if self.used.insert(cand.to_ascii_lowercase()) || self.n == u32::MAX { + return cand; + } + } + } +} + +/// Original part name (normalized) → written part name. +type NameMap = HashMap; + +fn rel_target(r: &PreservedRel, names: &NameMap) -> Option { + if r.external { Some(r.target.clone()) } else { names.get(&normalize(&r.target)).map(|n| format!("/{n}")) } +} + +/// A kept part's relationships, ids unchanged, targets renamed. +fn part_rels_xml(rels: &[PreservedRel], names: &NameMap) -> String { + let mut s = format!("{XML_DECL}"); + for r in rels { + let Some(target) = rel_target(r, names) else { continue }; + let _ = write!( + s, + "", + esc_attr(&r.id), + esc_attr(&r.rel_type), + esc_attr(&target), + if r.external { " TargetMode=\"External\"" } else { "" } + ); + } + s.push_str(""); + s +} + +fn write_parts(out: &mut Out, parts: &[PreservedPart]) -> NameMap { + let mut names = NameMap::new(); + for p in parts { + names.entry(normalize(&p.name)).or_insert_with(|| out.kept.claim(&p.name)); + } + let mut written = HashSet::new(); + for p in parts { + let Some(new) = names.get(&normalize(&p.name)).cloned() else { continue }; + if !written.insert(new.clone()) { + continue; + } + out.part(&new, Some(&p.content_type), p.data.to_vec()); + if !p.rels.is_empty() { + out.part(&rels_part_name(&new), None, part_rels_xml(&p.rels, &names).into_bytes()); + } + } + names +} + +/// Adds the owner's relationships; returns old id → new id. +fn attach(rels: &mut Rels, list: &[PreservedRel], names: &NameMap) -> HashMap { + let mut ids = HashMap::new(); + for r in list { + if let Some(target) = rel_target(r, names) { + let id = rels.add_raw(&r.rel_type, &target, r.external); + ids.insert(r.id.clone(), id); + } + } + ids +} + +fn remap(x: &str, ids: &HashMap) -> Option { + map_rel_ids(x, &mut |id| ids.get(id).cloned()) +} + +/// Writes the workbook's kept parts and relationships; returns the workbook XML elements that +/// go right after `` (``). +pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> String { + let Some(p) = wb.preserved.as_deref() else { return String::new() }; + let names = write_parts(out, &p.parts); + let ids = attach(wb_rels, &p.rels, &names); + for r in &p.package_rels { + if let Some(target) = rel_target(r, &names) { + out.kept.root.push((r.rel_type.clone(), target, r.external)); + } + } + p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect() +} + +/// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`. +pub fn write_package_rels(root: &mut Rels, out: &mut Out) { + for (ty, target, external) in std::mem::take(&mut out.kept.root) { + root.add_raw(&ty, &target, external); + } +} + +/// A sheet's kept content being written. +pub struct SheetKept<'a> { + p: Option<&'a Preserved>, + names: NameMap, + ids: HashMap, +} + +impl<'a> SheetKept<'a> { + /// Writes the sheet's kept parts and adds their relationships to the sheet's. + pub fn write(sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> SheetKept<'a> { + let Some(p) = sheet.preserved.as_deref() else { return SheetKept { p: None, names: NameMap::new(), ids: HashMap::new() } }; + let names = write_parts(out, &p.parts); + let ids = attach(rels, &p.rels, &names); + SheetKept { p: Some(p), names, ids } + } + + fn vml(&self) -> Option<&'a PreservedPart> { + self.p?.vml.as_ref() + } + + fn write_vml_rels(&self, vml: &PreservedPart, name: &str, out: &mut Out) { + if !vml.rels.is_empty() { + out.part(&rels_part_name(name), None, part_rels_xml(&vml.rels, &self.names).into_bytes()); + } + } + + /// GridCraft's notes VML for part `name`, merged with the kept control and object shapes. + pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String { + let Some(vml) = self.vml() else { return ours }; + self.write_vml_rels(vml, name, out); + merge_vml(&String::from_utf8_lossy(&vml.data), &ours) + } + + /// The kept VML drawing of a sheet without notes; its new relationship id. + pub fn lone_vml(&self, rels: &mut Rels, out: &mut Out) -> Option { + let vml = self.vml()?; + let name = out.kept.claim(&vml.name); + out.part(&name, Some(CT_VML), vml.data.to_vec()); + self.write_vml_rels(vml, &name, out); + Some(rels.add("vmlDrawing", &format!("/{name}"))) + } + + /// The new relationship id for `` (printer settings). + pub fn page_setup_id(&self) -> Option<&str> { + let old = &self.p?.elements.iter().find(|e| e.slot == "pageSetup")?.xml; + self.ids.get(old).map(String::as_str) + } + + /// The kept sheet elements, in schema order. + pub fn elements(&self) -> String { + let Some(p) = self.p else { return String::new() }; + let mut s = String::new(); + for slot in SHEET_SLOTS { + for e in p.elements.iter().filter(|e| e.slot == *slot) { + if let Some(x) = remap(&e.xml, &self.ids) { + s.push_str(&x); + } + } + } + s + } +} diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 050a9f3c6..50b568e5d 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -98,6 +98,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { return Err(IoError::Format(format!("unexpected root element <{}> in workbook part", wb_xml.name))); } let wb_rels = cx.pkg.rels(&wb_part)?; + let mut kept = crate::passthrough::KeptReader::new(&mut cx); cx.cell_pictures = match crate::richdata::read(&mut cx, &wb_rels) { Ok(pictures) => pictures, Err(error) => { @@ -212,6 +213,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)), Err(e) => cx.warn(format!("PivotTables on sheet \"{name}\" could not be read: {e}")), } + sheet.preserved = kept.sheet(&mut cx, &rel.target, &name); sheet.visibility = match se.attr("state") { Some("hidden") => Visibility::Hidden, Some("veryHidden") => Visibility::VeryHidden, @@ -308,9 +310,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if wb_rels.iter().any(|r| r.kind == "vbaProject") { cx.warn("macros (VBA project) are not supported and were dropped"); } - if wb_rels.iter().any(|r| r.kind == "externalLink") { - cx.warn("links to external workbooks are not supported"); - } + wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml); // Calls of LAMBDA names are spelled like the names (the parser upper-cases them). wb.apply_name_call_case(); wb.styles = cx.styles; diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 065558e6b..8f51f3320 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -183,7 +183,6 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_ext(cx, e, &mut sheet), - "oleObjects" | "controls" => cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not supported")), _ => {} } } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index b1e3fca2c..cfd1b6bf4 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -414,6 +414,8 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( } s.push_str("/>"); } + // Unmodelled content kept from the source file (printer settings, controls, OLE objects…). + let kept = crate::passthrough::SheetKept::write(sheet, &mut rels, out); let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64); let _ = write!(s, "", m(0), m(1), m(2), m(3), m(4), m(5)); @@ -429,7 +431,11 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if let Some((w, h)) = p.fit_to { let _ = write!(s, " fitToWidth=\"{w}\" fitToHeight=\"{h}\""); } - let _ = write!(s, " orientation=\"{}\"/>", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" }); + let _ = write!(s, " orientation=\"{}\"", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" }); + if let Some(id) = kept.page_setup_id() { + let _ = write!(s, " r:id=\"{id}\""); + } + s.push_str("/>"); if !p.header.is_empty() || !p.footer.is_empty() { s.push_str(""); if !p.header.is_empty() { @@ -506,11 +512,16 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let n = out.comments; out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet).into_bytes()); out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing"); - out.part(&format!("xl/drawings/vmlDrawing{n}.vml"), None, vml_xml(sheet, n).into_bytes()); + let vml_name = format!("xl/drawings/vmlDrawing{n}.vml"); + let vml = kept.notes_vml(vml_xml(sheet, n), &vml_name, out); + out.part(&vml_name, None, vml.into_bytes()); rels.add("comments", &format!("../comments{n}.xml")); let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml")); let _ = write!(s, ""); + } else if let Some(rid) = kept.lone_vml(&mut rels, out) { + let _ = write!(s, ""); } + s.push_str(&kept.elements()); // Tables. if !tables.is_empty() { let mut parts = String::new(); diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 43639fee4..cd5dacac3 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -5,6 +5,7 @@ mod csv_tests; mod fixtures; mod malformed; mod ods_tests; +mod passthrough_tests; mod pivot_tests; mod richdata_tests; mod roundtrip; diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs new file mode 100644 index 000000000..10b2681d7 --- /dev/null +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -0,0 +1,430 @@ +//! Pass-through of unmodelled package content (custom XML, external links, connections, query +//! tables, printer settings, form controls, OLE objects). Packages are built in code. + +use std::collections::HashMap; +use std::io::Read; +use std::sync::Arc; + +use gridcraft_model::Sheet; + +use super::make_zip; +use crate::package::{normalize, resolve, split_dir}; +use crate::xml; +use crate::{read_xlsx, write_xlsx}; + +const NS_R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const NS_MAIN: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; +const PRINTER: &[u8] = &[0, 1, 2, 3, 250, 251, 252, 253, 9, 9, 9]; +const OLE: &[u8] = b"\xD0\xCF\x11\xE0 not really a compound file"; +const EMF: &[u8] = b"\x01\x00\x00\x00 preview"; +const CUSTOM_XML: &str = r#"42"#; +const CUSTOM_PROPS: &str = r#""#; + +fn rels(items: &[(&str, &str, &str)]) -> String { + let mut s = String::from(r#""#); + for (id, ty, target) in items { + let ty = if ty.starts_with("http") { ty.to_string() } else { format!("{NS_R}/{ty}") }; + let ext = if target.contains("://") { r#" TargetMode="External""# } else { "" }; + s.push_str(&format!(r#""#)); + } + s.push_str(""); + s +} + +const CONTENT_TYPES: &str = r#""#; + +const EXTERNAL_LINK: &str = r#"7"#; +const CONNECTIONS: &str = r#""#; +const QUERY_TABLE: &str = r#""#; +const CTRL_PROP: &str = r#""#; +const COMMENTS: &str = r#"Anote"#; +const VML: &str = r##"1, 0, 1, 0, 3, 0, 5, 0Pict4, 0, 1, 0, 6, 0, 2, 0$C$1
old note
00
"##; + +/// A workbook with every kind of kept content: package root custom properties, custom XML, an +/// external link (with formulas using it), a connection, and on sheet "Data" a query table, +/// printer settings, an OLE object (in `mc:AlternateContent`), a form control, a note and the VML +/// drawing all of them share. +fn rich_package() -> Vec { + let workbook = format!( + r#""# + ); + let sheet1 = format!( + r#"[1]Sheet1!$A$17SUM([1]Sheet1!A1:A3)*21410103050"# + ); + let sheet2 = + format!(r#"1"#); + let root_rels = rels(&[("rId1", "officeDocument", "xl/workbook.xml"), ("rId2", "custom-properties", "docProps/custom.xml")]); + let wb_rels = rels(&[ + ("rId1", "worksheet", "worksheets/sheet1.xml"), + ("rId2", "worksheet", "worksheets/sheet2.xml"), + ("rId3", "externalLink", "externalLinks/externalLink1.xml"), + ("rId4", "customXml", "../customXml/item1.xml"), + ("rId5", "connections", "connections.xml"), + ]); + let sheet1_rels = rels(&[ + ("rId1", "printerSettings", "../printerSettings/printerSettings1.bin"), + ("rId2", "vmlDrawing", "../drawings/vmlDrawing1.vml"), + ("rId3", "oleObject", "../embeddings/oleObject1.bin"), + ("rId4", "image", "../media/image1.emf"), + ("rId5", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp", "../ctrlProps/ctrlProp1.xml"), + ("rId6", "queryTable", "../queryTables/queryTable1.xml"), + ("rId7", "comments", "../comments1.xml"), + ]); + let custom_rels = rels(&[("rId1", "customXmlProps", "itemProps1.xml")]); + let link_rels = rels(&[("rId1", "externalLinkPath", "file:///C:/never/opened/Book2.xlsx")]); + let vml_rels = rels(&[("rId1", "image", "../media/image1.emf")]); + let item_props = r#""#; + make_zip(&[ + ("[Content_Types].xml", CONTENT_TYPES.as_bytes()), + ("_rels/.rels", root_rels.as_bytes()), + ("docProps/custom.xml", CUSTOM_PROPS.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()), + ("xl/worksheets/sheet1.xml", sheet1.as_bytes()), + ("xl/worksheets/sheet2.xml", sheet2.as_bytes()), + ("xl/worksheets/_rels/sheet1.xml.rels", sheet1_rels.as_bytes()), + ("xl/printerSettings/printerSettings1.bin", PRINTER), + ("xl/drawings/vmlDrawing1.vml", VML.as_bytes()), + ("xl/drawings/_rels/vmlDrawing1.vml.rels", vml_rels.as_bytes()), + ("xl/embeddings/oleObject1.bin", OLE), + ("xl/media/image1.emf", EMF), + ("xl/ctrlProps/ctrlProp1.xml", CTRL_PROP.as_bytes()), + ("xl/queryTables/queryTable1.xml", QUERY_TABLE.as_bytes()), + ("xl/comments1.xml", COMMENTS.as_bytes()), + ("customXml/item1.xml", CUSTOM_XML.as_bytes()), + ("customXml/_rels/item1.xml.rels", custom_rels.as_bytes()), + ("customXml/itemProps1.xml", item_props.as_bytes()), + ("xl/externalLinks/externalLink1.xml", EXTERNAL_LINK.as_bytes()), + ("xl/externalLinks/_rels/externalLink1.xml.rels", link_rels.as_bytes()), + ("xl/connections.xml", CONNECTIONS.as_bytes()), + ]) +} + +/// An unpacked package: parts by name, content types, resolved relationships. +struct Unpacked { + parts: HashMap>, +} + +/// (id, type, resolved target, external) +type RelList = Vec<(String, String, String, bool)>; + +impl Unpacked { + fn new(bytes: &[u8]) -> Unpacked { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut parts = HashMap::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let mut v = vec![]; + f.read_to_end(&mut v).unwrap(); + parts.insert(f.name().to_string(), v); + } + Unpacked { parts } + } + fn text(&self, name: &str) -> String { + String::from_utf8(self.parts.get(name).unwrap_or_else(|| panic!("no part {name}")).clone()).unwrap() + } + fn rels(&self, part: &str) -> RelList { + let (dir, file) = split_dir(part); + let name = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") }; + let Some(b) = self.parts.get(&name) else { return vec![] }; + let root = xml::parse(b).unwrap(); + root.kids("Relationship") + .map(|r| { + let ext = r.attr("TargetMode") == Some("External"); + let t = r.attr("Target").unwrap(); + (r.attr("Id").unwrap().into(), r.attr("Type").unwrap().into(), if ext { t.into() } else { resolve(dir, t) }, ext) + }) + .collect() + } + fn target(&self, part: &str, id: &str) -> (String, String) { + let r = self.rels(part).into_iter().find(|r| r.0 == id).unwrap_or_else(|| panic!("{part}: no relationship {id}")); + (r.1, r.2) + } + /// Every part has a content type, every internal relationship target exists, rel ids are + /// unique per part. + fn assert_valid(&self) { + let types = xml::parse(self.parts.get("[Content_Types].xml").unwrap()).unwrap(); + let defaults: Vec = types.kids("Default").map(|d| d.attr("Extension").unwrap().to_ascii_lowercase()).collect(); + let overrides: Vec = types.kids("Override").map(|d| normalize(d.attr("PartName").unwrap())).collect(); + let mut seen_override = std::collections::HashSet::new(); + for o in &overrides { + assert!(seen_override.insert(o.clone()), "duplicate override {o}"); + assert!(self.parts.keys().any(|p| normalize(p) == *o), "override for a missing part {o}"); + } + for name in self.parts.keys().filter(|n| *n != "[Content_Types].xml") { + let ext = name.rsplit('.').next().unwrap().to_ascii_lowercase(); + assert!(overrides.contains(&normalize(name)) || defaults.contains(&ext), "{name} has no content type"); + if name.ends_with(".rels") { + let source = name.replace("_rels/", "").trim_end_matches(".rels").to_string(); + let rels = self.rels(&source); + let mut ids = std::collections::HashSet::new(); + for (id, _, target, ext) in rels { + assert!(ids.insert(id.clone()), "{name}: duplicate id {id}"); + if !ext { + assert!(self.parts.keys().any(|p| normalize(p) == normalize(&target)), "{name}: {id} → missing {target}"); + } + } + } + } + } + /// The part holding exactly `data`. + fn find(&self, data: &[u8]) -> Vec<&String> { + self.parts.iter().filter(|(_, v)| v.as_slice() == data).map(|(k, _)| k).collect() + } +} + +/// The value of attribute `attr` of the first `` in `x` (`r:id` of ` String { + let i = x.find(&format!("<{tag} ")).unwrap_or_else(|| panic!("no <{tag}> in {x}")); + let head = &x[i..i + x[i..].find('>').unwrap()]; + let j = head.find(&format!(" {attr}=\"")).unwrap_or_else(|| panic!("no {attr} in {head}")) + attr.len() + 3; + head[j..j + head[j..].find('"').unwrap()].to_string() +} + +fn sheet_part(u: &Unpacked, sheet: &str) -> String { + let wb = u.text("xl/workbook.xml"); + let i = wb.find(&format!(", its path kept as an external target. + let wbx = u.text("xl/workbook.xml"); + let link_id = attr_of(&wbx, "externalReference", "r:id"); + let (ty, link) = u.target("xl/workbook.xml", &link_id); + assert!(ty.ends_with("/externalLink")); + assert_eq!(u.parts.get(&link).unwrap(), EXTERNAL_LINK.as_bytes()); + let path = u.rels(&link); + assert_eq!(path.len(), 1); + assert_eq!((path[0].0.as_str(), path[0].2.as_str(), path[0].3), ("rId1", "file:///C:/never/opened/Book2.xlsx", true)); + assert!(wbx.find("
").unwrap() < wbx.find("[1]Sheet1!$A$1"), "{sx}"); + assert!(sx.contains("SUM([1]Sheet1!A1:A3)*2"), "{sx}"); + // Printer settings (#324). + let ps = attr_of(&sx, "pageSetup", "r:id"); + let (ty, target) = u.target(&sheet, &ps); + assert!(ty.ends_with("/printerSettings")); + assert_eq!(u.parts.get(&target).unwrap(), PRINTER); + // The OLE object and its preview image, re-linked. + let ole = attr_of(&sx, "oleObject", "r:id"); + assert_eq!(u.parts.get(&u.target(&sheet, &ole).1).unwrap(), OLE); + let preview = attr_of(&sx, "objectPr", "r:id"); + assert_eq!(u.parts.get(&u.target(&sheet, &preview).1).unwrap(), EMF); + // Namespace declarations travel with the fragment. + assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:mc"), "http://schemas.openxmlformats.org/markup-compatibility/2006"); + assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:x14"), "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"); + // The form control. + let ctrl = attr_of(&sx, "control", "r:id"); + assert_eq!(u.parts.get(&u.target(&sheet, &ctrl).1).unwrap(), CTRL_PROP.as_bytes()); + // Schema order: pageSetup < legacyDrawing < oleObjects < controls. + let pos = |t: &str| sx.find(t).unwrap_or_else(|| panic!("no {t}")); + assert!(pos(" = a.parts.iter().map(|p| &*p.data).collect(); + let mut db: Vec<&[u8]> = b.parts.iter().map(|p| &*p.data).collect(); + da.sort(); + db.sort(); + assert_eq!(da, db); + assert_eq!(a.elements.len(), b.elements.len()); + assert_eq!(wb.preserved.as_deref().unwrap().parts.len(), wb2.preserved.as_deref().unwrap().parts.len()); + u.assert_valid(); +} + +#[test] +fn edited_workbooks_keep_consistent_relationships() { + let (mut wb, _) = read_xlsx(&rich_package()).unwrap(); + // Insert a sheet before, rename the sheet with controls, drop its note, edit a cell. + wb.sheets.insert(0, Arc::new(Sheet::new("New"))); + let s = wb.sheet_mut(1).unwrap(); + s.name = "Renamed".into(); + s.comments.clear(); + s.set_value(gridcraft_core::CellRef::new(5, 5), gridcraft_core::Value::Number(3.0)); + let u = Unpacked::new(&write_xlsx(&wb).unwrap()); + u.assert_valid(); + let sheet = sheet_part(&u, "Renamed"); + assert_eq!(sheet, "xl/worksheets/sheet2.xml"); + let sx = u.text(&sheet); + assert_eq!(u.parts.get(&u.target(&sheet, &attr_of(&sx, "pageSetup", "r:id")).1).unwrap(), PRINTER); + assert_eq!(u.parts.get(&u.target(&sheet, &attr_of(&sx, "control", "r:id")).1).unwrap(), CTRL_PROP.as_bytes()); + // Without notes the kept VML is written on its own, still without the old note. + let vml = u.text(&u.target(&sheet, &attr_of(&sx, "legacyDrawing", "r:id")).1); + assert!(vml.contains("_x0000_s1026") && !vml.contains("ObjectType=\"Note\"")); + assert!(!u.text("xl/worksheets/sheet1.xml").contains("pageSetup orientation=\"landscape\" r:id")); + + // Deleting the sheet drops its content; workbook-level content stays. + wb.sheets.remove(1); + wb.names.clear(); + let u = Unpacked::new(&write_xlsx(&wb).unwrap()); + u.assert_valid(); + for d in [PRINTER, OLE, EMF, QUERY_TABLE.as_bytes(), CTRL_PROP.as_bytes()] { + assert!(u.find(d).is_empty()); + } + for d in [CUSTOM_XML.as_bytes(), EXTERNAL_LINK.as_bytes(), CONNECTIONS.as_bytes(), CUSTOM_PROPS.as_bytes()] { + assert_eq!(u.find(d).len(), 1); + } +} + +/// #324: an openpyxl-style workbook whose sheet carries printer settings. +#[test] +fn printer_settings_and_page_setup_id_survive_save() { + let ct = r#""#; + let bin: Vec = (0..=255u8).cycle().take(1024).collect(); + let pkg = super::minimal( + r#"42.1"#, + &[("xl/worksheets/_rels/sheet1.xml.rels", &rels(&[("rIdPs1", "printerSettings", "../printerSettings/printerSettings1.bin")]))], + "", + "", + ); + // Swap in content types that know `.bin`, and add the binary part. + let mut u = Unpacked::new(&pkg); + u.parts.insert("[Content_Types].xml".into(), ct.as_bytes().to_vec()); + u.parts.insert("xl/printerSettings/printerSettings1.bin".into(), bin.clone()); + let list: Vec<(&str, &[u8])> = u.parts.iter().map(|(k, v)| (k.as_str(), v.as_slice())).collect(); + let (wb, rep) = read_xlsx(&make_zip(&list)).unwrap(); + assert!(rep.warnings.iter().all(|w| !w.contains("printer")), "{:?}", rep.warnings); + let out = Unpacked::new(&write_xlsx(&wb).unwrap()); + out.assert_valid(); + let sx = out.text("xl/worksheets/sheet1.xml"); + assert!(sx.contains("orientation=\"landscape\"")); + let (ty, target) = out.target("xl/worksheets/sheet1.xml", &attr_of(&sx, "pageSetup", "r:id")); + assert!(ty.ends_with("/printerSettings")); + assert_eq!(target, "xl/printerSettings/printerSettings1.bin"); + assert_eq!(out.parts.get(&target).unwrap(), &bin); +} + +#[test] +fn damaged_kept_content_is_dropped_without_failing_the_read() { + let ct = CONTENT_TYPES.replace("", r#""#); + let workbook = format!( + r#""# + ); + let sheet = format!( + r#""# + ); + let wb_rels = rels(&[ + ("rId1", "worksheet", "worksheets/sheet1.xml"), + ("rId3", "externalLink", "externalLinks/externalLink1.xml"), + // A missing part. + ("rId4", "customXml", "../customXml/missing.xml"), + // A cycle: item1 → itemProps1 → item1. + ("rId5", "customXml", "../customXml/item1.xml"), + // Kept content that points into a part GridCraft rewrites. + ("rId6", "customXml", "../customXml/item2.xml"), + // Damaged rels behind a kept part. + ("rId7", "connections", "connections.xml"), + ]); + let cyclic = rels(&[("rId1", "customXmlProps", "itemProps1.xml")]); + let back = rels(&[("rId1", "customXml", "item1.xml")]); + let into_sheet = rels(&[("rId1", "worksheet", "../xl/worksheets/sheet1.xml")]); + let sheet_rels = rels(&[("rId1", "printerSettings", "../printerSettings/missing.bin")]); + let bytes = make_zip(&[ + ("[Content_Types].xml", ct.as_bytes()), + ("_rels/.rels", super::ROOT_RELS.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()), + ("xl/worksheets/sheet1.xml", sheet.as_bytes()), + ("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels.as_bytes()), + ("xl/externalLinks/externalLink1.xml", EXTERNAL_LINK.as_bytes()), + ("customXml/item1.xml", CUSTOM_XML.as_bytes()), + ("customXml/_rels/item1.xml.rels", cyclic.as_bytes()), + ("customXml/itemProps1.xml", b"

"), + ("customXml/_rels/itemProps1.xml.rels", back.as_bytes()), + ("customXml/item2.xml", b""), + ("customXml/_rels/item2.xml.rels", into_sheet.as_bytes()), + ("xl/connections.xml", CONNECTIONS.as_bytes()), + ("xl/_rels/connections.xml.rels", b" = p.parts.iter().map(|q| q.name.as_str()).collect(); + names.sort(); + assert_eq!(names, ["customXml/item1.xml", "customXml/itemProps1.xml"]); + assert!(p.elements.is_empty()); + assert!(wb.sheet(0).unwrap().preserved.is_none()); + let u = Unpacked::new(&write_xlsx(&wb).unwrap()); + u.assert_valid(); + assert!(!u.text("xl/workbook.xml").contains("externalReferences")); +} + +#[test] +fn oversized_kept_parts_are_dropped() { + let big = vec![0u8; crate::passthrough::MAX_KEPT_PART as usize + 1]; + let ct = CONTENT_TYPES; + let wb_rels = rels(&[("rId1", "worksheet", "worksheets/sheet1.xml"), ("rId2", "customXml", "../customXml/item1.xml")]); + let workbook = format!( + r#""# + ); + let sheet = format!(r#""#); + let bytes = make_zip(&[ + ("[Content_Types].xml", ct.as_bytes()), + ("_rels/.rels", super::ROOT_RELS.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()), + ("xl/worksheets/sheet1.xml", sheet.as_bytes()), + ("customXml/item1.xml", &big), + ]); + drop(big); + let (wb, rep) = read_xlsx(&bytes).unwrap(); + assert!(wb.preserved.is_none()); + assert!(rep.warnings.iter().any(|w| w.contains("customXml") && w.contains("dropped")), "{:?}", rep.warnings); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 8af3df1e8..96a803a39 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -49,6 +49,12 @@ impl Rels { self.items.push((id.clone(), ty.to_string(), target.to_string(), false)); id } + /// Adds a relationship with its complete type URI, internal or external (kept content). + pub fn add_raw(&mut self, ty: &str, target: &str, external: bool) -> String { + let id = format!("rId{}", self.items.len() + 1); + self.items.push((id.clone(), crate::xml::esc_attr(ty), target.to_string(), external)); + id + } pub fn is_empty(&self) -> bool { self.items.is_empty() } @@ -106,6 +112,8 @@ pub struct Out { pub dynamic_arrays: bool, pub needs_calc: bool, pub cell_pictures: crate::richdata::PicturesOut, + /// Kept (pass-through) parts written so far. + pub kept: crate::passthrough::KeptOut, } impl Out { @@ -158,6 +166,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { dynamic_arrays: false, needs_calc: false, cell_pictures: Default::default(), + kept: Default::default(), }; out.default_type("rels", "application/vnd.openxmlformats-package.relationships+xml"); out.default_type("xml", "application/xml"); @@ -247,6 +256,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { w.push_str(""); } let _ = write!(w, "{sheets_xml}"); + w.push_str(&crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out)); if !names.is_empty() { let _ = write!(w, "{names}"); } @@ -329,6 +339,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { root.add("officeDocument", "xl/workbook.xml"); root.add("core-properties", "docProps/core.xml"); root.add("extended-properties", "docProps/app.xml"); + crate::passthrough::write_package_rels(&mut root, &mut out); out.part("_rels/.rels", None, root.xml().into_bytes()); // Content types first, then the parts in creation order. diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index efa5cdcb1..3a4760e27 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -82,7 +82,7 @@ pub fn rich_text(e: &El) -> String { } /// Converts input bytes to UTF-8 XML text (handles BOMs and UTF-16). -fn to_utf8(xml: &[u8]) -> std::borrow::Cow<'_, [u8]> { +pub(crate) fn to_utf8(xml: &[u8]) -> std::borrow::Cow<'_, [u8]> { if let Some(rest) = xml.strip_prefix(&[0xEF, 0xBB, 0xBF]) { return std::borrow::Cow::Borrowed(rest); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index eeb2dd9b4..8959cc023 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; every format Excel reads or writes, against the code on main) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (XLSX pass-through of unmodelled parts, #330) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -15,7 +15,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures | | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, **without macros** | VBA project dropped with a warning; saving as `.xlsm` loses the macros (#6) | — | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the workbook content type, not the template one; "new from template" behaviour missing | — | @@ -62,7 +62,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Work | Hours | |---|---| | Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | -| Preserve unmodelled XLSX parts and extensions on round trip (pass-through with relationship fix-up) | 10–15 | +| Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | | Legacy `.xls` (BIFF8) import: cells, formulas, styles, names; then write | 20–30 | | Encrypted workbooks: open with password prompt, save with password (#11) | 8–12 | @@ -74,5 +74,6 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls, OLE objects, sheet backgrounds kept on save (#330, #324) | | 2026-10-10 | minor | Encrypted and `.xls` files are now recognised and refused with a message (#17) | | 2026-10-10 | major | Created: every Excel format with our read/write support; XLSB and ODS values-only imports recorded; `.xls`, encryption and large files flagged as beta gates | diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8d8..de3faba9d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (§3 narrowed by XLSX pass-through, #330) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -33,12 +33,14 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Impact:** finance and data users routinely have big workbooks. - **Estimate:** 25–40 h. [target-app-parity.md](target-app-parity.md#performance), [hardware-parity.md](hardware-parity.md) -### 3. Unmodelled XLSX content is dropped on save (FF) -- **Missing:** pass-through of parts and extensions we don't model (form controls, slicers, - timelines, external links, OLE objects, custom XML, query tables, data model, chart details). -- **Evidence:** reader warns and skips (`crates/xlsx/src/read.rs`); external links "not supported". -- **Impact:** open-edit-save silently simplifies colleagues' workbooks. -- **Estimate:** 10–15 h. [file-format-parity.md](file-format-parity.md) +### 3. Some unmodelled XLSX content is dropped on save (FF) +- **Missing:** slicers and their caches, timelines, the data model, query tables attached to + tables, unmodelled `extLst` entries, chart details. Pass-through (#330) keeps custom XML, + external links, connections, sheet query tables, printer settings, form and ActiveX controls, + OLE objects and sheet backgrounds; never checked in Excel (repair prompts unknown). +- **Evidence:** `crates/xlsx/src/passthrough.rs` (what is kept, and why the rest isn't). +- **Impact:** open-edit-save still simplifies workbooks with slicers, timelines or Power Pivot. +- **Estimate:** 6–10 h. [file-format-parity.md](file-format-parity.md) ### 4. Charts lose type and options on round trip; shallow chart model (F, FF) - **Missing:** kinds that survive XLSX (stock, combo, histogram, box, waterfall, funnel, treemap, @@ -149,6 +151,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §3: XLSX pass-through keeps custom XML, external links, connections, query tables, printer settings, controls and OLE objects (#330, #324) | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | | 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues | From 001f02511964ff0ddec099e114c0a7e065c2c3a9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:24:06 +0200 Subject: [PATCH 22/54] View: Page Layout view with rulers and in-place headers and footers (#329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View › Page Layout (and the status-bar button) now shows the sheet as printed pages on a desk instead of overlaying page outlines on the grid. - Model/XLSX: per-sheet view mode (normal / pageLayout / pageBreakPreview), saved as and read back. view.* commands set it per sheet ({sheet?}) without an undo step or dirtying a clean workbook. - Engine: paper::PageGrid paginates the sheet from A1 with the print pagination (print::split_bands, shared with PDF export and printing) and maps cells to pages and back; header/footer section helpers (split/join, &[Page] field names) shared with the PDF header code. pageLayout.headerFooter takes {part, section, text} for one section and a sheet; header/footer strings are capped at 255 characters. view.ruler toggles the rulers. - UI: Geo carries the page grid, so painting, hit-testing, selection, editing, objects and scrolling work through the page transform; frozen panes are ignored in this view. Pages with margins and gaps, "Click to add data" pages, rulers in inches (English) or centimetres, headers/footers drawn on printed pages, click a margin section to edit it in place; a click elsewhere commits (one undo step), Escape cancels. Header & Footer contextual tab: presets, Page Number, Number of Pages, Current Date/Time, File Path, File Name, Sheet Name, Go to Header/Footer. View › Ruler. - New strings in every catalog; new code-drawn icons; desk colour token. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/extra.rs | 51 +- crates/engine/src/cmd/print.rs | 249 +++++++++- crates/engine/src/cmd/view.rs | 41 +- crates/engine/src/lib.rs | 5 +- crates/engine/src/paper.rs | 322 ++++++++++++ crates/model/src/lib.rs | 2 +- crates/model/src/sheet.rs | 34 ++ crates/ui-egui/examples/snapshot.rs | 13 +- crates/ui-egui/src/grid.rs | 220 +++++++-- crates/ui-egui/src/i18n.rs | 26 + crates/ui-egui/src/i18n/ko.rs | 26 + crates/ui-egui/src/i18n/pt.rs | 26 + crates/ui-egui/src/i18n/ru.rs | 26 + crates/ui-egui/src/i18n/zh.rs | 26 + crates/ui-egui/src/icons.rs | 46 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/paper_view.rs | 605 +++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 11 + crates/ui-egui/src/tabs.rs | 2 +- crates/ui-egui/src/theme.rs | 4 + crates/ui-egui/tests/page_layout_view.rs | 126 +++++ crates/xlsx/src/sheet_read.rs | 1 + crates/xlsx/src/sheet_write.rs | 3 + crates/xlsx/src/tests/roundtrip.rs | 2 + docs/gaps.md | 7 +- docs/target-app-parity.md | 7 +- docs/ui-parity.md | 9 +- 28 files changed, 1789 insertions(+), 103 deletions(-) create mode 100644 crates/engine/src/paper.rs create mode 100644 crates/ui-egui/src/paper_view.rs create mode 100644 crates/ui-egui/tests/page_layout_view.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..2d7661a7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Page Layout view: pages with margins and rulers, headers and footers edited in place, Header & Footer tab, view saved per sheet in XLSX (#329) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index b6a362ba7..ef25d6500 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -116,9 +116,29 @@ pub fn specs() -> Vec { rotate ), // Views - cmd!(noundo "view.normal", "Normal", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "normal")), - cmd!(noundo "view.pageLayout", "Page Layout", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "pageLayout")), - cmd!(noundo "view.pageBreakPreview", "Page Break Preview", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "pageBreakPreview")), + cmd!(noundo "view.normal", "Normal", ["View", "Workbook Views"], None, "{sheet?} → {view} (per sheet, saved with the file)", has_doc, |s, p| set_view( + s, + p, + SheetViewMode::Normal + )), + cmd!( + noundo "view.pageLayout", + "Page Layout", + ["View", "Workbook Views"], + None, + "{sheet?} → {view}: pages on paper with rulers; headers and footers are edited in place (per sheet, saved with the file)", + has_doc, + |s, p| set_view(s, p, SheetViewMode::PageLayout) + ), + cmd!( + noundo "view.pageBreakPreview", + "Page Break Preview", + ["View", "Workbook Views"], + None, + "{sheet?} → {view} (per sheet, saved with the file)", + has_doc, + |s, p| set_view(s, p, SheetViewMode::PageBreakPreview) + ), cmd!(noundo "view.customViews", "Custom Views…", ["View", "Workbook Views"], None, "{save?: name, show?: name, delete?: name} → list", has_doc, custom_views), cmd!(noundo "view.split", "Split", ["View", "Window"], None, "{} (toggles a split at the active cell; shown as frozen panes in this version)", has_doc, split), cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} opens a second window on the same workbook (a linked copy)", has_doc, new_window), @@ -126,7 +146,7 @@ pub fn specs() -> Vec { cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{}", has_doc, hide_window), cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{index?}", has_doc, unhide_window), cmd!(query "view.switchWindows", "Switch Windows", ["View", "Window"], None, "{} → open windows", always, switch_windows), - cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?}", has_doc, |_, _| ok()), + cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?} → {on}: rulers in Page Layout view (an application option)", has_doc, ruler), cmd!(query "view.macros", "Macros", ["View", "Macros"], None, "{} → scripts", has_doc, all_scripts), // Table tools cmd!("table.resize", "Resize Table", ["Table Design", "Properties"], None, "{table?, range: \"A1:F20\"}", has_doc, table_resize), @@ -540,9 +560,26 @@ fn rotate(s: &mut Session, p: &Json) -> Result { with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height)) } -fn set_view(s: &mut Session, mode: &str) -> Result { - s.view_mode = mode.to_string(); - Ok(json!({"view": mode})) +/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it +/// neither records an undo step nor marks a clean workbook as changed. +fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result { + let si = target_sheet(s, p)?; + let d = s.doc_mut()?; + let dirty = d.is_dirty(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + if let Some(sh) = wb.sheet_mut(si) { + sh.view = mode; + } + if !dirty { + d.saved = d.wb.clone(); + } + Ok(json!({"view": mode.as_str()})) +} + +fn ruler(s: &mut Session, p: &Json) -> Result { + let on = bool_param(p, "on").unwrap_or(s.hide_ruler); + s.hide_ruler = !on; + Ok(json!({"on": on})) } const VIEWS_NAME: &str = "_sc_custom_views"; diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 033132124..aead37908 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -58,7 +58,7 @@ pub fn specs() -> Vec { } /// Sheet geometry is in pixels at 96 dpi; PDF units are points. -const PX_TO_PT: f32 = 0.75; +pub(crate) const PX_TO_PT: f32 = 0.75; /// Most pages one export produces. pub const MAX_PAGES: usize = 2000; /// Cells in a page block above which only stored cells are visited (not every grid position). @@ -110,13 +110,15 @@ pub struct SheetLayout { /// Heading sizes in sheet pixels (0 when headings are off). pub heading_w: f32, pub heading_h: f32, + /// Manual page breaks apply (they don't when the sheet is fitted to pages). + pub manual_breaks: bool, } impl SheetLayout { fn k(&self) -> f32 { PX_TO_PT * self.scale } - fn avail(&self) -> (f32, f32) { + pub(crate) fn avail(&self) -> (f32, f32) { let [l, r, t, b, ..] = self.margins; ((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0)) } @@ -271,30 +273,11 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid title_cols, heading_w, heading_h, + manual_breaks: fit.is_none(), }; let paginate = |lay: &mut SheetLayout, manual: bool| { - let k = lay.k(); - let (aw, ah) = lay.avail(); - let (aw, ah) = (aw / k, ah / k); - let rb: Vec = if manual { st.row_breaks.clone() } else { vec![] }; - let cb: Vec = if manual { st.col_breaks.clone() } else { vec![] }; - let (mut rb, mut cb) = (rb, cb); - rb.sort_unstable(); - cb.sort_unstable(); - let title_h = |start: u32| -> f32 { - match title_rows { - Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(), - _ => 0.0, - } - }; - let title_w = |start: u32| -> f32 { - match title_cols { - Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(), - _ => 0.0, - } - }; - lay.row_bands = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - heading_h - title_h(start)).max(1.0), &rb, MAX_PAGES); - lay.col_bands = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - heading_w - title_w(start)).max(1.0), &cb, MAX_PAGES); + lay.manual_breaks = manual; + (lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES); lay.pages.clear(); 'outer: for ci in 0..lay.col_bands.len() { for ri in 0..lay.row_bands.len() { @@ -332,6 +315,37 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid Some(lay) } +/// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each), +/// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The +/// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view. +pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) { + let k = lay.k(); + let (aw, ah) = lay.avail(); + let (aw, ah) = (aw / k, ah / k); + let st = &lay.settings; + let mut rb: Vec = if lay.manual_breaks { st.row_breaks.clone() } else { vec![] }; + let mut cb: Vec = if lay.manual_breaks { st.col_breaks.clone() } else { vec![] }; + rb.sort_unstable(); + cb.sort_unstable(); + let (title_rows, title_cols) = (lay.title_rows, lay.title_cols); + let title_h = |start: u32| -> f32 { + match title_rows { + Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(), + _ => 0.0, + } + }; + let title_w = |start: u32| -> f32 { + match title_cols { + Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(), + _ => 0.0, + } + }; + let area = lay.area; + let rows = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - lay.heading_h - title_h(start)).max(1.0), &rb, cap); + let cols = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - lay.heading_w - title_w(start)).max(1.0), &cb, cap); + (rows, cols) +} + /// Lays out every sheet a print command covers. pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec { let mut out = Vec::new(); @@ -375,6 +389,132 @@ pub struct HfContext { pub time: String, } +/// Today's date and the current time as headers and footers show them (`&D`, `&T`). +pub fn hf_date_time(wb: &Workbook) -> (String, String) { + let now = gridcraft_calc::now_serial(); + let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text; + let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text; + (date, time) +} + +/// Header/footer codes and the field names the Page Layout view shows for them while editing. +pub const HF_FIELDS: [(char, &str); 8] = [ + ('P', "&[Page]"), + ('N', "&[Pages]"), + ('D', "&[Date]"), + ('T', "&[Time]"), + ('Z', "&[Path]"), + ('F', "&[File]"), + ('A', "&[Tab]"), + ('G', "&[Picture]"), +]; + +/// Splits a header/footer string into its raw left, centre and right sections (codes kept). +/// Text before the first `&L`/`&C`/`&R` belongs to the centre, as when printing. +pub fn split_header_footer(spec: &str) -> [String; 3] { + let mut out: [String; 3] = Default::default(); + let mut cur = 1usize; + let chars: Vec = spec.chars().take(10_000).collect(); + let mut i = 0; + while let Some(&c) = chars.get(i) { + let next = chars.get(i + 1).copied(); + let sec = cur.min(2); + match (c, next) { + ('&', Some('L' | 'l')) => cur = 0, + ('&', Some('C' | 'c')) => cur = 1, + ('&', Some('R' | 'r')) => cur = 2, + ('&', Some('"')) => { + // &"Font,Style" is kept whole. + let end = chars[i + 2..].iter().position(|ch| *ch == '"').map_or(chars.len(), |p| i + 3 + p); + out[sec].extend(&chars[i..end.min(chars.len())]); + i = end; + continue; + } + ('&', Some(n)) => { + out[sec].push('&'); + out[sec].push(n); + } + ('&', None) => {} + (ch, _) => { + out[sec].push(ch); + i += 1; + continue; + } + } + i += 2; + } + out +} + +/// Joins raw left, centre and right sections into one header/footer string. +pub fn join_header_footer(sections: &[String; 3]) -> String { + let mut s = String::new(); + for (code, text) in ["&L", "&C", "&R"].iter().zip(sections) { + if !text.is_empty() { + s.push_str(code); + s.push_str(text); + } + } + s +} + +/// A raw section as the user edits it: `&P` shows as `&[Page]`, `&N` as `&[Pages]`, and so on. +pub fn codes_to_fields(raw: &str) -> String { + let mut out = String::new(); + let mut it = raw.chars().peekable(); + while let Some(c) = it.next() { + if c != '&' { + out.push(c); + continue; + } + // A dangling `&` at the end is dropped (printing ignores it too). + if let Some(n) = it.next() { + match HF_FIELDS.iter().find(|(code, _)| *code == n.to_ascii_uppercase()) { + Some((_, field)) => out.push_str(field), + None => { + out.push('&'); + out.push(n); + } + } + } + } + out +} + +/// The inverse of [`codes_to_fields`]: field names become codes again; a dangling `&` at the end +/// becomes a literal ampersand so it can't swallow the next section's code. +pub fn fields_to_codes(text: &str) -> String { + let chars: Vec = text.chars().take(10_000).collect(); + let mut out = String::new(); + let mut i = 0; + while let Some(&c) = chars.get(i) { + if c != '&' { + out.push(c); + i += 1; + continue; + } + if chars.get(i + 1) == Some(&'&') { + out.push_str("&&"); + i += 2; + continue; + } + let rest: String = chars[i..].iter().take(12).collect(); + if let Some((code, field)) = HF_FIELDS.iter().find(|(_, f)| rest.starts_with(f)) { + out.push('&'); + out.push(*code); + i += field.chars().count(); + continue; + } + if i + 1 == chars.len() { + out.push_str("&&"); + } else { + out.push('&'); + } + i += 1; + } + out +} + /// One header/footer section: its text and whether `&B` made it bold. #[derive(Clone, Debug, Default, PartialEq)] pub struct HfSection { @@ -1286,9 +1426,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str doc.info.title = if wb.props.title.is_empty() { file.to_string() } else { wb.props.title.clone() }; doc.info.author = wb.props.author.clone(); let total: usize = layouts.iter().map(|l| l.pages.len()).sum(); - let now = gridcraft_calc::now_serial(); - let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text; - let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text; + let (date, time) = hf_date_time(wb); let mut page_no = 0usize; for lay in layouts { let Some(sh) = wb.sheet(lay.sheet) else { continue }; @@ -1587,6 +1725,63 @@ mod tests { hay.windows(needle.len()).any(|w| w == needle.as_bytes()) } + #[test] + fn header_sections_split_join_and_field_names() { + let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D"; + let parts = split_header_footer(spec); + assert_eq!(parts, ["&\"Arial,Bold\"Sales &&Co".to_string(), "Page &P of &N".into(), "&D".into()]); + assert_eq!(join_header_footer(&parts), spec); + // Text before any section code is the centre section, as when printing. + assert_eq!(split_header_footer("Report"), [String::new(), "Report".into(), String::new()]); + assert_eq!(codes_to_fields("Page &P of &N, &A (&F) &&"), "Page &[Page] of &[Pages], &[Tab] (&[File]) &&"); + assert_eq!(fields_to_codes("Page &[Page] of &[Pages], &[Tab] (&[File]) &&"), "Page &P of &N, &A (&F) &&"); + // A dangling ampersand stays literal instead of eating the next section's code. + assert_eq!(fields_to_codes("R&"), "R&&"); + let joined = join_header_footer(&[fields_to_codes("R&"), "x".into(), String::new()]); + assert_eq!(split_header_footer(&joined)[0], "R&&"); + let cx = HfContext { page: 2, pages: 5, sheet: "Data".into(), ..Default::default() }; + let out = expand_header_footer(&join_header_footer(&[String::new(), fields_to_codes("&[Page] / &[Pages]"), fields_to_codes("&[Tab]")]), &cx); + assert_eq!((out[1].text.as_str(), out[2].text.as_str()), ("2 / 5", "Data")); + // Hostile input never panics. + for t in ["&", "&\"", "&\"unterminated", "&&&", "&[Pag", "\u{1F600}&[Page]"] { + let _ = join_header_footer(&split_header_footer(t)); + let _ = fields_to_codes(&codes_to_fields(t)); + } + } + + #[test] + fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() { + let mut s = s(); + s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&RRight"})).unwrap(); + let r = s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "center", "text": "Page &[Page] of &[Pages]"})).unwrap(); + assert_eq!(r["header"], "&LLeft&CPage &P of &N&RRight"); + s.execute("pageLayout.headerFooter", json!({"part": "footer", "section": "right", "text": "&[Date]"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.footer, "&R&D"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.header, "&LLeft&RRight"); + assert!(s.execute("pageLayout.headerFooter", json!({"part": "middle", "text": "x"})).is_err()); + assert!(s.execute("pageLayout.headerFooter", json!({"section": "top"})).is_err()); + let long = "x".repeat(1000); + s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "left", "text": long})).unwrap(); + assert!(s.doc().unwrap().wb.sheet(0).unwrap().print.header.chars().count() <= 255); + // The view is per sheet and doesn't dirty a clean workbook. + let mut fresh = Session::new(); + fresh.new_workbook(); + fresh.execute("home.insertSheet", json!({})).unwrap(); + fresh.doc_mut().unwrap().saved = fresh.doc().unwrap().wb.clone(); + fresh.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); + let wb = &fresh.doc().unwrap().wb; + assert_eq!((wb.sheet(0).unwrap().view, wb.sheet(1).unwrap().view), (SheetViewMode::PageLayout, SheetViewMode::Normal)); + assert!(!fresh.doc().unwrap().is_dirty(), "switching views is not an edit"); + s.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); + s.execute("view.normal", json!({"sheet": 0})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().view, SheetViewMode::Normal); + assert_eq!(s.execute("view.ruler", json!({"on": false})).unwrap()["on"], false); + assert!(s.hide_ruler); + assert_eq!(s.execute("view.ruler", json!({})).unwrap()["on"], true); + } + #[test] fn page_count_300_rows_letter_portrait() { let mut s = s(); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index d8450cdbf..1de5877e1 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -91,7 +91,7 @@ pub fn specs() -> Vec { "Header & Footer", ["Insert", "Text"], None, - "{header?: \"&C&P\", footer?: \"&CPage &P of &N\"}", + "{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\"} or one section: {sheet?, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer}", has_doc, header_footer ), @@ -281,17 +281,48 @@ fn breaks(s: &mut Session, p: &Json) -> Result { }) } +/// Excel keeps at most 255 characters per header or footer. +const MAX_HEADER_FOOTER: usize = 255; + fn header_footer(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "pageLayout.headerFooter"; + let si = target_sheet(s, p)?; + let cap = |t: &str| t.chars().take(MAX_HEADER_FOOTER).collect::(); + // One section edited in place (Page Layout view). + let section = match (str_param(p, "part"), str_param(p, "section")) { + (None, None) => None, + (part, sec) => { + let footer = match part.unwrap_or("header") { + "header" => false, + "footer" => true, + other => return Err(bad(CMD, format!("`part` must be header or footer, not `{other}`"))), + }; + let idx = match sec.unwrap_or("center") { + "left" => 0usize, + "center" | "centre" => 1, + "right" => 2, + other => return Err(bad(CMD, format!("`section` must be left, center or right, not `{other}`"))), + }; + Some((footer, idx, super::print::fields_to_codes(str_param(p, "text").unwrap_or("")))) + } + }; edit(s, |cx| { - let si = cx.sheet_index(); let pr = &mut cx.sheet_mut(si)?.print; if let Some(h) = str_param(p, "header") { - pr.header = h.to_string(); + pr.header = cap(h); } if let Some(f) = str_param(p, "footer") { - pr.footer = f.to_string(); + pr.footer = cap(f); } - Ok(Json::Null) + if let Some((footer, idx, text)) = §ion { + let spec = if *footer { &mut pr.footer } else { &mut pr.header }; + let mut parts = super::print::split_header_footer(spec); + if let Some(slot) = parts.get_mut(*idx) { + *slot = text.clone(); + } + *spec = cap(&super::print::join_header_footer(&parts)); + } + Ok(json!({"header": pr.header, "footer": pr.footer})) }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de786a1f4..1030709e4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod cmd; pub mod display; pub mod fill; pub mod io; +pub mod paper; pub mod pivot; pub mod sample; pub mod selection; @@ -190,8 +191,8 @@ pub struct Session { pub format_painter: Option<(Arc, usize, RangeRef, bool)>, /// Find state (last search). pub last_find: Option, - /// Workbook view: normal, pageLayout or pageBreakPreview. - pub view_mode: String, + /// View › Show › Ruler is off (rulers in Page Layout view; an application option). + pub hide_ruler: bool, /// Journal position where action recording started. pub recording: Option, /// Windows hidden with View › Hide. diff --git a/crates/engine/src/paper.rs b/crates/engine/src/paper.rs new file mode 100644 index 000000000..01065c09e --- /dev/null +++ b/crates/engine/src/paper.rs @@ -0,0 +1,322 @@ +//! Page Layout view geometry: a sheet shown as printed pages side by side. +//! +//! The view uses the print pagination ([`crate::cmd::print::split_bands`]) over the sheet from +//! A1, so its page edges are the printed ones whenever the print area starts at A1. Pages keep +//! going right and down past the data ("Click to add data" pages), as far as the view needs. +//! +//! Coordinates are *layout units*: sheet pixels (96 dpi) at the print scale, so a cell keeps its +//! sheet size and a page is `paper / scale` big. The UI multiplies by its zoom. Pages sit in a +//! grid with a gap around each: page `(row band, column band)` starts at +//! `(gap + j·(page_w + gap), gap + i·(page_h + gap))`, and its cells start at its top-left margin. + +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; +use gridcraft_model::{LineIndex, Sheet, Workbook}; + +use crate::cmd::print::{self, PX_TO_PT, PrintOptions}; + +/// Gap between pages on screen, in pixels at 100% zoom. +pub const PAGE_GAP: f32 = 14.0; + +/// One sheet paginated for the Page Layout view. +#[derive(Clone, Debug)] +pub struct PageGrid { + /// Page size in layout units. + pub page_w: f64, + pub page_h: f64, + /// Left, right, top, bottom, header and footer margins in layout units. + pub margins: [f64; 6], + /// Gap between pages in layout units. + pub gap: f64, + /// Print scale (1.0 = 100%): screen sizes of cells are `zoom × scale`. + pub scale: f32, + /// Paper size in points (after orientation), for the rulers. + pub paper_pt: (f32, f32), + pub row_bands: Vec<(u32, u32)>, + pub col_bands: Vec<(u32, u32)>, + /// The bands reach the sheet's last row / column. + pub rows_complete: bool, + pub cols_complete: bool, + /// Bands holding printed data: `[first, last]` row bands and column bands. + pub data_rows: (usize, usize), + pub data_cols: (usize, usize), + ri: LineIndex, + ci: LineIndex, +} + +/// A header or footer zone of one page. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HfZone { + pub footer: bool, + /// 0 left, 1 centre, 2 right. + pub section: usize, +} + +impl PageGrid { + /// Paginates sheet `si` far enough to cover row `need_row` and column `need_col` (and at + /// least the printed area). `None` when the sheet doesn't exist. + pub fn build(wb: &Workbook, si: usize, need_row: u32, need_col: u32) -> Option { + let sh = wb.sheet(si)?; + let printed = print::layout_sheet(wb, si, &PrintOptions::default(), None)?; + let mut lay = printed.clone(); + let end_row = need_row.max(printed.area.end.row).min(MAX_ROWS - 1); + let end_col = need_col.max(printed.area.end.col).min(MAX_COLS - 1); + lay.area = RangeRef::new(CellRef::new(0, 0), CellRef::new(end_row, end_col)); + let (row_bands, col_bands) = print::split_bands(sh, &lay, MAX_ROWS as usize); + let s = f64::from(lay.scale.clamp(0.1, 4.0)); + let to_layout = |pt: f32| f64::from(pt) / f64::from(PX_TO_PT) / s; + let margins = lay.margins.map(to_layout); + let band_range = |bands: &[(u32, u32)], lo: u32, hi: u32| { + let first = bands.iter().position(|b| b.1 >= lo).unwrap_or(0); + let last = bands.iter().rposition(|b| b.0 <= hi).unwrap_or(first).max(first); + (first, last) + }; + let area = printed.area; + let data_rows = band_range(&row_bands, area.start.row, area.end.row); + let data_cols = band_range(&col_bands, area.start.col, area.end.col); + Some(PageGrid { + page_w: to_layout(lay.page_w), + page_h: to_layout(lay.page_h), + margins, + gap: f64::from(PAGE_GAP) / s, + scale: lay.scale, + paper_pt: (lay.page_w, lay.page_h), + rows_complete: row_bands.last().is_some_and(|b| b.1 >= MAX_ROWS - 1), + cols_complete: col_bands.last().is_some_and(|b| b.1 >= MAX_COLS - 1), + row_bands, + col_bands, + data_rows, + data_cols, + ri: LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS), + ci: LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS), + }) + } + + /// The grid covers row `r` and column `c` (else it needs a rebuild with more pages). + pub fn covers(&self, r: u32, c: u32) -> bool { + (self.rows_complete || self.row_bands.last().is_some_and(|b| b.1 >= r)) + && (self.cols_complete || self.col_bands.last().is_some_and(|b| b.1 >= c)) + } + + fn band_of(bands: &[(u32, u32)], line: u32) -> usize { + bands.partition_point(|b| b.1 < line).min(bands.len().saturating_sub(1)) + } + + /// Left edge of page column `j` / top edge of page row `i`. + pub fn page_x(&self, j: usize) -> f64 { + self.gap + j as f64 * (self.page_w + self.gap) + } + pub fn page_y(&self, i: usize) -> f64 { + self.gap + i as f64 * (self.page_h + self.gap) + } + /// The page rectangle `(x, y, w, h)` of page `(i, j)`. + pub fn page_rect(&self, i: usize, j: usize) -> (f64, f64, f64, f64) { + (self.page_x(j), self.page_y(i), self.page_w, self.page_h) + } + /// Where the cells of page `(i, j)` go: inside the margins. + pub fn content_rect(&self, i: usize, j: usize) -> (f64, f64, f64, f64) { + let [l, r, t, b, ..] = self.margins; + (self.page_x(j) + l, self.page_y(i) + t, (self.page_w - l - r).max(1.0), (self.page_h - t - b).max(1.0)) + } + + /// Left edge of column `col`. + pub fn col_x(&self, col: u32) -> f64 { + let j = Self::band_of(&self.col_bands, col); + let start = self.col_bands.get(j).map_or(0, |b| b.0); + self.page_x(j) + self.margins[0] + (self.ci.start(col) - self.ci.start(start)) + } + /// Top edge of row `row`. + pub fn row_y(&self, row: u32) -> f64 { + let i = Self::band_of(&self.row_bands, row); + let start = self.row_bands.get(i).map_or(0, |b| b.0); + self.page_y(i) + self.margins[2] + (self.ri.start(row) - self.ri.start(start)) + } + + /// Page column under `x` (the nearest one in gaps). + pub fn page_col_at(&self, x: f64) -> usize { + let j = ((x - self.gap / 2.0) / (self.page_w + self.gap)).floor(); + (j.max(0.0) as usize).min(self.col_bands.len().saturating_sub(1)) + } + pub fn page_row_at(&self, y: f64) -> usize { + let i = ((y - self.gap / 2.0) / (self.page_h + self.gap)).floor(); + (i.max(0.0) as usize).min(self.row_bands.len().saturating_sub(1)) + } + + /// Column under `x`, clamped to the page's columns (margins and gaps hit the nearest one). + pub fn col_at(&self, x: f64) -> u32 { + let j = self.page_col_at(x); + let Some(&(a, b)) = self.col_bands.get(j) else { return 0 }; + let local = x - self.page_x(j) - self.margins[0]; + if local <= 0.0 { a } else { self.ci.at(self.ci.start(a) + local).clamp(a, b) } + } + pub fn row_at(&self, y: f64) -> u32 { + let i = self.page_row_at(y); + let Some(&(a, b)) = self.row_bands.get(i) else { return 0 }; + let local = y - self.page_y(i) - self.margins[2]; + if local <= 0.0 { a } else { self.ri.at(self.ri.start(a) + local).clamp(a, b) } + } + + /// The page `(i, j)` whose paper contains the point, if any. + pub fn page_at(&self, x: f64, y: f64) -> Option<(usize, usize)> { + let (i, j) = (self.page_row_at(y), self.page_col_at(x)); + let (px, py, w, h) = self.page_rect(i, j); + (x >= px && x < px + w && y >= py && y < py + h).then_some((i, j)) + } + + /// The point lies on cells (inside a page's margins, on a cell of that page). + pub fn on_cells(&self, x: f64, y: f64) -> bool { + let Some((i, j)) = self.page_at(x, y) else { return false }; + let (cx, cy, w, h) = self.content_rect(i, j); + let (Some(&(_, c1)), Some(&(_, r1))) = (self.col_bands.get(j), self.row_bands.get(i)) else { return false }; + // Cells end where the band ends (the rest of the printable area stays blank). + let right = (self.col_x(c1) + self.ci.start(c1 + 1) - self.ci.start(c1)).min(cx + w); + let bottom = (self.row_y(r1) + self.ri.start(r1 + 1) - self.ri.start(r1)).min(cy + h); + x >= cx && x < right && y >= cy && y < bottom + } + + /// The header or footer section under the point: the top or bottom margin of a page, split + /// into thirds between the side margins. + pub fn hf_zone_at(&self, x: f64, y: f64) -> Option<((usize, usize), HfZone)> { + let (i, j) = self.page_at(x, y)?; + let (px, py, _, _) = self.page_rect(i, j); + let [l, r, t, b, ..] = self.margins; + let footer = if y < py + t { + false + } else if y >= py + self.page_h - b { + true + } else { + return None; + }; + let inner = (self.page_w - l - r).max(3.0); + let section = (((x - px - l) / (inner / 3.0)).floor().max(0.0) as usize).min(2); + Some(((i, j), HfZone { footer, section })) + } + + /// Page `(i, j)` holds printed data (others are "Click to add data" pages). + pub fn is_data_page(&self, i: usize, j: usize) -> bool { + (self.data_rows.0..=self.data_rows.1).contains(&i) && (self.data_cols.0..=self.data_cols.1).contains(&j) + } + /// Printed page number of page `(i, j)` (down, then over) and the printed page count. + pub fn page_number(&self, i: usize, j: usize) -> Option<(usize, usize)> { + if !self.is_data_page(i, j) { + return None; + } + let rows = self.data_rows.1 - self.data_rows.0 + 1; + let cols = self.data_cols.1 - self.data_cols.0 + 1; + Some(((j - self.data_cols.0) * rows + (i - self.data_rows.0) + 1, rows * cols)) + } + + /// Page rows and columns overlapping the layout rectangle `[x0, x1] × [y0, y1]`. + pub fn pages_in(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> ((usize, usize), (usize, usize)) { + ((self.page_row_at(y0), self.page_row_at(y1)), (self.page_col_at(x0), self.page_col_at(x1))) + } + + /// Width / height of everything laid out so far. + pub fn extent(&self) -> (f64, f64) { + (self.page_x(self.col_bands.len()), self.page_y(self.row_bands.len())) + } + + /// Rows `(first, last)` of a page row and columns of a page column. + pub fn row_band(&self, i: usize) -> Option<(u32, u32)> { + self.row_bands.get(i).copied() + } + pub fn col_band(&self, j: usize) -> Option<(u32, u32)> { + self.col_bands.get(j).copied() + } +} + +/// Sheet-wide helper: the bottom-right cell a page grid for this sheet must reach so that the +/// layout rectangle up to `(x, y)` is covered. Layout coordinates never run ahead of sheet +/// coordinates (pages only add margins and gaps), so the sheet position is a safe bound. +pub fn need_for(sh: &Sheet, x: f64, y: f64) -> (u32, u32) { + (sh.row_at(y.max(0.0)).saturating_add(1).min(MAX_ROWS - 1), sh.col_at(x.max(0.0)).saturating_add(1).min(MAX_COLS - 1)) +} + +#[cfg(test)] +mod tests { + use gridcraft_model::{Orientation, Sheet}; + + use super::*; + + fn wb_with(rows: u32, cols: u32) -> Workbook { + let mut wb = Workbook::new(); + if let Some(sh) = wb.sheet_mut(0) { + for r in 0..rows { + for c in 0..cols { + sh.set_value(CellRef::new(r, c), gridcraft_core::Value::Number(1.0)); + } + } + } + wb + } + + #[test] + fn pages_match_print_pagination_and_cells_map_both_ways() { + let wb = wb_with(120, 14); + let g = PageGrid::build(&wb, 0, 400, 40).unwrap(); + let printed = print::layout_sheet(&wb, 0, &PrintOptions::default(), None).unwrap(); + // The printed area starts at A1, so the view's page edges are the printed ones (the + // last printed page simply ends with the data). + let starts = |b: &[(u32, u32)], n: usize| b.iter().take(n).map(|x| x.0).collect::>(); + assert!(printed.row_bands.len() > 1 && printed.col_bands.len() > 1); + assert_eq!(starts(&g.row_bands, printed.row_bands.len()), starts(&printed.row_bands, usize::MAX)); + assert_eq!(starts(&g.col_bands, printed.col_bands.len()), starts(&printed.col_bands, usize::MAX)); + assert_eq!(g.data_rows, (0, printed.row_bands.len() - 1)); + assert!(g.covers(400, 40)); + // Every cell maps to a point inside its own page's margins and back. + for (r, c) in [(0, 0), (5, 3), (60, 9), (119, 13), (300, 30)] { + let (x, y) = (g.col_x(c) + 1.0, g.row_y(r) + 1.0); + assert_eq!((g.row_at(y), g.col_at(x)), (r, c), "cell ({r},{c})"); + assert!(g.on_cells(x, y)); + let (i, j) = g.page_at(x, y).unwrap(); + let (cx, cy, _, _) = g.content_rect(i, j); + assert!(x >= cx && y >= cy); + } + // The first row of the second page starts below the first page plus a gap. + let (a, _) = g.row_bands[1]; + assert!((g.row_y(a) - (g.page_y(1) + g.margins[2])).abs() < 1e-6); + assert!(g.row_y(a) > g.page_h + g.gap); + } + + #[test] + fn margins_gaps_and_header_zones() { + let mut wb = wb_with(10, 3); + if let Some(sh) = wb.sheet_mut(0) { + sh.print.orientation = Orientation::Landscape; + } + let g = PageGrid::build(&wb, 0, 50, 20).unwrap(); + assert!(g.page_w > g.page_h, "landscape pages are wider than tall"); + // A point in the top margin hits the header, thirds pick the section. + let (px, py, w, h) = g.page_rect(0, 0); + let mid_top = (px + w / 2.0, py + g.margins[2] / 2.0); + assert_eq!(g.hf_zone_at(mid_top.0, mid_top.1), Some(((0, 0), HfZone { footer: false, section: 1 }))); + assert_eq!(g.hf_zone_at(px + g.margins[0] + 2.0, py + h - 2.0).map(|z| z.1), Some(HfZone { footer: true, section: 0 })); + assert_eq!(g.hf_zone_at(px + w - g.margins[1] - 2.0, py + 3.0).map(|z| z.1.section), Some(2)); + assert!(!g.on_cells(mid_top.0, mid_top.1), "margins aren't cells"); + // The gap between pages is no page at all. + assert_eq!(g.page_at(px + w + g.gap / 2.0, py + 10.0), None); + assert_eq!(g.hf_zone_at(px - 1.0, py + 3.0), None); + // Page numbers count printed pages only. + assert_eq!(g.page_number(0, 0), Some((1, 1))); + assert_eq!(g.page_number(0, 1), None); + assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0)); + } + + #[test] + fn print_scale_and_hostile_settings_stay_bounded() { + let mut wb = wb_with(3, 3); + if let Some(sh) = wb.sheet_mut(0) { + sh.print.scale = 50; + sh.print.margins = [f32::NAN, 1e9, -4.0, f32::INFINITY, 0.0, 0.0]; + sh.print.row_breaks = vec![0, 1, 2, u32::MAX]; + } + let g = PageGrid::build(&wb, 0, MAX_ROWS, MAX_COLS).unwrap(); + assert!(g.rows_complete && g.cols_complete); + assert!(g.page_w.is_finite() && g.page_h.is_finite() && g.page_w > 0.0); + let (ex, ey) = g.extent(); + assert!(ex.is_finite() && ey.is_finite()); + assert_eq!(g.row_at(ey * 2.0), MAX_ROWS - 1); + assert_eq!(g.col_at(-5.0), 0); + assert_eq!(need_for(&Sheet::new("S"), f64::MAX, f64::MAX), (MAX_ROWS - 1, MAX_COLS - 1)); + assert!(PageGrid::build(&wb, 7, 0, 0).is_none()); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index e3b8b6386..65f52ae2a 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -15,7 +15,7 @@ pub mod workbook; pub use cell::{Cell, CellPicture, Formula}; pub use features::*; -pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility}; +pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility}; pub use store::CellStore; pub use style::{ Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline, diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index d2d82ed94..ee0f380c3 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -19,6 +19,36 @@ pub enum Visibility { VeryHidden, } +/// How a sheet is displayed (View › Workbook Views; `` in XLSX). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SheetViewMode { + #[default] + Normal, + /// Pages on paper with rulers, margins and in-place headers and footers. + PageLayout, + PageBreakPreview, +} + +impl SheetViewMode { + /// The XLSX / command spelling: `normal`, `pageLayout` or `pageBreakPreview`. + pub fn as_str(self) -> &'static str { + match self { + SheetViewMode::Normal => "normal", + SheetViewMode::PageLayout => "pageLayout", + SheetViewMode::PageBreakPreview => "pageBreakPreview", + } + } + pub fn parse(s: &str) -> Option { + match s { + "normal" => Some(SheetViewMode::Normal), + "pageLayout" => Some(SheetViewMode::PageLayout), + "pageBreakPreview" => Some(SheetViewMode::PageBreakPreview), + _ => None, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Sheet { pub name: String, @@ -68,6 +98,9 @@ pub struct Sheet { /// Last selection and scroll position, saved with the file. pub view_active: CellRef, pub view_top_left: CellRef, + /// Normal, Page Layout or Page Break Preview. + #[serde(default)] + pub view: SheetViewMode, } /// Default width of a column (points) and height of a row (points) for the default font. @@ -111,6 +144,7 @@ impl Sheet { print: PrintSettings::default(), view_active: CellRef::default(), view_top_left: CellRef::default(), + view: SheetViewMode::Normal, } } diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 93359f03a..feed390c7 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -2,7 +2,7 @@ //! //! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx] //! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt] -//! [--dialog 'name={json}'] out.png` +//! [--dialog 'name={json}'] [--hf-edit header|footer] out.png` use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; @@ -21,6 +21,7 @@ fn main() { let mut chart: Option = None; let mut dialog: Option = None; let mut language: Option = None; + let mut hf_edit: Option = None; let mut out = "snapshot.png".to_string(); let mut i = 0; while i < args.len() { @@ -66,6 +67,10 @@ fn main() { i += 1; dialog = args.get(i).cloned(); } + "--hf-edit" => { + i += 1; + hf_edit = args.get(i).cloned(); + } "--language" => { i += 1; language = args.get(i).cloned(); @@ -113,6 +118,12 @@ fn main() { d.split_once('=').map(|(a, b)| (a, serde_json::from_str(b).unwrap_or(json!({})))).unwrap_or((d.as_str(), json!({}))); app.open_dialog(name, p); } + // Page Layout view: edit the first page's centre header (or footer) section. + let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet, s))); + if let (Some(part), Some((si, sh))) = (&hf_edit, active) { + let zone = gridcraft_engine::paper::HfZone { footer: part == "footer", section: 1 }; + gridcraft_ui_egui::paper_view::begin(&mut app, si, &sh, (0, 0), zone); + } app }, ); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..0a8ef12a6 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -84,6 +84,12 @@ pub struct GridState { /// Page ranges for Page Break Preview, cached per (doc uid, revision, sheet). pub pages: Option<((u64, u64, usize), Vec<(RangeRef, u32)>)>, pub header_menu: Option<(Pos2, bool)>, + /// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns). + pub paper: Option, + /// Header or footer being edited in place (Page Layout view). + pub hf_edit: Option, + /// The grid geometry of the last frame (hit-testing for agents and tests). + pub geo: Option, } impl GridState { @@ -111,6 +117,8 @@ pub struct Geo { /// Prefix sums over custom row heights / column widths, built once per frame. ri: std::sync::Arc, ci: std::sync::Arc, + /// Page Layout view: cells sit on pages (`scroll` is then in page-layout units). + pub paper: Option>, } impl Geo { @@ -127,9 +135,42 @@ impl Geo { let frozen_w = (ci.start(fc) as f32) * z; let frozen_h = (ri.start(fr) as f32) * z; let cells = Rect::from_min_max(pos2(rect.left() + header_w, rect.top() + header_h), rect.max); - Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, ri, ci } + Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, ri, ci, paper: None } + } + /// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale + /// on pages laid out by `grid`; frozen panes don't apply (as in Excel). + pub fn paged(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc) -> Geo { + let mut g = Geo::new(sh, rect, scroll); + let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); + g.z = zoom * DISPLAY_SCALE * grid.scale.clamp(0.1, 4.0); + (g.fr, g.fc, g.frozen_w, g.frozen_h) = (0, 0, 0.0, 0.0); + g.paper = Some(grid); + g + } + /// The point is over cells (in Page Layout view: on a page, inside its margins). + pub fn in_cells(&self, p: Pos2) -> bool { + if !self.cells.contains(p) { + return false; + } + match &self.paper { + Some(pg) => pg.on_cells( + self.scroll.x as f64 + ((p.x - self.cells.left()) / self.z) as f64, + self.scroll.y as f64 + ((p.y - self.cells.top()) / self.z) as f64, + ), + None => true, + } + } + /// Scroll offset (in `scroll` units) at which `row` reaches the top of the scrolled area. + pub fn scroll_top_of(&self, sh: &Sheet, row: u32) -> f32 { + match &self.paper { + Some(pg) => pg.row_y(row) as f32, + None => (sh.row_top(row) - sh.row_top(self.fr)) as f32, + } } pub fn x(&self, _sh: &Sheet, col: u32) -> f32 { + if let Some(pg) = &self.paper { + return self.cells.left() + (pg.col_x(col) - self.scroll.x as f64) as f32 * self.z; + } if col < self.fc { self.cells.left() + self.ci.start(col) as f32 * self.z } else { @@ -137,6 +178,9 @@ impl Geo { } } pub fn y(&self, _sh: &Sheet, row: u32) -> f32 { + if let Some(pg) = &self.paper { + return self.cells.top() + (pg.row_y(row) - self.scroll.y as f64) as f32 * self.z; + } if row < self.fr { self.cells.top() + self.ri.start(row) as f32 * self.z } else { @@ -155,6 +199,9 @@ impl Geo { Rect::from_min_max(a.min, b.max) } pub fn col_at(&self, _sh: &Sheet, x: f32) -> u32 { + if let Some(pg) = &self.paper { + return pg.col_at(self.scroll.x as f64 + ((x - self.cells.left()) / self.z) as f64); + } let dx = (x - self.cells.left()) / self.z; if dx < self.frozen_w / self.z { self.ci.at(dx.max(0.0) as f64) @@ -163,6 +210,9 @@ impl Geo { } } pub fn row_at(&self, _sh: &Sheet, y: f32) -> u32 { + if let Some(pg) = &self.paper { + return pg.row_at(self.scroll.y as f64 + ((y - self.cells.top()) / self.z) as f64); + } let dy = (y - self.cells.top()) / self.z; if dy < self.frozen_h / self.z { self.ri.at(dy.max(0.0) as f64) @@ -322,6 +372,19 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let Some(sh) = wb.sheet(si) else { return }; let sel = d.selection.clone(); let mut view = app.view(); + // Page Layout view: pages on a desk, rulers above and left of the headings. + let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; + let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; + let grid_rect = Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max); + let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { + if paper { + let probe = Geo::new(sh, grid_rect, scroll); + if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { + return Geo::paged(sh, grid_rect, scroll, g); + } + } + Geo::new(sh, rect, scroll) + }; // Scrolling. let (scroll_delta, zoom_delta, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers)); if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { @@ -329,7 +392,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round(); let _ = app.session.run("view.zoom", json!({"percent": z})); } else if scroll_delta != egui::Vec2::ZERO { - let z = sh.zoom as f32 / 100.0 * DISPLAY_SCALE; + let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; let mut d = scroll_delta; if modifiers.shift && d.x == 0.0 { d = vec2(d.y, 0.0); @@ -338,15 +401,28 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } } view.scroll = view.scroll.max(egui::Vec2::ZERO); - let max_y = sh.row_top(MAX_ROWS - 40) as f32; - let max_x = sh.col_left(MAX_COLS - 20) as f32; + let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32); + if paper { + // Page layout units run past sheet units (margins and gaps); the end is known once the + // pages reach the last row / column. + let g = make_geo(app, view.scroll); + if let Some(pg) = &g.paper { + let (ex, ey) = pg.extent(); + max_x = if pg.cols_complete { (ex as f32 - g.cells.width() / g.z).max(0.0) } else { 1.0e9 }; + max_y = if pg.rows_complete { (ey as f32 - g.cells.height() / g.z).max(0.0) } else { 1.0e9 }; + } + } view.scroll.x = view.scroll.x.min(max_x); view.scroll.y = view.scroll.y.min(max_y); - let mut geo = Geo::new(sh, rect, view.scroll); + let mut geo = make_geo(app, view.scroll); // Keep the active cell visible after keyboard moves. if app.grid.ensure_visible { app.grid.ensure_visible = false; let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(sel.active); + if paper { + crate::paper_view::ensure_covers(app, &wb, si, target); + geo = make_geo(app, view.scroll); + } let cr = geo.cell_rect(sh, target); let area = Rect::from_min_max(pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h), geo.cells.max); if target.row >= geo.fr { @@ -364,16 +440,70 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } } view.scroll = view.scroll.max(egui::Vec2::ZERO); - geo = Geo::new(sh, rect, view.scroll); + geo = make_geo(app, view.scroll); } // Snap scrolling to whole rows/columns at rest like Excel? Keep smooth (better on trackpads). if let Some(v) = app.view_mut() { *v = view; } app.grid.cells_rect = Some(geo.cells); + app.grid.geo = Some(geo.clone()); let painter = ui.painter_at(rect); painter.rect_filled(rect, 0.0, t.grid_bg); let mut cf = CfCache::new(); + let quads = if geo.paper.is_some() { + crate::paper_view::paint_pages(app, &painter, &geo, &wb, si, sh, &sel, &mut cf, &t) + } else { + paint_panes(app, &painter, &geo, &wb, si, sh, &sel, &mut cf, &t) + }; + // Objects above cells. + crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); + paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel); + if sh.view == gridcraft_engine::model::SheetViewMode::PageBreakPreview { + paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh); + } + if geo.paper.is_some() { + crate::paper_view::paint_headers_footers(app, ui, &painter, &geo, &wb, sh); + } + // Headers. + if sh.show_headings { + paint_headers(&painter, &geo, sh, &sel, &t, &quads); + } + if geo.paper.is_some() { + crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t); + } + // Interaction. + let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); + crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb); + interact(app, ui, &resp, &geo, sh, &wb); + crate::text_box::show(app, ui, &geo, sh); + if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { + in_cell_editor(app, ui, &geo, sh, &wb); + } + filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu); + list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); + header_menu(app, ui, previous_popups.2 == app.grid.header_menu); + context_menu(app, ui); + vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t); + if app.session.clipboard.is_some() { + app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); + } +} + +/// Normal view: up to four panes (frozen rows/columns), each painted with its own clip. +#[allow(clippy::too_many_arguments)] +fn paint_panes( + app: &SheetApp, + painter: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + cf: &mut CfCache, + t: &Tokens, +) -> Vec<((u32, u32), (u32, u32), Rect)> { // Quadrants: (rows, cols, clip). let (sr0, sr1) = geo.scroll_rows(sh); let (sc0, sc1) = geo.scroll_cols(sh); @@ -395,8 +525,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { continue; } let p = painter.with_clip_rect(*clip); - paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t); - paint_selection(&p, &geo, sh, &sel, &t, app); + paint_quadrant(&p, geo, wb, si, sh, *rows, *cols, cf, t); + paint_selection(&p, geo, sh, sel, t, app); } // Freeze lines. if geo.fr > 0 { @@ -405,32 +535,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if geo.fc > 0 { painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150))); } - // Objects above cells. - crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); - paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel); - if app.session.view_mode == "pageBreakPreview" || app.session.view_mode == "pageLayout" { - paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh); - } - // Headers. - if sh.show_headings { - paint_headers(&painter, &geo, sh, &sel, &t, &quads); - } - // Interaction. - let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); - interact(app, ui, &resp, &geo, sh, &wb); - crate::text_box::show(app, ui, &geo, sh); - if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { - in_cell_editor(app, ui, &geo, sh, &wb); - } - filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu); - list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); - header_menu(app, ui, previous_popups.2 == app.grid.header_menu); - context_menu(app, ui); - vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t); - if app.session.clipboard.is_some() { - app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); - } + quads } /// The worksheet is painted virtually, so it needs its own scrollbar rather than a @@ -443,7 +548,7 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G let visible = track.height() / geo.z; let scroll = app.view().scroll.y; let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); - let content = (sh.row_top(last) - sh.row_top(geo.fr)) as f32; + let content = geo.scroll_top_of(sh, last); let extent = (content - visible).max(scroll).max(0.0).min(limit); let id = ui.id().with("vscroll"); let resp = ui.interact(track, id, Sense::click_and_drag()); @@ -483,7 +588,17 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G } #[allow(clippy::too_many_arguments)] -fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, rows: (u32, u32), cols: (u32, u32), cf: &mut CfCache, t: &Tokens) { +pub(crate) fn paint_quadrant( + p: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + rows: (u32, u32), + cols: (u32, u32), + cf: &mut CfCache, + t: &Tokens, +) { let z = geo.z; let (r0, r1) = rows; let (c0, c1) = cols; @@ -901,7 +1016,7 @@ fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { } } -fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) { +pub(crate) fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) { // Fill all areas except the active cell. for r in &sel.ranges { if r.is_single() && sel.ranges.len() == 1 { @@ -973,7 +1088,14 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S } } -fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, quads: &[((u32, u32), (u32, u32), Rect)]) { +pub(crate) fn paint_headers( + p: &Painter, + geo: &Geo, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + t: &Tokens, + quads: &[((u32, u32), (u32, u32), Rect)], +) { let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0)); let r = geo.rect; let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top())); @@ -1049,7 +1171,7 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { // Text selection and editing must not also move the object or edit worksheet cells. - if app.text_box_editor.is_some() { + if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { return; } let ctx = ui.ctx().clone(); @@ -1058,7 +1180,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let pointer = ctx.input(|i| i.pointer.clone()); let mods = ctx.input(|i| i.modifiers); let pos = pointer.interact_pos().or(pointer.hover_pos()); - let in_cells = pos.is_some_and(|p| geo.cells.contains(p)); + let in_cells = pos.is_some_and(|p| geo.in_cells(p)); let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.rect.top() && p.x >= geo.cells.left()); let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.rect.left() && p.y >= geo.cells.top()); let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && geo.rect.contains(p)); @@ -1098,8 +1220,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if let Some(p) = pos && resp.dragged() { - let sx = sh.col_left(geo.fc) as f32 + geo.scroll.x + (p.x - geo.cells.left() - geo.frozen_w) / geo.z; - let sy = sh.row_top(geo.fr) as f32 + geo.scroll.y + (p.y - geo.cells.top() - geo.frozen_h) / geo.z; + // Through the cell under the pointer, so frozen panes and pages map right. + let c = geo.cell_at(sh, p); + let sx = sh.col_left(c.col) as f32 + (p.x - geo.x(sh, c.col)) / geo.z; + let sy = sh.row_top(c.row) as f32 + (p.y - geo.y(sh, c.row)) / geo.z; app.grid.ink.push([sx, sy]); } if !app.grid.ink.is_empty() { @@ -1109,10 +1233,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & .ink .iter() .map(|q| { - pos2( - geo.cells.left() + geo.frozen_w + (q[0] - sh.col_left(geo.fc) as f32 - geo.scroll.x) * geo.z, - geo.cells.top() + geo.frozen_h + (q[1] - sh.row_top(geo.fr) as f32 - geo.scroll.y) * geo.z, - ) + let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64)); + pos2(geo.x(sh, col) + (q[0] - sh.col_left(col) as f32) * geo.z, geo.y(sh, row) + (q[1] - sh.row_top(row) as f32) * geo.z) }) .collect(); ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9)))); @@ -1571,7 +1693,7 @@ fn on_border(r: Rect, p: Pos2) -> bool { fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> { let c = geo.col_at(sh, x); for cc in [c, c.saturating_sub(1)] { - let edge = geo.x(sh, cc + 1); + let edge = geo.x(sh, cc) + sh.col_width(cc) * geo.z; if (x - edge).abs() <= 4.0 { return Some((cc, edge)); } @@ -1582,7 +1704,7 @@ fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> { fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> { let r = geo.row_at(sh, y); for rr in [r, r.saturating_sub(1)] { - let edge = geo.y(sh, rr + 1); + let edge = geo.y(sh, rr) + sh.row_height(rr) * geo.z; if (y - edge).abs() <= 3.0 { return Some((rr, edge)); } @@ -1963,7 +2085,7 @@ pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option { } #[allow(clippy::too_many_arguments)] -fn paint_overlays( +pub(crate) fn paint_overlays( app: &mut SheetApp, ui: &egui::Ui, p: &Painter, diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..cf5b424be 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -416,6 +416,32 @@ const JAPANESE: &[(&str, &str)] = &[ ("More\nFunctions", "その他の関数"), ("Sheet Options|View", "画面"), ("Print", "印刷"), + // Page Layout view and the Header & Footer tab. + ("Click to add data", "クリックしてデータを追加"), + ("Add header", "ヘッダーの追加"), + ("Add footer", "フッターの追加"), + ("(none)", "(なし)"), + ("Header", "ヘッダー"), + ("Footer", "フッター"), + ("Page\nNumber", "ページ\n番号"), + ("Number\nof Pages", "ページ数"), + ("Current\nDate", "現在の\n日付"), + ("Current\nTime", "現在の\n時刻"), + ("File\nPath", "ファイルの\nパス"), + ("File\nName", "ファイル名"), + ("Sheet\nName", "シート名"), + ("Go to\nHeader", "ヘッダーに\n移動"), + ("Go to\nFooter", "フッターに\n移動"), + ("Page Number", "ページ番号"), + ("Number of Pages", "ページ数"), + ("Current Date", "現在の日付"), + ("Current Time", "現在の時刻"), + ("File Path", "ファイルのパス"), + ("File Name", "ファイル名"), + ("Sheet Name", "シート名"), + ("Go to Header", "ヘッダーに移動"), + ("Go to Footer", "フッターに移動"), + ("Ruler", "ルーラー"), ]; #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3cab..e7bd44c9f 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -268,6 +268,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "어둡게", "Sheet Options|View" => "화면", "Print" => "인쇄", + // Page Layout view and the Header & Footer tab. + "Click to add data" => "클릭하여 데이터 추가", + "Add header" => "머리글 추가", + "Add footer" => "바닥글 추가", + "(none)" => "(없음)", + "Header" => "머리글", + "Footer" => "바닥글", + "Page\nNumber" => "페이지\n번호", + "Number\nof Pages" => "페이지 수", + "Current\nDate" => "현재\n날짜", + "Current\nTime" => "현재\n시간", + "File\nPath" => "파일\n경로", + "File\nName" => "파일\n이름", + "Sheet\nName" => "시트\n이름", + "Go to\nHeader" => "머리글로\n이동", + "Go to\nFooter" => "바닥글로\n이동", + "Page Number" => "페이지 번호", + "Number of Pages" => "페이지 수", + "Current Date" => "현재 날짜", + "Current Time" => "현재 시간", + "File Path" => "파일 경로", + "File Name" => "파일 이름", + "Sheet Name" => "시트 이름", + "Go to Header" => "머리글로 이동", + "Go to Footer" => "바닥글로 이동", + "Ruler" => "눈금자", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3f2..0f9e0c19f 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -269,6 +269,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Escuro", "Sheet Options|View" => "Exibir", "Print" => "Imprimir", + // Page Layout view and the Header & Footer tab. + "Click to add data" => "Clique para adicionar dados", + "Add header" => "Adicionar cabeçalho", + "Add footer" => "Adicionar rodapé", + "(none)" => "(nenhum)", + "Header" => "Cabeçalho", + "Footer" => "Rodapé", + "Page\nNumber" => "Número\nda Página", + "Number\nof Pages" => "Número\nde Páginas", + "Current\nDate" => "Data\nAtual", + "Current\nTime" => "Hora\nAtual", + "File\nPath" => "Caminho\ndo Arquivo", + "File\nName" => "Nome do\nArquivo", + "Sheet\nName" => "Nome da\nPlanilha", + "Go to\nHeader" => "Ir para o\nCabeçalho", + "Go to\nFooter" => "Ir para o\nRodapé", + "Page Number" => "Número da Página", + "Number of Pages" => "Número de Páginas", + "Current Date" => "Data Atual", + "Current Time" => "Hora Atual", + "File Path" => "Caminho do Arquivo", + "File Name" => "Nome do Arquivo", + "Sheet Name" => "Nome da Planilha", + "Go to Header" => "Ir para o Cabeçalho", + "Go to Footer" => "Ir para o Rodapé", + "Ruler" => "Régua", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d5485658..b32f5786c 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -268,6 +268,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Тёмная", "Sheet Options|View" => "На экране", "Print" => "Печать", + // Page Layout view and the Header & Footer tab. + "Click to add data" => "Щелкните, чтобы добавить данные", + "Add header" => "Добавить верхний колонтитул", + "Add footer" => "Добавить нижний колонтитул", + "(none)" => "(нет)", + "Header" => "Верхний колонтитул", + "Footer" => "Нижний колонтитул", + "Page\nNumber" => "Номер\nстраницы", + "Number\nof Pages" => "Число\nстраниц", + "Current\nDate" => "Текущая\nдата", + "Current\nTime" => "Текущее\nвремя", + "File\nPath" => "Путь\nк файлу", + "File\nName" => "Имя\nфайла", + "Sheet\nName" => "Имя\nлиста", + "Go to\nHeader" => "Перейти к верхнему\nколонтитулу", + "Go to\nFooter" => "Перейти к нижнему\nколонтитулу", + "Page Number" => "Номер страницы", + "Number of Pages" => "Число страниц", + "Current Date" => "Текущая дата", + "Current Time" => "Текущее время", + "File Path" => "Путь к файлу", + "File Name" => "Имя файла", + "Sheet Name" => "Имя листа", + "Go to Header" => "Перейти к верхнему колонтитулу", + "Go to Footer" => "Перейти к нижнему колонтитулу", + "Ruler" => "Линейка", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada73..3f98ff7f2 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -268,6 +268,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "深色", "Sheet Options|View" => "屏幕", "Print" => "打印", + // Page Layout view and the Header & Footer tab. + "Click to add data" => "单击可添加数据", + "Add header" => "添加页眉", + "Add footer" => "添加页脚", + "(none)" => "(无)", + "Header" => "页眉", + "Footer" => "页脚", + "Page\nNumber" => "页码", + "Number\nof Pages" => "页数", + "Current\nDate" => "当前\n日期", + "Current\nTime" => "当前\n时间", + "File\nPath" => "文件\n路径", + "File\nName" => "文件名", + "Sheet\nName" => "工作表名", + "Go to\nHeader" => "转至\n页眉", + "Go to\nFooter" => "转至\n页脚", + "Page Number" => "页码", + "Number of Pages" => "页数", + "Current Date" => "当前日期", + "Current Time" => "当前时间", + "File Path" => "文件路径", + "File Name" => "文件名", + "Sheet Name" => "工作表名", + "Go to Header" => "转至页眉", + "Go to Footer" => "转至页脚", + "Ruler" => "标尺", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b2079c821..eb4606fe2 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -111,6 +111,14 @@ pub enum Icon { Sheet, Book, Folder, + /// Header & Footer: a page with its top / bottom band marked. + Header, + Footer, + PageNumber, + PageCount, + Calendar, + Clock, + FileName, } struct G<'a> { @@ -605,6 +613,44 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { Icon::Folder => { g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], YELLOW); } + Icon::Header | Icon::Footer => { + g.rect(3.5, 1.5, 9.0, 13.0, None, Some(c)); + let y = if icon == Icon::Header { 2.5 } else { 11.0 }; + g.rect(4.5, y, 7.0, 2.5, Some(BLUE), None); + for yy in if icon == Icon::Header { [7.0, 9.5] } else { [4.5, 7.0] } { + g.stroke(&[(5.5, yy), (10.5, yy)], light); + } + } + Icon::PageNumber | Icon::PageCount => { + if icon == Icon::PageCount { + g.rect(5.5, 1.0, 8.5, 11.5, None, Some(light)); + } + g.rect(2.5, 3.0, 8.5, 12.0, Some(Color32::WHITE), Some(c)); + // A "#" mark. + g.stroke(&[(5.3, 6.5), (4.8, 12.5)], BLUE); + g.stroke(&[(8.3, 6.5), (7.8, 12.5)], BLUE); + g.stroke(&[(4.0, 8.4), (9.6, 8.4)], BLUE); + g.stroke(&[(3.8, 10.8), (9.4, 10.8)], BLUE); + } + Icon::Calendar => { + g.rect(2.0, 3.0, 12.0, 11.0, Some(Color32::WHITE), Some(c)); + g.rect(2.0, 3.0, 12.0, 3.0, Some(BLUE), None); + for (x, y) in [(4.5, 8.5), (7.5, 8.5), (10.5, 8.5), (4.5, 11.5), (7.5, 11.5)] { + g.rect(x - 0.6, y - 0.6, 1.4, 1.4, Some(c), None); + } + } + Icon::Clock => { + let center = g.pt(8.0, 8.0); + g.p.circle_stroke(center, 6.0 * g.s, Stroke::new(1.15 * g.s.clamp(1.0, 2.0), c)); + g.stroke(&[(8.0, 4.5), (8.0, 8.0), (10.8, 9.6)], BLUE); + } + Icon::FileName => { + g.poly(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5)], Color32::WHITE); + g.line(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5), (3.0, 1.5)], c, 1.15); + g.stroke(&[(10.0, 1.5), (10.0, 4.5), (13.0, 4.5)], c); + g.line(&[(5.0, 9.0), (11.0, 9.0)], BLUE, 1.4); + g.stroke(&[(5.0, 11.5), (9.0, 11.5)], light); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eecdf..c1f0abe28 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,6 +20,7 @@ pub mod i18n; pub mod icons; pub mod keytips; pub mod panes; +pub mod paper_view; pub mod pivot_pane; pub mod ribbon; pub mod tabs; diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs new file mode 100644 index 000000000..ee9b78a1f --- /dev/null +++ b/crates/ui-egui/src/paper_view.rs @@ -0,0 +1,605 @@ +//! Page Layout view (View › Workbook Views › Page Layout): the sheet drawn as printed pages on a +//! desk, with rulers, margins, headers and footers. +//! +//! Pagination and the cell ↔ page mapping come from [`gridcraft_engine::paper::PageGrid`] (the +//! print pagination, so pages here are the printed pages). [`crate::grid::Geo`] carries the grid, +//! so painting, hit-testing, selection and editing work as in Normal view. Headers and footers are +//! edited in place: clicking a page's top or bottom margin edits that section, and the edit is +//! committed through `pageLayout.headerFooter` (one undo step) when the user clicks elsewhere. + +use std::sync::Arc; + +use egui::{Align2, Color32, FontId, Key, Painter, Pos2, Rect, Stroke, StrokeKind, TextEdit, pos2, vec2}; +use gridcraft_engine::cf::CfCache; +use gridcraft_engine::cmd::print::{self, HfContext}; +use gridcraft_engine::model::{Sheet, SheetViewMode, Workbook}; +use gridcraft_engine::paper::{self, HfZone, PageGrid}; +use serde_json::json; + +use crate::SheetApp; +use crate::grid::{self, DISPLAY_SCALE, Geo}; +use crate::icons::Icon; +use crate::theme::{self, Tokens}; + +/// Ruler thickness in points. +pub const RULER: f32 = 18.0; +/// Pages whose cells are painted in one frame; further visible pages only get their paper. +const MAX_PAGES_PAINTED: usize = 96; +/// Page frames considered per frame (tiny zooms show many pages). +const MAX_PAGE_FRAMES: usize = 2_000; +/// The contextual ribbon tab shown while a header or footer is edited. +pub const TAB: &str = "Header & Footer"; + +/// Cached pagination: (doc uid, revision, sheet, sheet identity) → grid. +pub type PaperCache = ((u64, u64, usize, usize), Arc); + +/// A header or footer being edited in place. +#[derive(Clone, Debug)] +pub struct HfEdit { + pub sheet: usize, + /// Page (row band, column band) whose header/footer is shown in edit. + pub page: (usize, usize), + pub footer: bool, + /// 0 left, 1 centre, 2 right. + pub section: usize, + /// The three sections as the user sees them (`&[Page]` field names). + pub texts: [String; 3], + original: [String; 3], + request_focus: bool, + /// Caret (characters) of the active section, kept while the ribbon has focus. + caret: usize, + set_caret: Option, + prev_tab: String, +} + +/// Rulers measure in inches for English, centimetres otherwise. +fn metric(app: &SheetApp) -> bool { + !matches!(app.ui.language, crate::i18n::Language::En) +} + +fn cache_key(app: &SheetApp, wb: &Workbook, si: usize) -> Option<(u64, u64, usize, usize)> { + let d = app.session.active()?; + Some((d.uid, d.revision, si, wb.sheets.get(si).map_or(0, |a| Arc::as_ptr(a) as usize))) +} + +/// Paginates at least as far as `cell` (before scrolling to it). +pub fn ensure_covers(app: &mut SheetApp, wb: &Workbook, si: usize, cell: gridcraft_engine::core::CellRef) { + let Some(key) = cache_key(app, wb, si) else { return }; + let (rows, cols) = match app.grid.paper.as_ref().filter(|(k, _)| *k == key) { + Some((_, g)) if g.covers(cell.row, cell.col) => return, + Some((_, g)) => (g.row_bands.last().map_or(0, |b| b.1), g.col_bands.last().map_or(0, |b| b.1)), + None => (0, 0), + }; + if let Some(g) = PageGrid::build(wb, si, rows.max(cell.row).saturating_add(64), cols.max(cell.col).saturating_add(8)) { + app.grid.paper = Some((key, Arc::new(g))); + } +} + +/// The page grid for the active sheet, covering what the view shows (cached). +pub fn grid_for(app: &mut SheetApp, wb: &Workbook, si: usize, sh: &Sheet, view: egui::Vec2, scroll: egui::Vec2) -> Option> { + let key = cache_key(app, wb, si)?; + let cached = app.grid.paper.as_ref().filter(|(k, _)| *k == key).map(|(_, g)| g.clone()); + let base = match cached { + Some(g) => g, + None => Arc::new(PageGrid::build(wb, si, 0, 0)?), + }; + let z = ((sh.zoom as f32 / 100.0).clamp(0.1, 4.0) * DISPLAY_SCALE * base.scale.clamp(0.1, 4.0)).max(0.001); + let (x1, y1) = (scroll.x as f64 + (view.x / z) as f64, scroll.y as f64 + (view.y / z) as f64); + let (need_r, need_c) = paper::need_for(sh, x1, y1); + let g = if base.covers(need_r, need_c) { + base + } else { + // Grow in steps so scrolling doesn't re-paginate every frame. + Arc::new(PageGrid::build(wb, si, need_r.saturating_add(511) / 512 * 512, need_c.saturating_add(31) / 32 * 32)?) + }; + app.grid.paper = Some((key, g.clone())); + Some(g) +} + +/// Layout point → screen. +fn screen(geo: &Geo, x: f64, y: f64) -> Pos2 { + pos2(geo.cells.left() + (x - geo.scroll.x as f64) as f32 * geo.z, geo.cells.top() + (y - geo.scroll.y as f64) as f32 * geo.z) +} + +fn screen_rect(geo: &Geo, (x, y, w, h): (f64, f64, f64, f64)) -> Rect { + Rect::from_min_max(screen(geo, x, y), screen(geo, x + w, y + h)) +} + +/// Visible pages (row band range, column band range). +fn visible_pages(geo: &Geo, pg: &PageGrid) -> ((usize, usize), (usize, usize)) { + let (x0, y0) = (geo.scroll.x as f64, geo.scroll.y as f64); + let (x1, y1) = (x0 + (geo.cells.width() / geo.z) as f64, y0 + (geo.cells.height() / geo.z) as f64); + pg.pages_in(x0, y0, x1, y1) +} + +fn pages_iter(geo: &Geo, pg: &PageGrid) -> impl Iterator { + let ((i0, i1), (j0, j1)) = visible_pages(geo, pg); + (j0..=j1).flat_map(move |j| (i0..=i1).map(move |i| (i, j))).take(MAX_PAGE_FRAMES) +} + +/// Paints the desk, the pages and their cells; returns the per-page quadrants for the headings. +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_pages( + app: &SheetApp, + p: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + cf: &mut CfCache, + t: &Tokens, +) -> Vec<((u32, u32), (u32, u32), Rect)> { + let mut quads = Vec::new(); + let Some(pg) = geo.paper.clone() else { return quads }; + p.rect_filled(geo.cells, 0.0, t.desk); + let cells = p.with_clip_rect(geo.cells); + let mut painted = 0usize; + for (i, j) in pages_iter(geo, &pg) { + let page = screen_rect(geo, pg.page_rect(i, j)); + if !page.intersects(geo.cells) { + continue; + } + cells.rect_filled(page.translate(vec2(1.5, 2.0)), 0.0, t.shadow); + cells.rect_filled(page, 0.0, t.grid_bg); + let (Some((r0, r1)), Some((c0, c1))) = (pg.row_band(i), pg.col_band(j)) else { continue }; + let clip = screen_rect(geo, pg.content_rect(i, j)).intersect(geo.cells); + if !clip.is_positive() { + continue; + } + let rows = (geo.row_at(sh, clip.top()).max(r0), geo.row_at(sh, clip.bottom() - 0.5).min(r1)); + let cols = (geo.col_at(sh, clip.left()).max(c0), geo.col_at(sh, clip.right() - 0.5).min(c1)); + if rows.0 > rows.1 || cols.0 > cols.1 { + continue; + } + quads.push((rows, cols, clip)); + if painted >= MAX_PAGES_PAINTED { + continue; + } + painted += 1; + let pc = p.with_clip_rect(clip); + grid::paint_quadrant(&pc, geo, wb, si, sh, rows, cols, cf, t); + if !pg.is_data_page(i, j) { + // "Click to add data" pages: faded until something is typed there. + pc.rect_filled(clip, 0.0, t.grid_bg.gamma_multiply(0.75)); + let size = (14.0 * geo.z / DISPLAY_SCALE).clamp(8.0, 22.0); + pc.text(clip.center(), Align2::CENTER_CENTER, tr(app, "Click to add data"), theme::ui_font(size), Color32::from_gray(150)); + } + // The selection outline may straddle the margin edge (as at A1 in Normal view). + let sel_clip = clip.expand(3.0).intersect(page).intersect(geo.cells); + grid::paint_selection(&p.with_clip_rect(sel_clip), geo, sh, sel, t, app); + } + quads +} + +fn tr<'a>(app: &SheetApp, s: &'a str) -> &'a str { + app.ui.language.tr(s) +} + +/// Section rectangles (left, centre, right) of a page's header or footer on screen. +fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool) -> [Rect; 3] { + let (px, py, w, h) = pg.page_rect(i, j); + let [l, r, t, b, ..] = pg.margins; + let (y0, y1) = if footer { (py + h - b, py + h) } else { (py, py + t) }; + let inner = (w - l - r).max(3.0); + let third = inner / 3.0; + let pad = 3.0 / geo.z as f64; + let mut out = [Rect::NOTHING; 3]; + for (k, slot) in out.iter_mut().enumerate() { + let x = px + l + third * k as f64; + *slot = screen_rect(geo, (x, y0 + pad, third, (y1 - y0 - 2.0 * pad).max(1.0))); + } + out +} + +fn hf_font(geo: &Geo, size_pt: Option, bold: bool) -> FontId { + let px = size_pt.unwrap_or(10.0).clamp(1.0, 72.0) * 96.0 / 72.0 * geo.z; + let px = px.clamp(4.0, 120.0); + if bold { theme::ui_bold(px) } else { theme::ui_font(px) } +} + +/// Draws headers and footers on the visible printed pages, and the "Add header" hints on hover. +pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter, geo: &Geo, wb: &Workbook, sh: &Sheet) { + let Some(pg) = geo.paper.clone() else { return }; + let Some(d) = app.session.active() else { return }; + let p = p.with_clip_rect(geo.cells); + let editing = app.grid.hf_edit.as_ref().map(|e| (e.page, e.footer)); + let hover = ui.input(|i| i.pointer.hover_pos()).filter(|q| geo.cells.contains(*q)).and_then(|q| { + let (x, y) = + (geo.scroll.x as f64 + ((q.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((q.y - geo.cells.top()) / geo.z) as f64); + pg.hf_zone_at(x, y) + }); + let needs_text = !sh.print.header.is_empty() || !sh.print.footer.is_empty(); + let (date, time) = if needs_text { print::hf_date_time(wb) } else { Default::default() }; + for (i, j) in pages_iter(geo, &pg) { + let page = screen_rect(geo, pg.page_rect(i, j)); + if !page.intersects(geo.cells) { + continue; + } + for (spec, footer) in [(&sh.print.header, false), (&sh.print.footer, true)] { + if editing == Some(((i, j), footer)) { + continue; + } + let rects = section_rects(geo, &pg, (i, j), footer); + let hovered = hover.is_some_and(|(page, z)| page == (i, j) && z.footer == footer); + if hovered { + for r in &rects { + p.rect_stroke(*r, 0.0, Stroke::new(1.0, Color32::from_gray(185)), StrokeKind::Inside); + } + } + let Some((n, total)) = pg.page_number(i, j).or_else(|| (spec.is_empty()).then_some((1, 1))) else { continue }; + if spec.is_empty() { + if hovered { + let label = if footer { "Add footer" } else { "Add header" }; + p.text(rects[1].center(), Align2::CENTER_CENTER, tr(app, label), hf_font(geo, Some(10.0), false), Color32::from_gray(120)); + } + continue; + } + let cx = HfContext { + page: n, + pages: total, + sheet: sh.name.clone(), + file: d.display_title(), + path: d.path.clone().unwrap_or_default(), + date: date.clone(), + time: time.clone(), + }; + let secs = print::expand_header_footer(spec, &cx); + let (_, py, _, h) = pg.page_rect(i, j); + let [_, _, _, _, hm, fm] = pg.margins; + for (k, sec) in secs.iter().enumerate() { + if sec.text.trim().is_empty() { + continue; + } + let font = hf_font(geo, sec.size, sec.bold); + let r = rects[k]; + let (anchor, x) = match k { + 0 => (Align2::LEFT_TOP, r.left()), + 1 => (Align2::CENTER_TOP, r.center().x), + _ => (Align2::RIGHT_TOP, r.right()), + }; + let lines: Vec<&str> = sec.text.split('\n').take(20).collect(); + let line_h = font.size * 1.2; + let block = line_h * lines.len() as f32; + let top = if footer { screen(geo, 0.0, py + h - fm).y - block } else { screen(geo, 0.0, py + hm).y }; + for (li, line) in lines.iter().enumerate() { + p.text(pos2(x, top + li as f32 * line_h), anchor, *line, font.clone(), Color32::BLACK); + } + } + } + } +} + +/// Clicks on headers and footers, and the in-place editor. Runs before the grid's own +/// interaction (which stands aside while a header or footer is being edited). +pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { + // Leaving the sheet or the view commits. + if let Some(e) = &app.grid.hf_edit + && (e.sheet != wb.active_sheet || sh.view != SheetViewMode::PageLayout || geo.paper.is_none()) + { + commit(app); + } + let Some(pg) = geo.paper.clone() else { return }; + if resp.clicked_by(egui::PointerButton::Primary) + && let Some(p) = ui.input(|i| i.pointer.interact_pos()) + { + let (x, y) = + (geo.scroll.x as f64 + ((p.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((p.y - geo.cells.top()) / geo.z) as f64); + match pg.hf_zone_at(x, y) { + Some((page, zone)) if geo.cells.contains(p) => begin(app, wb.active_sheet, sh, page, zone), + _ => { + // A click anywhere else ends the edit; the grid then handles the click. + if app.grid.hf_edit.is_some() { + commit(app); + } + } + } + } + editor(app, ui, geo, &pg); +} + +/// Starts (or moves) editing a header or footer section. +pub fn begin(app: &mut SheetApp, sheet: usize, sh: &Sheet, page: (usize, usize), zone: HfZone) { + if let Some(e) = app.grid.hf_edit.as_mut() + && e.sheet == sheet + && e.page == page + && e.footer == zone.footer + { + if e.section != zone.section { + e.section = zone.section; + e.caret = e.texts.get(zone.section).map_or(0, |t| t.chars().count()); + e.set_caret = Some(e.caret); + } + e.request_focus = true; + return; + } + if app.grid.hf_edit.is_some() { + commit(app); + } + // A cell being edited is entered first, as when clicking another cell. + if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { + return; + } + let spec = if zone.footer { &sh.print.footer } else { &sh.print.header }; + let texts = print::split_header_footer(spec).map(|s| print::codes_to_fields(&s)); + let caret = texts.get(zone.section).map_or(0, |t| t.chars().count()); + let prev_tab = if app.ui.ribbon_tab == TAB { "Home".to_string() } else { app.ui.ribbon_tab.clone() }; + app.grid.hf_edit = Some(HfEdit { + sheet, + page, + footer: zone.footer, + section: zone.section.min(2), + original: texts.clone(), + texts, + request_focus: true, + caret, + set_caret: Some(caret), + prev_tab, + }); + app.ui.ribbon_tab = TAB.to_string(); + app.ui.ribbon_collapsed = false; +} + +/// Ends editing and writes the header or footer when it changed (one undo step). +pub fn commit(app: &mut SheetApp) { + let Some(e) = app.grid.hf_edit.take() else { return }; + if app.ui.ribbon_tab == TAB { + app.ui.ribbon_tab = e.prev_tab.clone(); + } + if e.texts == e.original { + return; + } + let spec = print::join_header_footer(&e.texts.clone().map(|s| print::fields_to_codes(&s))); + let key = if e.footer { "footer" } else { "header" }; + app.run_or_alert("pageLayout.headerFooter", json!({"sheet": e.sheet, key: spec})); +} + +/// Ends editing without writing. +pub fn cancel(app: &mut SheetApp) { + if let Some(e) = app.grid.hf_edit.take() + && app.ui.ribbon_tab == TAB + { + app.ui.ribbon_tab = e.prev_tab; + } +} + +/// Inserts a field (`&[Page]`…) at the caret of the section being edited. +pub fn insert_field(app: &mut SheetApp, field: &str) { + let Some(e) = app.grid.hf_edit.as_mut() else { return }; + let Some(text) = e.texts.get_mut(e.section) else { return }; + let at = text.char_indices().nth(e.caret).map_or(text.len(), |(b, _)| b); + text.insert_str(at, field); + e.caret += field.chars().count(); + e.set_caret = Some(e.caret); + e.request_focus = true; +} + +/// Moves editing to the header or footer of the same page (Go to Header / Go to Footer). +pub fn go_to(app: &mut SheetApp, footer: bool) { + let Some(e) = app.grid.hf_edit.as_ref() else { return }; + if e.footer == footer { + return; + } + let (sheet, page, section) = (e.sheet, e.page, e.section); + commit(app); + let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(sheet)).cloned() else { return }; + begin(app, sheet, &sh, page, HfZone { footer, section }); +} + +fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { + let Some(mut e) = app.grid.hf_edit.take() else { return }; + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { + app.grid.hf_edit = Some(e); + cancel(app); + return; + } + let rects = section_rects(geo, pg, e.page, e.footer); + let p = ui.painter().with_clip_rect(geo.cells); + let t = Tokens::get(ui.ctx()); + let font = hf_font(geo, Some(10.0), false); + let mut switch_to = None; + for (k, r) in rects.iter().enumerate() { + if !r.intersects(geo.cells) { + continue; + } + p.rect_filled(*r, 0.0, Color32::WHITE); + let active = k == e.section; + p.rect_stroke( + *r, + 0.0, + Stroke::new(if active { 1.5 } else { 1.0 }, if active { t.sel_border } else { Color32::from_gray(160) }), + StrokeKind::Inside, + ); + if active { + continue; + } + let Some(text) = e.texts.get(k) else { continue }; + let anchor = [Align2::LEFT_TOP, Align2::CENTER_TOP, Align2::RIGHT_TOP][k]; + let x = [r.left() + 3.0, r.center().x, r.right() - 3.0][k]; + p.text(pos2(x, r.top() + 2.0), anchor, text, font.clone(), Color32::BLACK); + if ui.interact(*r, ui.id().with(("hf_section", k)), egui::Sense::click()).clicked() { + switch_to = Some(k); + } + } + // Scrolled away: keep the edit, show it again when the page comes back. + let Some(r) = rects.get(e.section).copied().filter(|r| r.intersects(geo.cells)) else { + app.grid.hf_edit = Some(e); + return; + }; + let id = egui::Id::new("gridcraft.header_footer_editor"); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(3.0, 2.0)).intersect(geo.cells))); + let align = [egui::Align::LEFT, egui::Align::Center, egui::Align::RIGHT][e.section.min(2)]; + if let Some(text) = e.texts.get_mut(e.section) { + let mut output = TextEdit::multiline(text) + .id(id) + .frame(egui::Frame::NONE) + .font(font) + .text_color(Color32::BLACK) + .background_color(Color32::WHITE) + .horizontal_align(align) + .desired_width(r.width() - 6.0) + .desired_rows(1) + .lock_focus(true) + .show(&mut child); + if e.request_focus { + output.response.request_focus(); + e.request_focus = false; + } + if let Some(c) = e.set_caret.take() { + output.state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(c)))); + output.state.store(ui.ctx(), id); + } else if let Some(cr) = output.cursor_range { + e.caret = cr.primary.index.into(); + } + } + if let Some(k) = switch_to { + e.section = k; + e.caret = e.texts.get(k).map_or(0, |t| t.chars().count()); + e.set_caret = Some(e.caret); + e.request_focus = true; + } + app.grid.hf_edit = Some(e); +} + +/// Horizontal and vertical rulers (`outer` is the grid area including the rulers). +pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, t: &Tokens) { + let Some(pg) = geo.paper.clone() else { return }; + if geo.rect.top() - outer.top() < RULER - 0.5 { + return; + } + let metric = metric(app); + // Screen points per unit: an inch is 96 sheet pixels at the print scale. + let inch = 96.0 / pg.scale.clamp(0.1, 4.0) * geo.z; + let unit = if metric { inch / 2.54 } else { inch }; + let minor = if metric { 2.0 } else { 4.0 }; + let label_every = ((22.0 / unit).ceil() as usize).max(1); + let font = theme::ui_font(9.5); + let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); + let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window); + p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window); + let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg); + let [ml, mr, mt, mb, ..] = pg.margins; + // One ruler span per page: margins shaded, the printable part light, ticks from the margin. + let span = |p: &Painter, from: f32, to: f32, m0: f32, m1: f32, horizontal: bool, area: Rect| { + let band = |a: f32, b: f32| { + if horizontal { + Rect::from_min_max(pos2(a, area.top() + 2.0), pos2(b, area.bottom() - 2.0)) + } else { + Rect::from_min_max(pos2(area.left() + 2.0, a), pos2(area.right() - 2.0, b)) + } + }; + let pc = p.with_clip_rect(area); + pc.rect_filled(band(from, to), 0.0, t.header_sel_bg); + pc.rect_filled(band(m0, m1), 0.0, t.header_bg); + if unit < 2.0 { + return; + } + let steps = (((m1 - m0) / unit * minor).ceil() as usize).min(4_000); + for s in 0..=steps { + let pos = m0 + s as f32 * unit / minor; + if pos > m1 + 0.5 { + break; + } + let major = s.is_multiple_of(minor as usize); + let len = if major { + 6.0 + } else if s.is_multiple_of(2) && !metric { + 4.0 + } else { + 2.5 + }; + if !major && unit / minor < 3.5 { + continue; + } + let n = s / minor as usize; + if major && n > 0 && n.is_multiple_of(label_every) { + let at = if horizontal { pos2(pos, area.center().y) } else { pos2(area.center().x, pos) }; + pc.text(at, Align2::CENTER_CENTER, n.to_string(), font.clone(), t.header_text); + continue; + } + let (a, b) = if horizontal { + (pos2(pos, area.bottom() - 2.0 - len), pos2(pos, area.bottom() - 2.0)) + } else { + (pos2(area.right() - 2.0 - len, pos), pos2(area.right() - 2.0, pos)) + }; + pc.line_segment([a, b], Stroke::new(1.0, t.header_text.gamma_multiply(0.6))); + } + }; + for j in j0..=j1.min(j0 + 200) { + let (x, _, w, _) = pg.page_rect(0, j); + let a = screen(geo, x, 0.0).x; + let b = screen(geo, x + w, 0.0).x; + span(p, a, b, screen(geo, x + ml, 0.0).x, screen(geo, x + w - mr, 0.0).x, true, hr); + } + for i in i0..=i1.min(i0 + 200) { + let (_, y, _, h) = pg.page_rect(i, 0); + let a = screen(geo, 0.0, y).y; + let b = screen(geo, 0.0, y + h).y; + span(p, a, b, screen(geo, 0.0, y + mt).y, screen(geo, 0.0, y + h - mb).y, false, vr); + } + p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line)); + p.line_segment( + [pos2(outer.left() + RULER - 0.5, outer.top()), pos2(outer.left() + RULER - 0.5, outer.bottom())], + Stroke::new(1.0, t.header_line), + ); +} + +/// The Header & Footer contextual ribbon tab. +pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { + use crate::widgets::big_button; + let Some(e) = app.grid.hf_edit.as_ref() else { return }; + let footer = e.footer; + let (sheet_name, file) = + app.session.active().map(|d| (d.wb.sheet(e.sheet).map(|s| s.name.clone()).unwrap_or_default(), d.display_title())).unwrap_or_default(); + // Header & Footer presets (shown with sample values, as the result will read). + for (label, part) in [("Header", false), ("Footer", true)] { + let r = big_button(ui, if part { Icon::Footer } else { Icon::Header }, label, label, true); + egui::Popup::menu(&r).show(|ui| { + let presets: [(String, &str); 7] = [ + (tr(app, "(none)").to_string(), ""), + ("Page 1".into(), "&CPage &P"), + ("Page 1 of ?".into(), "&CPage &P of &N"), + (sheet_name.clone(), "&C&A"), + (file.clone(), "&C&F"), + (format!("{sheet_name}, Page 1"), "&C&A, Page &P"), + (format!("{file}, Page 1"), "&C&F, Page &P"), + ]; + for (text, spec) in presets { + if ui.button(text).clicked() { + let sheet = app.grid.hf_edit.as_ref().map(|e| e.sheet).unwrap_or(0); + cancel(app); + app.run_or_alert("pageLayout.headerFooter", json!({"sheet": sheet, (if part { "footer" } else { "header" }): spec})); + ui.close(); + } + } + }); + } + sep(ui); + for (icon, label, tip, field) in [ + (Icon::PageNumber, "Page\nNumber", "Page Number", "&[Page]"), + (Icon::PageCount, "Number\nof Pages", "Number of Pages", "&[Pages]"), + (Icon::Calendar, "Current\nDate", "Current Date", "&[Date]"), + (Icon::Clock, "Current\nTime", "Current Time", "&[Time]"), + (Icon::Folder, "File\nPath", "File Path", "&[Path]&[File]"), + (Icon::FileName, "File\nName", "File Name", "&[File]"), + (Icon::Sheet, "Sheet\nName", "Sheet Name", "&[Tab]"), + ] { + if big_button(ui, icon, label, tip, false).clicked() { + insert_field(app, field); + } + } + sep(ui); + if big_button(ui, Icon::Header, "Go to\nHeader", "Go to Header", false).clicked() && footer { + go_to(app, false); + } + if big_button(ui, Icon::Footer, "Go to\nFooter", "Go to Footer", false).clicked() && !footer { + go_to(app, true); + } +} + +fn sep(ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), egui::Sense::hover()); + ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator)); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..0a398cb72 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -199,6 +199,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { "Automate" => automate(app, ui), "Table Design" => table_design(app, ui), "Chart Design" => chart_design(app, ui), + crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui), _ => home(app, ui), } }); @@ -244,6 +245,9 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { if app.selected_chart.is_some_and(|id| sh.charts.iter().any(|c| c.id == id)) { v.push("Chart Design"); } + if app.grid.hf_edit.is_some() { + v.push(crate::paper_view::TAB); + } } v } @@ -1635,7 +1639,14 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { sep(ui); let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true)); let lang = app.ui.language; + let page_layout = + app.session.active().and_then(|d| d.wb.active().map(|s| s.view == gridcraft_engine::model::SheetViewMode::PageLayout)).unwrap_or(false); ui.vertical(|ui| { + // Rulers exist only in Page Layout view. + let mut ruler = !app.session.hide_ruler; + if ui.add_enabled(page_layout, egui::Checkbox::new(&mut ruler, lang.tr("Ruler"))).changed() { + act(app, "view.ruler", json!({"on": ruler})); + } let mut fb = app.ui.formula_bar; if ui.checkbox(&mut fb, lang.tr("Formula Bar")).changed() { app.ui.formula_bar = fb; diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b1e345a66..f9f47ebb2 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -249,7 +249,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { (Icon::Normal, "Normal", "normal", "view.normal"), ] { let (r, resp) = ui.allocate_exact_size(vec2(26.0, 20.0), Sense::click()); - let cur = if app.session.view_mode.is_empty() { "normal" } else { app.session.view_mode.as_str() }; + let cur = app.session.active().and_then(|d| d.wb.active().map(|s| s.view.as_str())).unwrap_or("normal"); if cur == mode { ui.painter().rect_filled(r, 3.0, t.pressed); } else if resp.hovered() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 219e2d289..420dbc43d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -25,6 +25,8 @@ pub struct Tokens { pub pressed: Color32, pub separator: Color32, pub grid_bg: Color32, + /// Behind the pages in Page Layout view. + pub desk: Color32, pub gridline: Color32, pub header_bg: Color32, pub header_text: Color32, @@ -62,6 +64,7 @@ impl Tokens { pressed: Color32::from_rgb(0xDD, 0xDD, 0xDD), separator: Color32::from_rgb(0xE0, 0xE0, 0xE0), grid_bg: Color32::WHITE, + desk: Color32::from_rgb(0xDE, 0xDE, 0xDE), gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1), header_bg: Color32::from_rgb(0xF8, 0xF8, 0xF8), header_text: Color32::from_rgb(0x42, 0x42, 0x42), @@ -99,6 +102,7 @@ impl Tokens { pressed: Color32::from_rgb(0x4D, 0x4D, 0x4D), separator: Color32::from_rgb(0x48, 0x48, 0x48), grid_bg: Color32::WHITE, + desk: Color32::from_rgb(0x3A, 0x3A, 0x3A), gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1), header_bg: Color32::from_rgb(0x30, 0x30, 0x30), header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC), diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs new file mode 100644 index 000000000..d212d0577 --- /dev/null +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -0,0 +1,126 @@ +//! Page Layout view through real pointer and keyboard input: cells are hit through the page +//! transform, and headers are edited in place, committed on a click elsewhere, and undoable. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_engine::model::SheetViewMode; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::grid::Geo; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn fixture() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + for r in 1..=60 { + session.execute("cell.set", json!({"cell": format!("A{r}"), "input": r.to_string()})).unwrap(); + } + session.execute("pageLayout.headerFooter", json!({"header": "&LLeft"})).unwrap(); + session.execute("view.pageLayout", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(1200.0, 900.0)) + .with_step_dt(1.0 / 60.0) + .with_os(egui::os::OperatingSystem::Mac) + .build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn geo(h: &Harness) -> Geo { + h.state().grid.geo.clone().unwrap() +} + +fn click(h: &mut Harness, p: Pos2) { + // Keep clicks apart from egui's double-click window. + h.run_steps(40); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn header(h: &Harness) -> String { + h.state().session.doc().unwrap().wb.active().unwrap().print.header.clone() +} + +/// Screen point of a layout point. +fn screen(g: &Geo, x: f64, y: f64) -> Pos2 { + pos2(g.cells.left() + (x - g.scroll.x as f64) as f32 * g.z, g.cells.top() + (y - g.scroll.y as f64) as f32 * g.z) +} + +#[test] +fn cells_are_hit_through_the_page_transform() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().expect("Page Layout view lays out pages"); + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.view, SheetViewMode::PageLayout); + // Cells start inside the first page's margins, not at the grid's corner. + let a1 = g.cell_rect(sh, CellRef::new(0, 0)); + let content = screen(&g, pg.page_x(0) + pg.margins[0], pg.page_y(0) + pg.margins[2]); + assert!((a1.left() - content.x).abs() < 0.5 && (a1.top() - content.y).abs() < 0.5, "{a1:?} vs {content:?}"); + let c5 = g.cell_rect(sh, CellRef::parse("C5").unwrap()).center(); + assert_eq!(g.cell_at(sh, c5), CellRef::parse("C5").unwrap()); + click(&mut h, c5); + assert_eq!(h.state().session.doc().unwrap().selection.active, CellRef::parse("C5").unwrap()); + // A click in the gap left of the page selects nothing new. + let gap = screen(&g, pg.gap / 2.0, pg.page_y(0) + pg.margins[2] + 30.0); + click(&mut h, gap); + assert_eq!(h.state().session.doc().unwrap().selection.active, CellRef::parse("C5").unwrap()); +} + +#[test] +fn header_is_edited_in_place_committed_by_a_click_elsewhere_and_undoable() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + // The centre third of the first page's top margin. + let (px, py, w, _) = pg.page_rect(0, 0); + let centre = screen(&g, px + w / 2.0, py + pg.margins[2] / 2.0); + click(&mut h, centre); + { + let app = h.state(); + let e = app.grid.hf_edit.as_ref().expect("clicking the top margin edits the header"); + assert_eq!((e.footer, e.section), (false, 1)); + assert_eq!(app.ui.ribbon_tab, "Header & Footer"); + } + h.input_mut().events.push(Event::Text("Report ".into())); + h.run_steps(2); + gridcraft_ui_egui::paper_view::insert_field(h.state_mut(), "&[Page]"); + h.run_steps(2); + assert_eq!(h.state().grid.hf_edit.as_ref().unwrap().texts[1], "Report &[Page]"); + assert_eq!(header(&h), "&LLeft", "nothing is written while editing"); + // Clicking a cell commits (one undo step) and selects the cell. + let sh_geo = geo(&h); + let b3 = { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + sh_geo.cell_rect(sh, CellRef::parse("B3").unwrap()).center() + }; + click(&mut h, b3); + assert!(h.state().grid.hf_edit.is_none()); + assert_eq!(header(&h), "&LLeft&CReport &P"); + assert_eq!(h.state().session.doc().unwrap().selection.active, CellRef::parse("B3").unwrap()); + assert_ne!(h.state().ui.ribbon_tab, "Header & Footer"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(header(&h), "&LLeft"); + // Escape cancels an edit without writing. + click(&mut h, centre); + h.input_mut().events.push(Event::Text("x".into())); + h.run_steps(2); + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().grid.hf_edit.is_none()); + assert_eq!(header(&h), "&LLeft"); +} diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 065558e6b..8cf00d8af 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -208,6 +208,7 @@ fn read_views(e: &El, sheet: &mut Sheet) { sheet.show_formulas = v.flag("showFormulas", false); sheet.show_zeros = v.flag("showZeros", true); sheet.right_to_left = v.flag("rightToLeft", false); + sheet.view = v.attr("view").and_then(gridcraft_model::SheetViewMode::parse).unwrap_or_default(); if let Some(z) = v.attr_u32("zoomScale") { sheet.zoom = z.clamp(10, 400) as u16; } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index b1e3fca2c..242550615 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -152,6 +152,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if sheet.right_to_left { s.push_str(" rightToLeft=\"1\""); } + if sheet.view != gridcraft_model::SheetViewMode::Normal { + let _ = write!(s, " view=\"{}\"", sheet.view.as_str()); + } if sheet.zoom != 100 && (10..=400).contains(&sheet.zoom) { let _ = write!(s, " zoomScale=\"{0}\" zoomScaleNormal=\"{0}\"", sheet.zoom); } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 12bba62ed..63e980755 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -118,6 +118,7 @@ fn sample() -> Workbook { s.show_zeros = false; s.right_to_left = true; s.zoom = 125; + s.view = gridcraft_model::SheetViewMode::PageLayout; s.view_active = at("C7"); let red_b = Box::new(red.clone()); @@ -398,6 +399,7 @@ fn full_roundtrip() { assert_eq!(b.tab_color, a.tab_color); assert_eq!((b.show_gridlines, b.show_headings, b.show_zeros, b.right_to_left, b.zoom), (false, false, false, true, 125)); assert_eq!(b.view_active, a.view_active); + assert_eq!(b.view, gridcraft_model::SheetViewMode::PageLayout, "sheetView view=\"pageLayout\" round-trips"); assert_eq!(b.cond_formats, a.cond_formats); assert_eq!(b.validations, a.validations); assert_eq!(b.hyperlinks, a.hyperlinks); diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8d8..7958bfe10 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (§14 Page Layout view, #329) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -102,8 +102,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- **Estimate:** 22–35 h. ### 14. Page Layout view and print polish (U, F) -- No paper view with rulers and in-place header/footer editing; web print exports only (#141, PR - #143). **Estimate:** 12–20 h. +- Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. + Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging + margins on the ruler; web print exports only (#141, PR #143). **Estimate:** 5–10 h. ### 15. Remaining functions and an oracle suite (F) - 22 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2e5ed01cd..a96e24e23 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first full assessment; re-measure against Excel for Mac 16.113.4, replacing the estimates that lived in ROADMAP.md) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Page Layout view, #329) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -86,7 +86,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | | PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | -| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | +| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, Page Layout view with in-place headers/footers (#329), PDF export and printing. The PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | @@ -133,7 +133,7 @@ functions, co-authoring and admin, specialist hardware, and languages beyond the | Sort, filter, tables, validation | 10 | 70 | Feature-area table | | PivotTables (core: fields, layouts, grouping, refresh) | 8 | 60 | Slicers and calculated fields excluded as less frequent | | Charts (column, bar, line, pie, scatter, combo) | 9 | 45 | Common kinds work and survive save; no axis options or trendlines; resize broken (#190) | -| Page layout, print, PDF | 5 | 60 | Latin text exports; no Page Layout view | +| Page layout, print, PDF | 5 | 60 | Latin text exports | | Comments, notes, protection | 3 | 55 | Feature-area table | | Pictures and shapes | 2 | 45 | Feature-area table | | **Average depth** | 100 | **69.5** | | @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Page Layout view landed (#329); page layout notes updated, no score change | | 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) | | 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..ba910658d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Page Layout view, #329) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -15,7 +15,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Element | Excel | GridCraft | Status | |---|---|---|---| | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | -| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | +| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs) | done; missing contextual tabs for pictures, shapes, sparklines, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | @@ -24,7 +24,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | **no** (Page Break Preview only) | 8–12 h | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | header/footer pictures, Different First Page / Odd & Even, ruler margin dragging missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction @@ -84,12 +84,13 @@ by rule and will never match Office's, by design. ## Work (50–80 h) Key tips 3–5 · shortcut audit 6–10 · context menus and Quick Analysis 5–8 · Page Layout view -8–12 · Options dialog and ribbon/QAT customization 6–10 · accessibility 12–18 · selection and +polish 1–3 · Options dialog and ribbon/QAT customization 6–10 · accessibility 12–18 · selection and scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Page Layout view with rulers and in-place headers and footers (#329) | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From 64089568c8e68e85ee97a63cedd72e94e3925d49 Mon Sep 17 00:00:00 2001 From: Ege Arhan Date: Sun, 11 Oct 2026 16:50:42 +0000 Subject: [PATCH 23/54] fix(ribbon): Watch Window layout in Formulas ribbon (#342) --- crates/ui-egui/src/ribbon.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index bb8c53ce6..42d1304ea 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1524,10 +1524,10 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { if small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false).clicked() { app.open_dialog("evaluateFormula", json!({})); } - if small_button(ui, Icon::Search, "Watch Window", "Watch Window", false).clicked() { - app.grid.pane = Some("watch".into()); - } }); + if big_button(ui, Icon::Search, "Watch\nWindow", "Watch Window", false).clicked() { + app.grid.pane = Some("watch".into()); + } sep(ui); let co = big_button(ui, Icon::Calc, "Calculation\nOptions", "Calculation Options", true); egui::Popup::menu(&co).show(|ui| { From e78cc6edc91d35f4ca6af6fd8b7bdcce1bd3198f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:55:17 -0400 Subject: [PATCH 24/54] Fix-up #332: bound .xls formula nesting and share one record budget across sheets Chained PtgParen/PtgUminus (or operator) tokens built a ~16K-deep Expr whose printing or drop overflowed the stack; nesting past the formula parser's MAX_DEPTH (256) is now reported as unsupported. BoundSheet records pointing into one huge substream each got their own 40M-record allowance; the allowance is now shared by the whole Workbook stream, and two sheets at the same offset are refused. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/tests/xls_tests.rs | 39 ++++++++ crates/xlsx/src/xls/formula.rs | 138 ++++++++++++++++++++--------- crates/xlsx/src/xls/mod.rs | 49 +++++++--- 3 files changed, 169 insertions(+), 57 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index a150dc0b3..fd37795ec 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -437,3 +437,42 @@ fn hostile_xls_input_never_panics_or_hangs() { let _ = read_xls(&b); } } + +#[test] +fn sheets_share_one_record_budget() { + // Many BoundSheet records point into one long substream (at successive nested BOFs), so each + // sheet would read the whole tail. The record allowance is shared, not per sheet. + let n = 300u32; + let tail: Vec = (0..2000u16).flat_map(|r| rec(0x0203, &cat(&[&cell(r, 0, 0), &1.0f64.to_le_bytes()]))).collect(); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |base: u32| { + let mut g = bof(0x0005); + for i in 0..n { + g.extend(boundsheet(base + i * bof(0x0010).len() as u32, &format!("S{i}"))); + } + g.extend(eof()); + g + }; + let base = globals(0).len() as u32; + let mut stream = globals(base); + for _ in 0..n { + stream.extend(bof(0x0010)); + } + stream.extend(tail); + stream.extend(eof()); + let file = compound("Workbook", &stream); + // About n × 2,000 records in all: over a 100,000 shared allowance, though each sheet alone fits. + assert!(matches!(crate::xls::read_xls_with(&file, crate::xls::Budget::with_max_records(100_000)), Err(IoError::TooLarge(_)))); + assert!(crate::xls::read_xls_with(&file, crate::xls::Budget::with_max_records(10_000_000)).is_ok()); + + // Two sheets at the same offset are refused outright. + let mut dup = bof(0x0005); + let at = (dup.len() + 2 * boundsheet(0, "A").len() + eof().len()) as u32; + dup.extend(boundsheet(at, "A")); + dup.extend(boundsheet(at, "B")); + dup.extend(eof()); + dup.extend(bof(0x0010)); + dup.extend(eof()); + let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); + assert!(err.contains("same substream"), "{err}"); +} diff --git a/crates/xlsx/src/xls/formula.rs b/crates/xlsx/src/xls/formula.rs index 3fc3fc40c..87a512c59 100644 --- a/crates/xlsx/src/xls/formula.rs +++ b/crates/xlsx/src/xls/formula.rs @@ -180,13 +180,35 @@ fn function_name(name: &str) -> String { n.to_ascii_uppercase() } -fn pop(stack: &mut Vec) -> Result { - stack.pop().ok_or(Unsupported("malformed formula (operand missing)")) +/// Expression nesting at most, as in the formula parser: deeper trees would overflow the stack +/// when printed, evaluated or dropped (a hostile file can chain thousands of PtgParen tokens). +const MAX_DEPTH: usize = 256; + +/// Operand stack entries: an expression and its nesting depth. +type Stack = Vec<(Expr, usize)>; + +/// Pops an operand, recording its depth in `kids` (the deepest operand this token consumed). +fn pop(stack: &mut Stack, kids: &mut usize) -> Result { + let (e, d) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?; + *kids = (*kids).max(d); + Ok(e) } -fn pop_n(stack: &mut Vec, n: usize) -> Result, Unsupported> { +fn pop_n(stack: &mut Stack, n: usize, kids: &mut usize) -> Result, Unsupported> { let at = stack.len().checked_sub(n).ok_or(Unsupported("malformed formula (argument missing)"))?; - Ok(stack.split_off(at)) + let popped = stack.split_off(at); + *kids = popped.iter().map(|(_, d)| *d).fold(*kids, usize::max); + Ok(popped.into_iter().map(|(e, _)| e).collect()) +} + +/// Pushes the result of a token one level above the operands it consumed. +fn push(stack: &mut Stack, kids: usize, e: Expr) -> Result<(), Unsupported> { + let depth = kids.saturating_add(1); + if depth > MAX_DEPTH { + return Err(Unsupported("formula nested too deeply")); + } + stack.push((e, depth)); + Ok(()) } /// Decodes `rgce` (tokens) with `rgcb` (the extra data for array constants) evaluated at @@ -197,9 +219,10 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } let mut t = Bytes { b: rgce, pos: 0 }; let mut extra = Bytes { b: rgcb, pos: 0 }; - let mut stack: Vec = Vec::new(); + let mut stack: Stack = Vec::new(); while t.pos < rgce.len() { let ptg = t.u8()?; + let mut kids = 0usize; // Operand classes (reference / value / array) share one token number. let id = if ptg & 0x60 != 0 { 0x20 | (ptg & 0x1F) } else { ptg }; match id { @@ -221,9 +244,9 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: 0x10 => BinOp::Union, _ => BinOp::Range, }; - let b = pop(&mut stack)?; - let a = pop(&mut stack)?; - stack.push(Expr::Binary(op, Box::new(a), Box::new(b))); + let b = pop(&mut stack, &mut kids)?; + let a = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Binary(op, Box::new(a), Box::new(b)))?; } 0x12..=0x14 => { let op = match id { @@ -231,18 +254,18 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: 0x13 => UnOp::Neg, _ => UnOp::Percent, }; - let x = pop(&mut stack)?; - stack.push(Expr::Unary(op, Box::new(x))); + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Unary(op, Box::new(x)))?; } 0x15 => { - let x = pop(&mut stack)?; - stack.push(Expr::Paren(Box::new(x))); + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Paren(Box::new(x)))?; } - 0x16 => stack.push(Expr::Missing), + 0x16 => push(&mut stack, kids, Expr::Missing)?, 0x17 => { let cch = usize::from(t.u8()?); let high = t.u8()? & 1 != 0; - stack.push(Expr::Text(Arc::from(t.chars(cch, high)?))); + push(&mut stack, kids, Expr::Text(Arc::from(t.chars(cch, high)?)))?; } 0x19 => { let kind = t.u8()?; @@ -255,34 +278,34 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } if kind & 0x10 != 0 { // PtgAttrSum: SUM of the single operand. - let x = pop(&mut stack)?; - stack.push(Expr::Call("SUM".into(), vec![x])); + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Call("SUM".into(), vec![x]))?; } // Semi (volatile), If, Goto, Space, Baxcel: no effect on the expression. } 0x1C => { let e = t.u8()?; - stack.push(Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?)); + push(&mut stack, kids, Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?))?; } - 0x1D => stack.push(Expr::Bool(t.u8()? != 0)), - 0x1E => stack.push(Expr::Number(f64::from(t.u16()?))), + 0x1D => push(&mut stack, kids, Expr::Bool(t.u8()? != 0))?, + 0x1E => push(&mut stack, kids, Expr::Number(f64::from(t.u16()?)))?, 0x1F => { let n = t.f64()?; if !n.is_finite() { return Err(Unsupported("invalid number constant")); } - stack.push(Expr::Number(n)); + push(&mut stack, kids, Expr::Number(n))?; } 0x20 => { t.take(7)?; - stack.push(array_constant(&mut extra)?); + push(&mut stack, kids, array_constant(&mut extra)?)?; } 0x21 => { let iftab = t.u16()?; let (name, args) = function(iftab).ok_or(Unsupported("unknown function"))?; let n = usize::try_from(args).map_err(|_| Unsupported("unknown function argument count"))?; - let args = pop_n(&mut stack, n)?; - stack.push(Expr::Call(name.to_string(), args)); + let args = pop_n(&mut stack, n, &mut kids)?; + push(&mut stack, kids, Expr::Call(name.to_string(), args))?; } 0x22 => { let n = usize::from(t.u8()?); @@ -290,7 +313,7 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: if tab & 0x8000 != 0 { return Err(Unsupported("macro sheet commands")); } - let mut args = pop_n(&mut stack, n)?; + let mut args = pop_n(&mut stack, n, &mut kids)?; if tab & 0x7FFF == 0x00FF { // A function called by name (add-in or newer function): the name comes first. if args.is_empty() { @@ -300,10 +323,10 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: Expr::Name(n) => function_name(&n), _ => return Err(Unsupported("function call through an expression")), }; - stack.push(Expr::Call(name, args)); + push(&mut stack, kids, Expr::Call(name, args))?; } else { let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?; - stack.push(Expr::Call(name.to_string(), args)); + push(&mut stack, kids, Expr::Call(name.to_string(), args))?; } } 0x23 => { @@ -314,17 +337,17 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: .and_then(|i| links.names.get(i)) .filter(|n| !n.is_empty()) .ok_or(Unsupported("reference to a missing defined name"))?; - stack.push(Expr::Name(name.clone())); + push(&mut stack, kids, Expr::Name(name.clone()))?; } 0x24 | 0x2C => { let (r, c) = (t.u16()?, t.u16()?); let a = anchor(r, c, base, id == 0x2C); - stack.push(Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) })); + push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) }))?; } 0x25 | 0x2D => { let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); let kind = area(r0, r1, c0, c1, base, id == 0x2D); - stack.push(Expr::Ref(Reference { sheet: SheetSel::Current, kind })); + push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind }))?; } 0x26 => { // PtgMemArea: a cache of the sub-expression that follows; skip its extra data. @@ -340,42 +363,50 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } 0x2A => { t.take(4)?; - stack.push(Expr::Error(CellError::Ref)); + push(&mut stack, kids, Expr::Error(CellError::Ref))?; } 0x2B => { t.take(8)?; - stack.push(Expr::Error(CellError::Ref)); + push(&mut stack, kids, Expr::Error(CellError::Ref))?; } 0x39 => { let ixti = t.u16()?; let i = t.u32()?; - stack.push(Expr::Name(links.extern_name(ixti, i)?)); + push(&mut stack, kids, Expr::Name(links.extern_name(ixti, i)?))?; } 0x3A => { let ixti = t.u16()?; let (r, c) = (t.u16()?, t.u16()?); let a = anchor(r, c, base, mode == Mode::Name); - stack.push(match links.sheet_sel(ixti)? { - Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }), - None => Expr::Error(CellError::Ref), - }); + push( + &mut stack, + kids, + match links.sheet_sel(ixti)? { + Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }), + None => Expr::Error(CellError::Ref), + }, + )?; } 0x3B => { let ixti = t.u16()?; let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); let kind = area(r0, r1, c0, c1, base, mode == Mode::Name); - stack.push(match links.sheet_sel(ixti)? { - Some(sheet) => Expr::Ref(Reference { sheet, kind }), - None => Expr::Error(CellError::Ref), - }); + push( + &mut stack, + kids, + match links.sheet_sel(ixti)? { + Some(sheet) => Expr::Ref(Reference { sheet, kind }), + None => Expr::Error(CellError::Ref), + }, + )?; } 0x3C => { t.take(6)?; - stack.push(Expr::Error(CellError::Ref)); + push(&mut stack, kids, Expr::Error(CellError::Ref))?; } 0x3D => { t.take(10)?; - stack.push(Expr::Error(CellError::Ref)); + push(&mut stack, kids, Expr::Error(CellError::Ref))?; } 0x01 => return Err(Unsupported("nested shared or array formula reference")), 0x02 => return Err(Unsupported("what-if data tables")), @@ -386,7 +417,7 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: return Err(Unsupported("formula too complex")); } } - let e = pop(&mut stack)?; + let (e, _) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?; if !stack.is_empty() { return Err(Unsupported("malformed formula (unused operands)")); } @@ -493,4 +524,25 @@ mod tests { assert!(text(&[0x21, 0xFF, 0x7F], &[]).is_err()); assert!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &[0xFF, 0xFF, 0xFF]).is_err()); } + + #[test] + fn deep_nesting_is_refused_not_a_stack_overflow() { + // 1 followed by thousands of PtgParen / PtgUminus tokens: a hostile file used to build a + // 16K-deep tree that overflowed the stack when printed or dropped. + for op in [0x15u8, 0x13] { + let mut rgce = vec![0x1E, 1, 0]; + rgce.resize(MAX_RGCE, op); + assert_eq!(text(&rgce, &[]), Err(Unsupported("formula nested too deeply"))); + } + // Binary chains nest too: 1+1+1+... left-associated. + let mut chain = vec![0x1E, 1, 0]; + while chain.len() + 4 <= MAX_RGCE { + chain.extend([0x1E, 1, 0, 0x03]); + } + assert!(text(&chain, &[]).is_err()); + // Nesting up to the limit still decodes. + let mut ok = vec![0x1E, 1, 0]; + ok.resize(3 + MAX_DEPTH - 1, 0x15); + assert!(text(&ok, &[]).is_ok()); + } } diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index b070478d5..c9aee66a3 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -31,7 +31,9 @@ const MAX_SHEETS: usize = 4096; const MAX_NAMES: usize = 65_536; const MAX_XFS: usize = 65_536; const MAX_MERGES: usize = 100_000; -/// Records read from one substream at most (a 65,536 × 256 sheet has fewer). +/// Records read from the whole Workbook stream at most (a 65,536 × 256 sheet has fewer). One +/// allowance shared by every substream: many BoundSheet records pointing into one huge +/// substream must not each get their own. const MAX_RECORDS: usize = 40_000_000; const BOF: u16 = 0x0809; @@ -53,13 +55,32 @@ pub(crate) fn is_xls(bytes: &[u8]) -> bool { cf.has_root_stream("Workbook") || cf.has_root_stream("Book") } -#[derive(Default)] -struct Budget { +pub(crate) struct Budget { cells: usize, text: usize, + records: usize, + max_records: usize, +} + +impl Default for Budget { + fn default() -> Self { + Budget { cells: 0, text: 0, records: 0, max_records: MAX_RECORDS } + } } impl Budget { + /// A budget with a smaller record allowance (tests). + #[cfg(test)] + pub(crate) fn with_max_records(max_records: usize) -> Self { + Budget { max_records, ..Budget::default() } + } + fn record(&mut self) -> Result<(), IoError> { + self.records = self.records.saturating_add(1); + if self.records > self.max_records { + return Err(IoError::TooLarge("XLS: too many records".into())); + } + Ok(()) + } fn cell(&mut self) -> Result<(), IoError> { self.cells += 1; if self.cells > MAX_CELLS { @@ -106,6 +127,11 @@ struct Globals { /// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + read_xls_with(bytes, Budget::default()) +} + +/// [`read_xls`] with an explicit resource budget. +pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workbook, ReadReport), IoError> { let cf = Compound::open(bytes).map_err(invalid)?; let stream = match cf.root_stream("Workbook").map_err(invalid)? { Some(s) => s, @@ -117,7 +143,6 @@ pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } }; let mut report = ReadReport::default(); - let mut budget = Budget::default(); let g = globals(&stream, &mut budget)?; let mut wb = Workbook::new(); @@ -195,12 +220,9 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { return Err(invalid("the Workbook stream does not start with the workbook globals")); } let mut g = Globals::default(); - let mut count = 0usize; + let mut offsets = std::collections::HashSet::new(); while let Some(rec) = recs.next_record()? { - count += 1; - if count > MAX_RECORDS { - return Err(IoError::TooLarge("XLS: too many records".into())); - } + budget.record()?; let mut c = Cursor::new(&rec.parts); match rec.id { EOF => break, @@ -210,6 +232,9 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { return Err(IoError::TooLarge("XLS: too many sheets".into())); } let pos = c.u32()? as usize; + if !offsets.insert(pos) { + return Err(invalid("two sheets point at the same substream")); + } let state = c.u8()?; let kind = c.u8()?; let name = c.short_string()?; @@ -460,12 +485,8 @@ fn worksheet( unsupported: vec![], }; let mut depth = 1usize; - let mut count = 0usize; while let Some(rec) = recs.next_record()? { - count += 1; - if count > MAX_RECORDS { - return Err(IoError::TooLarge("XLS: too many records in a sheet".into())); - } + budget.record()?; match rec.id { BOF => { // An embedded chart's substream. From c83f214c9d16cd145e971c63c6ac3dae9320c38a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:00:27 -0400 Subject: [PATCH 25/54] Spanish, German and Czech strings for comment navigation (review fix-up for #286) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 6 ++++++ crates/ui-egui/src/i18n/de.rs | 6 ++++++ crates/ui-egui/src/i18n/es.rs | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index f8605b54f..0d01ba921 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Previous" => "Předchozí", + "Next" => "Další", + "Previous Comment" => "Předchozí komentář", + "Next Comment" => "Další komentář", + "Show All Notes" => "Zobrazit všechny poznámky", + "Convert to Comments" => "Převést na komentáře", "Checkbox" => "Zaškrtávací políčko", "Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk", "Sheet\nRight-to-Left" => "List zprava\ndoleva", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index e0f350884..c5f71196e 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Previous" => "Zurück", + "Next" => "Weiter", + "Previous Comment" => "Vorheriger Kommentar", + "Next Comment" => "Nächster Kommentar", + "Show All Notes" => "Alle Notizen anzeigen", + "Convert to Comments" => "In Kommentare konvertieren", "Checkbox" => "Kontrollkästchen", "Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen", "Sheet\nRight-to-Left" => "Blatt von rechts\nnach links", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index fe6f36873..7cf8c9a5a 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Previous" => "Anterior", + "Next" => "Siguiente", + "Previous Comment" => "Comentario anterior", + "Next Comment" => "Comentario siguiente", + "Show All Notes" => "Mostrar todas las notas", + "Convert to Comments" => "Convertir en comentarios", "Checkbox" => "Casilla", "Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas", "Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda", From e52fc4aa08b5f1b6456507566e3eaf2ede6a9b10 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:05:45 -0400 Subject: [PATCH 26/54] Fix-up #333: escape kept content types and charge kept VML to the kept total Content types (and extensions) of kept parts come from the input file and were written into [Content_Types].xml unescaped, so a quote or angle bracket injected XML; they are now attribute-escaped. Each sheet re-read and kept its VML drawing (up to 16 MB) outside MAX_KEPT_TOTAL, so many sheets sharing one highly compressed drawing amplified memory; the drawing is now read within and charged to the remaining kept-bytes allowance. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/passthrough.rs | 30 +++++++++- crates/xlsx/src/tests/passthrough_tests.rs | 65 ++++++++++++++++++++-- crates/xlsx/src/write.rs | 5 +- 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index 187009bca..f121977e2 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -32,6 +32,7 @@ use quick_xml::events::{BytesStart, Event}; use quick_xml::name::QName; use quick_xml::{Reader, Writer}; +use crate::IoError; use crate::package::{self, normalize}; use crate::read::Ctx; use crate::write::{NS_REL, Out, Rels, XML_DECL}; @@ -54,6 +55,23 @@ const MAX_FRAGMENT: usize = 4 * 1024 * 1024; /// Deepest element nesting accepted in a kept fragment. const MAX_FRAGMENT_DEPTH: usize = 256; +#[cfg(test)] +thread_local! { + /// A smaller kept-bytes allowance for tests on this thread. + pub(crate) static KEPT_TOTAL_FOR_TEST: std::cell::Cell = const { std::cell::Cell::new(MAX_KEPT_TOTAL) }; +} + +fn kept_total() -> usize { + #[cfg(test)] + { + KEPT_TOTAL_FOR_TEST.with(std::cell::Cell::get) + } + #[cfg(not(test))] + { + MAX_KEPT_TOTAL + } +} + const NS_REL_STRICT: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships"; const CT_VML: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing"; @@ -194,7 +212,7 @@ impl KeptReader { strict: false, defaults: HashMap::new(), overrides: HashMap::new(), - budget: MAX_KEPT_TOTAL, + budget: kept_total(), parts_left: MAX_KEPT_PARTS, }; if let Ok(root) = raw_rels(cx, "") @@ -446,11 +464,19 @@ impl KeptReader { seen: &mut HashSet, ) -> Result<(PreservedPart, Vec), String> { let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?; - let data = match cx.pkg.read_limited(&rel.target, MAX_META) { + // Charged to the kept total like any other part: many sheets can point at one highly + // compressed VML drawing, and each keeps its own copy. + if self.budget == 0 { + return Err("it is too large".into()); + } + let limit = MAX_META.min(u64::try_from(self.budget).unwrap_or(u64::MAX)); + let data = match cx.pkg.read_limited(&rel.target, limit) { Ok(Some(d)) => d, Ok(None) => return Err(format!("{} is missing", rel.target)), + Err(IoError::TooLarge(_)) => return Err("it is too large".into()), Err(e) => return Err(e.to_string()), }; + self.budget = self.budget.saturating_sub(data.len()); let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?; let text = strip_notes(&text); let vrels = raw_rels(cx, &rel.target)?; diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 10b2681d7..4cde9d69e 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -45,14 +45,23 @@ const VML: &str = r##" Vec { let workbook = format!( r#""# ); let sheet1 = format!( r#"[1]Sheet1!$A$17SUM([1]Sheet1!A1:A3)*21410103050"# ); - let sheet2 = - format!(r#"1"#); + let sheet2 = if twin { + sheet1.clone() + } else { + format!(r#"1"#) + }; let root_rels = rels(&[("rId1", "officeDocument", "xl/workbook.xml"), ("rId2", "custom-properties", "docProps/custom.xml")]); let wb_rels = rels(&[ ("rId1", "worksheet", "worksheets/sheet1.xml"), @@ -74,8 +83,8 @@ fn rich_package() -> Vec { let link_rels = rels(&[("rId1", "externalLinkPath", "file:///C:/never/opened/Book2.xlsx")]); let vml_rels = rels(&[("rId1", "image", "../media/image1.emf")]); let item_props = r#""#; - make_zip(&[ - ("[Content_Types].xml", CONTENT_TYPES.as_bytes()), + let mut files: Vec<(&str, &[u8])> = vec![ + ("[Content_Types].xml", content_types.as_bytes()), ("_rels/.rels", root_rels.as_bytes()), ("docProps/custom.xml", CUSTOM_PROPS.as_bytes()), ("xl/workbook.xml", workbook.as_bytes()), @@ -97,7 +106,11 @@ fn rich_package() -> Vec { ("xl/externalLinks/externalLink1.xml", EXTERNAL_LINK.as_bytes()), ("xl/externalLinks/_rels/externalLink1.xml.rels", link_rels.as_bytes()), ("xl/connections.xml", CONNECTIONS.as_bytes()), - ]) + ]; + if twin { + files.push(("xl/worksheets/_rels/sheet2.xml.rels", sheet1_rels.as_bytes())); + } + make_zip(&files) } /// An unpacked package: parts by name, content types, resolved relationships. @@ -428,3 +441,45 @@ fn oversized_kept_parts_are_dropped() { assert!(wb.preserved.is_none()); assert!(rep.warnings.iter().any(|w| w.contains("customXml") && w.contains("dropped")), "{:?}", rep.warnings); } + +#[test] +fn kept_content_types_are_escaped_on_save() { + // A content type holding `"` and `<` (legal once unescaped by the XML reader) must not break + // out of the attribute in the saved [Content_Types].xml. + let hostile = r#"application/x-test" PartName="/x"/><Default Extension="evil"#; + let ct = CONTENT_TYPES.replace("application/vnd.openxmlformats-officedocument.customXmlProperties+xml", hostile); + let (wb, rep) = read_xlsx(&rich_package_with(&ct, false)).unwrap(); + assert!(wb.preserved.is_some(), "{:?}", rep.warnings); + let u = Unpacked::new(&write_xlsx(&wb).unwrap()); + let types = xml::parse(u.text("[Content_Types].xml").as_bytes()).unwrap(); + assert!(!types.kids("Default").any(|d| d.attr("Extension") == Some("evil"))); + let item = types.kids("Override").find(|o| o.attr("PartName") == Some("/customXml/itemProps1.xml")).unwrap(); + assert_eq!(item.attr("ContentType"), Some(r#"application/x-test" PartName="/x"/>() + }; + // Two sheets share one VML drawing: each would keep its own copy. With room for one sheet's + // content only, the second sheet's VML (and the controls drawn from it) is dropped. + crate::passthrough::KEPT_TOTAL_FOR_TEST.with(|c| c.set(one + VML.len() / 2)); + let read = read_xlsx(&rich_package_with(CONTENT_TYPES, true)); + crate::passthrough::KEPT_TOTAL_FOR_TEST.with(|c| c.set(crate::passthrough::MAX_KEPT_TOTAL)); + let (wb, rep) = read.unwrap(); + let kept = wb.preserved.as_ref().map_or(0, |p| p.byte_len()) + + wb.sheets.iter().filter_map(|s| s.preserved.as_ref()).map(|p| p.byte_len()).sum::(); + assert!(kept <= one + VML.len() / 2, "kept {kept} bytes"); + assert!(wb.sheet(0).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some())); + assert!(!wb.sheet(1).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some())); + assert!(rep.warnings.iter().any(|w| w.contains("\"Other\"") && w.contains("too large")), "{:?}", rep.warnings); + // Both sheets keep their drawings when there is room. + let (wb, _) = read_xlsx(&rich_package_with(CONTENT_TYPES, true)).unwrap(); + assert!(wb.sheets.iter().all(|s| s.preserved.as_ref().is_some_and(|p| p.vml.is_some()))); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 95fbed9c6..dc2ddc484 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -375,10 +375,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { // Content types first, then the parts in creation order. let mut ct = format!("{XML_DECL}"); for (ext, t) in &out.defaults { - let _ = write!(ct, ""); + // Kept parts bring their content types (and extensions) from the input file: escape them. + let _ = write!(ct, "", esc_attr(ext), esc_attr(t)); } for (part, t) in &out.overrides { - let _ = write!(ct, "", esc_attr(part)); + let _ = write!(ct, "", esc_attr(part), esc_attr(t)); } ct.push_str(""); let mut zip = ZipOut::new(); From 1fb5f3264d1b4f222bfe6a1636ba396490f5c704 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:12:23 -0400 Subject: [PATCH 27/54] Spanish, German and Czech strings for Insert Copied/Cut Cells (review fix-up for #288) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 4 ++++ crates/ui-egui/src/i18n/de.rs | 4 ++++ crates/ui-egui/src/i18n/es.rs | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 0d01ba921..96b760408 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Insert Copied Cells" => "Vložit zkopírované buňky", + "Insert Copied Cells…" => "Vložit zkopírované buňky…", + "Insert Cut Cells" => "Vložit vyjmuté buňky", + "Insert Paste" => "Vložit a posunout", "Previous" => "Předchozí", "Next" => "Další", "Previous Comment" => "Předchozí komentář", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index c5f71196e..1fbf2e3bc 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Insert Copied Cells" => "Kopierte Zellen einfügen", + "Insert Copied Cells…" => "Kopierte Zellen einfügen…", + "Insert Cut Cells" => "Ausgeschnittene Zellen einfügen", + "Insert Paste" => "Einfügen und verschieben", "Previous" => "Zurück", "Next" => "Weiter", "Previous Comment" => "Vorheriger Kommentar", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 7cf8c9a5a..00fa6b93d 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Insert Copied Cells" => "Insertar celdas copiadas", + "Insert Copied Cells…" => "Insertar celdas copiadas…", + "Insert Cut Cells" => "Insertar celdas cortadas", + "Insert Paste" => "Insertar y pegar", "Previous" => "Anterior", "Next" => "Siguiente", "Previous Comment" => "Comentario anterior", From a0cb8f2ca286f82c60b68dc4a24a268963288be8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:13:01 -0400 Subject: [PATCH 28/54] Docs: catalog 271 / 290 after #286 and #288 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++-- docs/target-app-parity.md | 4 ++-- docs/ui-parity.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d44c74345..2ddc6b769 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 266 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 271 / 290) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 266 / 290 (91.7%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 271 / 290 (93.4%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 05ce81861..3eef3327a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 266 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 271 / 290) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **266 / 290 (91.7%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **271 / 290 (93.4%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 41e19b8b7..2af6715a9 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -4,7 +4,7 @@ Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (266 / 290). +separately in [parity-checklist.md](parity-checklist.md) (271 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes From 27ef18ec2ce9c4ed9b5b71fa4480094a891d591f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:14:30 -0400 Subject: [PATCH 29/54] symbol_glyphs test: compare against a private-use code point no font covers (review fix-up for #298) U+25FB is a real glyph in DejaVu, so the test could pass while symbols still rendered as boxes. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/symbol_glyphs.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/symbol_glyphs.rs b/crates/ui-egui/tests/symbol_glyphs.rs index 5e98daeef..7f2a55f74 100644 --- a/crates/ui-egui/tests/symbol_glyphs.rs +++ b/crates/ui-egui/tests/symbol_glyphs.rs @@ -22,7 +22,8 @@ fn ui_symbols_have_glyphs_when_a_symbol_face_is_installed() { let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK)); galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.chr, g.uv_rect))).collect::>() }; - let tofu: Vec<_> = glyphs("\u{25FB}").into_iter().map(|(_, uv)| uv).collect(); + // A private-use code point no font covers renders as the replacement box. + let tofu: Vec<_> = glyphs("\u{10FFFD}").into_iter().map(|(_, uv)| uv).collect(); let missing: String = glyphs(UI_SYMBOLS).into_iter().filter(|(_, uv)| tofu.contains(uv)).map(|(c, _)| c).collect(); assert!(missing.is_empty(), "these interface symbols render as boxes in {:?}: {missing}", font.family); } From ea2e2a5bbaa03fee371131b52ef5c0c81ccfee5e Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:11:19 +0200 Subject: [PATCH 30/54] Edit date cells as dates, not serial numbers (#362) --- crates/engine/src/display.rs | 31 +++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ crates/ui-egui/src/formula_bar.rs | 2 +- crates/ui-egui/src/keymap.rs | 2 +- crates/ui-egui/src/lib.rs | 9 ++++----- 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index a604f0b50..977051824 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -31,6 +31,37 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted { format_value(v, &parsed(code), wb.date_system) } +/// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a +/// constant number in a date or time format is shown as a date (as the grid shows it), not as its +/// serial. When the formatted text would not read back as the same date (for example +/// `dddd, mmmm d`), an ISO `yyyy-mm-dd` (with `hh:mm:ss` for a time of day; just `hh:mm:ss` +/// for a time alone) is used instead, and if even that would not read back, the serial. +pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { + let raw = sheet.input_text(c); + let Some(cell) = sheet.cell(c) else { return raw }; + let Value::Number(n) = &cell.value else { return raw }; + let code = wb.styles.get(cell.style).num_fmt.as_str(); + if cell.formula.is_some() || !parsed(code).is_date() { + return raw; + } + let reads_back = |s: &str| matches!(gridcraft_core::parse::parse_input(s, wb.date_system).value, Value::Number(m) if (m - n).abs() < 1e-9); + let shown = format(&cell.value, code, wb).text; + if reads_back(&shown) { + return shown; + } + let Some(dt) = gridcraft_core::date::datetime_from_serial(wb.date_system, *n) else { return raw }; + let time = format!("{:02}:{:02}:{:02}", dt.hour, dt.minute, dt.second); + let date = format!("{:04}-{:02}-{:02}", dt.year, dt.month, dt.day); + let iso = if *n < 1.0 { + time + } else if n.fract() == 0.0 { + date + } else { + format!("{date} {time}") + }; + if reads_back(&iso) { iso } else { raw } +} + /// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a /// protected sheet. A spilled cell follows its anchor. pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cf24ab3e7..40a0851c4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2095,3 +2095,31 @@ fn copying_areas_that_do_not_line_up_is_refused_with_a_message() { assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "E1"})).is_err()); assert!(v(&s, "E1").is_empty()); } + +#[test] +fn editing_a_date_cell_shows_a_date_not_the_serial() { + let mut s = s(); + let at = CellRef::parse("A1").unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "2026-03-04"})).unwrap(); + { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(crate::display::edit_text(&d.wb, sh, at), "2026-03-04"); + } + // A format whose text cannot be typed back falls back to ISO. + s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "dddd, mmmm d"}})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(crate::display::edit_text(&d.wb, sh, at), "2026-03-04"); + // A time alone falls back to a time, not to day 0 of 1900 ("9 AM" would lose the 36 minutes). + let mut s3 = self::s(); + s3.execute("cell.set", json!({"cell": "A1", "input": "0.4"})).unwrap(); + s3.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "h AM/PM"}})).unwrap(); + let d = s3.doc().unwrap(); + assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at), "09:36:00"); + // Plain numbers are untouched. + let mut s2 = self::s(); + s2.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + let d = s2.doc().unwrap(); + assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at), "42"); +} diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 40a25c45c..7404a6132 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -78,7 +78,7 @@ fn active_input(app: &SheetApp) -> String { // A legacy array formula shows in braces (`{=A1:A2*2}`), as `Cell::bar_text` does. if f.array.is_some() { format!("{{{shown}}}") } else { shown } } else { - c.input_text() + gridcraft_engine::display::edit_text(&d.wb, sh, a) }; } // A spilled cell shows the anchor's formula greyed out in Excel; we show it plainly. diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 1dc90e404..54ab3193d 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -184,7 +184,7 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic Shortcut::FormulaAbove | Shortcut::ValueAbove => { let Some(above) = active.row.checked_sub(1).map(|r| CellRef::new(r, active.col)) else { return true }; let text = if shortcut == Shortcut::FormulaAbove { - sh.cell(above).map(|c| c.input_text()).unwrap_or_default() + app.session.active().map(|d| gridcraft_engine::display::edit_text(&d.wb, sh, above)).unwrap_or_default() } else { app.session .active() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d17439708..e4fa4baea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -550,11 +550,10 @@ impl SheetApp { let current = if sh.cell_pictures.contains_key(&at) { String::new() } else { - sh.cell(at) - .map( - |c| if c.formula.is_some() { formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet) } else { c.input_text() }, - ) - .unwrap_or_default() + match sh.cell(at) { + Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet), + _ => gridcraft_engine::display::edit_text(&d.wb, sh, at), + } }; let (text, replace) = match text { Some(t) => (t, true), From 568b754211b1473b039208f7114ce2ea9f22eac4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:10:49 +0200 Subject: [PATCH 31/54] Ctrl+click on an already selected header or cell deselects it (#359) --- crates/ui-egui/src/grid.rs | 41 ++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 29085bdd4..258130027 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1479,9 +1479,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & d.selection.anchor = sel.anchor; } } else if mods.command { - let mut ranges: Vec = sel.ranges.iter().map(|r| r.a1()).collect(); - ranges.push(c.a1()); - let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": c.a1()})); + select_ranges(app, RangeRef::cell(c), true, c); } else { let _ = app.session.run("selection.set", json!({"cell": c.a1()})); if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { @@ -1903,11 +1901,32 @@ pub fn follow_link(app: &mut SheetApp, target: &str) { } } +/// The areas after a click: with `add` (Ctrl) the clicked area is added, or removed when it is already +/// selected (unless it is the only one, so the selection never ends up empty). +fn toggled_ranges(mut ranges: Vec, clicked: String, add: bool) -> Vec { + if !add { + return vec![clicked]; + } + if ranges.len() > 1 && ranges.contains(&clicked) { + ranges.retain(|x| *x != clicked); + } else if !ranges.contains(&clicked) { + ranges.push(clicked); + } + ranges +} + fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { - let mut ranges: Vec = + let current: Vec = if add { app.session.active().map(|d| d.selection.ranges.iter().map(|x| x.a1()).collect()).unwrap_or_default() } else { vec![] }; - ranges.push(r.a1()); - let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()})); + let clicked = r.a1(); + let ranges = toggled_ranges(current, clicked.clone(), add); + // A removed area takes the active cell with it; `selection.set` then puts it at the start of the last area. + let params = if ranges.contains(&clicked) { + json!({"range": ranges.join(","), "active": active.a1()}) + } else { + json!({"range": ranges.join(",")}) + }; + let _ = app.session.run("selection.set", params); } /// Grid keyboard handling when no text field has focus. @@ -2590,6 +2609,16 @@ mod pane_tests { assert!(!range_in_pane(sel, corner.0, corner.1)); } + #[test] + fn ctrl_click_on_a_selected_header_deselects_it() { + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), true), s(&["A:A"])); + assert_eq!(toggled_ranges(s(&["A:A"]), "C:C".into(), true), s(&["A:A", "C:C"])); + assert_eq!(toggled_ranges(s(&["A:A"]), "A:A".into(), true), s(&["A:A"])); + assert_eq!(toggled_ranges(s(&["A1", "B2", "3:3"]), "B2".into(), true), s(&["A1", "3:3"])); + assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), false), s(&["C:C"])); + } + #[test] fn selection_across_the_freeze_line_is_drawn_in_both_panes() { let sel = rng(5, 0, 7, 6); From baffd3c78d9fc57e2d0f0abff0f0a3e86f18309f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:30:47 -0400 Subject: [PATCH 32/54] Fix-up #325: cap Forecast Sheet history and forecast rows The default periods (a third of the history, up to ~33k) bypassed MAX_PERIODS, and each forecast row's three ETS formulas refit over the whole history, so 100k history rows x 10k periods froze the app. History is now at most 10,000 rows, periods at most 10,000, and history x periods at most 500,000 (about 3 us per history point per fit: a few seconds of recalc at the cap). The default is clamped to the cap; explicit periods or an end past it are refused. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/forecast.rs | 69 +++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 5bcd3d78e..f53ab6603 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -16,9 +16,25 @@ use crate::selection::current_region; const CMD: &str = "data.forecastSheet"; /// Most history rows a forecast sheet copies. -const MAX_HISTORY: u64 = 100_000; +const MAX_HISTORY: u64 = 10_000; /// Most forecast rows. const MAX_PERIODS: u32 = 10_000; +/// Most history rows × forecast rows. Every forecast row holds three ETS formulas that each refit +/// the model over the whole history (about 3 µs per history point in a release build), so this +/// bounds a sheet's recalculation to a few seconds: 10,000 rows of history get 50 forecast rows, +/// 50 rows of history the full 10,000. +const MAX_WORK: u64 = 500_000; + +/// Forecast rows by default: a third of the history, within [`max_periods`]. +fn default_periods(history: usize) -> u32 { + u32::try_from(history.div_ceil(3)).unwrap_or(u32::MAX).clamp(1, max_periods(history)) +} + +/// Most forecast rows for `history` rows of history. +fn max_periods(history: usize) -> u32 { + let h = u64::try_from(history).unwrap_or(u64::MAX).max(1); + u32::try_from(MAX_WORK / h).unwrap_or(u32::MAX).clamp(1, MAX_PERIODS) +} pub fn specs() -> Vec { vec![cmd!( @@ -26,7 +42,7 @@ pub fn specs() -> Vec { "Forecast Sheet", ["Data", "Forecast"], None, - "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", + "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", has_doc, forecast_sheet )] @@ -215,12 +231,13 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { }, _ => None, }; + let cap = max_periods(rows.len()); let periods = match end { - Some(end) => steps_to(&timeline, end)?, + Some(end) => steps_to(&timeline, end, cap)?, None => match number(p, "periods")? { - Some(n) if n >= 1.0 && n <= f64::from(MAX_PERIODS) => n.ceil() as u32, - Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {MAX_PERIODS}"))), - None => (rows.len() as u32).div_ceil(3).max(1), + Some(n) if n >= 1.0 && n.ceil() <= f64::from(cap) => n.ceil() as u32, + Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {cap} for {} rows of history", rows.len()))), + None => default_periods(rows.len()), }, }; let future: Vec = @@ -392,14 +409,14 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { } /// Forecast rows needed to reach `end` (rounded up to whole steps). -fn steps_to(timeline: &Timeline, end: f64) -> Result { +fn steps_to(timeline: &Timeline, end: f64, cap: u32) -> Result { let steps = timeline.steps_after_last(end).ok_or_else(|| bad(CMD, "`end` is not on the timeline"))?; if steps < 1e-9 { return Err(EngineError::Other("The forecast end must be after the last point of the timeline.".into())); } let steps = steps.ceil(); - if steps > f64::from(MAX_PERIODS) { - return Err(bad(CMD, format!("at most {MAX_PERIODS} forecast steps"))); + if steps > f64::from(cap) { + return Err(bad(CMD, format!("at most {cap} forecast steps for this much history"))); } Ok(steps as u32) } @@ -469,6 +486,40 @@ mod tests { assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1); } + #[test] + fn history_and_periods_are_capped() { + use super::{MAX_HISTORY, MAX_PERIODS, MAX_WORK, default_periods, max_periods}; + // The default (a third of the history) never exceeds the caps. + assert_eq!(default_periods(16), 6); + assert_eq!(default_periods(1_200), 400); + assert_eq!(default_periods(3_000), 166); + assert_eq!(default_periods(100_000), 5); + assert_eq!(max_periods(10), MAX_PERIODS); + assert_eq!(max_periods(0), MAX_PERIODS); + for h in [1usize, 2, 3, 50, 51, 1_000, 1_300, 3_000, 10_000, 100_000, usize::MAX] { + let (d, m) = (default_periods(h), max_periods(h)); + assert!(d >= 1 && d <= m && m <= MAX_PERIODS, "{h}: {d} {m}"); + assert!(u64::try_from(h).unwrap_or(u64::MAX).saturating_mul(u64::from(m)) <= MAX_WORK.max(u64::try_from(h).unwrap_or(u64::MAX)), "{h}"); + } + // Explicit periods or an end past the cap are refused before anything is built. + let mut s = Session::new(); + s.new_workbook(); + let mut rows = vec![json!(["t", "y"])]; + rows.extend((1..=1_300).map(|t| json!([t, (t % 7) as f64 + t as f64 * 0.5]))); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + let sheets = s.doc().unwrap().wb.sheets.len(); + let cap = max_periods(1_300); // 384 + let err = |r: crate::Result| r.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": cap + 1}))).contains("from 1 to 384")); + assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "end": 1_300 + cap + 1}))).contains("at most 384")); + assert!(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": 10_000})).is_err()); + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets); + // More history rows than MAX_HISTORY are refused. + let big = MAX_HISTORY + 2; + let r = s.execute("data.forecastSheet", json!({"timeline": format!("A1:A{big}"), "values": format!("B1:B{big}")})); + assert!(r.is_err()); + } + #[test] fn forecast_sheet_parameters_and_errors() { let mut s = session_with_history(); From 8fcb9b1007e7a5193efd5899559b83816e81c58a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 14:15:18 -0400 Subject: [PATCH 33/54] Fix-up: es/de/cs strings for Forecast Sheet (#325) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/es.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 6ca8162b9..65e9defc0 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Forecast\nSheet" => "List\nprognózy", "Add footer" => "Přidat zápatí", "Add header" => "Přidat záhlaví", "Click to add data" => "Kliknutím přidáte data", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index af76af0ef..5cb9881b7 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Forecast\nSheet" => "Prognose-\nblatt", "Add footer" => "Fußzeile hinzufügen", "Add header" => "Kopfzeile hinzufügen", "Click to add data" => "Klicken, um Daten hinzuzufügen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 96d250aec..b30a1d132 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Forecast\nSheet" => "Hoja de\npronóstico", "Add footer" => "Agregar pie de página", "Add header" => "Agregar encabezado", "Click to add data" => "Haga clic para agregar datos", From fb8e8ac8f1f7c56ecb6a3ad5729cfaeb9797e35d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 14:22:39 -0400 Subject: [PATCH 34/54] Docs: catalog 272 / 290 after #325 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++-- docs/parity-checklist.md | 4 ++-- docs/target-app-parity.md | 4 ++-- docs/ui-parity.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5cd6bf601..a62418e19 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 271 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 272 / 290) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 271 / 290 (93.4%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 272 / 290 (93.8%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8d84e4d0f..3fa0ef0a0 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 271 / 290 commands implemented (93.4%).** +**Overall: 272 / 290 commands implemented (93.8%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -14,7 +14,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | -| Data | 22 | 28 | 79% | +| Data | 23 | 28 | 82% | | Review | 15 | 18 | 83% | | View | 20 | 20 | 100% | | Automate | 3 | 3 | 100% | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 86234f851..f68d716db 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 271 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 272 / 290) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **271 / 290 (93.4%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **272 / 290 (93.8%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d7ce163fd..e56770edc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -4,7 +4,7 @@ Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (271 / 290). +separately in [parity-checklist.md](parity-checklist.md) (272 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes From 743ca809ccf80f18837f861f73a73a860ede8505 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 14:34:37 -0400 Subject: [PATCH 35/54] Fix-up: Insert Copied Cells target and PivotChart anchor use #206's checked cell_param Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/pivot.rs | 3 ++- crates/engine/src/cmd/sheet.rs | 5 +---- crates/ui-egui/src/grid.rs | 7 ++----- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 630fa2b9a..0d73b0a4a 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -1338,7 +1338,8 @@ fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result { let series = pv::chart_series(&sh.name, area.start, &out); let t = str_param(p, "type").unwrap_or("column"); let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or("")); - let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1))); + let at = + cell_param(s, p, "at")?.unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1))); let title = match p.get("title") { Some(Json::String(t)) => Some(t.clone()), Some(Json::Null) => None, diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 49f00e520..626e8fbbe 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -400,10 +400,7 @@ fn insert_copied_cells(s: &mut Session, p: &Json) -> Result { let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; let sheet = target_sheet(s, p)?; let d = s.doc()?; - let at = match str_param(p, "at") { - Some(t) => cell_param(p, "at").ok_or_else(|| bad("home.insertCopiedCells", format!("not a cell: {t}")))?, - None => d.selection.current().start, - }; + let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(d.selection.current().start); let shift = str_param(p, "shift").unwrap_or("down"); if shift != "down" && shift != "right" { return Err(bad("home.insertCopiedCells", format!("unknown `shift`: {shift} (down or right)"))); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d7009ecb5..c555c7411 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2115,11 +2115,8 @@ fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { let clicked = r.a1(); let ranges = toggled_ranges(current, clicked.clone(), add); // A removed area takes the active cell with it; `selection.set` then puts it at the start of the last area. - let params = if ranges.contains(&clicked) { - json!({"range": ranges.join(","), "active": active.a1()}) - } else { - json!({"range": ranges.join(",")}) - }; + let params = + if ranges.contains(&clicked) { json!({"range": ranges.join(","), "active": active.a1()}) } else { json!({"range": ranges.join(",")}) }; let _ = app.session.run("selection.set", params); } From 2ded7bf19333492e869442784475083e814c4c00 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:39:01 -0500 Subject: [PATCH 36/54] XLSX: write the _xlfn. prefix for GROUPBY, PIVOTBY, ENCODEURL and the REGEX functions These were missing from the list of functions newer than the original file format, so they were saved without the prefix, and Excel shows #NAME? for such formulas. --- crates/xlsx/src/fmla.rs | 2 ++ crates/xlsx/src/tables.rs | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index 9ac4ccf09..c48341462 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -132,6 +132,8 @@ mod tests { assert_eq!(to_file(&Formula::new("FILTER(A1:A3,B1:B3)")), "_xlfn._xlws.FILTER(A1:A3,B1:B3)"); assert_eq!(from_file("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); assert_eq!(text_to_file("=IFS(A1>0,1)"), "_xlfn.IFS(A1>0,1)"); + assert_eq!(to_file(&Formula::new("REGEXTEST(A1,\"x\")&ENCODEURL(B1)")), "_xlfn.REGEXTEST(A1,\"x\")&_xlfn.ENCODEURL(B1)"); + assert_eq!(to_file(&Formula::new("GROUPBY(A1:A3,B1:B3,\"SUM\")")), "_xlfn.GROUPBY(A1:A3,B1:B3,\"SUM\")"); } #[test] diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index 2d63dec74..fe7cbdc42 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -133,6 +133,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "DAYS", "DECIMAL", "DROP", + "ENCODEURL", "ERF.PRECISE", "ERFC.PRECISE", "EXPAND", @@ -157,6 +158,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "GAMMA.INV", "GAMMALN.PRECISE", "GAUSS", + "GROUPBY", "HSTACK", "HYPGEOM.DIST", "IFNA", @@ -199,6 +201,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "PERCENTRANK.INC", "PERMUTATIONA", "PHI", + "PIVOTBY", "POISSON.DIST", "QUARTILE.EXC", "QUARTILE.INC", @@ -206,6 +209,9 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "RANK.AVG", "RANK.EQ", "REDUCE", + "REGEXEXTRACT", + "REGEXREPLACE", + "REGEXTEST", "RRI", "SCAN", "SEC", From 85ca8f6af57cca475886d6b9179abb4cfb057060 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:54:04 -0500 Subject: [PATCH 37/54] INDEX: arrays of row and column numbers pick a value each INDEX took one row and one column number, so INDEX(A1:C3,{1;3},{1,3}) gave a single value and SUM(INDEX(A1:C3,{1,2,3},2)) summed one cell. In Excel, arrays of positions return an array of the values at each (broadcast) position. A position that picks a whole row or column gives #CALC! there, as arrays can't nest. --- crates/calc/src/eval.rs | 57 +++++++++++++++++++++++++++++++++------- crates/calc/src/tests.rs | 18 +++++++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 3d4a743d9..9aa80fdca 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1085,18 +1085,57 @@ impl<'h> Evaluator<'h> { return err(CellError::Value); } let src = self.eval(&args[0]); - let row = match self.arg_num(args, 1, 0.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let col = match self.arg_num(args, 2, 0.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), + let mut arg = |i: usize| match args.get(i) { + None | Some(Expr::Missing) => None, + Some(e) => Some(self.value(e)), }; + let (rows, cols) = (arg(1), arg(2)); let area_n = match self.arg_num(args, 3, 1.0) { Ok(n) => n.trunc() as i64, Err(e) => return err(e), }; + let two = args.len() == 2; + let num = |v: &Option| v.as_ref().map_or(Ok(0.0), |v| v.to_number()).map(|n| n.trunc() as i64); + // Arrays of positions pick a value for each: INDEX(A1:C10,{1;2},{1,3}) is 2×2. + if let (Some(Value::Array(_)), _) | (_, Some(Value::Array(_))) = (&rows, &cols) { + let as_array = |v: &Option| match v { + Some(Value::Array(a)) => Arc::clone(a), + Some(v) => Arc::new(Array::scalar(v.clone())), + None => Arc::new(Array::scalar(Value::Number(0.0))), + }; + let (ra, ca) = (as_array(&rows), as_array(&cols)); + let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols)); + if (h * w) as u64 > MAX_CELLS { + return err(CellError::Num); + } + let mut out = Vec::with_capacity(h * w); + for r in 0..h { + for c in 0..w { + let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c)))); + let v = match pos { + (Ok(row), Ok(col)) => { + let one = self.index_one(src.clone(), row, col, area_n, two); + match self.deref(one) { + Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc), + v => v.scalar(), + } + } + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }; + out.push(v); + } + } + return Ev::V(Array::new(h, w, out).map(Value::from).unwrap_or(Value::Error(CellError::Value))); + } + let (row, col) = match (num(&rows), num(&cols)) { + (Ok(r), Ok(c)) => (r, c), + (Err(e), _) | (_, Err(e)) => return err(e), + }; + self.index_one(src, row, col, area_n, two) + } + + /// INDEX for one row and column position (0 = the whole column or row). + fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev { if row < 0 || col < 0 { return err(CellError::Value); } @@ -1105,7 +1144,7 @@ impl<'h> Evaluator<'h> { let Some(a) = areas.get((area_n - 1).max(0) as usize).copied() else { return err(CellError::Ref) }; let r = a.range; // A single row or column with one index picks along it. - let (row, col) = if args.len() == 2 && r.height() == 1 { (1, row) } else { (row, col) }; + let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) }; if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { return err(CellError::Ref); } @@ -1114,7 +1153,7 @@ impl<'h> Evaluator<'h> { Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }]) } Ev::V(Value::Array(arr)) => { - let (row, col) = if args.len() == 2 && arr.rows == 1 { (1, row) } else { (row, col) }; + let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) }; if row as usize > arr.rows || col as usize > arr.cols { return err(CellError::Ref); } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 2205a1e61..50c4353bc 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -106,6 +106,24 @@ fn long_chain() { assert_eq!(t.num("A3000"), 3009.0); } +#[test] +fn index_with_arrays_of_positions() { + let mut t = T::new(); + for (i, row) in [[1, 2, 3], [4, 5, 6], [7, 8, 9]].iter().enumerate() { + for (j, v) in row.iter().enumerate() { + t.set(&format!("{}{}", ["A", "B", "C"][j], i + 1), &v.to_string()); + } + } + t.set("E1", "=INDEX(A1:C3,{1;3},{1,3})"); + assert_eq!([t.num("E1"), t.num("F1"), t.num("E2"), t.num("F2")], [1.0, 3.0, 7.0, 9.0]); + t.set("E4", "=SUM(INDEX(A1:C3,{1,2,3},2))"); + assert_eq!(t.num("E4"), 15.0); + t.set("E5", "=INDEX({10,20,30},{3,1})"); + assert_eq!([t.num("E5"), t.num("F5")], [30.0, 10.0]); + t.set("E7", "=INDEX(A1:C3,2,3)"); + assert_eq!(t.num("E7"), 6.0); +} + #[test] fn errors_and_cycles() { let mut t = T::new(); From 683ff6003c3f80975d383d732d80fb08b3ee5521 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:31:01 -0500 Subject: [PATCH 38/54] Defined names with relative references A name whose references have no $ was evaluated as if they were absolute. In Excel such references are relative: defined from B2, =Sheet1!A2 means "the cell to the left", and the file stores it relative to A1 (Sheet1!XFD1, wrapping around the sheet's edge). Name formulas with relative references are now moved by the using cell's offset from A1, wrapping at the edges, and formulas.defineName takes relative references as relative to the active cell, as Excel's Name Manager does, and stores them relative to A1. --- crates/calc/src/eval.rs | 7 +++++++ crates/calc/src/tests.rs | 26 +++++++++++++++++++++++ crates/engine/src/cmd/formulas.rs | 34 ++++++++++++++++++++++++++++++- crates/formula/src/adjust.rs | 29 ++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 3d4a743d9..e5d621d83 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -249,6 +249,13 @@ impl<'h> Evaluator<'h> { let scope = def.scope.unwrap_or(self.sheet); return match gridcraft_formula::parse(&text) { Ok(expr) => { + // Relative references in a name are relative to A1: used here, they move by + // this cell's offset from it. + let expr = if gridcraft_formula::adjust::has_relative_refs(&expr) { + gridcraft_formula::adjust::shift_relative_wrapping(expr, self.at.row as i64, self.at.col as i64) + } else { + expr + }; let saved = self.sheet; // Unqualified references in a name refer to the scope sheet. self.sheet = scope; diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 2205a1e61..bf64b5cd1 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -261,6 +261,32 @@ fn references_functions() { assert_eq!(t.num("B6"), 105.0); } +#[test] +fn relative_names() { + let mut t = T::new(); + // As files store them: relative to A1. `Left` is the cell to the left, `Above3` the three + // cells above, `Mixed` column A of the same row. + for (name, formula) in [("Left", "Sheet1!XFD1"), ("Above3", "Sheet1!A1048574:A1048576"), ("Mixed", "Sheet1!$A1")] { + t.wb.names.push(gridcraft_model::DefinedName { + name: name.into(), + scope: None, + formula: formula.into(), + comment: String::new(), + hidden: false, + }); + } + t.calc.rebuild(&t.wb); + for r in 1..=5 { + t.set(&format!("A{r}"), &(r * 10).to_string()); + } + t.set("B2", "=Left"); + assert_eq!(t.num("B2"), 20.0); + t.set("B4", "=Left+Mixed"); + assert_eq!(t.num("B4"), 80.0); + t.set("A6", "=SUM(Above3)"); + assert_eq!(t.num("A6"), 120.0); +} + #[test] fn spills() { let mut t = T::new(); diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index db8176bf2..df48a2c14 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -253,7 +253,15 @@ fn define_name(s: &mut Session, p: &Json) -> Result { Some(sh) => Some(d.wb.sheet_index(sh).ok_or_else(|| bad("formulas.defineName", "no such sheet"))?), }; let refers = match str_param(p, "refersTo") { - Some(r) => r.trim_start_matches('=').to_string(), + // As in Excel, relative references are relative to the active cell; names keep them + // relative to A1, as files do. + Some(r) => match gridcraft_formula::parse(r.trim_start_matches('=')) { + Ok(e) if gridcraft_formula::adjust::has_relative_refs(&e) => { + let at = d.selection.active; + gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative_wrapping(e, -(at.row as i64), -(at.col as i64))) + } + _ => r.trim_start_matches('=').to_string(), + }, None => { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let r = d.selection.current(); @@ -564,3 +572,27 @@ fn calc_options(s: &mut Session, p: &Json) -> Result { Ok(json!({"mode": format!("{:?}", cx.wb.calc.mode)})) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_with_relative_references() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap(); + // Defined from B2, `=Sheet1!A2` is "the cell to the left". + s.execute("selection.set", json!({"range": "B2"})).unwrap(); + let r = s.execute("formulas.defineName", json!({"name": "LeftCell", "refersTo": "=Sheet1!A2"})).unwrap(); + assert_eq!(r["refersTo"], "=Sheet1!XFD1"); + s.execute("cell.set", json!({"cell": "B1", "input": "=LeftCell"})).unwrap(); + s.execute("cell.set", json!({"cell": "B3", "input": "=LeftCell*10"})).unwrap(); + let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone(); + assert_eq!(v(&mut s, "B1"), json!(1.0)); + assert_eq!(v(&mut s, "B3"), json!(30.0)); + // Absolute references are kept as they are. + let r = s.execute("formulas.defineName", json!({"name": "First", "refersTo": "=Sheet1!$A$1"})).unwrap(); + assert_eq!(r["refersTo"], "=Sheet1!$A$1"); + } +} diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 760167436..a54eb0dbb 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -16,6 +16,35 @@ pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr { }) } +/// Defined names: relative parts move by `(dr, dc)`, wrapping around the sheet's edges. Files +/// store a name's relative references as seen from A1 (a name for "the cell to the left" defined +/// in B1 is `Sheet1!XFD1`), and a use in another cell moves them by that cell's offset from A1. +pub fn shift_relative_wrapping(e: Expr, dr: i64, dc: i64) -> Expr { + let wrap = |v: u32, abs: bool, d: i64, max: u32| if abs { v } else { (v as i64 + d).rem_euclid(max as i64) as u32 }; + let anchor = |a: Anchor| Anchor { row: wrap(a.row, a.row_abs, dr, MAX_ROWS), col: wrap(a.col, a.col_abs, dc, MAX_COLS), ..a }; + e.map(&mut |x| match x { + Expr::Ref(r) => { + let kind = match r.kind { + RefKind::Cell(a) => RefKind::Cell(anchor(a)), + RefKind::Range(a, b) => RefKind::Range(anchor(a), anchor(b)), + RefKind::Rows(r0, a0, r1, a1) => RefKind::Rows(wrap(r0, a0, dr, MAX_ROWS), a0, wrap(r1, a1, dr, MAX_ROWS), a1), + RefKind::Cols(c0, a0, c1, a1) => RefKind::Cols(wrap(c0, a0, dc, MAX_COLS), a0, wrap(c1, a1, dc, MAX_COLS), a1), + }; + Expr::Ref(Reference { sheet: r.sheet, kind }) + } + other => other, + }) +} + +/// Whether the formula has a relative (no `$`) row or column anywhere. +pub fn has_relative_refs(e: &Expr) -> bool { + e.references().iter().any(|r| match r.kind { + RefKind::Cell(a) => !a.row_abs || !a.col_abs, + RefKind::Range(a, b) => !a.row_abs || !a.col_abs || !b.row_abs || !b.col_abs, + RefKind::Rows(_, a0, _, a1) | RefKind::Cols(_, a0, _, a1) => !a0 || !a1, + }) +} + fn shift_anchor(a: &Anchor, dr: i64, dc: i64) -> Option { let row = if a.row_abs { a.row as i64 } else { a.row as i64 + dr }; let col = if a.col_abs { a.col as i64 } else { a.col as i64 + dc }; From 686501c7de991ccd3fa830c94281df6ab1047af4 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:01:24 -0500 Subject: [PATCH 39/54] Data validation: relative formulas, checked with the value being entered Validation formulas were evaluated as written, at every cell of the range, and against the cell's old value. A Custom rule =A1>10 on A1:A9 rejected 20 typed into A1 (A1 was still empty) and checked A1, not A5, when typing into A5; a Whole number rule "<= B1" on C1:C9 compared C2 with B1. As in Excel, the formulas are now relative to the first cell of the range and see the value being entered. --- crates/engine/src/cmd/data.rs | 35 +++++++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 13 +++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 32ec866dc..cb793e728 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -952,10 +952,26 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - return None; } let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; - let num = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, f).to_number().ok(); + // The formulas are written for the first cell of the range and see the value being entered, + // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). + let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); + let entered; + let wb = if sh.value(at) == v { + wb + } else { + let mut w = wb.clone(); + if let Some(sh) = w.sheet_mut(sheet) { + let style = sh.style_id(at); + sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() }); + } + entered = w; + &entered + }; + let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)); + let num = |f: &str| eval(f).to_number().ok(); let ok = match dv.kind { ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), - ValidationKind::Custom => gridcraft_calc::evaluate(wb, sheet, at, &dv.f1).to_bool().unwrap_or(false), + ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false), ValidationKind::TextLength => { let n = input.chars().count() as f64; compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) @@ -982,6 +998,21 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - Some((dv.clone(), msg)) } +/// A formula written for `first`, as seen from `at` (relative references shifted). +fn formula_at(f: &str, first: CellRef, at: CellRef) -> String { + if first == at { + return f.to_string(); + } + match gridcraft_formula::parse(f.trim_start_matches('=')) { + Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative( + e, + at.row as i64 - first.row as i64, + at.col as i64 - first.col as i64, + )), + Err(_) => f.to_string(), + } +} + fn compare_op(op: CfOperator, n: f64, a: Option, b: Option) -> bool { let Some(a) = a else { return false }; match op { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 575cc775d..032209531 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1152,6 +1152,19 @@ fn ods_import_never_reuses_source_as_save_target() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +fn validation_formulas_are_relative_and_see_the_new_value() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [100]]})).unwrap(); + s.execute("data.validation", json!({"range": "A1:A2", "type": "custom", "formula1": "=A1>10"})).unwrap(); + s.execute("data.validation", json!({"range": "C1:C2", "type": "whole", "operator": "lessOrEqual", "formula1": "=B1"})).unwrap(); + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + assert!(ok(&mut s, "A1", "20")); + assert!(!ok(&mut s, "A2", "5")); + assert!(ok(&mut s, "C2", "50")); + assert!(!ok(&mut s, "C1", "50")); +} + #[test] fn samples_build() { for (name, _) in crate::sample::SAMPLES { From 8aa9cad7039f44fa813555cb8c573e3d542c3121 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 14:58:29 -0400 Subject: [PATCH 40/54] Slicers: the anchor uses #206's checked cell_param and stays on the sheet; drop the personal macOS workflow (review fix-up for #193) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/slicer.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/slicer.rs b/crates/engine/src/cmd/slicer.rs index 907ac7e3f..00342a5a9 100644 --- a/crates/engine/src/cmd/slicer.rs +++ b/crates/engine/src/cmd/slicer.rs @@ -119,7 +119,8 @@ fn insert_slicer(s: &mut Session, p: &Json) -> Result { } }; let table = t.name.clone(); - let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2))); + let at = cell_param(s, p, "at")? + .unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1))); let columns = u32_param(p, "columns").unwrap_or(1).clamp(1, 10); let width = f64_param(p, "width").unwrap_or(168.0).clamp(60.0, 10000.0) as f32; let height = f64_param(p, "height").unwrap_or(200.0).clamp(60.0, 10000.0) as f32; From 7a4aaebab0f60b3b5511aa14d1ca49743db676dc Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 20:58:49 +0200 Subject: [PATCH 41/54] Grid: merged ranges draw their right and bottom borders from the edge cells (#376) Excel stores a merged range's outline on the cells along each edge; the grid only used the anchor cell's borders, so a boxed merge showed only top and left. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/grid.rs | 62 +++++++++++++++++++++++++++++---- crates/ui-egui/tests/borders.rs | 33 ++++++++++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a62418e19..aef29c14e 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-11 | Merged cells draw their whole outline: right and bottom edges come from the last column's and last row's cells (#376) | | 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | | 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index f6dc683cb..468b406e7 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use egui::{Align2, Color32, CursorIcon, FontId, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use gridcraft_engine::cf::{CfCache, CfLook}; use gridcraft_engine::core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters}; -use gridcraft_engine::model::{BorderStyle, Color, HAlign, LineIndex, PatternType, Sheet, Style, Underline, VAlign, Workbook}; +use gridcraft_engine::model::{BorderLine, BorderStyle, Color, HAlign, LineIndex, PatternType, Sheet, Style, Underline, VAlign, Workbook}; use serde_json::json; use crate::SheetApp; @@ -1037,12 +1037,19 @@ pub(crate) fn paint_quadrant( // Borders (over fills and text). for (c, rect, lk, _) in &looks { let b = &lk.style.borders; - let edges = [ - (b.top, [rect.left_top(), rect.right_top()]), - (b.bottom, [rect.left_bottom(), rect.right_bottom()]), - (b.left, [rect.left_top(), rect.left_bottom()]), - (b.right, [rect.right_top(), rect.right_bottom()]), - ]; + let merge = merges.iter().find(|m| m.start == *c); + let edges = match merge { + // A merged area's outline lives on the cells along each of its edges (Excel writes the + // right edge on the last column's cells and the bottom edge on the last row's), so each + // edge is drawn cell by cell from the cell it belongs to. + Some(m) => merged_edges(wb, si, sh, geo, *m, *rect, (r0, r1), (c0, c1), cf), + None => vec![ + (b.top, [rect.left_top(), rect.right_top()]), + (b.bottom, [rect.left_bottom(), rect.right_bottom()]), + (b.left, [rect.left_top(), rect.left_bottom()]), + (b.right, [rect.right_top(), rect.right_bottom()]), + ], + }; for (line, pts) in edges { if line.is_none() { continue; @@ -1113,6 +1120,47 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s)) } +/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the +/// first row's and first column's cells, the bottom and right edges from the last row's and last +/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`). +#[allow(clippy::too_many_arguments)] +fn merged_edges( + wb: &Workbook, + si: usize, + sh: &Sheet, + geo: &Geo, + m: RangeRef, + rect: Rect, + rows: (u32, u32), + cols: (u32, u32), + cf: &mut CfCache, +) -> Vec<(BorderLine, [Pos2; 2])> { + let mut out = Vec::new(); + let (cs, ce) = (m.start.col.max(cols.0), m.end.col.min(cols.1)); + for col in cs..=ce { + if sh.col_width(col) == 0.0 { + continue; + } + let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1))); + let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top; + let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom; + out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())])); + out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())])); + } + let (rs, re) = (m.start.row.max(rows.0), m.end.row.min(rows.1)); + for row in rs..=re { + if sh.row_height(row) == 0.0 { + continue; + } + let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1))); + let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left; + let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right; + out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)])); + out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)])); + } + out +} + pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { let w = style.width(); let (a, b) = (pts[0], pts[1]); diff --git a/crates/ui-egui/tests/borders.rs b/crates/ui-egui/tests/borders.rs index af8a1a9dc..f248fc842 100644 --- a/crates/ui-egui/tests/borders.rs +++ b/crates/ui-egui/tests/borders.rs @@ -127,3 +127,36 @@ fn border_preview_uses_the_colored_draft_until_ok_and_cancel_discards_it() { h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(borders(&h, "A1"), original); } + +/// #376: a merged range's outline lives on the cells along each edge (the right edge on the last +/// column's cells, the bottom on the last row's), not on the anchor alone. All four sides are drawn. +#[test] +fn merged_range_draws_the_outline_from_its_edge_cells() { + let mut h = harness(); + h.state_mut().run("home.borders", json!({"range": "B2:E5", "preset": "thickOutside", "color": "#C81E1E"})).unwrap(); + h.state_mut().run("home.mergeCells", json!({"range": "B2:E5"})).unwrap(); + h.state_mut().run("selection.set", json!({"range": "H10"})).unwrap(); + h.run_steps(6); + assert!(borders(&h, "B2").right.is_none(), "the anchor carries only the top and left edges"); + let red = Color32::from_rgb(0xC8, 0x1E, 0x1E); + let segs: Vec<[egui::Pos2; 2]> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::LineSegment { points, stroke } if stroke.color == red => Some(*points), + _ => None, + }) + .collect(); + let vertical = |s: &[egui::Pos2; 2]| (s[0].x - s[1].x).abs() < 0.5; + let xs: Vec = segs.iter().filter(|s| vertical(s)).map(|s| s[0].x).collect(); + let ys: Vec = segs.iter().filter(|s| !vertical(s)).map(|s| s[0].y).collect(); + let (min_x, max_x) = xs.iter().fold((f32::MAX, f32::MIN), |(a, b), x| (a.min(*x), b.max(*x))); + let (min_y, max_y) = ys.iter().fold((f32::MAX, f32::MIN), |(a, b), y| (a.min(*y), b.max(*y))); + // Left and right edges (four rows each), top and bottom edges (four columns each). + assert_eq!(xs.iter().filter(|x| (**x - min_x).abs() < 0.5).count(), 4, "left edge"); + assert_eq!(xs.iter().filter(|x| (**x - max_x).abs() < 0.5).count(), 4, "right edge"); + assert_eq!(ys.iter().filter(|y| (**y - min_y).abs() < 0.5).count(), 4, "top edge"); + assert_eq!(ys.iter().filter(|y| (**y - max_y).abs() < 0.5).count(), 4, "bottom edge"); + assert!(max_x - min_x > 100.0 && max_y - min_y > 40.0, "the outline spans the whole merged area"); +} From 9d0053f3add9fc96e543d516f5847167fabf1ca9 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:58:16 -0500 Subject: [PATCH 42/54] XLOOKUP: return a reference when the result range is one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XLOOKUP returned only values, so it couldn't end a range or feed functions that need a reference: SUM(XLOOKUP("b",A1:A4,B1:B4): XLOOKUP("d",A1:A4,B1:B4)) and SUM(B1:XLOOKUP(…)) gave #VALUE!, and so did CELL("address",XLOOKUP(…)). A match on an empty cell gave 0, so ISBLANK(XLOOKUP(…)) was FALSE and "x"&XLOOKUP(…) gave "x0" (#374). In Excel XLOOKUP returns a reference to the matching row (or column) of its result range. The evaluator now does so, finding the position with XMATCH; arrays of lookup values, array arguments and errors still go through the library function. --- crates/calc/src/eval.rs | 52 ++++++++++++++++++++++++++++++++++++++++ crates/calc/src/tests.rs | 26 ++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 3d4a743d9..025608ca1 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -681,6 +681,7 @@ impl<'h> Evaluator<'h> { "OFFSET" => self.offset(args), "INDIRECT" => self.indirect(args), "INDEX" => self.index(args), + "XLOOKUP" => self.xlookup(args), "CELL" => self.cell_info(args), "TRIMRANGE" => self.trimrange(args), "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), @@ -1137,6 +1138,57 @@ impl<'h> Evaluator<'h> { } } + /// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can + /// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of + /// lookup values, array arguments, errors) goes to the library function. + fn xlookup(&mut self, args: &[Expr]) -> Ev { + if (3..=6).contains(&args.len()) + && let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2])) + && let ([l], [r]) = (look.as_slice(), ret.as_slice()) + && let Some(spec) = gridcraft_functions::lookup("XMATCH") + { + let key = self.value(&args[0]); + let (l, r) = (*l, *r); + let vertical = l.range.width() == 1 && l.range.height() > 1; + let horizontal = l.range.height() == 1 && l.range.width() > 1; + let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() }; + if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len { + let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }]; + for i in [4, 5] { + match args.get(i) { + Some(Expr::Missing) | None => break, + Some(e) => { + let value = self.value(e); + xargs.push(Arg { value, from_ref: false }); + } + } + } + let mut ctx = FnCtx { ev: self }; + return match gridcraft_functions::call(spec, &xargs, &mut ctx) { + Value::Number(p) if p >= 1.0 => { + let k = p as u32 - 1; + let range = if vertical { + RangeRef::new( + CellRef::new(r.range.start.row + k, r.range.start.col), + CellRef::new(r.range.start.row + k, r.range.end.col), + ) + } else { + RangeRef::new( + CellRef::new(r.range.start.row, r.range.start.col + k), + CellRef::new(r.range.end.row, r.range.start.col + k), + ) + }; + Ev::R(vec![Area { sheet: r.sheet, range }]) + } + Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]), + Value::Error(e) => err(e), + _ => err(CellError::Value), + }; + } + } + self.builtin("XLOOKUP", args) + } + fn cell_info(&mut self, args: &[Expr]) -> Ev { let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { Some(Ok(t)) => t.to_ascii_lowercase(), diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 2205a1e61..3b2002759 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -94,6 +94,32 @@ fn chains_out_of_order() { assert_eq!(t.num("A5"), 5.0); } +#[test] +fn xlookup_returns_a_reference() { + let mut t = T::new(); + for (i, (k, v)) in [("a", 1), ("b", 2), ("c", 3), ("d", 4)].iter().enumerate() { + t.set(&format!("A{}", i + 1), k); + t.set(&format!("B{}", i + 1), &v.to_string()); + } + t.set("D1", "=SUM(XLOOKUP(\"b\",A1:A4,B1:B4):XLOOKUP(\"d\",A1:A4,B1:B4))"); + assert_eq!(t.num("D1"), 9.0); + t.set("D2", "=SUM(B1:XLOOKUP(\"c\",A1:A4,B1:B4))"); + assert_eq!(t.num("D2"), 6.0); + t.set("D3", "=CELL(\"address\",XLOOKUP(\"c\",A1:A4,B1:B4))"); + assert_eq!(t.get("D3"), Value::from("$B$3")); + // Not found: if_not_found, else #N/A, as before. + t.set("D4", "=XLOOKUP(\"z\",A1:A4,B1:B4,\"none\")"); + assert_eq!(t.get("D4"), Value::from("none")); + t.set("D5", "=XLOOKUP(\"z\",A1:A4,B1:B4)"); + assert_eq!(t.get("D5"), Value::Error(CellError::NA)); + // A match on an empty cell is a blank reference: ISBLANK is TRUE and & adds nothing (#374). + t.set("A5", "e"); + t.set("D6", "=ISBLANK(XLOOKUP(\"e\",A1:A5,B1:B5))"); + assert_eq!(t.get("D6"), Value::Bool(true)); + t.set("D7", "=\"x\"&XLOOKUP(\"e\",A1:A5,B1:B5)&\"y\""); + assert_eq!(t.get("D7"), Value::from("xy")); +} + #[test] fn long_chain() { let mut t = T::new(); From 347d2a5bc2e8d8f89c093e20fbbed4b67d5ac28f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 14:59:45 -0400 Subject: [PATCH 43/54] Docs: catalog 274 / 290 after #211 and #193 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++-- docs/parity-checklist.md | 17 +++++++---------- docs/target-app-parity.md | 4 ++-- docs/ui-parity.md | 2 +- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a62418e19..03830fb11 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 272 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 274 / 290) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 272 / 290 (93.8%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 3fa0ef0a0..d218c8169 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,13 +4,13 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 272 / 290 commands implemented (93.8%).** +**Overall: 274 / 290 commands implemented (94.5%).** | Tab | Implemented | Total | Parity | |---|---|---|---| | File | 11 | 11 | 100% | | Home | 94 | 94 | 100% | -| Insert | 16 | 25 | 64% | +| Insert | 17 | 25 | 68% | | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | @@ -18,7 +18,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Review | 15 | 18 | 83% | | View | 20 | 20 | 100% | | Automate | 3 | 3 | 100% | -| Table Design | 12 | 13 | 92% | +| Table Design | 13 | 13 | 100% | | Chart Design | 8 | 8 | 100% | | Format | 3 | 3 | 100% | | PivotTable Analyze | 13 | 13 | 100% | @@ -139,7 +139,7 @@ Implemented: - `edit.goToSpecial` Go To Special (Editing) - `home.addIns` Add-ins (Add-ins) -## Insert (16 / 25, 64%) +## Insert (17 / 25, 68%) Implemented: @@ -153,6 +153,7 @@ Implemented: - `insert.chart` Charts (Charts) - `insert.pivotChart` PivotChart (Charts) - `insert.sparkline` Sparklines (Sparklines) +- `insert.slicer` Slicer (Filters) - `insert.link` Link (Links) - `review.newComment` Comment (Comments) - `insert.textBox` Text Box (Text) @@ -165,7 +166,6 @@ Missing: - `insert.smartArt` SmartArt (Illustrations) - `insert.screenshot` Screenshot (Illustrations) - `insert.map` Maps (Charts) -- `insert.slicer` Slicer (Filters) - `insert.timeline` Timeline (Filters) - `insert.wordArt` WordArt (Text) - `insert.signatureLine` Signature Line (Text) @@ -332,7 +332,7 @@ Implemented: - `automate.allScripts` All Scripts (Scripting Tools) - `automate.recordActions` Record Actions (Scripting Tools) -## Table Design (12 / 13, 92%) +## Table Design (13 / 13, 100%) Implemented: @@ -341,6 +341,7 @@ Implemented: - `table.summarizePivot` Summarize with PivotTable (Tools) - `table.removeDuplicates` Remove Duplicates (Tools) - `table.convertToRange` Convert to Range (Tools) +- `table.insertSlicer` Insert Slicer (Tools) - `table.headerRow` Header Row (Table Style Options) - `table.totalRow` Total Row (Table Style Options) - `table.bandedRows` Banded Rows (Table Style Options) @@ -349,10 +350,6 @@ Implemented: - `table.bandedColumns` Banded Columns (Table Style Options) - `table.style` Table Styles (Table Styles) -Missing: - -- `table.insertSlicer` Insert Slicer (Tools) - ## Chart Design (8 / 8, 100%) Implemented: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f68d716db..8a00f5a3e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 272 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 274 / 290) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **272 / 290 (93.8%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **274 / 290 (94.5%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 152884c58..fcea332f5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -4,7 +4,7 @@ Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (272 / 290). +separately in [parity-checklist.md](parity-checklist.md) (274 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes From 3ae319154cb653341d30340d2d30d257380edb8f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 21:01:12 +0200 Subject: [PATCH 44/54] Grid: render Shrink to fit (#375) Cells with shrinkToFit drew at full size: text spilled or was clipped and numbers showed ####. Too-wide content now gets a smaller font that fits the cell or merged area, matching the PDF/print path. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/grid.rs | 22 +++++++++- crates/ui-egui/tests/shrink_to_fit.rs | 61 +++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/shrink_to_fit.rs diff --git a/ROADMAP.md b/ROADMAP.md index a62418e19..d6429312c 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-11 | Shrink to fit draws too-wide text and numbers in a smaller font inside the cell or merged area (#375) | | 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | | 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index f6dc683cb..2b018fe0f 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -934,10 +934,17 @@ pub(crate) fn paint_quadrant( continue; } let first_line = text.lines().next().unwrap_or("").to_string(); + // Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the + // merged area): it neither spills, nor is clipped, nor turns into ####. + let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none()) + .then(|| shrink_font(p, &first_line, &font, avail_w - 2.0 * pad - indent)) + .flatten(); + let shrunk = small.is_some(); + let font = small.unwrap_or(font); let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color); let mut w = galley.size().x; // Numbers and dates never overflow: they show #### when too narrow. - if (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill { + if !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill { let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize; galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); w = galley.size().x; @@ -1113,6 +1120,19 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s)) } +/// The smaller font that makes `text` fit in `room` points of width (Shrink to fit), or `None` +/// when it already fits at its own size. +fn shrink_font(p: &Painter, text: &str, font: &FontId, room: f32) -> Option { + let natural = p.layout_no_wrap(text.to_string(), font.clone(), Color32::PLACEHOLDER).size().x; + let room = room.max(1.0); + if natural.is_nan() || natural <= room { + return None; + } + // Glyph advances don't scale exactly linearly (rounding), so leave a little slack. + let size = (font.size * room / natural * 0.97).max(1.0); + Some(FontId::new(size, font.family.clone())) +} + pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { let w = style.width(); let (a, b) = (pts[0], pts[1]); diff --git a/crates/ui-egui/tests/shrink_to_fit.rs b/crates/ui-egui/tests/shrink_to_fit.rs new file mode 100644 index 000000000..a9428fa23 --- /dev/null +++ b/crates/ui-egui/tests/shrink_to_fit.rs @@ -0,0 +1,61 @@ +//! Shrink to fit (#375): too-wide content is drawn in a smaller font inside its cell. + +use egui::Shape; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + let shrink = json!({"align": {"shrink": true}}); + s.execute("cell.set", json!({"cell": "A1", "input": "Executive Producer"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1", "style": shrink})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "Control Producer"})).unwrap(); + s.execute("cell.set", json!({"cell": "A4", "input": "123456789012"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A4", "style": {"num_fmt": "#,##0", "align": {"shrink": true}}})).unwrap(); + s.execute("cell.set", json!({"cell": "A5", "input": "123456789012"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A5", "style": {"num_fmt": "#,##0"}})).unwrap(); + s.execute("selection.set", json!({"range": "H20"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +/// The painted text shape whose text is `text`: (font size, width, left x). +fn text_shape(h: &Harness, text: &str) -> Option<(f32, f32, f32)> { + h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == text => { + let size = t.galley.job.sections.first().map(|s| s.format.font_id.size)?; + Some((size, t.galley.size().x, t.pos.x)) + } + _ => None, + }) +} + +#[test] +fn shrink_to_fit_text_and_numbers_fit_their_cell() { + let h = harness(); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let a1 = geo.cell_rect(sheet, CellRef::parse("A1").unwrap()); + let (small, w, x) = text_shape(&h, "Executive Producer").expect("A1 is drawn"); + let (normal, cw, _) = text_shape(&h, "Control Producer").expect("A2 is drawn"); + assert!(small < normal, "A1 is drawn smaller ({small} vs {normal})"); + assert!(cw > a1.width(), "the control is wider than the column"); + assert!(x >= a1.left() && x + w <= a1.right(), "A1 fits inside column A"); + // A number shrinks instead of turning into ####; its unshrunk twin still shows ####. + let (num, nw, _) = text_shape(&h, "123,456,789,012").expect("A4 shows the number"); + assert!(num < normal && nw <= a1.width()); + assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.job.text.starts_with("##")))); +} From 806a39a5142904d7640b32c53b6ef333bd84c15a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 21:02:20 +0200 Subject: [PATCH 45/54] Windows: compile DirectX 12 shaders with FXC, never a stray dxcompiler.dll (#339, #354) wgpu's default DX12 compiler (Auto) loads the first dxcompiler.dll on the DLL search path. GridCraft ships none, so another program's copy is used; without a matching dxil.dll its 'DXIL signing library not found' warning fails wgpu's internal compute pipelines, the device is lost and the app exits before the first frame. Pin FXC (part of Windows) unless WGPU_DX12_COMPILER asks for another compiler. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++++ ROADMAP.md | 1 + apps/gridcraft/src/graphics.rs | 44 ++++++++++++++++++++++++++++++++-- docs/hardware-parity.md | 5 ++-- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 164c2701b..678495f1d 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,11 @@ the first renderer fails to start, it tries the other one once. The log file rec (lines starting with `graphics:`). `WGPU_BACKEND` still overrides it (and then nothing else is tried), e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`. +DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or +`dxil.dll` is needed, and a copy another program put on `PATH` can no longer stop the app at start +("Parent device is lost", #339). `$env:WGPU_DX12_COMPILER = "dxc"` (or `"auto"`) selects wgpu's DXC +handling again for troubleshooting. + ### macOS | Build | File | Notes | diff --git a/ROADMAP.md b/ROADMAP.md index a62418e19..958933481 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-11 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) | | 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | | 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | diff --git a/apps/gridcraft/src/graphics.rs b/apps/gridcraft/src/graphics.rs index 14344b4be..83478edde 100644 --- a/apps/gridcraft/src/graphics.rs +++ b/apps/gridcraft/src/graphics.rs @@ -3,11 +3,15 @@ //! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters //! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access //! violation happens in the driver, where no Rust error handling can catch it. +//! +//! DirectX 12 shaders are compiled with FXC (`d3dcompiler_47.dll`, part of Windows), never with +//! whichever `dxcompiler.dll` the DLL search path happens to find (#339, #354): see +//! [`dx12_compiler`]. use eframe::{ NativeOptions, egui_wgpu::WgpuSetup, - wgpu::{Backends, DeviceType}, + wgpu::{Backends, DeviceType, Dx12Compiler}, }; /// What the start-up probe learned about one DirectX 12 adapter. @@ -59,9 +63,24 @@ pub fn fallback_backends(backend_override: Option, first: Backends) -> (!rest.is_empty()).then_some(rest) } -/// Restricts the wgpu instance eframe creates to `backends`. +/// The DirectX 12 shader compiler: `env_choice` (normally `Dx12Compiler::from_env()`, i.e. +/// `WGPU_DX12_COMPILER`, for troubleshooting) or else FXC. +/// +/// wgpu's default (`Auto`) loads the first `dxcompiler.dll` on the DLL search path. GridCraft ships +/// none, so that is another program's copy (a Windows SDK, an Edge or WebView folder, anything on +/// `PATH`). Without a matching `dxil.dll` beside it, DXC prints "DXIL signing library not found", +/// wgpu treats that warning as a compile error, its internal compute pipelines fail, the device is +/// lost and the app quits before the first frame ("Parent device is lost"). FXC is part of Windows +/// and needs no extra DLL; egui's shaders don't need anything newer. +pub fn dx12_compiler(env_choice: Option) -> Dx12Compiler { + env_choice.unwrap_or(Dx12Compiler::Fxc) +} + +/// Restricts the wgpu instance eframe creates to `backends`, and pins its DirectX 12 shader +/// compiler ([`dx12_compiler`]). pub fn configure_backends(options: &mut NativeOptions, backends: Backends) { if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(Dx12Compiler::from_env()); // eframe's default includes Vulkan and GL. Creating the GL backend can crash inside AMD's // atio6axx.dll, and enumerating Vulkan adapters inside Intel's igvk64.dll (#120), before // adapter selection or any Rust error handling runs, so the window never appears. @@ -189,4 +208,25 @@ mod tests { // Both were already tried: nothing left to fall back to. assert_eq!(fallback_backends(None, Backends::DX12 | Backends::GL), None); } + + #[test] + fn dx12_compiles_shaders_with_fxc_unless_asked() { + // #339, #354: wgpu's `Auto` would load a stray dxcompiler.dll and lose the device. + assert!(matches!(dx12_compiler(None), Dx12Compiler::Fxc)); + assert!(matches!(dx12_compiler(Some(Dx12Compiler::Auto)), Dx12Compiler::Auto)); + assert!(matches!(dx12_compiler(Some(Dx12Compiler::default_dynamic_dxc())), Dx12Compiler::DynamicDxc { .. })); + // eframe's own default is `Auto`; the configured instance (first start and retry) uses FXC. + let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + if Dx12Compiler::from_env().is_none() { + assert!(matches!(eframe_default.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Auto)); + let mut options = NativeOptions::default(); + configure_backends(&mut options, Backends::DX12); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc)); + } + } } diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index f8ea38f8e..9d2c5e7f5 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Windows renderer fallback, #120) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Windows DirectX 12 shaders with FXC, #339) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 The hardware Excel uses, per platform, against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). @@ -8,7 +8,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Hardware feature | Excel | GridCraft (macOS · Windows · Linux · BSD · web) | Gap and estimate | |---|---|---|---| | Multi-core recalculation | Multi-threaded recalculation on all cores (Windows and Mac), configurable thread count | Single-threaded everywhere (no threading in `crates/calc`) | Parallel recalc of independent dependency chains on native builds; WASM stays single-threaded or uses workers later: 12–18 h | -| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | +| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | | Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h | | Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h | | Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h | @@ -20,5 +20,6 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) | | 2026-10-11 | trivial | Windows: OpenGL fallback when DirectX 12 has no GPU or fails to start; Vulkan never by default (#120) | | 2026-10-10 | major | Created: single-threaded calc, wgpu rendering, no pen pressure, single window | From 04fb60ea5d9d53e45e739cd816642e53dc24a80b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 15:04:09 -0400 Subject: [PATCH 46/54] Printing from the UI (Backstage, Ctrl+P, menu) goes through print_now, so the web build opens the browser's print dialog (review fix-up for #209) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 16 +++++++--------- crates/ui-egui/src/ribbon.rs | 4 ++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0213fc81c..7ff4ff75e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -332,6 +332,10 @@ impl SheetApp { /// Runs a command and shows its error in a message box (for menu/ribbon clicks). pub fn run_or_alert(&mut self, id: &str, params: Json) { + // Printing from the UI also hands the PDF to the host's print flow (web). + if id == "file.print" { + return self.print_now(params); + } if let Err(e) = self.run(id, params) { self.alert(e); } @@ -350,8 +354,8 @@ impl SheetApp { /// build) hands the bytes straight to the browser's print flow; errors show the usual /// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert` /// (which still produces the PDF — just without a UI to act on the result yet). - pub fn print_now(&mut self) { - match self.run("file.print", json!({})) { + 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 { @@ -362,13 +366,7 @@ impl SheetApp { print(&bytes); } } - Err(e) => { - if e.contains("is not available right now") { - self.toast = Some((e, now_ms())); - } else { - self.message = Some(("GridCraft".into(), clean_error(&e))); - } - } + Err(e) => self.alert(e), } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index fe32ee773..f91c8d391 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -106,7 +106,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } } if ui.button("Print…").clicked() { - app.print_now(); + app.print_now(json!({})); } }); // Centered title. @@ -1994,7 +1994,7 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { Key::N => Some(("file.new", json!({}))), Key::W => Some(("file.close", json!({}))), Key::P => { - app.print_now(); + app.print_now(json!({})); None } Key::K => { From 55d41eb13c0b748630c40aaf9d67dbe062bc3c6f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 15:09:43 -0400 Subject: [PATCH 47/54] Desktop theme watcher starts only in the desktop app, not in every SheetApp (tests, embedders) (review fix-up for #203) Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft/src/main.rs | 1 + crates/ui-egui/src/lib.rs | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index aa228267a..b7e15cf65 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -272,6 +272,7 @@ fn main() -> eframe::Result<()> { session.new_workbook(); } let mut app = SheetApp::new(session, services()); + app.watch_desktop_theme(); app.after_engine(); // Show warnings from files opened on the command line. let control_port = control_port; load_prefs(&mut app); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 24dbfeb72..144622b1e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -234,7 +234,7 @@ impl SheetApp { fonts_set: false, fonts_han: None, effective_dark: false, - desktop_theme: desktop_theme::DesktopTheme::start(), + desktop_theme: desktop_theme::DesktopTheme::default(), desktop_dark: None, name_box: None, keytips: keytips::KeyTips::default(), @@ -338,6 +338,12 @@ impl SheetApp { } /// Runs a command and shows its error in a message box (for menu/ribbon clicks). + /// Follow the desktop's light/dark choice while the app runs (Linux; the desktop app calls + /// this once — tests and embedders don't start the watcher thread). + pub fn watch_desktop_theme(&mut self) { + self.desktop_theme = desktop_theme::DesktopTheme::start(); + } + pub fn run_or_alert(&mut self, id: &str, params: Json) { // Printing from the UI also hands the PDF to the host's print flow (web). if id == "file.print" { From 04001fd94394d7f83aaf318d08cc77ad78b460fa Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 21:11:23 +0200 Subject: [PATCH 48/54] View: restore and save the scroll position (sheetView topLeftCell) (#378) The model read topLeftCell but the grid always opened at A1 and never wrote the current scroll back. Sheets now open scrolled to their saved first visible cell; saves record the current one (and the active cell) without dirtying the workbook. Frozen panes use the pane's topLeftCell. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/lib.rs | 29 ++++++++++++ crates/ui-egui/src/backstage.rs | 1 + crates/ui-egui/src/lib.rs | 46 +++++++++++++++++-- crates/ui-egui/tests/scroll_position.rs | 60 +++++++++++++++++++++++++ crates/xlsx/src/sheet_read.rs | 4 ++ crates/xlsx/src/sheet_write.rs | 6 ++- crates/xlsx/src/tests/roundtrip.rs | 25 +++++++++++ 8 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/scroll_position.rs diff --git a/ROADMAP.md b/ROADMAP.md index a62418e19..85fb5aec7 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-11 | Workbooks reopen at their saved scroll position (`topLeftCell`, also with frozen panes) and save the current one back (#378) | | 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | | 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c6f0fb99d..8dc28d15a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -139,6 +139,35 @@ impl DocState { pub fn sheet_index(&self) -> usize { self.wb.active_sheet } + /// Records the view state that is saved with the file (`sheetView topLeftCell`, the active + /// cell): the first visible cell of the scrolling area per sheet index in `tops`, and the + /// active sheet's active cell. View state never makes the workbook dirty. + pub fn store_view(&mut self, tops: &[(usize, CellRef)]) { + let active = self.selection.active; + let si = self.wb.active_sheet; + let changed = tops.iter().any(|(i, c)| self.wb.sheets.get(*i).is_some_and(|sh| sh.view_top_left != *c)) + || self.wb.sheets.get(si).is_some_and(|sh| sh.view_active != active); + if !changed { + return; + } + let dirty = self.is_dirty(); + let wb = Arc::make_mut(&mut self.wb); + for (i, c) in tops { + if let Some(sh) = wb.sheets.get_mut(*i) + && sh.view_top_left != *c + { + Arc::make_mut(sh).view_top_left = *c; + } + } + if let Some(sh) = wb.sheets.get_mut(si) + && sh.view_active != active + { + Arc::make_mut(sh).view_active = active; + } + if !dirty { + self.saved = self.wb.clone(); + } + } pub fn display_title(&self) -> String { self.path .as_deref() diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 32000f7d9..2c48b249e 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -724,6 +724,7 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool { } }; } + app.store_view_state(); match app.session.run("file.saveBytes", json!({"format": fmt})) { Ok(r) => { let bytes = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ea7404e77..826874538 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -247,6 +247,9 @@ impl SheetApp { /// Runs an engine command (or a UI command) and handles UI requests it makes. pub fn run(&mut self, id: &str, params: Json) -> Result { + if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { + self.store_view_state(); + } if id.starts_with("file.") || matches!( id, @@ -485,8 +488,9 @@ impl SheetApp { let path = if std::path::Path::new(&path).extension().is_none() { format!("{path}.xlsx") } else { path }; self.run_or_alert("file.save", json!({"path": path})); } - } else if let Some(dl) = &self.services.download - && let Ok(r) = self.session.run("file.saveBytes", json!({"format": "xlsx"})) + } else if self.services.download.is_some() + && let Ok(r) = self.run("file.saveBytes", json!({"format": "xlsx"})) + && let Some(dl) = &self.services.download && let Some(b) = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) { let name = r.get("name").and_then(Json::as_str).unwrap_or("Book.xlsx").to_string(); @@ -532,12 +536,45 @@ impl SheetApp { } pub fn view(&self) -> SheetView { - self.view_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_default() + self.view_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) } pub fn view_mut(&mut self) -> Option<&mut SheetView> { let k = self.view_key()?; - Some(self.views.entry(k).or_default()) + let saved = self.saved_view(); + Some(self.views.entry(k).or_insert(saved)) + } + + /// The view a sheet opens with: scrolled to the file's saved first visible cell (`topLeftCell`). + fn saved_view(&self) -> SheetView { + let Some(sh) = self.session.active().and_then(|d| d.wb.active()) else { return SheetView::default() }; + if sh.view != gridcraft_engine::model::SheetViewMode::Normal { + return SheetView::default(); + } + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + let top = sh.view_top_left; + let y = sh.row_top(top.row.max(fr)) - sh.row_top(fr); + let x = sh.col_left(top.col.max(fc)) - sh.col_left(fc); + SheetView { scroll: egui::vec2(x.max(0.0) as f32, y.max(0.0) as f32) } + } + + /// Hands the active document's scroll positions (first visible cell per sheet) and active + /// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only: + /// Page Layout view scrolls over pages, not cells. + pub fn store_view_state(&mut self) { + let Some(d) = self.session.active_mut() else { return }; + let mut tops = Vec::new(); + for (&(uid, si), v) in &self.views { + let Some(sh) = d.wb.sheets.get(si).filter(|sh| uid == d.uid && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { + continue; + }; + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + // A hair past the edge so a row that starts exactly at the scroll offset is the one found. + let row = sh.row_at(sh.row_top(fr) + v.scroll.y.max(0.0) as f64 + 0.01); + let col = sh.col_at(sh.col_left(fc) + v.scroll.x.max(0.0) as f64 + 0.01); + tops.push((si, CellRef::new(row, col))); + } + d.store_view(&tops); } /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. @@ -769,6 +806,7 @@ impl SheetApp { && self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))) { self.grid.last_autosave = now; + self.store_view_state(); let _ = self.session.run("file.save", json!({})); } // Window title. diff --git a/crates/ui-egui/tests/scroll_position.rs b/crates/ui-egui/tests/scroll_position.rs new file mode 100644 index 000000000..7945eb344 --- /dev/null +++ b/crates/ui-egui/tests/scroll_position.rs @@ -0,0 +1,60 @@ +//! The saved scroll position (`sheetView topLeftCell`) is restored on open and saved back (#378). + +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::Workbook; +use gridcraft_engine::{DocState, Session}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(freeze: Option<(u32, u32)>) -> Harness { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + for r in 0..60 { + sh.set_value(CellRef::new(r, 0), Value::text(format!("row {}", r + 1))); + } + sh.view_top_left = CellRef::parse("A31").unwrap(); + sh.view_active = CellRef::parse("A31").unwrap(); + sh.freeze = freeze; + let mut session = Session::new(); + session.add_document(DocState::new(wb, None, "Book1".into())); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +/// The first row painted at the top of the scrolling area. +fn first_scrolled_row(h: &Harness) -> u32 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let (fr, _) = sheet.freeze.unwrap_or((0, 0)); + let top = geo.cells.top() + sheet.row_top(fr) as f32 * geo.z; + (fr..200).find(|r| geo.y(sheet, *r) <= top + 0.5 && geo.y(sheet, *r + 1) > top + 0.5).unwrap() +} + +#[test] +fn saved_scroll_position_is_restored_and_saved_back() { + for freeze in [None, Some((1, 0))] { + let mut h = harness(freeze); + assert_eq!(first_scrolled_row(&h), 30, "row 31 is the first visible row ({freeze:?})"); + // Scroll to row 51 and save: the file gets the new position, the document stays clean. + { + let app = h.state_mut(); + let sh = app.session.active().unwrap().wb.active().unwrap().clone(); + let fr = freeze.map_or(0, |f| f.0); + app.view_mut().unwrap().scroll.y = (sh.row_top(50) - sh.row_top(fr)) as f32; + app.run("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + } + let d = h.state().session.active().unwrap(); + assert_eq!(d.wb.active().unwrap().view_top_left, CellRef::parse("A51").unwrap()); + assert!(!d.is_dirty(), "scrolling and saving view state does not dirty the workbook"); + } +} diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 07ee34008..8eac2859c 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -256,6 +256,10 @@ fn read_views(e: &El, sheet: &mut Sheet) { let rows = p.attr_f64("ySplit").unwrap_or(0.0).clamp(0.0, (MAX_ROWS - 1) as f64) as u32; if rows > 0 || cols > 0 { sheet.freeze = Some((rows, cols)); + // With frozen panes the scroll position is the scrolling pane's first cell. + if let Some(c) = p.attr("topLeftCell").and_then(CellRef::parse) { + sheet.view_top_left = c; + } } } active_pane = p.attr("activePane"); diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index fee804c7c..94f16635e 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -182,7 +182,11 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let _ = write!( s, " topLeftCell=\"{}\" activePane=\"{pane}\" state=\"frozen\"/>", - CellRef::new(rows.min(gridcraft_core::MAX_ROWS - 1), cols.min(gridcraft_core::MAX_COLS - 1)).a1() + CellRef::new( + rows.max(sheet.view_top_left.row).min(gridcraft_core::MAX_ROWS - 1), + cols.max(sheet.view_top_left.col).min(gridcraft_core::MAX_COLS - 1) + ) + .a1() ); let _ = write!(s, ""); } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 2385b8e32..a4f7b18c6 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -763,3 +763,28 @@ fn bad_comment_dates_are_left_out() { let c = &back.sheets[0].comments[&at("A1")]; assert!(c.threaded && c.dates.is_empty()); } + +/// #378: the saved scroll position survives a round trip, with and without frozen panes (where it +/// is the scrolling pane's `topLeftCell`). +#[test] +fn scroll_position_round_trips_with_and_without_frozen_panes() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().view_top_left = at("A31"); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.sheet(0).unwrap().view_top_left, at("A31")); + + let s = wb.sheet_mut(0).unwrap(); + s.freeze = Some((1, 0)); + s.view_top_left = at("C40"); + let bytes = write_xlsx(&wb).unwrap(); + let (xml, _) = sheet1_xml(&bytes); + assert!(xml.contains(r#" Date: Sun, 11 Oct 2026 21:12:24 +0200 Subject: [PATCH 49/54] Review: Protect Workbook dialog with an Excel-compatible password (#345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ribbon button toggled structure protection silently. It now opens a Protect Structure and Windows dialog with an optional password; when a password was set, unprotecting asks for it and really verifies it (SHA-512, spin count 100000, salted). The password hash is read from and written to XLSX workbookProtection (workbookAlgorithmName/HashValue/ SaltValue/SpinCount or the legacy workbookPassword); an unreadable one stays locked. File › Info uses the same flow. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/review.rs | 65 ++++++++++++++++++++++++++---- crates/model/src/workbook.rs | 4 ++ crates/ui-egui/src/backstage.rs | 16 +++++++- crates/ui-egui/src/dialogs.rs | 17 ++++++++ crates/ui-egui/src/ribbon.rs | 2 +- crates/xlsx/src/read.rs | 1 + crates/xlsx/src/sheet_read.rs | 44 +++++++++++++------- crates/xlsx/src/sheet_write.rs | 35 +++++++++------- crates/xlsx/src/tests/fixtures.rs | 5 ++- crates/xlsx/src/tests/roundtrip.rs | 3 ++ crates/xlsx/src/write.rs | 3 +- 12 files changed, 156 insertions(+), 40 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a62418e19..b927fc4fa 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-11 | Review › Protect Workbook opens a dialog with an optional password (Excel-compatible hash, kept in XLSX `workbookProtection`); unprotecting verifies it (#345) | | 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | | 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 70823cdeb..8931bf547 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -19,7 +19,15 @@ pub fn specs() -> Vec { protect_sheet ), cmd!("review.unprotectSheet", "Unprotect Sheet", ["Review", "Protect"], None, "{password?}", has_doc, unprotect_sheet), - cmd!("review.protectWorkbook", "Protect Workbook", ["Review", "Protect"], None, "{on?: bool}", has_doc, protect_workbook), + cmd!( + "review.protectWorkbook", + "Protect Workbook", + ["Review", "Protect"], + None, + "{on?: bool (default: toggle), password?} (structure protection; unprotecting needs the password when one was set)", + has_doc, + protect_workbook + ), cmd!(query "review.workbookStatistics", "Workbook Statistics", ["Review", "Proofing"], None, "{}", has_doc, stats), // Not journaled: the params can hold a password. CommandSpec { @@ -159,6 +167,15 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result { }) } +/// A new Excel-compatible (SHA-512, salted) hash of `pw`. The salt mixes `what` is protected +/// with the document, a counter and the clock, so equal passwords get different hashes. +fn new_password_hash(d: &DocState, what: &str, pw: &str) -> PasswordHash { + static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let seed = format!("{what}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits()); + PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes())) +} + fn range_text(r: &EditRange) -> String { r.ranges.iter().map(|x| x.a1()).collect::>().join(",") } @@ -166,7 +183,6 @@ fn range_text(r: &EditRange) -> String { /// Review › Allow Edit Ranges: list, add, modify or delete the ranges that stay editable on a /// protected sheet, or unlock a password range for this session. fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result { - static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let sheet = target_sheet(s, p)?; let action = str_param(p, "action").unwrap_or("list"); let title = str_param(p, "title").map(str::trim).filter(|t| !t.is_empty()); @@ -180,11 +196,7 @@ fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result { .position(|r| r.title.eq_ignore_ascii_case(t)) .ok_or_else(|| bad("review.allowEditRanges", format!("no range titled `{t}`"))) }; - let hash = |pw: &str, title: &str| { - let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let seed = format!("{sheet_name}\u{0}{title}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits()); - PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes())) - }; + let hash = |pw: &str, title: &str| new_password_hash(d, &format!("{sheet_name}\u{0}{title}"), pw); if matches!(action, "add" | "modify" | "delete") && sh.is_protected() { return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into())); } @@ -270,11 +282,28 @@ fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result { )) } +/// Review › Protect Workbook: lock the workbook structure (sheets can't be added, moved, +/// renamed, hidden or deleted), optionally with a password that unprotecting must match. fn protect_workbook(s: &mut Session, p: &Json) -> Result { - let cur = s.doc()?.wb.protected_structure; + let d = s.doc()?; + let cur = d.wb.protected_structure; let on = bool_param(p, "on").unwrap_or(!cur); + if on == cur { + return Ok(json!({"on": on})); + } + let password = if on { + str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| new_password_hash(d, "workbook", pw)) + } else { + if let Some(h) = &d.wb.structure_password + && !h.verify(str_param(p, "password").unwrap_or("")).map_err(EngineError::Other)? + { + return Err(EngineError::Other("The password you supplied is not correct.".into())); + } + None + }; edit(s, |cx| { cx.wb.protected_structure = on; + cx.wb.structure_password = password.clone(); Ok(json!({"on": on})) }) } @@ -355,6 +384,26 @@ mod tests { assert_eq!(at(&s), (0, "A1".to_string())); } + #[test] + fn protect_workbook_with_a_password_really_verifies() { + let mut s = book(); + s.execute("review.protectWorkbook", json!({"password": "pa55"})).unwrap(); + assert!(s.doc().unwrap().wb.protected_structure); + assert!(s.execute("home.insertSheet", json!({"name": "Three"})).is_err(), "the structure is locked"); + // A second Protect keeps it protected; unprotecting needs the right password. + assert_eq!(s.execute("review.protectWorkbook", json!({"on": true})).unwrap()["on"], true); + assert!(s.execute("review.protectWorkbook", json!({})).is_err()); + assert!(s.execute("review.protectWorkbook", json!({"on": false, "password": "pass"})).is_err()); + assert!(s.doc().unwrap().wb.protected_structure); + s.execute("review.protectWorkbook", json!({"on": false, "password": "pa55"})).unwrap(); + let wb = &s.doc().unwrap().wb; + assert!(!wb.protected_structure && wb.structure_password.is_none()); + // Without a password it toggles freely. + s.execute("review.protectWorkbook", json!({})).unwrap(); + s.execute("review.protectWorkbook", json!({})).unwrap(); + assert!(!s.doc().unwrap().wb.protected_structure); + } + #[test] fn convert_to_comments_turns_every_note_into_a_comment_and_undoes() { let mut s = book(); diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 7e03cb31b..6ea5f9951 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -67,6 +67,9 @@ pub struct Workbook { pub props: DocProps, /// Workbook structure protection. pub protected_structure: bool, + /// Password of the structure protection (XLSX `workbookProtection`), never the password. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub structure_password: Option, /// Custom fill lists (Options › Custom Lists) beyond the built-in ones. pub custom_lists: Vec>, /// Named cell styles (Cell Styles gallery): name → style. @@ -95,6 +98,7 @@ impl Workbook { active_sheet: 0, props: DocProps::default(), protected_structure: false, + structure_password: None, custom_lists: vec![], cell_styles: vec![], preserved: None, diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 32000f7d9..07e570598 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -422,6 +422,20 @@ fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { recent_list(app, ui, out, usize::MAX); } +/// Review › Protect Workbook and File › Info › Protect Workbook: the protect dialog (with an +/// optional password) when the structure is open; when it's locked, the password prompt if one +/// was set, else unprotect right away. +pub fn protect_workbook(app: &mut SheetApp) { + let (on, password) = app.session.active().map(|d| (d.wb.protected_structure, d.wb.structure_password.is_some())).unwrap_or((false, false)); + if !on { + app.open_dialog("protectWorkbook", json!({})); + } else if password { + app.open_dialog("unprotectWorkbook", json!({})); + } else { + app.run_or_alert("review.protectWorkbook", json!({"on": false})); + } +} + fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) { let t = Tokens::get(ui.ctx()); let lang = app.ui.language; @@ -451,7 +465,7 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) { ui.add_space(18.0); ui.horizontal(|ui| { if square_button(ui, Icon::Lock, lang.tr("Protect Workbook")).clicked() { - app.run_or_alert("review.protectWorkbook", json!({"on": !protected})); + protect_workbook(app); } ui.add_space(12.0); ui.vertical(|ui| { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 196d47aea..0291ebbdb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -219,6 +219,23 @@ impl Dialog { json!({"action": "add", "range": sel}), ) } + "protectWorkbook" => Dialog::form( + "protectWorkbook", + "Protect Structure and Windows", + "review.protectWorkbook", + vec![ + Password { key: "password", label: "Password (optional):" }, + Note("Protect workbook for: Structure (sheets can't be added, moved, renamed, hidden or deleted).".into()), + ], + json!({"on": true}), + ), + "unprotectWorkbook" => Dialog::form( + "unprotectWorkbook", + "Unprotect Workbook", + "review.protectWorkbook", + vec![Password { key: "password", label: "Password:" }], + json!({"on": false}), + ), "unprotectSheet" => Dialog::form( "unprotectSheet", "Unprotect Sheet", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ccc51eb70..af602b2fd 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1714,7 +1714,7 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { } } if big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false).clicked() { - act(app, "review.protectWorkbook", json!({})); + crate::backstage::protect_workbook(app); } if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() { app.open_dialog("allowEditRanges", json!({})); diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 7c00d012f..d1da00a55 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -185,6 +185,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } if let Some(p) = wb_xml.child("workbookProtection") { wb.protected_structure = p.flag("lockStructure", false); + wb.structure_password = if wb.protected_structure { crate::sheet_read::read_password(p, "workbook") } else { None }; } let active_tab = wb_xml.path(&["bookViews", "workbookView"]).and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize; diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 07ee34008..2c33d1fe0 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -208,6 +208,35 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result Option { + let attr = |base: &str| e.attr(&password_attr(prefix, base)); + if attr("algorithmName").is_some() || attr("hashValue").is_some() { + return Some(PasswordHash::Iterated { + algorithm: attr("algorithmName").unwrap_or("").to_string(), + hash: attr("hashValue").unwrap_or("").to_string(), + salt: attr("saltValue").unwrap_or("").to_string(), + spin_count: e.attr_u32(&password_attr(prefix, "spinCount")).unwrap_or(0), + }); + } + attr("password").map(|h| match u16::from_str_radix(h.trim(), 16) { + Ok(h) => PasswordHash::Legacy(h), + Err(_) => PasswordHash::Iterated { algorithm: String::new(), hash: String::new(), salt: String::new(), spin_count: 0 }, + }) +} + +/// `base` preceded by `prefix` in camel case: ("workbook", "hashValue") → `workbookHashValue`. +pub(crate) fn password_attr(prefix: &str, base: &str) -> String { + let mut chars = base.chars(); + match chars.next() { + Some(c) if !prefix.is_empty() => format!("{prefix}{}{}", c.to_ascii_uppercase(), chars.as_str()), + _ => base.to_string(), + } +} + /// Allow Edit Ranges. A range whose password can't be read keeps a hash that never matches /// (or an unsupported algorithm), so it stays locked rather than open. fn read_protected_ranges(e: &El, sheet: &mut Sheet) { @@ -216,20 +245,7 @@ fn read_protected_ranges(e: &El, sheet: &mut Sheet) { if ranges.is_empty() { continue; } - let password = if p.attr("algorithmName").is_some() || p.attr("hashValue").is_some() { - Some(PasswordHash::Iterated { - algorithm: p.attr("algorithmName").unwrap_or("").to_string(), - hash: p.attr("hashValue").unwrap_or("").to_string(), - salt: p.attr("saltValue").unwrap_or("").to_string(), - spin_count: p.attr_u32("spinCount").unwrap_or(0), - }) - } else { - p.attr("password").map(|h| match u16::from_str_radix(h.trim(), 16) { - Ok(h) => PasswordHash::Legacy(h), - // Unreadable: keep it locked. - Err(_) => PasswordHash::Iterated { algorithm: String::new(), hash: String::new(), salt: String::new(), spin_count: 0 }, - }) - }; + let password = read_password(p, ""); let title = p.attr("name").unwrap_or("").to_string(); sheet.edit_ranges.push(EditRange { title, ranges, password, security_descriptor: p.attr("securityDescriptor").map(str::to_string) }); } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index fee804c7c..acc98f686 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -1118,26 +1118,33 @@ fn threaded_xml(sheet: &Sheet, ids: &BTreeMap>, persons: &m s } +/// A protection password hash as attributes, each name preceded by `prefix` (see +/// [`crate::sheet_read::read_password`]). +pub(crate) fn password_attrs(h: &gridcraft_model::PasswordHash, prefix: &str) -> String { + let name = |base: &str| crate::sheet_read::password_attr(prefix, base); + match h { + gridcraft_model::PasswordHash::Legacy(h) => format!(" {}=\"{h:04X}\"", name("password")), + gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count } => format!( + " {}=\"{}\" {}=\"{}\" {}=\"{}\" {}=\"{spin_count}\"", + name("algorithmName"), + esc_attr(algorithm), + name("hashValue"), + esc_attr(hash), + name("saltValue"), + esc_attr(salt), + name("spinCount") + ), + } +} + /// Allow Edit Ranges: `` (after `sheetProtection`). fn protected_ranges_xml(sheet: &Sheet) -> String { let mut s = String::new(); for r in sheet.edit_ranges.iter().filter(|r| !r.ranges.is_empty()) { let sqref: Vec = r.ranges.iter().map(|x| x.a1()).collect(); let _ = write!(s, " { - let _ = write!(s, " password=\"{h:04X}\""); - } - Some(gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count }) => { - let _ = write!( - s, - " algorithmName=\"{}\" hashValue=\"{}\" saltValue=\"{}\" spinCount=\"{spin_count}\"", - esc_attr(algorithm), - esc_attr(hash), - esc_attr(salt) - ); - } - None => {} + if let Some(h) = &r.password { + s.push_str(&password_attrs(h, "")); } if let Some(sd) = &r.security_descriptor { let _ = write!(s, " securityDescriptor=\"{}\"", esc_attr(sd)); diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 5533e0105..42adcb52f 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -48,7 +48,7 @@ fn shared_formulas_and_value_types() { #[test] fn workbook_settings_names_and_states() { - let wb_extra = r#""#; + let wb_extra = r#""#; // Defined names must come after ; build the workbook by hand. let wbx = format!( r#"{wb_extra} @@ -69,6 +69,9 @@ fn workbook_settings_names_and_states() { assert_eq!(wb.sheets.len(), 3); assert_eq!(wb.date_system, DateSystem::D1904); assert!(wb.protected_structure); + // The legacy structure password ("password" hashes to 83AF) is kept and really verifies. + let pw = wb.structure_password.as_ref().unwrap(); + assert!(pw.verify("password").unwrap() && !pw.verify("Password").unwrap()); assert_eq!(wb.calc.mode, CalcMode::Manual); assert!(wb.calc.iterative); assert_eq!(wb.calc.max_iterations, 50); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 2385b8e32..403890872 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -22,6 +22,7 @@ fn sample() -> Workbook { wb.date_system = DateSystem::D1904; wb.calc = CalcSettings { mode: CalcMode::Manual, iterative: true, max_iterations: 42, max_change: 0.5, precision_as_displayed: true }; wb.protected_structure = true; + wb.structure_password = Some(gridcraft_model::PasswordHash::new_sha512("s3cret", b"0123456789abcdef")); wb.props = DocProps { title: "T & ".into(), subject: "S".into(), @@ -412,6 +413,8 @@ fn full_roundtrip() { assert_eq!(back.date_system, wb.date_system); assert_eq!(back.calc, wb.calc); assert!(back.protected_structure); + assert_eq!(back.structure_password, wb.structure_password); + assert!(back.structure_password.as_ref().unwrap().verify("s3cret").unwrap()); assert_eq!(back.props, wb.props); assert_eq!(back.theme, wb.theme); assert_eq!(back.names, wb.names); diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index dc2ddc484..6aae60edb 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -268,7 +268,8 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { let mut w = format!("{XML_DECL}"); w.push_str(if wb.date_system == gridcraft_core::DateSystem::D1904 { "" } else { "" }); if wb.protected_structure { - w.push_str(""); + let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default(); + let _ = write!(w, ""); } let _ = write!(w, "{sheets_xml}"); w.push_str(&crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out)); From 22978a7e62f64d5a8d5a9cb1d71dc3d28f84928b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 21:29:10 +0200 Subject: [PATCH 50/54] Review: Protect Sheet passwords use Excel-compatible hashes, kept in XLSX Sheet protection stored an internal FNV hash that was never written to XLSX, and an Excel sheet password was dropped on import, so imported protected sheets could be unprotected without it. Protect Sheet now stores a salted SHA-512 hash (spin count 100000) via PasswordHash; the XLSX sheetProtection password (iterated or legacy) is read and written, and Unprotect Sheet really verifies it. Hashes from older GridCraft JSON workbooks are still read and verified. Refs #346 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/review.rs | 45 ++++++++++++++++++++---- crates/engine/src/tests.rs | 2 +- crates/model/src/features.rs | 14 +++++--- crates/ui-egui/tests/protect_password.rs | 2 +- crates/xlsx/src/sheet_read.rs | 6 ++-- crates/xlsx/src/sheet_write.rs | 3 ++ crates/xlsx/src/tests/fixtures.rs | 7 ++-- crates/xlsx/src/tests/roundtrip.rs | 9 ++++- 9 files changed, 70 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b927fc4fa..1d31b92d5 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-11 | Protect Sheet passwords use Excel-compatible hashes and round-trip in XLSX `sheetProtection` (imported passwords verify instead of being dropped) | | 2026-10-11 | Review › Protect Workbook opens a dialog with an optional password (Excel-compatible hash, kept in XLSX `workbookProtection`); unprotecting verifies it (#345) | | 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 8931bf547..0dc5962b6 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -134,7 +134,8 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let b = |k: &str| bool_param(p, k).unwrap_or(false); let prot = SheetProtection { - password_hash: str_param(p, "password").filter(|x| !x.is_empty()).map(password_hash), + password: None, + legacy_password_hash: None, select_locked: true, select_unlocked: true, format_cells: b("formatCells"), @@ -147,6 +148,9 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result { sort: b("sort"), autofilter: b("autofilter"), }; + let d = s.doc()?; + let what = d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default(); + let prot = SheetProtection { password: str_param(p, "password").filter(|x| !x.is_empty()).map(|pw| new_password_hash(d, &what, pw)), ..prot }; edit(s, |cx| { cx.sheet_mut(sheet)?.protection = Some(prot.clone()); Ok(Json::Null) @@ -155,11 +159,16 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result { fn unprotect_sheet(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; - let ph = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).and_then(|pr| pr.password_hash); - if let Some(h) = ph - && str_param(p, "password").map(password_hash) != Some(h) - { - return Err(EngineError::Other("The password you supplied is not correct.".into())); + if let Some(pr) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()) { + let pw = str_param(p, "password").unwrap_or(""); + let ok = match (&pr.password, pr.legacy_password_hash) { + (Some(h), _) => h.verify(pw).map_err(EngineError::Other)?, + (None, Some(h)) => password_hash(pw) == h, + (None, None) => true, + }; + if !ok { + return Err(EngineError::Other("The password you supplied is not correct.".into())); + } } edit(s, |cx| { cx.sheet_mut(sheet)?.protection = None; @@ -384,6 +393,30 @@ mod tests { assert_eq!(at(&s), (0, "A1".to_string())); } + #[test] + fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() { + let mut s = book(); + s.execute("review.protectSheet", json!({"password": "pa55"})).unwrap(); + let prot = s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap(); + assert!(matches!(&prot.password, Some(PasswordHash::Iterated { algorithm, .. }) if algorithm == "SHA-512")); + assert!(s.execute("review.unprotectSheet", json!({})).is_err()); + assert!(s.execute("review.unprotectSheet", json!({"password": "pass"})).is_err()); + s.execute("review.unprotectSheet", json!({"password": "pa55"})).unwrap(); + assert!(!s.doc().unwrap().wb.active().unwrap().is_protected()); + // A GridCraft JSON workbook saved before carried the internal hash under `password_hash`. + let old = format!( + r#"{{"password_hash": {}, "select_locked": true, "select_unlocked": true, "format_cells": false, "format_columns": false, "format_rows": false, "insert_columns": false, "insert_rows": false, "delete_columns": false, "delete_rows": false, "sort": false, "autofilter": false}}"#, + password_hash("old") + ); + let prot: SheetProtection = serde_json::from_str(&old).unwrap(); + assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true)); + let i = s.doc().unwrap().wb.active_sheet; + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot); + assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err()); + s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap(); + } + #[test] fn protect_workbook_with_a_password_really_verifies() { let mut s = book(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dfe82543a..9be4bb963 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -816,7 +816,7 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() { r.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}: {e}")); let prot = r.doc().unwrap().wb.active().unwrap().protection.clone(); if id == "review.protectSheet" { - assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password_hash.is_none()), "{prot:?}"); + assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password.is_none()), "{prot:?}"); } } assert!(r.doc().unwrap().wb.active().unwrap().protection.is_none()); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 1864d6d45..c8dc28b0e 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -554,8 +554,14 @@ impl Default for PrintSettings { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct SheetProtection { - /// Hash of the password (our own salted FNV; never the password itself). - pub password_hash: Option, + /// Hash of the password (Excel-compatible, written to XLSX `sheetProtection`; never the + /// password itself). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + /// The internal hash older GridCraft JSON workbooks stored ([`password_hash`]); still + /// verified when unprotecting, never written for new passwords. + #[serde(rename = "password_hash", default, skip_serializing_if = "Option::is_none")] + pub legacy_password_hash: Option, pub select_locked: bool, pub select_unlocked: bool, pub format_cells: bool, @@ -587,8 +593,8 @@ impl EditRange { } } -/// Simple non-cryptographic password hash for sheet protection (like Excel's, protection is a -/// convenience, not security). +/// The non-cryptographic password hash older GridCraft versions stored for sheet protection +/// (only verified now, see [`SheetProtection::legacy_password_hash`]). pub fn password_hash(pw: &str) -> u64 { let mut h: u64 = 0xcbf29ce484222325 ^ 0x5348_4545_5443; for b in pw.bytes() { diff --git a/crates/ui-egui/tests/protect_password.rs b/crates/ui-egui/tests/protect_password.rs index dc2b1c59a..ee68cf2b6 100644 --- a/crates/ui-egui/tests/protect_password.rs +++ b/crates/ui-egui/tests/protect_password.rs @@ -57,7 +57,7 @@ fn protect_and_unprotect_sheet_mask_the_password() { h.get_by_label(" OK ").click(); h.run_steps(3); assert!(h.state().dialog.is_none()); - assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password_hash.is_some())); + assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password.is_some())); // Unprotect asks for the password with a masked field too, and it starts hidden again. h.state_mut().open_dialog("unprotectSheet", json!({})); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 2c33d1fe0..3e9e3741a 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -143,11 +143,9 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result sheet.print.row_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&r| r > 0 && r < MAX_ROWS).collect(), "colBreaks" => sheet.print.col_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&c| c > 0 && c < MAX_COLS).collect(), "sheetProtection" if e.flag("sheet", false) => { - if e.attr("password").is_some() || e.attr("hashValue").is_some() { - cx.warn(format!("sheet \"{name}\": the protection password was not imported")); - } sheet.protection = Some(SheetProtection { - password_hash: None, + password: read_password(e, ""), + legacy_password_hash: None, select_locked: !e.flag("selectLockedCells", false), select_unlocked: !e.flag("selectUnlockedCells", false), format_cells: !e.flag("formatCells", true), diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index acc98f686..a56cf5e40 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -332,6 +332,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if !p.select_unlocked { s.push_str(" selectUnlockedCells=\"1\""); } + if let Some(h) = &p.password { + s.push_str(&password_attrs(h, "")); + } s.push_str("/>"); } s.push_str(&protected_ranges_xml(sheet)); diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 42adcb52f..9cd09a33c 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -223,7 +223,7 @@ fn hyperlinks_comments_tables_validation_filters() { &CTitlePage &P - + Other!$A$1:$A$3G1:G4 Data!A1:E1F1"#; @@ -270,7 +270,10 @@ Check this"#; assert_eq!(s.print.row_breaks, vec![10]); let p = s.protection.as_ref().unwrap(); assert!(p.format_cells && p.sort && !p.insert_rows && p.select_locked); - assert!(rep.warnings.iter().any(|w| w.contains("password"))); + // The legacy sheet password is imported (CBEB is the hash of "test") and verifies. + let pw = p.password.as_ref().unwrap(); + assert!(pw.verify("test").unwrap() && !pw.verify("Test").unwrap()); + assert!(!rep.warnings.iter().any(|w| w.contains("password")), "{:?}", rep.warnings); assert_eq!(s.sparklines.len(), 1); assert_eq!(s.sparklines[0].kind, gridcraft_model::SparklineKind::Column); } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 403890872..be59af46c 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -248,7 +248,14 @@ fn sample() -> Workbook { (3, FilterCriterion::FillColor(Color::Rgb(0xFF0000))), ], }); - s.protection = Some(SheetProtection { format_cells: true, sort: true, select_locked: true, select_unlocked: true, ..Default::default() }); + s.protection = Some(SheetProtection { + format_cells: true, + sort: true, + select_locked: true, + select_unlocked: true, + password: Some(gridcraft_model::PasswordHash::new_sha512("sheet pw", b"fedcba9876543210")), + ..Default::default() + }); s.print = PrintSettings { orientation: Orientation::Landscape, paper: "A4".into(), From 6290bf123a26dac914d9e33a0c79819b76f06122 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 21:32:57 +0200 Subject: [PATCH 51/54] Keyboard: Shift+Space and Ctrl+Space select without starting an edit (#338) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/grid.rs | 8 +++-- crates/ui-egui/src/keymap.rs | 5 +++ crates/ui-egui/tests/keyboard_shortcuts.rs | 36 ++++++++++++++++++++++ docs/ui-parity.md | 1 + 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 03830fb11..4e55ad386 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-11 | Shift+Space and Ctrl+Space select the row / column without starting an edit; Control+Space on Mac (#338) | | 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | | 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 820acf784..9965a5c6f 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2192,6 +2192,11 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) }); let events = ctx.input(|i| i.events.clone()); + // A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit. + let shortcut_pressed = events.iter().any(|e| { + matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. } + if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space))) + }); let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64; for ev in events { match ev { @@ -2202,6 +2207,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: app.copy_to_clipboard(ctx, "edit.cut"); } egui::Event::Paste(text) => app.paste_external(text), + egui::Event::Text(_) if shortcut_pressed => {} egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => { app.begin_edit(Some(text), false); if let Some(ed) = app.editor.as_mut() { @@ -2270,8 +2276,6 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: app.session.format_painter = None; } Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})), - Key::Space if cmd && !shift => app.run_or_alert("selection.column", json!({})), - Key::Space if shift && !cmd => app.run_or_alert("selection.row", json!({})), Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), Key::F4 if !cmd => {} _ if cmd => crate::ribbon::shortcut(app, key, m), diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 54ab3193d..65dbcca85 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -108,6 +108,11 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio Period if cmd && s => run("home.increaseFontSize"), Comma if cmd && s => run("home.decreaseFontSize"), Space if cmd && s => run("edit.selectAll"), + // Shift+Space selects the row; Ctrl+Space (Windows) / Control+Space (Excel for Mac) the + // column. Cmd+Space stays free: it is the macOS Spotlight shortcut. + Space if plain && s => run("selection.row"), + Space if !mac && cmd && !s => run("selection.column"), + Space if mac && ctrl && !m.command && !s => run("selection.column"), Home if cmd && s => Shortcut::ExtendToStart, End if cmd && s => Shortcut::ExtendToEnd, Home if plain && s => Shortcut::ExtendToRowStart, diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index a06f918a3..8a2b1957a 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -106,3 +106,39 @@ fn windows_and_mac_variants() { assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None); assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None); } + +#[test] +fn space_selection_shortcuts_do_not_start_an_edit() { + let mut h = harness(book("B2")); + // Shift+Space: the key press, then the space the OS types with it. + let shift = Modifiers::SHIFT; + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: Some(Key::Space), pressed: true, repeat: false, modifiers: shift }); + h.input_mut().events.push(Event::Text(" ".into())); + h.run_steps(2); + assert!(h.state().editor.is_none(), "Shift+Space started an edit"); + assert_eq!(h.state().session.active().unwrap().selection.ranges[0].a1(), "2:2"); + h.state_mut().session.run("selection.set", json!({"range": "B2"})).unwrap(); + // Ctrl+Space selects the column, also without starting an edit. + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: Some(Key::Space), pressed: true, repeat: false, modifiers: CTRL }); + h.input_mut().events.push(Event::Text(" ".into())); + h.run_steps(2); + assert!(h.state().editor.is_none()); + assert_eq!(h.state().session.active().unwrap().selection.ranges[0].a1(), "B:B"); + // Plain typing still edits. + h.input_mut().events.push(Event::Text("x".into())); + h.run_steps(2); + assert!(h.state().editor.is_some()); +} + +#[test] +fn space_shortcuts_per_platform() { + let run = |id: &'static str| Some(Shortcut::Run(id, json!({}))); + let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + let space = |m, mac| lookup(Key::Space, Some(Key::Space), m, mac); + assert_eq!(space(Modifiers::SHIFT, false), run("selection.row")); + assert_eq!(space(Modifiers::SHIFT, true), run("selection.row")); + assert_eq!(space(CTRL, false), run("selection.column")); + assert_eq!(space(Modifiers::CTRL, true), run("selection.column")); + // Cmd+Space is Spotlight on macOS. + assert_eq!(space(mac_cmd, true), None); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fcea332f5..b6e3b91be 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -150,6 +150,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | | 2026-10-11 | minor | Selected whole columns can be reordered by dragging their headers to an insertion point | | 2026-10-11 | minor | Page Layout view with rulers and in-place headers and footers (#329) | | 2026-10-11 | minor | Forecast Sheet dialog (#322) | From 8587c2ba14433b10d3fbd6b9dc30b4c5c26a98dc Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 21:36:05 +0200 Subject: [PATCH 52/54] Review: Protect Sheet can't replace a sheet's password without unprotecting it Re-protecting a password-protected sheet would drop its hash and let a plain Unprotect succeed; it is now refused. The sheet tab menu offers Unprotect Sheet on a protected sheet, like the ribbon. Refs #346 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/review.rs | 9 +++++++++ crates/ui-egui/src/tabs.rs | 5 +++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 0dc5962b6..c55aac8e5 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -132,6 +132,12 @@ fn convert_notes(s: &mut Session, _: &Json) -> Result { fn protect_sheet(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; + // Re-protecting a password-protected sheet would replace the password without knowing it: + // unprotect it first, as in Excel. One without a password can take new options directly. + let has_password = |pr: &SheetProtection| pr.password.is_some() || pr.legacy_password_hash.is_some(); + if s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).is_some_and(has_password) { + return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into())); + } let b = |k: &str| bool_param(p, k).unwrap_or(false); let prot = SheetProtection { password: None, @@ -401,6 +407,9 @@ mod tests { assert!(matches!(&prot.password, Some(PasswordHash::Iterated { algorithm, .. }) if algorithm == "SHA-512")); assert!(s.execute("review.unprotectSheet", json!({})).is_err()); assert!(s.execute("review.unprotectSheet", json!({"password": "pass"})).is_err()); + // Protecting again can't swap the password out from under it. + assert!(s.execute("review.protectSheet", json!({})).is_err()); + assert_eq!(s.doc().unwrap().wb.active().unwrap().protection.as_ref(), Some(&prot)); s.execute("review.unprotectSheet", json!({"password": "pa55"})).unwrap(); assert!(!s.doc().unwrap().wb.active().unwrap().is_protected()); // A GridCraft JSON workbook saved before carried the internal hash under `password_hash`. diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 6e68ab5fa..62a9940e3 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -164,8 +164,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { app.run_or_alert("sheet.unhide", json!({})); } ui.separator(); - if ui.button("Protect Sheet…").clicked() { - app.open_dialog("protectSheet", json!({})); + let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); + if ui.button(if protected { "Unprotect Sheet…" } else { "Protect Sheet…" }).clicked() { + app.open_dialog(if protected { "unprotectSheet" } else { "protectSheet" }, json!({})); } if ui.button("Select All Sheets").clicked() { ui.close(); From fca801c17e0ec88379839c99dafed25e5c38b603 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 15:43:11 -0400 Subject: [PATCH 53/54] INDEX: compare the result size with saturating u64 math, so it can't overflow on 32-bit targets (review fix-up for #400) Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/eval.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index adaeeba3b..a7c677dc2 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1112,7 +1112,7 @@ impl<'h> Evaluator<'h> { }; let (ra, ca) = (as_array(&rows), as_array(&cols)); let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols)); - if (h * w) as u64 > MAX_CELLS { + if (h as u64).saturating_mul(w as u64) > MAX_CELLS { return err(CellError::Num); } let mut out = Vec::with_capacity(h * w); From 6d59233e62d18123fb85a7b21c1ea49e317d8cc6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 15:49:32 -0400 Subject: [PATCH 54/54] XLOOKUP reference path: an empty match_mode keeps the search_mode after it (review fix-up for #396) Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/eval.rs | 13 +++++++++++-- crates/calc/src/tests.rs | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 8216f5df5..d31a99722 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1200,9 +1200,18 @@ impl<'h> Evaluator<'h> { let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() }; if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len { let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }]; - for i in [4, 5] { + // An empty match_mode before a given search_mode is the default (0), not the end. + let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing)); + let last = if given(5) { + 5 + } else if given(4) { + 4 + } else { + 3 + }; + for i in 4..=last { match args.get(i) { - Some(Expr::Missing) | None => break, + Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }), Some(e) => { let value = self.value(e); xargs.push(Arg { value, from_ref: false }); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 8b33f93ba..791da3557 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -118,6 +118,13 @@ fn xlookup_returns_a_reference() { assert_eq!(t.get("D6"), Value::Bool(true)); t.set("D7", "=\"x\"&XLOOKUP(\"e\",A1:A5,B1:B5)&\"y\""); assert_eq!(t.get("D7"), Value::from("xy")); + // An empty match_mode keeps the search_mode after it: search last to first. + t.set("A6", "b"); + t.set("B6", "20"); + t.set("D8", "=XLOOKUP(\"b\",A1:A6,B1:B6,,,-1)"); + assert_eq!(t.num("D8"), 20.0); + t.set("D9", "=XLOOKUP(\"b\",A1:A6,B1:B6)"); + assert_eq!(t.num("D9"), 2.0); } #[test]