From 481f1c2b8bff703fe08fb73e3e86a744401763e0 Mon Sep 17 00:00:00 2001 From: Bryan Mark Date: Mon, 12 Oct 2026 10:38:55 +0900 Subject: [PATCH 01/62] File view: the recent list tells when each workbook was last changed The recent workbooks on the File view's Home and Open pages gain a Modified column: "Just now", "5 min ago", "3 h ago", "Yesterday", "4 days ago", "3 weeks ago", "5 months ago", "2 years ago", or "Not available" for a file that can't be reached. The file system is never asked while drawing. A thread asks for each file's metadata and the list shows each answer as it comes back, so a network drive that is asleep or a share that is away doesn't hold up the view. One thread asks at a time; a view opened anew asks anew. Only metadata is read: no workbook is opened until the user picks one. The list tells ages rather than dates because the UI has no time zone to tell a date by. From two days on a count is never one, so each language needs one form of "days", "weeks", "months" and "years"; the ten strings are in the nine catalogs, with the count placed by a {n} in each, as "Page {page} of {pages}" does. A long name or folder now stops short of the column instead of running under it. The web build, which has no files to ask about, and a narrow window show the list as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/backstage.rs | 236 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/i18n.rs | 10 ++ crates/ui-egui/src/i18n/cs.rs | 10 ++ crates/ui-egui/src/i18n/de.rs | 10 ++ crates/ui-egui/src/i18n/es.rs | 10 ++ crates/ui-egui/src/i18n/fr.rs | 10 ++ crates/ui-egui/src/i18n/ko.rs | 10 ++ crates/ui-egui/src/i18n/pt.rs | 10 ++ crates/ui-egui/src/i18n/ru.rs | 10 ++ crates/ui-egui/src/i18n/zh.rs | 10 ++ docs/ui-parity.md | 5 +- 11 files changed, 315 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 824b4da..42ee27c 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -4,8 +4,13 @@ //! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export, //! Options); Save and Close act at once. Every action runs an existing command through //! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is -//! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched -//! until the user picks one. +//! UI-only and never saved. Recent workbooks are listed by path, with when each was last changed: +//! that is asked of the file system on a thread of its own, never while drawing, and only the +//! files' metadata is read. No workbook is opened until the user picks one. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use egui::{Align2, Color32, Key, Modifiers, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value as Json, json}; @@ -109,6 +114,8 @@ pub struct Backstage { preview: Option<((u64, u64, bool, bool), Json)>, /// Save As: the chosen file type (an extension from [`SAVE_TYPES`]). pub save_format: &'static str, + /// Home and Open: when the recent workbooks were last changed. + recent: RecentFiles, } /// Save As file types: (extension, label). The first is the workbook itself; the others are @@ -118,7 +125,16 @@ pub const SAVE_TYPES: [(&str, &str); 4] = impl Backstage { pub fn new(page: Page) -> Backstage { - Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None, save_format: "xlsx" } + Backstage { + page, + props: Default::default(), + print_all: false, + fit_to_page: false, + preview_page: 0, + preview: None, + save_format: "xlsx", + recent: RecentFiles::default(), + } } } @@ -187,9 +203,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { ui.label(RichText::new(lang.tr(bs.page.label())).font(theme::ui_font(28.0)).color(t.text)); ui.add_space(18.0); match bs.page { - Page::Home => home(app, ui, &mut out), + Page::Home => home(app, ui, &mut bs, &mut out), Page::New => new_page(app, ui, &mut out), - Page::Open => open_page(app, ui, &mut out), + Page::Open => open_page(app, ui, &mut bs, &mut out), Page::Info => info(app, ui, &mut bs), Page::SaveAs => save_as(app, ui, &mut bs, &mut out), Page::Print => print(app, ui, &mut bs, &mut out), @@ -325,7 +341,7 @@ fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { }); } -/// File name and folder of a recent path, split as text (nothing on disk is read). +/// File name and folder of a recent path, split as text. fn split_path(path: &str) -> (String, String) { let p = std::path::Path::new(path); let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string(); @@ -333,8 +349,12 @@ fn split_path(path: &str) -> (String, String) { (name, folder) } -/// Recent workbooks, newest first. Opening one is the user's explicit choice. -fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) { +/// Width of the column that tells when a recent workbook was last changed. +const MODIFIED_W: f32 = 150.0; + +/// Recent workbooks, newest first, each with when it was last changed. Opening one is the user's +/// explicit choice. +fn recent_list(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome, limit: usize) { let t = Tokens::get(ui.ctx()); let lang = app.ui.language; if app.ui.recent.is_empty() { @@ -342,6 +362,21 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) return; } let width = ui.available_width().min(CONTENT_W); + // What the file system has said so far; the rest arrives in later frames (see `RecentFiles`). + let known = bs.recent.known(ui.ctx(), &app.ui.recent); + // The web build has no files to ask about, and a narrow window leaves the names their room. + let modified_w = if cfg!(target_arch = "wasm32") || width < 420.0 { 0.0 } else { MODIFIED_W }; + if modified_w > 0.0 { + let (head, _) = ui.allocate_exact_size(vec2(width, 20.0), Sense::hover()); + ui.painter().text( + pos2(head.right() - modified_w, head.center().y), + Align2::LEFT_CENTER, + lang.tr("Modified"), + theme::ui_font(11.5), + t.text_dim, + ); + } + let now = crate::now_ms() / 1000.0; for path in app.ui.recent.iter().take(limit).cloned().collect::>() { let (name, folder) = split_path(&path); let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); @@ -351,8 +386,19 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) p.rect_filled(rect, 4.0, t.hover); } icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(20.0, 20.0)), Icon::Book, t.text); - p.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text); - p.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim); + // A long name or folder stops short of the column instead of running under it. + let room = if modified_w > 0.0 { rect.right() - modified_w - 14.0 } else { rect.right() }; + let text = p.with_clip_rect(Rect::from_min_max(rect.min, pos2(room, rect.bottom())).intersect(p.clip_rect())); + text.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text); + text.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim); + if modified_w > 0.0 { + let (words, color) = match known.get(&path) { + Some(Modified::At(seconds)) => (age(lang, now - seconds), t.text_dim), + Some(Modified::Unknown) => (lang.tr("Not available").to_string(), t.text_disabled), + None => (String::new(), t.text_dim), + }; + p.text(pos2(rect.right() - modified_w, rect.center().y), Align2::LEFT_CENTER, words, theme::ui_font(12.0), color); + } let resp = resp.on_hover_text(&path); if resp.clicked() { app.open_path(&path); @@ -363,6 +409,98 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) } } +/// What the file system said about a recent workbook. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Modified { + /// When it was last changed, in seconds since 1970. + At(f64), + /// It can't be reached: moved, deleted, or on a drive that is away. + Unknown, +} + +/// One thread at a time asks about recent workbooks, so a drive that never answers holds up one +/// thread, not one for every time the view is opened. +static ASKING: AtomicBool = AtomicBool::new(false); + +/// When the listed workbooks were last changed, as far as the file system has answered. +/// +/// Asking can take long (a network drive that is asleep, a share that is away), so it is never +/// done while drawing: a thread asks, and the list shows each answer as it comes back. Only the +/// files' metadata is read. A view opened anew asks anew. +#[derive(Clone, Debug, Default)] +pub struct RecentFiles { + /// The list the answers are for. + asked: Vec, + found: Arc>>, +} + +impl RecentFiles { + /// What is known this frame about the files in `recent`; starts asking when the list is new. + fn known(&mut self, ctx: &egui::Context, recent: &[String]) -> HashMap { + if self.asked != recent && !ASKING.swap(true, Ordering::SeqCst) { + self.asked = recent.to_vec(); + if !ask(ctx.clone(), recent.to_vec(), self.found.clone()) { + ASKING.store(false, Ordering::SeqCst); + } + } + self.found.lock().unwrap_or_else(PoisonError::into_inner).clone() + } +} + +/// Starts the thread that asks. Returns whether it started. +#[cfg(not(target_arch = "wasm32"))] +fn ask(ctx: egui::Context, paths: Vec, found: Arc>>) -> bool { + /// Lets the next list ask, however the thread ends. + struct Done; + impl Drop for Done { + fn drop(&mut self) { + ASKING.store(false, Ordering::SeqCst); + } + } + let asking = std::thread::Builder::new().name("gridcraft-recent-files".into()).spawn(move || { + let _done = Done; + for path in paths { + let changed = std::fs::metadata(&path).and_then(|m| m.modified()).ok().and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok()); + let answer = changed.map_or(Modified::Unknown, |since| Modified::At(since.as_secs_f64())); + found.lock().unwrap_or_else(PoisonError::into_inner).insert(path, answer); + ctx.request_repaint(); + } + }); + asking.is_ok() +} + +/// The web build has no file system to ask. +#[cfg(target_arch = "wasm32")] +fn ask(_ctx: egui::Context, _paths: Vec, _found: Arc>>) -> bool { + false +} + +/// How long ago, in words. The clock's time zone isn't known here, so the list tells ages rather +/// than dates, which could be a day out. From two days on the counts are never one, so each +/// language needs one form of "days", "weeks", "months" and "years". +fn age(lang: Language, seconds: f64) -> String { + const DAY: f64 = 86_400.0; + let s = if seconds.is_finite() { seconds.max(0.0) } else { 0.0 }; + let counted = |pattern: &str, n: f64| lang.tr(pattern).replace("{n}", &(n.floor() as u64).to_string()); + if s < 60.0 { + lang.tr("Just now").to_string() + } else if s < 3600.0 { + counted("{n} min ago", s / 60.0) + } else if s < DAY { + counted("{n} h ago", s / 3600.0) + } else if s < 2.0 * DAY { + lang.tr("Yesterday").to_string() + } else if s < 14.0 * DAY { + counted("{n} days ago", s / DAY) + } else if s < 61.0 * DAY { + counted("{n} weeks ago", s / (7.0 * DAY)) + } else if s < 731.0 * DAY { + counted("{n} months ago", s / (30.44 * DAY)) + } else { + counted("{n} years ago", s / (365.25 * DAY)) + } +} + /// A large clickable row (icon, title, one-line description). fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Response { let t = Tokens::get(ui.ctx()); @@ -395,13 +533,13 @@ fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response { // ---------------------------------------------------------------- pages -fn home(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { +fn home(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { let lang = app.ui.language; section(ui, lang, "New"); new_tiles(app, ui, out); ui.add_space(22.0); section(ui, lang, "Recent"); - recent_list(app, ui, out, 8); + recent_list(app, ui, bs, out, 8); if app.ui.recent.len() > 8 { ui.add_space(6.0); if ui.link(lang.tr("More workbooks")).clicked() { @@ -414,7 +552,7 @@ fn new_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { new_tiles(app, ui, out); } -fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { +fn open_page(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { let lang = app.ui.language; if square_button(ui, Icon::OpenFolder, lang.tr("Browse…")).clicked() { let before = app.session.active().map(|d| d.uid); @@ -426,7 +564,7 @@ fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { } ui.add_space(22.0); section(ui, lang, "Recent"); - recent_list(app, ui, out, usize::MAX); + recent_list(app, ui, bs, out, usize::MAX); } /// Review › Protect Workbook and File › Info › Protect Workbook: the protect dialog (with an @@ -850,6 +988,76 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { mod tests { use super::*; + #[test] + fn ages_are_told_in_words() { + const DAY: f64 = 86_400.0; + for (seconds, words) in [ + (0.0, "Just now"), + (59.9, "Just now"), + (60.0, "1 min ago"), + (150.0, "2 min ago"), + (3600.0, "1 h ago"), + (23.9 * 3600.0, "23 h ago"), + (DAY, "Yesterday"), + (1.99 * DAY, "Yesterday"), + // From here on a count is never one. + (2.0 * DAY, "2 days ago"), + (13.9 * DAY, "13 days ago"), + (14.0 * DAY, "2 weeks ago"), + (60.9 * DAY, "8 weeks ago"), + (61.0 * DAY, "2 months ago"), + (730.9 * DAY, "24 months ago"), + (731.0 * DAY, "2 years ago"), + (4000.0 * DAY, "10 years ago"), + // A clock that is behind the file's, or a number that isn't one. + (-5.0, "Just now"), + (f64::NAN, "Just now"), + (f64::NEG_INFINITY, "Just now"), + (f64::INFINITY, "Just now"), + ] { + assert_eq!(age(Language::En, seconds), words, "{seconds}"); + } + assert!(age(Language::En, f64::MAX).ends_with(" years ago")); + // Other languages put the count where their own words want it. + assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前"); + assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen"); + assert_eq!(age(Language::Fr, 90.0), "il y a 1 min"); + for lang in Language::ALL { + for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] { + let words = age(lang, seconds); + assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}"); + } + } + } + + #[test] + fn the_recent_list_learns_when_its_files_were_last_changed() { + let dir = std::env::temp_dir().join(format!("gridcraft-recent-files-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let there = dir.join("There.xlsx").to_string_lossy().into_owned(); + let gone = dir.join("Gone.xlsx").to_string_lossy().into_owned(); + std::fs::write(&there, b"a file, if not a workbook").unwrap(); + let ctx = egui::Context::default(); + let recent = vec![there.clone(), gone.clone()]; + let mut files = RecentFiles::default(); + // Nothing is read while drawing: the answers arrive from a thread, in later frames. + let mut known = HashMap::new(); + for _ in 0..2_000 { + known = files.known(&ctx, &recent); + if known.len() == recent.len() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(matches!(known.get(&there), Some(Modified::At(seconds)) if *seconds > 1.0e9), "{known:?}"); + assert_eq!(known.get(&gone), Some(&Modified::Unknown)); + // The same list is not asked about twice; an empty one is never asked about. + assert_eq!(files.known(&ctx, &recent), known); + assert!(RecentFiles::default().known(&ctx, &[]).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn page_ids_round_trip_and_unknown_ids_are_refused() { for page in Page::ALL { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 406cc3d..aba2974 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -511,6 +511,16 @@ const JAPANESE: &[(&str, &str)] = &[ ("Recent", "最近使ったアイテム"), ("No recent workbooks yet.", "最近使ったブックはまだありません。"), ("More workbooks", "その他のブック"), + ("Modified", "更新日時"), + ("Not available", "利用できません"), + ("Just now", "たった今"), + ("Yesterday", "昨日"), + ("{n} min ago", "{n} 分前"), + ("{n} h ago", "{n} 時間前"), + ("{n} days ago", "{n} 日前"), + ("{n} weeks ago", "{n} 週間前"), + ("{n} months ago", "{n} か月前"), + ("{n} years ago", "{n} 年前"), ("Browse…", "参照…"), ("Not saved yet", "まだ保存されていません"), ("Location", "場所"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index ca2e559..2ed53f0 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -256,6 +256,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Naposledy použité", "No recent workbooks yet." => "Zatím žádné naposledy použité sešity.", "More workbooks" => "Další sešity", + "Modified" => "Změněno", + "Not available" => "Nedostupné", + "Just now" => "Právě teď", + "Yesterday" => "Včera", + "{n} min ago" => "před {n} min", + "{n} h ago" => "před {n} h", + "{n} days ago" => "před {n} dny", + "{n} weeks ago" => "před {n} týdny", + "{n} months ago" => "před {n} měsíci", + "{n} years ago" => "před {n} lety", "Browse…" => "Procházet…", "Not saved yet" => "Zatím neuloženo", "Location" => "Umístění", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2ad37f6..0b43bb7 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -254,6 +254,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Zuletzt verwendet", "No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.", "More workbooks" => "Weitere Arbeitsmappen", + "Modified" => "Geändert", + "Not available" => "Nicht verfügbar", + "Just now" => "Gerade eben", + "Yesterday" => "Gestern", + "{n} min ago" => "vor {n} Min.", + "{n} h ago" => "vor {n} Std.", + "{n} days ago" => "vor {n} Tagen", + "{n} weeks ago" => "vor {n} Wochen", + "{n} months ago" => "vor {n} Monaten", + "{n} years ago" => "vor {n} Jahren", "Browse…" => "Durchsuchen…", "Not saved yet" => "Noch nicht gespeichert", "Location" => "Speicherort", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index da41bd7..11a2f77 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -255,6 +255,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Recientes", "No recent workbooks yet." => "Todavía no hay libros recientes.", "More workbooks" => "Más libros", + "Modified" => "Modificado", + "Not available" => "No disponible", + "Just now" => "Ahora mismo", + "Yesterday" => "Ayer", + "{n} min ago" => "hace {n} min", + "{n} h ago" => "hace {n} h", + "{n} days ago" => "hace {n} días", + "{n} weeks ago" => "hace {n} semanas", + "{n} months ago" => "hace {n} meses", + "{n} years ago" => "hace {n} años", "Browse…" => "Examinar…", "Not saved yet" => "Aún no se ha guardado", "Location" => "Ubicación", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index fdf1f89..76134fc 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -336,6 +336,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification", "No recent workbooks yet." => "Rien à imprimer.", "More workbooks" => "Commentaire suivant", + "Modified" => "Modifié", + "Not available" => "Indisponible", + "Just now" => "À l’instant", + "Yesterday" => "Hier", + "{n} min ago" => "il y a {n} min", + "{n} h ago" => "il y a {n} h", + "{n} days ago" => "il y a {n} jours", + "{n} weeks ago" => "il y a {n} semaines", + "{n} months ago" => "il y a {n} mois", + "{n} years ago" => "il y a {n} ans", "Browse…" => "CSV (séparé par des virgules)", "Not saved yet" => "Nombre\nde pages", "Location" => "Nouveau", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index b684c78..c23b60c 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -342,6 +342,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "최근 항목", "No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.", "More workbooks" => "더 많은 통합 문서", + "Modified" => "수정한 날짜", + "Not available" => "사용할 수 없음", + "Just now" => "방금", + "Yesterday" => "어제", + "{n} min ago" => "{n}분 전", + "{n} h ago" => "{n}시간 전", + "{n} days ago" => "{n}일 전", + "{n} weeks ago" => "{n}주 전", + "{n} months ago" => "{n}개월 전", + "{n} years ago" => "{n}년 전", "Browse…" => "찾아보기…", "Not saved yet" => "아직 저장되지 않음", "Location" => "위치", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index b554ef9..1915146 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -343,6 +343,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Recentes", "No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.", "More workbooks" => "Mais pastas de trabalho", + "Modified" => "Modificado", + "Not available" => "Indisponível", + "Just now" => "Agora mesmo", + "Yesterday" => "Ontem", + "{n} min ago" => "há {n} min", + "{n} h ago" => "há {n} h", + "{n} days ago" => "há {n} dias", + "{n} weeks ago" => "há {n} semanas", + "{n} months ago" => "há {n} meses", + "{n} years ago" => "há {n} anos", "Browse…" => "Procurar…", "Not saved yet" => "Ainda não salvo", "Location" => "Local", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index c8f0445..66ec0a2 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -342,6 +342,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Последние", "No recent workbooks yet." => "Недавних книг пока нет.", "More workbooks" => "Другие книги", + "Modified" => "Изменён", + "Not available" => "Недоступен", + "Just now" => "Только что", + "Yesterday" => "Вчера", + "{n} min ago" => "{n} мин назад", + "{n} h ago" => "{n} ч назад", + "{n} days ago" => "{n} дн. назад", + "{n} weeks ago" => "{n} нед. назад", + "{n} months ago" => "{n} мес. назад", + "{n} years ago" => "{n} г. назад", "Browse…" => "Обзор…", "Not saved yet" => "Ещё не сохранено", "Location" => "Расположение", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index d1cf704..4b783e6 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -340,6 +340,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "最近", "No recent workbooks yet." => "还没有最近使用的工作簿。", "More workbooks" => "更多工作簿", + "Modified" => "修改时间", + "Not available" => "不可用", + "Just now" => "刚刚", + "Yesterday" => "昨天", + "{n} min ago" => "{n} 分钟前", + "{n} h ago" => "{n} 小时前", + "{n} days ago" => "{n} 天前", + "{n} weeks ago" => "{n} 周前", + "{n} months ago" => "{n} 个月前", + "{n} years ago" => "{n} 年前", "Browse…" => "浏览…", "Not saved yet" => "尚未保存", "Location" => "位置", diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..f4b5269 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (the File view's recent list tells when each workbook was last changed) · **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 @@ -26,7 +26,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | -| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | +| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | | Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | header/footer pictures, Different First Page / Odd & Even, ruler margin dragging missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | @@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | From c7701f6e137cd7594a242ba3cd6b2bfde55879c2 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:40:27 -0500 Subject: [PATCH 02/62] =?UTF-8?q?Functions=20given=20by=20name:=20GROUPBY(?= =?UTF-8?q?=E2=80=A6,SUM),=20BYROW(=E2=80=A6,SUM),=20MAP(=E2=80=A6,ABS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel lets a worksheet function be passed by name where a function is expected. In GridCraft GROUPBY(A2:A9,B2:B9,SUM) and PIVOTBY gave #NAME? (their documented form), and BYROW, BYCOL, MAP, REDUCE, SCAN and MAKEARRAY gave #VALUE!. A name that is a worksheet function, and not a LET/LAMBDA variable or a defined name, now works as a function there. Reading a file, the _xleta. prefix Excel writes for these names is removed. --- crates/calc/src/eval.rs | 43 +++++++++++++++++++++++++++++++----- crates/calc/src/tests.rs | 21 ++++++++++++++++++ crates/formula/src/parser.rs | 4 ++++ crates/formula/src/tests.rs | 1 + 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..357e3bf 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -737,6 +737,20 @@ impl<'h> Evaluator<'h> { Ev::V(Value::Bool(r)) } "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), + "GROUPBY" | "PIVOTBY" => { + // The aggregation may be a function given by name (`SUM`): pass it as its name. + let i = if name == "GROUPBY" { 2 } else { 3 }; + match args.get(i).and_then(|a| self.function_by_name(a)) { + Some(f) => { + let mut args = args.to_vec(); + if let Some(a) = args.get_mut(i) { + *a = Expr::Text(f.into()); + } + self.builtin(name, &args) + } + None => self.builtin(name, args), + } + } "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => { self.host.note_volatile(); self.builtin(name, args) @@ -903,7 +917,26 @@ impl<'h> Evaluator<'h> { r } - fn lambda_arg(&mut self, e: Option<&Expr>) -> Option> { + /// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`, + /// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has + /// that name. + fn function_by_name(&self, e: &Expr) -> Option { + let Expr::Name(n) = e else { return None }; + if self.env.iter().any(|(k, _)| k.eq_ignore_ascii_case(n)) || self.host.workbook().name(n, self.sheet).is_some() { + return None; + } + let u = n.to_ascii_uppercase(); + gridcraft_functions::lookup(&u).map(|_| u) + } + + /// The function argument of a LAMBDA helper; a function given by name takes `arity` + /// arguments. + fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option> { + if let Some(f) = e.and_then(|x| self.function_by_name(x)) { + let params: Vec = (0..arity).map(|i| format!("_xleta.{i}")).collect(); + let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect()); + return Some(Arc::new(Lambda { params, body, env: Vec::new() })); + } match e.map(|x| self.eval(x)) { Some(Ev::L(l)) => Some(l), _ => None, @@ -922,7 +955,7 @@ impl<'h> Evaluator<'h> { if args.len() < 2 { return err(CellError::Value); } - let Some(l) = self.lambda_arg(args.last()) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) }; let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); @@ -945,7 +978,7 @@ impl<'h> Evaluator<'h> { } let mut acc = self.value(&args[0]); let arr = as_array(self.value(&args[1])); - let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; if l.params.len() != 2 { return err(CellError::Value); } @@ -969,7 +1002,7 @@ impl<'h> Evaluator<'h> { return err(CellError::Value); } let arr = as_array(self.value(&args[0])); - let Some(l) = self.lambda_arg(args.get(1)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; let by_row = name == "BYROW"; let n = if by_row { arr.rows } else { arr.cols }; @@ -998,7 +1031,7 @@ impl<'h> Evaluator<'h> { if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 { return err(CellError::Value); } - let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) }; + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; if l.params.len() != 2 { return err(CellError::Value); } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..5d71a26 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -501,6 +501,27 @@ fn let_lambda() { assert_eq!(t.num("A4"), 14.0); } +#[test] +fn functions_given_by_name() { + let mut t = T::new(); + for (i, (k, v)) in [("a", 1), ("b", -2), ("a", 3)].iter().enumerate() { + t.set(&format!("A{}", i + 1), k); + t.set(&format!("B{}", i + 1), &v.to_string()); + } + t.set("D1", "=BYROW(B1:B3,SUM)"); + assert_eq!(t.num("D2"), -2.0); + t.set("E1", "=MAP(B1:B3,ABS)"); + assert_eq!(t.num("E2"), 2.0); + t.set("F1", "=REDUCE(0,B1:B3,SUM)"); + assert_eq!(t.num("F1"), 2.0); + t.set("G1", "=GROUPBY(A1:A3,B1:B3,SUM,0,0)"); + assert_eq!(t.get("G1"), Value::from("a")); + assert_eq!(t.num("H1"), 4.0); + // A LET variable of that name is still the variable. + t.set("J1", "=LET(ABS,LAMBDA(x,x*10),SUM(MAP(B1:B3,ABS)))"); + assert_eq!(t.num("J1"), 20.0); +} + #[test] fn names_and_sheets() { let mut t = T::new(); diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 4fd3b7b..568f7c3 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -357,6 +357,10 @@ impl Parser { if w.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Err(self.err(format!("unexpected `{w}`"))); } + // A function passed by name is saved as `_xleta.SUM`. + if let Some(f) = w.get(..7).filter(|p| p.eq_ignore_ascii_case("_xleta.")).and_then(|_| w.get(7..)) { + return Ok(Expr::Name(f.to_ascii_uppercase())); + } Ok(Expr::Name(w.to_string())) } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c2..ad9adc2 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -48,6 +48,7 @@ fn normalises() { assert_eq!(rt("=sum(a1:b2)"), "SUM(A1:B2)"); assert_eq!(rt("= 1 + 2"), "1+2"); assert_eq!(rt("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); + assert_eq!(rt("_xlfn.GROUPBY(A1:A3,B1:B3,_xleta.SUM)"), "GROUPBY(A1:A3,B1:B3,SUM)"); assert_eq!(rt("true"), "TRUE"); assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1"); } From a4c6ac3efff593fcaf0a6a233f155ec3d2ea6e9d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:08 +0200 Subject: [PATCH 03/62] Reject out-of-range numeric filterBy columns instead of overflowing (#573) --- crates/engine/src/cmd/data.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3ba..f7133e0 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -122,7 +122,7 @@ pub fn specs() -> Vec { fn col_param(v: Option<&Json>, base: u32) -> Option { match v? { Json::String(s) => gridcraft_core::letters_to_col(s.trim_start_matches('$')), - Json::Number(n) => n.as_u64().map(|o| base + o as u32), + Json::Number(n) => n.as_u64().and_then(|o| u32::try_from(o).ok()).and_then(|o| base.checked_add(o)), _ => None, } } @@ -488,7 +488,12 @@ fn filter_by(s: &mut Session, p: &Json) -> Result { s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))? } }; - let col = col_param(p.get("column"), af.range.start.col).ok_or_else(|| bad("data.filterBy", "missing `column`"))?; + let col = match col_param(p.get("column"), af.range.start.col) { + Some(c) => c, + // A number too large to be a column is outside the filter range, not a missing parameter. + None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")), + None => return Err(bad("data.filterBy", "missing `column`")), + }; if col < af.range.start.col || col > af.range.end.col { return Err(bad("data.filterBy", "column outside the filter range")); } @@ -1278,6 +1283,20 @@ fn subtotal(s: &mut Session, p: &Json) -> Result { mod tests { use super::*; + #[test] + fn filter_by_huge_numeric_column_is_rejected_not_overflowed() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); + s.execute("data.filter", json!({"range": "B1:C3"})).unwrap(); + for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] { + let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err(); + assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}"); + } + let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap(); + assert_eq!(ok["hiddenRows"], json!(1)); + } + #[test] fn partial_validation_edits_keep_the_rest_of_the_old_rule() { let mut s = Session::new(); From d64b30b3a462973e27e1a1c9bbdee4153d4306ab Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:56 +0200 Subject: [PATCH 04/62] Funnel chart: hide value labels when data labels are off (#544) --- crates/chart/src/render.rs | 12 +++++++----- crates/chart/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 63d30d9..d36e2ab 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -254,7 +254,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { Doughnut => pie(ctx, chart, data, area, 0.5, false), Sunburst => pie(ctx, chart, data, area, 0.2, true), Treemap => treemap(ctx, chart, data, area), - Funnel => funnel(ctx, data, area), + Funnel => funnel(ctx, chart, data, area), Radar => radar(ctx, chart, data, area), _ => { let area = axis_titles(ctx, chart, area); @@ -1301,7 +1301,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { // ---------------------------------------------------------------- funnel -fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let Some(s) = data.series.first() else { return }; let n = data.categories.len().max(s.values.len()).min(MAX_CATS); if n == 0 { @@ -1331,9 +1331,11 @@ fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) { } if show { let yc = y + bh / 2.0; - let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); - let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; - ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); + if chart.data_labels { + let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); + let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; + ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); + } let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a942677..f4a12ff 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -902,3 +902,25 @@ fn union_series_references() { c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000)); let _ = resolve(&wb, 0, &c); } + +#[test] +fn funnel_value_labels_follow_the_data_labels_flag() { + let data = ChartData { + categories: vec!["a".into(), "b".into(), "c".into()], + series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)], + }; + let mut c = chart(ChartKind::Funnel); + c.data_labels = false; + let off = render(&c, &data, 500.0, 350.0, &ApproxMeasure); + c.data_labels = true; + let on = render(&c, &data, 500.0, 350.0, &ApproxMeasure); + let values = ["$11,111", "$22,222", "$33,333"]; + for v in values { + assert!(texts(&on).contains(&v), "{v} missing with labels on"); + assert!(!texts(&off).contains(&v), "{v} drawn with labels off"); + } + for cat in ["a", "b", "c"] { + assert!(texts(&off).contains(&cat)); + assert!(texts(&on).contains(&cat)); + } +} From 34e1d373bfa8cd2b0ba30784eee806029d3a6fde Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:26 +0200 Subject: [PATCH 05/62] fix(engine): CSV named History keeps a valid sheet name (#570) --- crates/engine/src/io.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index e613611..f0bd5e4 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -144,7 +144,9 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) { let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1"); let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); let clean = clean.trim_matches('\'').to_string(); + // "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead. if !clean.is_empty() + && !clean.eq_ignore_ascii_case("History") && let Some(sh) = wb.sheet_mut(0) { sh.name = clean; @@ -434,6 +436,17 @@ mod tests { assert!(wb.vba_project.is_some(), "the open workbook keeps its macros"); } + #[test] + fn csv_named_history_does_not_get_the_reserved_sheet_name() { + for file in ["History.csv", "hIsToRy.csv", "history.tsv"] { + let (wb, _) = open_bytes(file, b"1\n").unwrap(); + let name = wb.sheets[0].name.clone(); + assert!(wb.check_sheet_name(&name, Some(0)).is_ok(), "{file} opened as {name:?}"); + } + let (wb, _) = open_bytes("Sales.csv", b"1\n").unwrap(); + assert_eq!(wb.sheets[0].name, "Sales"); + } + /// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a /// root entry and the given stream names. fn cfb(streams: &[&str]) -> Vec { From f8983e25ebff0b43a103514448c4ec0cbd972f8a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:39 +0200 Subject: [PATCH 06/62] Fix SEARCH position when text contains a char with multi-char lowercase (#571) --- crates/functions/src/criteria.rs | 14 +++++++++++--- crates/functions/src/text.rs | 5 +++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 20410c8..1784f08 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -168,19 +168,25 @@ fn tokenize(pattern: &str) -> Vec { while let Some(c) = it.next() { match c { '~' => match it.next() { - Some(n) => out.extend(n.to_lowercase().map(Tok::Char)), + Some(n) => out.extend(std::iter::once(lower_char(n)).map(Tok::Char)), None => out.push(Tok::Char('~')), }, '*' => out.push(Tok::Star), '?' => out.push(Tok::Any), - c => out.extend(c.to_lowercase().map(Tok::Char)), + c => out.extend(std::iter::once(lower_char(c)).map(Tok::Char)), } } out } +/// Lowercase one char to exactly one char, so positions in the lowered text match the original +/// (U+0130 `İ` folds to `i`, like `fold` in text.rs). +fn lower_char(c: char) -> char { + c.to_lowercase().next().unwrap_or(c) +} + fn lower_chars(s: &str) -> Vec { - s.chars().flat_map(char::to_lowercase).collect() + s.chars().map(lower_char).collect() } /// Whether the pattern contains an unescaped wildcard. @@ -317,6 +323,8 @@ mod tests { assert_eq!(wildcard_find("b?d", "abxxd bcd", 0), Some(6)); assert_eq!(wildcard_find("c*", "abcd", 0), Some(2)); assert_eq!(wildcard_find("z", "abcd", 0), None); + assert_eq!(wildcard_find("b", "a\u{130}bc", 0), Some(2)); + assert!(wildcard_match("i*", "\u{130}stanbul")); assert!(has_wildcards("a*")); assert!(!has_wildcards("abc")); } diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 723cd4a..eefb2d5 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -1032,6 +1032,11 @@ mod tests { close(ev("SEARCH", vec![t("~*"), t("a*b")]), 2.0); is_err(ev("SEARCH", vec![t("z"), t("abc")]), CellError::Value); is_err(ev("SEARCH", vec![t("a"), t("abc"), n(5.0)]), CellError::Value); + close(ev("SEARCH", vec![t("b"), t("\u{130}b")]), 2.0); + close(ev("SEARCH", vec![t("B"), t("\u{130}b")]), 2.0); + close(ev("SEARCH", vec![t("b?"), t("a\u{130}bc")]), 3.0); + close(ev("SEARCH", vec![t("i"), t("a\u{130}bc")]), 2.0); + is_err(ev("SEARCH", vec![t("b"), t("a\u{130}bc"), n(4.0)]), CellError::Value); assert_eq!(ev("EXACT", vec![t("a"), t("A")]), Value::Bool(false)); assert_eq!(ev("EXACT", vec![t("a"), t("a")]), Value::Bool(true)); } From 34125d7e43f572e13b57ef19e0e6d168730ea08c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:26 +0200 Subject: [PATCH 07/62] Quote sheet names when printing sheet-qualified defined names (#567) --- crates/formula/src/adjust.rs | 7 +++---- crates/formula/src/printer.rs | 10 +++++++++- crates/formula/src/tests.rs | 11 +++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 6f01e05..d4881aa 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -264,11 +264,10 @@ pub fn rename_sheet(e: Expr, old: &str, new: &str) -> Expr { } Expr::Ref(r) } - // Sheet-qualified defined name `Old!MyName` is stored as one string. + // Sheet-qualified defined name `Old!MyName` is stored as one string, with the bare sheet name as the parser + // stores it (the printer adds the quotes). Expr::Name(n) => match n.split_at_checked(old.len()) { - Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => { - Expr::Name(format!("{}{rest}", crate::printer::quote_sheet(new))) - } + Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => Expr::Name(format!("{new}{rest}")), _ => Expr::Name(n), }, other => other, diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index a63599b..a1ff7a7 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -178,7 +178,15 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { Some(at) => s.push_str(&reference_r1c1(r, at)), None => s.push_str(&reference_a1(r)), }, - Expr::Name(n) => s.push_str(n), + Expr::Name(n) => match n.rsplit_once('!') { + // A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back. + Some((sheet, name)) => { + s.push_str("e_sheet(sheet)); + s.push('!'); + s.push_str(name); + } + _ => s.push_str(n), + }, Expr::Struct(st) => s.push_str(&struct_ref(st)), Expr::Unary(op, x) => match op { UnOp::Neg => { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c2..66e1f01 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -5,6 +5,13 @@ fn rt(src: &str) -> String { print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}"))) } +#[test] +fn quoted_sheet_qualified_name_keeps_quotes() { + assert_eq!(rt("'My Sheet'!Total"), "'My Sheet'!Total"); + assert_eq!(rt("'Data'!Total"), "Data!Total"); + assert_eq!(rt("'It''s'!Total+1"), "'It''s'!Total+1"); +} + #[test] fn roundtrips() { for f in [ @@ -183,6 +190,10 @@ fn rename_sheet_updates_qualified_names() { assert_eq!(print(&rename_sheet(e, "data", "Numbers")), "Numbers!MyName+MyName+Other!MyName"); let e = parse("Data!MyName").unwrap(); assert_eq!(print(&rename_sheet(e, "Data", "New Name")), "'New Name'!MyName"); + // Stored bare, like a parsed name, so the evaluator finds the sheet and a second rename still matches. + let e = rename_sheet(parse("Data!MyName").unwrap(), "Data", "It's"); + assert_eq!(e, Expr::Name("It's!MyName".into())); + assert_eq!(print(&rename_sheet(e, "It's", "Totals")), "Totals!MyName"); } #[test] From eec3353e833a7daa102dcd9a9cb3820786591bfd Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:29:37 +0200 Subject: [PATCH 08/62] Read date text in the workbook date system in arithmetic and VALUE (#564) --- crates/calc/src/eval.rs | 18 ++++++++++-------- crates/calc/src/tests.rs | 20 ++++++++++++++++++++ crates/core/src/parse.rs | 7 ++++++- crates/core/src/value.rs | 8 ++++++-- crates/functions/src/text.rs | 10 +++++----- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..bfb2d4f 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -383,6 +383,7 @@ impl<'h> Evaluator<'h> { }; } let v = self.value(x); + let sys = self.host.workbook().date_system; Ev::V(map_unary( &v, &|n| match op { @@ -392,6 +393,7 @@ impl<'h> Evaluator<'h> { UnOp::At | UnOp::Spill | UnOp::Trim(_) => Value::number(n), }, op == UnOp::Plus, + sys, )) } @@ -467,7 +469,7 @@ impl<'h> Evaluator<'h> { _ => { let x = self.value(a); let y = self.value(b); - Ev::V(binary_values(op, &x, &y)) + Ev::V(binary_values(op, &x, &y, self.host.workbook().date_system)) } } } @@ -1492,12 +1494,12 @@ impl Acc { // ---------------------------------------------------------------- operators -fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool) -> Value { +fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value { match v { - Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus)).collect() }), + Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }), Value::Error(e) => Value::Error(*e), Value::Text(_) if plus => v.clone(), - other => match other.to_number() { + other => match other.to_number_in(sys) { Ok(n) => f(n), Err(e) => Value::Error(e), }, @@ -1550,18 +1552,18 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V Value::from(Array { rows, cols, data }) } -pub fn binary_values(op: BinOp, x: &Value, y: &Value) -> Value { - zip2(x, y, |a, b| binary_scalar(op, a, b)) +pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { + zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) } -fn binary_scalar(op: BinOp, a: &Value, b: &Value) -> Value { +fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value { if let Value::Error(e) = a { return Value::Error(*e); } if let Value::Error(e) = b { return Value::Error(*e); } - let num = |f: fn(f64, f64) -> Value| match (a.to_number(), b.to_number()) { + let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) { (Ok(x), Ok(y)) => f(x, y), (Err(e), _) | (_, Err(e)) => Value::Error(e), }; diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..4863cbd 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -270,6 +270,26 @@ fn circular_references_without_iterative_calculation() { assert_eq!(t.get("A1"), Value::Error(CellError::Circ)); } +#[test] +fn date_text_coerces_in_the_workbook_date_system() { + for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] { + let mut t = T::new(); + t.wb.date_system = sys; + t.set("A1", "'1/1/1904"); + t.set("B1", "=A1+0"); + t.set("C1", "=VALUE(A1)"); + t.set("D1", "=-A1"); + t.set("E1", "=DATEVALUE(A1)"); + t.set("F1", "=NUMBERVALUE(A1)"); + assert_eq!(t.get("A1"), Value::from("1/1/1904")); + assert_eq!(t.num("B1"), serial, "{sys:?}"); + assert_eq!(t.num("C1"), serial, "{sys:?}"); + assert_eq!(t.num("D1"), -serial, "{sys:?}"); + assert_eq!(t.num("E1"), serial, "{sys:?}"); + assert_eq!(t.num("F1"), serial, "{sys:?}"); + } +} + #[test] fn operators() { let mut t = T::new(); diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index a612a0b..3a27908 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -41,7 +41,12 @@ pub fn parse_input(s: &str, sys: DateSystem) -> Parsed { /// Numeric text as arithmetic coerces it ("1,000", "50%", "$3", "1/2/2020", "10:00"). pub fn parse_number_text(s: &str) -> Option { - parse_numeric(s.trim(), DateSystem::D1900).map(|(n, _)| n) + parse_number_text_in(s, DateSystem::D1900) +} + +/// Like [`parse_number_text`], with date text read in the given workbook date system. +pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option { + parse_numeric(s.trim(), sys).map(|(n, _)| n) } fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { diff --git a/crates/core/src/value.rs b/crates/core/src/value.rs index 390800f..cfef7ac 100644 --- a/crates/core/src/value.rs +++ b/crates/core/src/value.rs @@ -224,13 +224,17 @@ impl Value { /// Coercion to a number as arithmetic does it: empty = 0, TRUE = 1, numeric text parses /// (including dates, times, percentages and currency), other text = `#VALUE!`. pub fn to_number(&self) -> Result { + self.to_number_in(crate::DateSystem::D1900) + } + /// [`Value::to_number`] with date text read in the given workbook date system. + pub fn to_number_in(&self, sys: crate::DateSystem) -> Result { match self { Value::Empty => Ok(0.0), Value::Number(n) => Ok(*n), Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }), - Value::Text(t) => crate::parse::parse_number_text(t).ok_or(CellError::Value), + Value::Text(t) => crate::parse::parse_number_text_in(t, sys).ok_or(CellError::Value), Value::Error(e) => Err(*e), - Value::Array(a) => a.data.first().map(|v| v.to_number()).unwrap_or(Ok(0.0)), + Value::Array(a) => a.data.first().map(|v| v.to_number_in(sys)).unwrap_or(Ok(0.0)), } } /// Coercion to text as `&` does it. diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 723cd4a..429b9f9 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -1,6 +1,6 @@ //! Text functions. -use gridcraft_core::parse::parse_number_text; +use gridcraft_core::parse::parse_number_text_in; use gridcraft_core::{CellError, Value, number_to_text}; use crate::criteria::wildcard_find; @@ -294,17 +294,17 @@ fn unicode(a: &[Arg], _c: &mut dyn Ctx) -> R { /// VALUE: a blank cell reads as 0, but empty or blank text is not a number (observed: /// `VALUE("")` is `#VALUE!`). -fn value(a: &[Arg], _c: &mut dyn Ctx) -> R { +fn value(a: &[Arg], c: &mut dyn Ctx) -> R { match scalar(arg(a, 0)?) { Value::Number(n) => num_val(n), Value::Empty => num_val(0.0), Value::Error(e) => Err(e), - Value::Text(t) => parse_number_text(&t).ok_or(CellError::Value).and_then(num_val), + Value::Text(t) => parse_number_text_in(&t, c.date_system()).ok_or(CellError::Value).and_then(num_val), _ => Err(CellError::Value), } } -fn numbervalue(a: &[Arg], _c: &mut dyn Ctx) -> R { +fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R { let s = text(a, 0)?; let dec = if a.len() > 1 { text(a, 1)?.chars().next().ok_or(CellError::Value)? } else { '.' }; let grp = if a.len() > 2 { text(a, 2)?.chars().next().ok_or(CellError::Value)? } else { ',' }; @@ -337,7 +337,7 @@ fn numbervalue(a: &[Arg], _c: &mut dyn Ctx) -> R { out.push(c); } } - let mut n = parse_number_text(&out).ok_or(CellError::Value)?; + let mut n = parse_number_text_in(&out, c.date_system()).ok_or(CellError::Value)?; for _ in 0..pct { n /= 100.0; } From 5fb854be9cb9bf5e5aa362d8d700b5482696d538 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:28:58 +0200 Subject: [PATCH 09/62] Chart: color pie, doughnut, treemap and sunburst points from the workbook theme (#579) --- crates/chart/src/lib.rs | 3 ++ crates/chart/src/render.rs | 3 +- crates/chart/src/resolve.rs | 5 ++- crates/chart/src/tests.rs | 80 +++++++++++++++++++++++++++++++------ 4 files changed, 77 insertions(+), 14 deletions(-) diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs index 083ff8b..697e2a7 100644 --- a/crates/chart/src/lib.rs +++ b/crates/chart/src/lib.rs @@ -124,6 +124,9 @@ impl Measure for ApproxMeasure { pub struct ChartData { pub categories: Vec, pub series: Vec, + /// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme. + #[serde(default)] + pub palette: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 63d30d9..6874cc2 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -129,7 +129,7 @@ fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { fn point_color(data: &ChartData, j: usize) -> Rgba { match (j, data.series.first()) { (0, Some(s)) => s.color, - _ => series_color(&Theme::default(), j), + _ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)), } } @@ -190,6 +190,7 @@ fn sanitize_vals(v: &[Option]) -> Vec> { fn sanitize(d: &ChartData) -> ChartData { ChartData { + palette: d.palette.iter().take(MAX_POINTS).copied().collect(), categories: d.categories.iter().take(MAX_POINTS).cloned().collect(), series: d .series diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 2c17eff..e059103 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -16,6 +16,8 @@ pub(crate) const MAX_TRENDLINES: usize = 16; /// Tints used for series beyond the six theme accents: lighter, darker, lighter still… const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2]; +/// `series_color` repeats after this many colours (six accents × the tints). +pub(crate) const PALETTE_CYCLE: usize = 6 * CYCLE_TINTS.len(); /// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them. pub fn series_color(theme: &Theme, i: usize) -> Rgba { @@ -239,5 +241,6 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { } let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0); let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect()); - ChartData { categories, series } + let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE)); + ChartData { categories, series, palette } } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a942677..f283f8d 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData { let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2); b.secondary = kind == ChartKind::Combo; ChartData { + palette: vec![], categories: cats.iter().map(|s| s.to_string()).collect(), series: vec![ series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1), @@ -362,7 +363,7 @@ fn scatter_with_text_x_values_numbers_the_points() { let d = resolve(&wb, 0, &c); assert_eq!(d.series[0].x, None, "{kind:?}"); let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); - let empty = render(&c, &ChartData { categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); + let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points"); // Numeric X values are kept. c.series[0].categories = Some("Sheet1!$C$1:$C$3".into()); @@ -445,7 +446,11 @@ fn pie_starts_at_twelve_clockwise() { #[test] fn negative_values_put_axis_below_zero() { let c = chart(ChartKind::ColumnClustered); - let d = ChartData { categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] }; + let d = ChartData { + palette: vec![], + categories: vec![], + series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)], + }; let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); let t = texts(&prims); assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}"); @@ -456,8 +461,13 @@ fn negative_values_put_axis_below_zero() { fn edge_cases_never_panic() { let m = ApproxMeasure; let empty = ChartData::default(); - let zeros = ChartData { categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)] }; + let zeros = ChartData { + palette: vec![], + categories: vec!["a".into(), "b".into()], + series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)], + }; let weird = ChartData { + palette: vec![], categories: vec![String::new(); 3], series: vec![SeriesData { name: "w".repeat(500), @@ -488,7 +498,7 @@ fn thousands_of_points_are_decimated() { let vals: Vec = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect(); let mut c = chart(ChartKind::Line); c.data_labels = true; - let d = ChartData { categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure); let max_line = prims .iter() @@ -513,7 +523,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = smooth; - let d = ChartData { categories: cats.clone(), series: vec![s] }; + let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] }; render(&chart(ChartKind::Line), &d, 600.0, 400.0, m) .iter() .filter_map(|p| match p { @@ -530,7 +540,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { // Rendering either way must stay finite and inside the canvas. let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; - let d = ChartData { categories: cats, series: vec![s] }; + let d = ChartData { palette: vec![], categories: cats, series: vec![s] }; assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0); } @@ -540,7 +550,7 @@ fn smooth_line_splits_at_gaps() { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; s.values[1] = None; // gap between two data points - let d = ChartData { categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; + let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m); // Two separate runs (before/after the gap) → at least two line primitives. let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count(); @@ -617,7 +627,7 @@ fn fuzz_random_data_and_sizes() { }) .collect(); let ncat = (r.next() % 50) as usize; - let d = ChartData { categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; + let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; let mut c = chart(kind); c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize]; c.data_labels = r.next().is_multiple_of(2); @@ -792,7 +802,7 @@ fn line_chart_draws_trendline_forecast_and_labels() { let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line); s.trendlines = vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }]; - let d = ChartData { categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; + let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; let mut c = chart(ChartKind::Line); c.legend = LegendPos::None; // keeps legend swatches out of the line counts let prims = render(&c, &d, 600.0, 400.0, &m); @@ -816,7 +826,7 @@ fn line_chart_draws_trendline_forecast_and_labels() { assert!(lines_of(&pie, tc).is_empty()); // One point: nothing to fit, nothing drawn. s.values.truncate(1); - let one = ChartData { categories: vec!["a".into()], series: vec![s] }; + let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] }; assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty()); } @@ -836,7 +846,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] }, TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] }, ]; - let d = ChartData { categories: vec![], series: vec![s] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![s] }; let mut c = chart(ChartKind::Scatter); c.legend = LegendPos::None; let prims = render(&c, &d, 600.0, 400.0, &m); @@ -851,6 +861,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { #[test] fn treemap_and_histogram_shapes() { let d = ChartData { + palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)], }; @@ -871,7 +882,7 @@ fn treemap_and_histogram_shapes() { assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}"); let vals: Vec = (0..100).map(|i| i as f64).collect(); - let d = ChartData { categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count(); assert_eq!(bins, 10); @@ -902,3 +913,48 @@ fn union_series_references() { c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000)); let _ = resolve(&wb, 0, &c); } + +#[test] +fn pie_points_follow_workbook_theme() { + let mut wb = Workbook::new(); + wb.theme.colors[4] = 0x4F6D8A; + wb.theme.colors[5] = 0x8AA1B8; + let mut c = chart(ChartKind::Pie); + c.series = vec![Series { + name: None, + categories: None, + values: "{5,3}".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + trendlines: Vec::new(), + }]; + let d = resolve(&wb, 0, &c); + let expected = vec![series_color(&wb.theme, 0), series_color(&wb.theme, 1)]; + assert_eq!(expected, vec![[0x4F, 0x6D, 0x8A, 0xFF], [0x8A, 0xA1, 0xB8, 0xFF]]); + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend, expected); + let fills: Vec = render(&c, &d, 480.0, 288.0, &ApproxMeasure) + .iter() + .filter_map(|p| match p { + Prim::Wedge { fill, .. } => Some(*fill), + _ => None, + }) + .collect(); + assert_eq!(fills, expected); + for kind in [ChartKind::Doughnut, ChartKind::Treemap, ChartKind::Sunburst] { + c.kind = kind; + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend, expected, "{kind:?}"); + } + // Past the 36-colour cycle the points keep following the workbook theme. + c.kind = ChartKind::Pie; + c.series[0].values = format!("{{{}}}", ["1"; 40].join(",")); + let d = resolve(&wb, 0, &c); + let legend: Vec = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend.len(), 40); + assert_eq!(legend.get(37), Some(&series_color(&wb.theme, 37))); + assert_ne!(series_color(&wb.theme, 37), series_color(&gridcraft_model::Theme::default(), 37)); +} From 389c539da4f12e8c26fa10e198abd7df8c7056a6 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:14 +0200 Subject: [PATCH 10/62] Honour the gridlines flag on box-and-whisker and stock charts (#543) --- crates/chart/src/render.rs | 12 ++++++------ crates/chart/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 63d30d9..0e4ebfd 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -266,8 +266,8 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area), Histogram => histogram(ctx, chart, data, area), Waterfall => waterfall(ctx, chart, data, area), - BoxWhisker => box_whisker(ctx, data, area), - Stock => stock(ctx, data, area), + BoxWhisker => box_whisker(ctx, chart, data, area), + Stock => stock(ctx, chart, data, area), _ => cartesian(ctx, chart, data, area, false), } } @@ -1557,12 +1557,12 @@ fn quantile(sorted: &[f64], q: f64) -> f64 { a + (b - a) * frac } -fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } @@ -1601,7 +1601,7 @@ fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) { // ---------------------------------------------------------------- stock -fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) { +fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); if ncat == 0 { return; @@ -1610,7 +1610,7 @@ fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) { let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index a942677..db821b7 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -902,3 +902,24 @@ fn union_series_references() { c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000)); let _ = resolve(&wb, 0, &c); } + +#[test] +fn box_and_stock_charts_honour_the_gridlines_flag() { + // Horizontal full-width lines in the gridline colour are the value-axis gridlines. + let grid: Rgba = [217, 217, 217, 255]; + let is_grid = |p: &Prim| match p { + Prim::Line { color, pts, .. } => *color == grid && pts.len() == 2 && pts[0][1] == pts[1][1] && pts[1][0] - pts[0][0] > 200.0, + _ => false, + }; + let grids = |prims: &[Prim]| prims.iter().filter(|p| is_grid(p)).count(); + for kind in [ChartKind::BoxWhisker, ChartKind::Stock, ChartKind::ColumnClustered] { + let mut d = sample(kind); + d.series.push(series("Close", &[10000.0, 12000.0, 9000.0, 14000.0, 20000.0, 16000.0], [90, 90, 90, 255], kind)); + let mut c = chart(kind); + let on = render(&c, &d, 500.0, 350.0, &ApproxMeasure); + c.gridlines = false; + let off = render(&c, &d, 500.0, 350.0, &ApproxMeasure); + assert!(grids(&on) > 0, "{kind:?}: gridlines on must draw lines"); + assert_eq!(grids(&off), 0, "{kind:?}: gridlines off must hide them"); + } +} From 3ceb50f4043a1a5a01a371b4d1c1b0b2134eb6ee Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:34 +0200 Subject: [PATCH 11/62] Keep a solid data bar solid across an XLSX save and reopen (#550) --- crates/xlsx/src/read.rs | 3 ++ crates/xlsx/src/sheet_read.rs | 28 ++++++++++++- crates/xlsx/src/sheet_write.rs | 67 +++++++++++++++++++++++++++--- crates/xlsx/src/tests/roundtrip.rs | 28 +++++++++++++ 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index a25444d..9b16fef 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -28,6 +28,8 @@ pub struct Ctx<'a> { pub num_fmts: std::collections::HashMap, /// Value metadata blocks, indexed by the worksheet's one-based `vm` attribute. pub cell_pictures: Vec>>, + /// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`. + pub cf_ext_ids: Vec<(String, usize)>, } impl Ctx<'_> { @@ -79,6 +81,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { next_id: 1, num_fmts: Default::default(), cell_pictures: vec![], + cf_ext_ids: vec![], }; let root_rels = cx.pkg.rels("")?; let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into()); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 77b46d9..a457ffd 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -60,6 +60,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result { @@ -596,6 +597,12 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { let formulas: Vec = r.kids("formula").map(|f| from_file(f.text.trim())).collect(); let f0 = formulas.first().cloned().unwrap_or_default(); let text = r.attr("text").unwrap_or("").to_string(); + // The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there). + let ext_id = r + .child("extLst") + .and_then(|l| l.kids("ext").find_map(|x| x.child("id"))) + .map(|i| i.text.trim().to_string()) + .filter(|i| !i.is_empty()); let rule = match r.attr("type").unwrap_or("") { "cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style }, "expression" => CfRule::Expression { formula: f0, style }, @@ -649,6 +656,9 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { continue; } }; + if let Some(id) = ext_id { + cx.cf_ext_ids.push((id, sheet.cond_formats.len())); + } sheet.cond_formats.push(CondFormat { ranges: ranges.clone(), rule, @@ -953,7 +963,23 @@ fn read_ext(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { } } } - "conditionalFormattings" => cx.warn("some Excel 2010 conditional format extensions were skipped"), + "conditionalFormattings" => { + let mut skipped = false; + for r in c.kids("conditionalFormatting").flat_map(|f| f.kids("cfRule")) { + let idx = r.attr("id").and_then(|id| cx.cf_ext_ids.iter().find(|(i, _)| i == id)).map(|(_, n)| *n); + match (idx.and_then(|n| sheet.cond_formats.get_mut(n)), r.child("dataBar")) { + (Some(CondFormat { rule: CfRule::DataBar { gradient, .. }, .. }), Some(db)) => { + *gradient = db.flag("gradient", true); + // Only the fill style is modelled; borders, lengths, axis or negative colours are lost. + skipped |= db.attrs.iter().any(|(k, _)| k != "gradient") || db.children.iter().any(|k| k.name != "cfvo"); + } + _ => skipped = true, + } + } + if skipped { + cx.warn("some Excel 2010 conditional format extensions were skipped"); + } + } _ => {} } } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 0bfcb7d..8637c43 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -573,9 +573,33 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( } let _ = write!(s, "{parts}", tables.len()); } - // Sparklines (Excel 2010 extension). + // Excel 2010 extensions: solid data bars, then sparklines. + let solid_bars: Vec<(&CondFormat, u32)> = sheet + .cond_formats + .iter() + .enumerate() + .filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. })) + .map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1))) + .collect(); + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); + } + if !solid_bars.is_empty() { + let _ = write!( + s, + "" + ); + for (cf, p) in &solid_bars { + if let CfRule::DataBar { min, max, .. } = &cf.rule { + let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max)); + let _ = write!(s, "{lo}{hi}"); + let _ = write!(s, "{}", sqref(&cf.ranges)); + } + } + s.push_str(""); + } if !sheet.sparklines.is_empty() { - s.push_str(""); + let _ = write!(s, ""); for sp in &sheet.sparklines { let ty = match sp.kind { SparklineKind::Line => "", @@ -593,7 +617,10 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( sp.cell.a1() ); } - s.push_str(""); + s.push_str(""); + } + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); } s.push_str(""); (s, rels) @@ -714,6 +741,33 @@ fn quote(t: &str) -> String { format!("\"{}\"", t.replace('"', "\"\"")) } +const X14_NS: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; +const XM_NS: &str = "http://schemas.microsoft.com/office/excel/2006/main"; + +/// The id linking a solid data bar's `cfRule` to its x14 extension; unique per sheet because priorities are. +fn solid_bar_id(priority: u32) -> String { + format!("{{00000000-0000-0000-0000-{priority:012}}}") +} + +/// The `cfRule` extension that points a solid data bar at its x14 rule. +fn solid_bar_ext(priority: u32) -> String { + let id = solid_bar_id(priority); + format!("{id}") +} + +/// An x14 `cfvo`: unlike the 2006 one it has no `val` attribute, the value is an `xm:f` child. +fn x14_cfvo(v: &CfValueKind) -> String { + let (ty, val) = match v { + CfValueKind::Min => return "".into(), + CfValueKind::Max => return "".into(), + CfValueKind::Number(n) => ("num", num(*n)), + CfValueKind::Percent(n) => ("percent", num(*n)), + CfValueKind::Percentile(n) => ("percentile", num(*n)), + CfValueKind::Formula(f) => ("formula", text_to_file(f)), + }; + format!("{}", esc(&val)) +} + fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; @@ -809,12 +863,13 @@ fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { b.push_str(""); format!("{b}") } - CfRule::DataBar { min, max, color, show_value, .. } => format!( - "{}{}", + CfRule::DataBar { min, max, color, show_value, gradient } => format!( + "{}{}{}", if *show_value { "" } else { " showValue=\"0\"" }, cfvo_xml(min), cfvo_xml(max), - if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) } + if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) }, + if *gradient { String::new() } else { solid_bar_ext(priority) } ), CfRule::IconSet { set, thresholds, reverse, show_value } => { let mut b = format!("9"), "{xml}"); + let (back, rep) = read_xlsx(&bytes).unwrap(); + assert!(rep.warnings.is_empty(), "{:?}", rep.warnings); + let b = &back.sheets[0]; + assert_eq!(b.cond_formats, wb.sheets[0].cond_formats); + assert_eq!(b.sparklines.len(), 1); +} From 7d9c0a4dd9f355d2c044fdb50da2aaf4f9e771e8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:33:06 +0200 Subject: [PATCH 12/62] Center Across Selection: center text over the empty cells to its right (#479) --- crates/engine/src/cmd/print.rs | 41 +++++++++++++++++++++++++++++++--- crates/ui-egui/src/grid.rs | 39 ++++++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 986351e..0581261 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1027,6 +1027,21 @@ fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool { sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c)) } +/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too. +fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 { + let mut ext = 0.0; + let mut col = c.col + 1; + while col < MAX_COLS && ext < 20_000.0 { + let nc = CellRef::new(c.row, col); + if !empty_for_overflow(sh, nc, merges) || styles.get(sh.style_id(nc)).align.h != HAlign::CenterAcross { + break; + } + ext += sh.col_width(col); + col += 1; + } + ext +} + fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f32, f32), merged: bool, merges: &[RangeRef], limits: (f32, f32)) { let (x, y, w, h) = rect; let sh = b.sh; @@ -1110,8 +1125,11 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h, }; // Overflow into empty neighbours (single-line text, not merged). - let (mut clip_l, mut clip_r) = (x, x + w); - if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w { + // Center Across Selection spans the empty Center Across cells to its right; the text stays within that run. + let spans = st.align.h == HAlign::CenterAcross && !wrap && !merged && rot == 0; + let across = if spans { center_across_width(&wb.styles, sh, c, merges) * b.k } else { 0.0 }; + let (mut clip_l, mut clip_r) = (x, x + w + across); + if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w { let need = tw + 2.0 * pad + indent - w; let grow = |dir: i64, mut need: f32| -> f32 { let mut ext = 0.0; @@ -1183,7 +1201,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 let lw = gridcraft_pdf::text_width(font, size, line); let lx = match h_align { HAlign::Right => x + w - pad - lw - indent, - HAlign::Center => x + (w - lw) / 2.0, + HAlign::Center => x + (w + across - lw) / 2.0, _ => x + pad + indent, }; let ly = first_baseline + i as f32 * line_h; @@ -2063,6 +2081,23 @@ mod tests { assert!(w.len() > 2); } + #[test] + fn center_across_spans_following_empty_center_across_cells() { + let mut styles = StyleTable::default(); + let across = styles.intern(Style { align: Alignment { h: HAlign::CenterAcross, ..Alignment::default() }, ..Style::default() }); + let mut sh = Sheet::new("S"); + for col in 0..3 { + sh.set_style(CellRef::new(0, col), across); + } + sh.set_value(CellRef::new(0, 0), Value::Text("Title".into())); + let a1 = CellRef::new(0, 0); + assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1) + sh.col_width(2)); + sh.set_value(CellRef::new(0, 2), Value::Text("x".into())); + assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1)); + let merged = [RangeRef::new(CellRef::new(0, 1), CellRef::new(1, 1))]; + assert_eq!(center_across_width(&styles, &sh, a1, &merged), 0.0); + } + #[test] fn rotated_wrapped_text_prints_rotated() { // Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text. diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..e6399e0 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -368,6 +368,15 @@ fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 { } } +/// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds. +fn center_across_end(start: u32, limit: u32, continues: impl Fn(u32) -> bool) -> u32 { + let mut end = start; + while end < limit && end + 1 < MAX_COLS && continues(end + 1) { + end += 1; + } + end +} + fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (Look, Option) { let mut style = wb.styles.get(sh.style_id(c)).clone(); if let Some(t) = sh.table_at(c) { @@ -1035,17 +1044,25 @@ pub(crate) fn paint_quadrant( galley = p.layout_no_wrap(s, font.clone(), color); w = galley.size().x; } + // Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it. + let across_end = if halign == HAlign::CenterAcross && !rotated && in_merge(c).is_none() { + center_across_end(c.col, c1 + 20, |col| { + let n = CellRef::new(c.row, col); + empty_ok(n) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross + }) + } else { + c.col + }; // Overflow into empty neighbours for text (not upright text, whose width is its line height). let mut clip = *rect; - if filter_space == 0.0 - && !numeric - && !matches!(v, Value::Number(_)) - && st.align.rotation.abs() != 90 - && w > rect.width() - 2.0 * pad - && in_merge(c).is_none() + let overflows = !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad; + if filter_space == 0.0 && (overflows || across_end > c.col) && in_merge(c).is_none() { let mut right = rect.right(); let mut left = rect.left(); + if across_end > c.col { + right = geo.x(sh, across_end + 1); + } if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { let mut cc = c.col + 1; while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 { @@ -1075,7 +1092,8 @@ pub(crate) fn paint_quadrant( } } let x = match halign { - HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - w / 2.0, + HAlign::CenterAcross => clip.center().x - w / 2.0, + HAlign::Center | HAlign::Distributed => rect.center().x - w / 2.0, HAlign::Right => rect.right() - pad - w - indent, _ => text_left + pad + indent, }; @@ -3053,6 +3071,13 @@ mod selection_fill_tests { mod tests { use super::*; + #[test] + fn center_across_run_stops_at_first_non_continuing_column() { + assert_eq!(center_across_end(0, 20, |c| c <= 2), 2); + assert_eq!(center_across_end(0, 20, |_| false), 0); + assert_eq!(center_across_end(0, 1, |_| true), 1); + } + #[test] fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); From 0514e537ac5608fafad3579345b002f01fb39109 Mon Sep 17 00:00:00 2001 From: derechopiano <257026466+derechopiano@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:56:52 +0100 Subject: [PATCH 13/62] #441: Open AutoFilter header menus with Alt+Down --- crates/ui-egui/src/keymap.rs | 3 + crates/ui-egui/tests/keyboard_shortcuts.rs | 65 ++++++++++++++++++++++ docs/ui-parity.md | 4 +- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 21bf41f..94f6d30 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -218,6 +218,9 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic Shortcut::DropDown => { if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) { app.grid.list_picker = Some(active); + } else if sh.has_filter_button(active) { + let at = geo.mirror_pos(crate::grid::filter_button_rect(geo, sh, active).center()); + app.grid.filter_menu = Some((active.col, at)); } } Shortcut::Zoom(step) => { diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index 82d3f2f..ae0ce5c 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -149,3 +149,68 @@ fn space_shortcuts_per_platform() { // Cmd+Space is Spotlight on macOS. assert_eq!(space(mac_cmd, true), None); } + +#[test] +fn alt_down_opens_autofilter_headers_without_changing_the_workbook() { + for rtl in [false, true] { + for cell in ["A1", "B1"] { + let mut session = book(cell); + session.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + session.execute("view.rightToLeft", json!({"on": rtl})).unwrap(); + let mut h = harness(session); + let before = h.state().session.active().unwrap().wb.clone(); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + let (column, at) = h.state().grid.filter_menu.expect("Alt+Down must open the filter header menu"); + let d = h.state().session.active().unwrap(); + let active = CellRef::parse(cell).unwrap(); + assert_eq!(column, active.col); + assert_eq!(d.selection.active, active); + assert_eq!(d.wb, before); + let sh = d.wb.active().unwrap(); + let geo = gridcraft_ui_egui::grid::Geo::new(sh, h.state().grid.rect.unwrap(), h.state().view().scroll); + assert_eq!(at, geo.mirror_pos(gridcraft_ui_egui::grid::filter_button_rect(&geo, sh, active).center())); + // Opening focuses the existing search box; typing must not edit the header. + h.input_mut().events.push(Event::Text("1".into())); + h.run_steps(2); + assert_eq!(h.ctx.data(|data| data.get_temp::(egui::Id::new(("filter_search", column)))).as_deref(), Some("1")); + assert!(h.state().editor.is_none()); + assert_eq!(h.state().session.active().unwrap().wb, before); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().grid.filter_menu.is_none()); + } + } +} + +#[test] +fn alt_down_keeps_validation_priority_and_plain_down_navigation() { + let mut session = book("A1"); + session.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + session.execute("data.validation", json!({"range": "A1", "type": "list", "formula1": "\"First,Second\""})).unwrap(); + let mut h = harness(session); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert_eq!(h.state().grid.list_picker, Some(CellRef::parse("A1").unwrap())); + assert!(h.state().grid.filter_menu.is_none()); + key(&mut h, Key::Escape, Modifiers::NONE); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2"); + assert!(h.state().grid.filter_menu.is_none()); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert!(h.state().grid.filter_menu.is_none(), "a data cell has no filter button"); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2"); +} + +#[test] +fn alt_down_only_opens_enabled_table_filter_buttons() { + for enabled in [false, true] { + let mut session = book("B1"); + session.execute("insert.table", json!({"range": "A1:B3"})).unwrap(); + if !enabled { + session.execute("table.headerRow", json!({"on": false})).unwrap(); + } + session.execute("selection.set", json!({"cell": "B1"})).unwrap(); + let mut h = harness(session); + key(&mut h, Key::ArrowDown, Modifiers::ALT); + assert_eq!(h.state().grid.filter_menu.map(|(column, _)| column), enabled.then_some(1)); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..940305f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Alt+Down opens AutoFilter header menus) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -117,6 +117,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Ctrl+Alt+V | Cmd+Control+V, Cmd+Option+V | Paste Special | **added** | | Ctrl+Alt+= / Ctrl+Alt+- | — | zoom in / out (10% steps) | **added** | | Alt+=, Alt+Down, Alt+Page Down / Up | Option+Down, Option+Page Down / Up | AutoSum, drop-down list, one screen right / left | **added** | +| Alt+Down on a filter header | Option+Down | Open the existing AutoFilter popup; validation dropdowns keep priority | **added** (AutoFilter portion of #441; Ribbon dropdowns and column value picker are separate work) | | Alt+F1, Alt+Shift+F1, Shift+F11 | Shift+F11 | embedded chart, new sheet | **added** | | Shift+F2, Ctrl+Shift+F2 | Shift+F2, Cmd+Shift+F2 | note, threaded comment | **added** | | Shift+F3, Ctrl+F3, Ctrl+Shift+F3 | Shift+F3, Cmd+F3 (Define Name), Cmd+Shift+F3 | Insert Function, Name Manager, Create from Selection | **added** | @@ -160,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | From f7f04df4d79f7aba84a35a5033e489ef4a53c727 Mon Sep 17 00:00:00 2001 From: derechopiano <257026466+derechopiano@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:55:18 +0100 Subject: [PATCH 14/62] #681: Reveal the selected worksheet tab after navigation --- crates/ui-egui/src/tabs.rs | 12 ++ crates/ui-egui/tests/sheet_tabs.rs | 176 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 3 +- docs/ui-parity.md | 4 +- 4 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/sheet_tabs.rs diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index e55f575..5bd5456 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -40,6 +40,15 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // Keep worksheet tabs in their own horizontally scrollable // viewport, leaving room for the New Sheet button and grid scrollbar. let tabs_width = (ui.available_width() - 180.0).max(100.0); + // Reveal the selected sheet after navigation or layout changes, but + // allow browsing other tabs without snapping back on every frame. + let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); + let reveal_id = ui.id().with("worksheet_tabs_reveal"); + let reveal_active = ui.ctx().data_mut(|data| { + let changed = data.get_temp::(reveal_id) != Some(reveal_key); + data.insert_temp(reveal_id, reveal_key); + changed + }); ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { // Translate vertical wheel movement into horizontal // worksheet-tab scrolling. @@ -68,6 +77,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x; let w = (tw + 32.0).max(72.0); let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); + if active && reveal_active { + resp.scroll_to_me(None); + } let r = r.shrink2(vec2(0.0, 1.0)); if active { ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active); diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs new file mode 100644 index 0000000..966ea60 --- /dev/null +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -0,0 +1,176 @@ +//! Overflowing worksheet tabs, exercised through their actual painted geometry and input. +use egui::{Color32, Event, Modifiers, PointerButton, Rect, Shape, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(width: f32, active: usize) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap(); + for i in 1..30 { + s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap(); + } + s.execute("sheet.activate", json!({"sheet":active})).unwrap(); + let mut fonts_ready = false; + let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state( + move |ui, app: &mut SheetApp| { + if !fonts_ready { + ui.ctx().set_fonts(gridcraft_ui_egui::theme::font_definitions()); + fonts_ready = true; + return; + } + app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0))); + gridcraft_ui_egui::tabs::sheet_tabs(app, ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(20); + h +} + +fn tab(h: &Harness, name: &str) -> Option<(Rect, Rect)> { + h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == name => Some((t.visual_bounding_rect(), s.clip_rect)), + _ => None, + }) +} + +fn assert_visible(h: &Harness, name: &str) { + let (text, clip) = tab(h, name).unwrap_or_else(|| panic!("{name} was not painted in the tab viewport")); + assert!(clip.contains_rect(text), "{name} is clipped: text={text:?}, viewport={clip:?}"); +} + +fn click(h: &mut Harness, at: egui::Pos2) { + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); +} + +#[test] +fn opening_and_activating_late_sheets_reveals_their_tabs() { + for width in [500.0, 1000.0] { + let mut h = harness(width, 29); + assert_visible(&h, "Tab29"); + h.state_mut().run("sheet.activate", json!({"sheet":0})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab00"); + h.state_mut().run("sheet.activate", json!({"sheet":20})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab20"); + } +} + +#[test] +fn navigation_arrows_reveal_the_selected_tab() { + let mut h = harness(700.0, 0); + let (_, clip) = tab(&h, "Tab00").unwrap(); + let next = pos2(clip.left() - 20.0, clip.center().y); + for _ in 0..29 { + click(&mut h, next); + } + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); + assert_visible(&h, "Tab29"); +} + +#[test] +fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() { + for delta in [vec2(0.0, -350.0), vec2(-350.0, 0.0)] { + let mut h = harness(700.0, 0); + let before = h.state().session.doc().unwrap().wb.clone(); + let (_, clip) = tab(&h, "Tab00").unwrap(); + h.input_mut().events.extend([ + Event::PointerMoved(clip.center()), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::Start, + delta: vec2(0.0, 0.0), + modifiers: Modifiers::NONE, + }, + ]); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::Move, + delta, + modifiers: Modifiers::NONE, + }); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::End, + delta: vec2(0.0, 0.0), + modifiers: Modifiers::NONE, + }); + h.run_steps(40); + assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "gesture should browse away from the selected tab: {delta:?}"); + assert_eq!(h.state().session.doc().unwrap().wb, before, "browsing tabs must not edit the workbook"); + h.run_steps(40); + assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "idle frames must not snap back"); + h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab29"); + } +} +#[test] +fn hidden_sheets_long_names_and_window_resize_keep_active_tab_visible() { + let mut h = harness(1000.0, 0); + for sheet in 1..10 { + h.state_mut().run("sheet.hide", json!({"sheet":sheet})).unwrap(); + } + let name = "Quarterly results and forecasts"; + h.state_mut().run("sheet.rename", json!({"sheet":29,"name":name})).unwrap(); + h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap(); + h.run_steps(20); + assert_visible(&h, name); + h.set_size(vec2(500.0, 120.0)); + h.run_steps(20); + assert_visible(&h, name); + h.set_size(vec2(1000.0, 120.0)); + h.run_steps(20); + assert_visible(&h, name); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); +} + +#[test] +fn new_sheet_button_stays_reachable_and_reveals_the_inserted_tab() { + let mut h = harness(700.0, 29); + let (_, clip) = tab(&h, "Tab29").unwrap(); + click(&mut h, pos2(clip.right() + 18.0, clip.center().y)); + let d = h.state().session.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 31); + let name = d.wb.active().unwrap().name.clone(); + assert_visible(&h, &name); +} + +#[test] +fn overflow_leaves_the_worksheet_scrollbar_reachable() { + for width in [500.0, 700.0, 1200.0] { + let mut h = harness(width, 0); + let (bar, clip) = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Rect(r) if r.fill == Color32::from_gray(190) && r.rect.height() == 8.0 => Some((r.rect, s.clip_rect)), + _ => None, + }) + .expect("worksheet scrollbar thumb must be painted"); + assert!(clip.contains_rect(bar), "worksheet scrollbar is offscreen: {bar:?}, {clip:?}"); + let at = bar.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerMoved(at + vec2(12.0, 0.0))); + h.step(); + assert!(h.state().view().scroll.x > 0.0, "worksheet scrollbar must respond to dragging"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8b82092..484de9e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (stability: command-sequence fuzzing against undo/redo, XLSX round-trip and full-recalc invariants) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (sheet-tab navigation reveals the selected tab without interrupting manual scrolling) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | | 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h | | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..37fd5b2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (sheet-tab navigation reveals the selected tab without interrupting manual scrolling) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | -| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | rename focus bug on macOS (#145) | +| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | From 3830cee850cb9f795a53961b04b278c4822c7823 Mon Sep 17 00:00:00 2001 From: Vedran Dobos <19259909+vedran-dobos@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:05:49 +0200 Subject: [PATCH 15/62] View: Ctrl+wheel zoom steps along a ladder that never skips 100% (#689) Each frame multiplied the whole-number zoom by egui's eased wheel factor and rounded, so a notch out never undid a notch in: 100 -> 122 -> 101 -> 123 -> 102. A notch now moves to the next step on WHEEL_ZOOM_LADDER (10 ... 85, 100, 120 ... 400; four per doubling, about 20% apart). A step less than 5% away is skipped, but 100% and the limits never are, so 121% out goes to 100% and 99% in goes to 100%. The wheel's turning is counted in notches: parts of a notch add up, and a notch steps once 60% of it has turned. With animations on the zoom glides to the step, and a notch turned during a glide steps on from where it's going. The status bar's Zoom In / Zoom Out keep their 10% steps; that, and whether the glide needs a setting of its own, is noted in docs/decisions.md for the maintainers. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 173 ++++++++++++++++++++++++++- crates/ui-egui/tests/wheel_zoom.rs | 186 +++++++++++++++++++++++++++++ docs/decisions.md | 24 ++++ docs/ui-parity.md | 3 +- 4 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/wheel_zoom.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..84f5b6f 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -20,6 +20,42 @@ pub const COL_HEADER_H: f32 = 21.0; /// which matches desktop spreadsheet density (a default 20 px row is 16 pt tall). pub const DISPLAY_SCALE: f32 = 0.8; +/// The zoom percentages a Ctrl+wheel notch steps between: four per doubling (about 20% apart), +/// so 25, 50, 100, 200 and 400 are on it. Excel steps 15 points a notch instead; the status bar's +/// Zoom In / Zoom Out buttons keep their 10% steps. +pub const WHEEL_ZOOM_LADDER: [u16; 22] = [10, 12, 15, 18, 21, 25, 30, 35, 42, 50, 60, 70, 85, 100, 120, 140, 170, 200, 240, 280, 340, 400]; + +/// Seconds without Ctrl+wheel input after which part of a notch turned so far is forgotten. +const WHEEL_ZOOM_IDLE: f64 = 0.75; + +/// The zoom one Ctrl+wheel notch goes to from `zoom`: the next step on [`WHEEL_ZOOM_LADDER`] in +/// that direction, skipping a step less than 5% away (121% out goes to 100%, not 120%), but never +/// 100% or the 10% / 400% limits (99% or 101% towards 100% goes to 100%). +pub fn wheel_zoom_step(zoom: u16, zoom_in: bool) -> u16 { + let zoom = zoom.clamp(10, 400); + let z = u32::from(zoom); + let far = |l: u32| if zoom_in { l * 100 >= z * 105 } else { l * 105 <= z * 100 }; + let mut ahead = WHEEL_ZOOM_LADDER.iter().copied().filter(|&l| if zoom_in { l > zoom } else { l < zoom }).collect::>(); + if !zoom_in { + ahead.reverse(); + } + let limit = ahead.last().copied().unwrap_or(zoom); + ahead.into_iter().find(|&l| l == 100 || far(u32::from(l))).unwrap_or(limit) +} + +/// One frame of the zoom gliding from `from` to `to` percent: the share `t` (0–1) of the way, +/// measured as a ratio so zooming in and out feel alike, and at least one percent, so it arrives. +pub fn zoom_glide_step(from: u16, to: u16, t: f32) -> u16 { + let (f, g) = (f32::from(from.max(1)), f32::from(to.max(1))); + let next = (f * (g / f).powf(t.clamp(0.0, 1.0))).round(); + let next = if next.is_finite() { next.clamp(f32::from(from.min(to)), f32::from(from.max(to))) as u16 } else { to }; + match (next == from, from.cmp(&to)) { + (true, std::cmp::Ordering::Less) => from + 1, + (true, std::cmp::Ordering::Greater) => from - 1, + _ => next, + } +} + #[derive(Clone, Debug, Default, PartialEq)] pub enum Drag { #[default] @@ -104,6 +140,34 @@ pub struct GridState { pub hf_start: bool, /// The grid geometry of the last frame (hit-testing for agents and tests). pub geo: Option, + /// Ctrl+wheel zoom in progress (see [`WheelZoom`]). + pub wheel_zoom: Option, +} + +/// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and +/// fine-grained wheels send parts of one, so the turning is counted in notches, and each whole +/// notch steps [`WHEEL_ZOOM_LADDER`]. With animations on the zoom then glides to that step. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WheelZoom { + pub place: (u64, usize), + /// Turned so far towards the next step, in notches (+ in, − out). + pub notches: f32, + /// When `notches` last changed. + pub time: f64, + /// The step the zoom is gliding to, and the percentage set last on the way. + pub glide: Option<(u16, u16)>, +} + +impl WheelZoom { + /// The state for `place` showing `zoom` percent: fresh for another document or sheet, and + /// without its glide if the zoom was changed some other way since the glide set it. + fn at(app: &SheetApp, place: (u64, usize), zoom: u16, now: f64) -> WheelZoom { + let mut w = app.grid.wheel_zoom.filter(|w| w.place == place).unwrap_or(WheelZoom { place, notches: 0.0, time: now, glide: None }); + if w.glide.is_some_and(|(_, shown)| shown != zoom) { + w.glide = None; + } + w + } } impl GridState { @@ -502,8 +566,34 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { if zoom_delta != 1.0 { - let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round(); - let _ = app.session.run("view.zoom", json!({"percent": z})); + // egui's zoom factor is e^(speed × points); one notch is `line_scroll_speed` points. + let notch = ui.ctx().options(|o| o.input_options.scroll_zoom_speed * o.input_options.line_scroll_speed); + let turned = zoom_delta.ln() / notch; + let mut zooming = WheelZoom::at(app, place, sh.zoom, now); + if now - zooming.time > WHEEL_ZOOM_IDLE { + zooming.notches = 0.0; + } + if turned.is_finite() { + zooming.notches = (zooming.notches + turned).clamp(-32.0, 32.0); + } + zooming.time = now; + // A notch steps once 60% of it has turned, and the rest of it brings `notches` back to + // about 0. (Past half, so what's left after a step can't step back.) Steps count from + // where a glide is going, not from where it has got to. + let from = zooming.glide.map_or(sh.zoom, |(to, _)| to); + let mut z = from; + while zooming.notches >= 0.6 { + z = wheel_zoom_step(z, true); + zooming.notches -= 1.0; + } + while zooming.notches <= -0.6 { + z = wheel_zoom_step(z, false); + zooming.notches += 1.0; + } + if z != from { + zooming.glide = Some((z, sh.zoom)); + } + app.grid.wheel_zoom = Some(zooming); } else if scroll_delta != egui::Vec2::ZERO { let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; let mut d = scroll_delta; @@ -518,6 +608,22 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { view.scroll -= d / z; } } + // The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes + // there at once without animations (as for the selection outline below: View › Animations + // off, egui's off in the headless harness, or a screenshot on its way). + let mut zooming = WheelZoom::at(app, place, sh.zoom, now); + if let Some((to, shown)) = zooming.glide { + let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(); + let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1)); + let z = if glides { zoom_glide_step(shown, to, share) } else { to }; + let _ = app.session.run("view.zoom", json!({"percent": z})); + zooming.glide = (z != to).then_some((to, z)); + ui.ctx().request_repaint(); + } + // Store a glide's progress, or that it stopped. + if app.grid.wheel_zoom.is_some_and(|w| w.glide.is_some()) { + app.grid.wheel_zoom = Some(zooming); + } view.scroll = view.scroll.max(egui::Vec2::ZERO); let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32); if paper { @@ -3053,6 +3159,69 @@ mod selection_fill_tests { mod tests { use super::*; + #[test] + fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { + for (from, zoom_in, to) in [ + (100, true, 120), + (120, false, 100), + (100, false, 85), + // Between steps, the next one each way (Excel: 112% goes to 115% or 100%). + (112, true, 120), + (112, false, 100), + // A step less than 5% away is skipped… + (121, false, 100), + (86, false, 70), + (139, true, 170), + // …but never 100% or a limit. + (99, true, 100), + (101, false, 100), + (96, true, 100), + (385, true, 400), + (11, false, 10), + (400, true, 400), + (10, false, 10), + // Out-of-range zooms (from a file) come into range first. + (5, true, 12), + (1000, false, 340), + (0, false, 10), + (u16::MAX, true, 400), + ] { + assert_eq!(wheel_zoom_step(from, zoom_in), to, "{from}% {}", if zoom_in { "in" } else { "out" }); + } + // Every step on the ladder is 15–25% from the next, and a notch in then out returns. + // (The glide between steps is `zoom_glide_moves_by_ratio_and_always_arrives`.) + for w in WHEEL_ZOOM_LADDER.windows(2) { + let ratio = f32::from(w[1]) / f32::from(w[0]); + assert!((1.15..=1.25).contains(&ratio), "{w:?}"); + assert_eq!(wheel_zoom_step(w[0], true), w[1]); + assert_eq!(wheel_zoom_step(w[1], false), w[0]); + } + } + + #[test] + fn zoom_glide_moves_by_ratio_and_always_arrives() { + // A third of the way per frame, as at 60 frames a second. + for (from, to) in [(100, 120), (120, 100), (10, 12), (340, 400), (400, 10), (85, 100)] { + let (mut z, mut frames) = (from, 0); + while z != to { + let next = zoom_glide_step(z, to, 0.32); + assert!(next != z && next.abs_diff(to) < z.abs_diff(to), "{from} → {to}: {z} then {next}"); + z = next; + frames += 1; + assert!(frames < 40, "{from} → {to} arrives"); + } + assert!(frames >= 2, "{from} → {to} glides over more than one frame ({frames})"); + } + assert_eq!(zoom_glide_step(100, 120, 0.32), 106, "a third of the way by ratio"); + assert_eq!(zoom_glide_step(120, 100, 0.32), 113); + // No progress asked or nonsense: still a percent closer, or straight there. + assert_eq!(zoom_glide_step(100, 120, 0.0), 101); + assert_eq!(zoom_glide_step(120, 100, f32::NAN), 100); + assert_eq!(zoom_glide_step(100, 120, 5.0), 120); + assert_eq!(zoom_glide_step(120, 120, 0.5), 120); + assert_eq!(zoom_glide_step(0, 10, 0.5), 3); + } + #[test] fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); diff --git a/crates/ui-egui/tests/wheel_zoom.rs b/crates/ui-egui/tests/wheel_zoom.rs new file mode 100644 index 0000000..397fb6b --- /dev/null +++ b/crates/ui-egui/tests/wheel_zoom.rs @@ -0,0 +1,186 @@ +//! Ctrl+wheel zoom steps along a ladder of percentages that always comes back to 100%. + +use egui::{Event, Modifiers, MouseWheelUnit, TouchPhase, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + let over_grid = h.state().grid.rect.unwrap().center(); + h.input_mut().events.push(Event::PointerMoved(over_grid)); + h.run_steps(2); + h +} + +fn zoom(h: &Harness) -> u16 { + h.state().session.active().unwrap().wb.active().unwrap().zoom +} + +fn set_zoom(h: &mut Harness, percent: u16) { + h.state_mut().run("view.zoom", json!({"percent": percent})).unwrap(); + h.run_steps(2); +} + +/// Turns the wheel `turns` notches at a time with Ctrl held (up zooms in), each turn `frames` +/// frames after the last, then lets egui's easing play out. +fn wheel_turns(h: &mut Harness, turns: &[f32], frames: usize) { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + for &t in turns { + let delta = vec2(0.0, t); + h.input_mut().events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers: Modifiers::COMMAND }); + h.run_steps(frames); + } + h.run_steps(60); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +fn wheel(h: &mut Harness, notches: f32) { + wheel_turns(h, &[notches], 1); +} + +/// The harness turns egui's animations off (and the zoom glide follows that); this turns them on. +fn animated() -> Harness { + let mut h = harness(); + h.ctx.all_styles_mut(|s| s.animation_time = 0.1); + h.step(); + h +} + +/// Turns the wheel `notches` with Ctrl held and records the zoom over the next `frames` frames. +fn turn_and_watch(h: &mut Harness, notches: f32, frames: usize) -> Vec { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + let delta = vec2(0.0, notches); + h.input_mut().events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers: Modifiers::COMMAND }); + (0..frames) + .map(|_| { + h.step(); + zoom(h) + }) + .collect() +} + +#[test] +fn with_animations_the_zoom_glides_to_the_step() { + let mut h = animated(); + let seen = turn_and_watch(&mut h, 1.0, 40); + assert!(seen.windows(2).all(|w| w[0] <= w[1]), "only ever towards the step: {seen:?}"); + let between = seen.iter().filter(|&&z| z > 100 && z < 120).count(); + assert!(between >= 3, "on its way over several frames: {seen:?}"); + assert_eq!(seen.last(), Some(&120), "and exactly on the step: {seen:?}"); + let seen = turn_and_watch(&mut h, -1.0, 40); + assert!(seen.windows(2).all(|w| w[0] >= w[1]) && seen.iter().any(|&z| z > 100 && z < 120), "{seen:?}"); + assert_eq!(seen.last(), Some(&100), "{seen:?}"); +} + +#[test] +fn a_notch_during_a_glide_steps_on_from_where_it_is_going() { + let mut h = animated(); + let seen = turn_and_watch(&mut h, 1.0, 6); + assert!(seen.last().is_some_and(|&z| z > 100 && z < 120), "still gliding: {seen:?}"); + turn_and_watch(&mut h, 1.0, 60); + assert_eq!(zoom(&h), 140); + let seen = turn_and_watch(&mut h, -1.0, 6); + assert!(seen.last().is_some_and(|&z| z > 120 && z < 140), "{seen:?}"); + turn_and_watch(&mut h, -1.0, 60); + assert_eq!(zoom(&h), 100); +} + +#[test] +fn a_zoom_set_another_way_stops_a_glide() { + let mut h = animated(); + let seen = turn_and_watch(&mut h, 1.0, 6); + assert!(seen.last().is_some_and(|&z| z > 100 && z < 120), "{seen:?}"); + h.state_mut().run("view.zoom", json!({"percent": 150})).unwrap(); + h.run_steps(60); + assert_eq!(zoom(&h), 150); + // Setting the percentage the glide had got to doesn't start it again. + let stopped_at = seen.last().copied().unwrap(); + set_zoom(&mut h, stopped_at); + h.run_steps(60); + assert_eq!(zoom(&h), stopped_at); +} + +#[test] +fn with_view_animations_off_a_notch_steps_at_once() { + let mut h = animated(); + h.state_mut().run("view.animations", json!({"on": false})).unwrap(); + let seen = turn_and_watch(&mut h, 1.0, 40); + assert!(seen.iter().all(|&z| z == 100 || z == 120), "no percentages in between: {seen:?}"); + assert_eq!(seen.last(), Some(&120)); +} + +#[test] +fn a_notch_out_returns_to_the_zoom_before_the_notch_in() { + let mut h = harness(); + assert_eq!(zoom(&h), 100); + // Multiplying the rounded zoom by egui's eased factor every frame drifted: 100 → 122 → 101 → 123 → 102. + for _ in 0..4 { + wheel(&mut h, 1.0); + assert_eq!(zoom(&h), 120); + wheel(&mut h, -1.0); + assert_eq!(zoom(&h), 100); + } + for expect in [120, 140, 170] { + wheel(&mut h, 1.0); + assert_eq!(zoom(&h), expect); + } + wheel(&mut h, -3.0); + assert_eq!(zoom(&h), 100); + wheel(&mut h, -2.0); + assert_eq!(zoom(&h), 70); + // Notches turned quickly, each before the last one has eased in. + wheel_turns(&mut h, &[1.0, 1.0, 1.0, 1.0], 3); + assert_eq!(zoom(&h), 140); + wheel_turns(&mut h, &[-1.0, -1.0], 3); + assert_eq!(zoom(&h), 100); +} + +#[test] +fn parts_of_a_notch_add_up_to_whole_steps() { + // A fine-grained wheel (or a Windows touchpad pinch) sends a notch in pieces, not always evenly. + let mut h = harness(); + wheel_turns(&mut h, &[0.25, 0.25, 0.25], 4); + assert_eq!(zoom(&h), 120, "three quarters of a notch step"); + wheel_turns(&mut h, &[-0.3, -0.4, -0.3], 4); + assert_eq!(zoom(&h), 100); + wheel_turns(&mut h, &[0.25, 0.25, 0.25, 0.25, 0.3, 0.4, 0.3], 4); + assert_eq!(zoom(&h), 140, "one step per notch, however it's divided"); + wheel_turns(&mut h, &[-0.5, -0.25, -0.25, -0.25, -0.25, -0.5], 4); + assert_eq!(zoom(&h), 100); + wheel_turns(&mut h, &[0.5], 4); + assert_eq!(zoom(&h), 100, "half a notch doesn't step"); + // What's left of a notch is forgotten after a pause (`wheel_turns` waits a second) rather + // than adding up with the next turn. + wheel_turns(&mut h, &[0.4], 4); + assert_eq!(zoom(&h), 100); +} + +#[test] +fn from_a_zoom_set_another_way_a_notch_goes_to_the_next_step_or_100() { + let mut h = harness(); + for (from, notches, to) in + [(112, 1.0, 120), (112, -1.0, 100), (121, -1.0, 100), (99, 1.0, 100), (101, -1.0, 100), (385, 1.0, 400), (400, 1.0, 400), (11, -1.0, 10)] + { + set_zoom(&mut h, from); + wheel(&mut h, notches); + assert_eq!(zoom(&h), to, "{from}% by {notches}"); + } + // Two notches out from 121%: 100%, then 85%. + set_zoom(&mut h, 121); + wheel(&mut h, -2.0); + assert_eq!(zoom(&h), 85); +} diff --git a/docs/decisions.md b/docs/decisions.md index 209b88d..7b0a99a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -47,3 +47,27 @@ current or preceding frame are protected from eviction. If the visible set excee new images show the existing placeholder until capacity is available; header-based admission avoids repeatedly decoding overflow images. More than 128 small visible pictures now keep stable texture handles instead of cycling through the cache every frame. + +## Ctrl+wheel zoom steps along its own ladder, not Excel's 15-point steps + +A Ctrl+wheel notch moves the zoom to the next step on `grid::WHEEL_ZOOM_LADDER`: 10, 12, 15, 18, +21, 25, 30, 35, 42, 50, 60, 70, 85, 100, 120, 140, 170, 200, 240, 280, 340, 400 (four steps per +doubling, 17–25% apart). A step less than 5% away is skipped, except 100% and the 10% / 400% +limits, which are never skipped: 121% out goes to 100%, and 99% or 101% towards 100% goes to 100%. +The wheel's turning is counted in notches (egui eases each notch in over several frames, and +fine-grained wheels send parts of one); a notch steps once 60% of it has turned. With animations +on, the zoom then glides to the step over about a tenth of a second (as egui eases a scroll notch), +and a notch turned during a glide steps on from the step it's going to. With View › Animations off +it goes to the step at once. Whether the zoom glide should have a setting of its own is left to the +maintainers. + +Before, each frame multiplied the whole-number zoom by egui's eased factor and rounded, so a notch +out never undid a notch in (100 → 122 → 101 → 123 → 102). Keeping the zoom unrounded fixed that +for a wheel with exact notches, but not for one that sends uneven parts of a notch. + +Excel (observed: Microsoft 365, Windows) steps 15 points a notch: 100 → 115 → 130, and from 112% +to 115% or 100%. That's a large relative step at low zoom and a small one near 400%; equal +relative steps keep each notch feeling the same, and the doublings land on 25, 50, 100, 200 and +400%. The status bar's Zoom In / Zoom Out buttons and Ctrl+Alt+= / Ctrl+Alt+- keep their 10% +steps, so the wheel and the buttons now step differently. Whether they should share one ladder +(this one, Excel's, or another) is left to the maintainers. \ No newline at end of file diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..cff695d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Ctrl+wheel zoom steps along a ladder through 100%) · **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 @@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | From d4874373a437db4d12ff5bcb731ff85877259365 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 12 Oct 2026 23:54:44 +0800 Subject: [PATCH 16/62] #551: preserve editable ink through XLSX round trips --- ROADMAP.md | 4 +- crates/engine/tests/ink_roundtrip.rs | 135 ++++++++++++++++ crates/xlsx/src/drawing.rs | 11 ++ crates/xlsx/src/drawing/ink.rs | 207 +++++++++++++++++++++++++ crates/xlsx/src/tests/ink_roundtrip.rs | 194 +++++++++++++++++++++++ crates/xlsx/src/tests/mod.rs | 1 + docs/file-format-parity.md | 10 +- docs/gaps.md | 5 +- 8 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/ink_roundtrip.rs create mode 100644 crates/xlsx/src/drawing/ink.rs create mode 100644 crates/xlsx/src/tests/ink_roundtrip.rs diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..f40210f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink survives XLSX saves) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/engine/tests/ink_roundtrip.rs b/crates/engine/tests/ink_roundtrip.rs new file mode 100644 index 0000000..bff1b61 --- /dev/null +++ b/crates/engine/tests/ink_roundtrip.rs @@ -0,0 +1,135 @@ +//! Native ink remains editable after XLSX saves, including a save after an unrelated cell edit. + +use gridcraft_engine::Session; +use gridcraft_engine::cmd::draw::{ink_points, ink_width}; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{Shape, ShapeKind}; +use serde_json::json; + +fn workbook() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session +} + +fn reopen(session: &mut Session) -> Session { + let saved = session.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.open", json!({"name": "ink.xlsx", "base64": saved["base64"]})).unwrap(); + reopened +} + +fn shapes(session: &Session) -> &[Shape] { + &session.doc().unwrap().wb.active().unwrap().shapes +} + +fn assert_anchor(actual: &Shape, expected: &Shape) { + assert_eq!(actual.anchor.cell, expected.anchor.cell); + assert_eq!(actual.anchor.mode, expected.anchor.mode); + for (name, actual, expected) in [ + ("dx", actual.anchor.dx, expected.anchor.dx), + ("dy", actual.anchor.dy, expected.anchor.dy), + ("width", actual.anchor.width, expected.anchor.width), + ("height", actual.anchor.height, expected.anchor.height), + ] { + assert!((actual - expected).abs() < 0.001, "anchor {name}: {actual} != {expected}"); + } +} + +fn assert_ink(actual: &Shape, expected: &Shape) { + assert_eq!(actual.kind, ShapeKind::Ink); + let actual_points = ink_points(actual); + let expected_points = ink_points(expected); + assert_eq!(actual_points.len(), expected_points.len()); + for (actual, expected) in actual_points.iter().zip(&expected_points) { + for axis in 0..2 { + // DrawingML quantizes coordinates to integer EMUs (9,525 per model unit). + assert!((actual[axis] - expected[axis]).abs() < 0.001, "point {actual:?} != {expected:?}"); + } + } + assert_eq!(actual.line, expected.line); + assert_eq!(ink_width(actual), ink_width(expected)); + assert_anchor(actual, expected); +} + +#[test] +fn ink_geometry_and_style_survive_two_saves_and_a_cell_edit() { + let mut session = workbook(); + session + .execute("draw.stroke", json!({"points": [[130.5, 45.25], [172.5, 86.25], [151.5, 112.25], [215.5, 59.25]], "color": "#A12BCD", "width": 3})) + .unwrap(); + session + .execute("draw.stroke", json!({"points": [[300, 180], [360, 180], [360, 230], [300, 230], [300, 180]], "color": "#16843B", "width": 7})) + .unwrap(); + let expected = shapes(&session).to_vec(); + assert_eq!(expected.len(), 2); + + for pass in 0..2 { + session = reopen(&mut session); + assert_eq!(shapes(&session).len(), expected.len()); + for (actual, expected) in shapes(&session).iter().zip(&expected) { + assert_ink(actual, expected); + } + if pass == 0 { + session.execute("cell.set", json!({"cell": "Z30", "input": "42"})).unwrap(); + } + } + assert_eq!(session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("Z30").unwrap()), Value::Number(42.0)); +} + +#[test] +fn reopened_ink_can_still_be_converted_to_a_shape() { + for (points, expected_kind, expected_name) in [ + (vec![[100, 100], [200, 100], [200, 180], [100, 180], [100, 100]], ShapeKind::Rectangle, "Rectangle"), + (vec![[100, 100], [140, 110], [220, 130]], ShapeKind::Line, "Line"), + ] { + let mut session = workbook(); + session.execute("draw.stroke", json!({"points": points, "color": "#A12BCD", "width": 5})).unwrap(); + let original = shapes(&session)[0].clone(); + session = reopen(&mut session); + let id = shapes(&session)[0].id; + let converted = session.execute("draw.inkToShape", json!({"id": id})).unwrap(); + assert_eq!(converted["kind"], expected_name); + let shape = &shapes(&session)[0]; + assert_eq!(shape.kind, expected_kind); + assert_eq!(shape.line, original.line); + assert!(shape.text.is_empty()); + assert_anchor(shape, &original); + } +} + +#[test] +fn ordinary_shape_text_is_not_mistaken_for_ink_points() { + let mut session = workbook(); + session + .execute( + "insert.shape", + json!({"kind": "rectangle", "at": "D6", "width": 120, "height": 60, "fill": "#112233", "line": "#A12BCD", "text": "0.0,0.0;50.0,20.0"}), + ) + .unwrap(); + let original = shapes(&session)[0].clone(); + for _ in 0..2 { + session = reopen(&mut session); + assert_eq!(shapes(&session).len(), 1); + let shape = &shapes(&session)[0]; + assert_eq!(shape.kind, ShapeKind::Rectangle); + assert_eq!(shape.text, original.text); + assert_eq!(shape.fill, original.fill); + assert_eq!(shape.line, original.line); + assert_anchor(shape, &original); + assert!(session.execute("draw.inkToShape", json!({})).is_err()); + } +} + +#[test] +fn horizontal_vertical_and_signed_highlighter_strokes_survive_reopening() { + for points in [vec![[25.1, 50.2], [140.3, 50.2]], vec![[40.2, 10.1], [40.2, 100.3]], vec![[-12.3, -4.2], [20.1, 10.2], [80.3, -1.4]]] { + let mut session = workbook(); + session.execute("draw.stroke", json!({"points": points, "color": "#16843B", "width": 12})).unwrap(); + let expected = shapes(&session)[0].clone(); + for _ in 0..2 { + session = reopen(&mut session); + assert_ink(&shapes(&session)[0], &expected); + } + } +} diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 076148a..6dbd17e 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -11,6 +11,8 @@ use crate::read::Ctx; use crate::tables::EMU_PER_PX; use crate::xml::{El, esc, esc_attr}; +mod ink; + // ---------------------------------------------------------------- reading fn emu_px(v: Option) -> f32 { @@ -195,6 +197,9 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( fn read_shape(obj: &El, anchor: Anchor) -> Option { let sp = obj.child("spPr")?; + if let Some(shape) = ink::read(obj, anchor) { + return Some(shape); + } let prst = sp.child("prstGeom").and_then(|g| g.attr("prst")).unwrap_or("rect"); let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false)); let ln = sp.child("ln"); @@ -358,6 +363,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { let (open, close) = anchor_open(sheet, &sh.anchor); close_tag = close; s.push_str(&open); + if let Some(xml) = ink::write(sh, theme, id) { + s.push_str(&xml); + s.push_str(""); + s.push_str(close_tag); + continue; + } let (prst, tx) = match sh.kind { ShapeKind::Rectangle => ("rect", false), ShapeKind::RoundedRectangle => ("roundRect", false), diff --git a/crates/xlsx/src/drawing/ink.rs b/crates/xlsx/src/drawing/ink.rs new file mode 100644 index 0000000..b48d99e --- /dev/null +++ b/crates/xlsx/src/drawing/ink.rs @@ -0,0 +1,207 @@ +//! The open, solid polylines the model can represent as editable ink. This is DrawingML +//! geometry, not Office InkML. Unsupported geometry keeps the existing shape-import fallback. + +use super::*; + +const MAX_POINTS: usize = 20_000; +const MAX_COORD: f64 = 1e7; + +fn bounded(v: f64) -> bool { + v.is_finite() && v.abs() <= MAX_COORD +} + +fn only_children(e: &El, names: &[&str]) -> bool { + e.children.iter().all(|c| names.contains(&c.name.as_str())) +} + +fn only_attrs(e: &El, names: &[&str]) -> bool { + e.attrs.iter().all(|(k, _)| names.contains(&k.as_str())) +} + +fn one<'a>(e: &'a El, name: &str) -> Option<&'a El> { + let mut found = e.children.iter().filter(|c| c.name == name); + let first = found.next()?; + found.next().is_none().then_some(first) +} + +/// Do not partially import a path: losing a curve, subpath, transform or line effect changes +/// the drawing. Accept only the explicit subset emitted below, including its line styling. +pub(super) fn read(obj: &El, anchor: Anchor) -> Option { + if obj.name != "sp" + || !only_children(obj, &["nvSpPr", "spPr"]) + || !only_attrs(obj, &["macro", "textlink"]) + || obj.attrs.iter().any(|(_, v)| !v.is_empty()) + || obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|e| e.flag("txBox", false)) + { + return None; + } + let sp = one(obj, "spPr")?; + if !only_children(sp, &["xfrm", "custGeom", "noFill", "ln"]) || !sp.attrs.is_empty() { + return None; + } + let no_fill = one(sp, "noFill")?; + if !no_fill.attrs.is_empty() || !no_fill.children.is_empty() { + return None; + } + let xf = one(sp, "xfrm")?; + if !xf.attrs.is_empty() || !only_children(xf, &["off", "ext"]) { + return None; + } + let off = one(xf, "off")?; + if off.attr_i64("x")? != 0 || off.attr_i64("y")? != 0 { + return None; + } + let ext = one(xf, "ext")?; + let ew = ext.attr_i64("cx")?; + let eh = ext.attr_i64("cy")?; + if ew <= 0 || eh <= 0 || anchor.width <= 0.0 || anchor.height <= 0.0 || !bounded(anchor.width as f64) || !bounded(anchor.height as f64) { + return None; + } + let same_extent = + |emu: i64, extent: f32| (emu as f64 / EMU_PER_PX - extent as f64).abs() <= 0.001_f64.max(extent as f64 * f32::EPSILON as f64 * 2.0); + if !same_extent(ew, anchor.width) || !same_extent(eh, anchor.height) { + return None; + } + let geom = one(sp, "custGeom")?; + if !geom.attrs.is_empty() || !only_children(geom, &["avLst", "gdLst", "ahLst", "cxnLst", "rect", "pathLst"]) { + return None; + } + // Text bounds have no effect on this unfilled geometry, which has no caption. + if geom.children.iter().any(|c| !matches!(c.name.as_str(), "pathLst" | "rect") && (!c.children.is_empty() || !c.attrs.is_empty())) { + return None; + } + if geom.kids("rect").count() > 1 || geom.kids("rect").any(|r| !r.children.is_empty() || !only_attrs(r, &["l", "t", "r", "b"])) { + return None; + } + let paths = one(geom, "pathLst")?; + if paths.children.len() != 1 { + return None; + } + let path = one(paths, "path")?; + let w = path.attr_i64("w")?; + let h = path.attr_i64("h")?; + if w <= 0 + || h <= 0 + || path.attr("fill") != Some("none") + || !only_attrs(path, &["w", "h", "fill", "stroke", "extrusionOk"]) + || path.attr("stroke").is_some_and(|_| path.attr_bool("stroke") != Some(true)) + || path.attr("extrusionOk").is_some_and(|_| path.attr_bool("extrusionOk") != Some(false)) + || !(2..=MAX_POINTS).contains(&path.children.len()) + { + return None; + } + let ln = one(sp, "ln")?; + if !only_attrs(ln, &["w", "cap", "cmpd", "algn"]) + || ln.attr("cap") != Some("flat") + || ln.attr("cmpd").is_some_and(|v| v != "sng") + || ln.attr("algn").is_some_and(|v| v != "ctr") + || !only_children(ln, &["solidFill", "miter"]) + { + return None; + } + let width_emu = ln.attr_i64("w")?; + let unit = EMU_PER_PX as i64; + if width_emu % unit != 0 || !(1..=40).contains(&(width_emu / unit)) { + return None; + } + let width = width_emu / unit; + let join = one(ln, "miter")?; + if !join.attrs.is_empty() || !join.children.is_empty() { + return None; + } + let fill = one(ln, "solidFill")?; + if fill.children.len() != 1 { + return None; + } + let color = one(fill, "srgbClr")?; + if !only_attrs(color, &["val"]) || !only_children(color, &["alpha"]) { + return None; + } + let expected_alpha = if width > 8 { 45_000 } else { 100_000 }; + if color.children.is_empty() { + if expected_alpha != 100_000 { + return None; + } + } else { + let alpha = one(color, "alpha")?; + if !only_attrs(alpha, &["val"]) || !alpha.children.is_empty() || alpha.attr_i64("val")? != expected_alpha { + return None; + } + } + let line = Color::from_hex(color.attr("val")?)?; + let mut text = String::new(); + for (i, command) in path.children.iter().enumerate() { + if command.name != if i == 0 { "moveTo" } else { "lnTo" } || !command.attrs.is_empty() || command.children.len() != 1 { + return None; + } + let pt = one(command, "pt")?; + if !only_attrs(pt, &["x", "y"]) || !pt.children.is_empty() { + return None; + } + let x = pt.attr_i64("x")? as f64 * anchor.width as f64 / w as f64; + let y = pt.attr_i64("y")? as f64 * anchor.height as f64 / h as f64; + if !bounded(x) || !bounded(y) { + return None; + } + if i != 0 { + text.push(';'); + } + let _ = write!(text, "{},{}", x as f32, y as f32); + } + Some(Shape { id: 0, kind: ShapeKind::Ink, anchor, fill: Color::Rgb((width as u32) << 16), line, text, format: Default::default() }) +} + +/// Invalid/native out-of-range data uses the existing rectangle fallback. A successful +/// result is complete before it is appended, so malformed points never yield half a stroke. +pub(super) fn write(shape: &Shape, theme: &Theme, id: usize) -> Option { + if shape.kind != ShapeKind::Ink || shape.format.no_line { + return None; + } + let width = match shape.fill { + Color::Rgb(rgb) => ((rgb >> 16) & 0xff).max(1), + _ => 2, + }; + if !(1..=40).contains(&width) { + return None; + } + let a = &shape.anchor; + if !bounded(a.width as f64) || !bounded(a.height as f64) || a.width <= 0.0 || a.height <= 0.0 || !a.dx.is_finite() || !a.dy.is_finite() { + return None; + } + let w = (a.width as f64 * EMU_PER_PX).round().max(1.0) as i64; + let h = (a.height as f64 * EMU_PER_PX).round().max(1.0) as i64; + let mut points = Vec::new(); + for point in shape.text.split(';') { + if points.len() == MAX_POINTS { + return None; + } + let (x, y) = point.split_once(',')?; + let (x, y) = (x.parse::().ok()?, y.parse::().ok()?); + if !bounded(x) || !bounded(y) { + return None; + } + points.push(((x * EMU_PER_PX).round() as i64, (y * EMU_PER_PX).round() as i64)); + } + if points.len() < 2 { + return None; + } + let [r, g, b] = shape.line.resolve(theme)?; + let mut xml = format!( + "{}", + xfrm(a, "a:xfrm") + ); + for (i, (x, y)) in points.iter().enumerate() { + let tag = if i == 0 { "moveTo" } else { "lnTo" }; + let _ = write!(xml, ""); + } + let _ = write!( + xml, + "", + width as i64 * EMU_PER_PX as i64 + ); + if width > 8 { + xml.push_str(""); + } + xml.push_str(""); + Some(xml) +} diff --git a/crates/xlsx/src/tests/ink_roundtrip.rs b/crates/xlsx/src/tests/ink_roundtrip.rs new file mode 100644 index 0000000..2882765 --- /dev/null +++ b/crates/xlsx/src/tests/ink_roundtrip.rs @@ -0,0 +1,194 @@ +//! Ink uses visible DrawingML geometry, and only representable polylines become editable ink. + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_model::{Anchor, AnchorMode, Color, Shape, ShapeKind, Sheet, Workbook}; + +use super::minimal; +use crate::xml::{self, El}; +use crate::{read_xlsx, write_xlsx}; + +const POINTS: &str = r#""#; +const PATH: &str = r#""#; + +fn drawing() -> String { + format!( + r#" + 01905000285750 + + + {PATH}{POINTS} + + "# + ) +} + +fn read_drawing(drawing: &str) -> Workbook { + let rels = r#""#; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/drawing1.xml", drawing)], + "", + "", + ); + read_xlsx(&bytes).unwrap().0 +} + +fn points(shape: &Shape) -> Vec<[f64; 2]> { + shape + .text + .split(';') + .map(|pair| { + let (x, y) = pair.split_once(',').unwrap(); + [x.parse().unwrap(), y.parse().unwrap()] + }) + .collect() +} + +fn has_caption(element: &El) -> bool { + (element.name == "t" && !element.text.is_empty()) || element.children.iter().any(has_caption) +} + +#[test] +fn exported_ink_has_a_visible_unfilled_path_and_correct_stroke() { + let mut sheet = Sheet::new("Ink"); + for (id, width) in [(1, 3), (2, 12)] { + sheet.shapes.push(Shape { + id, + kind: ShapeKind::Ink, + anchor: Anchor { width: 100.0, height: 50.0, mode: AnchorMode::Absolute, ..Default::default() }, + fill: Color::Rgb(width << 16), + line: Color::Rgb(0xA12BCD), + text: "-10.0,5.0;50.0,40.0;90.0,10.0".into(), + format: Default::default(), + }); + } + let wb = Workbook { sheets: vec![Arc::new(sheet)], ..Default::default() }; + let bytes = write_xlsx(&wb).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut drawing = String::new(); + archive.by_name("xl/drawings/drawing1.xml").unwrap().read_to_string(&mut drawing).unwrap(); + let root = xml::parse(drawing.as_bytes()).unwrap(); + let shapes: Vec<_> = root.children.iter().filter_map(|anchor| anchor.child("sp")).collect(); + assert_eq!(shapes.len(), 2); + for (shape, width) in shapes.iter().zip([3, 12]) { + assert!(!has_caption(shape), "stroke coordinates must not be visible text"); + let properties = shape.child("spPr").unwrap(); + assert!(properties.child("prstGeom").is_none()); + assert!(properties.child("noFill").is_some()); + let paths: Vec<_> = properties.path(&["custGeom", "pathLst"]).unwrap().kids("path").collect(); + assert_eq!(paths.len(), 1); + let path = paths[0]; + assert_eq!(path.attr("fill"), Some("none")); + let w = path.attr_i64("w").unwrap(); + let h = path.attr_i64("h").unwrap(); + assert!(w > 0 && h > 0); + assert_eq!(path.children.iter().map(|c| c.name.as_str()).collect::>(), ["moveTo", "lnTo", "lnTo"]); + for (command, [x, y]) in path.children.iter().zip([[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]) { + let point = command.child("pt").unwrap(); + let actual_x = point.attr_i64("x").unwrap() as f64 * 100.0 / w as f64; + let actual_y = point.attr_i64("y").unwrap() as f64 * 50.0 / h as f64; + assert!((actual_x - x).abs() < 0.001 && (actual_y - y).abs() < 0.001); + } + let line = properties.child("ln").unwrap(); + assert_eq!(line.attr_i64("w"), Some(9525 * width)); + assert_eq!(line.attr("cap"), Some("flat")); + assert!(line.child("miter").is_some()); + let color = line.path(&["solidFill", "srgbClr"]).unwrap(); + assert_eq!(color.attr("val"), Some("A12BCD")); + if width > 8 { + assert_eq!(color.child_val("alpha"), Some("45000")); + } else { + assert!(color.child("alpha").is_none_or(|a| a.attr("val") == Some("100000"))); + } + } +} + +#[test] +fn numeric_polyline_from_an_independent_package_is_scaled_into_editable_ink() { + for (width, alpha) in [(3, ""), (12, r#""#)] { + let xml = drawing() + .replace("w=\"28575\"", &format!("w=\"{}\"", width * 9525)) + .replace(r#""#, &format!(r#"{alpha}"#)); + let wb = read_drawing(&xml); + let shapes = &wb.sheet(0).unwrap().shapes; + assert_eq!(shapes.len(), 1); + let shape = &shapes[0]; + assert_eq!(shape.kind, ShapeKind::Ink); + assert_eq!(points(shape), [[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]); + assert_eq!(shape.line, Color::Rgb(0xA12BCD)); + assert_eq!(shape.fill, Color::Rgb(width << 16)); + assert_eq!((shape.anchor.dx, shape.anchor.dy), (20.0, 30.0)); + assert_eq!((shape.anchor.width, shape.anchor.height), (100.0, 50.0)); + } +} + +#[test] +fn imported_points_are_not_snapped_to_native_tenth_units() { + let xml = drawing().replace("w=\"200\"", "w=\"2000000\"").replace( + POINTS, + r#""#, + ); + let wb = read_drawing(&xml); + let shape = &wb.sheet(0).unwrap().shapes[0]; + assert_eq!(shape.kind, ShapeKind::Ink); + let original = points(shape); + assert!((original[0][0] - 0.10005).abs() < 0.00000001, "an imported coordinate is not necessarily a native tenth-unit point"); + let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let after = &reopened.sheet(0).unwrap().shapes[0]; + assert_eq!(after.kind, ShapeKind::Ink); + for (actual, expected) in points(after).iter().zip(original) { + for axis in 0..2 { + // A saved DrawingML coordinate is quantized to one EMU, plus f32 model precision. + assert!((actual[axis] - expected[axis]).abs() < 0.00006); + } + } +} + +#[test] +fn unsupported_geometry_or_appearance_is_not_reclassified_as_editable_ink() { + let original = drawing(); + let cases = [ + ("formula coordinate", original.replace("x=\"-20\"", "x=\"wd2\"")), + ("nonfinite coordinate", original.replace("x=\"-20\"", "x=\"NaN\"")), + ("overflow coordinate", original.replace("x=\"-20\"", "x=\"9223372036854775808\"")), + ("zero path width", original.replace("w=\"200\"", "w=\"0\"")), + ("missing point", original.replace(r#""#, "")), + ( + "curve", + original + .replace(r#""#, r#""#), + ), + ("closed path", original.replace("", "")), + ("multiple paths", original.replace("", &format!("{PATH}{POINTS}"))), + ("filled path", original.replace("fill=\"none\"", "fill=\"norm\"")), + ("filled shape", original.replace("", "")), + ("rotation", original.replace("", "")), + ("reflection", original.replace("", "")), + ("square caps", original.replace("cap=\"flat\"", "cap=\"sq\"")), + ("round join", original.replace("", "")), + ("dash", original.replace("", "")), + ("arrow", original.replace("", "")), + ( + "caption", + original.replace("", "Caption"), + ), + ("fractional width", original.replace("w=\"28575\"", "w=\"33338\"")), + ("unsupported opacity", original.replace(r#""#, r#""#)), + ]; + for (name, xml) in cases { + let wb = read_drawing(&xml); + assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink), "{name} was silently converted to editable ink"); + } +} + +#[test] +fn oversized_polyline_is_not_truncated_into_different_ink() { + let mut commands = String::from(r#""#); + for _ in 0..20_000 { + commands.push_str(r#""#); + } + let wb = read_drawing(&drawing().replace(POINTS, &commands)); + assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink)); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index d5d2f2f..e1d86fe 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -4,6 +4,7 @@ mod array_export; mod csv_tests; mod encrypted_tests; mod fixtures; +mod ink_roundtrip; mod malformed; mod ods_tests; mod passthrough_tests; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8510659..cf5c6d8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink round-trips as DrawingML geometry) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -13,6 +13,13 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and ## Workbook formats +Ink created with Draw is written as standard, unfilled DrawingML polylines, without visible point +captions or private metadata. Supported numeric polylines reopen as editable ink, retaining points, +colour, integer width and anchor. Curves, filled paths, transforms and unsupported line styles are +not interpreted as ink. This does not implement Office InkML or fix inserted icons (#551). +Command and package tests cover repeated saves and unrelated cell edits; external Excel rendering +and repair-free opening remain unverified. + | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | @@ -78,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Ink paths and editing survive XLSX save/reopen; strict polyline import and malformed-input controls | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b8..fdd7492 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (ink XLSX preservation; icons still pending) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -125,6 +125,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- conversion; cloud storage requested (#86). **Estimate:** 35–55 h (co-authoring scope needs the owner). ### 17. Insert objects (F) +- Ink strokes now survive XLSX save/reopen as editable polylines. Inserted icons still lose their + identity/artwork on save (#551); native Office InkML import is not implemented. - SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170; pictures in cells (#61, PR #105). **Estimate:** 25–40 h. @@ -160,6 +162,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | §17: ink geometry survives XLSX saves; the icon portion of #551 remains | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | From e88d3d75831a8b09cf6a6671bc5a357fa34c5868 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:36:51 +0200 Subject: [PATCH 17/62] EDATE and EOMONTH: serial 0 is day 0 and February 1900 has 28 days (fixes #734) Excel's EDATE and EOMONTH read serial 0 as 0 January 1900 and end February 1900 on the 28th (serial 59), although the sheet itself keeps the Lotus 29 February 1900 (serial 60). GridCraft clamped day 0 up to day 1 and used the sheet's 29-day February, so EDATE(0,0) was 1, EDATE(0,1) 32, EDATE(31,1) and EOMONTH(59,0) 60. EDATE now keeps day 0 and both functions take the real calendar's month lengths. Serial 60 still reads as 29 February, so EDATE(60,1) stays 29 March. DATE, DAY and the parser keep the leap day. Tests: the Excel 365 table from the issue; the #124 cases that expected 60 at the end of February 1900 now expect 59. --- crates/functions/src/date.rs | 39 +++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs index 29aeb47..2c1e2fd 100644 --- a/crates/functions/src/date.rs +++ b/crates/functions/src/date.rs @@ -1,6 +1,6 @@ //! Date and time functions. -use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd}; +use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd}; use gridcraft_core::parse::parse_input; use gridcraft_core::{CellError, DateSystem, Value}; @@ -164,7 +164,9 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R { if !(1900..=9999).contains(&y) { return Err(CellError::Num); } - let dd = (d.day.max(1) as i64).min(days_in_month_in(sys, y as i32, mo as u32) as i64); + // Excel keeps serial 0's day 0 and gives February 1900 its real 28 days here, though the + // sheet's calendar has a 29 February 1900 (serial 60). + let dd = (d.day as i64).min(days_in_month(y as i32, mo as u32) as i64); num_val(ymd_serial(sys, y, mo, dd)? as f64) } @@ -177,7 +179,8 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R { if !(1900..=9999).contains(&y) { return Err(CellError::Num); } - num_val(ymd_serial(sys, y, mo, days_in_month_in(sys, y as i32, mo as u32) as i64)? as f64) + // Like EDATE, February 1900 ends on the 28th (serial 59). + num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64) } fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R { @@ -680,17 +683,35 @@ mod tests { close(ev("DATEVALUE", vec![t("2/29/1900")]), 60.0); close(ev("DATEVALUE", vec![t("3/1/1900")]), 61.0); is_err(ev("DATEVALUE", vec![t("2/29/1901")]), CellError::Value); - // EDATE and EOMONTH land on it as the end of February 1900. - close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 60.0); - close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 60.0); - close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 60.0); + // EDATE and EOMONTH read it as 29 February, but end February 1900 on the 28th. + close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 59.0); + close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 59.0); + close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 59.0); close(ev("EOMONTH", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0)); - close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 60.0); - close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 60.0); + close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 59.0); + close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 59.0); close(ev("EDATE", vec![n(60.0), n(1.0)]), d(1900.0, 3.0, 29.0)); close(ev("EDATE", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0)); } + #[test] + fn edate_eomonth_early_1900() { + // Excel 365: serial 0 is 0 January 1900 and February 1900 has 28 days here. + close(ev("EDATE", vec![n(0.0), n(0.0)]), 0.0); + close(ev("EDATE", vec![n(0.0), n(1.0)]), 31.0); + close(ev("EDATE", vec![n(0.0), n(12.0)]), 366.0); + close(ev("EOMONTH", vec![n(0.0), n(1.0)]), 59.0); + close(ev("EDATE", vec![n(31.0), n(1.0)]), 59.0); + close(ev("EOMONTH", vec![n(59.0), n(0.0)]), 59.0); + // Unchanged, and the same as Excel. + close(ev("EOMONTH", vec![n(0.0), n(0.0)]), 31.0); + is_err(ev("EOMONTH", vec![n(0.0), n(-1.0)]), CellError::Num); + close(ev("EDATE", vec![n(59.0), n(1.0)]), 88.0); + close(ev("EDATE", vec![n(60.0), n(1.0)]), 89.0); + close(ev("EDATE", vec![n(61.0), n(-1.0)]), 32.0); + close(ev("EOMONTH", vec![n(31.0), n(0.0)]), 31.0); + } + #[test] fn datedif_units() { let s = n(d(2001.0, 6.0, 1.0)); From dbfed89a36163dce5d6cb33b808fb260ea003ea2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:43:28 +0200 Subject: [PATCH 18/62] XLS import: read the iterative calculation settings (part of #724) CalcIter, CalcCount and CalcDelta repeat in every worksheet substream and were skipped, so a workbook saved with iterative calculation on opened with it off and its circular references showed #CIRC!. They now set wb.calc from the first worksheet, as does for .xlsx. --- crates/xlsx/src/tests/xls_tests.rs | 19 +++++++++++++++++++ crates/xlsx/src/xls/mod.rs | 23 ++++++++++++++++------- docs/file-format-parity.md | 5 +++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795..5f80734 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -476,3 +476,22 @@ fn sheets_share_one_record_budget() { let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); assert!(err.contains("same substream"), "{err}"); } + +#[test] +fn reads_iterative_calculation_settings() { + // CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only the first + // sheet carries them, so the sheet after it must not reset them to the defaults. + let calc = cat(&[&rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); + let sheet1 = cat(&[&bof(0x0010), &calc, &eof()]); + let sheet2 = cat(&[&bof(0x0010), &eof()]); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); + let p1 = globals(0, 0).len() as u32; + let p2 = p1 + sheet1.len() as u32; + let (wb, _) = read_xls(&compound("Workbook", &cat(&[&globals(p1, p2), &sheet1, &sheet2]))).unwrap(); + assert!(wb.calc.iterative); + assert_eq!((wb.calc.max_iterations, wb.calc.max_change), (50, 0.01)); + // Without the records: Excel's defaults, iteration off. + let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap(); + assert_eq!(wb.calc, gridcraft_model::CalcSettings::default()); +} diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66..df27a73 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -4,9 +4,9 @@ //! //! Read: worksheets, shared strings, cell values, formulas (text and cached results, including //! shared and array formulas), number formats, the 1904 date system, merged cells, column -//! widths, row heights, defined names, print areas and print titles. Everything else (fonts, -//! fills, borders, charts, pictures, comments, conditional formats, validation, macros) is -//! skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. +//! widths, row heights, defined names, print areas, print titles and the iterative calculation +//! settings. Everything else (fonts, fills, borders, charts, pictures, comments, conditional +//! formats, validation, macros) is skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. mod formula; mod ftab; @@ -16,7 +16,7 @@ use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; +use gridcraft_model::{CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; use crate::cfb::Compound; use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; @@ -175,12 +175,16 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo continue; } wb.check_sheet_name(&bs.name, None).map_err(invalid)?; - let Some(mut sheet) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { + let Some((mut sheet, calc)) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { report.warn("Dialog sheets and unreadable sheets were skipped."); model_index.push(None); continue; }; sheet.visibility = bs.visibility; + // Every worksheet repeats the workbook's iteration settings; Excel applies the first. + if wb.sheets.is_empty() { + wb.calc = calc; + } model_index.push(Some(wb.sheets.len())); wb.sheets.push(Arc::new(sheet)); } @@ -393,6 +397,7 @@ struct SheetState<'g> { pending_string: Option, dropped: usize, unsupported: Vec<&'static str>, + calc: CalcSettings, } impl SheetState<'_> { @@ -462,7 +467,7 @@ fn worksheet( styles: &[StyleId], budget: &mut Budget, report: &mut ReadReport, -) -> Result, IoError> { +) -> Result, IoError> { if bs.pos >= stream.len() { return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name))); } @@ -483,6 +488,7 @@ fn worksheet( pending_string: None, dropped: 0, unsupported: vec![], + calc: CalcSettings::default(), }; let mut depth = 1usize; while let Some(rec) = recs.next_record()? { @@ -525,7 +531,7 @@ fn worksheet( for why in &st.unsupported { report.warn(format!("Some formulas use {why}; their saved results were kept as values.")); } - Ok(Some(st.sheet)) + Ok(Some((st.sheet, st.calc))) } fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> { @@ -620,6 +626,9 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, st.arrays.insert(at, (range, f)); } } + 0x000C => st.calc.set_max_iterations(u32::from(c.u16()?)), + 0x0011 => st.calc.iterative = c.u16()? == 1, + 0x0010 => st.calc.set_max_change(c.f64()?), 0x0236 => report.warn("What-if data tables are not supported; their values were kept."), 0x00E5 => { let n = usize::from(c.u16()?); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8510659..ad458ca 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the iterative calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -22,7 +22,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles. Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, iterative calculation settings (enabled, maximum iterations, maximum change). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -78,6 +78,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | `.xls` import reads the iterative calculation settings (CalcIter, CalcCount, CalcDelta), so circular models calculate instead of showing `#CIRC!` (#724) | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | From f3e05e0ad8b4fa9bc1cc5c5f4c6bc8f632c07f01 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:47:53 +0200 Subject: [PATCH 19/62] XLS import: read the calculation mode and precision as displayed (fixes #724) CalcMode repeats in every worksheet substream like the iteration records and sets wb.calc.mode from the first worksheet; CalcPrecision sits in the globals and sets precision_as_displayed, matching calcMode and fullPrecision on the .xlsx side. --- crates/xlsx/src/tests/xls_tests.rs | 11 +++++++---- crates/xlsx/src/xls/mod.rs | 21 ++++++++++++++++----- docs/file-format-parity.md | 6 +++--- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index 5f80734..f284188 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -479,18 +479,21 @@ fn sheets_share_one_record_budget() { #[test] fn reads_iterative_calculation_settings() { - // CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only the first - // sheet carries them, so the sheet after it must not reset them to the defaults. - let calc = cat(&[&rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); + // CalcMode, CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only + // the first sheet carries them, so the sheet after it must not reset them to the defaults. + // CalcPrecision sits in the globals. + let calc = cat(&[&rec(0x000D, &u16s(&[0])), &rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); let sheet1 = cat(&[&bof(0x0010), &calc, &eof()]); let sheet2 = cat(&[&bof(0x0010), &eof()]); let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); - let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); + let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &rec(0x000E, &u16s(&[0])), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); let p1 = globals(0, 0).len() as u32; let p2 = p1 + sheet1.len() as u32; let (wb, _) = read_xls(&compound("Workbook", &cat(&[&globals(p1, p2), &sheet1, &sheet2]))).unwrap(); assert!(wb.calc.iterative); assert_eq!((wb.calc.max_iterations, wb.calc.max_change), (50, 0.01)); + assert_eq!(wb.calc.mode, gridcraft_model::CalcMode::Manual); + assert!(wb.calc.precision_as_displayed); // Without the records: Excel's defaults, iteration off. let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap(); assert_eq!(wb.calc, gridcraft_model::CalcSettings::default()); diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index df27a73..407a4a4 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -4,9 +4,10 @@ //! //! Read: worksheets, shared strings, cell values, formulas (text and cached results, including //! shared and array formulas), number formats, the 1904 date system, merged cells, column -//! widths, row heights, defined names, print areas, print titles and the iterative calculation -//! settings. Everything else (fonts, fills, borders, charts, pictures, comments, conditional -//! formats, validation, macros) is skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. +//! widths, row heights, defined names, print areas, print titles and the calculation settings +//! (mode, iteration, precision as displayed). Everything else (fonts, fills, borders, charts, +//! pictures, comments, conditional formats, validation, macros) is skipped with a warning. +//! Encrypted workbooks and pre-BIFF8 versions are refused. mod formula; mod ftab; @@ -16,7 +17,7 @@ use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; +use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; use crate::cfb::Compound; use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; @@ -123,6 +124,7 @@ struct Globals { sst: Vec>, links: Links, names: Vec, + precision_as_displayed: bool, } /// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. @@ -181,7 +183,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo continue; }; sheet.visibility = bs.visibility; - // Every worksheet repeats the workbook's iteration settings; Excel applies the first. + // Every worksheet repeats the workbook's calculation settings; Excel applies the first. if wb.sheets.is_empty() { wb.calc = calc; } @@ -191,6 +193,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo if wb.sheets.is_empty() { return Err(invalid("the workbook has no worksheets GridCraft can read")); } + wb.calc.precision_as_displayed = g.precision_as_displayed; defined_names(&g, &model_index, &mut wb, &mut report); wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); // The overview comes first. @@ -251,6 +254,7 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { g.sheets.push(BoundSheet { pos, name, visibility, kind }); } 0x0022 => g.date1904 = c.u16()? == 1, + 0x000E => g.precision_as_displayed = c.u16()? == 0, 0x041E => { let ifmt = c.u16()?; let code = c.string()?; @@ -626,6 +630,13 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, st.arrays.insert(at, (range, f)); } } + 0x000D => { + st.calc.mode = match c.u16()? as i16 { + 0 => CalcMode::Manual, + -1 => CalcMode::AutomaticExceptTables, + _ => CalcMode::Automatic, + } + } 0x000C => st.calc.set_max_iterations(u32::from(c.u16()?)), 0x0011 => st.calc.iterative = c.u16()? == 1, 0x0010 => st.calc.set_max_change(c.f64()?), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ad458ca..0bc4813 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the iterative calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -22,7 +22,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, iterative calculation settings (enabled, maximum iterations, maximum change). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -78,7 +78,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-10-12 | minor | `.xls` import reads the iterative calculation settings (CalcIter, CalcCount, CalcDelta), so circular models calculate instead of showing `#CIRC!` (#724) | +| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | From 94014c5594058eb3dad64cbc9a4745a1c56e450f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:51:40 +0200 Subject: [PATCH 20/62] Tables: structured references keep the spaces at the edges of a column name (fixes #732) --- crates/engine/src/tests.rs | 30 ++++++++++++++++++++++++++++++ crates/formula/src/parser.rs | 5 ++++- crates/formula/src/printer.rs | 7 ++++++- crates/formula/src/tests.rs | 14 ++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..66ef9d7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1018,6 +1018,36 @@ fn tables_and_structured_refs() { assert_eq!(v(&s, "B4"), Value::Number(7.0)); } +#[test] +fn structured_refs_to_columns_with_edge_spaces() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty "], ["a", 2], ["b", 3]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B3", "hasHeaders": true})).unwrap(); + s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap(); + assert_eq!(v(&s, "B4"), Value::Number(5.0)); + for (cell, input) in [ + ("F1", "=SUM(Table1[Qty ])"), + ("F2", "=Table1[[#Totals],[Qty ]]"), + ("F3", "=SUBTOTAL(109,Table1[Qty ])"), + ("F4", "=SUM(Table1[[Qty ]])"), + ("F5", "=SUM(Table1[Qty])"), + ("C2", "=Table1[@[Qty ]]*10"), + ] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let n = Value::Number; + assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3"), v(&s, "F4")], [n(5.0), n(5.0), n(5.0), n(5.0)]); + assert_eq!(v(&s, "F5"), Value::Error(gridcraft_core::CellError::Ref)); + assert_eq!(v(&s, "C2"), n(20.0)); + assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])"); + + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let b64 = r["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])"); + assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3")], [n(5.0), n(5.0), n(5.0)]); +} + #[test] fn slicer_filters_table() { let mut s = s(); diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 4fd3b7b..8fc4de9 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -417,6 +417,9 @@ fn combine(op: BinOp, lhs: Expr, rhs: Expr) -> Expr { pub fn parse_struct(table: &str, body: &str) -> Result { let mut specifiers = Vec::new(); let mut cols: Vec = Vec::new(); + // Spaces at the edges of a column name are part of it (`Table1[Qty ]` names `Qty `), so the + // untrimmed body is what a lone column is read from. + let raw = body; let body = body.trim(); let unescape = |s: &str| { let mut out = String::new(); @@ -468,7 +471,7 @@ pub fn parse_struct(table: &str, body: &str) -> Result { } else if let Some(i) = item(body) { specifiers.push(i); } else if !body.is_empty() { - cols.push(column(body)?); + cols.push(column(raw)?); } } else { // [[#Headers],[Col1]:[Col3]], [[#This Row],[Col 1]] or [[#This Row],Col1] diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index a63599b..3673036 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -109,7 +109,12 @@ pub fn struct_ref(s: &StructRef) -> String { let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or("")); } (Some(c), None) if s.specifiers.is_empty() => { - let _ = write!(out, "[{}]", esc(c)); + // Excel writes a name with spaces at its edges in the double-bracket form. + if c.trim() == c { + let _ = write!(out, "[{}]", esc(c)); + } else { + let _ = write!(out, "[[{}]]", esc(c)); + } } (Some(c), None) if this_row => { if simple_col(c) { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c2..aef2649 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -248,6 +248,20 @@ fn unbracketed_this_row_column() { } } +#[test] +fn struct_column_keeps_edge_spaces() { + // The spaces are part of the column name: `Table1[Qty ]` names `Qty `, never `Qty`. + let Expr::Struct(s) = parse("Table1[Qty ]").unwrap() else { panic!() }; + assert_eq!(s.col_start.as_deref(), Some("Qty ")); + let Expr::Struct(s) = parse("Table1[ Qty]").unwrap() else { panic!() }; + assert_eq!(s.col_start.as_deref(), Some(" Qty")); + // Excel writes such a column in the double-bracket form. + assert_eq!(rt("Table1[Qty ]"), "Table1[[Qty ]]"); + for f in ["Table1[[Qty ]]", "Table1[[ Sum for Q2 ]]", "Table1[[#Totals],[Qty ]]", "Table1[@[Qty ]]", "Table1[Unit Price]"] { + assert_eq!(rt(f), f); + } +} + #[test] fn trim_references() { for f in [ From eb534b5cafb038f8b399154d5fb16a785a4d6ef1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:58:24 +0200 Subject: [PATCH 21/62] Functions: DGET skips empty result fields and "" criteria match any text (part of #737) --- crates/functions/src/database.rs | 77 +++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index 38e3729..bf0c611 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -4,7 +4,7 @@ //! The database and the criteria are arrays whose first row holds column labels. Each criteria //! row is a set of conditions that must all hold (AND); a record matches when any criteria row //! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that -//! begin with the text, as in Excel's advanced filter. +//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. use std::borrow::Cow; @@ -49,7 +49,6 @@ fn field_index(db: &Array, a: &[Arg]) -> R> { fn cell_criterion(v: &Value) -> Option { match v { Value::Empty => None, - Value::Text(t) if t.is_empty() => None, Value::Text(t) => { let s: &str = t; let has_op = s.starts_with(['=', '<', '>']); @@ -158,9 +157,12 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { let s = select(a)?; let f = s.field.ok_or(CellError::Value)?; - match s.records.as_slice() { - [] => Err(CellError::Value), - [r] => Ok(s.db.get(*r, f).cloned().unwrap_or(Value::Empty)), + // Records whose field is blank (or "") don't count, as in Excel. + let mut found = + s.records.iter().filter_map(|&r| s.db.get(r, f)).filter(|v| !matches!(v, Value::Empty) && !matches!(v, Value::Text(t) if t.is_empty())); + match (found.next(), found.next()) { + (None, _) => Err(CellError::Value), + (Some(v), None) => Ok(v.clone()), _ => Err(CellError::Num), } } @@ -371,4 +373,69 @@ mod tests { is_err(ev("DSUM", vec![e(CellError::Ref), t("Yield"), rf(c("=Cherry"))]), CellError::Ref); close(ev("DCOUNTA", vec![rf(db()), empty(), rf(c("=Pear"))]), 2.0); } + + /// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 74-86). + #[test] + fn empty_string_criterion() { + let db = arr(vec![ + vec![tv("Index"), tv("Value")], + vec![tv("a"), tv("=")], + vec![tv("b"), Value::Empty], + vec![tv("c"), tv("")], + vec![tv("d"), tv(" ")], + vec![tv("f"), Value::Error(CellError::Value)], + vec![tv("h"), tv("abc")], + vec![tv("i"), nv(0.0)], + ]); + let get = |index: &str| { + let crit = arr(vec![vec![tv("Index"), tv("Value")], vec![tv(index), tv("")]]); + ev("DGET", vec![rf(db.clone()), t("Index"), rf(crit)]) + }; + // Like any plain text, "" is a "begins with" pattern: it matches every text value. + is_text(get("c"), "c"); + is_text(get("h"), "h"); + is_err(get("b"), CellError::Value); + is_err(get("i"), CellError::Value); + is_err(get("f"), CellError::Value); + let all = arr(vec![vec![tv("Value")], vec![tv("")]]); + close(ev("DCOUNTA", vec![rf(db.clone()), t("Index"), rf(all)]), 4.0); + + // Under a label that names no field, "" is a condition that never holds. + let db = arr(vec![vec![tv("One"), tv("Two"), tv("Three")], vec![tv("a"), tv("c"), tv("x")], vec![tv("b"), tv("d"), tv("y")]]); + let get = |label: Value, value: Value| { + let crit = arr(vec![vec![tv("One"), label, tv("Three")], vec![tv("a"), value, tv("x")]]); + ev("DGET", vec![rf(db.clone()), t("One"), rf(crit)]) + }; + is_text(get(Value::Empty, Value::Empty), "a"); + is_text(get(tv(""), Value::Empty), "a"); + is_err(get(Value::Empty, tv("")), CellError::Value); + is_err(get(tv(""), tv("")), CellError::Value); + } + + /// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 108-119). + #[test] + fn dget_skips_empty_fields() { + let db = arr(vec![ + vec![tv("One"), tv("Two")], + vec![tv("a"), Value::Empty], + vec![tv("b"), tv(" ")], + vec![tv("c"), tv("")], + vec![tv("d"), Value::Error(CellError::NA)], + vec![tv("j"), Value::Empty], + vec![tv("j"), tv("Text")], + vec![tv("j"), Value::Empty], + vec![tv("k"), Value::Empty], + vec![tv("k"), tv("")], + ]); + let get = |one: &str| { + let crit = arr(vec![vec![tv("One")], vec![tv(one)]]); + ev("DGET", vec![rf(db.clone()), t("Two"), rf(crit)]) + }; + is_err(get("a"), CellError::Value); + is_text(get("b"), " "); + is_err(get("c"), CellError::Value); + is_err(get("d"), CellError::NA); + is_text(get("j"), "Text"); + is_err(get("k"), CellError::Value); + } } From 0a27d4a5d4e9f472561c81e667a93da9826b246c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:04:17 +0200 Subject: [PATCH 22/62] Functions: MIRR, COUNTIF, INDIRECT and USDOLLAR match Excel 365 (fixes #738) --- crates/calc/src/eval.rs | 8 +++++++- crates/calc/src/tests.rs | 16 ++++++++++++++++ crates/functions/src/criteria.rs | 8 ++++++-- crates/functions/src/financial.rs | 5 ++++- crates/functions/src/text.rs | 3 +++ docs/function-parity.md | 5 ++++- 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..753e03d 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1081,8 +1081,14 @@ impl<'h> Evaluator<'h> { v.scalar().to_bool().unwrap_or(true) } }; - let text = if a1 { text } else { r1c1_to_a1(&text, self.at).unwrap_or(text) }; + // With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference. + let (text, a1_ok) = match (a1, r1c1_to_a1(&text, self.at)) { + (true, _) => (text, true), + (false, Some(t)) => (t, true), + (false, None) => (text, false), + }; match gridcraft_formula::parse(&text) { + Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref), Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) { r @ Ev::R(_) => r, _ => err(CellError::Ref), diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..ada11fb 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -403,6 +403,22 @@ fn references_functions() { assert_eq!(t.num("B3"), 50.0); t.set("B4", "=INDIRECT(\"A\"&4)"); assert_eq!(t.num("B4"), 40.0); + // With a1 = FALSE the text must be R1C1: A1-style text is #REF!, not read as A1. + t.set("C1", "=INDIRECT(\"R3C1\",FALSE)"); + assert_eq!(t.num("C1"), 30.0); + t.set("C2", "=INDIRECT(\"A3\",FALSE)"); + assert_eq!(t.get("C2"), Value::Error(CellError::Ref)); + // Defined names don't depend on the reference style. + t.wb.names.push(gridcraft_model::DefinedName { + name: "Third".into(), + scope: None, + formula: "Sheet1!$A$3".into(), + comment: String::new(), + hidden: false, + }); + t.calc.rebuild(&t.wb); + t.set("C3", "=INDIRECT(\"Third\",FALSE)"); + assert_eq!(t.num("C3"), 30.0); t.set("B5", "=SUM(A1:INDEX(A1:A4,3))"); assert_eq!(t.num("B5"), 60.0); t.set("B6", "=SUM(A:A)"); diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 20410c8..85039f4 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -98,9 +98,10 @@ impl Criterion { match self.op { Op::Eq => blank, Op::Ne => !blank, - // "<" / ">" with nothing: Excel compares against "" for text cells. + // "<" / ">" with nothing: Excel compares against "" for text cells, but an + // empty string never matches ("<=" and ">=" count it in neither). _ => match &v { - Value::Text(t) => cmp_op(self.op, compare_text(t, "")), + Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")), _ => false, }, } @@ -290,6 +291,9 @@ mod tests { assert!(m("<>", Value::from("a"))); assert!(!m("<>", Value::Empty)); assert!(m("TRUE", Value::Bool(true))); + // A bare "<=" or ">=" never matches an empty string (Excel 365: COUNTIF(="", "<=") is 0). + assert!(!m("<=", Value::from(""))); + assert!(!m(">=", Value::from(""))); assert!(m(" R { neg += x / (1.0 + fr).powi(i as i32); } } - if pos == 0.0 || neg == 0.0 { + // No positive flow is not an error in Excel: the formula below gives -1. + if neg == 0.0 { return Err(CellError::Div0); } let r = (-pos * (1.0 + rr).powf(n - 1.0) / neg).powf(1.0 / (n - 1.0)) - 1.0; @@ -1813,6 +1814,8 @@ mod tests { close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6); close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7); is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0); + // Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing. + close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12); close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089); close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584); is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num); diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 723cd4a..985ca08 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -841,6 +841,7 @@ pub(crate) fn specs() -> Vec { fixed ), f!("DOLLAR", 1, 2, Text, S, "DOLLAR(number, [decimals])", "Formats a number as currency text.", dollar), + f!("USDOLLAR", 1, 2, Compatibility, S, "USDOLLAR(number, [decimals])", "Formats a number as currency text, like DOLLAR.", dollar), f!("BAHTTEXT", 1, 1, Text, S, "BAHTTEXT(number)", "Spells out a number in Thai words with Baht.", bahttext), f!("T", 1, 1, Text, S, "T(value)", "Returns the value if it is text, otherwise empty text.", t_fn), f!( @@ -1091,6 +1092,8 @@ mod tests { is_text(ev("DOLLAR", vec![n(1234.567)]), "$1,234.57"); is_text(ev("DOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)"); is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1"); + is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00"); + is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)"); is_text(ev("T", vec![t("x")]), "x"); is_text(ev("T", vec![n(1.0)]), ""); } diff --git a/docs/function-parity.md b/docs/function-parity.md index 6dbced2..760ffd6 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (TEXT and number formats: fraction literals, `?` thousands padding, booleans through the text section) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -20,6 +20,8 @@ Excel's worksheet functions, category by category, against GridCraft's function evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE, OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL, MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name. +- **Not on Microsoft's list:** USDOLLAR, a legacy function Excel still evaluates (like DOLLAR), + is in our library as a Compatibility function for files that use it. It is not counted above. - **Working** excludes stubs that return an error by design: the 7 CUBE functions (`#N/A`, no cube connections) and WEBSERVICE and FILTERXML (`#VALUE!`). Excel for Mac also lacks WEBSERVICE and FILTERXML; Excel for Windows has them. @@ -106,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled | | 2026-10-11 | minor | Trim-reference operators (`:.`, `.:`, `.:.`) and unbracketed `[[#This Row],Col]` done; dropped the stale "Scope of what's missing" rows for TRIMRANGE, PERCENTOF, BAHTTEXT and the trim references | From f968bb32697c5f4ac02c6ae484704b402818c379 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:05:30 +0200 Subject: [PATCH 23/62] CELL("width") returns the Column Width dialog value, without the padding (#735) --- crates/calc/src/eval.rs | 2 +- crates/calc/src/tests.rs | 15 +++++++++++++++ crates/engine/src/cmd/format.rs | 4 +--- crates/model/src/lib.rs | 2 +- crates/model/src/sheet.rs | 6 ++++++ 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..6f0a44e 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1275,7 +1275,7 @@ impl<'h> Evaluator<'h> { _ => "v", })) } - "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| (s.col_width(c.col) as f64 / 7.0 * 100.0).round() / 100.0).round())), + "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())), "filename" => Ev::V(Value::text(String::new())), "sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())), "protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })), diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..6f1c8c7 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -968,3 +968,18 @@ fn trim_references() { t.set("K20", "=SUM(Q1:.Q9)"); assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); } + +#[test] +fn cell_width_is_the_column_width_dialog_value_rounded() { + // #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them + // (chars * 7 + 5): 10.71 -> 80, 12 -> 89, 10.57 -> 79, 2.86 -> 25; B keeps the default 8.43 (64). + let mut t = T::new(); + let sh = t.wb.sheet_mut(0).unwrap(); + for (col, px) in [(2, 80.0), (3, 89.0), (4, 79.0), (5, 25.0)] { + sh.cols.insert(col, gridcraft_model::LineInfo { size: Some(px), ..Default::default() }); + } + for (at, col) in [("H1", "B"), ("H2", "C"), ("H3", "D"), ("H4", "E"), ("H5", "F")] { + t.set(at, &format!("=CELL(\"width\",{col}1)")); + } + assert_eq!([t.num("H1"), t.num("H2"), t.num("H3"), t.num("H4"), t.num("H5")], [8.0, 11.0, 12.0, 11.0, 3.0]); +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index dde5fa1..a07715b 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -992,9 +992,7 @@ pub fn chars_to_points(c: f64) -> f64 { } } -pub fn points_to_chars(px: f64) -> f64 { - if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 } -} +pub use gridcraft_model::points_to_chars; /// Approximate text width in points for autofit (no font metrics in the engine: average glyph /// widths for the default font; the UI refines with real measurement when it autofits). diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 1bea842..fa64aad 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -19,7 +19,7 @@ pub use cell::{Cell, CellPicture, Formula}; pub use features::*; pub use password::PasswordHash; pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml}; -pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility}; +pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars}; pub use store::CellStore; pub use style::{ Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline, diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index e05d3bf..b404b89 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -117,6 +117,12 @@ pub struct Sheet { pub const DEFAULT_COL_WIDTH: f32 = 64.0; pub const DEFAULT_ROW_HEIGHT: f32 = 20.0; +/// Column width (points) → characters of the default font's digit width (7 px), without the +/// cell padding: the value the Column Width dialog shows. +pub fn points_to_chars(px: f64) -> f64 { + if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 } +} + impl Sheet { pub fn new(name: impl Into) -> Sheet { Sheet { From 4d2e8e3455701857caae78162ad623740e53e0ee Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:16:00 +0200 Subject: [PATCH 24/62] Functions: database functions evaluate computed criteria (part of #737) A formula under a criteria label that names no field is evaluated for each record: its relative references move from the first record to that record, and the database labels name the record's fields, as in Excel. The calc evaluator passes the criteria reference to the database functions through Ctx::computed_criterion. --- crates/calc/src/eval.rs | 39 +++++++++++++-- crates/calc/src/tests.rs | 52 ++++++++++++++++++++ crates/functions/src/database.rs | 83 +++++++++++++++++--------------- crates/functions/src/lib.rs | 8 ++- 4 files changed, 137 insertions(+), 45 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..98c1179 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -88,6 +88,8 @@ pub struct Evaluator<'h> { struct FnCtx<'a, 'h> { ev: &'a mut Evaluator<'h>, + /// The criteria argument of a database function, when it is a reference. + criteria: Option, } impl Ctx for FnCtx<'_, '_> { @@ -100,6 +102,24 @@ impl Ctx for FnCtx<'_, '_> { fn random(&mut self) -> f64 { self.ev.host.random() } + /// Excel's "formula as criteria": the formula is written for the first record, so its relative + /// references move down to `record`'s row, and the database's labels name its fields. + fn computed_criterion(&mut self, row: usize, col: usize, db: &Array, record: usize) -> Option { + let area = self.criteria?; + let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?; + let expr = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?.expr_arc()?; + let shifted = gridcraft_formula::adjust::shift_relative((*expr).clone(), record as i64 - 1, 0); + let env = (0..db.cols) + .filter_map(|c| match db.get(0, c) { + Some(Value::Text(t)) if !t.trim().is_empty() => { + Some((t.trim().to_string(), Ev::V(db.get(record, c).cloned().unwrap_or(Value::Empty)))) + } + _ => None, + }) + .collect(); + let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth }; + Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) + } } impl<'h> Evaluator<'h> { @@ -579,7 +599,7 @@ impl<'h> Evaluator<'h> { let value = self.value(a); evaluated.push(Arg { value, from_ref: false }); } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria: None }; return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); } let i = match index.to_number() { @@ -762,8 +782,17 @@ impl<'h> Evaluator<'h> { let mut evaluated = Vec::with_capacity(args.len()); // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. let mut criteria_shape = None; + // Database functions: the criteria range (argument 2), whose formulas are computed criteria. + let mut criteria = None; for (i, a) in args.iter().enumerate() { let mut ev = self.eval(a); + if spec.category == gridcraft_functions::Category::Database + && i == 2 + && let Ev::R(areas) = &ev + && let [area] = areas.as_slice() + { + criteria = Some(*area); + } if resizes_sum_range(name, args.len()) { if i == 0 { criteria_shape = ev_shape(&ev); @@ -791,7 +820,7 @@ impl<'h> Evaluator<'h> { }; evaluated.push(Arg { value, from_ref }); } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria }; gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) } @@ -845,13 +874,13 @@ impl<'h> Evaluator<'h> { }; let args = [Arg { value, from_ref }]; if kind != Agg::Count { - match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) { Value::Number(n) => acc.sum += n, other => return Err(Ev::V(other)), } } if kind != Agg::Sum { - match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self }) { + match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) { Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), other => return Err(Ev::V(other)), } @@ -1221,7 +1250,7 @@ impl<'h> Evaluator<'h> { } } } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria: None }; return match gridcraft_functions::call(spec, &xargs, &mut ctx) { Value::Number(p) if p >= 1.0 => { let k = p as u32 - 1; diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..3eafc41 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -968,3 +968,55 @@ fn trim_references() { t.set("K20", "=SUM(Q1:.Q9)"); assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); } + +/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 46-49): a criteria cell holding +/// a formula under a label that names no field is evaluated for every record. +#[test] +fn database_functions_with_computed_criteria() { + let mut t = T::new(); + let db = [ + ["name", "age", "size", "headgear", "glasses"], + ["Huey", "6", "45", "cap~", "FALSE"], + ["Dewey", "7", "fourty-six", "cap?", "FALSE"], + ["Louie", "8", "47", "cap*", "FALSE"], + ["Donald", "32", "70", "sailor's hat", "FALSE"], + ["Scrooge", "62", "65", "top hat", "TRUE"], + ]; + for (r, row) in db.iter().enumerate() { + for (c, v) in row.iter().enumerate() { + t.set(&format!("{}{}", ["F", "G", "H", "I", "J"][c], r + 2), v); + } + } + t.set("G45", "7"); + let cases = [ + // Relative references are to the first record's row and move to each record's row; + // `$G$45` stays put. + ("G", "=G2<$G$45", "Dewey"), + // Column labels stand for the record's fields. + ("H", "=glasses=TRUE", "Scrooge"), + ("I", "=age<7", "Huey"), + ("J", "=size/7=10", "Donald"), + // An absolute reference to a cell that is not the first record's. + ("K", "=H3=$L$1", "Donald"), + ]; + t.set("L1", "70"); + for (i, (col, formula, _)) in cases.iter().enumerate() { + t.set(&format!("{col}46"), "Formula"); + t.set(&format!("{col}47"), formula); + t.set(&format!("B{}", 46 + i), &format!("=DGET($F$2:$J$7,\"name\",{col}46:{col}47)")); + } + for (i, (_, formula, want)) in cases.iter().enumerate() { + assert_eq!(t.get(&format!("B{}", 46 + i)), Value::text(*want), "{formula}"); + } + // Every database function takes them, ANDed with the other criteria of their row. + // Text sorts after numbers, so Dewey's "fourty-six" is > 40. + t.set("L47", "=size>40"); + t.set("M46", "age"); + t.set("M47", ">6"); + t.set("B51", "=DCOUNT($F$2:$J$7,\"age\",I46:I47)"); + t.set("B52", "=DSUM($F$2:$J$7,\"age\",G46:G47)"); + t.set("B53", "=DCOUNTA($F$2:$J$7,\"name\",L46:M47)"); + assert_eq!(t.num("B51"), 1.0); + assert_eq!(t.num("B52"), 7.0); + assert_eq!(t.num("B53"), 4.0); +} diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index bf0c611..67132e9 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -4,7 +4,9 @@ //! The database and the criteria are arrays whose first row holds column labels. Each criteria //! row is a set of conditions that must all hold (AND); a record matches when any criteria row //! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that -//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. +//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. A +//! formula under a label that names no field is a computed criterion, which the host evaluates +//! for each record ([`Ctx::computed_criterion`]). use std::borrow::Cow; @@ -64,9 +66,10 @@ fn cell_criterion(v: &Value) -> Option { } /// Rows (1-based within the database, excluding the header) of matching records. -fn matching_records(db: &Array, crit: &Array) -> Vec { - // Per criteria row: list of (database column or None when the label is unknown, criterion). - let mut rows: Vec, Criterion)>> = Vec::new(); +fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec { + // Per criteria row: its index and a list of (criteria column, database column or None when + // the label is unknown, criterion). + let mut rows: Vec<(usize, Vec<(usize, Option, Criterion)>)> = Vec::new(); for r in 1..crit.rows { let mut conds = Vec::new(); for c in 0..crit.cols { @@ -77,18 +80,20 @@ fn matching_records(db: &Array, crit: &Array) -> Vec { Some(Value::Number(n)) => find_column(db, &gridcraft_core::number_to_text(*n)), _ => None, }; - conds.push((col, cr)); + conds.push((c, col, cr)); } - rows.push(conds); + rows.push((r, conds)); } let all_match = rows.is_empty(); (1..db.rows) - .filter(|&r| { + .filter(|&rec| { all_match - || rows.iter().any(|conds| { - conds.iter().all(|(col, cr)| match col { - Some(c) => cr.matches(db.get(r, *c).unwrap_or(&Value::Empty)), - None => false, + || rows.iter().any(|(r, conds)| { + conds.iter().all(|(c, col, cr)| match col { + Some(f) => cr.matches(db.get(rec, *f).unwrap_or(&Value::Empty)), + // Under a label that names no field, only a formula (a computed + // criterion) can hold. + None => ctx.computed_criterion(*r, *c, db, rec).unwrap_or(false), }) }) }) @@ -101,7 +106,7 @@ struct Selection<'a> { records: Vec, } -fn select(a: &[Arg]) -> R> { +fn select<'a>(a: &'a [Arg], ctx: &mut dyn Ctx) -> R> { let dbv = &a.first().ok_or(CellError::Value)?.value; let critv = &a.get(2).ok_or(CellError::Value)?.value; if let Value::Error(e) = dbv { @@ -113,31 +118,31 @@ fn select(a: &[Arg]) -> R> { let db = as_array(dbv); let crit = as_array(critv); let field = field_index(&db, a)?; - let records = matching_records(&db, &crit); + let records = matching_records(&db, &crit, ctx); Ok(Selection { db, field, records }) } /// Numbers of the field among matching records (field required). -fn field_numbers(a: &[Arg]) -> R> { - let s = select(a)?; +fn field_numbers(a: &[Arg], c: &mut dyn Ctx) -> R> { + let s = select(a, c)?; let f = s.field.ok_or(CellError::Value)?; Ok(s.records.iter().filter_map(|&r| s.db.get(r, f).and_then(Value::as_f64)).collect()) } -fn dsum(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(field_numbers(a)?.iter().sum()) +fn dsum(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(field_numbers(a, c)?.iter().sum()) } -fn daverage(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn daverage(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; if v.is_empty() { return Err(CellError::Div0); } num_val(v.iter().sum::() / v.len() as f64) } -fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dcount(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let n = match s.field { Some(f) => s.records.iter().filter(|&&r| matches!(s.db.get(r, f), Some(Value::Number(_)))).count(), None => s.records.len(), @@ -145,8 +150,8 @@ fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(n as f64) } -fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dcounta(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let n = match s.field { Some(f) => s.records.iter().filter(|&&r| !matches!(s.db.get(r, f), None | Some(Value::Empty))).count(), None => s.records.len(), @@ -154,8 +159,8 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(n as f64) } -fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dget(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let f = s.field.ok_or(CellError::Value)?; // Records whose field is blank (or "") don't count, as in Excel. let mut found = @@ -167,18 +172,18 @@ fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { } } -fn dmax(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dmax(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0)) } -fn dmin(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dmin(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0)) } -fn dproduct(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dproduct(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; if v.is_empty() { return num_val(0.0); } @@ -196,20 +201,20 @@ fn variance(v: &[f64], sample: bool) -> R { Ok(ss / if sample { n - 1.0 } else { n }) } -fn dvar(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, true)?) +fn dvar(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, true)?) } -fn dvarp(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, false)?) +fn dvarp(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, false)?) } -fn dstdev(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, true)?.sqrt()) +fn dstdev(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, true)?.sqrt()) } -fn dstdevp(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, false)?.sqrt()) +fn dstdevp(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, false)?.sqrt()) } pub(crate) fn specs() -> Vec { diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 282b230..0c5ede3 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::sync::OnceLock; -use gridcraft_core::{CellError, DateSystem, Value}; +use gridcraft_core::{Array, CellError, DateSystem, Value}; /// Builds a [`FnSpec`] whose implementation returns `Result`. /// @@ -105,6 +105,12 @@ pub trait Ctx { fn now_serial(&self) -> f64; /// Uniform in [0, 1). fn random(&mut self) -> f64; + /// For the database functions: whether the formula in the criteria cell at (`row`, `col`) holds + /// for `record` (a row of `db` below its labels). `None` when that cell holds no formula or the + /// criteria didn't come from a reference. + fn computed_criterion(&mut self, _row: usize, _col: usize, _db: &Array, _record: usize) -> Option { + None + } } pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value; From afce16c8d27ffe82a9d983b18af4ec1f8280b9bc Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 19:24:36 +0200 Subject: [PATCH 25/62] Grid: deleting several Ctrl-selected rows removes them all (#758) home.deleteRows/deleteColumns used only the last selected area. They now take every area (or a comma list like "3:3,7:8"), merge them and delete from the last to the first inside one edit, so it is one undo step. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 ++ crates/engine/src/cmd/sheet.rs | 63 ++++++++++++++++++++++++++-------- crates/engine/src/tests.rs | 22 ++++++++++++ 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..ffc306c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Deleting several Ctrl-selected rows or columns removes them all in one undo step (#758) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index f05b77d..1e35d39 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -288,27 +288,62 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { Ok(out) } +/// The areas a line delete/insert targets as `(first line, count)`, merged and sorted from the +/// last line to the first so deleting one never shifts the next. A comma-separated list +/// (`"3:3,7:8"`) or a multi-area selection yields several; `count` forces the single-area form. +fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result> { + let key = if axis == Axis::Rows { "rows" } else { "cols" }; + let spans: Vec<(u32, u32)> = if u32_param(p, "count").is_some() { + let (at, count) = lines(s, p, axis)?; + vec![(at, at.saturating_add(count - 1))] + } else if let Some(t) = str_param(p, key).or_else(|| str_param(p, "range")) { + t.split(',') + .map(|part| { + let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?; + Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }) + }) + .collect::>()? + } else { + s.doc()?.selection.ranges.iter().map(|r| if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect() + }; + let mut spans: Vec<(u32, u32)> = spans.into_iter().map(|(a, b)| (a.min(b), a.max(b))).collect(); + spans.sort_unstable(); + let mut merged: Vec<(u32, u32)> = Vec::new(); + for (a, b) in spans { + match merged.last_mut() { + Some(last) if a <= last.1.saturating_add(1) => last.1 = last.1.max(b), + _ => merged.push((a, b)), + } + } + Ok(merged.into_iter().rev().map(|(a, b)| (a, b - a + 1)).collect()) +} + fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { - let (at, count) = lines(s, p, axis)?; - check_lines_protection(s, p, axis, at, count, false)?; + let areas = delete_areas(s, p, axis)?; + for &(at, count) in &areas { + check_lines_protection(s, p, axis, at, count, false)?; + } let sheet = target_sheet(s, p)?; let out = edit(s, |cx| { let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); cx.protection_checked = true; - { - let sh = cx.sheet_mut(sheet)?; - let block = match axis { - Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)), - Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)), - }; - sh.take_cells(block); - match axis { - Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)), - Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)), + // Last area first, so the earlier ones keep their positions. + for (at, count) in areas { + { + let sh = cx.sheet_mut(sheet)?; + let block = match axis { + Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)), + Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)), + }; + sh.take_cells(block); + match axis { + Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)), + Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)), + } + shift_sheet_features(sh, axis, at, count, false); } - shift_sheet_features(sh, axis, at, count, false); + rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count }); } - rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count }); cx.structural = true; Ok(Json::Null) })?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..668c963 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -560,6 +560,28 @@ fn insert_delete_rows_adjust() { assert_eq!(v(&s, "A1"), Value::Number(10.0)); } +#[test] +fn deleting_a_multi_area_row_selection_removes_every_area_and_undoes_once() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5], [6]]})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=A6"})).unwrap(); + // Ctrl-select rows 2, 4 and 5:6 (A1 refs are 1-based). + let sel = &mut s.doc_mut().unwrap().selection; + sel.ranges = vec![gridcraft_core::RangeRef::rows(1, 1), gridcraft_core::RangeRef::rows(3, 3), gridcraft_core::RangeRef::rows(4, 5)]; + sel.active = CellRef::new(4, 0); + s.doc_mut().unwrap().undo.clear(); + s.execute("home.deleteRows", json!({})).unwrap(); + assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(3.0), Value::Empty)); + assert_eq!(s.execute("cell.get", json!({"cell": "B1"})).unwrap()["formula"], "=#REF!"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!((1..=6).map(|r| v(&s, &format!("A{r}"))).collect::>(), (1..=6).map(|n| Value::Number(n as f64)).collect::>()); + assert!(s.doc().unwrap().undo.is_empty(), "one undo step restored all rows"); + // The explicit list form behaves the same, for columns too. + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap(); + s.execute("home.deleteColumns", json!({"cols": "A:A,C:C"})).unwrap(); + assert_eq!((v(&s, "A1"), v(&s, "B1"), v(&s, "C1")), (Value::Number(2.0), Value::Number(4.0), Value::Empty)); +} + #[test] fn move_columns_reorders_without_overwriting_and_undoes_once() { let mut s = s(); From 2797d468c52ae96d06a132b6bc00466dbba2ff9c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:17:30 +0200 Subject: [PATCH 26/62] Security: cell hyperlinks open only http, https and mailto, after a confirmation (#777) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + apps/gridcraft-web/src/web.rs | 6 ++- apps/gridcraft/src/main.rs | 7 +++ crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/insert.rs | 34 ++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/links.rs | 83 +++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 16 +++++++ crates/ui-egui/src/dialogs.rs | 14 ++++++ crates/ui-egui/src/grid.rs | 17 ++++--- 10 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/links.rs diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..663da50 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Security: cell hyperlinks open only http, https and mailto, after a confirmation; other schemes (file:, smb:, UNC, custom handlers) are refused (#777, part 1) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index e1350a1..d07aad4 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -144,8 +144,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { } })), open_url: Some(Box::new(|url: &str| { - if let Some(w) = web_sys::window() { - let _ = w.open_with_url_and_target(url, "_blank"); + if let Some(w) = web_sys::window() + && let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url) + { + let _ = w.open_with_url_and_target(&url, "_blank"); } })), inbox: Some(inbox), diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 8093205..b48ed90 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -194,6 +194,13 @@ fn services() -> Services { /// Opens a URL or file with the system's default application. fn open_with_system(target: &str) { + // Last line of defence behind `link.open` and the click prompt: the system only ever gets + // http, https and mailto addresses. + let gridcraft_engine::links::LinkKind::External(target) = gridcraft_engine::links::classify(target) else { + log::warn!("refused to open {target:?}: only http, https and mailto links are opened"); + return; + }; + let target = target.as_str(); let cmd = if cfg!(target_os = "macos") { "open" } else if cfg!(windows) { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3da230b..90bc203 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -513,7 +513,7 @@ fn select_col(s: &mut Session, _: &Json) -> Result { Ok(json!({"selection": d.selection.a1()})) } -fn go_to(s: &mut Session, p: &Json) -> Result { +pub(super) fn go_to(s: &mut Session, p: &Json) -> Result { let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) }; // A defined name or table? let d = s.doc()?; diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 392c5ee..284ba3a 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -157,6 +157,15 @@ pub fn specs() -> Vec { has_doc, insert_link ), + cmd!( + "link.open", + "Open Hyperlink", + [], + None, + "{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); only http, https and mailto are opened, any other scheme is an error; the UI asks before opening", + has_doc, + open_link + ), cmd!("review.newNote", "New Note", ["Review", "Notes"], Some("Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)), cmd!("review.newComment", "New Comment", ["Review", "Comments"], Some("Cmd+Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment( s, p, true @@ -1173,6 +1182,31 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { }) } +/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the +/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no +/// dialog; the interactive click asks for confirmation first, in the UI. +fn open_link(s: &mut Session, p: &Json) -> Result { + let target = match str_param(p, "url").or_else(|| str_param(p, "target")) { + Some(t) => t.to_string(), + None => { + let sheet = target_sheet(s, p)?; + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); + let Some(h) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.hyperlinks.get(&at)) else { + return Err(EngineError::Other("There's no hyperlink in this cell.".into())); + }; + h.target.clone() + } + }; + match crate::links::classify(&target) { + crate::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})), + crate::links::LinkKind::External(u) => { + s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone())); + Ok(json!({"opened": u})) + } + crate::links::LinkKind::Refused(why) => Err(EngineError::Other(why)), + } +} + fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result { let sheet = target_sheet(s, p)?; let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3aceab0..adec9fa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod cmd; pub mod display; pub mod fill; pub mod io; +pub mod links; pub mod outline; pub mod paper; pub mod pivot; diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs new file mode 100644 index 0000000..04d8da6 --- /dev/null +++ b/crates/engine/src/links.rs @@ -0,0 +1,83 @@ +//! Which hyperlink targets may be opened. A cell's link comes from the document (often a file +//! somebody sent), so it is hostile input: only `http`, `https` and `mailto` ever leave the +//! workbook for the system; `file:`, `smb:`, UNC paths, custom URL handlers, `javascript:` and +//! the like are refused. Everything without a scheme stays an in-workbook reference. + +use gridcraft_core::RangeRef; + +/// What a link target is. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LinkKind { + /// A cell, range, name or table of the workbook (`Sheet2!A1`, `Total`). + Internal(String), + /// An address to hand to the system, trimmed: `http`, `https` or `mailto` only. + External(String), + /// Refused, with the reason to show. + Refused(String), +} + +const ALLOWED: [&str; 3] = ["http", "https", "mailto"]; + +/// Classifies a link target. Never panics; the single place that decides what may be opened. +pub fn classify(target: &str) -> LinkKind { + let t = target.trim(); + if t.chars().any(char::is_control) { + return LinkKind::Refused("The link contains control characters.".into()); + } + if t.starts_with("\\\\") || t.starts_with("//") { + return LinkKind::Refused("Links to network locations aren't opened.".into()); + } + if RangeRef::parse(t).is_some() { + return LinkKind::Internal(t.to_string()); + } + let Some((scheme, rest)) = t.split_once(':') else { return LinkKind::Internal(t.to_string()) }; + let scheme_like = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')); + if !scheme_like { + return LinkKind::Internal(t.to_string()); + } + let lower = scheme.to_ascii_lowercase(); + if ALLOWED.contains(&lower.as_str()) && !rest.trim().is_empty() { + LinkKind::External(t.to_string()) + } else { + LinkKind::Refused(format!("Links of type \"{lower}:\" aren't opened. Only http, https and mailto links are allowed.")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_http_https_and_mailto_leave_the_workbook() { + for ok in ["http://a.example/x", "HTTPS://A.example", " https://a.example ", "MailTo:a@b.example"] { + assert!(matches!(classify(ok), LinkKind::External(_)), "{ok}"); + } + assert_eq!(classify(" https://a.example "), LinkKind::External("https://a.example".into())); + for bad in [ + "javascript:alert(1)", + "JaVaScRiPt:alert(1)", + "file:///etc/passwd", + "smb://host/share", + "ms-excel:ofe|u|http://x", + "\\\\host\\share\\x.exe", + "//host/share", + "C:\\Windows\\calc.exe", + "\tjavascript:1", + "java\nscript:1", + "\u{1}http://a.example", + "http:", + "mailto:", + "x-custom://a", + ] { + assert!(matches!(classify(bad), LinkKind::Refused(_)), "{bad:?} -> {:?}", classify(bad)); + } + } + + #[test] + fn references_stay_internal() { + for r in ["Sheet2!A1", "'My Sheet'!B2:C3", "A1:B2", "#Sheet1!A1", "Total", "", " "] { + assert!(matches!(classify(r), LinkKind::Internal(_)), "{r:?} -> {:?}", classify(r)); + } + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..ba096cf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2795,3 +2795,19 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() { assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched"); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn link_open_refuses_every_scheme_but_http_https_and_mailto() { + let mut s = s(); + s.execute("insert.link", json!({"cell": "A1", "target": "file:///etc/passwd"})).unwrap(); + s.take_ui_requests(); + for url in ["file:///etc/passwd", "smb://h/s", "\\\\h\\s", "JAVASCRIPT:1"] { + assert!(s.execute("link.open", json!({"url": url})).is_err(), "{url}"); + } + assert!(s.execute("link.open", json!({"cell": "A1"})).is_err()); + assert!(s.take_ui_requests().is_empty()); + s.execute("link.open", json!({"url": "HTTPS://example.org/a"})).unwrap(); + assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl("HTTPS://example.org/a".into())]); + s.execute("link.open", json!({"url": "B2"})).unwrap(); + assert!(s.take_ui_requests().is_empty()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c6..82ce68a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -338,6 +338,20 @@ impl Dialog { json!({"text": existing}), ) } + // Asks before a web or e-mail link leaves the workbook; OK runs `link.open`, which + // refuses anything but http, https and mailto. + "openLink" => { + let url = params.get("url").and_then(Json::as_str).unwrap_or_default().to_string(); + Dialog::form( + "openLink", + "Open Hyperlink", + "link.open", + vec![Note(format!( + "This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook.\n\n{url}" + ))], + json!({"url": url}), + ) + } "insertLink" => Dialog::form( "insertLink", "Insert Hyperlink", diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..2e39f33 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2281,15 +2281,18 @@ fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool { true } +/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's +/// OK runs `link.open`); every other kind is refused with a message. The decision is the +/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies. pub fn follow_link(app: &mut SheetApp, target: &str) { - if target.contains("://") || target.starts_with("mailto:") { - if let Some(f) = &app.services.open_url { - f(target); + use gridcraft_engine::links::{LinkKind, classify}; + match classify(target) { + LinkKind::Internal(r) => { + let _ = app.session.run("edit.goTo", json!({"reference": r.trim_start_matches('#')})); + app.grid.ensure_visible = true; } - } else { - let t = target.trim_start_matches('#'); - let _ = app.session.run("edit.goTo", json!({"reference": t})); - app.grid.ensure_visible = true; + LinkKind::External(url) => app.open_dialog("openLink", json!({"url": url})), + LinkKind::Refused(why) => app.message = Some(("Hyperlink".into(), why)), } } From 47c0af3f1b8887c6ace710b8a1fc047bb41e6b91 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:34:07 +0200 Subject: [PATCH 27/62] Data: Forecast Sheet Forecast Start option (#785) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/forecast.rs | 110 ++++++++++++++++++++++++------ crates/ui-egui/src/dialogs.rs | 1 + docs/gaps.md | 3 +- 4 files changed, 94 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..a928c68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Forecast Sheet gets Forecast Start: begin the forecast inside the history to compare it with the actuals (#785) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 13c120c..9795ea6 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Forecast Sheet", ["Data", "Forecast"], None, - "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", + "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", has_doc, forecast_sheet )] @@ -223,13 +223,39 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) { return Err(EngineError::Other("There are no values to forecast.".into())); } - let end = match p.get("end") { - Some(Json::Number(n)) => n.as_f64(), - Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { - Value::Number(end) => Some(end), - _ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))), - }, - _ => None, + let time_param = |key: &str| -> Result> { + match p.get(key) { + Some(Json::Number(n)) => Ok(n.as_f64()), + Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { + Value::Number(x) => Ok(Some(x)), + _ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))), + }, + None | Some(Json::Null) => Ok(None), + Some(Json::String(_)) => Ok(None), + Some(_) => Err(bad(CMD, format!("`{key}` is not a number or a date"))), + } + }; + let end = time_param("end")?; + // Forecast Start: the index (into `rows`) of the first forecast point, or `rows.len()` for the + // usual forecast after the history. + let fit = match time_param("forecastStart")? { + None => rows.len(), + Some(start) => { + let last_t = times.last().copied().unwrap_or(f64::NAN); + let tol = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0); + if tol(start, last_t) || timeline.after_last(1).is_some_and(|next| tol(start, next)) { + rows.len() + } else { + let i = times + .iter() + .position(|t| tol(*t, start)) + .ok_or_else(|| bad(CMD, "`forecastStart` is not on the timeline (it is a point of the history, before its last point)"))?; + if rows.get(..i).is_none_or(|h| h.iter().filter(|r| matches!(r.1, Value::Number(_))).count() < 2) { + return Err(EngineError::Other("The forecast start needs at least two values before it to fit the forecast.".into())); + } + i + } + } }; let cap = max_periods(rows.len()); let periods = match end { @@ -252,8 +278,9 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { // Columns: A timeline, B values, C forecast, D/E bounds. let n = rows.len() as u32; let last = n + 1; // 1-based row of the last history point + let fit_last = fit as u32 + 1; // 1-based row of the last point the model is fit to let end_row = last + periods; // 1-based row of the last forecast - let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col)); + let hist = |col: u32| format!("${0}$2:${0}${fit_last}", col_letters(col)); let mut tail = format!("{},{}", opts.seasonality, opts.fill); if let Some(a) = opts.aggregate { tail.push_str(&format!(",{a}")); @@ -305,22 +332,30 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { put(&mut sheet, r, 1, v.clone(), vstyle); } } - // The last history point starts the forecast lines, so they join the values. - formula(&mut sheet, n, 2, format!("=B{last}"), vstyle); + // The last point the model is fit to starts the forecast lines, so they join the values. + let join = fit_last - 1; // 0-based row + formula(&mut sheet, join, 2, format!("=B{fit_last}"), vstyle); if opts.confidence.is_some() { - formula(&mut sheet, n, 3, format!("=C{last}"), vstyle); - formula(&mut sheet, n, 4, format!("=C{last}"), vstyle); + formula(&mut sheet, join, 3, format!("=C{fit_last}"), vstyle); + formula(&mut sheet, join, 4, format!("=C{fit_last}"), vstyle); } let (tl, vl) = (hist(0), hist(1)); + let forecast_row = |sheet: &mut Sheet, r: u32| { + formula(sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle); + if let Some(c) = opts.confidence { + let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})"); + formula(sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle); + formula(sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle); + } + }; + // Hindcast: forecasts over the actuals from the start to the last history point. + for r in (fit_last + 1)..=last { + forecast_row(&mut sheet, r); + } for (k, t) in future.iter().enumerate() { let r = last + k as u32 + 1; // 1-based put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle); - formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle); - if let Some(c) = opts.confidence { - let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})"); - formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle); - formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle); - } + forecast_row(&mut sheet, r); } if opts.statistics { put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold); @@ -407,7 +442,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { if let Ok(d) = s.doc_mut() { d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default()); } - Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods})) + Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods, "fitted": fit})) } /// Forecast rows needed to reach `end` (rounded up to whole steps). @@ -545,4 +580,39 @@ mod tests { s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap(); assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err()); } + + #[test] + fn forecast_start_hindcasts_over_the_actuals() { + let mut s = session_with_history(); // t = 1..16 in A2:A17 + let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": 13, "periods": 2, "confidenceInterval": false})).unwrap(); + assert_eq!(r["fitted"], 12); + // Fit to t = 1..12 (rows 2..13); the line joins at row 13 and forecasts rows 14..19. + assert_eq!(formula(&s, 0, "C13"), "B13"); + assert_eq!(value(&s, 0, "C13"), value(&s, 0, "B13")); + assert_eq!(formula(&s, 0, "C14"), "FORECAST.ETS(A14,$B$2:$B$13,$A$2:$A$13,1,1)"); + assert_eq!(formula(&s, 0, "C19"), "FORECAST.ETS(A19,$B$2:$B$13,$A$2:$A$13,1,1)"); + // The exact seasonal series is recovered, and the actual is still shown next to it. + let c14 = value(&s, 0, "C14").as_f64().unwrap(); + assert!((c14 - 39.0).abs() < 1e-6, "{c14}"); // t = 13: 10 + 26 + 3 + assert!(value(&s, 0, "B14").as_f64().is_some()); + // Start at the last point or one step later: as without forecastStart. + for start in [json!(16), json!(17)] { + let r = s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "forecastStart": start, "periods": 2})).unwrap(); + assert_eq!(r["fitted"], 16); + } + assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)"); + } + + #[test] + fn forecast_start_invalid_values_are_errors() { + let mut s = session_with_history(); + let sheets = s.doc().unwrap().wb.sheets.len(); + for bad in [json!(0), json!(2), json!(3.5), json!(18), json!(1000), json!("abc"), json!([1]), json!(1e308)] { + assert!(s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets); + // Blank or null behaves like no start; empty params unchanged. + let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": ""})).unwrap(); + assert_eq!(r["fitted"], 16); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c6..1aabd9d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -415,6 +415,7 @@ impl Dialog { Text { key: "timeline", label: "Timeline range (blank: first column of the selection):" }, Text { key: "values", label: "Values range (blank: second column of the selection):" }, Text { key: "end", label: "Forecast end (date or number; blank: a third of the history):" }, + Text { key: "forecastStart", label: "Forecast start (a date or number in the history; blank: after the last point):" }, Check { key: "confidenceInterval", label: "Confidence interval" }, Text { key: "confidence", label: "Confidence level (%):" }, Text { key: "seasonality", label: "Seasonality (auto, 0 for none, or a season length):" }, diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b8..eefab42 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -106,7 +106,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) - `data.solver`, `data.analysisToolPak` missing. Forecast Sheet (`data.forecastSheet`) and the - FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 15–25 h. + FORECAST.ETS family are in, including Forecast Start (hindcast, #785). **Estimate:** 15–25 h. ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. @@ -160,6 +160,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | From 26ed66cfe1cd8de9f936e1c4512994cf95c5b90f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Marcin=20Brzuchalski?= Date: Mon, 12 Oct 2026 20:35:10 +0200 Subject: [PATCH 28/62] i18n: look up the Format Cells dialog labels in the catalog The Format Cells dialog drew English literals in every language. Its tabs, labels, options and protection note now go through Language::tr, as do dialog titles and the shared OK/Cancel buttons. The Fill tab is keyed Format Cells|Fill because the Alignment tab's Fill is a different word in some languages. d.tab keeps the English name. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 91 +++++++++++++++++------------ crates/ui-egui/tests/interaction.rs | 19 ++++++ docs/localization-parity.md | 6 +- 3 files changed, 78 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c6..ade0968 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -891,7 +891,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "solver" => 460.0, _ => 380.0, }; - let window = egui::Window::new(d.title.clone()) + let window = egui::Window::new(app.ui.language.tr(&d.title)) .id(egui::Id::new("dialog").with(&d.name)) .collapsible(false) .resizable(false) @@ -1223,11 +1223,15 @@ fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent)); + let lang = crate::i18n::current(ui.ctx()); + let ok = ui.add( + egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)) + .fill(crate::theme::Tokens::get(ui.ctx()).accent), + ); if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { *confirm = true; } - if ui.button(" Cancel ").clicked() { + if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { *open = false; } }); @@ -1362,9 +1366,20 @@ fn macros(ui: &mut egui::Ui, d: &mut Dialog) { } fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn + // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a + // different word in some languages. + let lang = app.ui.language; ui.horizontal(|ui| { - for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] { - if ui.selectable_label(d.tab == tab, tab).clicked() { + for (tab, key) in [ + ("Number", "Number"), + ("Alignment", "Alignment"), + ("Font", "Font"), + ("Border", "Border"), + ("Fill", "Format Cells|Fill"), + ("Protection", "Protection"), + ] { + if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() { d.tab = tab.to_string(); } } @@ -1376,20 +1391,20 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: "Number" => { let mut code = st.num_fmt.as_str().to_string(); ui.columns(2, |cols| { - cols[0].label(egui::RichText::new("Category:").strong()); + cols[0].label(egui::RichText::new(lang.tr("Category:")).strong()); for name in ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] { let c = gridcraft_engine::cmd::format::format_code_for(name).to_string(); - if cols[0].selectable_label(code == c, name).clicked() { + if cols[0].selectable_label(code == c, lang.tr(name)).clicked() { code = c; } } - cols[1].label(egui::RichText::new("Sample").strong()); + cols[1].label(egui::RichText::new(lang.tr("Sample")).strong()); let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); cols[1].add_space(8.0); - cols[1].label("Type (custom format code):"); + cols[1].label(lang.tr("Type (custom format code):")); cols[1].text_edit_singleline(&mut code); for c in [ "0", @@ -1418,7 +1433,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } "Alignment" => { use gridcraft_engine::model::{HAlign, VAlign}; - ui.label(egui::RichText::new("Horizontal").strong()); + ui.label(egui::RichText::new(lang.tr("Horizontal")).strong()); ui.horizontal_wrapped(|ui| { for (h, n) in [ (HAlign::General, "General"), @@ -1430,10 +1445,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (HAlign::CenterAcross, "Center Across Selection"), (HAlign::Distributed, "Distributed"), ] { - ui.radio_value(&mut st.align.h, h, n); + ui.radio_value(&mut st.align.h, h, lang.tr(n)); } }); - ui.label(egui::RichText::new("Vertical").strong()); + ui.label(egui::RichText::new(lang.tr("Vertical")).strong()); ui.horizontal_wrapped(|ui| { for (v, n) in [ (VAlign::Top, "Top"), @@ -1442,29 +1457,29 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (VAlign::Justify, "Justify"), (VAlign::Distributed, "Distributed"), ] { - ui.radio_value(&mut st.align.v, v, n); + ui.radio_value(&mut st.align.v, v, lang.tr(n)); } }); let mut indent = st.align.indent as f32; - ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text("Indent")); + ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent"))); st.align.indent = indent as u8; let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 }; - ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text("Orientation (degrees)")); + ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)"))); st.align.rotation = rot as i16; - ui.checkbox(&mut st.align.wrap, "Wrap text"); - ui.checkbox(&mut st.align.shrink, "Shrink to fit"); + ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); + ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); } "Font" => { - egui::ComboBox::from_label("Font").selected_text(st.font.name.clone()).show_ui(ui, |ui| { + egui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| { for f in crate::fonts::families() { ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); } }); - ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text("Size")); + ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size"))); ui.horizontal(|ui| { - ui.checkbox(&mut st.font.bold, "Bold"); - ui.checkbox(&mut st.font.italic, "Italic"); - ui.checkbox(&mut st.font.strike, "Strikethrough"); + ui.checkbox(&mut st.font.bold, lang.tr("Bold")); + ui.checkbox(&mut st.font.italic, lang.tr("Italic")); + ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough")); }); use gridcraft_engine::model::{Underline, VertAlign}; ui.horizontal(|ui| { @@ -1475,17 +1490,17 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (Underline::SingleAccounting, "Single Accounting"), (Underline::DoubleAccounting, "Double Accounting"), ] { - ui.radio_value(&mut st.font.underline, u, n); + ui.radio_value(&mut st.font.underline, u, lang.tr(n)); } }); ui.horizontal(|ui| { - ui.radio_value(&mut st.font.vert, VertAlign::Baseline, "Normal"); - ui.radio_value(&mut st.font.vert, VertAlign::Superscript, "Superscript"); - ui.radio_value(&mut st.font.vert, VertAlign::Subscript, "Subscript"); + ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); + ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); + ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); }); - ui.label("Color:"); + ui.label(lang.tr("Color:")); let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "Automatic") { + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) { st.font.color = if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() }; } @@ -1494,10 +1509,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: use gridcraft_engine::model::{BorderLine, BorderStyle}; let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto }; ui.horizontal(|ui| { - if ui.button("None").clicked() { + if ui.button(lang.tr("None")).clicked() { st.borders = Default::default(); } - if ui.button("Outline").clicked() { + if ui.button(lang.tr("Outline")).clicked() { st.borders.top = line; st.borders.bottom = line; st.borders.left = line; @@ -1512,6 +1527,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: ("Diagonal ↘", &mut st.borders.diag_down), ("Diagonal ↗", &mut st.borders.diag_up), ] { + let n = lang.tr(n); ui.horizontal(|ui| { ui.label(format!("{n}:")); for (s, label) in [ @@ -1523,6 +1539,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (BorderStyle::Dotted, "dotted"), (BorderStyle::Double, "double"), ] { + let label = lang.tr(label); let response = ui.selectable_label(slot.style == s, label); response .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}"))); @@ -1537,8 +1554,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } "Fill" => { let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - ui.label("Background Color:"); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") { + ui.label(lang.tr("Background Color:")); + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) { st.fill = if c == "none" { Default::default() } else { @@ -1547,11 +1564,13 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } } "Protection" => { - ui.checkbox(&mut st.protection.locked, "Locked"); - ui.checkbox(&mut st.protection.hidden, "Hidden"); + ui.checkbox(&mut st.protection.locked, lang.tr("Locked")); + ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); ui.label( - egui::RichText::new("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet).") - .small(), + egui::RichText::new( + lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."), + ) + .small(), ); } _ => {} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 7093933..8e8eaaa 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -279,6 +279,25 @@ fn language_switch_translates_the_ribbon_and_persists() { assert_eq!(h.state().ui.language, Language::En); } +#[test] +fn format_cells_draws_its_labels_in_the_interface_language() { + let mut h = harness(blank()); + h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap(); + h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); + h.run_steps(2); + assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated"); + assert!(h.query_by_label("Scientific").is_none(), "no English category is left"); + h.get_by_label("Wissenschaftlich").click(); + h.run_steps(2); + let style = &h.state().dialog.as_ref().unwrap().values["style"]; + assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code"); + h.state_mut().dialog = None; + h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap(); + h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); + h.run_steps(2); + assert!(h.query_by_label("Scientific").is_some(), "English is unchanged"); +} + #[test] fn name_manager_new_and_edit_open_the_name_dialog_and_come_back() { // #238: New… in the Name Manager did nothing (the manager put itself back over the dialog it diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e7d1de9..6f05a60 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German ribbon catalog added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -14,7 +14,8 @@ right-to-left sheets and full complex-script support. Part of system language (closes #20) and a View-tab switcher, with English-keyed lookup tables: Japanese 268 labels, Simplified Chinese, Korean, Russian, Brazilian Portuguese, German and Czech 266 each. The tables are used by - the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`); dialogs, menus, + the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`), the Format Cells + dialog, every dialog title and the shared OK/Cancel buttons; the other dialogs, menus, context menus, task panes, status messages, command labels and function descriptions are still English literals. Untranslated labels fall back to English. - **About 2,200 user-visible strings** in all (heuristic source scan: ~1,700 UI and command @@ -77,6 +78,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | | 2026-10-11 | minor | Sheet Right-to-Left mirrors the grid (#146) | | 2026-10-11 | minor | Czech (cs) ribbon table: 266 labels (~12%), menus only | From 339f8ed35b0eab58068ffaa6be31c37ef9a54a83 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:57:02 +0200 Subject: [PATCH 29/62] Page Layout view: drag the margins on the rulers (#792) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 + crates/ui-egui/src/grid.rs | 3 + crates/ui-egui/src/i18n.rs | 4 + crates/ui-egui/src/i18n/cs.rs | 4 + crates/ui-egui/src/i18n/de.rs | 4 + crates/ui-egui/src/i18n/es.rs | 4 + crates/ui-egui/src/i18n/fr.rs | 4 + crates/ui-egui/src/i18n/ko.rs | 4 + crates/ui-egui/src/i18n/pt.rs | 4 + crates/ui-egui/src/i18n/ru.rs | 4 + crates/ui-egui/src/i18n/zh.rs | 4 + crates/ui-egui/src/paper_view.rs | 221 +++++++++++++++++++++++ crates/ui-egui/tests/page_layout_view.rs | 54 ++++++ docs/gaps.md | 4 +- docs/ui-parity.md | 3 +- 15 files changed, 320 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..6907953 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Page Layout view: drag the margin edges on the rulers to set the page margins (resize cursor, live preview, value tooltip, one undo step, Esc cancels) (#792) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..04c924d 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -100,6 +100,8 @@ pub struct GridState { pub paper: Option, /// Header or footer being edited in place (Page Layout view). pub hf_edit: Option, + /// A page margin being dragged on a ruler (Page Layout view); committed on release. + pub ruler_drag: Option, /// Insert › Header & Footer: start editing the first page's header once Page Layout view is up. pub hf_start: bool, /// The grid geometry of the last frame (hit-testing for agents and tests). @@ -493,6 +495,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if paper { let probe = Geo::new(sh, grid_rect, scroll); if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { + let g = crate::paper_view::with_dragged_margin(app, si, g); return Geo::paged(sh, grid_rect, scroll, g); } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 406cc3d..27230b1 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -290,6 +290,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Select\nObjects", "オブジェクトの選択"), ("Themes", "テーマ"), ("Margins", "余白"), + ("Left Margin", "左余白"), + ("Right Margin", "右余白"), + ("Top Margin", "上余白"), + ("Bottom Margin", "下余白"), ("Size", "サイズ"), ("Print\nArea", "印刷範囲"), ("Set Print Area", "印刷範囲の設定"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index ca2e559..96a5a11 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -116,6 +116,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Vybrat\nobjekty", "Themes" => "Motivy", "Margins" => "Okraje", + "Left Margin" => "Levý okraj", + "Right Margin" => "Pravý okraj", + "Top Margin" => "Horní okraj", + "Bottom Margin" => "Dolní okraj", "Size" => "Velikost", "Print\nArea" => "Oblast\ntisku", "Set Print Area" => "Nastavit oblast tisku", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2ad37f6..30bff11 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -116,6 +116,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Objekte\nauswählen", "Themes" => "Designs", "Margins" => "Seitenränder", + "Left Margin" => "Linker Seitenrand", + "Right Margin" => "Rechter Seitenrand", + "Top Margin" => "Oberer Seitenrand", + "Bottom Margin" => "Unterer Seitenrand", "Size" => "Größe", "Print\nArea" => "Druck-\nbereich", "Set Print Area" => "Druckbereich festlegen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index da41bd7..b97cce8 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -117,6 +117,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Seleccionar\nobjetos", "Themes" => "Temas", "Margins" => "Márgenes", + "Left Margin" => "Margen izquierdo", + "Right Margin" => "Margen derecho", + "Top Margin" => "Margen superior", + "Bottom Margin" => "Margen inferior", "Size" => "Tamaño", "Print\nArea" => "Área de\nimpresión", "Set Print Area" => "Establecer área de impresión", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 7bf9fd1..1ecddfa 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -124,6 +124,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Sélection d’objets", "Themes" => "Thèmes", "Margins" => "Marges", + "Left Margin" => "Marge gauche", + "Right Margin" => "Marge droite", + "Top Margin" => "Marge haute", + "Bottom Margin" => "Marge basse", "Size" => "Taille", "Print\nArea" => "Zone d’impression", "Set Print Area" => "Définir la zone d’impression", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index b684c78..1642739 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "개체\n선택", "Themes" => "테마", "Margins" => "여백", + "Left Margin" => "왼쪽 여백", + "Right Margin" => "오른쪽 여백", + "Top Margin" => "위쪽 여백", + "Bottom Margin" => "아래쪽 여백", "Size" => "용지 크기", "Print\nArea" => "인쇄\n영역", "Set Print Area" => "인쇄 영역 설정", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index b554ef9..9d6a128 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -122,6 +122,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Selecionar\nObjetos", "Themes" => "Temas", "Margins" => "Margens", + "Left Margin" => "Margem esquerda", + "Right Margin" => "Margem direita", + "Top Margin" => "Margem superior", + "Bottom Margin" => "Margem inferior", "Size" => "Tamanho", "Print\nArea" => "Área de\nImpressão", "Set Print Area" => "Definir Área de Impressão", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index c8f0445..221b870 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "Выбрать\nобъекты", "Themes" => "Темы", "Margins" => "Поля", + "Left Margin" => "Левое поле", + "Right Margin" => "Правое поле", + "Top Margin" => "Верхнее поле", + "Bottom Margin" => "Нижнее поле", "Size" => "Размер", "Print\nArea" => "Область\nпечати", "Set Print Area" => "Задать область печати", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index d1cf704..477ab82 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -121,6 +121,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select\nObjects" => "选择\n对象", "Themes" => "主题", "Margins" => "页边距", + "Left Margin" => "左边距", + "Right Margin" => "右边距", + "Top Margin" => "上边距", + "Bottom Margin" => "下边距", "Size" => "纸张大小", "Print\nArea" => "打印\n区域", "Set Print Area" => "设置打印区域", diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 110193a..69784de 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -284,6 +284,7 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo commit(app); } let Some(pg) = geo.paper.clone() else { return }; + ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet); if std::mem::take(&mut app.grid.hf_start) { begin(app, wb.active_sheet, sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 }); } @@ -594,6 +595,197 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, ); } +/// A page edge that can be dragged on a ruler. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Edge { + Left, + Right, + Top, + Bottom, +} + +impl Edge { + /// Index into `PrintSettings::margins` / `PageGrid::margins`. + fn index(self) -> usize { + match self { + Edge::Left => 0, + Edge::Right => 1, + Edge::Top => 2, + Edge::Bottom => 3, + } + } + /// The `pageLayout.pageSetup` margin key. + fn key(self) -> &'static str { + ["left", "right", "top", "bottom"][self.index()] + } + fn label(self) -> &'static str { + ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin"][self.index()] + } + fn horizontal(self) -> bool { + matches!(self, Edge::Left | Edge::Right) + } +} + +/// A margin being dragged on a ruler: the page it started on and the previewed value (inches). +#[derive(Clone, Debug)] +pub struct RulerDrag { + pub sheet: usize, + pub edge: Edge, + /// Page column (horizontal ruler) or row (vertical ruler) the drag started on. + pub page: usize, + pub start_inches: f32, + pub inches: f32, +} + +/// Content that must stay between the margins, in points. +const MIN_CONTENT_PT: f64 = 36.0; +/// Screen distance within which a ruler margin edge is grabbed. +const EDGE_GRAB: f32 = 4.0; + +/// Margin (inches, rounded to 0.01) for an edge dragged to `rel_pt` points from the page's +/// left/top edge. The margin stays on the page and leaves [`MIN_CONTENT_PT`] between the two +/// margins of its axis. `None` for NaN/infinite input or a page without a size. +pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 4]) -> Option { + let page = f64::from(if edge.horizontal() { page_pt.0 } else { page_pt.1 }); + if !rel_pt.is_finite() || !page.is_finite() || page <= 0.0 { + return None; + } + let other = f64::from(margins_in.get(edge.index() ^ 1).copied().filter(|v| v.is_finite())?).max(0.0) * 72.0; + let raw = match edge { + Edge::Left | Edge::Top => rel_pt, + Edge::Right | Edge::Bottom => page - rel_pt, + }; + let max = (page - other - MIN_CONTENT_PT).max(0.0); + let pt = raw.clamp(0.0, max); + Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) +} + +/// The grid with a margin being dragged applied, so the page content follows the pointer. +pub(crate) fn with_dragged_margin(app: &SheetApp, si: usize, pg: Arc) -> Arc { + let Some(d) = app.grid.ruler_drag.as_ref().filter(|d| d.sheet == si) else { return pg }; + // Layout units per point (the print scale and pixel/point ratio). + let per_pt = pg.page_w / f64::from(pg.paper_pt.0); + let mut g = (*pg).clone(); + let v = f64::from(d.inches) * 72.0 * per_pt; + if let Some(m) = g.margins.get_mut(d.edge.index()).filter(|_| v.is_finite() && per_pt.is_finite()) { + *m = v; + } + Arc::new(g) +} + +/// Points per layout unit of the grid (paper size over the layout page size). +fn pt_per_unit(pg: &PageGrid) -> Option { + let k = f64::from(pg.paper_pt.0) / pg.page_w; + (k.is_finite() && k > 0.0).then_some(k) +} + +/// The margin edge of a visible page under `pos` on the horizontal (`h`) or vertical ruler. +fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize)> { + let ((i0, i1), (j0, j1)) = visible_pages(geo, pg); + let [ml, mr, mt, mb, ..] = pg.margins; + let mut best: Option<(f32, Edge, usize)> = None; + let mut consider = |d: f32, e: Edge, page: usize| { + if d <= EDGE_GRAB && best.is_none_or(|b| d < b.0) { + best = Some((d, e, page)); + } + }; + if h { + for j in j0..=j1.min(j0.saturating_add(200)) { + let (x, _, w, _) = pg.page_rect(0, j); + consider((screen(geo, x + ml, 0.0).x - pos.x).abs(), Edge::Left, j); + consider((screen(geo, x + w - mr, 0.0).x - pos.x).abs(), Edge::Right, j); + } + } else { + for i in i0..=i1.min(i0.saturating_add(200)) { + let (_, y, _, hh) = pg.page_rect(i, 0); + consider((screen(geo, 0.0, y + mt).y - pos.y).abs(), Edge::Top, i); + consider((screen(geo, 0.0, y + hh - mb).y - pos.y).abs(), Edge::Bottom, i); + } + } + best.map(|(_, e, p)| (e, p)) +} + +/// Dragging the margin edges on the rulers sets the page margins: a resize cursor and a tooltip +/// on hover, a live preview while dragging, one `pageLayout.pageSetup` (one undo step) on +/// release, Esc cancels. +fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid, sh: &Sheet, si: usize) { + let Some(outer) = app.grid.rect else { return }; + if geo.rect.top() - outer.top() < RULER - 0.5 { + app.grid.ruler_drag = None; + return; + } + // Leaving the sheet or the view drops the drag. + if app.grid.ruler_drag.as_ref().is_some_and(|d| d.sheet != si) { + app.grid.ruler_drag = None; + } + let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); + let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); + let margins_in = |sh: &Sheet| -> [f32; 4] { + let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); + [m(0), m(1), m(2), m(3)] + }; + let current = margins_in(sh); + let metric = metric(app); + let tip = |ui: &egui::Ui, app: &SheetApp, edge: Edge, inches: f32| { + let value = if metric { format!("{:.2} cm", inches * 2.54) } else { format!("{inches:.2} in") }; + let text = format!("{}: {value}", app.ui.language.tr(edge.label())); + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("ruler_margin_tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(text); + }); + }; + let cursor = |ui: &egui::Ui, edge: Edge| { + ui.ctx().set_cursor_icon(if edge.horizontal() { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + }; + if let Some(d) = app.grid.ruler_drag.clone() { + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { + app.grid.ruler_drag = None; + return; + } + cursor(ui, d.edge); + let down = ui.input(|i| i.pointer.primary_down()); + let pos = ui.input(|i| i.pointer.latest_pos()); + let mut cur = d.clone(); + if let (Some(pos), Some(k)) = (pos, pt_per_unit(pg)) { + let rel = if d.edge.horizontal() { + let page_x = pg.page_x(d.page); + f64::from(geo.scroll.x) + f64::from((pos.x - geo.cells.left()) / geo.z) - page_x + } else { + let page_y = pg.page_y(d.page); + f64::from(geo.scroll.y) + f64::from((pos.y - geo.cells.top()) / geo.z) - page_y + }; + if let Some(v) = margin_from_drag(d.edge, rel * k, pg.paper_pt, current) { + cur.inches = v; + } + } + if down { + tip(ui, app, cur.edge, cur.inches); + app.grid.ruler_drag = Some(cur); + ui.ctx().request_repaint(); + } else { + app.grid.ruler_drag = None; + if (cur.inches - cur.start_inches).abs() > f32::EPSILON { + app.run_or_alert("pageLayout.pageSetup", json!({"sheet": si, "margins": {cur.edge.key(): cur.inches}})); + } + } + return; + } + for (resp, h) in [(&hresp, true), (&vresp, false)] { + let Some(pos) = resp.hover_pos().or_else(|| resp.interact_pointer_pos()) else { continue }; + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(pos) } else { pos }; + let Some((edge, page)) = edge_at(geo, pg, origin, h) else { continue }; + let value = current.get(edge.index()).copied().unwrap_or(0.5); + if resp.drag_started() { + app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value }); + cursor(ui, edge); + } else if resp.hovered() { + cursor(ui, edge); + tip(ui, app, edge, value); + } + } +} + /// The Header & Footer contextual ribbon tab. pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { use crate::widgets::big_button; @@ -667,3 +859,32 @@ fn sep(ui: &mut egui::Ui) { let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), egui::Sense::hover()); ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator)); } + +#[cfg(test)] +mod tests { + use super::*; + + const A4: (f32, f32) = (595.0, 842.0); + + #[test] + fn drag_maps_to_margins_and_clamps_inside_the_page() { + let m = [0.7, 0.7, 0.75, 0.75]; + // 72 pt from the left edge is one inch; from the right edge the margin is measured inward. + assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); + assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); + assert_eq!(margin_from_drag(Edge::Top, 108.0, A4, m), Some(1.5)); + // Dragged off the page: never negative. + assert_eq!(margin_from_drag(Edge::Left, -50.0, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.0)); + // Dragged across the opposite margin: stops a minimum content width short of it. + let max = margin_from_drag(Edge::Left, 10_000.0, A4, m).unwrap(); + assert!((f64::from(max) * 72.0 - (f64::from(A4.0) - 0.7 * 72.0 - MIN_CONTENT_PT)).abs() < 1.0, "{max}"); + // Hostile input is ignored, not clamped to something arbitrary. + assert_eq!(margin_from_drag(Edge::Left, f64::NAN, A4, m), None); + assert_eq!(margin_from_drag(Edge::Left, f64::INFINITY, A4, m), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, (0.0, 0.0), m), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7]), None); + // Oversized opposite margin leaves nothing to drag into. + assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0]), Some(0.0)); + } +} diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index f8850b3..632b9e2 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -162,3 +162,57 @@ fn insert_header_and_footer_switches_to_page_layout_view() { let e = h.state().grid.hf_edit.as_ref().expect("the header is being edited"); assert_eq!((e.footer, e.section, e.texts[0].as_str()), (false, 1, "Left")); } + +fn margins(h: &Harness) -> [f32; 6] { + h.state().session.doc().unwrap().wb.active().unwrap().print.margins +} + +fn mouse(h: &mut Harness, ev: Event) { + h.input_mut().events.push(ev); + h.step(); +} + +#[test] +fn dragging_a_ruler_edge_previews_commits_on_release_as_one_undo_step_and_esc_cancels() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start_margins = margins(&h); + let ruler_y = h.state().grid.rect.unwrap().top() + 9.0; + let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y); + // One inch further in = 96 layout pixels at 100% print scale; use the screen scale to be exact. + let per_inch = (72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z; + let target = pos2(edge.x + per_inch, ruler_y); + h.run_steps(40); + mouse(&mut h, Event::PointerMoved(edge)); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerMoved(pos2(edge.x + 5.0, ruler_y))); + mouse(&mut h, Event::PointerMoved(target)); + h.run_steps(2); + // Live preview: the page content moved, the document did not change yet. + let live = geo(&h).paper.unwrap().margins[0]; + assert!(live > pg.margins[0] + 1.0, "{live} vs {}", pg.margins[0]); + assert_eq!(margins(&h), start_margins); + let want = (start_margins[0] + 1.0 * 1.0).min(10.0); + mouse(&mut h, Event::PointerButton { pos: target, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let after = margins(&h); + assert!((after[0] - want).abs() < 0.03, "{after:?} want left {want}"); + assert_eq!(after[1..], start_margins[1..]); + // One undo step restores it. + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(margins(&h), start_margins); + // Esc during a drag cancels: nothing is committed. + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y); + h.run_steps(40); + mouse(&mut h, Event::PointerMoved(edge)); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerMoved(pos2(edge.x + per_inch, ruler_y))); + mouse(&mut h, Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(margins(&h), start_margins); +} diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b8..c595c1c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -110,8 +110,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. - Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging - margins on the ruler. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. + Margins are dragged on the rulers (#792). Remaining: pictures in headers/footers, Different First Page / Odd & Even pages. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. ### 15. Remaining functions and an oracle suite (F) - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. @@ -160,6 +159,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..52dd3a3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -28,7 +28,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | header/footer pictures, Different First Page / Odd & Even, ruler margin dragging missing (1–3 h) | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction @@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | From c4ad8e3a9bfea58156bb92df10b68c12a653dfa9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:01:09 +0200 Subject: [PATCH 30/62] Page Layout view: header/footer margins on the ruler, live re-pagination, right-to-left rulers (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/grid.rs | 2 +- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/paper_view.rs | 90 +++++++++++++------ crates/ui-egui/tests/page_layout_view.rs | 108 +++++++++++++++++++++++ docs/ui-parity.md | 2 +- 13 files changed, 191 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 04c924d..80af151 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -495,7 +495,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if paper { let probe = Geo::new(sh, grid_rect, scroll); if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { - let g = crate::paper_view::with_dragged_margin(app, si, g); + let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); return Geo::paged(sh, grid_rect, scroll, g); } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 27230b1..e2566a6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -294,6 +294,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Right Margin", "右余白"), ("Top Margin", "上余白"), ("Bottom Margin", "下余白"), + ("Header Margin", "ヘッダーの余白"), + ("Footer Margin", "フッターの余白"), ("Size", "サイズ"), ("Print\nArea", "印刷範囲"), ("Set Print Area", "印刷範囲の設定"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 96a5a11..bdcedbf 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -120,6 +120,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Pravý okraj", "Top Margin" => "Horní okraj", "Bottom Margin" => "Dolní okraj", + "Header Margin" => "Okraj záhlaví", + "Footer Margin" => "Okraj zápatí", "Size" => "Velikost", "Print\nArea" => "Oblast\ntisku", "Set Print Area" => "Nastavit oblast tisku", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 30bff11..75653e5 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -120,6 +120,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Rechter Seitenrand", "Top Margin" => "Oberer Seitenrand", "Bottom Margin" => "Unterer Seitenrand", + "Header Margin" => "Kopfzeilenabstand", + "Footer Margin" => "Fußzeilenabstand", "Size" => "Größe", "Print\nArea" => "Druck-\nbereich", "Set Print Area" => "Druckbereich festlegen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index b97cce8..0582110 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -121,6 +121,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Margen derecho", "Top Margin" => "Margen superior", "Bottom Margin" => "Margen inferior", + "Header Margin" => "Margen del encabezado", + "Footer Margin" => "Margen del pie de página", "Size" => "Tamaño", "Print\nArea" => "Área de\nimpresión", "Set Print Area" => "Establecer área de impresión", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 1ecddfa..0db9df4 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -128,6 +128,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Marge droite", "Top Margin" => "Marge haute", "Bottom Margin" => "Marge basse", + "Header Margin" => "Marge de l'en-tête", + "Footer Margin" => "Marge du pied de page", "Size" => "Taille", "Print\nArea" => "Zone d’impression", "Set Print Area" => "Définir la zone d’impression", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 1642739..f3e1688 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "오른쪽 여백", "Top Margin" => "위쪽 여백", "Bottom Margin" => "아래쪽 여백", + "Header Margin" => "머리글 여백", + "Footer Margin" => "바닥글 여백", "Size" => "용지 크기", "Print\nArea" => "인쇄\n영역", "Set Print Area" => "인쇄 영역 설정", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 9d6a128..d6185a5 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -126,6 +126,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Margem direita", "Top Margin" => "Margem superior", "Bottom Margin" => "Margem inferior", + "Header Margin" => "Margem do cabeçalho", + "Footer Margin" => "Margem do rodapé", "Size" => "Tamanho", "Print\nArea" => "Área de\nImpressão", "Set Print Area" => "Definir Área de Impressão", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 221b870..868f653 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "Правое поле", "Top Margin" => "Верхнее поле", "Bottom Margin" => "Нижнее поле", + "Header Margin" => "Поле верхнего колонтитула", + "Footer Margin" => "Поле нижнего колонтитула", "Size" => "Размер", "Print\nArea" => "Область\nпечати", "Set Print Area" => "Задать область печати", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 477ab82..a9989f2 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -125,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Right Margin" => "右边距", "Top Margin" => "上边距", "Bottom Margin" => "下边距", + "Header Margin" => "页眉边距", + "Footer Margin" => "页脚边距", "Size" => "纸张大小", "Print\nArea" => "打印\n区域", "Set Print Area" => "设置打印区域", diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 69784de..f560f13 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -524,17 +524,21 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let minor = if metric { 2.0 } else { 4.0 }; let label_every = ((22.0 / unit).ceil() as usize).max(1); let font = theme::ui_font(9.5); - let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); + let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + // Right-to-left sheets: the horizontal ruler is mirrored with the pages (ticks run from the + // right margin leftwards). + let mx = |x: f32| geo.mirror_x(x); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window); let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg); - let [ml, mr, mt, mb, ..] = pg.margins; + let [ml, mr, mt, mb, mh, mf] = pg.margins; // One ruler span per page: margins shaded, the printable part light, ticks from the margin. let span = |p: &Painter, from: f32, to: f32, m0: f32, m1: f32, horizontal: bool, area: Rect| { let band = |a: f32, b: f32| { if horizontal { - Rect::from_min_max(pos2(a, area.top() + 2.0), pos2(b, area.bottom() - 2.0)) + let (a, b) = (mx(a), mx(b)); + Rect::from_min_max(pos2(a.min(b), area.top() + 2.0), pos2(a.max(b), area.bottom() - 2.0)) } else { Rect::from_min_max(pos2(area.left() + 2.0, a), pos2(area.right() - 2.0, b)) } @@ -551,6 +555,7 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, if pos > m1 + 0.5 { break; } + let pos = if horizontal { mx(pos) } else { pos }; let major = s.is_multiple_of(minor as usize); let len = if major { 6.0 @@ -587,6 +592,12 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let a = screen(geo, 0.0, y).y; let b = screen(geo, 0.0, y + h).y; span(p, a, b, screen(geo, 0.0, y + mt).y, screen(geo, 0.0, y + h - mb).y, false, vr); + // Header and footer margins: a small marker at each distance, grabbable like the margins. + let pc = p.with_clip_rect(vr); + for at in [screen(geo, 0.0, y + mh).y, screen(geo, 0.0, y + h - mf).y] { + let x = vr.left() + 2.0; + pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + 6.0, at)], t.header_text, Stroke::NONE)); + } } p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line)); p.line_segment( @@ -602,6 +613,10 @@ pub enum Edge { Right, Top, Bottom, + /// Distance of the header from the top of the page. + Header, + /// Distance of the footer from the bottom of the page. + Footer, } impl Edge { @@ -612,14 +627,16 @@ impl Edge { Edge::Right => 1, Edge::Top => 2, Edge::Bottom => 3, + Edge::Header => 4, + Edge::Footer => 5, } } /// The `pageLayout.pageSetup` margin key. fn key(self) -> &'static str { - ["left", "right", "top", "bottom"][self.index()] + ["left", "right", "top", "bottom", "header", "footer"][self.index()] } fn label(self) -> &'static str { - ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin"][self.index()] + ["Left Margin", "Right Margin", "Top Margin", "Bottom Margin", "Header Margin", "Footer Margin"][self.index()] } fn horizontal(self) -> bool { matches!(self, Edge::Left | Edge::Right) @@ -635,6 +652,9 @@ pub struct RulerDrag { pub page: usize, pub start_inches: f32, pub inches: f32, + /// The sheet re-paginated with `inches` (page breaks and fit-to-page scale as a commit would + /// give them), rebuilt when the value changes. + preview: Option<(f32, Arc)>, } /// Content that must stay between the margins, in points. @@ -645,32 +665,40 @@ const EDGE_GRAB: f32 = 4.0; /// Margin (inches, rounded to 0.01) for an edge dragged to `rel_pt` points from the page's /// left/top edge. The margin stays on the page and leaves [`MIN_CONTENT_PT`] between the two /// margins of its axis. `None` for NaN/infinite input or a page without a size. -pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 4]) -> Option { +pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 6]) -> Option { let page = f64::from(if edge.horizontal() { page_pt.0 } else { page_pt.1 }); if !rel_pt.is_finite() || !page.is_finite() || page <= 0.0 { return None; } let other = f64::from(margins_in.get(edge.index() ^ 1).copied().filter(|v| v.is_finite())?).max(0.0) * 72.0; let raw = match edge { - Edge::Left | Edge::Top => rel_pt, - Edge::Right | Edge::Bottom => page - rel_pt, + Edge::Left | Edge::Top | Edge::Header => rel_pt, + Edge::Right | Edge::Bottom | Edge::Footer => page - rel_pt, }; let max = (page - other - MIN_CONTENT_PT).max(0.0); let pt = raw.clamp(0.0, max); Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) } -/// The grid with a margin being dragged applied, so the page content follows the pointer. -pub(crate) fn with_dragged_margin(app: &SheetApp, si: usize, pg: Arc) -> Arc { - let Some(d) = app.grid.ruler_drag.as_ref().filter(|d| d.sheet == si) else { return pg }; - // Layout units per point (the print scale and pixel/point ratio). - let per_pt = pg.page_w / f64::from(pg.paper_pt.0); - let mut g = (*pg).clone(); - let v = f64::from(d.inches) * 72.0 * per_pt; - if let Some(m) = g.margins.get_mut(d.edge.index()).filter(|_| v.is_finite() && per_pt.is_finite()) { - *m = v; +/// The grid while a margin is dragged: the sheet is re-paginated with the previewed margin, so +/// the content area, page breaks and the fit-to-page scale are those a commit would give. +pub(crate) fn with_dragged_margin(app: &mut SheetApp, wb: &Workbook, si: usize, pg: Arc) -> Arc { + let Some(d) = app.grid.ruler_drag.as_mut().filter(|d| d.sheet == si) else { return pg }; + if let Some((v, g)) = &d.preview + && (*v - d.inches).abs() < f32::EPSILON + { + return g.clone(); } - Arc::new(g) + let mut w = wb.clone(); + let Some(sheet) = w.sheets.get_mut(si) else { return pg }; + let Some(slot) = Arc::make_mut(sheet).print.margins.get_mut(d.edge.index()) else { return pg }; + *slot = d.inches; + let rows = pg.row_bands.last().map_or(0, |b| b.1); + let cols = pg.col_bands.last().map_or(0, |b| b.1); + let Some(g) = PageGrid::build(&w, si, rows, cols) else { return pg }; + let g = Arc::new(g); + d.preview = Some((d.inches, g.clone())); + g } /// Points per layout unit of the grid (paper size over the layout page size). @@ -682,7 +710,7 @@ fn pt_per_unit(pg: &PageGrid) -> Option { /// The margin edge of a visible page under `pos` on the horizontal (`h`) or vertical ruler. fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize)> { let ((i0, i1), (j0, j1)) = visible_pages(geo, pg); - let [ml, mr, mt, mb, ..] = pg.margins; + let [ml, mr, mt, mb, mh, mf] = pg.margins; let mut best: Option<(f32, Edge, usize)> = None; let mut consider = |d: f32, e: Edge, page: usize| { if d <= EDGE_GRAB && best.is_none_or(|b| d < b.0) { @@ -700,6 +728,8 @@ fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize) let (_, y, _, hh) = pg.page_rect(i, 0); consider((screen(geo, 0.0, y + mt).y - pos.y).abs(), Edge::Top, i); consider((screen(geo, 0.0, y + hh - mb).y - pos.y).abs(), Edge::Bottom, i); + consider((screen(geo, 0.0, y + mh).y - pos.y).abs(), Edge::Header, i); + consider((screen(geo, 0.0, y + hh - mf).y - pos.y).abs(), Edge::Footer, i); } } best.map(|(_, e, p)| (e, p)) @@ -720,11 +750,12 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid } let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); - let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + // Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical. + let hresp = ui.interact(geo.mirror_rect(hr), egui::Id::new("ruler_margin_h"), egui::Sense::drag()); let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); - let margins_in = |sh: &Sheet| -> [f32; 4] { + let margins_in = |sh: &Sheet| -> [f32; 6] { let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); - [m(0), m(1), m(2), m(3)] + [m(0), m(1), m(2), m(3), m(4), m(5)] }; let current = margins_in(sh); let metric = metric(app); @@ -745,7 +776,7 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid } cursor(ui, d.edge); let down = ui.input(|i| i.pointer.primary_down()); - let pos = ui.input(|i| i.pointer.latest_pos()); + let pos = ui.input(|i| i.pointer.latest_pos()).map(|p| geo.mirror_pos(p)); let mut cur = d.clone(); if let (Some(pos), Some(k)) = (pos, pt_per_unit(pg)) { let rel = if d.edge.horizontal() { @@ -774,10 +805,11 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid for (resp, h) in [(&hresp, true), (&vresp, false)] { let Some(pos) = resp.hover_pos().or_else(|| resp.interact_pointer_pos()) else { continue }; let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(pos) } else { pos }; + let origin = geo.mirror_pos(origin); let Some((edge, page)) = edge_at(geo, pg, origin, h) else { continue }; let value = current.get(edge.index()).copied().unwrap_or(0.5); if resp.drag_started() { - app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value }); + app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value, preview: None }); cursor(ui, edge); } else if resp.hovered() { cursor(ui, edge); @@ -868,7 +900,7 @@ mod tests { #[test] fn drag_maps_to_margins_and_clamps_inside_the_page() { - let m = [0.7, 0.7, 0.75, 0.75]; + let m = [0.7, 0.7, 0.75, 0.75, 0.3, 0.3]; // 72 pt from the left edge is one inch; from the right edge the margin is measured inward. assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); @@ -883,8 +915,12 @@ mod tests { assert_eq!(margin_from_drag(Edge::Left, f64::NAN, A4, m), None); assert_eq!(margin_from_drag(Edge::Left, f64::INFINITY, A4, m), None); assert_eq!(margin_from_drag(Edge::Left, 10.0, (0.0, 0.0), m), None); - assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7]), None); + assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7, 0.3, 0.3]), None); // Oversized opposite margin leaves nothing to drag into. - assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0]), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0, 0.3, 0.3]), Some(0.0)); + // Header/footer distances are measured from the top / bottom edge. + assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); + assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); + assert_eq!(margin_from_drag(Edge::Footer, -1e9, A4, m), Some(0.0)); } } diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index 632b9e2..35028a7 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -12,8 +12,20 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn fixture() -> Harness { + fixture_with(false, None) +} + +/// `rtl` mirrors the sheet; `setup` is extra `pageLayout.pageSetup` params. +fn fixture_with(rtl: bool, setup: Option) -> Harness { let mut session = Session::new(); session.new_workbook(); + if rtl { + session.execute("view.rightToLeft", json!({"on": true})).unwrap(); + } + if let Some(p) = setup { + session.execute("cell.set", json!({"cell": "Z1", "input": "wide"})).unwrap(); + session.execute("pageLayout.pageSetup", p).unwrap(); + } for r in 1..=60 { session.execute("cell.set", json!({"cell": format!("A{r}"), "input": r.to_string()})).unwrap(); } @@ -216,3 +228,99 @@ fn dragging_a_ruler_edge_previews_commits_on_release_as_one_undo_step_and_esc_ca h.run_steps(3); assert_eq!(margins(&h), start_margins); } + +fn press(h: &mut Harness, p: Pos2) { + h.run_steps(40); + mouse(h, Event::PointerMoved(p)); + mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); +} + +fn release(h: &mut Harness, p: Pos2) { + mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Screen pixels per inch of the page. +fn per_inch(g: &Geo) -> f32 { + let pg = g.paper.clone().unwrap(); + (72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z +} + +#[test] +fn header_and_footer_margins_are_dragged_on_the_vertical_ruler() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let ruler_x = h.state().grid.rect.unwrap().left() + 9.0; + // Header: distance from the top of the page, 0.2 in further down. + let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y); + let to = pos2(ruler_x, at.y + 0.2 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[4] - (start[4] + 0.2)).abs() < 0.03, "{after:?} vs {start:?}"); + assert_eq!((after[..4].to_vec(), after[5]), (start[..4].to_vec(), start[5])); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(margins(&h), start); + // Footer: distance from the bottom, dragged up by 0.2 in grows it (zoom out so the page + // bottom is on screen). + h.state_mut().run("view.zoom", json!({"percent": 50})).unwrap(); + h.run_steps(4); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let (_, py, _, ph) = pg.page_rect(0, 0); + let at = pos2(ruler_x, screen(&g, 0.0, py + ph - pg.margins[5]).y); + let to = pos2(ruler_x, at.y - 0.2 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y - 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + assert!((margins(&h)[5] - (start[5] + 0.2)).abs() < 0.03, "{:?}", margins(&h)); +} + +#[test] +fn right_to_left_sheets_mirror_the_horizontal_ruler_drag() { + let mut h = fixture_with(true, None); + let g = geo(&h); + assert!(g.rtl); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let y = h.state().grid.rect.unwrap().top() + 9.0; + // The left margin edge is drawn on the right of the mirrored page; dragging it towards the + // page centre (screen-left) grows the left margin. + let at = pos2(g.mirror_x(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x), y); + let to = pos2(at.x - per_inch(&g), y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x - 4.0, y))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[0] - (start[0] + 1.0)).abs() < 0.03, "{after:?} vs {start:?}"); + assert_eq!(after[1], start[1]); +} + +#[test] +fn preview_repaginates_so_fit_to_width_matches_the_commit() { + let mut h = fixture_with(false, Some(json!({"fitWidth": 1, "fitHeight": 0}))); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let y = h.state().grid.rect.unwrap().top() + 9.0; + let at = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, y); + let to = pos2(at.x + 2.0 * per_inch(&g), y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x + 4.0, y))); + mouse(&mut h, Event::PointerMoved(to)); + h.run_steps(2); + let live_scale = geo(&h).paper.unwrap().scale; + assert_eq!(margins(&h), start); + assert!(live_scale < pg.scale, "live scale {live_scale} should shrink below {}", pg.scale); + release(&mut h, to); + h.run_steps(2); + let committed = geo(&h).paper.unwrap().scale; + assert!((committed - live_scale).abs() < 0.01, "committed {committed} vs preview {live_scale}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 52dd3a3..04a7750 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -28,7 +28,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction From 600c25098f4396a641b20a17f0bf02f664e96493 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:01:22 +0200 Subject: [PATCH 31/62] Fix footer clamp expectation in the drag test (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/paper_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index f560f13..89a567e 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -921,6 +921,6 @@ mod tests { // Header/footer distances are measured from the top / bottom edge. assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); - assert_eq!(margin_from_drag(Edge::Footer, -1e9, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Footer, 1e9, A4, m), Some(0.0)); } } From bd105a569bbee8a11b5fca9ab3c0203c66e2589f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:03:37 +0200 Subject: [PATCH 32/62] Page Layout view: vertical ruler on the right for RTL sheets, header/footer distance clamped to the margins (#792) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/grid.rs | 7 ++- crates/ui-egui/src/paper_view.rs | 60 +++++++++++++++++++----- crates/ui-egui/tests/page_layout_view.rs | 48 +++++++++++++++++++ 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 80af151..10321d1 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -490,7 +490,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Page Layout view: pages on a desk, rulers above and left of the headings. let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; - let grid_rect = Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max); + // Right-to-left sheets carry the vertical ruler on the right, beside the row headings. + let grid_rect = if rtl { + Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom())) + } else { + Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) + }; let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { if paper { let probe = Geo::new(sh, grid_rect, scroll); diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 89a567e..621a979 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -511,6 +511,14 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { app.grid.hf_edit = Some(e); } +/// Screen rectangles of the horizontal and vertical rulers. Right-to-left sheets mirror both: +/// the vertical ruler sits on the right, beside the row headings. +fn ruler_rects(outer: Rect, geo: &Geo) -> (Rect, Rect) { + let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); + let (x0, x1) = if geo.rtl { (outer.right() - RULER, outer.right()) } else { (outer.left(), outer.left() + RULER) }; + (hr, Rect::from_min_max(pos2(x0, geo.cells.top()), pos2(x1, geo.cells.bottom()))) +} + /// Horizontal and vertical rulers (`outer` is the grid area including the rulers). pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, t: &Tokens) { let Some(pg) = geo.paper.clone() else { return }; @@ -524,13 +532,12 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, let minor = if metric { 2.0 } else { 4.0 }; let label_every = ((22.0 / unit).ceil() as usize).max(1); let font = theme::ui_font(9.5); - let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER))); - let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let (hr, vr) = ruler_rects(outer, geo); // Right-to-left sheets: the horizontal ruler is mirrored with the pages (ticks run from the // right margin leftwards). let mx = |x: f32| geo.mirror_x(x); p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window); - p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window); + p.rect_filled(Rect::from_min_max(pos2(vr.left(), outer.top()), pos2(vr.right(), outer.bottom())), 0.0, t.window); let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg); let [ml, mr, mt, mb, mh, mf] = pg.margins; // One ruler span per page: margins shaded, the printable part light, ticks from the margin. @@ -575,6 +582,8 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, } let (a, b) = if horizontal { (pos2(pos, area.bottom() - 2.0 - len), pos2(pos, area.bottom() - 2.0)) + } else if geo.rtl { + (pos2(area.left() + 2.0, pos), pos2(area.left() + 2.0 + len, pos)) } else { (pos2(area.right() - 2.0 - len, pos), pos2(area.right() - 2.0, pos)) }; @@ -595,13 +604,17 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, // Header and footer margins: a small marker at each distance, grabbable like the margins. let pc = p.with_clip_rect(vr); for at in [screen(geo, 0.0, y + mh).y, screen(geo, 0.0, y + h - mf).y] { - let x = vr.left() + 2.0; - pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + 6.0, at)], t.header_text, Stroke::NONE)); + let (x, d) = if geo.rtl { (vr.right() - 2.0, -6.0) } else { (vr.left() + 2.0, 6.0) }; + pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + d, at)], t.header_text, Stroke::NONE)); } } p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line)); p.line_segment( - [pos2(outer.left() + RULER - 0.5, outer.top()), pos2(outer.left() + RULER - 0.5, outer.bottom())], + if geo.rtl { + [pos2(vr.left() + 0.5, outer.top()), pos2(vr.left() + 0.5, outer.bottom())] + } else { + [pos2(vr.right() - 0.5, outer.top()), pos2(vr.right() - 0.5, outer.bottom())] + }, Stroke::new(1.0, t.header_line), ); } @@ -675,8 +688,19 @@ pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in Edge::Left | Edge::Top | Edge::Header => rel_pt, Edge::Right | Edge::Bottom | Edge::Footer => page - rel_pt, }; - let max = (page - other - MIN_CONTENT_PT).max(0.0); - let pt = raw.clamp(0.0, max); + let mut max = (page - other - MIN_CONTENT_PT).max(0.0); + let mut min = 0.0_f64; + let pt_of = |i: usize| f64::from(margins_in.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).max(0.0) * 72.0; + match edge { + // The header/footer sits within its page margin (conservative: never past the margin edge). + Edge::Header => max = max.min(pt_of(2)), + Edge::Footer => max = max.min(pt_of(3)), + // ... so the top/bottom margin can't be dragged inside it. + Edge::Top => min = pt_of(4), + Edge::Bottom => min = pt_of(5), + _ => {} + } + let pt = raw.clamp(min.min(max), max); Some(((pt / 72.0 * 100.0).round() / 100.0) as f32) } @@ -748,10 +772,9 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid if app.grid.ruler_drag.as_ref().is_some_and(|d| d.sheet != si) { app.grid.ruler_drag = None; } - let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)); - let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom())); + let (hr, vr) = ruler_rects(outer, geo); // Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical. - let hresp = ui.interact(geo.mirror_rect(hr), egui::Id::new("ruler_margin_h"), egui::Sense::drag()); + let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); let margins_in = |sh: &Sheet| -> [f32; 6] { let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); @@ -905,9 +928,9 @@ mod tests { assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0)); assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Top, 108.0, A4, m), Some(1.5)); - // Dragged off the page: never negative. + // Dragged off the page: never negative (top/bottom keep the header/footer distance). assert_eq!(margin_from_drag(Edge::Left, -50.0, A4, m), Some(0.0)); - assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.0)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.3)); // Dragged across the opposite margin: stops a minimum content width short of it. let max = margin_from_drag(Edge::Left, 10_000.0, A4, m).unwrap(); assert!((f64::from(max) * 72.0 - (f64::from(A4.0) - 0.7 * 72.0 - MIN_CONTENT_PT)).abs() < 1.0, "{max}"); @@ -922,5 +945,16 @@ mod tests { assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5)); assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25)); assert_eq!(margin_from_drag(Edge::Footer, 1e9, A4, m), Some(0.0)); + // Header stays within the top margin (0.75 in), footer within the bottom margin. + assert_eq!(margin_from_drag(Edge::Header, 500.0, A4, m), Some(0.75)); + assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 500.0, A4, m), Some(0.75)); + // The top/bottom margin can't be dragged inside the header/footer distance (0.3 in). + assert_eq!(margin_from_drag(Edge::Top, 2.0, A4, m), Some(0.3)); + assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) - 2.0, A4, m), Some(0.3)); + // Hostile header/footer values (NaN, negative, huge) never panic or escape the page. + assert_eq!(margin_from_drag(Edge::Header, 10.0, A4, [0.7, 0.7, 0.75, 0.75, f32::NAN, 0.3]), Some(0.14)); + assert_eq!(margin_from_drag(Edge::Top, 300.0, A4, [0.7, 0.7, 0.75, 0.75, -5.0, f32::INFINITY]), Some(4.17)); + assert_eq!(margin_from_drag(Edge::Header, f64::NAN, A4, m), None); + assert!(margin_from_drag(Edge::Footer, 10.0, A4, [0.0, 0.0, 1e9, 1e9, 0.3, 1e9]).is_some_and(|v| (0.0..=11.7).contains(&v))); } } diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index 35028a7..b2e80d0 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -324,3 +324,51 @@ fn preview_repaginates_so_fit_to_width_matches_the_commit() { let committed = geo(&h).paper.unwrap().scale; assert!((committed - live_scale).abs() < 0.01, "committed {committed} vs preview {live_scale}"); } + +#[test] +fn right_to_left_sheets_carry_the_vertical_ruler_on_the_right() { + let mut h = fixture_with(true, None); + let g = geo(&h); + assert!(g.rtl); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let outer = h.state().grid.rect.unwrap(); + // The grid leaves the ruler's width free on the right, not on the left. + assert!((g.rect.right() - (outer.right() - gridcraft_ui_egui::paper_view::RULER)).abs() < 0.5, "{:?} {outer:?}", g.rect); + assert!((g.rect.left() - outer.left()).abs() < 0.5); + let x = outer.right() - 9.0; + let at = pos2(x, screen(&g, 0.0, pg.page_y(0) + pg.margins[2]).y); + let to = pos2(x, at.y + 0.25 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[2] - (start[2] + 0.25)).abs() < 0.03, "{after:?} vs {start:?}"); + // The left ruler strip is not a handle any more: nothing happens there. + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let at = pos2(outer.left() + 9.0, screen(&g, 0.0, pg.page_y(0) + pg.margins[3]).y); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(at.x, at.y - 30.0))); + release(&mut h, pos2(at.x, at.y - 30.0)); + assert_eq!(margins(&h)[3], start[3]); +} + +#[test] +fn header_distance_cannot_be_dragged_past_the_top_margin() { + let mut h = fixture(); + let g = geo(&h); + let pg = g.paper.clone().unwrap(); + let start = margins(&h); + let ruler_x = h.state().grid.rect.unwrap().left() + 9.0; + let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y); + let to = pos2(ruler_x, at.y + 3.0 * per_inch(&g)); + press(&mut h, at); + mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0))); + mouse(&mut h, Event::PointerMoved(to)); + release(&mut h, to); + let after = margins(&h); + assert!((after[4] - start[2]).abs() < 0.011, "header {} should stop at the top margin {}", after[4], start[2]); + assert_eq!(after[2], start[2]); +} From a5f531838ba2341eee61a3ab59573ef3eb032ccf Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 21:08:20 +0200 Subject: [PATCH 33/62] Functions: note the dependency gap of computed criteria (part of #737) --- crates/calc/src/eval.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 98c1179..b22c48a 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -104,6 +104,10 @@ impl Ctx for FnCtx<'_, '_> { } /// Excel's "formula as criteria": the formula is written for the first record, so its relative /// references move down to `record`'s row, and the database's labels name its fields. + /// Known gap: the shifted references are not dependencies of the calling cell. One that lands + /// outside both the database and the criteria cell's own precedents (say `=G2 Option { let area = self.criteria?; let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?; From 2be04524b3a4fcfecfd28a274c30067c3d488aca Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:09:27 +0200 Subject: [PATCH 34/62] Calc: SUBTOTAL(10x) recalculates when Row Height, AutoFit Row Height or Ungroup reveal rows home.rowHeight, home.autofitRowHeight and data.ungroup change row hidden state without marking the edit structural, so SUBTOTAL(101-111) kept a stale value. Found by the command-sequence fuzz (seed 3). Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/data.rs | 1 + crates/engine/src/cmd/format.rs | 2 ++ crates/engine/src/tests.rs | 16 ++++++++++++++++ 4 files changed, 20 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..fafa759 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | SUBTOTAL(109) over hidden rows recalculates after Row Height, AutoFit Row Height and Ungroup reveal rows (found by the command-sequence fuzz) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3ba..d2b3612 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -1129,6 +1129,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result { { e.collapsed = false; } + cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index dde5fa1..8d46010 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -953,6 +953,7 @@ fn row_height(s: &mut Session, p: &Json) -> Result { e.custom = true; } } + cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } @@ -1069,6 +1070,7 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result { } cx.fit_rows.extend((a..=b).map(|r| (si, r))); } + cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..1e78b37 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2795,3 +2795,19 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() { assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched"); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [4], [8]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=SUBTOTAL(109,A1:A4)"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(9.0)); + // A non-zero height unhides the rows (the fuzz found the stale SUBTOTAL). + s.execute("home.rowHeight", json!({"rows": "2:3", "height": 60.0})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(15.0)); + s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(9.0)); + s.execute("home.autofitRowHeight", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(15.0)); +} From fd53de7c46bf399c4979184444b37bb1d10eec9e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:09:09 +0200 Subject: [PATCH 35/62] Numfmt: TEXT() and chart labels parse each format code once (#806) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/chart/src/axis.rs | 2 +- crates/chart/src/resolve.rs | 2 +- crates/numfmt/src/lib.rs | 76 +++++++++++++++- crates/numfmt/src/tests.rs | 171 ++++++++++++++++++++++++++++++++++++ 5 files changed, 249 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..a751272 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | `TEXT()` over a range and chart labels parse each number format code once instead of once per value (#806) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/chart/src/axis.rs b/crates/chart/src/axis.rs index eb4f733..8686bf7 100644 --- a/crates/chart/src/axis.rs +++ b/crates/chart/src/axis.rs @@ -122,7 +122,7 @@ impl Fmt { pub fn new(code: &str) -> Fmt { let t = code.trim(); let general = t.is_empty() || t.eq_ignore_ascii_case("general"); - Fmt { general, fmt: NumberFormat::parse(if general { "General" } else { t }) } + Fmt { general, fmt: NumberFormat::parse_cached(if general { "General" } else { t }) } } /// Formats `v`; `step` (axis labels) fixes the decimals for General. pub fn format(&self, v: f64, step: Option) -> String { diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 2c17eff..b3de1ca 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -145,7 +145,7 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { if let Some(c) = at { let code = &wb.styles.get(s.style_id(c)).num_fmt.0; if !code.eq_ignore_ascii_case("general") { - return format_value(v, &NumberFormat::parse(code), wb.date_system).text; + return format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text; } } } diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 674e844..b4f259f 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -101,6 +101,8 @@ impl Default for NumberFormat { impl NumberFormat { /// Parses a format code. Never fails: unknown or malformed parts degrade to literal text. pub fn parse(code: &str) -> NumberFormat { + #[cfg(test)] + parse_cache::count_parse(); let trimmed = code.trim(); // `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other // value displays as General. @@ -294,6 +296,78 @@ pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted } } +/// A small per-thread cache of parsed format codes for callers that format many values with the +/// same code (`TEXT` over a range, chart labels). A parsed format depends on the code text only +/// (the date system and values are applied when formatting), so the code is the whole key. +mod parse_cache { + use std::cell::RefCell; + use std::collections::HashMap; + + use super::NumberFormat; + + /// Most codes kept per thread. A full cache is emptied, so memory stays bounded. + pub(super) const CAPACITY: usize = 64; + /// Longer codes are parsed every time and never stored. + pub(super) const MAX_CODE_LEN: usize = 256; + + thread_local! { + static CACHE: RefCell> = RefCell::new(HashMap::new()); + } + + pub(super) fn get(code: &str) -> NumberFormat { + if code.len() > MAX_CODE_LEN { + return NumberFormat::parse(code); + } + // Not usable (thread shutting down, re-entered): parse without the cache. + let cached = CACHE + .try_with(|c| { + let mut c = c.try_borrow_mut().ok()?; + if let Some(f) = c.get(code) { + return Some(f.clone()); + } + let f = NumberFormat::parse(code); + if c.len() >= CAPACITY { + c.clear(); + } + c.insert(code.to_string(), f.clone()); + Some(f) + }) + .ok() + .flatten(); + cached.unwrap_or_else(|| NumberFormat::parse(code)) + } + + #[cfg(test)] + thread_local! { + static PARSES: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + #[cfg(test)] + pub(super) fn count_parse() { + let _ = PARSES.try_with(|p| p.set(p.get() + 1)); + } + + /// Parses done on this thread so far (tests only). + #[cfg(test)] + pub(super) fn parses() -> usize { + PARSES.try_with(std::cell::Cell::get).unwrap_or(0) + } + + /// Empties this thread's cache (tests only). + #[cfg(test)] + pub(super) fn clear() { + let _ = CACHE.try_with(|c| c.borrow_mut().clear()); + } +} + +impl NumberFormat { + /// Like [`NumberFormat::parse`], but remembers recently parsed codes (per thread, bounded) so + /// formatting many values with one code parses it once. Same result as `parse`. + pub fn parse_cached(code: &str) -> NumberFormat { + parse_cache::get(code) + } +} + /// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is /// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text /// section, or are returned as is. @@ -301,7 +375,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result return Err(e), Value::Empty => 0.0, diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 6c2306c..ca61418 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -582,3 +582,174 @@ fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() { let (d, e) = decimal_digits(999999999999999.6); assert_eq!((d, e), ([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15)); } + +// ---- parse cache ---- + +/// `TEXT()` as it was before the cache: parse the code, then format. +fn text_uncached(v: &Value, code: &str, sys: DateSystem) -> Result { + if code.is_empty() { + return Ok(String::new()); + } + let fmt = NumberFormat::parse(code); + let n = match v.scalar() { + Value::Error(e) => return Err(e), + Value::Empty => 0.0, + Value::Number(n) => n, + Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text), + Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) { + Some(n) => n, + None => return Ok(fmt.format_text(&t).text), + }, + Value::Array(_) => return Err(CellError::Value), + }; + if !n.is_finite() { + return Err(CellError::Num); + } + let (f, overflow) = fmt.format_number(n, sys); + if overflow { Err(CellError::Value) } else { Ok(f.text) } +} + +const CACHE_CODES: &[&str] = &[ + "0", + "0.00", + "#,##0.00", + "#,##0.00;[Red](#,##0.00)", + "$#,##0.00", + "0%", + "0.0%", + "0.00E+00", + "##0.0E+0", + "General", + "general", + "checkbox", + "@", + "0;@", + "0;-0;;@", + "00000", + "# ?/?", + "# ??/16", + "?/?", + "m/d/yyyy", + "yyyy-mm-dd hh:mm:ss", + "dddd, mmmm d, yyyy", + "h:mm AM/PM", + "[h]:mm:ss", + "mm:ss.000", + "[Red]0;[Blue]-0", + "[>100]0.0;[<5]0.000;0", + "[<=9999999]###-####;(###) ###-####", + "[$-409]d mmm yy", + "\"abc\"0", + "\\x0", + "*-0", + "_(* #,##0_)", + "0.0.0", + "[", + "\"abc", + "0;0;0;0;0", + "#,##0.00;[Red", + "[$-]0", + " 0.00 ", + "[Color99]0", + "0.00_);(0.00)", + "#,,\"M\"", + "yyyy", + "", +]; + +fn cache_values() -> Vec { + vec![ + Value::Number(0.0), + Value::Number(1234.5678), + Value::Number(-42.5), + Value::Number(0.5), + Value::Number(45000.75), + Value::Number(1e15), + Value::Number(1e-7), + Value::Number(-0.0001), + Value::Number(f64::INFINITY), + Value::from("abc"), + Value::from("12.5"), + Value::Bool(true), + Value::Empty, + Value::Error(CellError::NA), + ] +} + +#[test] +fn parse_cached_gives_the_same_results_cold_and_warm() { + assert!(CACHE_CODES.len() >= 40); + parse_cache::clear(); + let values = cache_values(); + for sys in [DateSystem::D1900, DateSystem::D1904] { + // Cold on the first system, warm on the second. + for code in CACHE_CODES { + let direct = NumberFormat::parse(code); + let cached = NumberFormat::parse_cached(code); + assert_eq!(cached.code(), direct.code(), "{code:?}"); + assert_eq!( + (cached.kind(), cached.is_date(), cached.is_text(), cached.is_general()), + (direct.kind(), direct.is_date(), direct.is_text(), direct.is_general()) + ); + for v in &values { + let want = text_uncached(v, code, sys); + assert_eq!(text_function(v, code, sys), want, "TEXT({v:?}, {code:?})"); + // Again, now certainly warm. + assert_eq!(text_function(v, code, sys), want, "warm TEXT({v:?}, {code:?})"); + assert_eq!(format_value(v, &cached, sys), format_value(v, &direct, sys), "{v:?} {code:?}"); + } + } + } +} + +#[test] +fn parse_cached_parses_each_code_once() { + parse_cache::clear(); + let before = parse_cache::parses(); + for i in 0..10_000 { + assert!(text_function(&Value::Number(f64::from(i) + 0.5), "0.00", S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 1); + + let before = parse_cache::parses(); + for i in 0..10_000 { + let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" }; + assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok()); + } + // "0.00" is still cached from above: only the second code is new. + assert_eq!(parse_cache::parses() - before, 1); + + parse_cache::clear(); + let before = parse_cache::parses(); + for i in 0..10_000 { + let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" }; + assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 2); +} + +#[test] +fn parse_cache_is_bounded() { + parse_cache::clear(); + // More distinct codes than the capacity: all still format correctly... + let n = parse_cache::CAPACITY + 10; + for i in 0..n { + let c = format!("0.00\"{i}\""); + assert_eq!(text_function(&Value::Number(1.0), &c, S), Ok(format!("1.00{i}"))); + } + // ...the first ones were evicted (parsed again), the newest is still cached. + let before = parse_cache::parses(); + let newest = format!("0.00\"{}\"", n - 1); + assert!(text_function(&Value::Number(1.0), &newest, S).is_ok()); + assert_eq!(parse_cache::parses() - before, 0); + assert!(text_function(&Value::Number(1.0), "0.00\"0\"", S).is_ok()); + assert_eq!(parse_cache::parses() - before, 1); + + // A very long code is parsed on every call and not stored. + let long = format!("0\"{}\"", "x".repeat(parse_cache::MAX_CODE_LEN)); + let before = parse_cache::parses(); + for _ in 0..3 { + assert!(text_function(&Value::Number(1.0), &long, S).is_ok()); + } + assert_eq!(parse_cache::parses() - before, 3); +} From 5f992be525d7f102919a1398e6073ba519c0c946 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:09:29 +0200 Subject: [PATCH 36/62] Numfmt: no panic on a fraction denominator with 65,536 zeros; format codes are capped at 255 characters (#807) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/numfmt/src/lib.rs | 30 ++++++++++++++++++++++++++-- crates/numfmt/src/render.rs | 4 +++- crates/numfmt/src/tests.rs | 32 ++++++++++++++++++++++++++++++ crates/xlsx/src/tests/malformed.rs | 18 +++++++++++++++++ 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..3feaaef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 674e844..e380182 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -98,13 +98,36 @@ impl Default for NumberFormat { } } +/// Longest number format code, in characters (not bytes), that is parsed: Excel's own limit. +/// A longer code is invalid: [`NumberFormat::parse`] returns a General format (the cell shows +/// its value unformatted; the stored code is kept as is) and `TEXT` returns `#VALUE!`. The cap +/// also bounds every allocation that scales with the number of placeholders in a code. +pub const MAX_FORMAT_CODE_CHARS: usize = 255; + +/// True when `code` is longer than [`MAX_FORMAT_CODE_CHARS`] characters. +fn too_long(code: &str) -> bool { + // A code of more than 255 bytes can still be short in characters; count only when needed. + code.len() > MAX_FORMAT_CODE_CHARS && code.chars().count() > MAX_FORMAT_CODE_CHARS +} + impl NumberFormat { - /// Parses a format code. Never fails: unknown or malformed parts degrade to literal text. + /// Parses a format code. Never fails: unknown or malformed parts degrade to literal text, and + /// a code longer than [`MAX_FORMAT_CODE_CHARS`] characters degrades to General. pub fn parse(code: &str) -> NumberFormat { + Self::parse_inner(code, false) + } + + /// `parse` without the length cap, so tests can reach renderer paths a capped code cannot. + #[cfg(test)] + fn parse_uncapped(code: &str) -> NumberFormat { + Self::parse_inner(code, true) + } + + fn parse_inner(code: &str, uncapped: bool) -> NumberFormat { let trimmed = code.trim(); // `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other // value displays as General. - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" { + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" || (!uncapped && too_long(code)) { return NumberFormat { inner: Arc::new(Inner { code: code.to_string(), general: true, sections: Vec::new(), num_sections: 0, text_section: None }), }; @@ -301,6 +324,9 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result return Err(e), diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 7befe59..969e62c 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -496,7 +496,9 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt // Denominator digits are zero padded to the `0` placeholders, then left aligned; `?` // pads with spaces after them. let zeros = den_idx.iter().filter(|&&i| toks.get(i) == Some(&Tok::Digit('0'))).count(); - let padded = format!("{den_s:0>zeros$}"); + // No `{:0>zeros$}`: a format width above u16::MAX panics. + let mut padded = "0".repeat(zeros.saturating_sub(den_s.chars().count())); + padded.push_str(&den_s); let dc: Vec = padded.chars().collect(); let count = den_idx.len(); for (k, &ti) in den_idx.iter().enumerate() { diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 6c2306c..c70fbe7 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -582,3 +582,35 @@ fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() { let (d, e) = decimal_digits(999999999999999.6); assert_eq!((d, e), ([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15)); } + +#[test] +fn fraction_denominator_with_65536_zeros_does_not_panic() { + // `{:0>n$}` panics for a width above u16::MAX; the cap keeps this out of reach of `parse`, + // so the renderer is driven with the uncapped parser. + let code = format!("# ?/{}", "0".repeat(65_536)); + let fmt = NumberFormat::parse_uncapped(&code); + let text = format_value(&Value::Number(0.5), &fmt, S).text; + assert!(text.starts_with(" 1/"), "{text}"); + // And a denominator with fewer digits than zeros is still zero padded. + assert_eq!(f("# ?/000", 0.5), " 1/002"); +} + +#[test] +fn format_codes_are_capped_at_255_characters() { + let ok = format!("0{}", "0".repeat(254)); + let long = format!("0{}", "0".repeat(255)); + assert_eq!(ok.chars().count(), MAX_FORMAT_CODE_CHARS); + assert!(text_function(&Value::Number(1.0), &ok, S).is_ok()); + assert_eq!(text_function(&Value::Number(1.0), &long, S), Err(CellError::Value)); + // Capped codes display as General. + assert_eq!(f(&long, 0.5), "0.5"); + // Characters are counted, not bytes: 255 two-byte characters are fine, 256 are not. + let multi_ok = "é".repeat(255); + assert!(multi_ok.len() > 255); + assert!(text_function(&Value::Number(1.0), &multi_ok, S).is_ok()); + assert_eq!(text_function(&Value::Number(1.0), &"é".repeat(256), S), Err(CellError::Value)); + // 65536 zeros through the public API: no panic, TEXT is #VALUE!. + let huge = format!("# ?/{}", "0".repeat(65_536)); + assert_eq!(text_function(&Value::Number(0.5), &huge, S), Err(CellError::Value)); + assert_eq!(f(&huge, 0.5), "0.5"); +} diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index b14e53a..d41de8e 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -193,3 +193,21 @@ fn hostile_protected_ranges_stay_locked() { assert!(!matches!(h.verify("password"), Ok(true)), "{}", r.title); } } + +#[test] +fn an_overlong_number_format_code_does_not_stop_the_open() { + // A format code far over the 255-character cap: the file still opens, the format code is kept as + // stored (display falls back to General) and the cell keeps its value. + let code = format!("# ?/{}", "0".repeat(65_536)); + let styles = format!( + r#" + "# + ); + let rels = r#""#; + let body = r#"0.5"#; + let (wb, _) = read_xlsx(&minimal(body, &[("xl/styles.xml", &styles)], rels, "")).unwrap(); + let s = wb.sheet(0).unwrap(); + let cell = s.cell(gridcraft_core::CellRef::parse("A1").unwrap()).unwrap(); + assert_eq!(s.value(gridcraft_core::CellRef::parse("A1").unwrap()), gridcraft_core::Value::Number(0.5)); + assert_eq!(wb.styles.get(cell.style).num_fmt.as_str(), code); +} From a60b49fad0626f011ba5c04fbdc63a8d5bd0f968 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:10:35 +0200 Subject: [PATCH 37/62] Numfmt: format-code parsing is linear, and an unclosed [ is treated the same in both passes (#810) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/display.rs | 6 ++-- crates/numfmt/src/parse.rs | 54 ++++++++++++++++++++++++++---------- crates/numfmt/src/tests.rs | 45 ++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..5046fc4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index db7e7c8..b90e427 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -10,11 +10,13 @@ use gridcraft_numfmt::{Formatted, NumberFormat, format_value}; /// Parsed number formats are cached by code. fn parsed(code: &str) -> NumberFormat { static CACHE: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); - let mut m = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(f) = m.get(code) { + let lock = || CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(f) = lock().get(code) { return f.clone(); } + // Parse outside the lock, so a slow code never blocks the other threads' lookups. let f = NumberFormat::parse(code); + let mut m = lock(); if m.len() > 4096 { m.clear(); } diff --git a/crates/numfmt/src/parse.rs b/crates/numfmt/src/parse.rs index e54f04c..8d60000 100644 --- a/crates/numfmt/src/parse.rs +++ b/crates/numfmt/src/parse.rs @@ -125,12 +125,20 @@ pub(crate) struct Section { } /// Splits a code into sections at unquoted, unescaped, unbracketed semicolons. +/// +/// A `[` opens a bracket only when a `]` follows it; the bracket then runs to the next `]` (quotes +/// inside it are not special), exactly as `tokenize` reads it. An unclosed `[` is a literal in both +/// passes, so a later `;` still separates sections. Linear: each `[` either jumps over its bracket +/// or, with no `]` left in the code, is a literal at once. fn split_sections(code: &str) -> Vec { + let cs: Vec = code.chars().collect(); + let last_close = cs.iter().rposition(|&c| c == ']'); let mut out = vec![String::new()]; let mut quoted = false; - let mut bracket = false; let mut escape = false; - for c in code.chars() { + let mut i = 0usize; + while let Some(&c) = cs.get(i) { + i += 1; let cur = match out.last_mut() { Some(c) => c, None => break, @@ -145,19 +153,22 @@ fn split_sections(code: &str) -> Vec { quoted = !quoted; cur.push(c); } - '\\' | '_' | '*' if !quoted && !bracket => { + '\\' | '_' | '*' if !quoted => { escape = true; cur.push(c); } - '[' if !quoted => { - bracket = true; + '[' if !quoted && last_close.is_some_and(|l| l >= i) => { + // `i` is already past the `[`; the closing bracket is at or after it. cur.push(c); + while let Some(&d) = cs.get(i) { + i += 1; + cur.push(d); + if d == ']' { + break; + } + } } - ']' if !quoted => { - bracket = false; - cur.push(c); - } - ';' if !quoted && !bracket => { + ';' if !quoted => { if out.len() >= MAX_SECTIONS { break; } @@ -246,6 +257,8 @@ fn tokenize(src: &str) -> Raw { let cs: Vec = src.chars().collect(); let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false }; let toks = &mut raw.toks; + // With no `]` at or after a `[`, that `[` is a literal; checking this once keeps the scan linear. + let last_close = cs.iter().rposition(|&c| c == ']'); let mut i = 0usize; while let Some(&c) = cs.get(i) { match c { @@ -290,7 +303,7 @@ fn tokenize(src: &str) -> Raw { continue; } '[' => { - let close = cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']')); + let close = if last_close.is_some_and(|l| l > i) { cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']')) } else { None }; if let Some(p) = close { let content: String = cs.get(i + 1..i + 1 + p).unwrap_or(&[]).iter().collect(); bracket(&mut raw_parts(toks, &mut raw.color, &mut raw.cond, &mut raw.currency), &content); @@ -513,6 +526,19 @@ fn analyze(raw: Raw) -> Section { let mut seen_exp = false; let mut seen_digit = false; let mut fraction_slash = false; + // Precomputed so each comma and slash is O(1) (a long run of them stays linear): whether the + // first non-comma token after a position is a digit, and where the last exponent marker is. + let mut digit_after_commas = vec![false; toks.len()]; + let mut after = false; + for (j, t) in toks.iter().enumerate().rev() { + if let Some(slot) = digit_after_commas.get_mut(j) { + *slot = after; + } + if !matches!(t, Tok::Comma) { + after = matches!(t, Tok::Digit(_)); + } + } + let last_exp = toks.iter().rposition(|t| matches!(t, Tok::Exp { .. })); for i in 0..toks.len() { let Some(t) = toks.get(i).cloned() else { break }; let replace = match t { @@ -542,8 +568,7 @@ fn analyze(raw: Raw) -> Section { } } Tok::Comma => { - let next = toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Comma))); - let next_digit = matches!(next, Some(Tok::Digit(_))); + let next_digit = digit_after_commas.get(i).copied().unwrap_or(false); if seen_digit && next_digit && !seen_point && !seen_exp { sec.thousands = true; Some(Tok::Lit(String::new())) @@ -558,7 +583,8 @@ fn analyze(raw: Raw) -> Section { // Literal text may sit between the bar and the denominator (`#/ #`, `# ?=/=?`). let next_ok = matches!(toks.get(i + 1), Some(Tok::FixedDenom(_))) || matches!(toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Lit(_)))), Some(Tok::Digit(_))); - let has_exp = toks.iter().any(|t| matches!(t, Tok::Exp { .. })); + // An exponent kept before this bar (`seen_exp`) or any marker still ahead of it. + let has_exp = seen_exp || last_exp.is_some_and(|l| l > i); if !fraction_slash && seen_digit && next_ok && !seen_point && !has_exp { fraction_slash = true; sec.fraction = true; diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 6c2306c..8021a19 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -582,3 +582,48 @@ fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() { let (d, e) = decimal_digits(999999999999999.6); assert_eq!((d, e), ([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15)); } + +/// Parses and renders `code` on a worker thread; panics if that takes more than 20 s, so a quadratic +/// parser fails the test instead of hanging CI. Not a benchmark: at this size an O(n^2) pass takes +/// minutes and an O(n) pass milliseconds, so the margin is decades wide. +fn finishes_in_time(code: String) -> String { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f(&code, 1234.5)); + }); + rx.recv_timeout(std::time::Duration::from_secs(20)).expect("format code did not parse and render in time") +} + +#[test] +fn hostile_codes_parse_in_linear_time() { + let n = 1_000_000; + // Unclosed brackets, a run of commas after a digit, a run of fraction bars. + assert!(finishes_in_time("[".repeat(n)).starts_with('[')); + finishes_in_time(format!("0{}", ",".repeat(n))); + finishes_in_time(format!("0{}", "/".repeat(n))); + finishes_in_time(format!("0{}0", ",".repeat(n))); + // Unclosed brackets mixed with semicolons: each is a literal and a section separator stays one. + finishes_in_time("[;".repeat(n / 2)); +} + +#[test] +fn unclosed_bracket_is_a_literal_in_both_passes() { + // Sections still split after an unclosed `[`. + assert_eq!(f("[0;0", 5.0), "[5"); + assert_eq!(f("0;[0", -5.0), "[5"); + assert_eq!(f("0;-0;[", 0.0), "["); + assert_eq!(parse::parse_sections("0;[Red-0").len(), 2); + assert_eq!(parse::parse_sections("[0;0").len(), 2); +} + +#[test] +fn closed_brackets_still_hold_their_semicolons() { + assert_eq!(f("[Red]0;[Blue]-0", -5.0), "-5"); + assert_eq!(f("[>=100]0.0;0", 150.0), "150.0"); + assert_eq!(f("[$-409]0.00", 2.5), "2.50"); + assert_eq!(f("[$€-2] #,##0", 1234.0), "€ 1,234"); + assert_eq!(parse::parse_sections("[Red]0;[Blue]0").len(), 2); + assert_eq!(parse::parse_sections("[unknown;x]0;0").len(), 2); + assert_eq!(f("0\\[0", 5.0), "0[5"); + assert_eq!(f("\"[\"0;0", 5.0), "[5"); +} From 0042240c0582da1213c1c68653343648af045532 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:18:41 +0200 Subject: [PATCH 38/62] PDF: title and author are written as UTF-16BE when they are not ASCII (#813) Info strings were WinAnsi; PDF text strings are PDFDocEncoding or UTF-16BE. Printable ASCII stays a literal string; anything else is a hex string. Info strings are capped at 1024 characters. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/pdf/src/lib.rs | 19 +++++++++++++++++-- crates/pdf/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..43805ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | PDF export writes the document title and author as UTF-16BE when they are not ASCII, so viewers show accents, currency signs and non-Latin text correctly (#813) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 51c2df1..5cc4a83 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -516,8 +516,23 @@ fn run_length(data: &[u8]) -> Vec { out } -fn pdf_text_string(s: &str) -> String { - pdf_string(s) +/// Longest Info string written, in characters; longer values are cut on a character boundary. +const MAX_INFO_CHARS: usize = 1024; + +/// A PDF text string (document Info entries) for `text`: a literal `( … )` for printable ASCII, +/// otherwise UTF-16BE with a byte-order mark as a hex string (ISO 32000-1 7.9.2.2). +fn pdf_text_string(text: &str) -> String { + let text: String = text.chars().take(MAX_INFO_CHARS).collect(); + if text.chars().all(|c| matches!(c, ' '..='~')) { + return pdf_string(&text); + } + let mut s = String::with_capacity(text.len() * 4 + 6); + s.push_str("'); + s } impl PdfDoc { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e7b488a..4ea9170 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -157,3 +157,43 @@ fn hostile_inputs_never_panic() { let pdf = d.finish(); check_xref(&pdf); } + +/// Decodes the `` hex string following `key` in the file bytes. +fn utf16_entry(pdf: &[u8], key: &str) -> String { + let at = find(pdf, key.as_bytes()).unwrap() + key.len(); + let rest = std::str::from_utf8(&pdf[at..at + 9000.min(pdf.len() - at)]).unwrap_or(""); + let hex = rest.trim_start().strip_prefix("').unwrap()]; + let units: Vec = (0..hex.len()).step_by(4).map(|i| u16::from_str_radix(&hex[i..i + 4], 16).unwrap()).collect(); + String::from_utf16(&units).unwrap() +} + +#[test] +fn info_strings_with_non_ascii_are_utf16be() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = "Übersicht €5 – “Q1” 日本 😀".into(); + doc.info.author = "Müller & Søn".into(); + let pdf = doc.finish(); + assert_eq!(utf16_entry(&pdf, "/Title"), "Übersicht €5 – “Q1” 日本 😀"); + assert_eq!(utf16_entry(&pdf, "/Author"), "Müller & Søn"); +} + +#[test] +fn ascii_info_strings_stay_literal_and_escaped() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = r"Q1 (draft) a\b".into(); + let pdf = doc.finish(); + assert!(find(&pdf, br"/Title (Q1 \(draft\) a\\b)").is_some()); + assert!(find(&pdf, b"/Producer (GridCraft PDF)").is_some()); +} + +#[test] +fn info_strings_are_capped() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = "日".repeat(100_000); + let pdf = doc.finish(); + assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS); +} From a540a7138a87e7db2faa19ccc91cef9bb585fd44 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:18:58 +0200 Subject: [PATCH 39/62] PDF: text width cannot overflow; dash, stroke width and NaN sizes are sanitised (#812) text_width sums in u64; an all-zero or non-finite dash array is written as solid ([] 0 d); stroke widths are clamped to >= 0 (NaN -> 0); drawing calls with non-finite or non-positive geometry return before storing anything. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/pdf/src/lib.rs | 69 +++++++++++++++++++++++++++-------- crates/pdf/src/tests.rs | 80 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..ac5d315 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | PDF writer hardening: text width cannot overflow, all-zero dash arrays and negative or NaN stroke widths are sanitised, non-finite sizes and positions draw nothing (#812) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 51c2df1..828f8fb 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -145,7 +145,8 @@ impl Font { /// Width of `text` in points when set in `font` at `size` points. pub fn text_width(font: Font, size: f32, text: &str) -> f32 { - let units: u32 = text.chars().map(|c| font.char_width(c) as u32).sum(); + // u64: a u32 sum overflows for ~8 million characters. + let units: u64 = text.chars().map(|c| u64::from(font.char_width(c))).sum(); units as f32 * size / 1000.0 } @@ -221,6 +222,32 @@ fn n(v: f32) -> String { } } +/// True when every value is finite (NaN and infinities are rejected). +fn finite(v: &[f32]) -> bool { + v.iter().all(|x| x.is_finite()) +} + +/// A positive, finite extent (a width, height or radius). +fn extent(v: f32) -> bool { + v > 0.0 && v.is_finite() +} + +/// A stroke width: negative, NaN and infinite become 0 (the thinnest line). +fn stroke_width(w: f32) -> f32 { + if w.is_finite() { w.max(0.0) } else { 0.0 } +} + +/// The entries of a dash array (`[on, off, …]`, at most 8). A pattern the PDF specification +/// forbids (any non-finite entry, or no entry that survives rounding to a positive number) is +/// replaced by an empty array, i.e. a solid line. +fn dash_values(dash: &[f32]) -> String { + let d: Vec = dash.iter().take(8).map(|v| if v.is_finite() { v.max(0.0) } else { f32::NAN }).collect(); + if !finite(&d) || !d.iter().any(|v| (v.min(1.0e7) * 1000.0).round() > 0.0) { + return String::new(); + } + d.iter().map(|v| n(*v)).collect::>().join(" ") +} + fn rgb_op(c: Rgb, stroke: bool) -> String { let f = |v: u8| n(v as f32 / 255.0); format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" }) @@ -288,7 +315,7 @@ impl Page { } pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) { - if w <= 0.0 || h <= 0.0 { + if !(extent(w) && extent(h) && finite(&[x, y])) { return; } let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h)); @@ -296,7 +323,10 @@ impl Page { } pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) { - let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h)); + if !finite(&[x, y, w, h]) { + return; + } + let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(stroke_width(width)), n(x), n(self.y(y + h)), n(w), n(h)); self.op(&s); } @@ -306,19 +336,28 @@ impl Page { /// A line with a dash pattern (`[on, off, …]` in points; empty = solid). pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) { - let d: Vec = dash.iter().take(8).map(|v| n(v.max(0.0))).collect(); - let s = - format!("{}\n{} w [{}] 0 d\n{} {} m {} {} l S", rgb_op(color, true), n(width), d.join(" "), n(x0), n(self.y(y0)), n(x1), n(self.y(y1))); + if !finite(&[x0, y0, x1, y1]) { + return; + } + let s = format!( + "{}\n{} w [{}] 0 d\n{} {} m {} {} l S", + rgb_op(color, true), + n(stroke_width(width)), + dash_values(dash), + n(x0), + n(self.y(y0)), + n(x1), + n(self.y(y1)) + ); self.op(&s); } /// An open polyline. pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) { - if pts.len() < 2 { + if pts.len() < 2 || !pts.iter().all(|p| finite(p)) { return; } - let d: Vec = dash.iter().take(8).map(|v| n(v.max(0.0))).collect(); - let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(width), d.join(" ")); + let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash)); self.path(&mut s, pts, false); s.push_str(" S"); self.op(&s); @@ -335,7 +374,7 @@ impl Page { /// A closed polygon, filled and/or stroked. pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option, stroke: Option<(Rgb, f32)>) { - if pts.len() < 2 || (fill.is_none() && stroke.is_none()) { + if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) { return; } let mut s = String::new(); @@ -344,7 +383,7 @@ impl Page { s.push('\n'); } if let Some((c, w)) = stroke { - let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(w)); + let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w))); } self.path(&mut s, pts, true); s.push_str(match (fill.is_some(), stroke.is_some()) { @@ -357,7 +396,7 @@ impl Page { /// A circle approximated by four cubic Béziers. pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option, stroke: Option<(Rgb, f32)>) { - if r <= 0.0 || (fill.is_none() && stroke.is_none()) { + if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) { return; } const K: f32 = 0.552_284_8; @@ -369,7 +408,7 @@ impl Page { s.push('\n'); } if let Some((c, w)) = stroke { - let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(w)); + let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w))); } let _ = write!( s, @@ -417,7 +456,7 @@ impl Page { /// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate /// system) about its baseline origin. pub fn text_rotated(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str, angle: f32) { - if text.is_empty() || !(size.is_finite() && size > 0.0) { + if text.is_empty() || !extent(size) || !finite(&[x, y]) { return; } let fi = font.index(); @@ -446,7 +485,7 @@ impl Page { /// `(x, y, w, h)`. Malformed or oversized buffers are ignored. pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) { let px = pw as u64 * ph as u64; - if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || w <= 0.0 || h <= 0.0 { + if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) { return; } let px = px as usize; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e7b488a..176176b 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -157,3 +157,83 @@ fn hostile_inputs_never_panic() { let pdf = d.finish(); check_xref(&pdf); } + +fn page_content(d: &PdfDoc) -> &str { + &d.pages[0].content +} + +#[test] +fn text_width_does_not_overflow() { + // 8,000,000 x 556 units exceeds u32::MAX: a u32 sum panics in debug builds. + let long = "a".repeat(8_000_000); + let w = text_width(Font::Helvetica, 10.0, &long); + assert!(w.is_finite() && w > 0.0); + assert!(w > text_width(Font::Helvetica, 10.0, &"a".repeat(7_000_000))); +} + +#[test] +fn all_zero_dash_is_drawn_solid() { + let mut d = PdfDoc::new(); + let p = d.add_page(100.0, 100.0); + p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[0.0, 0.0]); + p.polyline(&[[0.0, 0.0], [5.0, 5.0]], 1.0, [0, 0, 0], &[0.0, 0.0001, f32::NAN]); + p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[3.0, 2.0]); + let c = page_content(&d); + assert_eq!(c.matches("[] 0 d").count(), 2); + assert!(!c.contains("[0 0]")); + assert!(c.contains("[3 2] 0 d")); +} + +#[test] +fn negative_and_nan_stroke_widths_become_zero() { + let mut d = PdfDoc::new(); + let p = d.add_page(100.0, 100.0); + p.rect_stroke(1.0, 1.0, 10.0, 10.0, -3.0, [0, 0, 0]); + p.stroke_line(0.0, 0.0, 5.0, 5.0, f32::NAN, [0, 0, 0]); + p.polygon(&[[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]], None, Some(([0, 0, 0], -1.0))); + p.circle(20.0, 20.0, 5.0, None, Some(([0, 0, 0], f32::NEG_INFINITY))); + let c = page_content(&d); + assert_eq!(c.matches("\n0 w ").count(), 4); + assert!(!c.contains("-1 w") && !c.contains("-3 w")); +} + +#[test] +fn non_finite_image_size_stores_nothing() { + let mut d = PdfDoc::new(); + let p = d.add_page(200.0, 200.0); + let px = [255u8; 16]; + for (w, h) in [(f32::NAN, 10.0), (10.0, f32::NAN), (f32::INFINITY, 10.0), (-1.0, 10.0)] { + p.image_rgba(0.0, 0.0, w, h, &px, 2, 2); + } + p.image_rgba(f32::NAN, 0.0, 10.0, 10.0, &px, 2, 2); + assert!(p.images.is_empty()); + assert!(p.content.is_empty()); +} + +#[test] +fn every_draw_method_survives_hostile_numbers() { + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -7.0, 0.0, 1.0e30, -1.0e30]; + let px = [200u8; 16]; + let mut d = PdfDoc::new(); + let p = d.add_page(300.0, 300.0); + for &a in &bad { + for &b in &bad { + p.clip_rect(a, b, a, b); + p.fill_rect(a, b, b, a, [1, 2, 3]); + p.rect_stroke(a, b, a, b, a, [1, 2, 3]); + p.stroke_line(a, b, b, a, a, [1, 2, 3]); + p.stroke_line_dashed(a, b, b, a, b, [1, 2, 3], &[a, b, 0.0]); + p.polyline(&[[a, b], [b, a], [1.0, 1.0]], a, [1, 2, 3], &[b, a]); + p.polygon(&[[a, b], [b, a], [1.0, 1.0]], Some([1, 2, 3]), Some(([4, 5, 6], a))); + p.circle(a, b, b, Some([1, 2, 3]), Some(([4, 5, 6], a))); + p.text(a, b, a, Font::Helvetica, [0, 0, 0], "x"); + p.text_rotated(a, b, 10.0, Font::TimesRoman, [0, 0, 0], "y", b); + p.image_rgba(a, b, b, a, &px, 2, 2); + } + } + p.stroke_line_dashed(0.0, 0.0, 1.0, 1.0, 1.0, [0, 0, 0], &[0.0; 100]); + let c = page_content(&d); + assert!(!c.contains("NaN") && !c.contains("inf")); + let pdf = d.finish(); + check_xref(&pdf); +} From 75305dfedf73e3227304108c75358d07bff5ccc4 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:51:41 +0200 Subject: [PATCH 40/62] XLSX: keep the workbook's default font on save (fixes #805) Cell format 0 always mapped to the model's built-in Calibri 11 default, so the file's Normal font was replaced on save and every default-formatted cell changed size. The Normal style now takes the font of cell format 0, and a Normal font in the theme's body face is written with scheme="minor" again. --- crates/model/src/style.rs | 6 ++++ crates/xlsx/src/read.rs | 4 +++ crates/xlsx/src/styles.rs | 13 ++++++--- crates/xlsx/src/tests/roundtrip.rs | 47 ++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index 126727e..d397687 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -377,6 +377,12 @@ impl Default for StyleTable { } impl StyleTable { + /// A table whose default (Normal) style is `normal`, such as a file's own Normal font. + pub fn with_default(normal: Style) -> Self { + let mut index = HashMap::new(); + index.insert(normal.clone(), StyleId::DEFAULT); + StyleTable { styles: vec![normal], index } + } pub fn get(&self, id: StyleId) -> &Style { self.styles.get(id.0 as usize).or_else(|| self.styles.first()).unwrap_or(&DEFAULT_STYLE) } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index a25444d..c4a1d27 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -120,6 +120,10 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if let Some(x) = cx.optional_xml(&r.target)? { let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x, &checkboxes); cx.num_fmts = num_fmts; + // Cell format 0 is the Normal style: its font is what every unformatted cell shows. + if let Some(normal) = xfs.first() { + cx.styles = StyleTable::with_default(Style { font: normal.font.clone(), ..Style::default() }); + } cx.xf_map = xfs.into_iter().enumerate().map(|(i, s)| if i == 0 { StyleId::DEFAULT } else { cx.styles.intern(s) }).collect(); cx.dxfs = dxfs; cx.palette = palette; diff --git a/crates/xlsx/src/styles.rs b/crates/xlsx/src/styles.rs index 1c98a54..01f1fa6 100644 --- a/crates/xlsx/src/styles.rs +++ b/crates/xlsx/src/styles.rs @@ -396,7 +396,8 @@ pub fn color_attrs(c: &Color) -> String { } } -fn font_xml(f: &Font, dxf: bool) -> String { +/// `minor`: the font follows the theme's body font (``). +fn font_xml(f: &Font, dxf: bool, minor: bool) -> String { let mut s = String::from(""); if f.bold { s.push_str(""); @@ -429,6 +430,9 @@ fn font_xml(f: &Font, dxf: bool) -> String { } if !dxf { let _ = write!(s, "", esc_attr(&f.name)); + if minor { + s.push_str(""); + } } s.push_str(""); s @@ -667,7 +671,7 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St || d.font.color != Color::Auto || d.font.vert != VertAlign::Baseline { - dx.push_str(&font_xml(&d.font, true)); + dx.push_str(&font_xml(&d.font, true, false)); } if d.num_fmt != def.num_fmt { let id = nf.id(d.num_fmt.as_str()); @@ -699,8 +703,9 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St s.push_str(""); } let _ = write!(s, "", fonts.len()); - for f in &fonts { - s.push_str(&font_xml(f, false)); + for (i, f) in fonts.iter().enumerate() { + // A Normal font in the theme's body face follows the theme, as Excel writes it (#805). + s.push_str(&font_xml(f, false, i == 0 && f.name == wb.theme.minor_font)); } let _ = write!(s, "", fills.len()); for f in &fills { diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 9601572..a1457fd 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1014,3 +1014,50 @@ fn package_kind_sets_workbook_content_type() { } assert_eq!(crate::XlsxKind::from_path("noext"), crate::XlsxKind::Workbook); } + +/// The workbook's default font (`fonts[0]`, the Normal style's) is what every default-formatted +/// cell shows, typed or empty; saving keeps it instead of resetting it to Calibri 11 (#805). +#[test] +fn default_font_survives_a_round_trip() { + let styles = |font0: &str| { + format!( + r#"{font0} + + + + "# + ) + }; + let rels = r#""#; + let body = r#"typed text1"#; + let font0 = |bytes: &[u8]| { + let mut pkg = Package::open(bytes).unwrap(); + let st = pkg.read_xml("xl/styles.xml").unwrap().unwrap(); + let f = st.child("fonts").unwrap().kids("font").next().unwrap().clone(); + let val = |n: &str| f.child(n).and_then(|e| e.attr("val")).map(str::to_string); + (val("sz"), val("name"), val("family"), val("scheme")) + }; + + let file = + super::minimal(body, &[("xl/styles.xml", &styles(r#""#))], rels, ""); + let (wb, _) = read_xlsx(&file).unwrap(); + let saved = write_xlsx(&wb).unwrap(); + assert_eq!(font0(&saved), (Some("10".into()), Some("Arial".into()), Some("2".into()), None)); + let (back, _) = read_xlsx(&saved).unwrap(); + let normal = &back.styles.get(StyleId::DEFAULT).font; + assert_eq!((normal.name.as_str(), normal.size), ("Arial", 10.0), "empty cells and new input take the Normal font"); + let s = back.sheet(0).unwrap(); + assert_eq!(s.cell(at("A1")).unwrap().style, StyleId::DEFAULT); + let b1 = &back.styles.get(s.cell(at("B1")).unwrap().style).font; + assert!(b1.bold && b1.size == 14.0, "an explicit font is still its own: {b1:?}"); + + // Excel's own Normal font follows the theme's body font; the scheme link survives too. + let file = super::minimal( + body, + &[("xl/styles.xml", &styles(r#""#))], + rels, + "", + ); + let saved = write_xlsx(&read_xlsx(&file).unwrap().0).unwrap(); + assert_eq!(font0(&saved), (Some("12".into()), Some("Calibri".into()), Some("2".into()), Some("minor".into()))); +} From 5406fc562926d6fe4dd93fa2f2d3067f295e37ac Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:55:53 +0200 Subject: [PATCH 41/62] XLSX: Save As into another folder keeps links on the same workbook (fixes #801) Excel stores the path of a linked workbook relative to the file that uses it, and rewrites it when the file is saved into another folder. The path was kept as text, so after Save As it named a file next to the new copy: a missing link, or another workbook with the same name. Relative externalLinkPath targets are now resolved against the folder the workbook was read from and written relative to the folder it is saved into (absolute when there is no relative path, on another drive). --- apps/gridcraft-cli/src/main.rs | 2 +- crates/engine/src/cmd/file.rs | 5 +- crates/engine/src/io.rs | 14 +++ crates/engine/src/tests.rs | 76 ++++++++++++ crates/model/src/workbook.rs | 5 + crates/xlsx/src/lib.rs | 1 + crates/xlsx/src/passthrough.rs | 137 ++++++++++++++++++++- crates/xlsx/src/tests/passthrough_tests.rs | 48 ++++++++ 8 files changed, 284 insertions(+), 4 deletions(-) diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index d72bbd9..152ddbc 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -277,7 +277,7 @@ fn convert(args: &[String]) -> Result<(), String> { let mut s = open(input)?; activate_sheet(&mut s, a.opt("sheet"))?; let d = s.doc().map_err(|e| e.to_string())?; - let bytes = gridcraft_engine::io::save_bytes(&d.wb, out).map_err(|e| e.to_string())?; + let bytes = gridcraft_engine::io::save_file_bytes(&d.wb, out).map_err(|e| e.to_string())?; gridcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?; eprintln!("wrote {out} ({} bytes)", bytes.len()); Ok(()) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3c4a101..0166193 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -56,7 +56,8 @@ fn open(s: &mut Session, p: &Json) -> Result { return ok(); }; let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted; - let (wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?; + let (mut wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?; + wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir); 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); @@ -105,7 +106,7 @@ fn save(s: &mut Session, p: &Json) -> Result { return super::print::export_pdf(s, &json!({"path": path})); } let d = s.doc_mut()?; - let bytes = crate::io::save_bytes(&d.wb, &path)?; + let bytes = crate::io::save_file_bytes(&d.wb, &path)?; crate::io::write_file(&path, &bytes)?; let kind = crate::io::FileKind::from_path(&path).unwrap_or(crate::io::FileKind::Xlsx); // Only a full-fidelity format clears the dirty flag and becomes the document's file. diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index bc7011d..f45d533 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -171,6 +171,20 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { } } +/// [`save_bytes`] for the file at `path`: relative paths to linked workbooks are rewritten for +/// its folder, so they still name the same files. +pub fn save_file_bytes(wb: &Workbook, path: &str) -> Result> { + match source_dir(path).and_then(|f| gridcraft_xlsx::rebase_links(wb, &f)) { + Some(rebased) => save_bytes(&rebased, path), + None => save_bytes(wb, path), + } +} + +/// The folder of the file at `path`, which relative paths to linked workbooks are relative to. +pub fn source_dir(path: &str) -> Option { + std::path::absolute(path).ok()?.parent().map(std::path::Path::to_path_buf) +} + /// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all /// characters in each cell" (Microsoft Support, *Excel formatting and features that are not /// transferred to other file formats*): number formats applied, never column-width `#####`. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..4edd7d4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1519,6 +1519,82 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() { std::fs::remove_dir_all(dir).unwrap(); } +/// A workbook whose B1 is `=[1]Sheet1!A1`, linked to `link` (an external relationship target). +fn linked_workbook(link: &str) -> Vec { + use std::io::{Cursor, Write}; + const NS_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + let types = r#""#.to_string(); + let rels = format!( + r#""# + ); + let book = format!( + r#""# + ); + let book_rels = format!( + r#""# + ); + let sheet = r#"[1]Sheet1!A110"#.to_string(); + let link_part = format!( + r#"10"# + ); + let link_rels = format!( + r#""# + ); + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, data) in [ + ("[Content_Types].xml", types), + ("_rels/.rels", rels), + ("xl/workbook.xml", book), + ("xl/_rels/workbook.xml.rels", book_rels), + ("xl/worksheets/sheet1.xml", sheet), + ("xl/externalLinks/externalLink1.xml", link_part), + ("xl/externalLinks/_rels/externalLink1.xml.rels", link_rels), + ] { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(data.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +/// The target of the linked workbook in a saved file. +fn link_target(path: &std::path::Path) -> String { + use std::io::Read; + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(std::fs::read(path).unwrap())).unwrap(); + let mut rels = String::new(); + zip.by_name("xl/externalLinks/_rels/externalLink1.xml.rels").unwrap().read_to_string(&mut rels).unwrap(); + let (_, after) = rels.split_once("Target=\"").unwrap(); + after.split_once('"').unwrap().0.to_string() +} + +/// Save As into another folder keeps the link on the same file, as Excel does: the relative path +/// is rewritten from the new folder (#801). +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn save_as_into_another_folder_keeps_linked_workbooks_on_the_same_file() { + #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let a = std::env::temp_dir().join(format!("gridcraft-links-{}-{nonce}", std::process::id())); + let b = a.join("b"); + std::fs::create_dir_all(&b).unwrap(); + let dest = a.join("Dest.xlsx"); + std::fs::write(&dest, linked_workbook("Source.xlsx")).unwrap(); + let mut s = s(); + s.execute("file.open", json!({"path": dest})).unwrap(); + s.execute("file.saveAs", json!({"path": b.join("Dest.xlsx")})).unwrap(); + assert_eq!(link_target(&b.join("Dest.xlsx")), "../Source.xlsx"); + // Saved again into the folder it was read from, the path is the one it was read with. + s.execute("file.saveAs", json!({"path": a.join("Copy.xlsx")})).unwrap(); + assert_eq!(link_target(&a.join("Copy.xlsx")), "Source.xlsx"); + // Read from a subfolder and saved into its parent, the path goes down into the subfolder. + let deep = b.join("Deep.xlsx"); + std::fs::write(&deep, linked_workbook("Source.xlsx")).unwrap(); + let mut s = Session::new(); + s.execute("file.open", json!({"path": deep})).unwrap(); + s.execute("file.saveAs", json!({"path": a.join("Deep.xlsx")})).unwrap(); + assert_eq!(link_target(&a.join("Deep.xlsx")), "b/Source.xlsx"); + std::fs::remove_dir_all(a).unwrap(); +} + #[test] fn validation_formulas_are_relative_and_see_the_new_value() { let mut s = s(); diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 18e1971..828ca69 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -116,6 +116,10 @@ pub struct Workbook { /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON. #[serde(skip)] pub preserved: Option>, + /// The folder the workbook was read from, which relative paths to linked workbooks in + /// `preserved` are relative to. Saving into another folder rewrites them from there. Not in JSON. + #[serde(skip)] + pub source_dir: Option, } impl Default for Workbook { @@ -143,6 +147,7 @@ impl Workbook { vba_project: None, csv_delimiter: None, preserved: None, + source_dir: None, } } pub fn sheet(&self, i: usize) -> Option<&Sheet> { diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index b5e4bb6..0c22764 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -47,6 +47,7 @@ use gridcraft_model::Workbook; pub use crypto::{DecryptError, check_encryption, decrypt}; pub use csv::{read_csv, write_csv}; pub use ods::read_ods; +pub use passthrough::rebase_links; pub use xls::read_xls; pub use xlsb::read_xlsb; diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index f121977..6817869 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -14,7 +14,8 @@ //! 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. +//! opened, followed or refreshed. A relative path to a linked workbook is rewritten when the +//! workbook is saved into another folder ([`rebase_links`]), so it still names the same file. //! - 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. @@ -24,6 +25,7 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet, VecDeque}; use std::fmt::Write as _; +use std::path::{Component, Path}; use std::sync::Arc; use gridcraft_model::{Preserved, PreservedPart, PreservedRel, PreservedXml, Sheet, Workbook}; @@ -922,3 +924,136 @@ impl<'a> SheetKept<'a> { s } } + +// ---------------------------------------------------------------- linked workbooks + +/// `wb` as it is saved into the folder `to` (absolute): the relative paths of its linked +/// workbooks, relative to the folder it was read from, are rewritten relative to `to`, as Excel +/// does on Save As, or made absolute when no relative path exists (another drive). `None` when +/// nothing changes. +pub fn rebase_links(wb: &Workbook, to: &Path) -> Option { + let p = wb.preserved.as_deref()?; + let from = Folder::of(wb.source_dir.as_deref()?)?; + let to = Folder::of(to)?; + if from == to { + return None; + } + let mut parts = p.parts.clone(); + let mut changed = false; + for r in parts.iter_mut().flat_map(|part| part.rels.iter_mut()) { + if r.external + && is_kind(r, &["externalLinkPath"]) + && let Some(target) = rebase(&r.target, &from, &to) + { + r.target = target; + changed = true; + } + } + if !changed { + return None; + } + let mut out = wb.clone(); + out.preserved = Some(Arc::new(Preserved { parts, ..p.clone() })); + Some(out) +} + +/// An absolute folder, lexically normalized. +#[derive(PartialEq)] +struct Folder { + /// Drive or share and root separator (`/`, `C:\`, `\\server\share\`). + root: String, + names: Vec, +} + +impl Folder { + fn of(dir: &Path) -> Option { + if !dir.is_absolute() { + return None; + } + let mut f = Folder { root: String::new(), names: vec![] }; + for c in dir.components() { + match c { + Component::Prefix(_) | Component::RootDir => f.root.push_str(&c.as_os_str().to_string_lossy()), + Component::CurDir => {} + Component::ParentDir => { + f.names.pop(); + } + Component::Normal(n) => f.names.push(n.to_string_lossy().into_owned()), + } + } + Some(f) + } +} + +/// A relative link target read from `from`, rewritten for `to`. `None` keeps it as it is: an +/// absolute path or URL, or a path that climbs above the root. +fn rebase(target: &str, from: &Folder, to: &Folder) -> Option { + if !is_relative_path(target) { + return None; + } + // Each name as the file system knows it, and as it is written in a target. Names taken from + // the original target keep their spelling. + let mut path: Vec<(String, String)> = from.names.iter().map(|n| (n.clone(), encode_name(n))).collect(); + for seg in target.split(['/', '\\']) { + match seg { + "" | "." => {} + ".." => { + path.pop()?; + } + _ => path.push((decode_name(seg), seg.to_string())), + } + } + let folders = path.len().checked_sub(1)?; + if !from.root.eq_ignore_ascii_case(&to.root) { + let sep = if from.root.contains('\\') { "\\" } else { "/" }; + let names: Vec<&str> = path.iter().map(|(name, _)| name.as_str()).collect(); + return Some(format!("file:///{}{}", from.root, names.join(sep))); + } + let common = to.names.iter().zip(&path).take(folders).take_while(|(a, (b, _))| *a == b).count(); + let up = "../".repeat(to.names.len().saturating_sub(common)); + let down: Vec<&str> = path.iter().skip(common).map(|(_, written)| written.as_str()).collect(); + Some(format!("{up}{}", down.join("/"))) +} + +/// Not empty, no root, no scheme or drive (`file:`, `http:`, `C:`). +fn is_relative_path(target: &str) -> bool { + let scheme = target.split_once(':').is_some_and(|(s, _)| { + s.starts_with(|c: char| c.is_ascii_alphabetic()) && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + }); + !target.is_empty() && !target.starts_with(['/', '\\']) && !scheme +} + +/// A folder name as a segment of a relationship target (a URI): ASCII characters a URI path +/// can't hold are percent-encoded, other characters are written as they are. +fn encode_name(name: &str) -> String { + let mut s = String::with_capacity(name.len()); + for c in name.chars() { + if !c.is_ascii() || c.is_ascii_alphanumeric() || "-._~!$&'()+,;=@".contains(c) { + s.push(c); + } else { + let _ = write!(s, "%{:02X}", c as u32); + } + } + s +} + +/// A target segment with its percent-escapes decoded; as written when they don't decode to UTF-8. +fn decode_name(seg: &str) -> String { + let b = seg.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(byte) if c == b'%' => { + out.push(byte); + i += 3; + } + _ => { + out.push(c); + i += 1; + } + } + } + String::from_utf8(out).unwrap_or_else(|_| seg.to_string()) +} diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 4cde9d6..810fa7c 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -483,3 +483,51 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() { 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()))); } + +/// Where a link with `target`, in a workbook read from `from`, points once saved into `to`. +#[cfg(unix)] +fn rebased(target: &str, from: &str, to: &str) -> String { + let link = |rel_type: &str| gridcraft_model::PreservedRel { + id: "rId1".into(), + rel_type: format!("{NS_R}/{rel_type}"), + target: target.into(), + external: true, + }; + let part = gridcraft_model::PreservedPart { + name: "xl/externalLinks/externalLink1.xml".into(), + content_type: String::new(), + data: Arc::from(&b""[..]), + rels: vec![link("externalLinkPath"), link("hyperlink")], + }; + let mut wb = gridcraft_model::Workbook::new(); + wb.preserved = Some(Arc::new(gridcraft_model::Preserved { parts: vec![part], ..Default::default() })); + wb.source_dir = Some(from.into()); + let Some(out) = crate::rebase_links(&wb, std::path::Path::new(to)) else { return target.into() }; + let rels = &out.preserved.as_ref().unwrap().parts[0].rels; + assert_eq!(rels[1].target, target, "only paths of linked workbooks change"); + rels[0].target.clone() +} + +#[test] +#[cfg(unix)] +fn saving_into_another_folder_rebases_relative_links_to_workbooks() { + assert_eq!(rebased("Source.xlsx", "/links", "/links/move2"), "../Source.xlsx"); + assert_eq!(rebased("Source.xlsx", "/links", "/other/deeper"), "../../links/Source.xlsx"); + assert_eq!(rebased("../Source.xlsx", "/links/move2", "/links"), "Source.xlsx"); + assert_eq!(rebased("sub\\Source.xlsx", "/links", "/links/sub"), "Source.xlsx"); + assert_eq!(rebased("./sub/../Source.xlsx", "/links", "/links/sub/"), "../Source.xlsx"); + // Folder names from the file system are written as a URI path; the target's own spelling stays. + assert_eq!(rebased("My%20Book.xlsx", "/links/q2 data", "/links"), "q2%20data/My%20Book.xlsx"); + assert_eq!(rebased("Résumé.xlsx", "/a/Été", "/a"), "Été/Résumé.xlsx"); + // The same folder, absolute paths, URLs and paths above the root are kept as they are. + assert_eq!(rebased("Source.xlsx", "/links/", "/links/x/.."), "Source.xlsx"); + for target in ["/links/Source.xlsx", "file:///C:/links/Source.xlsx", "C:\\links\\Source.xlsx", "https://example.com/Source.xlsx", "../../x.xlsx"] + { + assert_eq!(rebased(target, "/links", "/links/move2"), target); + } + // Without the folder it was read from, nothing to rebase. + let (mut wb, _) = read_xlsx(&rich_package()).unwrap(); + assert!(crate::rebase_links(&wb, std::path::Path::new("/elsewhere")).is_none()); + wb.source_dir = Some("relative/dir".into()); + assert!(crate::rebase_links(&wb, std::path::Path::new("/elsewhere")).is_none()); +} From 484612f638c2089ba458a851d4e96cf92027bd1e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:56:40 +0200 Subject: [PATCH 42/62] XLS: formulas that use a skipped defined name keep their saved results (fixes #803) --- crates/xlsx/src/tests/xls_tests.rs | 44 ++++++++++++++++++++++++++++++ crates/xlsx/src/xls/formula.rs | 17 +++++++----- crates/xlsx/src/xls/mod.rs | 3 ++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795..816c964 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -476,3 +476,47 @@ fn sheets_share_one_record_budget() { let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); assert!(err.contains("same substream"), "{err}"); } + +#[test] +fn formulas_using_a_skipped_name_keep_their_saved_results() { + // Lbl "ExtRange" = [Source.xlsx]Sheet1!$A$1:$A$3 (PtgArea3d through an external SupBook), + // "Local" = Sheet1!$A$1 and "Twice" = ExtRange*2. Only the first is skipped on import; a + // name that uses it is still read as it was. + let ext = cat(&[&[0x3B], &1u16.to_le_bytes(), &u16s(&[0, 2, 0, 0])]); + let local = cat(&[&[0x3A], &0u16.to_le_bytes(), &u16s(&[0, 0])]); + let twice = cat(&[&[0x23], &1u32.to_le_bytes(), &ptg_int(2), &[0x05]]); + let lbl = |name: &str, rgce: &[u8]| { + rec(0x0018, &cat(&[&u16s(&[0]), &[0, name.len() as u8], &u16s(&[rgce.len() as u16, 0, 0]), &[0; 4], &[0], name.as_bytes(), rgce])) + }; + let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])); + let globals = |pos: u32| { + cat(&[ + &bof(0x0005), + &boundsheet(pos), + &rec(0x01AE, &u16s(&[1, 0x0401])), + &rec(0x01AE, &cat(&[&u16s(&[1]), &xlstr("Source.xlsx"), &xlstr("Sheet1")])), + &rec(0x0017, &cat(&[&u16s(&[2]), &u16s(&[0, 0, 0]), &u16s(&[1, 0, 0])])), + &lbl("ExtRange", &ext), + &lbl("Local", &local), + &lbl("Twice", &twice), + &eof(), + ]) + }; + let sum_ext = cat(&[&[0x43], &1u32.to_le_bytes(), &[0x22, 1], &4u16.to_le_bytes()]); + let local_x2 = cat(&[&[0x43], &2u32.to_le_bytes(), &ptg_int(2), &[0x05]]); + let sheet = cat(&[ + &bof(0x0010), + &rec(0x0203, &cat(&[&cell(0, 0, 0), &10.0f64.to_le_bytes()])), + &formula(5, 1, num(60.0), &sum_ext), + &formula(6, 1, num(20.0), &local_x2), + &eof(), + ]); + let pos = globals(0).len() as u32; + let (wb, report) = read_xls(&compound("Workbook", &cat(&[&globals(pos), &sheet]))).unwrap(); + let names: Vec<_> = wb.names.iter().map(|n| (n.name.as_str(), n.formula.as_str())).collect(); + assert_eq!(names, [("Local", "Sheet1!$A$1"), ("Twice", "ExtRange*2")]); + assert_eq!(ftext(&wb, "B6"), None); + assert_eq!(v(&wb, 0, "B6"), Value::Number(60.0)); + assert_eq!(ftext(&wb, "B7").as_deref(), Some("Local*2")); + assert!(report.warnings.iter().any(|w| w.contains("defined names") && w.contains("saved results")), "{:?}", report.warnings); +} diff --git a/crates/xlsx/src/xls/formula.rs b/crates/xlsx/src/xls/formula.rs index 87a512c..3de8607 100644 --- a/crates/xlsx/src/xls/formula.rs +++ b/crates/xlsx/src/xls/formula.rs @@ -51,6 +51,8 @@ pub(super) struct Links { pub sheets: Vec, /// Defined names (Lbl records), in order; `None` for names that can't be referenced. pub names: Vec, + /// Per defined name: whether the import skips it (its formula can't be decoded). + pub skipped: Vec, pub books: Vec, pub xti: Vec, } @@ -330,13 +332,13 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } } 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"))?; + let i = usize::try_from(t.u32()?).ok().and_then(|i| i.checked_sub(1)); + let name = i.and_then(|i| links.names.get(i)).filter(|n| !n.is_empty()).ok_or(Unsupported("reference to a missing defined name"))?; + // A cell using a name the import drops would calculate to #NAME?: keep its + // saved result instead, as for references to other workbooks. + if mode != Mode::Name && i.and_then(|i| links.skipped.get(i)) == Some(&true) { + return Err(Unsupported("defined names GridCraft skipped")); + } push(&mut stack, kids, Expr::Name(name.clone()))?; } 0x24 | 0x2C => { @@ -479,6 +481,7 @@ mod tests { Links { sheets: vec!["Sheet1".into(), "My Data".into()], names: vec!["Rate".into()], + skipped: vec![false], books: vec![ SupBook { internal: true, addin: false, names: vec![] }, SupBook { internal: false, addin: true, names: vec!["_xlfn.IFERROR".into()] }, diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66..3c6c2f5 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -297,6 +297,9 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { _ => {} } } + // The same test `defined_names` applies, before any cell formula is decoded. + let skipped = g.names.iter().map(|l| !l.rgce.is_empty() && decode(&l.rgce, &l.rgcb, CellRef::new(0, 0), Mode::Name, &g.links).is_err()).collect(); + g.links.skipped = skipped; Ok(g) } From 0153457580ec0409cee9f1554d1d049d0d1e35a4 Mon Sep 17 00:00:00 2001 From: bmh129 <11478682+bmh129@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:03:47 -0500 Subject: [PATCH 43/62] View ribbon: lay out the Show group's checkboxes in two columns The Show group stacked Ruler, Formula Bar, Gridlines and Headings in one column, but the ribbon body fits only three rows, so Headings was cut off at the bottom and could not be reached. The four checkboxes now sit in two columns of two (Ruler/Gridlines, Formula Bar/Headings), as in Excel's Show group. Checked in English and German, which has the longest labels. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/ribbon.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291ac..135938c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2106,6 +2106,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { let lang = app.ui.language; let page_layout = app.session.active().and_then(|d| d.wb.active().map(|s| s.view == gridcraft_engine::model::SheetViewMode::PageLayout)).unwrap_or(false); + // Two columns of two, as in Excel's Show group: four stacked rows overflow the ribbon body. ui.vertical(|ui| { // Rulers exist only in Page Layout view. let mut ruler = !app.session.hide_ruler; @@ -2114,18 +2115,21 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { if ruler_resp.changed() { act(app, "view.ruler", json!({"on": ruler})); } - let mut fb = app.session.prefs.formula_bar; - let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar")); - control_keytip(app, ui, &resp, "view.formulaBar"); - if resp.changed() || invoke(app, "view.formulaBar") { - act(app, "view.formulaBar", json!({"on": !fb})); - } let mut g = s.0; let resp = ui.checkbox(&mut g, lang.tr("Gridlines")); control_keytip(app, ui, &resp, "view.gridlines"); if resp.changed() { act(app, "view.gridlines", json!({"on": g})); } + }); + ui.add_space(6.0); + ui.vertical(|ui| { + let mut fb = app.session.prefs.formula_bar; + let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar")); + control_keytip(app, ui, &resp, "view.formulaBar"); + if resp.changed() || invoke(app, "view.formulaBar") { + act(app, "view.formulaBar", json!({"on": !fb})); + } let mut h = s.1; let resp = ui.checkbox(&mut h, lang.tr("Headings")); control_keytip(app, ui, &resp, "view.headings"); From a7e22d299f9a6c69dd0581699ccbb673e1025f47 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:14:40 +0200 Subject: [PATCH 44/62] Grid: horizontal scrollbar in the sheet tab bar (fixes #840) --- crates/ui-egui/src/grid.rs | 66 +++++++++++++++++++++++++++++ crates/ui-egui/src/tabs.rs | 43 ++++--------------- crates/ui-egui/tests/interaction.rs | 66 +++++++++++++++++++++++++++++ docs/ui-parity.md | 5 ++- 4 files changed, 143 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..c42ff9e 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -104,6 +104,8 @@ pub struct GridState { pub hf_start: bool, /// The grid geometry of the last frame (hit-testing for agents and tests). pub geo: Option, + /// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests). + pub hscroll: Option, } impl GridState { @@ -230,6 +232,13 @@ impl Geo { None => (sh.row_top(row) - sh.row_top(self.fr)) as f32, } } + /// Scroll offset (in `scroll` units) at which `col` reaches the left of the scrolled area. + pub fn scroll_left_of(&self, sh: &Sheet, col: u32) -> f32 { + match &self.paper { + Some(pg) => pg.col_x(col) as f32, + None => (sh.col_left(col) - sh.col_left(self.fc)) as f32, + } + } /// Screen x for a logical x (and back): identity unless the sheet is right-to-left. pub fn mirror_x(&self, x: f32) -> f32 { if self.rtl { self.rect.left() + self.rect.right() - x } else { x } @@ -730,6 +739,63 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G } } +/// The horizontal counterpart of `vertical_scrollbar`, drawn in the sheet tab bar as in Excel. The tab +/// bar is laid out before the grid, so this reads the last frame's geometry and leaves clamping the +/// far end to the grid, which runs later in the same frame. +pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: Rect, t: &Tokens) { + app.grid.hscroll = Some(track); + if track.height() <= 0.0 || track.width() <= 0.0 { + return; + } + let Some(geo) = app.grid.geo.clone() else { return }; + let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; + let Some(sh) = wb.active() else { return }; + let visible = ((geo.cells.width() - geo.frozen_w) / geo.z).max(0.0); + let scroll = app.view().scroll.x; + let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1); + let content = geo.scroll_left_of(sh, last); + let extent = (content - visible).max(scroll).max(0.0); + let id = ui.id().with("hscroll"); + let resp = ui.interact(track, id, Sense::click_and_drag()); + let drag_id = id.with("extent"); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); + } + let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; + let thumb_w = (track.width() * visible / (extent + visible).max(1.0)).clamp(24.0_f32.min(track.width()), track.width()); + let travel = track.width() - thumb_w; + let offset = if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; + // A right-to-left sheet scrolls towards the left, so its thumb starts at the right end. + let dir = if geo.rtl { -1.0 } else { 1.0 }; + let thumb_left = if geo.rtl { track.right() - thumb_w - offset } else { track.left() + offset }; + let thumb = Rect::from_min_size(pos2(thumb_left, track.top() + 3.0), vec2(thumb_w, (track.height() - 6.0).max(0.0))); + ui.painter().rect_filled(track, 0.0, t.tab_bar); + ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); + let mut next = scroll; + if resp.dragged() && travel > 0.0 { + next += dir * resp.drag_delta().x / travel * extent; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + if p.x < thumb.left() { + next -= dir * visible; + } else if p.x > thumb.right() { + next += dir * visible; + } + } + if resp.hovered() { + let d = ui.input(|i| i.smooth_scroll_delta); + next -= (if d.x != 0.0 { dir * d.x } else { d.y }) / geo.z; + } + next = next.max(0.0); + if next != scroll { + if let Some(v) = app.view_mut() { + v.scroll.x = next; + } + ui.ctx().request_repaint(); + } +} + pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { let pad_v = 1.5 * z; let y_top = rect.top() + pad_v; diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index e55f575..8b12631 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -1,6 +1,6 @@ //! Sheet tabs and the status bar. -use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use gridcraft_engine::model::Visibility; use serde_json::json; @@ -37,9 +37,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { .filter(|(_, s)| s.visibility == Visibility::Visible) .map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet)) .collect(); - // Keep worksheet tabs in their own horizontally scrollable - // viewport, leaving room for the New Sheet button and grid scrollbar. - let tabs_width = (ui.available_width() - 180.0).max(100.0); + // Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the + // tabs get 60% of the bar and the grid's horizontal scrollbar the rest. + let tabs_width = (ui.available_width() * 0.6).max(100.0); ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { // Translate vertical wheel movement into horizontal // worksheet-tab scrolling. @@ -180,38 +180,11 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, "New sheet (⇧F11)", vec2(26.0, 26.0)).clicked() { app.run_or_alert("home.insertSheet", json!({})); } - // Horizontal position indicator (a slim scrollbar). + // The track ends where the grid does, so it stops under the vertical scrollbar. let avail = ui.available_rect_before_wrap(); - if avail.width() > 120.0 { - let bar = Rect::from_min_max(pos2(avail.left() + 40.0, avail.center().y - 4.0), pos2(avail.right() - 16.0, avail.center().y + 4.0)); - let used_w = app - .session - .active() - .and_then(|d| d.wb.active().and_then(|s| s.used_range().map(|u| s.col_left(u.end.col + 10) as f32))) - .unwrap_or(2000.0) - .max(2000.0); - let view = app.view(); - let vis = app.grid.cells_rect.map(|r| r.width()).unwrap_or(1000.0); - let total = used_w.max(view.scroll.x + vis); - let frac0 = (view.scroll.x / total).clamp(0.0, 1.0); - let frac1 = ((view.scroll.x + vis) / total).clamp(0.0, 1.0); - let thumb = Rect::from_min_max( - pos2(bar.left() + bar.width() * frac0, bar.top()), - pos2(bar.left() + bar.width() * frac1.max(frac0 + 0.03), bar.bottom()), - ); - let resp = ui.interact(bar, ui.id().with("hscroll"), Sense::click_and_drag()); - ui.painter().rect_filled( - thumb, - 4.0, - if resp.hovered() || resp.dragged() { Color32::from_gray(150) } else { Color32::from_gray(190) }, - ); - if resp.dragged() { - let dx = resp.drag_delta().x / bar.width() * total; - if let Some(v) = app.view_mut() { - v.scroll.x = (v.scroll.x + dx).max(0.0); - } - } - } + let right = app.grid.rect.map_or(avail.right(), |r| r.right()).min(avail.right()); + let track = Rect::from_min_max(pos2(avail.left() + 8.0, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0)); + crate::grid::horizontal_scrollbar(app, ui, track, &t); }); }); } diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 7093933..30f417f 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -58,6 +58,72 @@ fn vertical_scrollbar_pages_and_drags_without_changing_selection() { assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0)); } +/// The first column painted right of the frozen ones. +fn first_scrolled_col(h: &egui_kittest::Harness<'static, SheetApp>) -> u32 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = app.grid.geo.as_ref().unwrap(); + geo.col_at(sheet, geo.cells.left() + geo.frozen_w + 0.5) +} + +fn click(h: &mut egui_kittest::Harness<'static, SheetApp>, p: egui::Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn horizontal_scrollbar_pages_and_drags_across_a_wide_sheet() { + let mut wb = gridcraft_engine::model::Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + for c in 0..26 { + sh.set_value(CellRef::new(0, c), Value::text(format!("col {}", c + 1))); + } + // Column A frozen: a page is the scrolling part only. + sh.freeze = Some((0, 1)); + let mut s = Session::new(); + s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into())); + let mut h = harness(s); + let track = h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar"); + assert!(track.width() >= 200.0, "the scrollbar gets a usable share of the tab bar: {track:?}"); + assert_eq!(first_scrolled_col(&h), 1); + + let page = { + let geo = h.state().grid.geo.as_ref().unwrap(); + (geo.cells.width() - geo.frozen_w) / geo.z + }; + click(&mut h, egui::pos2(track.right() - 6.0, track.center().y)); + let scrolled = h.state().view().scroll.x; + assert!((scrolled - page).abs() < 0.5, "clicking the track right of the thumb scrolls one page: {scrolled} vs {page}"); + assert!(first_scrolled_col(&h) > 1); + let geo = h.state().grid.geo.clone().unwrap(); + let sheet = h.state().session.active().unwrap().wb.active().unwrap().clone(); + assert_eq!(geo.x(&sheet, 0), geo.cells.left(), "the frozen column stays put"); + click(&mut h, egui::pos2(track.right() - 6.0, track.center().y)); + let further = h.state().view().scroll.x; + click(&mut h, egui::pos2(track.left() + 2.0, track.center().y)); + assert!(h.state().view().scroll.x < further, "clicking left of the thumb scrolls back"); + + h.state_mut().view_mut().unwrap().scroll.x = 0.0; + h.run_steps(2); + let start = egui::pos2(track.left() + 6.0, track.center().y); + h.input_mut().events.push(Event::PointerMoved(start)); + h.input_mut().events.push(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + let end = start + egui::vec2(120.0, 0.0); + h.input_mut().events.push(Event::PointerMoved(end)); + h.run_steps(2); + let dragged = h.state().view().scroll.x; + assert!(first_scrolled_col(&h) > 1, "dragging the thumb right changes the first visible column"); + h.run_steps(3); + assert_eq!(h.state().view().scroll.x, dragged, "holding the thumb still must not keep scrolling"); + h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0)); +} + fn key(h: &mut egui_kittest::Harness<'static, SheetApp>, k: Key, m: Modifiers) { h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..bd9755f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **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 @@ -59,7 +59,7 @@ this logic, with one undo step. | Move and resize charts, pictures and shapes | charts can't be resized (#190) | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | -| Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar reported missing (#176) | +| Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); the tabs/scrollbar split can't be dragged yet | | Text overflow into empty neighbours, wrap, rotation, shrink to fit | done | ## Keyboard @@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | From 443301f9eea66b69b1328b1faa831008ea6b7799 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:21:25 +0200 Subject: [PATCH 45/62] UI: Enter pastes the copied range and ends copy mode (fixes #845) --- crates/ui-egui/src/grid.rs | 6 ++++++ crates/ui-egui/tests/clipboard.rs | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..7673deb 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2398,6 +2398,12 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()})); app.grid.ensure_visible = true; } + // Excel: Enter while a copied range is marked pastes it and ends copy mode. + Key::Enter if !m.alt && !m.ctrl && !shift && !cmd && app.session.clipboard.is_some() => { + app.run_or_alert("edit.paste", json!({})); + app.session.clipboard = None; + app.grid.ensure_visible = true; + } Key::Enter => { if m.alt || m.ctrl { app.begin_edit(None, false); diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs index 71fe336..e2ac4fb 100644 --- a/crates/ui-egui/tests/clipboard.rs +++ b/crates/ui-egui/tests/clipboard.rs @@ -116,3 +116,24 @@ fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() { assert_eq!(h.state().message.as_ref().map(|(_, text)| text.as_str()), Some("This action won't work on multiple selections.")); assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2), "the earlier copy stays"); } + +#[test] +fn enter_pastes_the_copied_cells_and_ends_copy_mode() { + // #845: with the marching ants showing, Enter pastes into the selection and cancels the copy. + let mut h = harness(Services::default()); + h.input_mut().events.push(Event::Copy); + h.step(); + h.state_mut().run("selection.set", json!({"cell": "D5"})).unwrap(); + h.key_press(egui::Key::Enter); + h.run_steps(2); + let value = |r, c| h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(gridcraft_engine::core::CellRef::new(r, c)))); + assert_eq!(value(4, 3), Some(gridcraft_engine::core::Value::text("Name"))); + assert_eq!(value(5, 4), Some(gridcraft_engine::core::Value::number(42.0))); + assert!(h.state().session.clipboard.is_none(), "the marching ants are gone"); + assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()), Some("D5".to_string()), "Enter does not also move down"); + + // Copy mode is over: the next Enter just moves. + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()), Some("D6".to_string())); +} From 39b3d55f8a0185827067f068da9b396baf1fbbf7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:16:05 +0200 Subject: [PATCH 46/62] CSV export: refuse a file over 512 MiB instead of expanding the whole used range (#528) write_csv visited every position of the used range and appended to one growing String with no limit, so two cells far apart (A1 and XFD1048576) asked for about 16 GiB of commas. The output is now capped at 512 MiB: a lower bound (delimiters and line ends of every row) is checked before anything is allocated, and the text written so far is checked after each row. Over the cap, write_csv returns IoError::TooLarge naming the rows, columns and limit, and Save As / Export CSV and TSV report it without writing a file. Accepted exports are unchanged. write_csv now returns a Result. --- crates/engine/src/io.rs | 7 +-- crates/engine/tests/csv_export_budget.rs | 45 +++++++++++++++++++ crates/xlsx/src/csv.rs | 33 ++++++++++++-- crates/xlsx/src/lib.rs | 2 +- crates/xlsx/src/tests/csv_tests.rs | 56 ++++++++++++++++++++++-- 5 files changed, 132 insertions(+), 11 deletions(-) create mode 100644 crates/engine/tests/csv_export_budget.rs diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index bc7011d..5453be8 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -164,8 +164,8 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { 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())), // A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`). - FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()), - FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()), + FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default), + FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default), FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), FileKind::Html => Ok(to_html(wb, sheet).into_bytes()), } @@ -174,7 +174,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { /// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all /// characters in each cell" (Microsoft Support, *Excel formatting and features that are not /// transferred to other file formats*): number formats applied, never column-width `#####`. -fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec { +fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result> { gridcraft_xlsx::write_csv(sh, delimiter, |c| { // General's grid-width formatting rounds large identifiers to scientific notation. // Delimited files have no column width, so preserve the whole numeric value instead. @@ -187,6 +187,7 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec { crate::display::value_text(wb, sh, c) } }) + .map_err(|e| EngineError::Other(e.to_string())) } fn esc(s: &str) -> String { diff --git a/crates/engine/tests/csv_export_budget.rs b/crates/engine/tests/csv_export_budget.rs new file mode 100644 index 0000000..22068d1 --- /dev/null +++ b/crates/engine/tests/csv_export_budget.rs @@ -0,0 +1,45 @@ +//! CSV and TSV export is capped: two cells far apart in a sheet would otherwise make a file as +//! big as the whole rectangle between them. A refused export is an error and writes nothing. + +use gridcraft_engine::Session; +use serde_json::json; + +fn two_far_cells() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap(); + s.execute("cell.set", json!({"cell": "XFD1048576", "input": "b"})).unwrap(); + s +} + +#[test] +fn a_sparse_sheet_spanning_the_grid_is_refused_not_expanded() { + let mut s = two_far_cells(); + for format in ["csv", "tsv"] { + let err = s.execute("file.saveBytes", json!({"format": format})).unwrap_err().to_string(); + assert!(err.contains("1048576 rows by 16384 columns") && err.contains("MiB"), "{format}: {err}"); + } + // Save As and Export write no file, and the document stays as it was. + let dir = std::env::temp_dir().join(format!("gridcraft-csv-budget-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("big.csv"); + let err = s.execute("file.exportCsv", json!({"path": path.to_str().unwrap()})).unwrap_err().to_string(); + assert!(err.contains("over the limit"), "{err}"); + assert!(!path.exists(), "nothing is written for a refused export"); + std::fs::remove_dir_all(&dir).unwrap(); + // Other formats are unaffected, and so is a small export from the same session. + assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok()); + s.execute("cell.set", json!({"cell": "XFD1048576", "input": ""})).unwrap(); + assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], 3); +} + +#[test] +fn exports_below_the_cap_keep_their_positions() { + for (corner, bytes) in [("C3", 14), ("Z10", 272), ("XFD8", 131_082)] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap(); + s.execute("cell.set", json!({"cell": corner, "input": "b"})).unwrap(); + assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], bytes, "{corner}"); + } +} diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index 2becc0e..47486bf 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -8,6 +8,9 @@ use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook}; use crate::{CsvOptions, IoError}; +/// The largest CSV or tab-separated file written (bytes). +pub const MAX_CSV_BYTES: usize = 512 << 20; + /// Windows-1252 code points for bytes 0x80–0x9F (others map to Latin-1). const CP1252: [u32; 32] = [ 0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019, @@ -187,10 +190,31 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { /// Writes the sheet's used range as CSV (CRLF line ends, UTF-8 without BOM). `cell_text` gives /// each cell's field text: the caller decides how values look (Save As CSV writes them as /// displayed, number formats applied); quoting is done here. -pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef) -> String) -> Vec { +/// +/// The output is capped at [`MAX_CSV_BYTES`]: two cells far apart make the used range, and so the +/// file, as big as the whole rectangle between them (`A1` and `XFD1048576` are 16 GiB of commas). +/// An export that would pass the cap is refused before anything is allocated, or as soon as the +/// text written so far passes it, with [`IoError::TooLarge`]; accepted exports keep every field in +/// its place. +pub fn write_csv(sheet: &Sheet, delimiter: u8, cell_text: impl FnMut(CellRef) -> String) -> Result, IoError> { + write_csv_limited(sheet, delimiter, MAX_CSV_BYTES, cell_text) +} + +/// [`write_csv`] with the output cap `limit` (bytes) given. +pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_text: impl FnMut(CellRef) -> String) -> Result, IoError> { let delim = if delimiter == 0 { ',' } else { delimiter as char }; let mut out = String::new(); - let Some(r) = sheet.used_range() else { return Vec::new() }; + let Some(r) = sheet.used_range() else { return Ok(Vec::new()) }; + let too_large = + |what: String| IoError::TooLarge(format!("the CSV would be {what}, over the limit of {} MiB; export a smaller range", limit >> 20)); + // Every row has a delimiter between its fields and a CRLF, whatever the cells hold: a lower + // bound on the output that needs no cell text. + let (rows, cols) = (u64::from(r.end.row - r.start.row) + 1, u64::from(r.end.col - r.start.col) + 1); + let per_row = (cols - 1).saturating_mul(delim.len_utf8() as u64).saturating_add(2); + let least = rows.saturating_mul(per_row); + if least > limit as u64 { + return Err(too_large(format!("at least {} MiB ({rows} rows by {cols} columns)", least >> 20))); + } for row in r.start.row..=r.end.row { for col in r.start.col..=r.end.col { if col > r.start.col { @@ -206,6 +230,9 @@ pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef } } out.push_str("\r\n"); + if out.len() > limit { + return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1))); + } } - out.into_bytes() + Ok(out.into_bytes()) } diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index b5e4bb6..6fd80d9 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -45,7 +45,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use crypto::{DecryptError, check_encryption, decrypt}; -pub use csv::{read_csv, write_csv}; +pub use csv::{MAX_CSV_BYTES, read_csv, write_csv, write_csv_limited}; pub use ods::read_ods; pub use xls::read_xls; pub use xlsb::read_xlsb; diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index c595155..cc52072 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -2,7 +2,7 @@ use gridcraft_core::{CellRef, DateSystem, Value}; use gridcraft_model::{Sheet, Workbook}; use crate::csv::{decode_text, sniff_delimiter}; -use crate::{CsvOptions, Format, read_csv, sniff, write_csv}; +use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited}; fn v(wb: &Workbook, r: u32, c: u32) -> Value { wb.sheet(0).unwrap().value(CellRef::new(r, c)) @@ -109,11 +109,11 @@ fn writing() { Value::Error(e) => e.as_str().into(), Value::Array(_) => String::new(), }; - let out = String::from_utf8(write_csv(sh, b',', raw)).unwrap(); + let out = String::from_utf8(write_csv(sh, b',', raw).unwrap()).unwrap(); assert_eq!(out, "\"a,b\",\"q\"\"t\",\r\n0.1,FALSE,\r\n#DIV/0!,,\"x\ny\"\r\n"); - let semi = String::from_utf8(write_csv(sh, b';', raw)).unwrap(); + let semi = String::from_utf8(write_csv(sh, b';', raw).unwrap()).unwrap(); assert!(semi.starts_with("a,b;")); - assert!(write_csv(&Sheet::new("E"), b',', raw).is_empty()); + assert!(write_csv(&Sheet::new("E"), b',', raw).unwrap().is_empty()); // Round trip. let back = read_csv(out.as_bytes(), &CsvOptions::default()).unwrap(); assert_eq!(v(&back, 0, 0), Value::text("a,b")); @@ -142,3 +142,51 @@ fn hostile_csv() { let wb = read_csv(format!("{wide}x").as_bytes(), &CsvOptions::default()).unwrap(); assert!(wb.sheet(0).unwrap().cells.is_empty(), "fields past column XFD are dropped"); } + +/// A sheet with `a` in A1 and `b` in `corner`. +fn two_cells(corner: (u32, u32)) -> Sheet { + let mut s = Sheet::new("S"); + s.set_value(CellRef::new(0, 0), Value::text("a")); + s.set_value(CellRef::new(corner.0, corner.1), Value::text("b")); + s +} + +fn text_of(sh: &Sheet) -> impl FnMut(CellRef) -> String + '_ { + |c| match sh.value(c) { + Value::Text(t) => t.to_string(), + _ => String::new(), + } +} + +#[test] +fn two_far_apart_cells_are_refused_before_anything_is_allocated() { + // A1 and XFD1048576 span the whole grid: 16 GiB of commas. Refused at once, with the numbers. + let s = two_cells((gridcraft_core::MAX_ROWS - 1, gridcraft_core::MAX_COLS - 1)); + let err = write_csv(&s, b',', text_of(&s)).unwrap_err(); + assert!(matches!(err, IoError::TooLarge(_)), "{err}"); + let msg = err.to_string(); + assert!(msg.contains("1048576 rows by 16384 columns") && msg.contains("512 MiB"), "{msg}"); + // A single row or column across the grid is refused too when it is over the cap. + let wide = two_cells((gridcraft_core::MAX_ROWS - 1, 0)); + assert!(write_csv_limited(&wide, b',', 1 << 20, text_of(&wide)).is_err()); +} + +#[test] +fn the_cap_counts_the_bytes_the_cells_hold_and_accepted_exports_keep_every_position() { + // 3 rows x 3 columns, 14 bytes: accepted at the cap, refused one byte under. + let s = two_cells((2, 2)); + let ok = write_csv_limited(&s, b',', 14, text_of(&s)).unwrap(); + assert_eq!(String::from_utf8(ok).unwrap(), "a,,\r\n,,\r\n,,b\r\n"); + assert!(write_csv_limited(&s, b',', 13, text_of(&s)).is_err()); + // Rows that pass the lower bound but hold long text stop as soon as the cap is passed. + let mut long = Sheet::new("L"); + for r in 0..50u32 { + long.set_value(CellRef::new(r, 0), Value::text("x".repeat(100))); + } + let err = write_csv_limited(&long, b',', 1000, text_of(&long)).unwrap_err(); + assert!(err.to_string().contains("stopped at row"), "{err}"); + assert!(write_csv_limited(&long, b',', 50 * 102, text_of(&long)).is_ok()); + // A tab is one byte, like a comma. + let tabs = write_csv_limited(&s, b'\t', 14, text_of(&s)).unwrap(); + assert_eq!(tabs.len(), 14); +} From d1260e1878c41894ad6171404ee51b8f0e9396ed Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:17:16 +0200 Subject: [PATCH 47/62] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ docs/file-format-parity.md | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..94ae973 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | CSV and tab-separated export is capped at 512 MiB: a sheet whose used range would expand to more (two cells far apart, such as A1 and XFD1048576, are 16 GiB of commas) is refused with the rows, columns and limit, before anything is allocated or written (#528) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: CSV export size cap (#528) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8510659..5cf441c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528); previously minor: CSV save keeps the separator it was opened with) · **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** @@ -34,9 +34,9 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | Format | Excel | GridCraft read | GridCraft write | Notes | |---|---|---|---|---| -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes | Text Import Wizard options partly via Text to Columns | -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | @@ -78,6 +78,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | From daeda9db472e89a845ece49b320135c01fa80c1f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:17:16 +0200 Subject: [PATCH 48/62] Credit Silvertree2010 in the About window --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 5d343e9..d522b84 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -61,3 +61,7 @@ sync_github_name = true real_name = "James Symes" sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false From cf7138b5e19d061e98f90731f024f42502ba4621 Mon Sep 17 00:00:00 2001 From: derechopiano <257026466+derechopiano@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:31:48 +0100 Subject: [PATCH 49/62] UI: Show rule-specific data validation fields --- crates/ui-egui/src/dialogs.rs | 229 +++++++++++++++++++++++++++------- 1 file changed, 184 insertions(+), 45 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c6..63a9809 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -534,48 +534,8 @@ impl Dialog { "dataValidation", "Data Validation", "data.validation", - vec![ - Choice { - key: "type", - label: "Allow", - options: vec![ - ("any", "Any value"), - ("whole", "Whole number"), - ("decimal", "Decimal"), - ("list", "List"), - ("date", "Date"), - ("time", "Time"), - ("textLength", "Text length"), - ("custom", "Custom"), - ], - }, - Choice { - key: "operator", - label: "Data", - options: vec![ - ("between", "between"), - ("notBetween", "not between"), - ("equal", "equal to"), - ("notEqual", "not equal to"), - ("greater", "greater than"), - ("less", "less than"), - ("greaterOrEqual", "greater than or equal to"), - ("lessOrEqual", "less than or equal to"), - ], - }, - Text { key: "formula1", label: "Minimum / Source / Formula:" }, - Text { key: "formula2", label: "Maximum:" }, - Check { key: "ignoreBlank", label: "Ignore blank" }, - Check { key: "dropdown", label: "In-cell dropdown" }, - Text { key: "inputMessage", label: "Input message:" }, - Choice { - key: "errorStyle", - label: "Error style", - options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")], - }, - Text { key: "errorMessage", label: "Error message:" }, - ], - json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}), + data_validation_fields("any", "between"), + json!({"type": "any", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}), ), "cfQuick" => { let ty = p("type").as_str().unwrap_or("cellIs").to_string(); @@ -812,10 +772,132 @@ fn abs(r: &gridcraft_engine::core::RangeRef) -> String { if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } } +/// The criterion controls in Excel's Settings tab depend on both Allow and Data. +/// `formula1` is the sole source for a list or custom rule; `formula2` is a +/// second bound only for Between and Not between. +fn data_validation_fields(kind: &str, operator: &str) -> Vec { + use Field::*; + let mut fields = vec![Choice { + key: "type", + label: "Allow", + options: vec![ + ("any", "Any value"), + ("whole", "Whole number"), + ("decimal", "Decimal"), + ("list", "List"), + ("date", "Date"), + ("time", "Time"), + ("textLength", "Text length"), + ("custom", "Custom"), + ], + }]; + let two_bounds = matches!(operator, "between" | "notBetween"); + match kind { + "list" => { + fields.push(Text { key: "formula1", label: "Source:" }); + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + fields.push(Check { key: "dropdown", label: "In-cell dropdown" }); + } + "custom" => { + fields.push(Text { key: "formula1", label: "Formula:" }); + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + } + "whole" | "decimal" | "date" | "time" | "textLength" => { + fields.push(Choice { + key: "operator", + label: "Data", + options: vec![ + ("between", "between"), + ("notBetween", "not between"), + ("equal", "equal to"), + ("notEqual", "not equal to"), + ("greater", "greater than"), + ("less", "less than"), + ("greaterOrEqual", "greater than or equal to"), + ("lessOrEqual", "less than or equal to"), + ], + }); + let lower = matches!(operator, "greater" | "greaterOrEqual"); + let (first, second) = match kind { + "date" => ( + if two_bounds || lower { + "Start date:" + } else if matches!(operator, "less" | "lessOrEqual") { + "End date:" + } else { + "Date:" + }, + "End date:", + ), + "time" => ( + if two_bounds || lower { + "Start time:" + } else if matches!(operator, "less" | "lessOrEqual") { + "End time:" + } else { + "Time:" + }, + "End time:", + ), + _ => ( + if two_bounds || lower { + "Minimum:" + } else if matches!(operator, "less" | "lessOrEqual") { + "Maximum:" + } else { + "Value:" + }, + "Maximum:", + ), + }; + fields.push(Text { key: "formula1", label: first }); + if two_bounds { + fields.push(Text { key: "formula2", label: second }); + } + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + } + _ => {} + } + fields.push(Text { key: "inputMessage", label: "Input message:" }); + fields.push(Choice { + key: "errorStyle", + label: "Error style", + options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")], + }); + fields.push(Text { key: "errorMessage", label: "Error message:" }); + fields +} + +fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); + let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); + d.fields = data_validation_fields(kind, operator); + form_fields(ui, d); + ok_cancel(ui, confirm, open); +} + /// Builds the command params from form values for dialogs that need translation. fn build_params(d: &Dialog) -> Json { let mut v = Json::Object(d.values.clone()); match d.name.as_str() { + "dataValidation" => { + let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); + let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); + if let Some(m) = v.as_object_mut() { + if !matches!(operator, "between" | "notBetween") || matches!(kind, "any" | "list" | "custom") { + m.remove("formula2"); + } + if matches!(kind, "any" | "list" | "custom") { + m.remove("operator"); + } + if kind == "any" { + m.remove("formula1"); + } + if kind != "list" { + m.remove("dropdown"); + } + } + } "textToColumns" => { let mut delims = Vec::new(); for (k, c) in [("tab", "\t"), ("semicolon", ";"), ("comma", ","), ("space", " ")] { @@ -903,6 +985,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { ui.set_min_width(width - 20.0); match d.name.as_str() { "formatCells" => format_cells(app, ui, &mut d, &mut confirm), + "dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open), "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), "find" => find(app, ui, &mut d), "goTo" => go_to(app, ui, &mut d, &mut confirm), @@ -1286,9 +1369,20 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { ui.vertical(|ui| { ui.label(egui::RichText::new(label).strong()); let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - for (val, text) in options { - if ui.radio(cur == val, text).clicked() { - d.values.insert(key.into(), json!(val)); + if d.name == "dataValidation" { + let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| *text).unwrap_or(""); + egui::ComboBox::from_id_salt(("dataValidation", key)).selected_text(selected).width(300.0).show_ui(ui, |ui| { + for (val, text) in options { + if ui.selectable_label(cur == val, text).clicked() { + d.values.insert(key.into(), json!(val)); + } + } + }); + } else { + for (val, text) in options { + if ui.radio(cur == val, text).clicked() { + d.values.insert(key.into(), json!(val)); + } } } }); @@ -2460,3 +2554,48 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo } }); } + +#[cfg(test)] +mod data_validation_tests { + use super::*; + + #[test] + fn list_uses_one_source_and_does_not_submit_a_second_list() { + let fields = data_validation_fields("list", "between"); + assert!(fields.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "Source:" }))); + assert!(!fields.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. } | Field::Choice { key: "operator", .. }))); + + let dialog = Dialog::form( + "dataValidation", + "Data Validation", + "data.validation", + fields, + json!({"type": "list", "operator": "between", "formula1": "ABC,DEF", "formula2": "GHI,JKL", "dropdown": true}), + ); + let params = build_params(&dialog); + assert_eq!(params["formula1"], "ABC,DEF"); + assert!(params.get("formula2").is_none()); + assert!(params.get("operator").is_none()); + } + + #[test] + fn only_between_operators_show_and_submit_two_bounds() { + let two = data_validation_fields("whole", "between"); + assert!(two.iter().any(|f| matches!(f, Field::Text { key: "formula2", label: "Maximum:" }))); + let one = data_validation_fields("date", "lessOrEqual"); + assert!(one.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "End date:" }))); + assert!(!one.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. }))); + + let dialog = Dialog::form( + "dataValidation", + "Data Validation", + "data.validation", + one, + json!({"type": "date", "operator": "lessOrEqual", "formula1": "2026-10-12", "formula2": "2030-01-01"}), + ); + let params = build_params(&dialog); + assert_eq!(params["operator"], "lessOrEqual"); + assert_eq!(params["formula1"], "2026-10-12"); + assert!(params.get("formula2").is_none()); + } +} From c5c1763774056ec83ed59401bb1c8a21c6f7fc23 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:45:27 +0200 Subject: [PATCH 50/62] XLSX: keep chart sheets on open and save (#727) Chart sheets (a sheet that holds one chart; Move Chart > New sheet, F11) were skipped on open, and their chart sheet, drawing, chart, style and colour parts were dropped with them because the pass-through treats those kinds as content GridCraft rewrites. Saving a workbook lost every chart sheet. They are now kept as they were: the entry (name, visibility and its place among the worksheets), the chartsheet part and everything behind it, byte for byte, written back in place. The workbook's other sheet positions (active tab, print areas, scoped names) are computed with the chart sheets counted. A chart sheet is left out on save when the chart is no longer valid for the workbook: a sheet it plots was deleted or renamed, or a worksheet took its name. --- crates/model/src/lib.rs | 2 +- crates/model/src/preserved.rs | 24 ++- crates/xlsx/src/passthrough.rs | 228 +++++++++++++++++++-- crates/xlsx/src/read.rs | 7 +- crates/xlsx/src/tests/passthrough_tests.rs | 180 ++++++++++++++++ crates/xlsx/src/write.rs | 31 ++- 6 files changed, 450 insertions(+), 22 deletions(-) diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 1bea842..c5400ac 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -18,7 +18,7 @@ pub mod workbook; pub use cell::{Cell, CellPicture, Formula}; pub use features::*; pub use password::PasswordHash; -pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml}; +pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml}; pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility}; pub use store::CellStore; pub use style::{ diff --git a/crates/model/src/preserved.rs b/crates/model/src/preserved.rs index 6d0b35d..ada77b3 100644 --- a/crates/model/src/preserved.rs +++ b/crates/model/src/preserved.rs @@ -7,6 +7,8 @@ use std::sync::Arc; +use crate::sheet::Visibility; + /// 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)] @@ -42,6 +44,19 @@ pub struct PreservedXml { pub xml: String, } +/// A chart sheet (a sheet that holds one chart and no cells) kept as it was in the source file. Its +/// part, drawing, chart, style and colour parts are in [`Preserved::parts`]; the workbook +/// relationship to it is in [`Preserved::rels`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PreservedSheet { + pub name: String, + pub visibility: Visibility, + /// The id of the workbook relationship to the chart sheet part, in the source file. + pub rel_id: String, + /// How many worksheets come before it: its position among the model's sheets. + pub before: usize, +} + /// Unmodelled content kept for one owner (the workbook, which also holds package-root content, /// or one sheet). #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -57,11 +72,18 @@ pub struct Preserved { /// 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, + /// Chart sheets, in file order (workbook owner only). + pub sheets: Vec, } 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() + self.package_rels.is_empty() + && self.rels.is_empty() + && self.parts.is_empty() + && self.elements.is_empty() + && self.vml.is_none() + && self.sheets.is_empty() } /// Total bytes held. pub fn byte_len(&self) -> usize { diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index f121977..02e73ae 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -26,7 +26,7 @@ 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 gridcraft_model::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml, Sheet, Visibility, Workbook}; use quick_xml::events::attributes::Attribute; use quick_xml::events::{BytesStart, Event}; use quick_xml::name::QName; @@ -131,6 +131,10 @@ const REWRITTEN: &[&str] = &[ "powerpivotdata", ]; +/// Relationship kinds a chart sheet reaches that are otherwise rewritten: its drawing, chart and +/// their style, colour and shape parts are kept byte for byte (the chart sheet is not modelled). +const CHART_SHEET_GRAPH: &[&str] = &["chartsheet", "drawing", "chart", "chartex", "chartstyle", "chartcolorstyle", "chartusershapes"]; + /// 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] = &[ @@ -195,6 +199,21 @@ fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result, String> { // ---------------------------------------------------------------- reading +/// The `` entries of the workbook that are chart sheets, each with the number of worksheets +/// (model sheets) before it. +fn chart_sheet_entries<'a>(wb_xml: &'a xml::El, wb_rels: &[PreservedRel], file_to_model: &[Option]) -> Vec<(usize, &'a xml::El)> { + let mut out = vec![]; + let mut worksheets = 0usize; + for (i, se) in wb_xml.child("sheets").into_iter().flat_map(|s| s.kids("sheet")).enumerate() { + if file_to_model.get(i).copied().flatten().is_some() { + worksheets += 1; + } else if se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).is_some_and(|r| is_kind(r, &["chartsheet"])) { + out.push((worksheets, se)); + } + } + out +} + /// Collects kept content while a package is read. pub struct KeptReader { on: bool, @@ -251,11 +270,12 @@ impl KeptReader { /// 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> { + fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet, through: &[&str]) -> Result, String> { if root.external { return Ok(vec![]); } - if rewritten(root) { + let blocked = |r: &PreservedRel| rewritten(r) && !through.iter().any(|k| k.eq_ignore_ascii_case(kind(&r.rel_type))); + if blocked(root) { return Err(format!("it refers to {}", kind(&root.rel_type))); } let mut parts = vec![]; @@ -285,7 +305,7 @@ impl KeptReader { } let rels = raw_rels(cx, &name)?; for r in rels.iter().filter(|r| !r.external) { - if rewritten(r) { + if blocked(r) { return Err(format!("it refers to {}", kind(&r.rel_type))); } queue.push_back((r.target.clone(), depth + 1)); @@ -299,10 +319,22 @@ impl KeptReader { /// 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> { + self.keep_through(cx, p, roots, seen, &[]) + } + + /// [`Self::keep`] for roots whose graph may reach the rewritten kinds in `through`. + fn keep_through( + &mut self, + cx: &mut Ctx<'_>, + p: &mut Preserved, + roots: &[PreservedRel], + seen: &mut HashSet, + through: &[&str], + ) -> Result<(), String> { let mut new_parts: Vec = vec![]; let mut scratch = seen.clone(); for r in roots { - let parts = self.closure(cx, r, &scratch)?; + let parts = self.closure(cx, r, &scratch, through)?; scratch.extend(parts.iter().map(|q| normalize(&q.name))); new_parts.extend(parts); } @@ -317,8 +349,12 @@ impl KeptReader { } /// Package-root and workbook-level content. - pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El) -> Option> { + pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El, file_to_model: &[Option]) -> Option> { if !self.on { + let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); + for (_, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { + cx.warn(format!("chart sheet \"{}\" was skipped and is not kept on save", se.attr("name").unwrap_or(""))); + } if self.strict { cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save"); } @@ -345,6 +381,25 @@ impl KeptReader { cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target)); } } + // Chart sheets: the sheet entry (name, state, position) plus the chart sheet part and the + // drawing, chart, style and colour parts behind it. + for (worksheets, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { + let name = se.attr("name").unwrap_or("").to_string(); + let Some(r) = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)) else { continue }; + match self.keep_through(cx, &mut p, std::slice::from_ref(r), &mut seen, CHART_SHEET_GRAPH) { + Ok(()) => p.sheets.push(PreservedSheet { + name, + visibility: match se.attr("state") { + Some("hidden") => Visibility::Hidden, + Some("veryHidden") => Visibility::VeryHidden, + _ => Visibility::Visible, + }, + rel_id: r.id.clone(), + before: worksheets, + }), + Err(why) => cx.warn(format!("chart sheet \"{name}\" was dropped: {why}")), + } + } // 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> = @@ -839,18 +894,165 @@ 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); +/// A kept chart sheet being written: its `` entry's fields and its new relationship id. +pub struct KeptSheet { + before: usize, + name: String, + visibility: Visibility, + rid: String, +} + +/// The workbook's kept content as written. +#[derive(Default)] +pub struct WorkbookKept { + /// Workbook XML elements that go right after `` (``). + pub elements: String, + sheets: Vec, +} + +impl WorkbookKept { + /// Where the model's sheet `i` is in the file's sheet list: chart sheets come between worksheets. + pub fn file_index(&self, i: usize) -> usize { + i + self.sheets.iter().filter(|s| s.before <= i).count() + } + + /// The `` entries of the chart sheets that go right before the model's sheet `i` + /// (`i` = the number of sheets for those that come last); `id` numbers them from `first_id`. + pub fn sheet_entries(&self, i: usize, last: bool, mut id: usize) -> (String, usize) { + let mut x = String::new(); + for s in self.sheets.iter().filter(|s| if last { s.before >= i } else { s.before == i }) { + let state = match s.visibility { + Visibility::Visible => "", + Visibility::Hidden => " state=\"hidden\"", + Visibility::VeryHidden => " state=\"veryHidden\"", + }; + let name: String = s.name.chars().take(31).collect(); + let _ = write!(x, "", esc_attr(&name), esc_attr(&s.rid)); + id += 1; + } + (x, id) + } +} + +/// The part names reachable from `root` through kept parts' relationships (normalized). +fn reachable(p: &Preserved, root: &PreservedRel) -> HashSet { + let by_name: HashMap = p.parts.iter().map(|q| (normalize(&q.name), q)).collect(); + let mut seen = HashSet::new(); + let mut queue = VecDeque::from([normalize(&root.target)]); + while let Some(n) = queue.pop_front() { + if !seen.insert(n.clone()) { + continue; + } + if let Some(q) = by_name.get(&n) { + queue.extend(q.rels.iter().filter(|r| !r.external).map(|r| normalize(&r.target))); + } + } + seen +} + +/// Every formula (``) of a chart part, depth first. +fn chart_formulas(el: &xml::El, out: &mut Vec) { + if el.name == "f" && !el.text.trim().is_empty() { + out.push(el.text.trim().to_string()); + } + for c in &el.children { + chart_formulas(c, out); + } +} + +/// The sheet names a formula such as `'My Sheet'!$A$1:$A$9,Data!$B$1` refers to. References to +/// other workbooks (`[1]Sheet!A1`) are not sheets of this one and are left out. +pub(crate) fn sheet_names_in(formula: &str) -> Vec { + let mut names = vec![]; + let chars: Vec = formula.chars().collect(); + let at = |i: usize| chars.get(i).copied(); + let mut i = 0; + while let Some(c) = at(i) { + let mut name = String::new(); + let start = i; + if c == '\'' { + i += 1; + while let Some(c) = at(i) { + if c == '\'' { + if at(i + 1) == Some('\'') { + name.push('\''); + i += 2; + continue; + } + break; + } + name.push(c); + i += 1; + } + i += 1; + } else { + while let Some(c) = at(i).filter(|c| !matches!(c, '!' | ',' | '(' | ')' | '\'' | ' ')) { + name.push(c); + i += 1; + } + } + if at(i) == Some('!') { + if !name.is_empty() && !name.starts_with('[') { + names.push(name); + } + i += 1; + } else if i == start { + i += 1; + } + } + names +} + +/// Whether a kept chart sheet can be written back: its chart's series must still refer to sheets +/// that exist under the same names (the chart is kept as it was, so a series on a deleted or +/// renamed sheet would be left pointing at nothing), and no worksheet may have taken its name. +fn chart_sheet_usable(wb: &Workbook, p: &Preserved, s: &PreservedSheet) -> bool { + let same = |a: &str, b: &str| a.chars().take(31).collect::().eq_ignore_ascii_case(&b.chars().take(31).collect::()); + if wb.sheets.iter().any(|w| same(&w.name, &s.name)) { + return false; + } + let Some(root) = p.rels.iter().find(|r| r.id == s.rel_id) else { return false }; + let parts = reachable(p, root); + let mut formulas = vec![]; + for q in p.parts.iter().filter(|q| parts.contains(&normalize(&q.name)) && q.content_type.contains("chart")) { + if let Ok(x) = xml::parse(&q.data) { + chart_formulas(&x, &mut formulas); + } + } + formulas.iter().flat_map(|f| sheet_names_in(f)).all(|n| wb.sheets.iter().any(|w| same(&w.name, &n))) +} + +/// Writes the workbook's kept parts and relationships. Chart sheets that can't be written back +/// (see [`chart_sheet_usable`]) are left out with the parts only they reach. +pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> WorkbookKept { + let Some(p) = wb.preserved.as_deref() else { return WorkbookKept::default() }; + let (live, dropped): (Vec<&PreservedSheet>, Vec<&PreservedSheet>) = p.sheets.iter().partition(|s| chart_sheet_usable(wb, p, s)); + let root_of = |s: &PreservedSheet| p.rels.iter().find(|r| r.id == s.rel_id); + let dropped_ids: HashSet<&str> = dropped.iter().map(|s| s.rel_id.as_str()).collect(); + // Parts reached only by dropped chart sheets go with them. + let mut gone: HashSet = dropped.iter().filter_map(|s| root_of(s)).flat_map(|r| reachable(p, r)).collect(); + if !gone.is_empty() { + let others = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).flat_map(|r| reachable(p, r)); + for n in others { + gone.remove(&n); + } + } + let parts: Vec = p.parts.iter().filter(|q| !gone.contains(&normalize(&q.name))).cloned().collect(); + let rels: Vec = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).cloned().collect(); + let names = write_parts(out, &parts); + let ids = attach(wb_rels, &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() + WorkbookKept { + elements: p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect(), + sheets: live + .iter() + .filter_map(|s| Some(KeptSheet { before: s.before, name: s.name.clone(), visibility: s.visibility, rid: ids.get(&s.rel_id)?.clone() })) + .collect(), + } } /// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`. diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index a25444d..8301479 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -201,6 +201,11 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { file_to_model.push(None); continue; }; + if rel.kind == "chartsheet" { + // Kept as it is and written back in place (see `passthrough`); nothing to model. + file_to_model.push(None); + continue; + } if rel.kind != "worksheet" { cx.warn(format!("sheet \"{name}\" is a {} and was skipped", rel.kind)); file_to_model.push(None); @@ -327,7 +332,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { Err(e) => cx.warn(format!("macros (VBA project) could not be read and were dropped: {e}")), } } - wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml); + wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml, &file_to_model); // 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/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 4cde9d6..f7af899 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -483,3 +483,183 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() { 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()))); } + +// ------------------------------------------------------------------ chart sheets (#727) + +const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; +const CT_PREFIX: &str = "application/vnd.openxmlformats-officedocument"; + +fn chart_xml(formula: &str) -> String { + format!( + r#"{formula}"# + ) +} + +fn chartsheet_xml(note: &str) -> String { + format!( + r#""# + ) +} + +fn drawing_xml(note: &str) -> String { + format!( + r#""# + ) +} + +fn worksheet_xml(value: &str) -> String { + format!( + r#"{value}"# + ) +} + +/// Sheets in file order: Data, Chart1 (a chart sheet on Data), More, Spare, Last (a hidden chart +/// sheet on More, last in the list). "More" has a print area; the active tab is "More". +fn chartsheet_package() -> Vec { + let workbook = format!( + r#"More!$A$1:$B$2"# + ); + let content_types = format!( + r#""# + ); + let (cs1, cs2) = (chartsheet_xml("first"), chartsheet_xml("last")); + let (d1, d2) = (drawing_xml("first"), drawing_xml("last")); + let (c1, c2) = (chart_xml("Data!$A$1:$A$3"), chart_xml("'More'!$A$1")); + let (s1, s2, s3) = (worksheet_xml("data"), worksheet_xml("more"), worksheet_xml("spare")); + make_zip(&[ + ("[Content_Types].xml", content_types.as_bytes()), + ("_rels/.rels", rels(&[("rId1", "officeDocument", "xl/workbook.xml")]).as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ( + "xl/_rels/workbook.xml.rels", + rels(&[ + ("rId1", "worksheet", "worksheets/sheet1.xml"), + ("rId2", "chartsheet", "chartsheets/sheet1.xml"), + ("rId3", "worksheet", "worksheets/sheet2.xml"), + ("rId4", "worksheet", "worksheets/sheet3.xml"), + ("rId5", "chartsheet", "chartsheets/sheet2.xml"), + ]) + .as_bytes(), + ), + ("xl/worksheets/sheet1.xml", s1.as_bytes()), + ("xl/worksheets/sheet2.xml", s2.as_bytes()), + ("xl/worksheets/sheet3.xml", s3.as_bytes()), + ("xl/chartsheets/sheet1.xml", cs1.as_bytes()), + ("xl/chartsheets/sheet2.xml", cs2.as_bytes()), + ( + "xl/chartsheets/_rels/sheet1.xml.rels", + rels(&[("rId1", "drawing", "../drawings/drawing1.xml"), ("rId2", "printerSettings", "../printerSettings/printerSettings1.bin")]) + .as_bytes(), + ), + ("xl/chartsheets/_rels/sheet2.xml.rels", rels(&[("rId1", "drawing", "../drawings/drawing2.xml")]).as_bytes()), + ("xl/printerSettings/printerSettings1.bin", PRINTER), + ("xl/drawings/drawing1.xml", d1.as_bytes()), + ("xl/drawings/drawing2.xml", d2.as_bytes()), + ("xl/drawings/_rels/drawing1.xml.rels", rels(&[("rId1", "chart", "../charts/chart1.xml")]).as_bytes()), + ("xl/drawings/_rels/drawing2.xml.rels", rels(&[("rId1", "chart", "../charts/chart2.xml")]).as_bytes()), + ("xl/charts/chart1.xml", c1.as_bytes()), + ("xl/charts/chart2.xml", c2.as_bytes()), + ]) +} + +/// The sheets of a written workbook: (name, state) in file order. +fn written_sheets(u: &Unpacked) -> Vec<(String, String)> { + let wb = xml::parse(u.parts.get("xl/workbook.xml").unwrap()).unwrap(); + wb.child("sheets").unwrap().kids("sheet").map(|s| (s.attr("name").unwrap().to_string(), s.attr("state").unwrap_or("").to_string())).collect() +} + +/// The part a written chart sheet is in, found through the workbook relationship; its drawing and +/// that drawing's chart, followed through relationships. +fn chart_sheet_chain(u: &Unpacked, name: &str) -> (String, String, String) { + let wb = xml::parse(u.parts.get("xl/workbook.xml").unwrap()).unwrap(); + let id = wb.child("sheets").unwrap().kids("sheet").find(|s| s.attr("name") == Some(name)).unwrap().attr("id").unwrap().to_string(); + let (ty, part) = u.target("xl/workbook.xml", &id); + assert!(ty.ends_with("/chartsheet"), "{ty}"); + let drawing = u.rels(&part).into_iter().find(|r| r.1.ends_with("/drawing")).unwrap().2; + let chart = u.rels(&drawing).into_iter().find(|r| r.1.ends_with("/chart")).unwrap().2; + (part, drawing, chart) +} + +#[test] +fn chart_sheets_survive_a_round_trip_in_place() { + let (wb, rep) = read_xlsx(&chartsheet_package()).unwrap(); + assert!(!rep.warnings.iter().any(|w| w.contains("skipped") || w.contains("dropped")), "{:?}", rep.warnings); + let names: Vec<&str> = wb.sheets.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Data", "More", "Spare"], "chart sheets are not worksheets"); + + let out = write_xlsx(&wb).unwrap(); + let u = Unpacked::new(&out); + u.assert_valid(); + // Same order, same visibility, the way Excel shows the tabs. + assert_eq!( + written_sheets(&u), + [("Data", ""), ("Chart1", ""), ("More", ""), ("Spare", ""), ("Last", "hidden")].map(|(a, b)| (a.to_string(), b.to_string())), + ); + // Sheet ids are unique. + let wbx = u.text("xl/workbook.xml"); + let mut ids: Vec = wbx.match_indices("`. + let (cs_part, _, _) = chart_sheet_chain(&u, "Chart1"); + let ps = u.target(&cs_part, &attr_of(&u.text(&cs_part), "pageSetup", "r:id")); + assert!(ps.0.ends_with("/printerSettings")); + assert_eq!(u.parts.get(&ps.1).unwrap(), PRINTER); + + // Reading the output again finds the same two chart sheets, and a second save is stable. + let (wb2, rep2) = read_xlsx(&out).unwrap(); + assert!(!rep2.warnings.iter().any(|w| w.contains("skipped") || w.contains("dropped")), "{:?}", rep2.warnings); + let again = Unpacked::new(&write_xlsx(&wb2).unwrap()); + again.assert_valid(); + assert_eq!(written_sheets(&again), written_sheets(&u)); +} + +#[test] +fn a_chart_sheet_whose_data_sheet_was_renamed_is_dropped_with_its_parts() { + let (mut wb, _) = read_xlsx(&chartsheet_package()).unwrap(); + wb.sheet_mut(0).unwrap().name = "Renamed".into(); + let u = Unpacked::new(&write_xlsx(&wb).unwrap()); + u.assert_valid(); + // "Chart1" plots Data, which is gone under that name; "Last" plots More, which is not. + assert_eq!(written_sheets(&u).iter().map(|s| s.0.as_str()).collect::>(), ["Renamed", "More", "Spare", "Last"]); + assert!(u.find(chart_xml("Data!$A$1:$A$3").as_bytes()).is_empty()); + assert!(u.find(drawing_xml("first").as_bytes()).is_empty()); + assert!(u.find(PRINTER).is_empty()); + chart_sheet_chain(&u, "Last"); + let wbx = u.text("xl/workbook.xml"); + assert_eq!(attr_of(&wbx, "workbookView", "activeTab"), "1"); + assert_eq!(attr_of(&wbx, "definedName", "localSheetId"), "1"); +} + +#[test] +fn a_chart_sheet_whose_name_a_worksheet_took_is_dropped() { + let (mut wb, _) = read_xlsx(&chartsheet_package()).unwrap(); + wb.sheet_mut(2).unwrap().name = "LAST".into(); + let u = Unpacked::new(&write_xlsx(&wb).unwrap()); + u.assert_valid(); + assert_eq!(written_sheets(&u).iter().map(|s| s.0.as_str()).collect::>(), ["Data", "Chart1", "More", "LAST"]); + assert!(u.find(chart_xml("'More'!$A$1").as_bytes()).is_empty()); + chart_sheet_chain(&u, "Chart1"); +} + +#[test] +fn formulas_name_the_sheets_a_chart_plots() { + use crate::passthrough::sheet_names_in; + assert_eq!(sheet_names_in("Data!$A$1:$A$3"), ["Data"]); + assert_eq!(sheet_names_in("'My ''Sheet'!$A$1,Data!$B$1"), ["My 'Sheet", "Data"]); + assert_eq!(sheet_names_in("SUM(Data!A1:A3,'Other sheet'!B2)"), ["Data", "Other sheet"]); + assert!(sheet_names_in("[1]Ext!A1").is_empty() && sheet_names_in("[1]Ext!A1:A3").is_empty()); + assert!(sheet_names_in("Name").is_empty() && sheet_names_in("").is_empty()); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 9c117f4..28e326a 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -199,9 +199,15 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { }; let mut wb_rels = Rels::default(); + // Kept content first: the chart sheets among the worksheets need their relationship ids. + let kept = crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out); let mut pivots = crate::pivot::PivotWriter::default(); let mut sheets_xml = String::new(); + let mut sheet_id = 1usize; for (i, sheet) in wb.sheets.iter().enumerate() { + let (before, next) = kept.sheet_entries(i, false, sheet_id); + sheets_xml.push_str(&before); + sheet_id = next; let (xml, mut rels) = crate::sheet_write::write_sheet(wb, i, i == active, &mut out); pivots.add_sheet(wb, i, &mut out, &mut rels); let name = format!("xl/worksheets/sheet{}.xml", i + 1); @@ -217,8 +223,10 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { Visibility::VeryHidden => " state=\"veryHidden\"", }; let name: String = sheet.name.chars().take(31).collect(); - let _ = write!(sheets_xml, "", esc_attr(&name), i + 1); + let _ = write!(sheets_xml, "", esc_attr(&name)); + sheet_id += 1; } + sheets_xml.push_str(&kept.sheet_entries(wb.sheets.len(), true, sheet_id).0); // Defined names, then print areas and titles. let mut names = String::new(); @@ -234,7 +242,7 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { } let _ = write!(names, "