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/56] 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 | 26 +- 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, 580 insertions(+), 33 deletions(-) create mode 100644 crates/ui-egui/src/fonts.rs diff --git a/Cargo.lock b/Cargo.lock index f3b1e64..0855bf0 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 a7f99e0..e87627d 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 7fba23f..d27ee15 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 37a58dd..34b957c 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 10892bc..e149120 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(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()); - } - break; - } + #[cfg(target_arch = "wasm32")] + { + HashSet::new() + } + #[cfg(not(target_arch = "wasm32"))] + { + 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; + } + } + set } - set }) } diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 3a0bab3..654395f 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 abdb4e7..51a5f32 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 fca5dc4..fbd6649 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 363dec1..523b77d 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 01d7c00..283d53f 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 b6c186d..e921cdc 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 0000000..940bca9 --- /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 753585f..48141c3 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 4b326cc..4faf43a 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 8eb1eca..fd08ba3 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 47c5432..6abe3f3 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 2dbd8df..1b51559 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/56] 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 | 26 ++++++++++++++++---------- 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, 25 insertions(+), 24 deletions(-) diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 7fba23f..167522b 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 37a58dd..34b957c 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 10892bc..e149120 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(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()); - } - break; - } + #[cfg(target_arch = "wasm32")] + { + HashSet::new() + } + #[cfg(not(target_arch = "wasm32"))] + { + 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; + } + } + set } - set }) } diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 3a0bab3..654395f 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 abdb4e7..51a5f32 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 fca5dc4..fbd6649 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 363dec1..79fe490 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 753585f..48141c3 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 4b326cc..4faf43a 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/56] 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 1b0cd34..99c1237 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 5ebc8b8..b690866 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 6354952..30db4b4 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 8652b20..ea404ba 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 915d8e4..017658c 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 bfc83b9..b747cbb 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 073dd36..c20c554 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/56] 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 0000000..5aef3a5 --- /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/56] 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 b6c186d..9fa6d15 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 4faf43a..5683f64 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 8eb1eca..f907aa2 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 47c5432..d4b0a0b 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 0258169..df1e3f1 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 2dbd8df..36c2621 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 139256f..503288c 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 74bbeb4..302a2f9 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/56] 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 8e08ea6..6f9d369 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 d238ef7..7172bcb 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 0000000..907ac7e --- /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 9121a06..ad30855 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 b9fe2ce..be10c91 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 4b4200d..5e84bb4 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 6ccef9e..b1587ff 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 adeeddb..188fbad 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 4bd50dd..b989d25 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 28d025c..17c7633 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/56] 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 0000000..2180636 --- /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/56] 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 6ff01cc..a4631e8 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 a6a2da2..bd48b92 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 0000000..2eb5d83 --- /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 b039943..b6ef62b 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 0d2cdea..c92ca91 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/56] 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 fd50afb..74cbe58 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 bb54fe0..0a2c532 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 b6a362b..a9d3c6d 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 f3576e4..019dd8f 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 4c96005..463df81 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 de670e9..414ab42 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 c4a290e..7be6faf 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 10892bc..e78a6ea 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 d8450cd..c9bb195 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 cf590be..844b923 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/56] 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 356c63b..0f69e38 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 f5744bd..9f758b4 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 1225eec..6ca6f05 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 264479a..5fc91f8 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/56] 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 24e6268..7affcdb 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 6bd03d5..890d297 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 7601674..9424d7d 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 9a6dab3..78dd7eb 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 72d33e2..7180d46 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 0000000..d28f9ae --- /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 e85bca1..4d1feb4 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 c2d2f22e6a0443aaab53c6c4a812f416acae7243 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:54:30 +0200 Subject: [PATCH 12/56] 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 6608f1d..e636acc 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 0000000..55ed453 --- /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 c4a290e..d411f0b 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 4649f41..617b171 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 c2b904b..0d74e6a 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 7dd3acb..ee5c44b 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 1e7b212..7574d3d 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 c37ae3c..c81c964 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 1bdc2c3..41b0cf1 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 8d54856..855c84b 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 9f04ada..8cd112e 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 264479a..2eb9477 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 a033d0b..2265ded 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 e98835d..9819de9 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 e85bca1..03a75cd 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 13/56] 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 770b3fe..db6a5bf 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 1189fd7..f961ed4 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 f5744bd..f4f4e3f 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 3c73a95..1495350 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 d52418b..8837ea6 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 25dec98..52ee2d1 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 6bd03d5..df8b142 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 d5d5c36..eb741b8 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 252096d..3a74718 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 05d1afc..ce3353a 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 43639fe..e311a50 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 0000000..a150dc0 --- /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 0000000..3fc3fc4 --- /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 0000000..d796419 --- /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 0000000..b070478 --- /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 0000000..fac7847 --- /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 44e69e5..a4606fe 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 eeb2dd9..7477f02 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 aa752b8..1da4104 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 14/56] 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 1189fd7..f2c255a 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 24e6268..bee10b1 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 e3b8b63..7dd43df 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 0000000..6d0b35d --- /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 d2d82ed..79fd3fd 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 6ccef9e..7e03cb3 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 05d1afc..e5d1bc4 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 0000000..187009b --- /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 050a9f3..50b568e 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 065558e..8f51f33 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 b1e3fca..cfd1b6b 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 43639fe..cd5daca 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 0000000..10b2681 --- /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 8af3df1..96a803a 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 efa5cdc..3a4760e 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 eeb2dd9..8959cc0 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 aa752b8..de3faba 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 64089568c8e68e85ee97a63cedd72e94e3925d49 Mon Sep 17 00:00:00 2001 From: Ege Arhan Date: Sun, 11 Oct 2026 16:50:42 +0000 Subject: [PATCH 15/56] 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 bb8c53c..42d1304 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 16/56] 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 a150dc0..fd37795 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 3fc3fc4..87a512c 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 b070478..c9aee66 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 e52fc4aa08b5f1b6456507566e3eaf2ede6a9b10 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:05:45 -0400 Subject: [PATCH 17/56] 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 187009b..f121977 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 10b2681..4cde9d6 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 95fbed9..dc2ddc4 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 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 18/56] 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 a604f0b..9770518 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 cf24ab3..40a0851 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 40a25c4..7404a61 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 1dc90e4..54ab319 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 d174397..e4fa4ba 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 19/56] 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 29085bd..2581300 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 20/56] 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 5bcd3d7..f53ab66 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 21/56] 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 6ca8162..65e9def 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 af76af0..5cb9881 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 96d250a..b30a1d1 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 22/56] 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 5cd6bf6..a62418e 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 8d84e4d..3fa0ef0 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 86234f8..f68d716 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 d7ce163..e56770e 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 23/56] 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 630fa2b..0d73b0a 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 49f00e5..626e8fb 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 d7009ec..c555c74 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 24/56] 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 9ac4ccf..c483414 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 2d63dec..fe7cbdc 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 25/56] 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 3d4a743..9aa80fd 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 2205a1e..50c4353 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 26/56] 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 3d4a743..e5d621d 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 2205a1e..bf64b5c 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 db8176b..df48a2c 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 7601674..a54eb0d 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 27/56] 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 32ec866..cb793e7 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 575cc77..0322095 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 28/56] 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 907ac7e..00342a5 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 29/56] 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 a62418e..aef29c1 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 f6dc683..468b406 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 af8a1a9..f248fc8 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 30/56] 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 3d4a743..025608c 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 2205a1e..3b20027 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 31/56] 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 a62418e..03830fb 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 3fa0ef0..d218c81 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 f68d716..8a00f5a 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 152884c..fcea332 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 32/56] 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 a62418e..d642931 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 f6dc683..2b018fe 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 0000000..a9428fa --- /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 33/56] 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 164c270..678495f 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 a62418e..9589334 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 14344b4..83478ed 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 f8ea38f..9d2c5e7 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 34/56] 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 0213fc8..7ff4ff7 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 fe32ee7..f91c8d3 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 35/56] 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 aa22826..b7e15cf 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 24dbfeb..144622b 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 36/56] 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 a62418e..85fb5ae 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 c6f0fb9..8dc28d1 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 32000f7..2c48b24 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 ea7404e..8268745 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 0000000..7945eb3 --- /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 07ee340..8eac285 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 fee804c..94f1663 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 2385b8e..a4f7b18 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 37/56] 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 a62418e..b927fc4 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 70823cd..8931bf5 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 7e03cb3..6ea5f99 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 32000f7..07e5705 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 196d47a..0291ebb 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 ccc51eb..af602b2 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 7c00d01..d1da00a 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 07ee340..2c33d1f 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 fee804c..acc98f6 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 5533e01..42adcb5 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 2385b8e..4038908 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 dc2ddc4..6aae60e 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 1b04d06014179ed4a5286d88c70a88bb889c85a2 Mon Sep 17 00:00:00 2001 From: debarghaya <175645893+GhostMitra@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:51:23 +0530 Subject: [PATCH 38/56] Web & UI: resolve system theme preference and default web to system theme --- apps/gridcraft-web/src/web.rs | 1 + contributors/people.toml | 4 ++++ crates/ui-egui/src/theme.rs | 15 +++++++++++++++ crates/ui-egui/src/widgets.rs | 28 ++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+) diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index f5744bd..30df447 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -38,6 +38,7 @@ pub fn start() { let _ = session.execute("file.new", serde_json::json!({"sample": "sales"})); } let mut app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone())); + app.ui.system_theme = true; // Follow the browser's language, except a CJK one: the web build has no system // CJK fonts (none are bundled), so that interface would render as tofu. let browser = gridcraft_ui_egui::i18n::Language::system(); diff --git a/contributors/people.toml b/contributors/people.toml index 734bf81..6d4b23e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -45,3 +45,7 @@ sync_github_name = false [people.Losminek] sync_github_name = true + +[people.GhostMitra] +sync_github_name = true + diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 6812c51..5259de8 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -125,6 +125,21 @@ impl Tokens { } } +/// Resolves an [`egui::ThemePreference`] against the context's system theme. +pub fn resolve_theme(pref: egui::ThemePreference, ctx: &egui::Context) -> egui::Theme { + match pref { + egui::ThemePreference::System => { + if ctx.input(|i| i.raw.system_theme == Some(egui::Theme::Dark)) { + egui::Theme::Dark + } else { + egui::Theme::Light + } + } + egui::ThemePreference::Light => egui::Theme::Light, + egui::ThemePreference::Dark => egui::Theme::Dark, + } +} + pub const UI: &str = "ui"; pub const UI_BOLD: &str = "ui-bold"; pub const CELL: &str = "cell"; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 7369892..9c82b98 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -236,3 +236,31 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) { ); ctx.request_repaint_after(std::time::Duration::from_millis(200)); } + +/// Turn a monochrome image (black on white) into an alpha mask tinted with the active text color. +pub fn raster_to_alpha_mask(pixels: &mut [egui::Color32]) { + for p in pixels.iter_mut() { + // Darkness of source becomes the opacity + let darkness = 255u8.saturating_sub(p.r()); + // Pure white glyph with variable alpha + *p = egui::Color32::from_rgba_unmultiplied(255, 255, 255, darkness); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_raster_to_alpha_mask() { + let mut pixels = vec![ + egui::Color32::WHITE, // r=255 -> darkness=0 -> alpha=0 + egui::Color32::BLACK, // r=0 -> darkness=255 -> alpha=255 + egui::Color32::from_rgb(100, 100, 100), // r=100 -> darkness=155 -> alpha=155 + ]; + raster_to_alpha_mask(&mut pixels); + assert_eq!(pixels[0], egui::Color32::from_rgba_unmultiplied(255, 255, 255, 0)); + assert_eq!(pixels[1], egui::Color32::from_rgba_unmultiplied(255, 255, 255, 255)); + assert_eq!(pixels[2], egui::Color32::from_rgba_unmultiplied(255, 255, 255, 155)); + } +} From cedfc5e44c01888342ba12249a04bc11b857b7e5 Mon Sep 17 00:00:00 2001 From: debarghaya <175645893+GhostMitra@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:54:49 +0530 Subject: [PATCH 39/56] Web: add favicon to web app index and disable dev incremental compilation --- Cargo.toml | 2 ++ apps/gridcraft-web/index.html | 1 + contributors/people.toml | 4 ++++ 3 files changed, 7 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index eab2853..0c00014 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,8 @@ opt-level = 2 [profile.dev] opt-level = 1 +incremental = false + [profile.release] opt-level = 3 diff --git a/apps/gridcraft-web/index.html b/apps/gridcraft-web/index.html index 96d6347..5fdd1ea 100644 --- a/apps/gridcraft-web/index.html +++ b/apps/gridcraft-web/index.html @@ -4,6 +4,7 @@ GridCraft +