From 51f32791353eb0153b64d117d0db712d321bd026 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:56:08 -0400 Subject: [PATCH 01/19] Fix #23: copy selected cells as HTML tables --- Cargo.lock | 88 +++++++++++++++++++- apps/gridcraft-web/Cargo.toml | 2 +- apps/gridcraft-web/src/web.rs | 34 ++++++++ apps/gridcraft/Cargo.toml | 4 + apps/gridcraft/src/main.rs | 8 ++ apps/gridcraft/src/native_menu.rs | 6 +- crates/engine/src/cmd/edit.rs | 7 +- crates/engine/src/display.rs | 7 +- crates/engine/src/io.rs | 133 ++++++++++++++++++++++++------ crates/engine/src/tests.rs | 82 ++++++++++++++++++ crates/ui-egui/src/grid.rs | 24 +----- crates/ui-egui/src/lib.rs | 19 +++++ crates/ui-egui/src/ribbon.rs | 14 +--- crates/ui-egui/tests/clipboard.rs | 83 +++++++++++++++++++ 14 files changed, 443 insertions(+), 68 deletions(-) create mode 100644 crates/ui-egui/tests/clipboard.rs diff --git a/Cargo.lock b/Cargo.lock index 29bd97a..ec87db0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1121,6 +1122,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1132,6 +1139,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1289,7 +1302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1394,6 +1407,7 @@ dependencies = [ name = "gridcraft" version = "0.3.0" dependencies = [ + "arboard", "eframe", "egui", "gridcraft-engine", @@ -1593,6 +1607,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1601,7 +1624,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1610,7 +1633,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2038,6 +2061,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2426,6 +2458,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -2474,6 +2516,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -3355,6 +3408,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -4222,6 +4286,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 991c633..356c63b 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index c06d890..15862d5 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -93,6 +93,7 @@ impl eframe::App for WebShell { fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); Services { + copy_html: Some(Box::new(copy_html)), open_async: Some(Box::new(move || { let inbox = open_inbox.clone(); let ctx = ctx.clone(); @@ -122,6 +123,39 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { } } +/// Start inside the copy gesture; deferring `write` can lose browser user activation. +fn copy_html(html: &str, text: &str) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let window = web_sys::window().ok_or("no window")?; + let clipboard = window.navigator().clipboard(); + if clipboard.is_undefined() || clipboard.is_null() { + return Err("browser clipboard is unavailable (a secure context is required)".into()); + } + if !js_sys::Reflect::get(&clipboard, &"write".into()).map_err(js)?.is_function() { + return Err("browser does not support rich clipboard writes".into()); + } + let data = js_sys::Object::new(); + for (mime, value) in [("text/html", html), ("text/plain", text)] { + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type(mime); + let parts = js_sys::Array::of1(&wasm_bindgen::JsValue::from_str(value)); + let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &opts).map_err(js)?; + js_sys::Reflect::set(&data, &mime.into(), &blob).map_err(js)?; + } + let item = web_sys::ClipboardItem::new_with_record_from_str_to_blob_promise(&data).map_err(js)?; + let promise = clipboard.write(&js_sys::Array::of1(&item)); + let text = text.to_owned(); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = wasm_bindgen_futures::JsFuture::from(promise).await { + log::warn!("Rich clipboard write failed; trying plain text: {e:?}"); + if let Err(e) = wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await { + log::error!("Plain-text clipboard fallback also failed: {e:?}"); + } + } + }); + Ok(()) +} + /// Triggers a browser download of `bytes`. fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 0d3e34c..66711fa 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -25,6 +25,10 @@ winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +arboard = { workspace = true } + +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +arboard = { workspace = true, features = ["wayland-data-control"] } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 6acaf51..43c2eb8 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -93,7 +93,15 @@ fn save_prefs(app: &SheetApp) { } fn services() -> Services { + // Keep the owner alive: X11/Wayland serve clipboard data from this handle. + let mut clipboard: Option = None; Services { + copy_html: Some(Box::new(move |html, text| { + if clipboard.is_none() { + clipboard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?); + } + clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string()) + })), pick_open: Some(Box::new(|| { rfd::FileDialog::new() .add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]) diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 43e57a4..68010c4 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -243,11 +243,7 @@ impl NativeMenu { let name = if name == "note" { "comment" } else { name }; app.open_dialog(name, params); } else if id == "edit.copy" || id == "edit.cut" { - if let Ok(r) = app.run(&id, json!({})) - && let Some(t) = r.get("text").and_then(Value::as_str) - { - ctx.copy_text(t.to_string()); - } + app.copy_to_clipboard(ctx, &id); } else { app.run_or_alert(&id, json!({})); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cffe154..e682950 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -611,9 +611,14 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { range }; let text = crate::display::range_text(&d.wb, sheet, range); + let html = crate::io::range_html(&d.wb, sheet, range, &text); let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() }; s.clipboard = Some(clip); - Ok(json!({"range": range.a1(), "text": text})) + let mut result = json!({"range": range.a1(), "text": text}); + if let Some(html) = html { + result["html"] = Json::String(html); + } + Ok(result) } fn copy(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index 24df503..a892e9a 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -57,7 +57,7 @@ pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { let Some(sh) = wb.sheet(sheet) else { return String::new() }; let mut out = String::new(); - let r = if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r }; + let r = clipboard_range(r); for row in r.start.row..=r.end.row { if sh.is_row_hidden(row) { continue; @@ -82,6 +82,11 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { out } +/// The existing plain-text clipboard limit, shared with its HTML representation. +pub(crate) fn clipboard_range(r: RangeRef) -> RangeRef { + if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r } +} + /// Status-bar statistics for a selection: average, count (non-empty), numerical count, min, /// max, sum. #[derive(Clone, Debug, Default, PartialEq, serde::Serialize)] diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index aef3681..8a40b9e 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -2,8 +2,8 @@ use std::fmt::Write as _; -use gridcraft_core::CellRef; -use gridcraft_model::Workbook; +use gridcraft_core::{CellRef, RangeRef}; +use gridcraft_model::{Sheet, Workbook}; use crate::{EngineError, Result}; @@ -92,6 +92,109 @@ fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } +fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String { + let st = wb.styles.get(sh.style_id(c)); + let mut css = String::new(); + if st.font.bold { + css.push_str("font-weight:bold;"); + } + if st.font.italic { + css.push_str("font-style:italic;"); + } + if let Some(h) = st.font.color.hex(&wb.theme) { + let _ = write!(css, "color:{h};"); + } + if st.fill.pattern != gridcraft_model::PatternType::None + && let Some(h) = st.fill.fg.hex(&wb.theme) + { + let _ = write!(css, "background:{h};"); + } + let v = sh.value(c); + let align = match st.align.h { + gridcraft_model::HAlign::Center => "center", + gridcraft_model::HAlign::Right => "right", + gridcraft_model::HAlign::Left => "left", + _ if v.is_number() => "right", + _ => "left", + }; + let _ = write!(css, "text-align:{align};"); + css +} + +const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024; +const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000; + +/// An HTML fragment for the same range as `plain_text`, previously produced by +/// `display::range_text`. Large copies keep plain text only; never return a partial table. +pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &str) -> Option { + let r = crate::display::clipboard_range(r); + if r.count() > CLIPBOARD_HTML_MAX_CELLS || plain_text.len() > CLIPBOARD_HTML_MAX_BYTES { + return None; + } + let sh = wb.sheet(sheet)?; + let mut out = ClipboardHtml(String::new()); + out.write_str("").ok()?; + for row in r.start.row..=r.end.row { + if sh.is_row_hidden(row) { + continue; + } + out.write_str("").ok()?; + for col in r.start.col..=r.end.col { + let c = CellRef::new(row, col); + let span = if let Some(merged) = sh.merges.iter().find(|m| m.contains(c)).and_then(|m| m.intersection(&r)) { + let first_row = (merged.start.row..=merged.end.row).find(|&row| !sh.is_row_hidden(row))?; + if c != CellRef::new(first_row, merged.start.col) { + continue; + } + let rows = (first_row..=merged.end.row).filter(|&row| !sh.is_row_hidden(row)).count(); + format!(" rowspan=\"{rows}\" colspan=\"{}\"", merged.width()) + } else { + String::new() + }; + let css = html_cell_css(wb, sh, c); + write!(out, "").ok()?; + } + out.write_str("").ok()?; + } + out.write_str("
").ok()?; + // Use the visible selected cell, even when the merge's original anchor + // is outside the range or hidden. This matches the plain-text copy. + let text = crate::display::cell_text(wb, sh, c); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '&' => out.write_str("&"), + '<' => out.write_str("<"), + '>' => out.write_str(">"), + '"' => out.write_str("""), + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + out.write_str("
") + } + '\n' => out.write_str("
"), + _ => out.write_char(ch), + } + .ok()?; + } + out.write_str("
").ok()?; + Some(out.0) +} + +/// Enforce the budget while escaping, before HTML expansion can allocate a large string. +struct ClipboardHtml(String); + +impl std::fmt::Write for ClipboardHtml { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + if s.len() > CLIPBOARD_HTML_MAX_BYTES.saturating_sub(self.0.len()) { + return Err(std::fmt::Error); + } + self.0.push_str(s); + Ok(()) + } +} + /// A sheet as a standalone HTML table (Save as Web Page). pub fn to_html(wb: &Workbook, sheet: usize) -> String { let Some(sh) = wb.sheet(sheet) else { return String::new() }; @@ -112,31 +215,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String { if sh.merges.iter().any(|m| m.contains(c) && m.start != c) { continue; } - let st = wb.styles.get(sh.style_id(c)); - let mut css = String::new(); - if st.font.bold { - css.push_str("font-weight:bold;"); - } - if st.font.italic { - css.push_str("font-style:italic;"); - } - if let Some(h) = st.font.color.hex(&wb.theme) { - let _ = write!(css, "color:{h};"); - } - if st.fill.pattern != gridcraft_model::PatternType::None - && let Some(h) = st.fill.fg.hex(&wb.theme) - { - let _ = write!(css, "background:{h};"); - } - let v = sh.value(c); - let align = match st.align.h { - gridcraft_model::HAlign::Center => "center", - gridcraft_model::HAlign::Right => "right", - gridcraft_model::HAlign::Left => "left", - _ if v.is_number() => "right", - _ => "left", - }; - let _ = write!(css, "text-align:{align};"); + let css = html_cell_css(wb, sh, c); let span = sh .merges .iter() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..41fc3ae 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -56,6 +56,88 @@ fn copy_paste_shifts_formulas() { assert_eq!(f["formula"], "=E5+B1"); } +#[test] +fn rich_clipboard_copies_selected_displayed_cells_and_styles() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "outside selection"})).unwrap(); + s.execute("range.setValues", json!({"range": "B2", "values": [[0.125, "&\"\nnext"], [0.25, "last"]]})).unwrap(); + s.execute("home.numberFormat", json!({"range": "B2:B3", "code": "0.0%"})).unwrap(); + s.execute("home.bold", json!({"range": "B2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "B2", "on": true})).unwrap(); + s.execute("home.fontColor", json!({"range": "B2", "color": "#123456"})).unwrap(); + s.execute("home.fillColor", json!({"range": "B2", "color": "#FEDCBA"})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "B2:C3"})).unwrap(); + let html = copied["html"].as_str().expect("copy publishes HTML as well as plain text"); + assert_eq!(copied["text"], "12.5%\t\"&\"\"\nnext\"\n25.0%\tlast\n"); + assert!(html.starts_with("")); + assert_eq!(html.matches("").count(), 2); + assert_eq!(html.matches("")); + + // A selection beginning inside a merge must keep its shape without copying + // the original anchor's value from outside the selected rectangle. + let copied = s.execute("edit.copy", json!({"range": "C3:E5"})).unwrap(); + let html = copied["html"].as_str().expect("partially selected merge publishes HTML"); + assert_eq!(html.matches("").count(), 2); + assert_eq!(html.matches("")); +} + +#[test] +fn rich_clipboard_omits_html_for_large_ranges_without_truncating_text() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A10001", "input": "last cell"})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "A1:A10001"})).unwrap(); + assert!(copied.get("html").is_none(), "oversized HTML must be omitted, not partially copied"); + let text = copied["text"].as_str().unwrap(); + assert_eq!(text.lines().count(), 10_001); + assert!(text.ends_with("last cell\n")); + let copied = s.execute("edit.copy", json!({"range": "A10001"})).unwrap(); + assert!(copied["html"].as_str().is_some_and(|html| html.contains("last cell"))); +} + +#[test] +fn rich_clipboard_omits_html_when_escaping_exceeds_byte_budget() { + let mut s = s(); + let text = "&".repeat(1_000_000); + s.execute("range.setValues", json!({"range": "A1", "values": [[text]]})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + assert!(copied.get("html").is_none(), "an oversized escaped value must not produce partial HTML"); + assert_eq!(copied["text"].as_str().unwrap().len(), 1_000_001); + assert!(copied["text"].as_str().unwrap().ends_with("&\n")); +} + #[test] fn insert_delete_rows_adjust() { let mut s = s(); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..6392ec9 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1523,18 +1523,10 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: for ev in events { match ev { egui::Event::Copy => { - if let Ok(r) = app.run("edit.copy", json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ctx.copy_text(t.to_string()); - } + app.copy_to_clipboard(ctx, "edit.copy"); } egui::Event::Cut => { - if let Ok(r) = app.run("edit.cut", json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ctx.copy_text(t.to_string()); - } + app.copy_to_clipboard(ctx, "edit.cut"); } egui::Event::Paste(text) => app.run_or_alert("edit.paste", json!({"text": text})), egui::Event::Text(text) => { @@ -1790,11 +1782,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } "edit.copy" | "edit.cut" => { - if let Ok(r) = app.run(id, json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ui.ctx().copy_text(t.to_string()); - } + app.copy_to_clipboard(ui.ctx(), id); } other => app.run_or_alert(other, json!({})), } @@ -2021,11 +2009,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) { "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), "edit.copy" | "edit.cut" => { - if let Ok(r) = app.run(id, json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ui.ctx().copy_text(t.to_string()); - } + app.copy_to_clipboard(ui.ctx(), id); } other => app.run_or_alert(other, json!({})), } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca..2670a57 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -69,6 +69,9 @@ pub struct Services { pub pick_open: Option Option>>, pub pick_save: Option Option>>, pub open_url: Option>, + /// Publish HTML and its plain-text alternative together. `Ok` means the host owns the + /// write (including any asynchronous fallback); an immediate error uses egui's text path. + pub copy_html: Option Result<(), String>>>, /// Web: deliver a file to the user (name, bytes). pub download: Option>, /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. @@ -156,6 +159,22 @@ impl SheetApp { r } + /// Copy or cut the selected cells to the system clipboard. Engine-only commands keep + /// their existing internal clipboard behavior and do not write to the host clipboard. + pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { + let Ok(result) = self.run(command, json!({})) else { return }; + let Some(text) = result.get("text").and_then(Json::as_str) else { return }; + if let Some(html) = result.get("html").and_then(Json::as_str) + && let Some(copy_html) = &mut self.services.copy_html + { + match copy_html(html, text) { + Ok(()) => return, + Err(error) => log::warn!("HTML clipboard unavailable; copying plain text: {error}"), + } + } + ctx.copy_text(text.to_string()); + } + /// Runs a command and shows its error in a message box (for menu/ribbon clicks). pub fn run_or_alert(&mut self, id: &str, params: Json) { if let Err(e) = self.run(id, params) { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432..8108985 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -287,17 +287,11 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); ui.vertical(|ui| { - if icon_button(ui, Icon::Cut, t.text, "Cut (⌘X)", vec2(26.0, 23.0)).clicked() - && let Ok(r) = app.run("edit.cut", json!({})) - && let Some(s) = r.get("text").and_then(|x| x.as_str()) - { - ui.ctx().copy_text(s.to_string()); + if icon_button(ui, Icon::Cut, t.text, "Cut (⌘X)", vec2(26.0, 23.0)).clicked() { + app.copy_to_clipboard(ui.ctx(), "edit.cut"); } - if icon_button(ui, Icon::Copy, t.text, "Copy (⌘C)", vec2(26.0, 23.0)).clicked() - && let Ok(r) = app.run("edit.copy", json!({})) - && let Some(s) = r.get("text").and_then(|x| x.as_str()) - { - ui.ctx().copy_text(s.to_string()); + if icon_button(ui, Icon::Copy, t.text, "Copy (⌘C)", vec2(26.0, 23.0)).clicked() { + app.copy_to_clipboard(ui.ctx(), "edit.copy"); } let fp_on = app.session.format_painter.is_some(); let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs new file mode 100644 index 0000000..633351e --- /dev/null +++ b/crates/ui-egui/tests/clipboard.rs @@ -0,0 +1,83 @@ +//! Copy/cut route rich tables to the host without a later egui text write replacing them. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, OutputCommand}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{Services, SheetApp}; +use serde_json::json; + +fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["Ada", 42]]})).unwrap(); + session.execute("selection.set", json!({"range": "A1:B2"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, services), + ); + h.run_steps(4); + h +} + +#[test] +fn copy_and_cut_publish_html_and_text_without_overwriting_rich_clipboard() { + for event in [Event::Copy, Event::Cut] { + let calls = Rc::new(RefCell::new(Vec::new())); + let received = calls.clone(); + let mut h = harness(Services { + copy_html: Some(Box::new(move |html, text| { + received.borrow_mut().push((html.to_string(), text.to_string())); + Ok(()) + })), + ..Default::default() + }); + h.input_mut().events.push(event.clone()); + h.step(); + let calls = calls.borrow(); + assert_eq!(calls.len(), 1); + assert!(calls[0].0.contains(" Date: Fri, 9 Oct 2026 09:44:04 -0400 Subject: [PATCH 02/19] Add initial ribbon keytips and legacy shortcuts for #33 --- README.md | 23 +++ crates/ui-egui/src/dialogs.rs | 78 +++++++- crates/ui-egui/src/keytips.rs | 208 ++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 40 +++++ crates/ui-egui/src/ribbon.rs | 78 +++++++- crates/ui-egui/tests/keytips.rs | 306 ++++++++++++++++++++++++++++++++ 6 files changed, 724 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/keytips.rs create mode 100644 crates/ui-egui/tests/keytips.rs diff --git a/README.md b/README.md index f5aa689..733312b 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,29 @@ cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sa You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the browser's file picker (dropping files works too) and Save downloads the workbook. +### Ribbon access keys + +Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform, +then type a sequence below one key at a time. Keytips appear on the supported Home +controls and menu items. On macOS, use F10 (Fn+F10 if required by your keyboard); Option +remains available for entering accented characters. + +| Sequence after Alt/F10 | Action | +|---|---| +| `H A L` / `H A C` / `H A R` | Align left / center / right | +| `H V S` or legacy `E S` | Open Paste Special | +| `H O I` or legacy `O C A` | AutoFit selected columns | + +In Paste Special, **T/V/F/C** select formats/values/formulas/comments, and **E** toggles +Transpose. **Enter** applies the selected options; **Escape** cancels the dialog. +These letters also work with Alt held on Windows/Linux/BSD. + +Escape backs out one keytip level; Alt/F10 again exits keytips. Clicking, scrolling, +or switching windows cancels navigation. Access keys do not start while editing text +or using another dialog. This is initial support for these Home and legacy paths; +other ribbon commands and full mouse-free navigation remain to be implemented. +Browsers or desktop environments may reserve Alt/F10 for their own menus. + ## For agents: MCP, CLI and the control channel ```sh diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b6c186d..827d542 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -188,10 +188,10 @@ impl Dialog { label: "Paste", options: vec![ ("all", "All"), - ("formulas", "Formulas"), - ("values", "Values"), - ("formats", "Formats"), - ("comments", "Comments and notes"), + ("formulas", "Formulas (F)"), + ("values", "Values (V)"), + ("formats", "Formats (T)"), + ("comments", "Comments and notes (C)"), ("validation", "Validation"), ("allExceptBorders", "All except borders"), ("columnWidths", "Column widths"), @@ -205,7 +205,7 @@ impl Dialog { options: vec![("none", "None"), ("add", "Add"), ("subtract", "Subtract"), ("multiply", "Multiply"), ("divide", "Divide")], }, Check { key: "skipBlanks", label: "Skip blanks" }, - Check { key: "transpose", label: "Transpose" }, + Check { key: "transpose", label: "Transpose (E)" }, Check { key: "link", label: "Paste Link" }, ], json!({"what": "all", "operation": "none"}), @@ -632,6 +632,9 @@ fn build_params(d: &Dialog) -> Json { pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; + if d.name == "pasteSpecial" { + paste_special_shortcuts(&mut d, ctx); + } let mut open = true; let mut win_open = true; let mut confirm = false; @@ -828,6 +831,71 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } } +/// Paste Special's legacy mnemonics select options; Enter still performs the paste. +fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { + if ctx.text_edit_focused() { + return; + } + let right_alt = ctx.input(|i| { + i.key_down(Key::AltRight) + || i.events.iter().any(|event| { + matches!(event, egui::Event::Key { key: Key::AltRight, .. }) + || matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. }) + }) + }); + let mac = ctx.os() == egui::os::OperatingSystem::Mac; + let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt)); + let mnemonic = |key| match key { + Key::T => Some('t'), + Key::V => Some('v'), + Key::F => Some('f'), + Key::C => Some('c'), + Key::E => Some('e'), + _ => None, + }; + let mut text_to_consume = Vec::new(); + for event in ctx.input(|i| i.events.clone()) { + let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue }; + if !allowed(modifiers) { + continue; + } + let Some(letter) = mnemonic(key) else { continue }; + text_to_consume.push(letter); + if repeat { + continue; + } + if key == Key::E { + let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false); + d.values.insert("transpose".into(), json!(!transpose)); + } else { + let what = match key { + Key::T => "formats", + Key::V => "values", + Key::F => "formulas", + _ => "comments", + }; + d.values.insert("what".into(), json!(what)); + } + } + // Browsers can deliver Text before or after Key. Act only on Key, then remove + // its matching text so one physical press cannot toggle Transpose twice. + ctx.input_mut(|i| { + i.events.retain(|event| match event { + egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()), + egui::Event::Text(text) if text.len() == 1 => { + let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase(); + if let Some(index) = text_to_consume.iter().position(|&c| c == letter) { + text_to_consume.remove(index); + false + } else { + true + } + } + _ => true, + }); + }); +} + 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| { diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs new file mode 100644 index 0000000..6fc620a --- /dev/null +++ b/crates/ui-egui/src/keytips.rs @@ -0,0 +1,208 @@ +//! Sequential ribbon shortcuts. The host paints hints from `prefix` and runs returned actions. + +use egui::{Context, Event, Key, Modifiers, os::OperatingSystem}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + Home, + Command(&'static str), + Dialog(&'static str), +} + +#[derive(Default)] +pub struct KeyTips { + prefix: Option, + alt_armed: bool, + // Keep consuming a held mnemonic after its action closes keytips. + held: Vec, +} + +impl KeyTips { + pub fn prefix(&self) -> Option<&str> { + self.prefix.as_deref() + } + + pub fn clear(&mut self) { + self.prefix = None; + self.alt_armed = false; + self.held.clear(); + } + + fn toggle(&mut self) { + self.prefix = if self.prefix.is_some() { None } else { Some(String::new()) }; + self.alt_armed = false; + } + + /// Remove only handled events before any text editors or the grid see them. + pub fn process(&mut self, ctx: &Context, enabled: bool) -> Vec { + if !enabled || !ctx.input(|i| i.focused) { + self.clear(); + return Vec::new(); + } + let mac = ctx.os() == OperatingSystem::Mac; + let events = ctx.input_mut(|i| std::mem::take(&mut i.events)); + let pairs = text_pairs(&events); + let mut consumed = std::collections::HashSet::new(); + let mut actions = Vec::new(); + for (index, event) in events.iter().enumerate() { + let mut handled = false; + match event { + Event::PointerButton { pressed: true, .. } | Event::MouseWheel { .. } | Event::WindowFocused(false) | Event::Ime(_) => self.clear(), + Event::Copy | Event::Cut | Event::Paste(_) => self.clear(), + Event::ModifiersChanged(m) => { + if command_modifier(*m) || (mac && m.alt) { + self.clear(); + } else if m.shift { + self.alt_armed = false; + } + } + Event::Key { key: Key::AltLeft, pressed, repeat, modifiers, .. } if !mac && !command_modifier(*modifiers) && !modifiers.shift => { + if *pressed { + if !repeat { + self.alt_armed = true; + } + handled = true; + } else if self.alt_armed { + self.toggle(); + handled = true; + } + } + Event::Key { key, pressed, repeat, modifiers, .. } => { + self.alt_armed = false; + if command_modifier(*modifiers) + || modifiers.alt + || matches!(key, Key::AltLeft | Key::AltRight | Key::ControlLeft | Key::ControlRight | Key::SuperLeft | Key::SuperRight) + { + self.clear(); + } else if !pressed { + handled = self.held.contains(key); + self.held.retain(|held| held != key); + } else if *repeat && (self.prefix.is_some() || self.held.contains(key)) { + handled = true; + } else if *key == Key::F10 && modifiers.is_none() { + self.toggle(); + handled = true; + } else if self.prefix.is_some() && !matches!(key, Key::ShiftLeft | Key::ShiftRight) { + handled = true; + if *key == Key::Escape { + if let Some(prefix) = self.prefix.as_mut() + && prefix.pop().is_none() + { + self.prefix = None; + } + } else if key.name().len() == 1 { + let next = format!("{}{key_name}", self.prefix.as_deref().unwrap_or_default(), key_name = key.name()); + match next.as_str() { + "H" => { + self.prefix = Some(next); + actions.push(Action::Home); + } + "HA" | "HV" | "HO" | "E" | "O" | "OC" => self.prefix = Some(next), + "HAR" => self.finish(Action::Command("home.alignRight"), &mut actions), + "HAL" => self.finish(Action::Command("home.alignLeft"), &mut actions), + "HAC" => self.finish(Action::Command("home.alignCenter"), &mut actions), + "HVS" | "ES" => self.finish(Action::Dialog("pasteSpecial"), &mut actions), + "HOI" | "OCA" => self.finish(Action::Command("home.autofitColumnWidth"), &mut actions), + _ => {} + } + } + } + if handled && *pressed && !self.held.contains(key) { + self.held.push(*key); + } + } + Event::Text(_) => { + self.alt_armed = false; + // Paired text is decided with its key, which may occur later on web. + handled = self.prefix.is_some() && !pairs.iter().any(|(_, text)| *text == index); + } + _ => {} + } + if handled { + consumed.insert(index); + if let Some((_, text)) = pairs.iter().find(|(key, _)| *key == index) { + consumed.insert(*text); + } + } + } + ctx.input_mut(|i| { + i.events = events.into_iter().enumerate().filter_map(|(index, event)| (!consumed.contains(&index)).then_some(event)).collect() + }); + actions + } + + fn finish(&mut self, action: Action, actions: &mut Vec) { + self.prefix = None; + actions.push(action); + } +} + +fn command_modifier(m: Modifiers) -> bool { + m.ctrl || m.command || m.mac_cmd +} + +/// Native integrations emit Key then Text; browsers can emit Text then Key. +fn text_pairs(events: &[Event]) -> Vec<(usize, usize)> { + let mut pairs = Vec::new(); + for (text_index, event) in events.iter().enumerate() { + let Event::Text(_) = event else { continue }; + let before = events.iter().enumerate().take(text_index).rev().find(|(_, e)| !matches!(e, Event::ModifiersChanged(_))); + let after = events.iter().enumerate().skip(text_index + 1).find(|(_, e)| !matches!(e, Event::ModifiersChanged(_))); + for (key_index, event) in before.into_iter().chain(after) { + if let Event::Key { key, pressed: true, modifiers, .. } = event + && (key.name().len() == 1 || modifiers.alt || command_modifier(*modifiers)) + && !pairs.iter().any(|(paired, _)| *paired == key_index) + { + pairs.push((key_index, text_index)); + break; + } + } + } + pairs +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } + } + + #[test] + fn active_mode_preserves_modified_text_before_key() { + for (os, letter, text, modifiers) in [ + (OperatingSystem::Windows, Key::E, "€", Modifiers::CTRL | Modifiers::ALT), + (OperatingSystem::Nix, Key::E, "€", Modifiers::ALT), + (OperatingSystem::Mac, Key::O, "œ", Modifiers::ALT), + (OperatingSystem::Mac, Key::C, "c", Modifiers::COMMAND), + ] { + let ctx = Context::default(); + ctx.set_os(os); + let mut tips = KeyTips { prefix: Some("H".into()), ..Default::default() }; + let events = vec![Event::Text(text.into()), key(letter, true, modifiers)]; + let mut remaining = Vec::new(); + let mut output = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| { + assert!(tips.process(ui.ctx(), true).is_empty()); + remaining = ui.input(|i| i.events.clone()); + }); + output.textures_delta.clear(); + assert_eq!(tips.prefix(), None); + assert_eq!(remaining, events); + } + } + + #[test] + fn shift_between_alt_press_and_release_does_not_activate() { + let ctx = Context::default(); + ctx.set_os(OperatingSystem::Windows); + let mut tips = KeyTips::default(); + let events = + vec![key(Key::AltLeft, true, Modifiers::ALT), key(Key::ShiftLeft, true, Modifiers::NONE), key(Key::AltLeft, false, Modifiers::NONE)]; + let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + assert!(tips.process(ui.ctx(), true).is_empty()); + }); + output.textures_delta.clear(); + assert_eq!(tips.prefix(), None); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca..2e0014d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,7 @@ pub mod editor; pub mod formula_bar; pub mod grid; pub mod icons; +pub mod keytips; pub mod panes; pub mod pivot_pane; pub mod ribbon; @@ -105,6 +106,8 @@ pub struct SheetApp { pub fonts_ready: bool, fonts_set: bool, pub name_box: Option, + /// Transient ribbon keyboard navigation; never saved with UI preferences. + pub keytips: keytips::KeyTips, pub(crate) shots: control::Shots, /// Chart selected on the sheet (id). pub selected_chart: Option, @@ -134,6 +137,7 @@ impl SheetApp { fonts_ready: false, fonts_set: false, name_box: None, + keytips: keytips::KeyTips::default(), shots: control::Shots::default(), selected_chart: None, started: now_ms(), @@ -400,6 +404,7 @@ impl SheetApp { } let t0 = now_ms(); let t = theme::Tokens::get(&ctx); + self.ribbon_keys(&ctx); ribbon::title_bar(self, ui); ribbon::show(self, ui); if self.ui.formula_bar { @@ -452,6 +457,41 @@ impl SheetApp { } } + fn ribbon_keys(&mut self, ctx: &egui::Context) { + let before = self.keytips.prefix().map(str::to_string); + let enabled = self.editor.is_none() + && self.dialog.is_none() + && self.message.is_none() + && self.name_box.is_none() + && !ctx.text_edit_focused() + && self.grid.context_menu.is_none() + && self.grid.header_menu.is_none() + && self.grid.filter_menu.is_none() + && self.grid.list_picker.is_none() + && self.grid.renaming_tab.is_none() + && (before.is_some() || !egui::Popup::is_any_open(ctx)); + let actions = self.keytips.process(ctx, enabled); + if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) { + // Leave pointer cancellation to the popup so clicking a menu item still works. + egui::Popup::close_all(ctx); + } + // The legacy Edit/Format paths lead to the same existing Home menus. + if matches!(self.keytips.prefix(), Some("E" | "O" | "OC")) { + self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"})); + self.ui.ribbon_collapsed = false; + } + for action in actions { + match action { + keytips::Action::Home => { + self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"})); + self.ui.ribbon_collapsed = false; + } + keytips::Action::Command(id) => self.run_or_alert(id, json!({})), + keytips::Action::Dialog(name) => self.open_dialog(name, json!({})), + } + } + } + /// Current selection's top-left cell text as A1 (for the Name Box). pub fn name_box_text(&self) -> String { let Some(d) = self.session.active() else { return String::new() }; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432..e19a7d5 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -167,6 +167,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { if resp.double_clicked() { app.ui.ribbon_collapsed = !collapsed; } + if tab == "Home" && app.keytips.prefix() == Some("") { + keytip(app, ui, "H", pos2(r.center().x, r.bottom() - 1.0)); + } } }); }); @@ -242,13 +245,36 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { app.run_or_alert(id, params); } +/// Keytips are painted, not focusable widgets: the grid retains keyboard focus. +fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { + let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); + let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text); + let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0)); + painter.rect_filled(rect, 2.0, t.input_bg); + painter.rect_stroke(rect, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside); + painter.galley(rect.min + vec2(3.5, 1.5), text, t.text); +} + fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) { for (label, id, p) in items { if *label == "-" { ui.separator(); continue; } - if ui.add(egui::Button::new(*label).frame(false).min_size(vec2(220.0, 22.0))).clicked() { + let response = ui.add(egui::Button::new(*label).frame(false).min_size(vec2(220.0, 22.0))); + let sequence = match (app.keytips.prefix(), *id) { + (Some("HV"), "dialog:pasteSpecial") => Some("HVS"), + (Some("E"), "dialog:pasteSpecial") => Some("ES"), + (Some("HO"), "home.autofitColumnWidth") => Some("HOI"), + (Some("OC"), "home.autofitColumnWidth") => Some("OCA"), + _ => None, + }; + if let Some(sequence) = sequence { + keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y)); + } + if response.clicked() { if let Some(d) = id.strip_prefix("dialog:") { app.open_dialog(d, p.clone()); } else { @@ -263,6 +289,9 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { let st = active_style(app); // Clipboard let paste = big_button(ui, Icon::Paste, "Paste", "Paste (⌘V)", true); + if app.keytips.prefix() == Some("H") { + keytip(app, ui, "HV", paste.rect.center_bottom()); + } if paste.clicked() { if let Some(text) = app.grid.system_clipboard.take() { act(app, "edit.paste", json!({"text": text})); @@ -270,7 +299,29 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { act(app, "edit.paste", json!({})); } } - egui::Popup::context_menu(&paste).show(|ui| { + let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E")); + // Keep a keyboard-opened menu anchored to the button when a pointer click + // cancels keytips, so its items remain in place and can still be clicked. + let anchor_id = paste.id.with("keytip_anchor"); + let anchored = ui.ctx().data_mut(|data| { + let anchored = if paste_keys { + true + } else if paste.secondary_clicked() { + false + } else { + data.get_temp::(anchor_id).unwrap_or(false) + }; + data.insert_temp(anchor_id, anchored); + anchored + }); + let mut paste_popup = egui::Popup::context_menu(&paste); + if anchored { + paste_popup = paste_popup.anchor(&paste); + } + if paste_keys { + paste_popup = paste_popup.open_memory(egui::SetOpenCommand::Bool(true)); + } + paste_popup.show(|ui| { menu_items( app, ui, @@ -453,7 +504,16 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { (Icon::AlignCenter, HAlign::Center, "home.alignCenter", "Center"), (Icon::AlignRight, HAlign::Right, "home.alignRight", "Align Right"), ] { - if toggle_button(ui, icon, st.align.h == h, tip).clicked() { + let response = toggle_button(ui, icon, st.align.h == h, tip); + if matches!(app.keytips.prefix(), Some("H" | "HA")) { + let sequence = match h { + HAlign::Left => "HAL", + HAlign::Center => "HAC", + _ => "HAR", + }; + keytip(app, ui, sequence, response.rect.center_bottom()); + } + if response.clicked() { act(app, id, json!({})); } } @@ -569,7 +629,17 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); let fmt = small_button(ui, Icon::Format, "Format", "Format", true); - egui::Popup::menu(&fmt).show(|ui| { + match app.keytips.prefix() { + Some("H") => keytip(app, ui, "HO", fmt.rect.center_bottom()), + Some("O") => keytip(app, ui, "OC", fmt.rect.center_bottom()), + _ => {} + } + let format_keys = matches!(app.keytips.prefix(), Some("HO" | "OC")); + let mut format_popup = egui::Popup::menu(&fmt); + if format_keys { + format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true)); + } + format_popup.show(|ui| { menu_items( app, ui, diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs new file mode 100644 index 0000000..619254b --- /dev/null +++ b/crates/ui-egui/tests/keytips.rs @@ -0,0 +1,306 @@ +//! Ribbon access keys through the complete egui input and UI path. + +use egui::{Event, Key, Modifiers, PointerButton, os::OperatingSystem}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::HAlign; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(session: Session) -> Harness { + harness_on(session, OperatingSystem::Nix) +} + +fn harness_on(session: Session, os: OperatingSystem) -> Harness { + let mut h = egui_kittest::Harness::builder().with_os(os).with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn blank() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } +} + +fn events(h: &mut Harness, events: impl IntoIterator) { + h.input_mut().events.extend(events); + h.run_steps(2); +} + +fn key(h: &mut Harness, key: Key) { + events(h, [key_event(key, true, Modifiers::NONE), key_event(key, false, Modifiers::NONE)]); +} + +fn letter(h: &mut Harness, key: Key, text_first: bool) { + let text = Event::Text(key.name().to_ascii_lowercase()); + let pressed = key_event(key, true, Modifiers::NONE); + let pair = if text_first { [text, pressed] } else { [pressed, text] }; + events(h, pair.into_iter().chain([key_event(key, false, Modifiers::NONE)])); +} + +fn sequence(h: &mut Harness, letters: &[Key], text_first: bool) { + key(h, Key::F10); + for &k in letters { + letter(h, k, text_first); + } +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn alignment(h: &Harness) -> HAlign { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).align.h +} + +fn copied_row() -> Session { + let mut s = blank(); + s.execute("cell.set", json!({"cell": "A1", "input": "=20+22"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "99"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:B1"})).unwrap(); + s.execute("selection.set", json!({"cell": "D4"})).unwrap(); + s +} + +#[test] +fn alignment_sequences_consume_key_and_text_events_in_either_order() { + for text_first in [false, true] { + let mut s = blank(); + s.execute("cell.set", json!({"cell": "A1", "input": "keep me"})).unwrap(); + let mut h = harness(s); + h.state_mut().ui.ribbon_tab = "Insert".into(); + for (k, expected) in [(Key::R, HAlign::Right), (Key::L, HAlign::Left), (Key::C, HAlign::Center)] { + sequence(&mut h, &[Key::H, Key::A, k], text_first); + assert_eq!(h.state().ui.ribbon_tab, "Home"); + assert_eq!(alignment(&h), expected); + assert_eq!(value(&h, "A1"), Value::from("keep me")); + assert!(h.state().editor.is_none(), "access-key letters must not begin a cell edit"); + assert_eq!(h.state().keytips.prefix(), None); + } + // Consuming the terminal access key must not swallow the next ordinary keystroke. + letter(&mut h, Key::X, text_first); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "A1"), Value::from("x")); + } +} + +#[test] +fn paste_special_options_wait_for_enter_and_transpose_values() { + let mut h = harness(copied_row()); + sequence(&mut h, &[Key::H, Key::V, Key::S], true); + assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial"); + for (k, what) in [(Key::T, "formats"), (Key::V, "values"), (Key::F, "formulas"), (Key::C, "comments")] { + letter(&mut h, k, true); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("what"), Some(&json!(what))); + assert_eq!(value(&h, "D4"), Value::Empty, "choosing a paste option must not paste yet"); + } + letter(&mut h, Key::V, true); + letter(&mut h, Key::E, true); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("transpose"), Some(&json!(true))); + for modifiers in [Modifiers::CTRL, Modifiers::COMMAND] { + events( + &mut h, + [ + Event::ModifiersChanged(modifiers), + key_event(Key::T, true, modifiers), + key_event(Key::T, false, modifiers), + key_event(Key::E, true, modifiers), + key_event(Key::E, false, modifiers), + Event::ModifiersChanged(Modifiers::NONE), + ], + ); + let dialog = h.state().dialog.as_ref().unwrap(); + assert_eq!(dialog.values.get("what"), Some(&json!("values")), "Ctrl/Cmd must not select a paste mnemonic"); + assert_eq!(dialog.values.get("transpose"), Some(&json!(true)), "Ctrl/Cmd must not toggle transpose"); + } + // Non-macOS Alt+E selects the dialog mnemonic without activating ribbon access keys. + events( + &mut h, + [ + Event::ModifiersChanged(Modifiers::ALT), + Event::Text("e".into()), + key_event(Key::E, true, Modifiers::ALT), + key_event(Key::E, false, Modifiers::ALT), + Event::ModifiersChanged(Modifiers::NONE), + ], + ); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("transpose"), Some(&json!(false))); + assert_eq!(h.state().keytips.prefix(), None); + letter(&mut h, Key::E, false); + key(&mut h, Key::Enter); + assert!(h.state().dialog.is_none()); + assert!(h.state().editor.is_none()); + assert_eq!(value(&h, "D4"), Value::Number(42.0)); + assert_eq!(value(&h, "D5"), Value::Number(99.0)); + assert_eq!(value(&h, "E4"), Value::Empty); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert!(sh.cell(CellRef::parse("D4").unwrap()).unwrap().formula.is_none()); +} + +#[test] +fn keyboard_opened_paste_menu_accepts_a_mouse_click_after_keytips_cancel() { + let mut h = harness(copied_row()); + sequence(&mut h, &[Key::H, Key::V], false); + assert_eq!(h.state().keytips.prefix(), Some("HV")); + assert!(h.state().dialog.is_none()); + + // This sends actual pointer events at the accessible menu item's rectangle. + // The press cancels keytips, but must not relocate or close the menu before the click. + h.get_by_label("Paste Special…").click(); + h.run_steps(3); + + assert_eq!(h.state().keytips.prefix(), None); + assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial"); + assert_eq!(value(&h, "D4"), Value::Empty); + assert!(h.state().editor.is_none()); +} + +#[test] +fn legacy_paste_special_keeps_formulas_when_requested() { + let mut h = harness(copied_row()); + sequence(&mut h, &[Key::E, Key::S], false); + assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial"); + letter(&mut h, Key::F, false); + assert_eq!(value(&h, "D4"), Value::Empty); + key(&mut h, Key::Enter); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sh.cell(CellRef::parse("D4").unwrap()).unwrap().formula.as_ref().unwrap().text, "20+22"); + assert_eq!(value(&h, "D4"), Value::Number(42.0)); + assert_eq!(value(&h, "E4"), Value::Number(99.0)); + assert!(h.state().editor.is_none()); + key(&mut h, Key::ArrowDown); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "D5"); +} + +#[test] +fn ribbon_and_legacy_autofit_sequences_resize_the_selected_column() { + for route in [&[Key::H, Key::O, Key::I][..], &[Key::O, Key::C, Key::A][..]] { + let mut s = blank(); + s.execute("cell.set", json!({"cell": "A1", "input": "A long heading that needs a wider column"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:A", "width": 12})).unwrap(); + let mut h = harness(s); + let before = h.state().session.active().unwrap().wb.active().unwrap().col_width(0); + sequence(&mut h, route, false); + let after = h.state().session.active().unwrap().wb.active().unwrap().col_width(0); + assert!(after > before, "autofit must widen the narrow column"); + assert_eq!(value(&h, "A1"), Value::from("A long heading that needs a wider column")); + assert!(h.state().editor.is_none()); + assert_eq!(h.state().keytips.prefix(), None); + letter(&mut h, Key::X, false); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "A1"), Value::from("x")); + } +} + +#[test] +fn escape_backs_out_without_clearing_copy_and_f10_toggles_off() { + let mut h = harness(copied_row()); + sequence(&mut h, &[Key::H, Key::A], false); + assert_eq!(h.state().keytips.prefix(), Some("HA")); + for expected in [Some("H"), Some(""), None] { + key(&mut h, Key::Escape); + assert_eq!(h.state().keytips.prefix(), expected); + assert_eq!(h.state().session.clipboard.as_ref().unwrap().range.a1(), "A1:B1"); + } + key(&mut h, Key::F10); + assert_eq!(h.state().keytips.prefix(), Some("")); + key(&mut h, Key::F10); + assert_eq!(h.state().keytips.prefix(), None); + assert!(h.state().session.clipboard.is_some()); + + // A bare left Alt is the desktop entry point on non-macOS systems. + events(&mut h, [Event::ModifiersChanged(Modifiers::ALT), key_event(Key::AltLeft, true, Modifiers::ALT)]); + events(&mut h, [Event::ModifiersChanged(Modifiers::NONE), key_event(Key::AltLeft, false, Modifiers::NONE)]); + assert_eq!(h.state().keytips.prefix(), Some("")); +} + +#[test] +fn mouse_press_and_window_focus_loss_cancel_keytips() { + let mut h = harness(blank()); + sequence(&mut h, &[Key::H], false); + let pos = h.state().grid.cells_rect.unwrap().center(); + events( + &mut h, + [ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ], + ); + assert_eq!(h.state().keytips.prefix(), None); + key(&mut h, Key::F10); + assert_eq!(h.state().keytips.prefix(), Some("")); + h.input_mut().focused = false; + events(&mut h, [Event::WindowFocused(false)]); + assert_eq!(h.state().keytips.prefix(), None); + assert!(h.state().editor.is_none()); +} + +#[test] +fn held_keys_do_not_toggle_twice_or_leak_repeated_letters() { + let mut h = harness(blank()); + // egui derives repeats from held-key state, so send presses across frames before release. + events(&mut h, [key_event(Key::F10, true, Modifiers::NONE)]); + events(&mut h, [key_event(Key::F10, true, Modifiers::NONE)]); + assert_eq!(h.state().keytips.prefix(), Some("")); + events(&mut h, [key_event(Key::F10, false, Modifiers::NONE)]); + events(&mut h, [key_event(Key::H, true, Modifiers::NONE), Event::Text("h".into())]); + events(&mut h, [key_event(Key::H, true, Modifiers::NONE), Event::Text("h".into())]); + assert_eq!(h.state().keytips.prefix(), Some("H")); + events(&mut h, [key_event(Key::H, false, Modifiers::NONE)]); + letter(&mut h, Key::A, false); + events( + &mut h, + [ + key_event(Key::R, true, Modifiers::NONE), + Event::Text("r".into()), + key_event(Key::R, true, Modifiers::NONE), + Event::Text("r".into()), + key_event(Key::R, false, Modifiers::NONE), + ], + ); + assert_eq!(alignment(&h), HAlign::Right); + assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.alignRight").count(), 1); + assert!(h.state().editor.is_none()); + assert_eq!(value(&h, "A1"), Value::Empty); +} + +#[test] +fn ordinary_typing_editor_shortcuts_and_altgr_remain_text_input() { + let mut h = harness(blank()); + events(&mut h, [Event::Text("hello".into())]); + key(&mut h, Key::F10); + assert_eq!(h.state().keytips.prefix(), None, "F10 must not interrupt the cell editor"); + assert_eq!(h.state().editor.as_ref().unwrap().text, "hello"); + events(&mut h, [Event::Text("!".into())]); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "A1"), Value::from("hello!")); + + let mut h = harness(blank()); + let altgr = Modifiers { alt: true, ctrl: true, ..Modifiers::NONE }; + events(&mut h, [Event::ModifiersChanged(altgr), key_event(Key::AltRight, true, altgr), key_event(Key::Q, true, altgr), Event::Text("@".into())]); + assert_eq!(h.state().keytips.prefix(), None); + assert_eq!(h.state().editor.as_ref().unwrap().text, "@"); + + let mut h = harness_on(blank(), OperatingSystem::Mac); + events(&mut h, [Event::ModifiersChanged(Modifiers::ALT), key_event(Key::AltLeft, true, Modifiers::ALT)]); + events(&mut h, [Event::ModifiersChanged(Modifiers::NONE), key_event(Key::AltLeft, false, Modifiers::NONE), Event::Text("é".into())]); + assert_eq!(h.state().keytips.prefix(), None, "macOS Option must remain available for text composition"); + assert_eq!(h.state().editor.as_ref().unwrap().text, "é"); +} From 631a49ec30ef381e63ee1a9299bbd2f0ff778eaa Mon Sep 17 00:00:00 2001 From: lloyd Date: Fri, 9 Oct 2026 20:51:15 +0200 Subject: [PATCH 03/19] Recalc: linear in the number of range formulas Opening a workbook with a SUM(E:J) on each of 50,000 rows took 9 s (1.7 s at 25,000, 0.4 s at 12,500), and editing a cell that 50,000 VLOOKUPs read froze the app for 40 s or more. Two things grew with the number of ranges: - Evaluating a range trims it to the sheet's used range, and Sheet::used_range walks every stored row. It ran once per range read, so a full recalc was rows x range formulas. The pass's host now works it out once per sheet (the workbook doesn't change during a pass); Host::used_range keeps the old behaviour for other hosts. - The reverse index kept each column's range precedents in a Vec: finding a cell's dependents scanned every range in the column, and re-entering a formula ran a retain over each of its columns. Pasting a column, filling formulas down or sorting was quadratic. Each column (and each sheet's wide ranges) now has an interval index: sorted by start, with a segment tree of the largest end, so a lookup costs O(log n + matches). Removals mark entries dead and insertions wait in a small sorted set; Graph::settle merges them after each batch, so edits cost O(log n) amortised. Single-cell dependents are a set, so removing one is O(1). dependents_of_range asks each column once instead of once per cell. Semantics are unchanged: whole rows and columns, other sheets, names, tables, spills and volatile functions go through the same paths. Release CLI, open + full recalc: 12.5k rows 0.39 s -> 0.06 s, 25k 1.83 s -> 0.14 s, 50k 9.3 s -> 0.28 s. The 50k x 20 bug-hunt workbook: recalc on open 87 s -> 1.7 s, editing C2 (100k dependents) 51 s -> 1.3 s; filling 50k SUMs down 17 s -> 0.18 s, sorting 50k rows 9 s -> 0.14 s. Tests: the interval index against a linear scan (inserts, removals, revivals, merges) and its query cost in tree nodes; range, wide, whole-column, whole-row and cross-sheet dependents after edits; and a scaling test (open, change a column, re-enter every formula) that allows 8x the time for 4x the rows. Before the fix it took 16x. Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/eval.rs | 15 +- crates/calc/src/recalc.rs | 498 ++++++++++++++++++++++++++++++++++---- 2 files changed, 468 insertions(+), 45 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index abef54f..4be8341 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -58,6 +58,11 @@ pub trait Host { fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec { range.iter().map(|c| self.cell_value(sheet, c)).collect() } + /// The sheet's used range (`Sheet::used_range`, which walks every stored row). Hosts that + /// keep the workbook unchanged while evaluating can remember it. + fn used_range(&mut self, sheet: usize) -> Option { + self.workbook().sheet(sheet).and_then(|s| s.used_range()) + } } pub struct Evaluator<'h> { @@ -140,9 +145,11 @@ impl<'h> Evaluator<'h> { /// Trims the bottom/right of an area to the sheet's used range (keeps the top-left so /// positions stay right). - pub fn trim(&self, a: &Area) -> RangeRef { - let Some(sheet) = self.host.workbook().sheet(a.sheet) else { return a.range }; - let used = sheet.used_range(); + pub fn trim(&mut self, a: &Area) -> RangeRef { + if self.host.workbook().sheet(a.sheet).is_none() { + return a.range; + } + let used = self.host.used_range(a.sheet); let (mut r1, mut c1) = (a.range.end.row, a.range.end.col); match used { Some(u) => { @@ -881,7 +888,7 @@ impl<'h> Evaluator<'h> { } /// Whole rows/columns trimmed for ROW()/COLUMN() arrays. - fn trim_lines(&self, a: &Area) -> RangeRef { + fn trim_lines(&mut self, a: &Area) -> RangeRef { if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range } } diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 13b47c4..10dd058 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -1,13 +1,14 @@ //! Dependency graph and recalculation. //! //! The graph maps every formula cell to its static precedents (resolved areas). A reverse index -//! answers "which formulas read this cell?": single-cell precedents in a hash map, ranges in -//! per-column buckets (wide ranges in a separate list). After an edit, the dirty set is the +//! answers "which formulas read this cell?": single-cell precedents in a hash map, ranges as row +//! intervals per column (wide ranges per sheet), each an interval index so a lookup costs +//! O(log n + matches) however many ranges share the column. After an edit, the dirty set is the //! transitive closure of dependents; it is evaluated in topological order, with on-demand //! evaluation as a safety net for dynamic references. Cycles produce `#CIRC!` unless iterative //! calculation is on. -use std::collections::VecDeque; +use std::collections::{BTreeSet, VecDeque}; use std::hash::{BuildHasherDefault, Hasher}; /// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate). @@ -51,19 +52,147 @@ struct Node { dynamic: bool, } -/// Ranges wider than this many columns go to the wide list instead of per-column buckets. +/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones. const WIDE: u32 = 64; +/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in. +const PENDING: usize = 32; + +/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows +/// `a..=b`?" in O(log n + k). +/// +/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query +/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one +/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops +/// dead entries once either grows, so mutations cost O(log n) amortised. +#[derive(Clone, Debug)] +struct Intervals { + /// Sorted, without duplicates. + items: Vec<(u32, u32, T)>, + dead: Vec, + dead_count: usize, + /// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at + /// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included). + max_end: Vec, + /// Inserted since the last merge. + pending: BTreeSet<(u32, u32, T)>, +} + +impl Default for Intervals { + fn default() -> Self { + Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() } + } +} + +impl Intervals { + fn is_empty(&self) -> bool { + self.items.len() == self.dead_count && self.pending.is_empty() + } + + fn insert(&mut self, r0: u32, r1: u32, t: T) { + let it = (r0, r1, t); + if let Ok(i) = self.items.binary_search(&it) { + if let Some(d) = self.dead.get_mut(i) + && *d + { + *d = false; + self.dead_count -= 1; + } + return; + } + self.pending.insert(it); + } + + fn remove(&mut self, r0: u32, r1: u32, t: T) { + let it = (r0, r1, t); + if let Ok(i) = self.items.binary_search(&it) { + if let Some(d) = self.dead.get_mut(i) + && !*d + { + *d = true; + self.dead_count += 1; + } + return; + } + self.pending.remove(&it); + } + + /// Merges pending insertions and drops dead entries when there are enough of them. + fn settle(&mut self) { + if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) { + return; + } + let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len()); + all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it)); + all.extend(std::mem::take(&mut self.pending)); + // Two sorted runs: the stable sort merges them in linear time. + all.sort(); + all.dedup(); + let size = all.len().next_power_of_two(); + let mut max_end = vec![0; 2 * size]; + if let Some(leaves) = max_end.get_mut(size..) { + for (slot, it) in leaves.iter_mut().zip(&all) { + *slot = it.1; + } + } + for i in (1..size).rev() { + let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0)); + if let Some(slot) = max_end.get_mut(i) { + *slot = m; + } + } + self.dead = vec![false; all.len()]; + self.dead_count = 0; + self.items = all; + self.max_end = max_end; + } + + /// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the + /// number of tree nodes visited. + fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize { + for it in &self.pending { + if it.0 <= b && it.1 >= a { + f(&it.2); + } + } + // Entries before `p` start at or before row `b`. + let p = self.items.partition_point(|it| it.0 <= b); + if p == 0 { + return 0; + } + self.walk(1, 0, self.max_end.len() / 2, p, a, f) + } + + fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize { + if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { + return 1; + } + if hi - lo <= 1 { + if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo)) + && it.1 >= a + { + f(&it.2); + } + return 1; + } + let mid = lo + (hi - lo) / 2; + 1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f) + } +} + #[derive(Clone, Debug, Default)] pub struct Graph { nodes: HashMap, /// single cell → dependents - cell_deps: HashMap>, - /// (sheet, col) → [(r0, r1, dependent)] - col_deps: HashMap<(usize, u32), Vec<(u32, u32, Key)>>, - /// (sheet, range, dependent) - wide_deps: Vec<(usize, RangeRef, Key)>, + cell_deps: HashMap>, + /// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column + col_deps: HashMap<(usize, u32), Intervals>, + /// sheet → row intervals of wider ranges, with their columns: (first, last, dependent) + wide_deps: HashMap>, dynamic: HashSet, + /// Range indexes changed since the last [`Graph::settle`]. + unsettled_cols: HashSet<(usize, u32)>, + unsettled_wide: HashSet, } impl Graph { @@ -74,19 +203,44 @@ impl Graph { self.nodes.is_empty() } - fn insert(&mut self, k: Key, node: Node) { - self.remove(k); - for a in &node.areas { + /// Index entries of a formula's precedents, without duplicates: single cells, column + /// intervals and wide ranges. + fn entries(areas: &[Area]) -> (Vec, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) { + let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new()); + for a in areas { if a.range.is_single() { - self.cell_deps.entry((a.sheet, a.range.start)).or_default().push(k); + cells.push((a.sheet, a.range.start)); } else if a.range.width() > WIDE { - self.wide_deps.push((a.sheet, a.range, k)); + wide.push((a.sheet, a.range)); } else { for c in a.range.start.col..=a.range.end.col { - self.col_deps.entry((a.sheet, c)).or_default().push((a.range.start.row, a.range.end.row, k)); + cols.push((a.sheet, c, a.range.start.row, a.range.end.row)); } } } + cells.sort_unstable(); + cells.dedup(); + cols.sort_unstable(); + cols.dedup(); + wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end)); + wide.dedup(); + (cells, cols, wide) + } + + fn insert(&mut self, k: Key, node: Node) { + self.remove(k); + let (cells, cols, wide) = Self::entries(&node.areas); + for c in cells { + self.cell_deps.entry(c).or_default().insert(k); + } + for (s, c, r0, r1) in cols { + self.col_deps.entry((s, c)).or_default().insert(r0, r1, k); + self.unsettled_cols.insert((s, c)); + } + for (s, r) in wide { + self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k)); + self.unsettled_wide.insert(s); + } if node.dynamic { self.dynamic.insert(k); } @@ -95,54 +249,104 @@ impl Graph { fn remove(&mut self, k: Key) { let Some(old) = self.nodes.remove(&k) else { return }; - for a in &old.areas { - if a.range.is_single() { - if let Some(v) = self.cell_deps.get_mut(&(a.sheet, a.range.start)) { - v.retain(|x| *x != k); - } - } else if a.range.width() > WIDE { - self.wide_deps.retain(|(_, _, d)| *d != k); - } else { - for c in a.range.start.col..=a.range.end.col { - if let Some(v) = self.col_deps.get_mut(&(a.sheet, c)) { - v.retain(|(_, _, d)| *d != k); - } + let (cells, cols, wide) = Self::entries(&old.areas); + for c in cells { + if let Some(v) = self.cell_deps.get_mut(&c) { + v.remove(&k); + if v.is_empty() { + self.cell_deps.remove(&c); } } } + for (s, c, r0, r1) in cols { + if let Some(v) = self.col_deps.get_mut(&(s, c)) { + v.remove(r0, r1, k); + self.unsettled_cols.insert((s, c)); + } + } + for (s, r) in wide { + if let Some(v) = self.wide_deps.get_mut(&s) { + v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k)); + self.unsettled_wide.insert(s); + } + } self.dynamic.remove(&k); } + /// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it, + /// just slower). + fn settle(&mut self) { + for key in std::mem::take(&mut self.unsettled_cols) { + if let Some(v) = self.col_deps.get_mut(&key) { + v.settle(); + if v.is_empty() { + self.col_deps.remove(&key); + } + } + } + for key in std::mem::take(&mut self.unsettled_wide) { + if let Some(v) = self.wide_deps.get_mut(&key) { + v.settle(); + if v.is_empty() { + self.wide_deps.remove(&key); + } + } + } + } + /// Formulas that read cell `c` of `sheet`. pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec) { if let Some(v) = self.cell_deps.get(&(sheet, c)) { - out.extend_from_slice(v); + out.extend(v.iter().copied()); } if let Some(v) = self.col_deps.get(&(sheet, c.col)) { - out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= c.row && c.row <= *r1).map(|(_, _, k)| *k)); + v.query(c.row, c.row, &mut |k| out.push(*k)); + } + if let Some(v) = self.wide_deps.get(&sheet) { + v.query(c.row, c.row, &mut |(c0, c1, k)| { + if *c0 <= c.col && c.col <= *c1 { + out.push(*k); + } + }); } - out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.contains(c)).map(|(_, _, k)| *k)); } - /// Formulas that read any cell of `range`. + /// Formulas that read any cell of `range` (a formula can be listed more than once). pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { if range.count() <= 4096 { for c in range.iter() { - self.dependents(sheet, c, out); + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend(v.iter().copied()); + } } - return; - } - for ((s, c), v) in &self.cell_deps { - if *s == sheet && range.contains(*c) { - out.extend_from_slice(v); + } else { + for ((s, c), v) in &self.cell_deps { + if *s == sheet && range.contains(*c) { + out.extend(v.iter().copied()); + } } } - for ((s, col), v) in &self.col_deps { - if *s == sheet && range.start.col <= *col && *col <= range.end.col { - out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= range.end.row && range.start.row <= *r1).map(|(_, _, k)| *k)); + let (r0, r1) = (range.start.row, range.end.row); + if (range.width() as usize) <= self.col_deps.len() { + for col in range.start.col..=range.end.col { + if let Some(v) = self.col_deps.get(&(sheet, col)) { + v.query(r0, r1, &mut |k| out.push(*k)); + } + } + } else { + for ((s, col), v) in &self.col_deps { + if *s == sheet && range.start.col <= *col && *col <= range.end.col { + v.query(r0, r1, &mut |k| out.push(*k)); + } } } - out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.intersects(&range)).map(|(_, _, k)| *k)); + if let Some(v) = self.wide_deps.get(&sheet) { + v.query(r0, r1, &mut |(c0, c1, k)| { + if *c0 <= range.end.col && range.start.col <= *c1 { + out.push(*k); + } + }); + } } /// Precedent areas of a formula cell (for Trace Precedents). @@ -177,6 +381,9 @@ struct PassHost<'a> { in_progress: HashSet, /// New spills found during the pass: anchor → array. spills: HashMap>, + /// Used range per sheet: the workbook doesn't change during a pass, and working it out walks + /// every row, so once per sheet rather than once per range read. + used: HashMap>, rng: &'a mut u64, now: f64, cycle: bool, @@ -301,6 +508,10 @@ impl Host for PassHost<'_> { } out } + fn used_range(&mut self, sheet: usize) -> Option { + let wb = self.wb; + *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) + } fn now_serial(&self) -> f64 { self.now } @@ -340,6 +551,7 @@ impl Calc { } } } + self.graph.settle(); } /// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading). @@ -369,6 +581,7 @@ impl Calc { } } } + self.graph.settle(); if wb.calc.mode == CalcMode::Manual { // Only the edited formulas themselves are evaluated. let own: Vec = changed.iter().copied().filter(|k| self.graph.nodes.contains_key(k)).collect(); @@ -466,6 +679,7 @@ impl Calc { pending: exprs.keys().copied().collect(), in_progress: HashSet::default(), spills: HashMap::default(), + used: HashMap::default(), rng: &mut rng, now, cycle: false, @@ -582,6 +796,7 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V pending: HashSet::default(), in_progress: HashSet::default(), spills: HashMap::default(), + used: HashMap::default(), rng: &mut rng, now: now_serial(), cycle: false, @@ -602,3 +817,204 @@ fn prof_now() -> f64 { 0.0 } } + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use gridcraft_core::{CellRef, RangeRef, Value}; + use gridcraft_model::{Cell, Formula, Workbook}; + + use super::{Calc, Intervals, Key}; + + /// xorshift64 for repeatable pseudo-random cases. + fn rng(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + } + + fn found(iv: &Intervals, a: u32, b: u32) -> Vec { + let mut v = Vec::new(); + iv.query(a, b, &mut |t| v.push(*t)); + v.sort_unstable(); + v + } + + #[test] + fn intervals_match_a_linear_scan() { + let mut iv: Intervals = Intervals::default(); + let mut model: Vec<(u32, u32, u32)> = Vec::new(); + let mut s = 0x9E37_79B9_7F4A_7C15; + for step in 0..20_000u32 { + let roll = rng(&mut s) % 10; + if roll < 6 || model.is_empty() { + let r0 = (rng(&mut s) % 500) as u32; + let r1 = r0 + (rng(&mut s) % 40) as u32; + if !model.contains(&(r0, r1, step)) { + iv.insert(r0, r1, step); + model.push((r0, r1, step)); + } + } else { + let i = (rng(&mut s) % model.len() as u64) as usize; + let (r0, r1, t) = model.swap_remove(i); + iv.remove(r0, r1, t); + if roll == 9 { + // Removed and put back before the next merge. + iv.insert(r0, r1, t); + model.push((r0, r1, t)); + } + } + if rng(&mut s).is_multiple_of(50) { + iv.settle(); + } + if step.is_multiple_of(97) { + let a = (rng(&mut s) % 560) as u32; + let b = a + (rng(&mut s) % 30) as u32; + let mut want: Vec = model.iter().filter(|(r0, r1, _)| *r0 <= b && *r1 >= a).map(|(_, _, t)| *t).collect(); + want.sort_unstable(); + assert_eq!(found(&iv, a, b), want, "rows {a}..={b} at step {step}"); + } + } + iv.settle(); + for row in 0..560 { + let mut want: Vec = model.iter().filter(|(r0, r1, _)| *r0 <= row && row <= *r1).map(|(_, _, t)| *t).collect(); + want.sort_unstable(); + assert_eq!(found(&iv, row, row), want, "row {row}"); + } + for (r0, r1, t) in std::mem::take(&mut model) { + iv.remove(r0, r1, t); + } + iv.settle(); + assert!(iv.is_empty()); + } + + #[test] + fn interval_queries_cost_log_n_plus_matches() { + // 40k one-row ranges (like =SUM(E1:J1) down a column) and 1k ranges over everything: + // a point query visits O(log n) tree nodes per match, not every range in the column. + let n = 40_000u32; + let mut iv: Intervals = Intervals::default(); + for r in 0..n { + iv.insert(r, r, r); + } + for t in 0..1_000 { + iv.insert(0, n, n + t); + } + iv.settle(); + let mut worst = 0; + for row in (0..n).step_by(7) { + let mut k = 0; + let visited = iv.query(row, row, &mut |_| k += 1); + assert_eq!(k, 1_001); + worst = worst.max(visited); + } + // 1,001 matches in a tree of depth 16: well under 4 nodes per match plus the path. + assert!(worst < 4 * 1_001 + 64, "visited {worst} nodes"); + let mut k = 0; + let visited = iv.query(n + 1, n + 5, &mut |_| k += 1); + assert_eq!(k, 0); + assert!(visited < 64, "visited {visited} nodes for no match"); + } + + /// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K. + fn sum_rows(rows: u32) -> Workbook { + let mut wb = Workbook::new(); + if let Some(sh) = wb.sheet_mut(0) { + for r in 0..rows { + for c in 4..10 { + sh.cells.set(CellRef::new(r, c), Cell::value(Value::Number(f64::from(r % 97 + c)))); + } + sh.cells.set(CellRef::new(r, 10), Cell::formula(Formula::new(&format!("=SUM(E{0}:J{0})", r + 1)))); + } + } + wb + } + + /// Opening (full recalc), changing every value of a column, and re-entering every formula. + fn open_and_bulk_edit(rows: u32) -> Duration { + let mut wb = sum_rows(rows); + let mut calc = Calc::new(); + let t = Instant::now(); + calc.recalc_all(&mut wb); + let values: Vec = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect(); + if let Some(sh) = wb.sheet_mut(0) { + for (_, c) in &values { + sh.cells.set(*c, Cell::value(Value::Number(1.0))); + } + } + calc.cells_changed(&mut wb, &values); + let formulas: Vec = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect(); + calc.cells_changed(&mut wb, &formulas); + let elapsed = t.elapsed(); + let last = wb.sheet(0).map(|s| s.value(CellRef::new(rows - 1, 10))); + let r = rows - 1; + assert_eq!(last, Some(Value::Number(f64::from(1 + (5..10).map(|c| r % 97 + c).sum::())))); + elapsed + } + + #[test] + fn recalc_with_range_formulas_scales_linearly() { + // Each row's SUM reads a range in the same columns. Recalculating on open used to walk + // every row per range read (the used range), and finding a cell's dependents scanned + // every range in its column: 4x the rows took 16x the time. Linear is 4x; allow 8x. + let best = |rows| (0..3).map(|_| open_and_bulk_edit(rows)).min().unwrap_or_default(); + let small = best(5_000); + let large = best(20_000); + assert!(large < small * 8 + Duration::from_millis(100), "5k rows {small:?}, 20k rows {large:?}"); + } + + #[test] + fn range_dependents_after_edits() { + // Formulas that read ranges, wide ranges and whole columns, found again after edits. + let mut wb = Workbook::new(); + let mut calc = Calc::new(); + let set = |wb: &mut Workbook, calc: &mut Calc, at: &str, input: &str| { + let c = CellRef::parse(at).unwrap(); + let cell = if input.starts_with('=') { Cell::formula(Formula::new(input)) } else { Cell::value(Value::Number(input.parse().unwrap())) }; + wb.sheet_mut(0).unwrap().cells.set(c, cell); + calc.cells_changed(wb, &[(0, c)]); + }; + let num = |wb: &Workbook, at: &str| { + let v = wb.sheet(0).unwrap().value(CellRef::parse(at).unwrap()); + v.as_f64().unwrap_or_else(|| panic!("{at} = {v:?}")) + }; + set(&mut wb, &mut calc, "A1", "1"); + set(&mut wb, &mut calc, "B2", "2"); + set(&mut wb, &mut calc, "CZ3", "3"); + set(&mut wb, &mut calc, "DA5", "=SUM(A1:B2)"); + set(&mut wb, &mut calc, "DA6", "=SUM(A1:CZ3)"); + set(&mut wb, &mut calc, "DA7", "=SUM(B:B)"); + set(&mut wb, &mut calc, "DA8", "=SUM(2:2)"); + assert_eq!((num(&wb, "DA5"), num(&wb, "DA6"), num(&wb, "DA7"), num(&wb, "DA8")), (3.0, 6.0, 2.0, 2.0)); + set(&mut wb, &mut calc, "B2", "20"); + assert_eq!((num(&wb, "DA5"), num(&wb, "DA6"), num(&wb, "DA7"), num(&wb, "DA8")), (21.0, 24.0, 20.0, 20.0)); + // Re-entering a formula with another range drops the old one. + set(&mut wb, &mut calc, "DA5", "=SUM(A1:A2)"); + set(&mut wb, &mut calc, "B2", "5"); + assert_eq!(num(&wb, "DA5"), 1.0); + let mut deps = Vec::new(); + calc.graph.dependents(0, CellRef::parse("B2").unwrap(), &mut deps); + deps.sort_unstable(); + let at = |s: &str| (0, CellRef::parse(s).unwrap()); + assert_eq!(deps, vec![at("DA6"), at("DA7"), at("DA8")]); + deps.clear(); + calc.graph.dependents_of_range(0, RangeRef::new(CellRef::parse("A1").unwrap(), CellRef::parse("A9").unwrap()), &mut deps); + deps.sort_unstable(); + deps.dedup(); + assert_eq!(deps, vec![at("DA5"), at("DA6"), at("DA8")]); + set(&mut wb, &mut calc, "CZ3", "30"); + assert_eq!(num(&wb, "DA6"), 36.0); + // Ranges on another sheet. + wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data"))); + set(&mut wb, &mut calc, "DA9", "=SUM(Data!A1:A3)+SUM(Data!A:CZ)"); + assert_eq!(num(&wb, "DA9"), 0.0); + wb.sheet_mut(1).unwrap().cells.set(CellRef::parse("A2").unwrap(), Cell::value(Value::Number(4.0))); + calc.cells_changed(&mut wb, &[(1, CellRef::parse("A2").unwrap())]); + assert_eq!(num(&wb, "DA9"), 8.0); + deps.clear(); + calc.graph.dependents(1, CellRef::parse("A2").unwrap(), &mut deps); + assert_eq!(deps, vec![at("DA9"), at("DA9")]); + } +} From 4d46b9170b6e4c599d9d4c625b32d831f732e8ec Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 9 Oct 2026 17:14:43 -0400 Subject: [PATCH 04/19] ci: publish Flatpak repository to Pages --- .github/workflows/flatpak-pages.yml | 162 ++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 .github/workflows/flatpak-pages.yml diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml new file mode 100644 index 0000000..44f97cc --- /dev/null +++ b/.github/workflows/flatpak-pages.yml @@ -0,0 +1,162 @@ +name: Publish Flatpak repository to Pages + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: "Published GitHub release tag to publish (for example, v1.2.3)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: flatpak-pages-${{ github.repository }} + cancel-in-progress: false + +jobs: + build: + name: Build Flatpak repository + runs-on: ubuntu-24.04 + permissions: + contents: read + pages: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + APP_ID: ai.storyteller.gridcraft + steps: + - name: Configure Pages + id: pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Install Flatpak repository tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends flatpak ostree + + - name: Download and validate release bundles + shell: bash + run: | + set -euo pipefail + tag="$RELEASE_TAG" + if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then + echo "::error::Release tag '$tag' is not strict SemVer prefixed with v" + exit 1 + fi + published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \ + 'select(.draft == false and .published_at != null) | .tag_name')" + if [[ "$published_tag" != "$tag" ]]; then + echo "::error::$tag is not a published release in $GITHUB_REPOSITORY" + exit 1 + fi + + version="${tag#v}" + mkdir -p bundles + gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ + --pattern SHA256SUMS.txt --dir bundles + for arch in x86_64 aarch64; do + asset="gridcraft-$version-linux-$arch.flatpak" + gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ + --pattern "$asset" --dir bundles + test -s "bundles/$asset" + ( + cd bundles + awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \ + SHA256SUMS.txt | sha256sum --check - + ) + done + test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2 + + for arch in x86_64 aarch64; do + asset="bundles/gridcraft-$version-linux-$arch.flatpak" + validation_repo="$(mktemp -d)" + ostree --repo="$validation_repo" init --mode=archive-z2 + flatpak build-import-bundle "$validation_repo" "$asset" + expected_ref="app/$APP_ID/$arch/stable" + ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || { + echo "::error::$(basename "$asset") does not contain $expected_ref" + exit 1 + } + rm -rf "$validation_repo" + done + + - name: Build repository and site + shell: bash + env: + PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }} + run: | + set -euo pipefail + site="$RUNNER_TEMP/site" + repo_dir="$site/flatpak/repo" + mkdir -p "$repo_dir" + ostree --repo="$repo_dir" init --mode=archive-z2 + + previous_url="${PAGES_BASE_URL%/}/flatpak/repo" + if curl --fail --silent --show-error --location \ + --output /dev/null "$previous_url/summary"; then + ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url" + ostree --repo="$repo_dir" pull --mirror previous + ostree --repo="$repo_dir" remote delete previous + fi + + version="${RELEASE_TAG#v}" + for arch in x86_64 aarch64; do + flatpak build-import-bundle "$repo_dir" \ + "bundles/gridcraft-$version-linux-$arch.flatpak" + done + flatpak build-update-repo \ + --title="GridCraft" \ + --comment='Official GridCraft releases' \ + --description='Official Flatpak repository for GridCraft' \ + --homepage="https://github.com/$GITHUB_REPOSITORY" \ + --default-branch=stable \ + "$repo_dir" + + for arch in x86_64 aarch64; do + ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null + done + test -s "$repo_dir/summary" + + base_url="${PAGES_BASE_URL%/}" + repo_url="$base_url/flatpak/repo/" + cat >"$site/flatpak/gridcraft.flatpakrepo" <"$site/flatpak/index.html" <GridCraft Flatpak repository

GridCraft Flatpak repository

Latest published release: $RELEASE_TAG

Add the GridCraft repository

flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
+          flatpak install gridcraft $APP_ID
+ HTML + cat >"$site/index.html" <GridCraft downloads

GridCraft downloads

Flatpak repository

Release assets

+ HTML + touch "$site/.nojekyll" + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: ${{ runner.temp }}/site + include-hidden-files: true + + deploy: + name: Deploy Pages + needs: build + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 From 628732ef5811a5a933ba314effb15ec39b763dac Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:47:58 -0400 Subject: [PATCH 05/19] Enable inline editing of worksheet text boxes (#56) --- apps/gridcraft/src/native_menu.rs | 25 +++ crates/engine/src/cmd/insert.rs | 21 +++ crates/engine/src/tests.rs | 74 ++++++++ crates/ui-egui/src/chartview.rs | 7 +- crates/ui-egui/src/grid.rs | 23 ++- crates/ui-egui/src/lib.rs | 40 +++- crates/ui-egui/src/ribbon.rs | 2 +- crates/ui-egui/src/text_box.rs | 165 ++++++++++++++++ crates/ui-egui/tests/text_box_editing.rs | 230 +++++++++++++++++++++++ 9 files changed, 577 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/text_box.rs create mode 100644 crates/ui-egui/tests/text_box_editing.rs diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 43e57a4..0bdce38 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -226,6 +226,31 @@ impl NativeMenu { pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) { while let Ok(ev) = self.events.try_recv() { let id = ev.id.as_ref().to_string(); + // Text selection belongs to the inline editor, not the worksheet clipboard/undo. + if app.text_box_editor.is_some() { + let clipboard = match id.as_str() { + "edit.copy" => Some(egui::ViewportCommand::RequestCopy), + "edit.cut" => Some(egui::ViewportCommand::RequestCut), + "edit.paste" => Some(egui::ViewportCommand::RequestPaste), + _ => None, + }; + if let Some(command) = clipboard { + ctx.send_viewport_cmd(command); + continue; + } + if id == "edit.undo" || id == "edit.redo" { + ctx.input_mut(|i| { + i.events.push(egui::Event::Key { + key: egui::Key::Z, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: id == "edit.redo", ..Default::default() }, + }); + }); + continue; + } + } if let Some(url) = id.strip_prefix("url:") { if let Some(open) = &app.services.open_url { open(url); diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index e4b5bc9..d2f0e35 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -122,6 +122,7 @@ pub fn specs() -> Vec { s, &with(p, "kind", json!("textBox")) )), + cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text), cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape, id}", has_doc, delete_object), cmd!("object.move", "Move Object", [], None, "{kind: chart|image|shape, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object), cmd!( @@ -776,6 +777,26 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { }) } +fn shape_set_text(s: &mut Session, p: &Json) -> Result { + let id = + p.get("id").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()).ok_or_else(|| bad("shape.setText", "`id` must be a text box id"))?; + let text = str_param(p, "text").ok_or_else(|| bad("shape.setText", "`text` must be a string"))?; + let d = s.doc()?; + let sheet = d.wb.active_sheet; + let shape = d.wb.active().and_then(|sh| sh.shapes.iter().find(|shape| shape.id == id)).ok_or_else(|| bad("shape.setText", "no such text box"))?; + if shape.kind != ShapeKind::TextBox { + return Err(bad("shape.setText", "the shape is not a text box")); + } + if shape.text == text { + return ok(); + } + edit(s, |cx| { + let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?; + shape.text = text.into(); + Ok(Json::Null) + }) +} + fn delete_object(s: &mut Session, p: &Json) -> Result { let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?; let kind = str_param(p, "kind").unwrap_or("").to_string(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..bb25512 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -39,6 +39,80 @@ fn undo_redo() { assert!(s.doc().unwrap().is_dirty()); } +#[test] +fn shape_text_edit_undo_redo_and_noop() { + let mut s = s(); + let id = s.execute("insert.textBox", json!({"at": "C4", "width": 210, "height": 90, "text": "Original"})).unwrap()["shape"].clone(); + let original = s.doc().unwrap().wb.active().unwrap().shapes[0].clone(); + let mut changed = original.clone(); + changed.text = "First line\n\nSecond line & ".into(); + let undo_len = s.doc().unwrap().undo.len(); + s.execute("shape.setText", json!({"id": id, "text": changed.text})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0], changed); + assert_eq!(s.doc().unwrap().undo.len(), undo_len + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0], original); + + let before_noop = s.doc().unwrap().wb.clone(); + let revision = s.doc().unwrap().revision; + s.execute("shape.setText", json!({"id": id, "text": "Original"})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before_noop)); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(s.doc().unwrap().undo.len(), undo_len); + assert_eq!(s.doc().unwrap().redo.len(), 1); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0], changed); + + s.execute("shape.setText", json!({"id": id, "text": ""})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0].text, ""); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0], changed); +} + +#[test] +fn shape_text_rejects_invalid_targets_and_params() { + let mut s = s(); + let id = s.execute("insert.textBox", json!({"text": "Original"})).unwrap()["shape"].clone(); + let rectangle = s.execute("insert.shape", json!({"kind": "rectangle", "text": "Rectangle"})).unwrap()["shape"].clone(); + let before = s.doc().unwrap().wb.clone(); + let undo_len = s.doc().unwrap().undo.len(); + for params in [ + json!({}), + json!({"id": id}), + json!({"id": id, "text": 12}), + json!({"id": "1", "text": "bad id"}), + json!({"id": -1, "text": "bad id"}), + json!({"id": 1.5, "text": "bad id"}), + json!({"id": u64::from(u32::MAX) + 1, "text": "bad id"}), + json!({"id": 999, "text": "unknown"}), + json!({"id": rectangle, "text": "not a text box"}), + ] { + assert!(matches!(s.execute("shape.setText", params), Err(crate::EngineError::BadParams { .. }))); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before)); + assert_eq!(s.doc().unwrap().undo.len(), undo_len); + } + s.execute("home.insertSheet", json!({})).unwrap(); + assert!(matches!(s.execute("shape.setText", json!({"id": id, "text": "wrong sheet"})), Err(crate::EngineError::BadParams { .. }))); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().shapes[0].text, "Original"); + assert!(s.doc().unwrap().wb.active().unwrap().shapes.is_empty()); +} + +#[test] +fn shape_text_edit_survives_xlsx_roundtrip() { + let mut s = s(); + s.execute("insert.textBox", json!({"text": "Imported"})).unwrap(); + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "text-box.xlsx", "base64": saved["base64"]})).unwrap(); + let id = s.doc().unwrap().wb.active().unwrap().shapes[0].id; + let text = "Edited & \n\nLast line\n"; + s.execute("shape.setText", json!({"id": id, "text": text})).unwrap(); + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "edited.xlsx", "base64": saved["base64"]})).unwrap(); + let shape = &s.doc().unwrap().wb.active().unwrap().shapes[0]; + assert_eq!(shape.kind, gridcraft_model::ShapeKind::TextBox); + assert_eq!(shape.text, text); +} + #[test] fn copy_paste_shifts_formulas() { let mut s = s(); diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 082be11..adeeddb 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -180,7 +180,7 @@ fn area_mesh(v: &[Pos2], col: Color32) -> egui::Mesh { mesh } -fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect { +pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect { let x = geo.x(sh, a.cell.col) + a.dx * geo.z; let y = geo.y(sh, a.cell.row) + a.dy * geo.z; Rect::from_min_size(pos2(x, y), vec2(a.width * geo.z, a.height.max(1.0) * geo.z)) @@ -243,7 +243,7 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: } } } - if !sp.text.is_empty() { + if !sp.text.is_empty() && !app.editing_text_box(sp.id) { let job = egui::text::LayoutJob::simple( sp.text.clone(), FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())), @@ -254,6 +254,9 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: let pos = if sp.kind == ShapeKind::TextBox { r.min + vec2(4.0, 4.0) } else { r.center() - g.size() / 2.0 }; p.galley(pos, g, Color32::BLACK); } + if sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) { + selection_frame(p, r); + } } for im in &sh.images { let r = anchor_rect(geo, sh, &im.anchor); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..a3e0fb7 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -323,7 +323,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let mut view = app.view(); // Scrolling. let (scroll_delta, zoom_delta, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers)); - if resp.hovered() || resp.contains_pointer() { + 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})); @@ -417,6 +417,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } // Interaction. interact(app, ui, &resp, &geo, sh, &wb); + crate::text_box::show(app, ui, &geo, sh); if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { in_cell_editor(app, ui, &geo, sh, &wb); } @@ -982,6 +983,10 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel // ------------------------------------------------------------------ interaction fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { + // Text selection and editing must not also move the object or edit worksheet cells. + if app.text_box_editor.is_some() { + return; + } let ctx = ui.ctx().clone(); let pointer = ctx.input(|i| i.pointer.clone()); let mods = ctx.input(|i| i.modifiers); @@ -1064,6 +1069,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) { app.selected_chart = Some(id); + if kind == "shape" + && resp.double_clicked() + && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox) + { + app.begin_text_box_edit(id); + return; + } if kind == "chart" && resp.double_clicked() { app.grid.pane = Some("formatChart".into()); } @@ -1503,12 +1515,13 @@ fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { /// Grid keyboard handling when no text field has focus. fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) { // Focus left on an editor that no longer exists goes back to the grid. - let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar")]; - if app.editor.is_none() && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { + let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar"), crate::text_box::editor_id()]; + let editing = app.editor.is_some() || app.text_box_editor.is_some(); + if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { resp.request_focus(); } - let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && !(app.editor.is_none() && stale.contains(&f))); - if app.editor.is_some() || app.dialog.is_some() || other_focus || app.message.is_some() { + let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); + if editing || app.dialog.is_some() || other_focus || app.message.is_some() { return; } if ctx.memory(|m| m.focused()).is_none() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca..36a0fed 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ pub mod panes; pub mod pivot_pane; pub mod ribbon; pub mod tabs; +pub mod text_box; pub mod theme; pub mod widgets; @@ -93,6 +94,7 @@ pub struct SheetApp { pub ui: UiState, pub views: HashMap<(u64, usize), SheetView>, pub editor: Option, + pub text_box_editor: Option, pub dialog: Option, pub message: Option<(String, String)>, pub toast: Option<(String, f64)>, @@ -118,6 +120,7 @@ impl SheetApp { ui: UiState::default(), views: HashMap::new(), editor: None, + text_box_editor: None, dialog: None, message: None, toast: None, @@ -148,6 +151,29 @@ impl SheetApp { /// Runs an engine command (or a UI command) and handles UI requests it makes. pub fn run(&mut self, id: &str, params: Json) -> Result { + if id.starts_with("file.") + || matches!( + id, + "window.activate" + | "sheet.activate" + | "sheet.next" + | "sheet.previous" + | "sheet.move" + | "sheet.hide" + | "sheet.unhide" + | "view.newWindow" + | "view.hideWindow" + | "view.unhideWindow" + | "home.insertSheet" + | "home.deleteSheet" + | "edit.undo" + | "edit.redo" + | "object.delete" + | "object.move" + ) + { + self.commit_text_box_edit()?; + } if let Some(r) = self.run_ui_command(id, ¶ms) { return r; } @@ -218,6 +244,10 @@ impl SheetApp { } pub fn open_dialog(&mut self, name: &str, params: Json) { + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } match name { "open" => { if let Some(start) = &self.services.open_async { @@ -248,7 +278,7 @@ impl SheetApp { } pub fn open_path(&mut self, path: &str) { - match self.session.run("file.open", json!({"path": path})) { + match self.run("file.open", json!({"path": path})) { Ok(_) => { self.ui.recent.retain(|p| p != path); self.ui.recent.insert(0, path.to_string()); @@ -274,6 +304,10 @@ impl SheetApp { /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } let Some(d) = self.session.active() else { return }; let Some(sh) = d.wb.active() else { return }; let at = d.selection.active; @@ -375,7 +409,7 @@ impl SheetApp { .unwrap_or_default(); for (name, bytes) in arrived { let b64 = gridcraft_engine::io::base64_encode(&bytes); - if let Err(e) = self.session.run("file.open", json!({"name": name, "base64": b64})) { + if let Err(e) = self.run("file.open", json!({"name": name, "base64": b64})) { self.message = Some(("GridCraft".into(), clean_error(&e))); } self.after_engine(); @@ -399,6 +433,7 @@ impl SheetApp { return; } let t0 = now_ms(); + text_box::before_ui(self, &ctx); let t = theme::Tokens::get(&ctx); ribbon::title_bar(self, ui); ribbon::show(self, ui); @@ -436,6 +471,7 @@ impl SheetApp { // AutoSave: save workbooks that have a file shortly after they change. if self.grid.autosave && self.editor.is_none() + && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))) { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432..87e33dd 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -210,7 +210,7 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { if sh.table_at(d.selection.active).is_some() { v.push("Table Design"); } - if app.selected_chart.is_some() { + if app.selected_chart.is_some_and(|id| sh.charts.iter().any(|c| c.id == id)) { v.push("Chart Design"); } } diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs new file mode 100644 index 0000000..f9d8dec --- /dev/null +++ b/crates/ui-egui/src/text_box.rs @@ -0,0 +1,165 @@ +//! In-place editing of a text box. The draft becomes one undoable engine command. + +use egui::{Color32, FontId, Key, Modifiers, Rect, TextEdit}; +use gridcraft_engine::{Mode, model::ShapeKind}; +use serde_json::json; + +use crate::{SheetApp, grid::Geo, theme}; + +#[derive(Clone, Debug)] +pub struct EditState { + pub id: u32, + pub text: String, + uid: u64, + sheet: usize, + original: String, + request_focus: bool, + rect: Option, +} + +pub(crate) fn editor_id() -> egui::Id { + egui::Id::new("gridcraft.text_box_editor") +} + +impl SheetApp { + pub fn begin_text_box_edit(&mut self, id: u32) -> bool { + if !self.commit_edit(0, 0, false, false) { + return false; + } + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), crate::clean_error(&e))); + return false; + } + let Some(d) = self.session.active() else { return false }; + let Some(shape) = d.wb.active().and_then(|sh| sh.shapes.iter().find(|s| s.id == id && s.kind == ShapeKind::TextBox)) else { + return false; + }; + self.text_box_editor = Some(EditState { + id, + text: shape.text.clone(), + uid: d.uid, + sheet: d.wb.active_sheet, + original: shape.text.clone(), + request_focus: true, + rect: None, + }); + self.selected_chart = Some(id); + self.grid.drag = crate::grid::Drag::None; + self.grid.drag_select = false; + self.session.mode = Mode::Edit; + true + } + + pub fn commit_text_box_edit(&mut self) -> Result<(), String> { + let Some(ed) = self.text_box_editor.take() else { return Ok(()) }; + let result = if self.view_key() != Some((ed.uid, ed.sheet)) { + Err("The text box's worksheet is no longer active.".into()) + } else if ed.text == ed.original { + Ok(()) + } else { + // Call the engine directly: app.run flushes this draft before saving/switching. + self.session.run("shape.setText", json!({"id": ed.id, "text": ed.text})).map(|_| ()) + }; + match result { + Ok(()) => { + self.session.mode = Mode::Ready; + self.after_engine(); + Ok(()) + } + Err(e) => { + self.text_box_editor = Some(ed); + Err(e) + } + } + } + + pub fn cancel_text_box_edit(&mut self) { + self.text_box_editor = None; + self.session.mode = Mode::Ready; + } + + pub(crate) fn editing_text_box(&self, id: u32) -> bool { + self.text_box_editor.as_ref().is_some_and(|ed| ed.id == id && self.view_key() == Some((ed.uid, ed.sheet))) + } +} + +/// Resolve outside clicks before the ribbon/tabs can save or switch the workbook. +pub(crate) fn before_ui(app: &mut SheetApp, ctx: &egui::Context) { + let Some(ed) = &app.text_box_editor else { return }; + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + app.cancel_text_box_edit(); + ctx.memory_mut(|m| m.surrender_focus(editor_id())); + return; + } + let outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| ed.rect.is_none_or(|r| !r.contains(p)))); + if outside { + match app.commit_text_box_edit() { + Ok(()) => ctx.memory_mut(|m| m.surrender_focus(editor_id())), + Err(e) => app.message = Some(("Text Box".into(), crate::clean_error(&e))), + } + } +} + +pub(crate) fn contains_pointer(app: &SheetApp, ctx: &egui::Context) -> bool { + app.text_box_editor.as_ref().and_then(|ed| ed.rect).is_some_and(|r| ctx.input(|i| i.pointer.hover_pos().is_some_and(|p| r.contains(p)))) +} + +pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcraft_engine::model::Sheet) { + let Some(mut ed) = app.text_box_editor.take() else { return }; + if app.view_key() != Some((ed.uid, ed.sheet)) { + app.text_box_editor = Some(ed); + return; + } + let Some(shape) = sh.shapes.iter().find(|s| s.id == ed.id && s.kind == ShapeKind::TextBox) else { + app.session.mode = Mode::Ready; + return; + }; + let save = ui.input_mut(|i| { + if i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::S) { + Some("file.saveAs") + } else if i.consume_key(Modifiers::COMMAND, Key::S) { + Some("file.save") + } else { + None + } + }); + let rect = crate::chartview::anchor_rect(geo, sh, &shape.anchor).shrink(4.0).intersect(geo.cells); + if !rect.is_positive() { + ed.rect = None; + app.text_box_editor = Some(ed); + if let Some(command) = save { + app.run_or_alert(command, json!({})); + } + return; + } + ed.rect = Some(rect); + let id = editor_id(); + if ed.request_focus { + // A new editing session must not reuse the previous box's caret/undo history. + egui::text_edit::TextEditState::default().store(ui.ctx(), id); + } + let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(rect)); + child.set_clip_rect(rect); + egui::ScrollArea::vertical().id_salt(id).max_height(rect.height()).auto_shrink([false, false]).show(&mut child, |ui| { + let response = ui.add( + TextEdit::multiline(&mut ed.text) + .id(id) + .font(FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into()))) + .text_color(Color32::BLACK) + .frame(egui::Frame::NONE) + .margin(egui::Margin::ZERO) + .desired_width(rect.width()) + .desired_rows(1) + .min_size(rect.size()) + .lock_focus(true), + ); + if ed.request_focus { + response.request_focus(); + ed.request_focus = false; + } + }); + app.text_box_editor = Some(ed); + if let Some(command) = save { + app.run_or_alert(command, json!({})); + } +} diff --git a/crates/ui-egui/tests/text_box_editing.rs b/crates/ui-egui/tests/text_box_editing.rs new file mode 100644 index 0000000..64e4ccb --- /dev/null +++ b/crates/ui-egui/tests/text_box_editing.rs @@ -0,0 +1,230 @@ +//! Text-box editing through real pointer and keyboard input, including document boundaries. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{Shape, Workbook}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const ORIGINAL: &str = "First line\nSecond line"; + +fn at(s: &str) -> CellRef { + CellRef::parse(s).unwrap() +} + +fn fixture() -> (Harness, u32) { + let mut session = Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "B2", "input": "cell stays"})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let id = session.execute("insert.textBox", json!({"at": "B2", "width": 240, "height": 120, "text": ORIGINAL})).unwrap()["shape"].as_u64().unwrap() + as u32; + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(1200.0, 800.0)) + .with_step_dt(1.0 / 60.0) + .with_os(egui::os::OperatingSystem::Mac) + .build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + (h, id) +} + +fn shape(h: &Harness, id: u32) -> &Shape { + h.state().session.doc().unwrap().wb.active().unwrap().shapes.iter().find(|s| s.id == id).unwrap() +} + +fn shape_text(wb: &Workbook, sheet: usize, id: u32) -> &str { + &wb.sheet(sheet).unwrap().shapes.iter().find(|s| s.id == id).unwrap().text +} + +fn shape_rect(h: &Harness, id: u32) -> Rect { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + let a = shape(h, id).anchor; + Rect::from_min_size(pos2(geo.x(sh, a.cell.col) + a.dx * geo.z, geo.y(sh, a.cell.row) + a.dy * geo.z), vec2(a.width, a.height) * geo.z) +} + +fn cell_center(h: &Harness, address: &str) -> Pos2 { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sh, at(address)).center() +} + +fn pointer(h: &mut Harness, p: Pos2, pressed: bool) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); +} + +fn click(h: &mut Harness, p: Pos2) { + pointer(h, p, true); + pointer(h, p, false); + h.run_steps(2); +} + +fn edit_box(h: &mut Harness, id: u32) { + // Separate this gesture from egui's 600 ms triple-click window as well. + h.run_steps(45); + let p = shape_rect(h, id).center(); + click(h, p); + click(h, p); +} + +fn key(h: &mut Harness, key: Key, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); +} + +fn event(h: &mut Harness, event: Event) { + h.input_mut().events.push(event); + h.run_steps(2); +} + +fn replace(h: &mut Harness, text: &str) { + key(h, Key::A, Modifiers::COMMAND); + event(h, Event::Paste(text.into())); +} + +fn assert_cell_unchanged(h: &Harness) { + assert_eq!(h.state().session.doc().unwrap().wb.active().unwrap().value(at("B2")), Value::text("cell stays")); + assert!(h.state().editor.is_none(), "text-box input must not start a cell editor"); +} + +#[test] +fn double_click_edits_multiline_text_and_commits_on_outside_click() { + let (mut h, id) = fixture(); + edit_box(&mut h, id); + assert_eq!(h.state().selected_chart, Some(id)); + let anchor = shape(&h, id).anchor; + let start = shape_rect(&h, id).min + vec2(5.0, 10.0); + drag(&mut h, start, start + vec2(60.0, 0.0)); + assert_eq!(shape(&h, id).anchor, anchor, "dragging inside the editor selects text instead of moving the box"); + h.input_mut().events.push(Event::Copy); + h.step(); + assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if !text.is_empty()))); + key(&mut h, Key::A, Modifiers::COMMAND); + h.input_mut().events.push(Event::Copy); + h.step(); + assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if text == ORIGINAL))); + event(&mut h, Event::Cut); + event(&mut h, Event::Paste("New first line".into())); + key(&mut h, Key::Enter, Modifiers::NONE); + event(&mut h, Event::Text("New second line".into())); + assert_cell_unchanged(&h); + assert_eq!(h.state().session.doc().unwrap().selection.active, at("B2")); + + let outside = cell_center(&h, "J15"); + click(&mut h, outside); + assert_eq!(shape(&h, id).text, "New first line\nNew second line"); + assert_cell_unchanged(&h); +} + +fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + pointer(h, from, true); + // Cross egui's drag threshold while still inside the object/resize handle. + h.input_mut().events.push(Event::PointerMoved(from + vec2(5.0, 5.0))); + h.step(); + h.input_mut().events.push(Event::PointerMoved(to)); + h.step(); + pointer(h, to, false); + h.run_steps(2); +} + +#[test] +fn escape_discards_text_and_the_box_still_moves_and_resizes() { + let (mut h, id) = fixture(); + edit_box(&mut h, id); + replace(&mut h, "discard this edit"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(shape(&h, id).text, ORIGINAL); + assert_cell_unchanged(&h); + h.run_steps(30); + + let before = shape_rect(&h, id); + drag(&mut h, before.center(), before.center() + vec2(40.0, 30.0)); + let moved = shape_rect(&h, id); + assert!(moved.left() > before.left() && moved.top() > before.top(), "ordinary dragging still moves the box"); + assert!((moved.size() - before.size()).abs().max_elem() < 0.1); + + let handle = moved.right_bottom() - vec2(7.0, 7.0); + drag(&mut h, handle, handle + vec2(45.0, 25.0)); + let resized = shape_rect(&h, id); + assert!(resized.width() > moved.width() && resized.height() > moved.height(), "the corner still resizes the box"); + assert_eq!(shape(&h, id).text, ORIGINAL); + assert_cell_unchanged(&h); + + edit_box(&mut h, id); + replace(&mut h, "Offscreen draft"); + h.state_mut().view_mut().unwrap().scroll = vec2(900.0, 700.0); + h.run_steps(2); + assert_eq!(h.state().text_box_editor.as_ref().unwrap().text, "Offscreen draft"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(shape(&h, id).text, ORIGINAL); + assert_cell_unchanged(&h); +} + +#[test] +fn saving_and_switching_context_commit_to_the_original_text_box() { + let (mut h, id) = fixture(); + edit_box(&mut h, id); + replace(&mut h, "Saved draft\nSecond paragraph"); + h.state_mut().run("sheet.read", json!({})).unwrap(); + assert_eq!(shape(&h, id).text, ORIGINAL, "inspecting the sheet must not commit a draft"); + assert!(h.state().text_box_editor.is_some()); + let saved = h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let bytes = gridcraft_engine::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(); + let (reopened, _) = gridcraft_engine::io::open_bytes("edited.xlsx", &bytes).unwrap(); + assert_eq!(shape_text(&reopened, 0, id), "Saved draft\nSecond paragraph"); + assert_eq!(reopened.sheet(0).unwrap().value(at("B2")), Value::text("cell stays")); + + // Save and Save As must work while the editor still owns keyboard focus. + let downloaded = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = downloaded.clone(); + h.state_mut().services.download = Some(Box::new(move |_, bytes| *sink.lock().unwrap() = bytes.to_vec())); + for modifiers in [Modifiers::COMMAND, Modifiers::COMMAND | Modifiers::SHIFT] { + edit_box(&mut h, id); + replace(&mut h, "Saved from the text editor"); + key(&mut h, Key::S, modifiers); + let (saved, _) = gridcraft_engine::io::open_bytes("shortcut.xlsx", &downloaded.lock().unwrap()).unwrap(); + assert_eq!(shape_text(&saved, 0, id), "Saved from the text editor"); + downloaded.lock().unwrap().clear(); + } + + h.state_mut().run("home.insertSheet", json!({"name": "Other"})).unwrap(); + let other_id = h.state_mut().run("insert.textBox", json!({"at": "B2", "text": "Other sheet"})).unwrap()["shape"].as_u64().unwrap() as u32; + h.state_mut().run("sheet.activate", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + edit_box(&mut h, id); + replace(&mut h, "Original sheet only"); + h.state_mut().run("sheet.activate", json!({"sheet": 1})).unwrap(); + let wb = &h.state().session.doc().unwrap().wb; + assert_eq!(shape_text(wb, 0, id), "Original sheet only"); + assert_eq!(shape_text(wb, 1, other_id), "Other sheet"); + + h.state_mut().run("file.new", json!({})).unwrap(); + let other_doc_id = h.state_mut().run("insert.textBox", json!({"at": "B2", "text": "Other workbook"})).unwrap()["shape"].as_u64().unwrap() as u32; + h.state_mut().run("window.activate", json!({"index": 0})).unwrap(); + h.state_mut().run("sheet.activate", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + edit_box(&mut h, id); + replace(&mut h, "Original workbook only"); + h.state_mut().run("window.activate", json!({"index": 1})).unwrap(); + let docs = h.state().session.documents(); + assert_eq!(shape_text(&docs[0].wb, 0, id), "Original workbook only"); + assert_eq!(shape_text(&docs[1].wb, 0, other_doc_id), "Other workbook"); + assert_eq!(docs[0].wb.sheet(0).unwrap().value(at("B2")), Value::text("cell stays")); +} From da0bf32d8a4787c0abe3d40b4d4e7b55a58b87bb Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:09:09 -0400 Subject: [PATCH 06/19] #16: import ODS worksheet data without overwriting source files --- Cargo.lock | 1 + README.md | 3 +- apps/gridcraft-cli/src/main.rs | 8 + apps/gridcraft-web/src/web.rs | 2 +- apps/gridcraft/src/main.rs | 4 +- crates/engine/Cargo.toml | 3 + crates/engine/src/cmd/file.rs | 20 +- crates/engine/src/io.rs | 9 +- crates/engine/src/tests.rs | 70 ++++ crates/mcp/src/tools.rs | 2 +- crates/xlsx/src/lib.rs | 9 +- crates/xlsx/src/ods.rs | 652 +++++++++++++++++++++++++++++ crates/xlsx/src/read.rs | 2 +- crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/ods_tests.rs | 218 ++++++++++ docs/cli.md | 4 + docs/ods-import.md | 22 + 17 files changed, 1018 insertions(+), 12 deletions(-) create mode 100644 crates/xlsx/src/ods.rs create mode 100644 crates/xlsx/src/tests/ods_tests.rs create mode 100644 docs/ods-import.md diff --git a/Cargo.lock b/Cargo.lock index e3a4144..d742e69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1466,6 +1466,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] diff --git a/README.md b/README.md index f5aa689..c9d9738 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ - **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays), tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines, PivotTables and print settings round-trip. CSV and TSV too. + [ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas. - **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling, 500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY, structured table references, Excel's full number-format language, sort and AutoFilter, @@ -141,7 +142,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the |---|---| | L0 | `core` (addresses, values, errors, dates, input parsing) · `numfmt` (number format codes) | | L1 | `formula` (lexer, parser, printer, reference adjustment) · `functions` (worksheet function library) | -| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX and CSV) | +| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV and ODS data import) | | L3 | `chart` (toolkit-free chart layout and rendering) | | L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) | | L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) | diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 561162f..c97b1d6 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -147,6 +147,14 @@ impl Args { fn exec(s: &mut Session, id: &str, params: Value) -> Result { let r = s.execute(id, params).map_err(|e| e.to_string()); s.take_ui_requests(); + if id == "file.open" + && let Ok(value) = &r + && let Some(warnings) = value.get("warnings").and_then(Value::as_array) + { + for warning in warnings.iter().filter_map(Value::as_str) { + eprintln!("Warning: {warning}"); + } + } r } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index c06d890..4c5f5b8 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -98,7 +98,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let Some(file) = - rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]).pick_file().await + rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "ods", "csv", "tsv", "txt", "json"]).pick_file().await else { return; }; diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 6acaf51..4dd5913 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -96,8 +96,9 @@ fn services() -> Services { Services { pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "ods", "csv", "tsv", "txt", "json"]) .add_filter("Excel Workbook", &["xlsx", "xlsm"]) + .add_filter("OpenDocument Spreadsheet (data import)", &["ods"]) .add_filter("CSV", &["csv"]) .pick_file() .and_then(|p| p.to_str().map(str::to_string)) @@ -172,6 +173,7 @@ fn main() -> eframe::Result<()> { session.new_workbook(); } let mut app = SheetApp::new(session, services()); + app.after_engine(); // Show warnings from files opened on the command line. let control_port = control_port; load_prefs(&mut app); let mut viewport = egui::ViewportBuilder::default() diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 53e2350..b559b83 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,3 +23,6 @@ gridcraft-xlsx = { workspace = true } gridcraft-chart = { workspace = true } gridcraft-pdf = { workspace = true } image = { workspace = true } + +[dev-dependencies] +zip = { workspace = true } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 37a58dd..0ec5432 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -8,7 +8,7 @@ use crate::DocState; pub fn specs() -> Vec { vec![ cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, csv, tsv, txt, json)", always, open), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, ods data import, csv, tsv, txt, json)", always, open), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), @@ -47,8 +47,15 @@ fn open(s: &mut Session, p: &Json) -> Result { return ok(); }; let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?; - let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string(); - let path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true); + let imported = + crate::io::FileKind::from_path(&name) == Some(crate::io::FileKind::Ods) || gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Ods; + let title = if imported { + format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book")) + } else { + std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string() + }; + // An import has no save target: ordinary Save must ask for a new destination. + let path = str_param(p, "path").filter(|_| !imported).map(str::to_string); // Replace an untouched blank Book1 like Excel does. if s.documents().len() == 1 && s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty())) @@ -56,6 +63,13 @@ fn open(s: &mut Session, p: &Json) -> Result { s.close_document(0); } let i = s.add_document(DocState::new(wb, path, title)); + if !warnings.is_empty() { + let mut message = warnings.iter().take(5).cloned().collect::>().join("\n\n"); + if warnings.len() > 5 { + message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5)); + } + s.ui_requests.push(crate::UiRequest::Message(message)); + } let d = s.doc()?; Ok( json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::>(), "warnings": warnings}), diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index aef3681..f9a08e9 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,4 +1,4 @@ -//! Opening and saving files: XLSX (the native format), CSV/TSV, JSON (debug), HTML export. +//! Opening and saving files: XLSX, ODS data import, CSV/TSV, JSON (debug), HTML export. use std::fmt::Write as _; @@ -11,6 +11,7 @@ use crate::{EngineError, Result}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FileKind { Xlsx, + Ods, Csv, Tsv, Json, @@ -22,6 +23,7 @@ impl FileKind { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); Some(match ext.as_str() { "xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx, + "ods" => FileKind::Ods, "csv" => FileKind::Csv, "tsv" | "tab" | "txt" => FileKind::Tsv, "json" | "scjson" => FileKind::Json, @@ -36,6 +38,10 @@ impl FileKind { pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { let kind = FileKind::from_path(name); let sniffed = gridcraft_xlsx::sniff(bytes); + if sniffed == gridcraft_xlsx::Format::Ods || kind == Some(FileKind::Ods) { + let (wb, report) = gridcraft_xlsx::read_ods(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?; + return Ok((wb, report.warnings)); + } if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) { let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?; return Ok((wb, report.warnings)); @@ -81,6 +87,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { let sheet = wb.active_sheet; match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) { FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())), + FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()), FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()), FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..9f3b398 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -145,6 +145,76 @@ fn xlsx_roundtrip_through_engine() { assert_eq!(v(&s, "B3"), Value::Number(3.0)); } +fn ods_fixture() -> Vec { + use std::io::{Cursor, Write}; + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, data) in [ + ("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), + ( + "META-INF/manifest.xml", + r#""#, + ), + ( + "content.xml", + r#"Original data"#, + ), + ] { + zip.start_file(name, options).unwrap(); + zip.write_all(data.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +#[test] +fn ods_import_keeps_cached_values_and_reports_limits() { + let bytes = ods_fixture(); + assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods); + for name in ["source.ods", "source.xlsx"] { + let mut s = s(); + let r = s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(&bytes)})).unwrap(); + assert!(!r["warnings"].as_array().unwrap().is_empty()); + assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Message(_)))); + assert!(s.doc().unwrap().path.is_none()); + assert!(s.doc().unwrap().display_title().ends_with(".xlsx")); + assert_eq!(v(&s, "A1"), Value::Number(42.0)); + assert_eq!(v(&s, "B1"), Value::from("Original data")); + assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().formula.is_none()); + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "imported.xlsx", "base64": saved["base64"]})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(42.0)); + } +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn ods_import_never_reuses_source_as_save_target() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("gridcraft-ods-{}-{nonce}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let bytes = ods_fixture(); + for name in ["source.ods", "disguised.xlsx"] { + let path = dir.join(name); + std::fs::write(&path, &bytes).unwrap(); + let mut s = s(); + s.execute("file.open", json!({"path": path})).unwrap(); + s.take_ui_requests(); + s.execute("cell.set", json!({"cell": "A1", "input": "43"})).unwrap(); + s.execute("file.save", json!({})).unwrap(); + assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Dialog(name, _) if name == "saveAs"))); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert!(s.execute("file.saveAs", json!({"path": dir.join("source.ods")})).is_err()); + assert!(s.execute("file.saveBytes", json!({"format": "ods"})).is_err()); + let output = dir.join("converted.xlsx"); + s.execute("file.saveAs", json!({"path": output})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + s.execute("file.open", json!({"name": "converted.xlsx", "base64": crate::io::base64_encode(&std::fs::read(output).unwrap())})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(43.0)); + } + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn samples_build() { for (name, _) in crate::sample::SAMPLES { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 0fb6ecd..263196e 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec { tool( "open_workbook", "Open workbook", - "Open a workbook file (.xlsx, .xlsm, .csv, .tsv, .txt, .json) and make it active.", + "Open a workbook file (.xlsx, .xlsm, .ods, .csv, .tsv, .txt, .json) and make it active. ODS imports worksheet data and cached formula values only; check the returned warnings.", obj(json!({"path": string("File path")}), &["path"]), false, ), diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 7c72476..1d08957 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -1,4 +1,4 @@ -//! GridCraft file formats: XLSX (Office Open XML SpreadsheetML, transitional and strict) and CSV. +//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus ODS data import. //! //! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry //! points work on byte slices (no file system access), so the crate builds for WebAssembly. @@ -14,6 +14,7 @@ mod chart; mod csv; mod drawing; mod fmla; +mod ods; mod package; mod pivot; mod read; @@ -32,6 +33,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use csv::{read_csv, write_csv}; +pub use ods::read_ods; /// Errors from reading or writing files. #[derive(Debug, thiserror::Error)] @@ -92,14 +94,15 @@ impl Default for CsvOptions { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Format { Xlsx, + Ods, Csv, Unknown, } -/// Guesses the format from the first bytes: a zip package is XLSX, text is CSV. +/// Guesses the format from content: ODS mimetype, other ZIP packages as XLSX, text as CSV. pub fn sniff(bytes: &[u8]) -> Format { if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") || bytes.starts_with(b"PK\x07\x08") { - return Format::Xlsx; + return if ods::has_mimetype(bytes) { Format::Ods } else { Format::Xlsx }; } if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { return Format::Csv; diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs new file mode 100644 index 0000000..c41bec6 --- /dev/null +++ b/crates/xlsx/src/ods.rs @@ -0,0 +1,652 @@ +//! Data-only OpenDocument Spreadsheet import. Formula caches become constants; styles and +//! other ODF features are deliberately not translated. All fixtures are authored in Rust. +//! Format reference: ODF 1.3, part 3, sections 6.1 and 9.1: +//! . + +use std::io::{Cursor, Read}; +use std::sync::Arc; + +use gridcraft_core::date::{days_in_month, serial_from_ymd}; +use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; +use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook}; +use quick_xml::NsReader; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::name::ResolveResult; + +use crate::package::{MAX_ENTRIES, Package}; +use crate::{IoError, ReadReport}; + +const MIME: &[u8] = b"application/vnd.oasis.opendocument.spreadsheet"; +const OFFICE: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:office:1.0"; +const TABLE: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:table:1.0"; +const TEXT: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:text:1.0"; +const CALC_EXT: &[u8] = b"urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0"; +const MANIFEST: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"; +const MAX_CELLS: u64 = 1_000_000; +const MAX_MERGES: u64 = 100_000; +const MAX_CELL_TEXT: usize = 1024 * 1024; +const MAX_TEXT: u64 = 64 * 1024 * 1024; +const MAX_SHEETS: usize = 1024; + +/// Reads only the small, exact mimetype entry, including when the ZIP uses compression. +/// Looking at a filename or at arbitrary bytes inside the archive is not sufficient. +pub(crate) fn has_mimetype(bytes: &[u8]) -> bool { + let Ok(mut zip) = zip::ZipArchive::new(Cursor::new(bytes)) else { return false }; + if zip.len() > MAX_ENTRIES { + return false; + } + let Ok(mut file) = zip.by_name("mimetype") else { return false }; + if file.size() != MIME.len() as u64 { + return false; + } + let mut value = Vec::new(); + (&mut file).take(MIME.len() as u64 + 1).read_to_end(&mut value).is_ok() && value == MIME +} + +/// Imports ODS sheets, typed values, displayed text and merges. Cached formula results are +/// imported as constants, so editing their inputs does not recalculate the original formula. +/// Always inspect the returned warnings. Saving to ODS is not supported. +pub fn read_ods(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + let mut package = Package::open(bytes)?; + if !has_mimetype(bytes) { + return Err(IoError::Format("missing or invalid ODS mimetype".into())); + } + if let Some(manifest) = package.read("META-INF/manifest.xml")? { + check_manifest(&manifest)?; + } + let content = package.read("content.xml")?.ok_or_else(|| IoError::Format("ODS content.xml is missing".into()))?; + let mut import = Import::new(); + import.parse(&content)?; + if import.wb.sheets.is_empty() { + return Err(IoError::Format("ODS contains no worksheets".into())); + } + Ok((import.wb, import.report)) +} + +fn is_ns(ns: ResolveResult<'_>, expected: &[u8]) -> bool { + matches!(ns, ResolveResult::Bound(n) if n.as_ref() == expected) +} + +fn attr(reader: &NsReader<&[u8]>, e: &BytesStart<'_>, ns: &[u8], name: &[u8]) -> Result, IoError> { + let mut found = None; + for a in e.attributes() { + let a = a.map_err(|e| IoError::Xml(e.to_string()))?; + let (resolved, local) = reader.resolve_attribute(a.key); + if is_ns(resolved, ns) && local.as_ref() == name { + let value = a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?; + if value.len() > MAX_CELL_TEXT { + return Err(IoError::TooLarge("ODS attribute exceeds 1 MiB".into())); + } + found = Some(value.into_owned()); + } + } + Ok(found) +} + +fn positive(value: Option, default: u32, limit: u32) -> Result { + let n = match value { + Some(s) => s.parse::().map_err(|_| IoError::Format("invalid ODS repeat or span count".into()))?, + None => default, + }; + if n == 0 || n > limit { + return Err(IoError::TooLarge("ODS repeat or span exceeds worksheet bounds".into())); + } + Ok(n) +} + +fn xml_error(e: impl std::fmt::Display) -> IoError { + IoError::Xml(format!("ODS: {e}")) +} + +fn check_manifest(bytes: &[u8]) -> Result<(), IoError> { + let mut reader = NsReader::from_reader(bytes); + let mut depth = 0usize; + let mut root_seen = false; + loop { + let event = reader.read_event().map_err(xml_error)?; + match &event { + Event::Start(e) | Event::Empty(e) => { + let (ns, name) = reader.resolve_element(e.name()); + if is_ns(ns.clone(), MANIFEST) && name.as_ref() == b"encryption-data" { + return Err(IoError::Format("encrypted ODS files are not supported".into())); + } + if depth == 0 { + if root_seen || !is_ns(ns, MANIFEST) || name.as_ref() != b"manifest" { + return Err(xml_error("expected one manifest:manifest root")); + } + root_seen = true; + } + if matches!(event, Event::Start(_)) { + depth += 1; + if depth > crate::xml::MAX_DEPTH { + return Err(xml_error("manifest nested too deeply")); + } + } + } + Event::End(_) => depth = depth.checked_sub(1).ok_or_else(|| xml_error("unexpected manifest closing element"))?, + Event::DocType(_) => return Err(xml_error("DOCTYPE is not supported")), + Event::Eof => break, + _ => {} + } + } + if !root_seen || depth != 0 { + return Err(xml_error("incomplete manifest")); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tag { + Document, + Body, + Spreadsheet, + Table, + Rows, + Row, + Cell, + Covered, + Paragraph, + Inline, + Space, + Tab, + Break, + Other, +} + +fn tag(reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Tag { + let (ns, name) = reader.resolve_element(e.name()); + let local = name.as_ref(); + if is_ns(ns.clone(), OFFICE) { + return match local { + b"document-content" => Tag::Document, + b"body" => Tag::Body, + b"spreadsheet" => Tag::Spreadsheet, + _ => Tag::Other, + }; + } + if is_ns(ns.clone(), TABLE) { + return match local { + b"table" => Tag::Table, + b"table-row-group" | b"table-header-rows" | b"table-rows" => Tag::Rows, + b"table-row" => Tag::Row, + b"table-cell" => Tag::Cell, + b"covered-table-cell" => Tag::Covered, + _ => Tag::Other, + }; + } + if is_ns(ns, TEXT) { + return match local { + b"p" | b"h" => Tag::Paragraph, + b"span" | b"a" | b"meta" | b"meta-field" | b"ruby" | b"ruby-base" => Tag::Inline, + b"s" => Tag::Space, + b"tab" => Tag::Tab, + b"line-break" => Tag::Break, + _ => Tag::Other, + }; + } + Tag::Other +} + +struct SheetDraft { + sheet: Sheet, + depth: usize, + row: u32, +} +struct RowDraft { + depth: usize, + repeat: u32, + col: u32, + cells: Vec, +} +struct RepeatedCell { + col: u32, + repeat: u32, + width: u32, + height: u32, + cell: Cell, +} +struct CellDraft { + depth: usize, + repeat: u32, + width: u32, + height: u32, + covered: bool, + kind: Option, + raw: Option, + formula: bool, + text: String, + paragraph: Option, + paragraphs: usize, + paragraph_started: bool, + pending_space: bool, +} +struct Import { + wb: Workbook, + report: ReadReport, + stack: Vec, + sheet: Option, + row: Option, + cell: Option, + cells: u64, + merges: u64, + text: u64, + formats: Vec<(&'static str, StyleId)>, + spreadsheet_seen: bool, +} + +impl Import { + fn new() -> Self { + let mut wb = Workbook::new(); + wb.sheets.clear(); + let mut report = ReadReport::default(); + report.warn("ODS data-only import: formulas become their saved results and will not recalculate. Source formatting, charts, images and other features are not imported. Save a new .xlsx file; ODS saving is not supported."); + Self { wb, report, stack: vec![], sheet: None, row: None, cell: None, cells: 0, merges: 0, text: 0, formats: vec![], spreadsheet_seen: false } + } + + fn parse(&mut self, bytes: &[u8]) -> Result<(), IoError> { + let mut reader = NsReader::from_reader(bytes); + let mut root_seen = false; + loop { + match reader.read_event().map_err(xml_error)? { + Event::Start(e) => { + if self.stack.len() >= crate::xml::MAX_DEPTH { + return Err(xml_error("XML nested too deeply")); + } + if self.stack.is_empty() { + if root_seen || tag(&reader, &e) != Tag::Document { + return Err(xml_error("expected one office:document-content root")); + } + root_seen = true; + } + self.start(&reader, &e)?; + } + Event::Empty(e) => { + if self.stack.is_empty() { + return Err(xml_error("expected office:document-content with a spreadsheet")); + } + self.start(&reader, &e)?; + self.end()?; + } + Event::End(_) => self.end()?, + Event::Text(t) => { + let text = t.xml_content().map_err(xml_error)?; + if self.stack.is_empty() && !text.trim().is_empty() { + return Err(xml_error("text outside the document")); + } + self.append(&text)?; + } + Event::CData(t) => self.append(&t.decode().map_err(xml_error)?)?, + Event::GeneralRef(r) => { + let ch = match r.resolve_char_ref().map_err(xml_error)? { + Some(c) => c, + None => match r.as_ref() { + b"amp" => '&', + b"lt" => '<', + b"gt" => '>', + b"apos" => '\'', + b"quot" => '"', + _ => return Err(xml_error("unsupported XML entity")), + }, + }; + self.append(ch.encode_utf8(&mut [0; 4]))?; + } + Event::DocType(_) => return Err(xml_error("DOCTYPE is not supported")), + Event::Eof => break, + _ => {} + } + } + if !root_seen || !self.stack.is_empty() || !self.spreadsheet_seen { + return Err(xml_error("incomplete ODS document")); + } + Ok(()) + } + + fn start(&mut self, reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Result<(), IoError> { + let t = tag(reader, e); + let depth = self.stack.len(); + match t { + Tag::Spreadsheet if self.stack == [Tag::Document, Tag::Body] => { + if self.spreadsheet_seen { + return Err(xml_error("multiple spreadsheets in one document")); + } + self.spreadsheet_seen = true; + } + Tag::Table if self.stack == [Tag::Document, Tag::Body, Tag::Spreadsheet] => { + if self.wb.sheets.len() >= MAX_SHEETS { + return Err(IoError::TooLarge("ODS contains more than 1024 sheets".into())); + } + let original = attr(reader, e, TABLE, b"name")?.unwrap_or_default(); + let mut name = crate::read::unique_name(&self.wb, original.trim_matches('\''), self.wb.sheets.len()); + if self.wb.check_sheet_name(&name, None).is_err() { + name = self.wb.next_sheet_name(); + } + if name != original { + self.report.warn("Some ODS sheet names were shortened or renamed to valid, unique XLSX sheet names."); + } + self.sheet = Some(SheetDraft { sheet: Sheet::new(name), depth, row: 0 }); + } + Tag::Row if self.sheet.as_ref().is_some_and(|s| self.stack.get(s.depth + 1..).is_some_and(|p| p.iter().all(|t| *t == Tag::Rows))) => { + self.row = + Some(RowDraft { depth, repeat: positive(attr(reader, e, TABLE, b"number-rows-repeated")?, 1, MAX_ROWS)?, col: 0, cells: vec![] }); + } + Tag::Cell | Tag::Covered if self.row.as_ref().is_some_and(|r| depth == r.depth + 1) => { + let kind = if attr(reader, e, CALC_EXT, b"value-type")?.as_deref() == Some("error") { + Some("error".into()) + } else { + attr(reader, e, OFFICE, b"value-type")? + }; + let key: &[u8] = match kind.as_deref() { + Some("boolean") => b"boolean-value", + Some("date") => b"date-value", + Some("time") => b"time-value", + Some("string") => b"string-value", + _ => b"value", + }; + self.cell = Some(CellDraft { + depth, + repeat: positive(attr(reader, e, TABLE, b"number-columns-repeated")?, 1, MAX_COLS)?, + width: positive(attr(reader, e, TABLE, b"number-columns-spanned")?, 1, MAX_COLS)?, + height: positive(attr(reader, e, TABLE, b"number-rows-spanned")?, 1, MAX_ROWS)?, + covered: t == Tag::Covered, + kind, + raw: attr(reader, e, OFFICE, key)?, + formula: attr(reader, e, TABLE, b"formula")?.is_some(), + text: String::new(), + paragraph: None, + paragraphs: 0, + paragraph_started: false, + pending_space: false, + }); + } + Tag::Paragraph if self.cell.as_ref().is_some_and(|c| depth == c.depth + 1) => { + if let Some(cell) = self.cell.as_mut() { + if cell.paragraphs > 0 { + push_text(&mut cell.text, "\n")?; + } + cell.paragraph = Some(depth); + cell.paragraphs += 1; + cell.paragraph_started = false; + cell.pending_space = false; + } + } + Tag::Space | Tag::Tab | Tag::Break if self.captures_text() => { + let count = if t == Tag::Space { + let n = attr(reader, e, TEXT, b"c")?.map(|s| s.parse::()).transpose().map_err(xml_error)?.unwrap_or(1); + if n as usize > MAX_CELL_TEXT { + return Err(IoError::TooLarge("ODS repeated spaces exceed 1 MiB".into())); + } + n + } else { + 1 + }; + let value = match t { + Tag::Space => " ", + Tag::Tab => "\t", + _ => "\n", + }; + if let Some(cell) = self.cell.as_mut() { + if cell.pending_space { + push_text(&mut cell.text, " ")?; + cell.pending_space = false; + } + cell.paragraph_started = true; + if cell.text.len().saturating_add(count as usize) > MAX_CELL_TEXT { + return Err(IoError::TooLarge("ODS cell text exceeds 1 MiB".into())); + } + for _ in 0..count { + cell.text.push_str(value); + } + } + } + _ => {} + } + self.stack.push(t); + Ok(()) + } + + fn captures_text(&self) -> bool { + self.cell + .as_ref() + .and_then(|c| c.paragraph) + .is_some_and(|depth| self.stack.get(depth..).is_some_and(|p| p.iter().all(|t| matches!(t, Tag::Paragraph | Tag::Inline)))) + } + + fn append(&mut self, text: &str) -> Result<(), IoError> { + if self.captures_text() + && let Some(cell) = self.cell.as_mut() + { + // ODF 1.3 section 6.1.2: collapse XML whitespace across inline runs, + // but expand explicit text:s/tab/line-break elements separately. + for ch in text.chars() { + if matches!(ch, ' ' | '\t' | '\n' | '\r') { + cell.pending_space = cell.paragraph_started; + } else { + if cell.pending_space { + push_text(&mut cell.text, " ")?; + cell.pending_space = false; + } + push_text(&mut cell.text, ch.encode_utf8(&mut [0; 4]))?; + cell.paragraph_started = true; + } + } + } + Ok(()) + } + + fn end(&mut self) -> Result<(), IoError> { + self.stack.pop().ok_or_else(|| xml_error("unexpected closing element"))?; + let depth = self.stack.len(); + if let Some(cell) = self.cell.as_mut() + && cell.paragraph == Some(depth) + { + cell.paragraph = None; + } + if self.cell.as_ref().is_some_and(|c| c.depth == depth) { + self.finish_cell()?; + } + if self.row.as_ref().is_some_and(|r| r.depth == depth) { + self.finish_row()?; + } + if self.sheet.as_ref().is_some_and(|s| s.depth == depth) + && let Some(sheet) = self.sheet.take() + { + self.wb.sheets.push(Arc::new(sheet.sheet)); + } + Ok(()) + } + + fn finish_cell(&mut self) -> Result<(), IoError> { + let Some(draft) = self.cell.take() else { return Ok(()) }; + let cell = self.convert(&draft); + let (width, height) = if draft.covered { (1, 1) } else { (draft.width, draft.height) }; + let Some(row) = self.row.as_mut() else { return Ok(()) }; + let end = row + .col + .checked_add(draft.repeat) + .filter(|n| *n <= MAX_COLS) + .ok_or_else(|| IoError::TooLarge("ODS row exceeds worksheet width".into()))?; + if !cell.is_blank() || width > 1 || height > 1 { + row.cells.push(RepeatedCell { col: row.col, repeat: draft.repeat, width, height, cell }); + } + row.col = end; + Ok(()) + } + + fn convert(&mut self, draft: &CellDraft) -> Cell { + let raw = draft.raw.as_deref(); + let mut format = None; + let value = match draft.kind.as_deref() { + Some("float" | "percentage" | "currency") => raw.and_then(|s| s.parse::().ok()).filter(|n| n.is_finite()).map(|n| { + if draft.kind.as_deref() == Some("percentage") { + format = Some("0.00%"); + } + Value::Number(n) + }), + Some("boolean") => match raw { + Some("true" | "1") => Some(Value::Bool(true)), + Some("false" | "0") => Some(Value::Bool(false)), + _ => None, + }, + Some("string") if raw.is_some() || draft.paragraphs > 0 || !draft.formula => Some(Value::Text(Arc::from(raw.unwrap_or(&draft.text)))), + Some("void") if !draft.formula => Some(Value::Empty), + Some("date") => raw.and_then(parse_date).map(|(n, f)| { + format = Some(f); + Value::Number(n) + }), + Some("time") => raw.and_then(parse_duration).map(|n| { + format = Some("[h]:mm:ss.000"); + Value::Number(n) + }), + None if !draft.formula => Some(if draft.text.is_empty() { Value::Empty } else { Value::Text(Arc::from(draft.text.as_str())) }), + _ => None, + }; + let value = value.unwrap_or_else(|| { + self.report.warn("Some ODS values or formula caches were unsupported or missing; their displayed text (or raw value) was kept as text. A formula without either is marked [Unavailable ODS formula result]."); + let text = if !draft.text.is_empty() { draft.text.as_str() } else if let Some(raw) = raw { raw } else if draft.formula { "[Unavailable ODS formula result]" } else { "[Unsupported ODS value]" }; + Value::Text(Arc::from(text)) + }); + let style = match format { + Some(code) => match self.formats.iter().find(|(f, _)| *f == code) { + Some((_, id)) => *id, + None => { + let id = self.wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code)); + self.formats.push((code, id)); + id + } + }, + None => StyleId::DEFAULT, + }; + Cell { value, formula: None, style } + } + + fn finish_row(&mut self) -> Result<(), IoError> { + let Some(row) = self.row.take() else { return Ok(()) }; + let Some(sheet) = self.sheet.as_mut() else { return Ok(()) }; + let end = + sheet.row.checked_add(row.repeat).filter(|n| *n <= MAX_ROWS).ok_or_else(|| IoError::TooLarge("ODS exceeds worksheet height".into()))?; + for entry in row.cells { + let count = u64::from(entry.repeat) * u64::from(row.repeat); + if !entry.cell.is_blank() { + self.cells = self.cells.saturating_add(count); + if let Value::Text(t) = &entry.cell.value { + self.text = self.text.saturating_add(count.saturating_mul(t.len() as u64)); + } + } + let merged = entry.width > 1 || entry.height > 1; + if merged { + self.merges = self.merges.saturating_add(count); + } + if self.cells > MAX_CELLS || self.merges > MAX_MERGES || self.text > MAX_TEXT { + return Err(IoError::TooLarge("ODS expands beyond the import limit (1 million cells, 100000 merges or 64 MiB of text)".into())); + } + let last_col = entry.col.checked_add(entry.repeat - 1).and_then(|c| c.checked_add(entry.width - 1)); + let last_row = end.checked_add(entry.height - 1); + if last_col.is_none_or(|c| c >= MAX_COLS) || last_row.is_none_or(|r| r > MAX_ROWS) { + return Err(IoError::TooLarge("ODS merged cell exceeds worksheet bounds".into())); + } + if merged && ((entry.repeat > 1 && entry.width > 1) || (row.repeat > 1 && entry.height > 1)) { + return Err(IoError::Format("repeated ODS cells have overlapping merges".into())); + } + for r in sheet.row..end { + for c in entry.col..entry.col + entry.repeat { + let pos = CellRef::new(r, c); + if !entry.cell.is_blank() { + sheet.sheet.cells.set(pos, entry.cell.clone()); + } + if merged { + sheet.sheet.merges.push(RangeRef::new(pos, CellRef::new(r + entry.height - 1, c + entry.width - 1))); + } + } + } + } + sheet.row = end; + Ok(()) + } +} + +fn push_text(out: &mut String, text: &str) -> Result<(), IoError> { + if out.len().saturating_add(text.len()) > MAX_CELL_TEXT { + return Err(IoError::TooLarge("ODS cell text exceeds 1 MiB".into())); + } + out.push_str(text); + Ok(()) +} + +/// Support unzoned ISO calendar dates and times. A timezone, invalid date or out-of-range +/// calendar year remains text; never silently discard an offset or roll an invalid date. +fn parse_date(s: &str) -> Option<(f64, &'static str)> { + let (date, time) = s.split_once('T').map_or((s, None), |(d, t)| (d, Some(t))); + if date.len() != 10 || date.get(4..5)? != "-" || date.get(7..8)? != "-" { + return None; + } + let year: i32 = digits(date.get(..4)?)?.parse().ok()?; + let month: u32 = digits(date.get(5..7)?)?.parse().ok()?; + let day: u32 = digits(date.get(8..10)?)?.parse().ok()?; + if !(1900..=9999).contains(&year) || !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) { + return None; + } + let day = serial_from_ymd(DateSystem::D1900, year.into(), month.into(), day.into())?; + let Some(time) = time else { return Some((day, "yyyy-mm-dd")) }; + let mut parts = time.split(':'); + let h = parts.next()?; + let m = parts.next()?; + let sec = parts.next()?; + if parts.next().is_some() || h.len() != 2 || m.len() != 2 || sec.split('.').next()?.len() != 2 { + return None; + } + let h: u32 = digits(h)?.parse().ok()?; + let m: u32 = digits(m)?.parse().ok()?; + let sec = decimal(sec)?; + if h >= 24 || m >= 60 || sec >= 60.0 { + return None; + } + Some((day + (h as f64 * 3600.0 + m as f64 * 60.0 + sec) / 86400.0, "yyyy-mm-dd hh:mm:ss.000")) +} + +fn digits(s: &str) -> Option<&str> { + (!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())).then_some(s) +} +fn decimal(s: &str) -> Option { + match s.split_once('.') { + Some((a, b)) => { + digits(a)?; + digits(b)?; + } + None => { + digits(s)?; + } + } + s.parse::().ok().filter(|n| n.is_finite()) +} + +/// A bounded ISO day/time duration. Calendar years/months cannot be represented as days. +fn parse_duration(s: &str) -> Option { + let (negative, s) = s.strip_prefix('-').map_or((false, s), |s| (true, s)); + let mut rest = s.strip_prefix('P')?; + let mut total = 0.0; + let mut any = false; + if let Some((days, tail)) = rest.split_once('D') { + total += digits(days)?.parse::().ok()? * 86400.0; + rest = tail; + any = true; + } + if let Some(mut time) = rest.strip_prefix('T') { + let mut time_any = false; + for (unit, factor) in [('H', 3600.0), ('M', 60.0), ('S', 1.0)] { + if let Some((n, tail)) = time.split_once(unit) { + let value = if unit == 'S' { decimal(n)? } else { digits(n)?.parse::().ok()? }; + total += value * factor; + time = tail; + time_any = true; + } + } + if !time.is_empty() || !time_any { + return None; + } + any = true; + } else if !rest.is_empty() { + return None; + } + let days = total / 86400.0; + (any && days.is_finite() && days <= 2_958_465.0).then_some(if negative { -days } else { days }) +} diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 915d8e4..be38442 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -283,7 +283,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { Ok((wb, cx.report)) } -fn unique_name(wb: &Workbook, name: &str, i: usize) -> String { +pub(crate) fn unique_name(wb: &Workbook, name: &str, i: usize) -> String { let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); if base.trim().is_empty() { base = format!("Sheet{}", i + 1); diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 46bd997..2bad0c5 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -3,6 +3,7 @@ mod csv_tests; mod fixtures; mod malformed; +mod ods_tests; mod pivot_tests; mod roundtrip; diff --git a/crates/xlsx/src/tests/ods_tests.rs b/crates/xlsx/src/tests/ods_tests.rs new file mode 100644 index 0000000..69f92c2 --- /dev/null +++ b/crates/xlsx/src/tests/ods_tests.rs @@ -0,0 +1,218 @@ +//! Original ODS packages assembled in Rust, not files from another spreadsheet application. + +use gridcraft_core::{CellRef, RangeRef, Value}; + +use super::make_zip; +use crate::{Format, IoError, read_ods, read_xlsx, sniff, write_xlsx}; + +const MIME: &[u8] = b"application/vnd.oasis.opendocument.spreadsheet"; +const MANIFEST: &[u8] = br#""#; + +fn content(tables: &str) -> String { + format!( + r#"{tables}"# + ) +} +fn ods(tables: &str) -> Vec { + make_zip(&[("mimetype", MIME), ("META-INF/manifest.xml", MANIFEST), ("content.xml", content(tables).as_bytes())]) +} +fn row(cells: &str) -> Vec { + ods(&format!(r#"{cells}"#)) +} +fn text(s: &str) -> Value { + Value::Text(s.into()) +} + +#[test] +fn ods_typed_values_and_formula_caches_are_constants_and_survive_xlsx() { + let bytes = row(r#" + + + + + + 85 + #DIV/0! + + unavailable + + "#); + let (wb, report) = read_ods(&bytes).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("data-only") && w.contains("will not recalculate"))); + assert!(report.warnings.iter().any(|w| w.contains("unsupported or missing"))); + let expected = [ + Value::Number(42.5), + Value::Bool(true), + Value::Number(0.25), + Value::Number(19.99), + text("literal =1+1"), + Value::Number(85.0), + text("#DIV/0!"), + text("[Unavailable ODS formula result]"), + text("unavailable"), + Value::Bool(false), + ]; + for (c, value) in expected.iter().enumerate() { + assert_eq!(&wb.sheets[0].value(CellRef::new(0, c as u32)), value); + assert!(wb.sheets[0].cell(CellRef::new(0, c as u32)).unwrap().formula.is_none()); + } + let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + for (c, value) in expected.iter().enumerate() { + assert_eq!(&reopened.sheets[0].value(CellRef::new(0, c as u32)), value); + } +} + +#[test] +fn ods_ordered_text_preserves_paragraphs_runs_entities_spaces_tabs_and_breaks() { + let bytes = row( + r#"one two & three fourfivesixseven!]]>ninenot cell text"#, + ); + let (wb, _) = read_ods(&bytes).unwrap(); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("one two & three four five\tsix\nseven!\nnine")); + let bytes = row(r#" one + two three four five "#); + assert_eq!(read_ods(&bytes).unwrap().0.sheets[0].value(CellRef::new(0, 0)), text("one two three four \n five")); +} + +#[test] +fn ods_repeats_merges_sheet_names_and_empty_tail_remain_sparse() { + let bytes = ods(r#" + + merged + + + + + + "#); + let (wb, _) = read_ods(&bytes).unwrap(); + assert_eq!(wb.sheets.len(), 2); + assert_eq!(wb.sheets[0].name, "First & Second"); + assert_eq!(wb.sheets[0].cells.len(), 8); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 1)), Value::Number(99.0)); + assert_eq!(wb.sheets[0].merges, vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(1, 1))]); + assert_eq!(wb.sheets[0].value(CellRef::new(3, 2)), Value::Number(7.0)); + assert_eq!(wb.sheets[1].value(CellRef::new(0, 0)), text("second sheet")); +} + +#[test] +fn ods_dates_and_durations_use_formats_and_unsupported_values_stay_text() { + let bytes = row(r#" + + + + + + + + + "#); + let (wb, report) = read_ods(&bytes).unwrap(); + let s = &wb.sheets[0]; + assert_eq!(s.value(CellRef::new(0, 0)), Value::Number(59.0)); + assert_eq!(s.value(CellRef::new(0, 1)), Value::Number(46302.0 + 66600.125 / 86400.0)); + assert_eq!(s.value(CellRef::new(0, 2)), Value::Number(1.0 + 7384.5 / 86400.0)); + assert_eq!(s.value(CellRef::new(0, 3)), Value::Number(-2.0 / 24.0)); + for (c, value) in [(4, "1900-02-29"), (5, "2026-10-07T18:00:00Z"), (6, "P1M"), (7, "PT2M1H")] { + assert_eq!(s.value(CellRef::new(0, c)), text(value)); + } + assert_eq!(wb.styles.get(s.cell(CellRef::new(0, 0)).unwrap().style).num_fmt.0.as_ref(), "yyyy-mm-dd"); + assert_eq!(wb.styles.get(s.cell(CellRef::new(0, 2)).unwrap().style).num_fmt.0.as_ref(), "[h]:mm:ss.000"); + assert_eq!(report.warnings.len(), 2); +} + +#[test] +fn ods_namespaces_are_resolved_not_inferred_from_prefix_or_local_name() { + let xml = content(r#""#) + .replace("kept"#); + assert_eq!(read_ods(&bytes).unwrap().0.sheets[0].value(CellRef::new(0, 0)), text("kept")); +} + +#[test] +fn ods_rejects_bad_packages_encryption_and_truncated_xml() { + assert!(read_ods(b"not zip").is_err()); + let xml = content(r#""#); + for mime in [b"application/zip".as_slice(), b"application/vnd.oasis.opendocument.spreadsheet "] { + assert!(read_ods(&make_zip(&[("mimetype", mime), ("content.xml", xml.as_bytes())])).is_err()); + } + let encrypted = + br#""#; + assert!( + read_ods(&make_zip(&[("mimetype", MIME), ("META-INF/manifest.xml", encrypted), ("content.xml", xml.as_bytes())])) + .unwrap_err() + .to_string() + .contains("encrypted") + ); + for bad in [ + xml.trim_end_matches("").to_owned(), + xml.replace("", ""), + "".to_owned(), + format!("{xml}{xml}"), + ] { + assert!(read_ods(&make_zip(&[("mimetype", MIME), ("content.xml", bad.as_bytes())])).is_err(), "{bad}"); + } + let deep = content(&format!("{}{}", "".repeat(300), "".repeat(300))); + assert!(read_ods(&make_zip(&[("mimetype", MIME), ("content.xml", deep.as_bytes())])).is_err()); +} + +#[test] +fn ods_expansion_bounds_stop_small_repeat_bombs() { + for cells in [ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ] { + assert!(read_ods(&row(cells)).is_err(), "{cells}"); + } + let cells = ods( + r#""#, + ); + assert!(matches!(read_ods(&cells), Err(IoError::TooLarge(_)))); + let text = format!( + r#""#, + "x".repeat(1024) + ); + assert!(matches!(read_ods(&ods(&text)), Err(IoError::TooLarge(_)))); +} + +#[test] +fn ods_sniff_requires_exact_bounded_mimetype() { + assert_eq!(sniff(&row("")), Format::Ods); + assert_eq!(sniff(&make_zip(&[("mimetype", b"application/zip")])), Format::Xlsx); + assert_eq!(sniff(&make_zip(&[("elsewhere", MIME)])), Format::Xlsx); + assert_eq!(sniff(&make_zip(&[("mimetype", &vec![b'x'; 1_000_000])])), Format::Xlsx); + assert_eq!(sniff(b"a,b\n1,2"), Format::Csv); +} + +#[test] +fn ods_invalid_sheet_names_are_made_safe_for_xlsx() { + let long = "A".repeat(50); + let bytes = ods(&format!( + r#""# + )); + let (wb, report) = read_ods(&bytes).unwrap(); + for (i, sheet) in wb.sheets.iter().enumerate() { + assert!(wb.check_sheet_name(&sheet.name, Some(i)).is_ok()); + } + assert!(report.warnings.iter().any(|w| w.contains("sheet names"))); + assert_eq!(read_xlsx(&write_xlsx(&wb).unwrap()).unwrap().0.sheets.len(), 4); +} + +#[test] +fn ods_extension_error_cache_never_imports_its_placeholder_zero() { + let bytes = row( + r#"#DIV/0!"#, + ); + let (wb, report) = read_ods(&bytes).unwrap(); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("#DIV/0!")); + assert!(report.warnings.iter().any(|w| w.contains("unsupported or missing"))); +} diff --git a/docs/cli.md b/docs/cli.md index 2cf5985..22ffaeb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,6 +3,10 @@ Headless GridCraft: inspect, convert, evaluate, script and serve MCP without a window. Errors go to stderr with a non-zero exit code. +`.ods` files support [worksheet data import](ods-import.md). Formula cells use their last saved +values; source formatting and other workbook features are omitted. Import warnings go to stderr. +Convert to `.xlsx` to save the imported workbook; ODS output is not supported. + ```sh cargo install --path apps/gridcraft-cli # or: cargo run -p gridcraft-cli -- ``` diff --git a/docs/ods-import.md b/docs/ods-import.md new file mode 100644 index 0000000..69d95cf --- /dev/null +++ b/docs/ods-import.md @@ -0,0 +1,22 @@ +# Importing OpenDocument spreadsheets + +Open an `.ods` file with File → Open, drag it onto GridCraft, or pass it to the CLI. GridCraft +imports worksheet data into a new workbook and shows a warning describing the conversion limits. + +The import reads sheets, numbers, text, booleans, supported dates/times, repeated cells/rows and +merged cells. Formula cells become their **last saved results**, not live formulas. These results +may be out of date if the source application did not recalculate before saving. Unsupported or +missing values retain available display text and produce a warning. + +This is data import, not full ODS compatibility. Source formatting, formulas, charts, images, +names and other workbook features are not preserved. GridCraft does not write ODS files. + +Save opens Save As and suggests a new `.xlsx` file. The source ODS file is not used as the +workbook's save destination; requests to save to `.ods` return an error. + +```sh +gridcraft-cli info source.ods +gridcraft-cli convert source.ods imported.xlsx +``` + +CLI import warnings go to stderr, leaving structured output on stdout available for scripts. From c51e51fe7bea9306633f3d0dbb80bee0c5596cd6 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:19:36 -0400 Subject: [PATCH 07/19] #92: import XLSB worksheet data and saved formula results --- Cargo.lock | 1 + README.md | 3 +- apps/gridcraft-cli/src/main.rs | 8 + apps/gridcraft-web/src/web.rs | 2 +- apps/gridcraft/src/main.rs | 6 +- crates/engine/Cargo.toml | 3 + crates/engine/src/cmd/file.rs | 20 +- crates/engine/src/io.rs | 9 +- crates/engine/src/tests.rs | 126 ++++++ crates/mcp/src/tools.rs | 2 +- crates/xlsx/src/lib.rs | 9 +- crates/xlsx/src/package.rs | 14 +- crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/xlsb_tests.rs | 306 +++++++++++++ crates/xlsx/src/xlsb.rs | 667 ++++++++++++++++++++++++++++ docs/cli.md | 4 + docs/xlsb-import.md | 22 + 17 files changed, 1187 insertions(+), 16 deletions(-) create mode 100644 crates/xlsx/src/tests/xlsb_tests.rs create mode 100644 crates/xlsx/src/xlsb.rs create mode 100644 docs/xlsb-import.md diff --git a/Cargo.lock b/Cargo.lock index e3a4144..d742e69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1466,6 +1466,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] diff --git a/README.md b/README.md index f5aa689..b27fbfb 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ - **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays), tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines, PivotTables and print settings round-trip. CSV and TSV too. + [XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas. - **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling, 500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY, structured table references, Excel's full number-format language, sort and AutoFilter, @@ -141,7 +142,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the |---|---| | L0 | `core` (addresses, values, errors, dates, input parsing) · `numfmt` (number format codes) | | L1 | `formula` (lexer, parser, printer, reference adjustment) · `functions` (worksheet function library) | -| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX and CSV) | +| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV and XLSB data import) | | L3 | `chart` (toolkit-free chart layout and rendering) | | L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) | | L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) | diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 561162f..c97b1d6 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -147,6 +147,14 @@ impl Args { fn exec(s: &mut Session, id: &str, params: Value) -> Result { let r = s.execute(id, params).map_err(|e| e.to_string()); s.take_ui_requests(); + if id == "file.open" + && let Ok(value) = &r + && let Some(warnings) = value.get("warnings").and_then(Value::as_array) + { + for warning in warnings.iter().filter_map(Value::as_str) { + eprintln!("Warning: {warning}"); + } + } r } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index c06d890..b9da27c 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -98,7 +98,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let Some(file) = - rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]).pick_file().await + rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "csv", "tsv", "txt", "json"]).pick_file().await else { return; }; diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 6acaf51..901a6c6 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -4,7 +4,7 @@ //! //! `--control ` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control //! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. -//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods. +//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methxlsb. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -96,8 +96,9 @@ fn services() -> Services { Services { pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "csv", "tsv", "txt", "json"]) .add_filter("Excel Workbook", &["xlsx", "xlsm"]) + .add_filter("Excel Binary Workbook (data import)", &["xlsb"]) .add_filter("CSV", &["csv"]) .pick_file() .and_then(|p| p.to_str().map(str::to_string)) @@ -172,6 +173,7 @@ fn main() -> eframe::Result<()> { session.new_workbook(); } let mut app = SheetApp::new(session, services()); + app.after_engine(); // Show warnings from files opened on the command line. let control_port = control_port; load_prefs(&mut app); let mut viewport = egui::ViewportBuilder::default() diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 53e2350..b559b83 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,3 +23,6 @@ gridcraft-xlsx = { workspace = true } gridcraft-chart = { workspace = true } gridcraft-pdf = { workspace = true } image = { workspace = true } + +[dev-dependencies] +zip = { workspace = true } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 37a58dd..a2448d1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -8,7 +8,7 @@ use crate::DocState; pub fn specs() -> Vec { vec![ cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, csv, tsv, txt, json)", always, open), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xlsb data import, csv, tsv, txt, json)", always, open), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), @@ -47,8 +47,15 @@ fn open(s: &mut Session, p: &Json) -> Result { return ok(); }; let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?; - let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string(); - let path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true); + let imported = + crate::io::FileKind::from_path(&name) == Some(crate::io::FileKind::Xlsb) || gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsb; + let title = if imported { + format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book")) + } else { + std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string() + }; + // An import has no save target: ordinary Save must ask for a new destination. + let path = str_param(p, "path").filter(|_| !imported).map(str::to_string); // Replace an untouched blank Book1 like Excel does. if s.documents().len() == 1 && s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty())) @@ -56,6 +63,13 @@ fn open(s: &mut Session, p: &Json) -> Result { s.close_document(0); } let i = s.add_document(DocState::new(wb, path, title)); + if !warnings.is_empty() { + let mut message = warnings.iter().take(5).cloned().collect::>().join("\n\n"); + if warnings.len() > 5 { + message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5)); + } + s.ui_requests.push(crate::UiRequest::Message(message)); + } let d = s.doc()?; Ok( json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::>(), "warnings": warnings}), diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index aef3681..da5d45c 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,4 +1,4 @@ -//! Opening and saving files: XLSX (the native format), CSV/TSV, JSON (debug), HTML export. +//! Opening and saving files: XLSX, XLSB data import, CSV/TSV, JSON (debug), HTML export. use std::fmt::Write as _; @@ -11,6 +11,7 @@ use crate::{EngineError, Result}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FileKind { Xlsx, + Xlsb, Csv, Tsv, Json, @@ -22,6 +23,7 @@ impl FileKind { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); Some(match ext.as_str() { "xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx, + "xlsb" => FileKind::Xlsb, "csv" => FileKind::Csv, "tsv" | "tab" | "txt" => FileKind::Tsv, "json" | "scjson" => FileKind::Json, @@ -36,6 +38,10 @@ impl FileKind { pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { let kind = FileKind::from_path(name); let sniffed = gridcraft_xlsx::sniff(bytes); + if sniffed == gridcraft_xlsx::Format::Xlsb || kind == Some(FileKind::Xlsb) { + let (wb, report) = gridcraft_xlsx::read_xlsb(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?; + return Ok((wb, report.warnings)); + } if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) { let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?; return Ok((wb, report.warnings)); @@ -81,6 +87,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { let sheet = wb.active_sheet; match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) { FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())), + FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()), FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()), FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..1c49414 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -153,6 +153,71 @@ fn samples_build() { } } +/// Original minimal BIFF12 records, built in source rather than a vendor workbook fixture. +fn xlsb_fixture() -> Vec { + use std::io::{Cursor, Write}; + fn wide(s: &str) -> Vec { + let units: Vec<_> = s.encode_utf16().collect(); + (units.len() as u32).to_le_bytes().into_iter().chain(units.into_iter().flat_map(u16::to_le_bytes)).collect() + } + fn record(out: &mut Vec, id: u16, payload: &[u8]) { + if id < 128 { + out.push(id as u8); + } else { + out.extend([(id as u8 & 127) | 128, (id >> 7) as u8]); + } + let mut size = payload.len(); + while size >= 128 { + out.push((size as u8 & 127) | 128); + size >>= 7; + } + out.push(size as u8); + out.extend(payload); + } + let mut book = vec![]; + record(&mut book, 131, &[]); // BeginBook + record(&mut book, 143, &[]); // BeginBundleShs + let mut bundle = vec![0; 4]; // Visible + bundle.extend(1u32.to_le_bytes()); + bundle.extend(wide("rId1")); + bundle.extend(wide("Imported")); + record(&mut book, 156, &bundle); + record(&mut book, 144, &[]); + record(&mut book, 132, &[]); + let mut sheet = vec![]; + record(&mut sheet, 129, &[]); // BeginSheet + record(&mut sheet, 145, &[]); // BeginSheetData + record(&mut sheet, 0, &[0; 17]); // Row 0, no spans + let mut numeric = vec![0; 8]; // A1, default style + numeric.extend(42.0f64.to_le_bytes()); + numeric.extend([0; 2]); // Formula flags + numeric.extend(3u32.to_le_bytes()); + numeric.extend([0x1e, 42, 0]); // PtgInt(42) + numeric.extend(0u32.to_le_bytes()); + record(&mut sheet, 9, &numeric); // FmlaNum + let mut text = 1u32.to_le_bytes().to_vec(); // B1 + text.extend(0u32.to_le_bytes()); + text.extend(wide("Original data")); + record(&mut sheet, 6, &text); + record(&mut sheet, 146, &[]); + record(&mut sheet, 130, &[]); + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let types = br#""#; + let rels = br#""#; + let book_rels = br#""#; + for (name, data) in [ + ("[Content_Types].xml", types.as_slice()), + ("_rels/.rels", rels.as_slice()), + ("xl/_rels/workbook.bin.rels", book_rels.as_slice()), + ("xl/workbook.bin", book.as_slice()), + ("xl/worksheets/sheet1.bin", sheet.as_slice()), + ] { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(data).unwrap(); + } + zip.finish().unwrap().into_inner() +} + #[test] fn every_command_survives_empty_params() { let mut s = Session::new(); @@ -190,3 +255,64 @@ fn ink_strokes_and_ink_to_shape() { assert_eq!(s.execute("draw.inkToShape", json!({})).unwrap()["kind"], "Line"); assert!(s.execute("draw.stroke", json!({"points": [[1.0, 1.0]]})).is_err()); } + +#[test] +fn xlsb_import_keeps_cached_values_and_reports_limits() { + let bytes = xlsb_fixture(); + assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xlsb); + for name in ["source.xlsb", "source.xlsx"] { + let mut s = s(); + let r = s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(&bytes)})).unwrap(); + assert!(!r["warnings"].as_array().unwrap().is_empty()); + assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Message(_)))); + assert!(s.doc().unwrap().path.is_none()); + assert!(s.doc().unwrap().display_title().ends_with(".xlsx")); + assert_eq!(v(&s, "A1"), Value::Number(42.0)); + assert_eq!(v(&s, "B1"), Value::from("Original data")); + assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().formula.is_none()); + s.execute("cell.set", json!({"cell": "B1", "input": "Changed"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(42.0)); // Imported formula caches are constants. + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "imported.xlsx", "base64": saved["base64"]})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(42.0)); + } +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn xlsb_import_never_reuses_source_as_save_target() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("gridcraft-xlsb-{}-{nonce}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let bytes = xlsb_fixture(); + for name in ["source.xlsb", "disguised.xlsx"] { + let path = dir.join(name); + std::fs::write(&path, &bytes).unwrap(); + let mut s = s(); + s.execute("file.open", json!({"path": path})).unwrap(); + s.take_ui_requests(); + s.execute("cell.set", json!({"cell": "A1", "input": "43"})).unwrap(); + s.execute("file.save", json!({})).unwrap(); + assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Dialog(name, _) if name == "saveAs"))); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert!(s.execute("file.saveAs", json!({"path": dir.join("source.xlsb")})).is_err()); + assert!(s.execute("file.saveBytes", json!({"format": "xlsb"})).is_err()); + let output = dir.join("converted.xlsx"); + s.execute("file.saveAs", json!({"path": output})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + s.execute("file.open", json!({"name": "converted.xlsx", "base64": crate::io::base64_encode(&std::fs::read(output).unwrap())})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(43.0)); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn malformed_xlsb_keeps_the_current_workbook() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "Keep me"})).unwrap(); + assert!(s.execute("file.open", json!({"name": "bad.xlsb", "base64": crate::io::base64_encode(b"not a workbook")})).is_err()); + assert_eq!(s.documents().len(), 1); + assert_eq!(v(&s, "A1"), Value::from("Keep me")); + assert!(s.doc().unwrap().is_dirty()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 0fb6ecd..3e2728b 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec { tool( "open_workbook", "Open workbook", - "Open a workbook file (.xlsx, .xlsm, .csv, .tsv, .txt, .json) and make it active.", + "Open a workbook file (.xlsx, .xlsm, .xlsb, .csv, .tsv, .txt, .json) and make it active. XLSB imports worksheet data and cached formula values only; check the returned warnings.", obj(json!({"path": string("File path")}), &["path"]), false, ), diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 7c72476..49bfbe7 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -1,4 +1,4 @@ -//! GridCraft file formats: XLSX (Office Open XML SpreadsheetML, transitional and strict) and CSV. +//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB data import. //! //! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry //! points work on byte slices (no file system access), so the crate builds for WebAssembly. @@ -23,6 +23,7 @@ mod styles; mod tables; mod theme; mod write; +mod xlsb; mod xml; #[cfg(test)] @@ -32,6 +33,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use csv::{read_csv, write_csv}; +pub use xlsb::read_xlsb; /// Errors from reading or writing files. #[derive(Debug, thiserror::Error)] @@ -92,14 +94,15 @@ impl Default for CsvOptions { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Format { Xlsx, + Xlsb, Csv, Unknown, } -/// Guesses the format from the first bytes: a zip package is XLSX, text is CSV. +/// Guesses XLSB from package content types, other ZIP packages as XLSX, and text as CSV. pub fn sniff(bytes: &[u8]) -> Format { if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") || bytes.starts_with(b"PK\x07\x08") { - return Format::Xlsx; + return if xlsb::is_xlsb(bytes) { Format::Xlsb } else { Format::Xlsx }; } if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { return Format::Csv; diff --git a/crates/xlsx/src/package.rs b/crates/xlsx/src/package.rs index e6cade6..9a28999 100644 --- a/crates/xlsx/src/package.rs +++ b/crates/xlsx/src/package.rs @@ -58,22 +58,28 @@ impl<'a> Package<'a> { /// Reads a part's bytes; `Ok(None)` when it doesn't exist. pub fn read(&mut self, part: &str) -> Result>, IoError> { + self.read_limited(part, MAX_PART) + } + + /// Reads a small metadata part without allowing it to expand to the general part limit. + pub fn read_limited(&mut self, part: &str, maximum: u64) -> Result>, IoError> { + let maximum = maximum.min(MAX_PART); let Some(&idx) = self.names.get(&normalize(part)) else { return Ok(None); }; let mut f = self.zip.by_index(idx).map_err(|e| IoError::Zip(format!("{part}: {e}")))?; - if f.size() > MAX_PART { + if f.size() > maximum { return Err(IoError::TooLarge(format!("{part} expands to {} bytes", f.size()))); } let remaining = MAX_TOTAL.saturating_sub(self.total); - let limit = MAX_PART.min(remaining); + let limit = maximum.min(remaining); let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize); (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| IoError::Zip(format!("{part}: {e}")))?; if out.len() as u64 > limit { - return Err(IoError::TooLarge(if limit < MAX_PART { + return Err(IoError::TooLarge(if remaining < maximum { "package expands to more than 1 GB".into() } else { - format!("{part} expands to more than 512 MB") + format!("{part} expands to more than {maximum} bytes") })); } self.total += out.len() as u64; diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 46bd997..9b47cc9 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -5,6 +5,7 @@ mod fixtures; mod malformed; mod pivot_tests; mod roundtrip; +mod xlsb_tests; use std::io::Write; diff --git a/crates/xlsx/src/tests/xlsb_tests.rs b/crates/xlsx/src/tests/xlsb_tests.rs new file mode 100644 index 0000000..7c4311e --- /dev/null +++ b/crates/xlsx/src/tests/xlsb_tests.rs @@ -0,0 +1,306 @@ +//! Original BIFF12 records and ZIP packages assembled in Rust from the public format schema. +use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value}; +use gridcraft_model::Visibility; + +use super::make_zip; +use crate::{Format, read_xlsb, read_xlsx, sniff, write_xlsx}; + +const ROOT: &str = r#""#; +const RELS: &str = r#""#; +const TYPES: &str = r#""#; + +fn record(id: u16, bytes: &[u8]) -> Vec { + let mut out = vec![]; + if id < 128 { + out.push(id as u8); + } else { + out.extend([(id as u8 & 0x7f) | 0x80, (id >> 7) as u8]); + } + let mut len = bytes.len(); + loop { + let byte = (len & 0x7f) as u8; + len >>= 7; + out.push(if len == 0 { byte } else { byte | 0x80 }); + if len == 0 { + break; + } + } + out.extend(bytes); + out +} +fn wide(text: &str) -> Vec { + let units: Vec<_> = text.encode_utf16().collect(); + let mut out = (units.len() as u32).to_le_bytes().to_vec(); + for unit in units { + out.extend(unit.to_le_bytes()); + } + out +} +fn rich(text: &str, extras: bool) -> Vec { + let mut out = vec![if extras { 3 } else { 0 }]; + out.extend(wide(text)); + if extras { + out.extend(1u32.to_le_bytes()); + out.extend([0, 0, 0, 0]); // One original formatting run. + out.extend(wide("sound")); + out.extend(1u32.to_le_bytes()); + out.extend([0, 0, 0, 0, 1, 0, 0, 0, 0, 0]); // One ten-byte phonetic run. + } + out +} +fn row(r: u32) -> Vec { + let mut out = r.to_le_bytes().to_vec(); + out.extend([0; 13]); + record(0, &out) +} +fn cell(id: u16, col: u32, value: &[u8]) -> Vec { + let mut data = col.to_le_bytes().to_vec(); + data.extend([0; 4]); + data.extend(value); + if (8..=11).contains(&id) { + data.extend([0; 2]); // Formula flags. + data.extend(3u32.to_le_bytes()); + data.extend([0x1e, 1, 0]); // Original PtgInt(1); ignored because we import the cache. + data.extend(0u32.to_le_bytes()); + } + record(id, &data) +} +fn worksheet(data: &[u8], tail: &[u8]) -> Vec { + let mut out = record(129, &[]); + out.extend(record(148, &[0xff; 16])); // A bogus dimension must never allocate a dense grid. + out.extend(record(145, &[])); + out.extend(data); + out.extend(record(146, &[])); + out.extend(tail); + out.extend(record(130, &[])); + out +} +fn book(date1904: bool) -> Vec { + let mut out = record(131, &[]); + let mut props = u32::from(date1904).to_le_bytes().to_vec(); + props.extend(0u32.to_le_bytes()); + props.extend(wide("")); + out.extend(record(153, &props)); + out.extend(record(143, &[])); + for (id, rel, name, state) in [(1u32, "one", "Data", 0u32), (2, "two", "Hidden", 1)] { + let mut data = state.to_le_bytes().to_vec(); + data.extend(id.to_le_bytes()); + data.extend(wide(rel)); + data.extend(wide(name)); + out.extend(record(156, &data)); + } + out.extend(record(144, &[])); + out.extend(record(132, &[])); + out +} +fn sst() -> Vec { + let mut out = record(159, &[1, 0, 0, 0, 1, 0, 0, 0]); + out.extend(record(19, &rich("shared 🦀 café", true))); + out.extend(record(160, &[])); + out +} +fn package(book: &[u8], sheet: &[u8], strings: &[u8], roots: &str, rels: &str, types: &str) -> Vec { + make_zip(&[ + ("[Content_Types].xml", types.as_bytes()), + ("_rels/.rels", roots.as_bytes()), + ("records/book.bin", book), + ("records/_rels/book.bin.rels", rels.as_bytes()), + ("data/one.bin", sheet), + ("data/two.bin", &worksheet(&[], &[])), + ("text/strings.bin", strings), + ]) +} +fn file(sheet: &[u8]) -> Vec { + package(&book(false), sheet, &sst(), ROOT, RELS, TYPES) +} +fn text(s: &str) -> Value { + Value::Text(s.into()) +} + +#[test] +fn xlsb_scalar_caches_rk_unicode_visibility_and_epochs_survive_xlsx_save() { + let mut data = row(2); + let real_rk = (3.5f64.to_bits() >> 32) as u32; + let encoded = [ + cell(1, 0, &[]), + cell(2, 1, &((-123i32 << 2) | 2).to_le_bytes()), + cell(2, 2, &((123i32 << 2) | 3).to_le_bytes()), + cell(2, 3, &real_rk.to_le_bytes()), + cell(2, 4, &(real_rk | 1).to_le_bytes()), + cell(3, 5, &[0x17]), + cell(4, 6, &[1]), + cell(5, 7, &123.5f64.to_le_bytes()), + cell(6, 8, &wide("inline λ")), + cell(7, 9, &0u32.to_le_bytes()), + cell(8, 10, &wide("cached text")), + cell(9, 11, &2.0f64.to_le_bytes()), + cell(10, 12, &[0]), + cell(11, 13, &[7]), + cell(62, 14, &rich("rich text", true)), + ]; + for cell in encoded { + data.extend(cell); + } + let mut merge = vec![]; + for n in [5u32, 6, 0, 1] { + merge.extend(n.to_le_bytes()); + } + let sheet = worksheet(&data, &record(176, &merge)); + let expected = [ + Value::Empty, + Value::Number(-123.0), + Value::Number(1.23), + Value::Number(3.5), + Value::Number(0.035), + Value::Error(CellError::Ref), + Value::Bool(true), + Value::Number(123.5), + text("inline λ"), + text("shared 🦀 café"), + text("cached text"), + Value::Number(2.0), + Value::Bool(false), + Value::Error(CellError::Div0), + text("rich text"), + ]; + for epoch in [false, true] { + let bytes = package(&book(epoch), &sheet, &sst(), ROOT, RELS, TYPES); + assert_eq!(sniff(&bytes), Format::Xlsb); + let (wb, report) = read_xlsb(&bytes).unwrap(); + assert_eq!(wb.date_system, if epoch { DateSystem::D1904 } else { DateSystem::D1900 }); + assert_eq!(wb.sheets.len(), 2); + assert_eq!(wb.sheets[1].visibility, Visibility::Hidden); + assert_eq!(wb.sheets[0].cells.len(), 14); + assert_eq!(wb.sheets[0].merges, vec![RangeRef::new(CellRef::new(5, 0), CellRef::new(6, 1))]); + assert!(report.warnings.iter().any(|w| w.contains("will not recalculate") && w.contains("may display as numbers"))); + for (col, expected) in expected.iter().enumerate() { + assert_eq!(&wb.sheets[0].value(CellRef::new(2, col as u32)), expected); + } + assert!(wb.sheets[0].cells.iter().all(|(_, c)| c.formula.is_none())); + let (roundtrip, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(roundtrip.date_system, wb.date_system); + for (col, expected) in expected.iter().enumerate() { + assert_eq!(&roundtrip.sheets[0].value(CellRef::new(2, col as u32)), expected); + } + } +} + +#[test] +fn xlsb_extension_blocks_are_skipped_with_strict_boundaries_and_record_framing() { + let mut data = row(0); + data.extend(cell(5, 0, &1.0f64.to_le_bytes())); + data.extend(record(35, &[0; 4])); + data.extend(record(37, &[1, 0, 0, 0, 0, 0])); + data.extend(cell(5, 1, &99.0f64.to_le_bytes())); + data.extend(record(130, &[])); // Looks like EndSheet, but is inside an ignored extension. + data.extend(record(38, &[])); + data.extend(record(36, &[])); + data.extend([0xe8, 0x87, 0x80, 0x80, 0x80, 0x80]); // ID1000: ignored high second-ID/fourth-length bits. + data.extend(cell(5, 1, &2.0f64.to_le_bytes())); + let (wb, report) = read_xlsb(&file(&worksheet(&data, &[]))).unwrap(); + assert_eq!(wb.sheets[0].cells.len(), 2); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 1)), Value::Number(2.0)); + assert!(report.warnings.iter().any(|w| w.contains("blocks were skipped"))); + for bad in [ + vec![0x81], + vec![0x81, 0], + vec![1, 0x80], + vec![1, 10, 0], + record(36, &[]), + [record(35, &[0; 4]), record(38, &[])].concat(), + record(35, &[0; 4]), + record(37, &[0, 0]), + ] { + assert!(read_xlsb(&file(&worksheet(&bad, &[]))).is_err(), "{bad:?}"); + } + let nested = record(35, &[0; 4]).repeat(65); + assert!(read_xlsb(&file(&worksheet(&nested, &[]))).unwrap_err().to_string().contains("nested too deeply")); + let mut missing_end = worksheet(&[], &[]); + missing_end.truncate(missing_end.len() - record(130, &[]).len()); + assert!(read_xlsb(&file(&missing_end)).is_err()); +} + +#[test] +fn xlsb_bad_values_strings_and_coordinates_fail_without_silent_data_loss() { + let malformed = [ + cell(4, 0, &[2]), + cell(3, 0, &[0xff]), + cell(5, 0, &f64::INFINITY.to_le_bytes()), + cell(2, 0, &0x7ff00000u32.to_le_bytes()), + cell(7, 0, &1u32.to_le_bytes()), + cell(6, 0, &[1, 0, 0, 0, 0, 0xd8]), + cell(6, 0, &u32::MAX.to_le_bytes()), + cell(5, 16384, &1.0f64.to_le_bytes()), + cell(5, 0, &[0; 3]), + cell(62, 0, &[1, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), + ]; + for bad in malformed { + assert!(read_xlsb(&file(&worksheet(&[row(0), bad].concat(), &[]))).is_err()); + } + assert!(read_xlsb(&file(&worksheet(&cell(5, 0, &1.0f64.to_le_bytes()), &[]))).is_err()); + assert!(read_xlsb(&file(&worksheet(&row(1048576), &[]))).is_err()); + let repeated = [row(0), cell(5, 0, &1.0f64.to_le_bytes()), cell(5, 0, &2.0f64.to_le_bytes())].concat(); + assert!(read_xlsb(&file(&worksheet(&repeated, &[]))).is_err()); + let mut formula_payload = [0u8; 18].to_vec(); // Cell header + f64 cache + flags, but no parsed-formula structure. + assert!(read_xlsb(&file(&worksheet(&[row(0), record(9, &formula_payload)].concat(), &[]))).is_err()); + formula_payload.extend(0u32.to_le_bytes()); // Invalid zero token count. + assert!(read_xlsb(&file(&worksheet(&[row(0), record(9, &formula_payload)].concat(), &[]))).is_err()); + let bad_sst = [record(159, &[1, 0, 0, 0, 2, 0, 0, 0]), record(160, &[])].concat(); + assert!(read_xlsb(&package(&book(false), &worksheet(&[], &[]), &bad_sst, ROOT, RELS, TYPES)).is_err()); +} + +#[test] +fn xlsb_requires_internal_typed_relationships_and_sniffs_only_root_workbook() { + let sheet = worksheet(&[], &[]); + for (roots, rels, types) in [ + (ROOT.replace("officeDocument\"", "wrong\""), RELS.to_owned(), TYPES.to_owned()), + (ROOT.replace("Target=", "TargetMode=\"External\" Target="), RELS.to_owned(), TYPES.to_owned()), + (ROOT.to_owned(), RELS.replace("Target=\"../data/one.bin\"", "Target=\"missing.bin\""), TYPES.to_owned()), + ( + ROOT.to_owned(), + RELS.replace("Target=\"../data/one.bin\"", "TargetMode=\"External\" Target=\"https://example.com/data.bin\""), + TYPES.to_owned(), + ), + ( + ROOT.to_owned(), + RELS.replace("Target=\"../text/strings.bin\"", "TargetMode=\"External\" Target=\"https://example.com/strings.bin\""), + TYPES.to_owned(), + ), + (ROOT.to_owned(), RELS.to_owned(), TYPES.replace("application/vnd.ms-excel.worksheet", "application/xml")), + (ROOT.to_owned(), RELS.trim_end_matches("").to_owned(), TYPES.to_owned()), + (ROOT.to_owned(), RELS.to_owned(), TYPES.replace("http://schemas.openxmlformats.org/package/2006/content-types", "wrong")), + ] { + assert!(read_xlsb(&package(&book(false), &sheet, &sst(), &roots, &rels, &types)).is_err()); + } + let chartsheet = RELS.replace( + "Id=\"two\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + "Id=\"two\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet", + ); + let (wb, report) = read_xlsb(&package(&book(false), &sheet, &sst(), ROOT, &chartsheet, TYPES)).unwrap(); + assert_eq!(wb.sheets.len(), 1); + assert!(report.warnings.iter().any(|w| w.contains("non-worksheet"))); + // An unrelated binary part must not change the format of an XML workbook package. + let xml_book = TYPES.replace( + "/records/book.bin\" ContentType=\"application/vnd.ms-excel.sheet.binary.macroEnabled.main", + "/elsewhere.bin\" ContentType=\"application/vnd.ms-excel.sheet.binary.macroEnabled.main", + ); + assert_eq!(sniff(&package(&book(false), &sheet, &sst(), ROOT, RELS, &xml_book)), Format::Xlsx); + let spec_type = TYPES.replace("application/vnd.ms-excel.sheet.binary.macroEnabled.main", "application/vnd.ms-excel.main"); + assert_eq!(sniff(&package(&book(false), &sheet, &sst(), ROOT, RELS, &spec_type)), Format::Xlsb); + assert!(read_xlsb(&package(&book(false), &sheet, &sst(), ROOT, RELS, &spec_type)).is_ok()); +} + +#[test] +fn xlsb_declared_counts_and_decoded_text_are_bounded() { + let bad_sst = record(159, &[0xff; 8]); + assert!(read_xlsb(&package(&book(false), &worksheet(&[], &[]), &bad_sst, ROOT, RELS, TYPES)).is_err()); + let mut count_mismatch = record(159, &[1, 0, 0, 0, 1, 0, 0, 0]); + count_mismatch.extend(record(160, &[])); + assert!(read_xlsb(&package(&book(false), &worksheet(&[], &[]), &count_mismatch, ROOT, RELS, TYPES)).is_err()); + let mut data = row(0); + let text = wide(&"x".repeat(32767)); + for col in 0..2050 { + data.extend(cell(6, col, &text)); + } + assert!(read_xlsb(&file(&worksheet(&data, &[]))).unwrap_err().to_string().contains("64 MiB")); +} diff --git a/crates/xlsx/src/xlsb.rs b/crates/xlsx/src/xlsb.rs new file mode 100644 index 0000000..5574e46 --- /dev/null +++ b/crates/xlsx/src/xlsb.rs @@ -0,0 +1,667 @@ +//! Data-only XLSB import, implemented from Microsoft's public MS-XLSB specification: +//! . +//! Formula caches become constants. Styles, macros and other workbook features are not run or imported. + +use std::sync::Arc; + +use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; +use gridcraft_model::{Cell, Sheet, Visibility, Workbook}; +use quick_xml::NsReader; +use quick_xml::events::Event; +use quick_xml::name::ResolveResult; + +use crate::package::{Package, Rel, normalize, resolve, split_dir}; +use crate::{IoError, ReadReport}; + +const MAIN: &str = "application/vnd.ms-excel.main"; +const BINARY_MAIN: &str = "application/vnd.ms-excel.sheet.binary.macroEnabled.main"; +const WORKSHEET: &str = "application/vnd.ms-excel.worksheet"; +const STRINGS: &str = "application/vnd.ms-excel.sharedStrings"; +const CT_NS: &[u8] = b"http://schemas.openxmlformats.org/package/2006/content-types"; +const REL_NS: &[u8] = b"http://schemas.openxmlformats.org/package/2006/relationships"; +const REL_TYPE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"; +const STRICT_REL_TYPE: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships/"; +const MS_REL_TYPE: &str = "http://schemas.microsoft.com/office/2006/relationships/"; +const MAX_META: u64 = 1024 * 1024; +const MAX_TEXT: usize = 64 * 1024 * 1024; +const MAX_CELLS: usize = 1_000_000; +const MAX_STRINGS: usize = 1_000_000; +const MAX_SHEETS: usize = 1024; +const MAX_RECORDS: usize = 5_000_000; + +fn invalid(msg: impl Into) -> IoError { + IoError::Format(format!("XLSB: {}", msg.into())) +} + +#[derive(Default)] +struct Budget { + cells: usize, + text: usize, +} +impl Budget { + fn text(&mut self, len: usize) -> Result<(), IoError> { + self.text = self.text.saturating_add(len); + if self.text > MAX_TEXT { + return Err(IoError::TooLarge("XLSB decoded text exceeds 64 MiB".into())); + } + Ok(()) + } + fn cell(&mut self) -> Result<(), IoError> { + self.cells += 1; + if self.cells > MAX_CELLS { + return Err(IoError::TooLarge("XLSB exceeds 1 million imported cells".into())); + } + Ok(()) + } +} + +/// Imports worksheet scalar values and saved formula results. Number formats are omitted; +/// dates remain serial numbers with the workbook's original date system. Inspect warnings. +pub fn read_xlsb(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + let mut package = Package::open(bytes)?; + let types = content_types(&mut package)?; + let roots = relationships(&mut package, "")?; + let roots: Vec<_> = roots.iter().filter(|r| r.kind == "officeDocument").collect(); + if roots.len() != 1 { + return Err(invalid("expected one workbook relationship")); + } + let root = roots.first().ok_or_else(|| invalid("missing workbook relationship"))?; + if root.external { + return Err(invalid("external workbook relationship")); + } + check_type(&types, &root.target, MAIN)?; + let book = required(&mut package, &root.target)?; + let mut wb = Workbook::new(); + wb.sheets.clear(); + let mut report = ReadReport::default(); + report.warn("XLSB data-only import: formulas become their saved results and will not recalculate. Formatting, charts, images, macros and other features are not imported; dates, currency and percentages may display as numbers. Save a new .xlsx file; XLSB saving is not supported."); + let sheets = workbook(&book, &mut wb, &mut report)?; + let rels = relationships(&mut package, &root.target)?; + let mut budget = Budget::default(); + let sst_rels: Vec<_> = rels.iter().filter(|r| r.kind == "sharedStrings").collect(); + if sst_rels.len() > 1 { + return Err(invalid("multiple shared-string relationships")); + } + let strings = if let Some(rel) = sst_rels.first() { + if rel.external { + return Err(invalid("external shared-string relationship")); + } + check_type(&types, &rel.target, STRINGS)?; + shared_strings(&required(&mut package, &rel.target)?, &mut budget, &mut report)? + } else { + vec![] + }; + for info in sheets { + let Some(id) = info.rel else { + if info.visibility != Visibility::VeryHidden { + return Err(invalid("worksheet relationship is null")); + } + report.warn("XLSB module sheets were skipped."); + continue; + }; + let rel = rels.iter().find(|r| r.id == id).ok_or_else(|| invalid("worksheet relationship is missing"))?; + if rel.external { + return Err(invalid("external sheet relationship")); + } + if rel.kind != "worksheet" { + if !matches!(rel.kind.as_str(), "chartsheet" | "dialogsheet" | "xlMacrosheet" | "xlIntlMacrosheet") { + return Err(invalid("unknown sheet relationship type")); + } + report.warn("XLSB non-worksheet sheets were skipped."); + continue; + } + check_type(&types, &rel.target, WORKSHEET)?; + wb.check_sheet_name(&info.name, None).map_err(invalid)?; + let mut sheet = worksheet(&required(&mut package, &rel.target)?, &info.name, &strings, &mut budget, &mut report)?; + sheet.visibility = info.visibility; + wb.sheets.push(Arc::new(sheet)); + } + if wb.sheets.is_empty() { + return Err(invalid("no supported worksheets")); + } + wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); + Ok((wb, report)) +} + +pub(crate) fn is_xlsb(bytes: &[u8]) -> bool { + let inspect = || -> Result { + let mut package = Package::open(bytes)?; + let types = content_types(&mut package)?; + let roots = relationships(&mut package, "")?; + let mut books = roots.iter().filter(|r| r.kind == "officeDocument"); + let Some(root) = books.next() else { return Ok(false) }; + Ok(books.next().is_none() && !root.external && check_type(&types, &root.target, MAIN).is_ok()) + }; + inspect().unwrap_or(false) +} + +fn required(package: &mut Package<'_>, path: &str) -> Result, IoError> { + package.read(path)?.ok_or_else(|| invalid(format!("missing part {path}"))) +} + +struct Entry { + name: String, + attrs: Vec<(String, String)>, +} +impl Entry { + fn attr(&self, name: &str) -> Option<&str> { + self.attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) + } +} + +/// The two package metadata parts are flat, namespace-qualified XML documents. Keep only +/// direct child entries, and reject truncated XML instead of guessing a partial relationship. +fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result, IoError> { + let mut reader = NsReader::from_reader(bytes); + let mut depth = 0usize; + let mut seen = false; + let mut entries = vec![]; + loop { + let event = reader.read_event().map_err(|e| IoError::Xml(e.to_string()))?; + match &event { + Event::Start(e) | Event::Empty(e) => { + let (namespace, local) = reader.resolve_element(e.name()); + let valid_ns = matches!(namespace, ResolveResult::Bound(n) if n.as_ref() == ns); + if depth == 0 { + if seen || !valid_ns || local.as_ref() != root_name { + return Err(invalid("invalid package metadata root")); + } + seen = true; + } else if depth == 1 && valid_ns { + let mut attrs = vec![]; + for a in e.attributes() { + let a = a.map_err(|e| IoError::Xml(e.to_string()))?; + if !a.key.as_ref().contains(&b':') { + attrs.push(( + String::from_utf8_lossy(a.key.as_ref()).into_owned(), + a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?.into_owned(), + )); + } + } + entries.push(Entry { name: String::from_utf8_lossy(local.as_ref()).into_owned(), attrs }); + } + if matches!(event, Event::Start(_)) { + depth += 1; + if depth > crate::xml::MAX_DEPTH { + return Err(invalid("package metadata is nested too deeply")); + } + } + } + Event::End(_) => depth = depth.checked_sub(1).ok_or_else(|| invalid("unexpected metadata end"))?, + Event::Text(t) if depth == 0 && !t.xml_content().map_err(|e| IoError::Xml(e.to_string()))?.trim().is_empty() => { + return Err(invalid("text outside package metadata")); + } + Event::DocType(_) => return Err(invalid("DOCTYPE in package metadata is unsupported")), + Event::Eof => break, + _ => {} + } + } + if !seen || depth != 0 { + return Err(invalid("incomplete package metadata")); + } + Ok(entries) +} + +fn content_types(package: &mut Package<'_>) -> Result, IoError> { + let data = package.read_limited("[Content_Types].xml", MAX_META)?.ok_or_else(|| invalid("missing content types"))?; + metadata(&data, CT_NS, b"Types") +} + +fn check_type(types: &[Entry], path: &str, expected: &str) -> Result<(), IoError> { + let found = types.iter().find(|e| e.name == "Override" && e.attr("PartName").is_some_and(|p| normalize(p) == normalize(path))).or_else(|| { + types.iter().find(|e| { + e.name == "Default" && e.attr("Extension").is_some_and(|ext| path.rsplit('.').next().is_some_and(|p| p.eq_ignore_ascii_case(ext))) + }) + }); + let actual = found.and_then(|e| e.attr("ContentType")); + if actual != Some(expected) && !(expected == MAIN && actual == Some(BINARY_MAIN)) { + return Err(invalid(format!("wrong or missing content type for {path}"))); + } + Ok(()) +} + +fn relationships(package: &mut Package<'_>, path: &str) -> Result, IoError> { + let (dir, file) = split_dir(path); + let relpath = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") }; + let data = package.read_limited(&relpath, MAX_META)?.ok_or_else(|| invalid(format!("missing relationships for {path}")))?; + let mut out: Vec = vec![]; + for e in metadata(&data, REL_NS, b"Relationships")? { + if e.name != "Relationship" { + continue; + } + let id = e.attr("Id").filter(|s| !s.is_empty()).ok_or_else(|| invalid("relationship has no ID"))?; + if out.iter().any(|r| r.id == id) { + return Err(invalid("duplicate relationship ID")); + } + let ty = e.attr("Type").ok_or_else(|| invalid("relationship has no type"))?; + let kind = ty + .strip_prefix(REL_TYPE) + .or_else(|| ty.strip_prefix(STRICT_REL_TYPE)) + .or_else(|| ty.strip_prefix(MS_REL_TYPE)) + .map(str::to_owned) + .unwrap_or_else(|| format!("unsupported:{ty}")); + let target = e.attr("Target").ok_or_else(|| invalid("relationship has no target"))?; + let external = e.attr("TargetMode") == Some("External"); + out.push(Rel { id: id.into(), kind, target: if external { target.into() } else { resolve(dir, target) }, external }); + } + Ok(out) +} + +struct Bytes<'a> { + rest: &'a [u8], +} +impl<'a> Bytes<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { rest: bytes } + } + fn take(&mut self, n: usize) -> Result<&'a [u8], IoError> { + let out = self.rest.get(..n).ok_or_else(|| invalid("truncated record or string"))?; + self.rest = self.rest.get(n..).ok_or_else(|| invalid("truncated record"))?; + Ok(out) + } + fn u8(&mut self) -> Result { + self.take(1)?.first().copied().ok_or_else(|| invalid("truncated byte")) + } + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.take(2)?.try_into().map_err(|_| invalid("truncated u16"))?)) + } + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.take(4)?.try_into().map_err(|_| invalid("truncated u32"))?)) + } + fn f64(&mut self) -> Result { + let n = f64::from_le_bytes(self.take(8)?.try_into().map_err(|_| invalid("truncated number"))?); + if !n.is_finite() { + return Err(invalid("nonfinite numeric value")); + } + Ok(n) + } + fn string(&mut self, max: u32, nullable: bool) -> Result, IoError> { + let count = self.u32()?; + if nullable && count == u32::MAX { + return Ok(None); + } + if count > max { + return Err(IoError::TooLarge("XLSB string length exceeds its limit".into())); + } + let size = (count as usize).checked_mul(2).ok_or_else(|| invalid("string length overflow"))?; + let bytes = self.take(size)?; + let mut text = String::new(); + for ch in char::decode_utf16(bytes.as_chunks::<2>().0.iter().copied().map(u16::from_le_bytes)) { + text.push(ch.map_err(|_| invalid("invalid UTF-16 string"))?); + } + Ok(Some(text)) + } + fn text(&mut self, max: u32) -> Result { + self.string(max, false)?.ok_or_else(|| invalid("null string")) + } + fn finish(self) -> Result<(), IoError> { + if !self.rest.is_empty() { + return Err(invalid("unexpected bytes after record payload")); + } + Ok(()) + } +} + +struct Records<'a> { + bytes: Bytes<'a>, + count: usize, + blocks: Vec, +} +impl<'a> Records<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes: Bytes::new(bytes), count: 0, blocks: vec![] } + } + fn next(&mut self, report: &mut ReadReport) -> Result, IoError> { + while !self.bytes.rest.is_empty() { + self.count += 1; + if self.count > MAX_RECORDS { + return Err(IoError::TooLarge("XLSB part contains too many records".into())); + } + let first = self.bytes.u8()?; + let id = if first & 0x80 == 0 { + first as u16 + } else { + let id = u16::from(first & 0x7f) | (u16::from(self.bytes.u8()? & 0x7f) << 7); + if id < 128 { + return Err(invalid("invalid two-byte record type")); + } + id + }; + let mut size = 0usize; + for shift in [0, 7, 14, 21] { + let b = self.bytes.u8()?; + size |= usize::from(b & 0x7f) << shift; + // The high bit of the fourth length byte is ignored by MS-XLSB. + if b & 0x80 == 0 || shift == 21 { + break; + } + } + let payload = self.bytes.take(size)?; + match id { + 35 | 37 => { + let mut b = Bytes::new(payload); + if id == 35 { + b.take(4)?; + } else { + let count = b.u16()?; + if count == 0 { + return Err(invalid("empty alternate-content version list")); + } + b.take(usize::from(count) * 4)?; + } + b.finish()?; + if self.blocks.len() >= 64 { + return Err(invalid("extension blocks nested too deeply")); + } + self.blocks.push(id + 1); + report.warn("Unsupported XLSB future-record and alternate-content blocks were skipped."); + } + 36 | 38 => { + if self.blocks.pop() != Some(id) || !payload.is_empty() { + return Err(invalid("mismatched extension block end")); + } + } + _ if !self.blocks.is_empty() => {} + _ => return Ok(Some((id, payload))), + } + } + if !self.blocks.is_empty() { + return Err(invalid("unterminated extension block")); + } + Ok(None) + } + fn first(&mut self, id: u16, report: &mut ReadReport) -> Result<(), IoError> { + if self.next(report)? != Some((id, &[])) { + return Err(invalid("missing part begin record")); + } + Ok(()) + } + fn end(&mut self, payload: &[u8], report: &mut ReadReport) -> Result<(), IoError> { + if !payload.is_empty() || self.next(report)?.is_some() { + return Err(invalid("records after part end")); + } + Ok(()) + } +} + +struct SheetInfo { + name: String, + rel: Option, + visibility: Visibility, +} +fn workbook(bytes: &[u8], wb: &mut Workbook, report: &mut ReadReport) -> Result, IoError> { + let mut records = Records::new(bytes); + records.first(131, report)?; + let mut sheets = vec![]; + let mut bundle = false; + let mut bundle_seen = false; + while let Some((id, payload)) = records.next(report)? { + let mut b = Bytes::new(payload); + match id { + 132 => { + if bundle || !bundle_seen { + return Err(invalid("incomplete worksheet list")); + } + records.end(payload, report)?; + return Ok(sheets); + } + 143 => { + if bundle_seen { + return Err(invalid("duplicate worksheet list")); + } + bundle_seen = true; + bundle = true; + b.finish()?; + } + 144 => { + if !bundle { + return Err(invalid("unexpected worksheet list end")); + } + bundle = false; + b.finish()?; + } + 153 => { + wb.date_system = if b.u32()? & 1 != 0 { DateSystem::D1904 } else { DateSystem::D1900 }; + b.u32()?; + b.text(31)?; + b.finish()?; + } + 156 => { + if !bundle { + return Err(invalid("worksheet outside the worksheet list")); + } + if sheets.len() >= MAX_SHEETS { + return Err(IoError::TooLarge("XLSB exceeds 1024 sheets".into())); + } + let visibility = match b.u32()? { + 0 => Visibility::Visible, + 1 => Visibility::Hidden, + 2 => Visibility::VeryHidden, + _ => return Err(invalid("invalid sheet visibility")), + }; + let sheet_id = b.u32()?; + if !(1..=65535).contains(&sheet_id) { + return Err(invalid("invalid worksheet ID")); + } + let rel = b.string(255, true)?; + let name = b.text(31)?; + b.finish()?; + sheets.push(SheetInfo { name, rel, visibility }); + } + _ => {} + } + } + Err(invalid("missing workbook end record")) +} + +fn rich_string(b: &mut Bytes<'_>) -> Result { + let flags = b.u8()?; + let text = b.text(32767)?; + if flags & 1 != 0 { + let count = b.u32()?; + if count > 32767 { + return Err(invalid("too many rich-text runs")); + } + b.take(count as usize * 4)?; + } + if flags & 2 != 0 { + b.text(32767)?; + let count = b.u32()?; + if count > 32767 { + return Err(invalid("too many phonetic runs")); + } + b.take(count as usize * 10)?; + } + Ok(text) +} + +fn shared_strings(bytes: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Result>, IoError> { + let mut records = Records::new(bytes); + let Some((159, payload)) = records.next(report)? else { + return Err(invalid("missing shared-string begin record")); + }; + let mut header = Bytes::new(payload); + let total = header.u32()?; + let unique = header.u32()?; + header.finish()?; + if unique > total || unique as usize > MAX_STRINGS { + return Err(IoError::TooLarge("invalid or oversized shared-string count".into())); + } + let mut strings = vec![]; + while let Some((id, payload)) = records.next(report)? { + match id { + 160 => { + records.end(payload, report)?; + if strings.len() != unique as usize { + return Err(invalid("shared-string count does not match records")); + } + return Ok(strings); + } + 19 => { + if strings.len() >= unique as usize { + return Err(invalid("too many shared-string items")); + } + let mut b = Bytes::new(payload); + let text = rich_string(&mut b)?; + b.finish()?; + budget.text(text.len())?; + strings.push(Arc::from(text)); + } + _ => return Err(invalid("unexpected shared-string record")), + } + } + Err(invalid("missing shared-string end record")) +} + +fn error(code: u8) -> Result { + Ok(match code { + 0x00 => CellError::Null, + 0x07 => CellError::Div0, + 0x0f => CellError::Value, + 0x17 => CellError::Ref, + 0x1d => CellError::Name, + 0x24 => CellError::Num, + 0x2a => CellError::NA, + 0x2b => CellError::GettingData, + _ => return Err(invalid("unknown cell error code")), + }) +} +fn boolean(b: &mut Bytes<'_>) -> Result { + match b.u8()? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(invalid("invalid boolean value")), + } +} +fn cached_formula(b: &mut Bytes<'_>) -> Result<(), IoError> { + b.take(2)?; + let cce = b.u32()?; + if !(1..=16384).contains(&cce) { + return Err(invalid("invalid formula token length")); + } + b.take(cce as usize)?; + let extra = b.u32()?; + b.take(extra as usize)?; + Ok(()) +} +fn value(id: u16, b: &mut Bytes<'_>, strings: &[Arc], budget: &mut Budget) -> Result { + let value = match id { + 1 => Value::Empty, + 2 => { + let rk = b.u32()?; + let n = if rk & 2 != 0 { ((rk as i32) >> 2) as f64 } else { f64::from_bits(u64::from(rk & !3) << 32) }; + let n = if rk & 1 != 0 { n / 100.0 } else { n }; + if !n.is_finite() { + return Err(invalid("nonfinite RK number")); + } + Value::Number(n) + } + 3 | 11 => Value::Error(error(b.u8()?)?), + 4 | 10 => Value::Bool(boolean(b)?), + 5 | 9 => Value::Number(b.f64()?), + 6 | 8 | 62 => { + let s = if id == 62 { rich_string(b)? } else { b.text(32767)? }; + budget.text(s.len())?; + Value::Text(Arc::from(s)) + } + 7 => Value::Text(strings.get(b.u32()? as usize).cloned().ok_or_else(|| invalid("shared-string index is out of bounds"))?), + _ => return Err(invalid("unsupported cell record")), + }; + if (8..=11).contains(&id) { + cached_formula(b)?; + } + Ok(value) +} + +fn worksheet(bytes: &[u8], name: &str, strings: &[Arc], budget: &mut Budget, report: &mut ReadReport) -> Result { + let mut records = Records::new(bytes); + records.first(129, report)?; + let mut sheet = Sheet::new(name); + let mut data = false; + let mut data_seen = false; + let mut row = None; + let mut col = None; + while let Some((id, payload)) = records.next(report)? { + let mut b = Bytes::new(payload); + match id { + 130 => { + if data || !data_seen { + return Err(invalid("incomplete worksheet data")); + } + records.end(payload, report)?; + return Ok(sheet); + } + 145 => { + if data_seen { + return Err(invalid("duplicate worksheet data")); + } + data_seen = true; + data = true; + b.finish()?; + } + 146 => { + if !data { + return Err(invalid("unexpected worksheet data end")); + } + data = false; + b.finish()?; + } + 0 => { + if !data { + return Err(invalid("row outside worksheet data")); + } + let r = b.u32()?; + if r >= MAX_ROWS || row.is_some_and(|previous| r <= previous) { + return Err(invalid("row is out of bounds or unordered")); + } + b.take(9)?; + let spans = b.u32()?; + if spans > 16 { + return Err(invalid("too many row spans")); + } + b.take(spans as usize * 8)?; + b.finish()?; + row = Some(r); + col = None; + } + 1..=11 | 62 => { + if !data { + return Err(invalid("cell outside worksheet data")); + } + let r = row.ok_or_else(|| invalid("cell before row header"))?; + let c = b.u32()?; + if c >= MAX_COLS || col.is_some_and(|previous| c <= previous) { + return Err(invalid("column is out of bounds or unordered")); + } + b.u32()?; // Style index and phonetic flag; formatting is not imported. + let value = value(id, &mut b, strings, budget)?; + b.finish()?; + if !value.is_empty() { + budget.cell()?; + sheet.cells.set(CellRef::new(r, c), Cell::value(value)); + } + col = Some(c); + } + 176 => { + if data { + return Err(invalid("merge inside worksheet data")); + } + let r0 = b.u32()?; + let r1 = b.u32()?; + let c0 = b.u32()?; + let c1 = b.u32()?; + b.finish()?; + if r0 > r1 || c0 > c1 || r1 >= MAX_ROWS || c1 >= MAX_COLS { + report.warn("Invalid XLSB merged ranges were skipped."); + continue; + } + if sheet.merges.len() >= 100_000 { + return Err(IoError::TooLarge("XLSB exceeds 100000 merges".into())); + } + sheet.merges.push(RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))); + } + _ => {} + } + } + Err(invalid("missing worksheet end record")) +} diff --git a/docs/cli.md b/docs/cli.md index 2cf5985..164ebfb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,6 +3,10 @@ Headless GridCraft: inspect, convert, evaluate, script and serve MCP without a window. Errors go to stderr with a non-zero exit code. +`.xlsb` files support [worksheet data import](xlsb-import.md). Formula cells use their last saved +values; source formatting and other workbook features are omitted. Import warnings go to stderr. +Convert to `.xlsx` to save the imported workbook; XLSB output is not supported. + ```sh cargo install --path apps/gridcraft-cli # or: cargo run -p gridcraft-cli -- ``` diff --git a/docs/xlsb-import.md b/docs/xlsb-import.md new file mode 100644 index 0000000..f513d75 --- /dev/null +++ b/docs/xlsb-import.md @@ -0,0 +1,22 @@ +# Importing Excel binary workbooks + +Open an `.xlsb` file with File → Open, drag it onto GridCraft, or pass it to the CLI. +GridCraft imports worksheet data into a new workbook and shows the conversion limits. + +The import reads worksheet names, visibility, numbers, text, booleans, errors and merged +cells. Formula cells become their **last saved results**, not live formulas. Those results +may be out of date if the source application did not recalculate before saving. + +Source formatting is omitted: dates, times, percentages and currencies initially display as +their underlying numbers. The workbook's date system is retained. Charts, images, names, +macros and other workbook features are not imported. GridCraft does not write XLSB files. + +Save opens Save As and suggests a new `.xlsx` file. The source XLSB file is not used as the +workbook's save destination; requests to save to `.xlsb` return an error. + +```sh +gridcraft-cli info source.xlsb +gridcraft-cli convert source.xlsb imported.xlsx +``` + +CLI import warnings go to stderr, leaving structured output on stdout available for scripts. From e893621d76ac8266e2eb4395683f2eeda8e7723d Mon Sep 17 00:00:00 2001 From: lloyd Date: Sat, 10 Oct 2026 09:03:33 +0200 Subject: [PATCH 08/19] Web app: committing a cell no longer traps on the clock Every edit goes through cmd::commit, which read std::time::Instant::now() up front for GRIDCRAFT_PROFILE timing, whether profiling was on or not. wasm32-unknown-unknown has no clock: Instant::now panics there, and with panic=abort the browser reports `RuntimeError: unreachable`. So in the web app, typing 1 into A1 and pressing Enter trapped (#118). The native apps and the CLI have a clock and were unaffected. commit now reads the clock only when GRIDCRAFT_PROFILE is set, and never on wasm. The other clock reads (NOW(), the current year for typed dates, the UI's frame timer) already skip wasm. Native tests can't see this trap, so clippy.toml now disallows Instant::now and SystemTime::now. A clock read has to be gated off wasm and allowed at the call, and `cargo clippy -D warnings` fails on the old commit code. The existing, already-gated reads and the native-only perf example allow it. Checked by building the engine for wasm32-unknown-unknown into a small cdylib that runs cell.set A1=1 on a new workbook, and running it in Node: before the fix it traps with `RuntimeError: unreachable`, after it returns normally. Co-Authored-By: Claude Opus 5.5 --- clippy.toml | 8 ++++++++ crates/calc/src/recalc.rs | 2 ++ crates/core/src/parse.rs | 1 + crates/engine/examples/perf.rs | 2 ++ crates/engine/src/cmd/mod.rs | 12 +++++++----- crates/ui-egui/src/lib.rs | 1 + 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/clippy.toml b/clippy.toml index 4b6c79f..8b8bc5a 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,11 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true allow-indexing-slicing-in-tests = true + +# The web app (wasm32-unknown-unknown) has no clock: these trap (`RuntimeError: unreachable`) +# instead of returning. Read the clock only off wasm (`cfg(not(target_arch = "wasm32"))` or +# `cfg!`) and allow the lint at that call. +disallowed-methods = [ + { path = "std::time::Instant::now", reason = "traps on wasm; read the clock only off wasm" }, + { path = "std::time::SystemTime::now", reason = "traps on wasm; read the clock only off wasm" }, +] diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 13b47c4..a15bec9 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -553,6 +553,7 @@ impl Calc { } /// Local date-time as a serial (1900 system). Wasm without a clock returns a fixed date. +#[allow(clippy::disallowed_methods)] // the clock is read only off wasm pub fn now_serial() -> f64 { #[cfg(not(target_arch = "wasm32"))] { @@ -592,6 +593,7 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V ev.value(expr) } +#[allow(clippy::disallowed_methods)] // the clock is read only off wasm fn prof_now() -> f64 { #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b08cf2a..217c45b 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -275,6 +275,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option { /// The year used for dates typed without one. Fixed per process from the system clock when /// available (wasm without clock falls back to 2026). +#[allow(clippy::disallowed_methods)] // the clock is read only off wasm pub fn current_year() -> i64 { #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/engine/examples/perf.rs b/crates/engine/examples/perf.rs index 2a533a2..61ecf56 100644 --- a/crates/engine/examples/perf.rs +++ b/crates/engine/examples/perf.rs @@ -1,6 +1,8 @@ //! Performance scenarios on large workbooks (release build): //! `cargo run --release -p gridcraft-engine --example perf -- [rows]` +#![allow(clippy::disallowed_methods)] // a native-only benchmark: wasm never runs it + use std::time::Instant; use gridcraft_engine::Session; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 397a05c..530bc3a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -217,8 +217,10 @@ pub(crate) fn edit(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result) -> } pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) -> Result { - let prof = std::env::var_os("GRIDCRAFT_PROFILE").is_some(); - let t0 = std::time::Instant::now(); + // `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there + // `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app. + #[allow(clippy::disallowed_methods)] + let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) }; let mut sel = d.selection.clone(); let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new() }; let r = f(&mut ctx)?; @@ -226,7 +228,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } - if prof { + if let Some(t0) = t0 { eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len()); } if structural { @@ -234,7 +236,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) } else if !changed.is_empty() { d.calc.cells_changed(&mut wb, &changed); } - if prof { + if let Some(t0) = t0 { eprintln!("commit: + recalc {:?}", t0.elapsed()); } fit_rows.sort_unstable(); @@ -242,7 +244,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) for (si, row) in fit_rows { auto_row_height(&mut wb, si, row); } - if prof { + if let Some(t0) = t0 { eprintln!("commit: + rows {:?}", t0.elapsed()); } d.wb = std::sync::Arc::new(wb); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca..530097b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -486,6 +486,7 @@ pub fn clean_error(e: &str) -> String { e.to_string() } +#[allow(clippy::disallowed_methods)] // the clock is read only off wasm pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] { From 2f9fce88b0a9324b730e868fd6a0b0b13fd8a9a9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:10:59 -0400 Subject: [PATCH 09/19] Review fix-up: allow the desktop logger's clock read under the new disallowed-methods lint The logger (apps/gridcraft/src/logging.rs, merged from main) reads SystemTime::now; the desktop app never runs on wasm, so allow the lint at that call. Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft/src/logging.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/gridcraft/src/logging.rs b/apps/gridcraft/src/logging.rs index d73cd71..e3b56a8 100644 --- a/apps/gridcraft/src/logging.rs +++ b/apps/gridcraft/src/logging.rs @@ -262,6 +262,7 @@ impl log::Log for AppLogger { } // Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock. let thread = std::thread::current(); + #[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it let line = format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); if self.stderr { From deecb18f5d75f8bd54f85a0f0a8337b211fd1b52 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:11:51 -0400 Subject: [PATCH 10/19] Review fix-up: skip pre-releases, prune the Pages repo, read-only pages in build - Skip the build when the published release is a pre-release, and refuse a manual tag with a pre-release suffix or a release GitHub marks as one, so rc builds never reach the stable branch. - flatpak build-update-repo --prune --prune-depth=3 so the re-mirrored Pages repository does not grow without bound. - The build job only needs pages: read for configure-pages. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/flatpak-pages.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml index 44f97cc..cd8f12a 100644 --- a/.github/workflows/flatpak-pages.yml +++ b/.github/workflows/flatpak-pages.yml @@ -20,10 +20,14 @@ concurrency: jobs: build: name: Build Flatpak repository + # Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped + # here, and the validation step below refuses a manual tag with a pre-release suffix. + if: github.event_name != 'release' || !github.event.release.prerelease runs-on: ubuntu-24.04 permissions: contents: read - pages: write + # configure-pages only reads the Pages configuration (base_url); deploy needs write. + pages: read env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} @@ -47,10 +51,17 @@ jobs: echo "::error::Release tag '$tag' is not strict SemVer prefixed with v" exit 1 fi + # A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1). + case "${tag%%+*}" in + *-*) + echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository" + exit 1 + ;; + esac published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \ - 'select(.draft == false and .published_at != null) | .tag_name')" + 'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')" if [[ "$published_tag" != "$tag" ]]; then - echo "::error::$tag is not a published release in $GITHUB_REPOSITORY" + echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY" exit 1 fi @@ -108,7 +119,10 @@ jobs: flatpak build-import-bundle "$repo_dir" \ "bundles/gridcraft-$version-linux-$arch.flatpak" done + # Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository + # (re-mirrored and re-deployed on every release) does not grow without bound. flatpak build-update-repo \ + --prune --prune-depth=3 \ --title="GridCraft" \ --comment='Official GridCraft releases' \ --description='Official Flatpak repository for GridCraft' \ From d3b8929fde9161e5c1e7d0066469f4009e765d1a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 9 Oct 2026 23:14:29 -0400 Subject: [PATCH 11/19] ci: add RISC-V Linux build --- .github/workflows/release.yml | 69 +++++++++++++++++++++++++++++++++++ docs/releasing.md | 1 + packaging/linux/package.sh | 19 +++++++--- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de15fd8..5ae44f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -323,6 +323,75 @@ jobs: # FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true, # uploads the release-freebsd artifact (gridcraft--freebsd-x86_64.tar.gz). It needs no # secrets, so the called workflow runs without the release environment (and in dry runs). + linux-riscv64: + name: Linux riscv64 + needs: version + runs-on: ubuntu-26.04 + env: + GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} + GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + with: + toolchain: stable + targets: riscv64gc-unknown-linux-gnu + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$GRIDCRAFT_VERSION" + - name: Install cross-compilation dependencies + run: | + sudo dpkg --add-architecture riscv64 + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + fi + if [ -f /etc/apt/sources.list ]; then + sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list + sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list + fi + cat << 'EOF' | sudo tee /etc/apt/sources.list.d/riscv64.list + deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute main universe restricted multiverse + deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute-updates main universe restricted multiverse + deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute-security main universe restricted multiverse + EOF + sudo apt-get update + sudo apt-get install -y --allow-downgrades --no-install-recommends \ + gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ + libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \ + libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64 + if command -v qemu-riscv64 >/dev/null 2>&1 && ! command -v qemu-riscv64-static >/dev/null 2>&1; then + sudo ln -s "$(command -v qemu-riscv64)" /usr/local/bin/qemu-riscv64-static + fi + - name: Build and package + env: + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C link-arg=-L/usr/riscv64-linux-gnu/lib -C link-arg=-L/usr/lib/riscv64-linux-gnu" + PKG_CONFIG_SYSROOT_DIR: / + PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" + CROSS_ARCH: riscv64 + CROSS_TARGET: riscv64gc-unknown-linux-gnu + CROSS_COMPILE: riscv64-linux-gnu- + EMULATOR: qemu-riscv64-static + run: | + export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 + packaging/linux/package.sh --formats "tar" + - name: Smoke test CLI under QEMU + run: | + tar -xzf dist/release/*.tar.gz + qemu-riscv64-static -L /usr/riscv64-linux-gnu gridcraft-*/bin/gridcraft-cli --version + - uses: actions/upload-artifact@v4 + with: + name: release-linux-riscv64 + path: dist/release/*.tar.gz + if-no-files-found: error + freebsd: name: FreeBSD needs: version diff --git a/docs/releasing.md b/docs/releasing.md index f4a28eb..de94b21 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -82,6 +82,7 @@ and upload their artifacts, the environment refuses macOS and Windows, and no re | Windows 11 ARM64 | `gridcraft--windows-arm64.msi`, `…-portable.zip` (cross-compiled, signed like x64) | `windows-latest`; installed and run on `windows-11-arm` by `windows-arm64.yml` | | Linux x86_64 | `gridcraft--linux-x86_64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `gridcraft--linux-aarch64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Linux riscv64 | `gridcraft--linux-riscv64.tar.gz` | cross-compiled on `ubuntu-26.04` | | Flatpak x86_64, aarch64 | `gridcraft--linux-.flatpak` (repackages the Linux tarball) | `ubuntu-24.04`, `ubuntu-24.04-arm` | | FreeBSD 14 x86_64 | `gridcraft--freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM (`freebsd.yml`, called by `release.yml`) | | Web | `gridcraft-web-.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 20ad1b2..e712303 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -30,8 +30,9 @@ while [ $# -gt 0 ]; do esac done -ARCH="$(uname -m)" +ARCH="${CROSS_ARCH:-$(uname -m)}" case "$ARCH" in + riscv64) DEB_ARCH=riscv64 ;; x86_64) DEB_ARCH=amd64 ;; aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; @@ -42,9 +43,13 @@ BASENAME="gridcraft-$VERSION-linux-$ARCH" echo "==> GridCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then - (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) + if [ -n "${CROSS_TARGET:-}" ]; then + (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli --target "$CROSS_TARGET") + else + (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) + fi fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" WORK="$CARGO_TARGET_DIR/linux-package" STAGE="$WORK/root" rm -rf "$WORK" @@ -52,7 +57,7 @@ rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- install -Dm755 "$BIN/gridcraft" "$STAGE/usr/bin/gridcraft" install -Dm755 "$BIN/gridcraft-cli" "$STAGE/usr/bin/gridcraft-cli" -strip "$STAGE/usr/bin/gridcraft" "$STAGE/usr/bin/gridcraft-cli" 2>/dev/null || true +"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/gridcraft" "$STAGE/usr/bin/gridcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" @@ -135,6 +140,10 @@ if has appimage; then fi fi -"$STAGE/usr/bin/gridcraft-cli" --version +if [ -z "${CROSS_TARGET:-}" ]; then + "$STAGE/usr/bin/gridcraft-cli" --version +elif [ -n "${EMULATOR:-}" ]; then + $EMULATOR "$STAGE/usr/bin/gridcraft-cli" --version +fi echo "==> done" ls -lh "$DIST" From 699b95288a9cc7e10fc0f8fb2b19d6d142d387f6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:14:34 -0400 Subject: [PATCH 12/19] Review fix-up: switch to Home only when the legacy keytip prefix changes ribbon_keys ran ui.ribbonTab every frame while the prefix was E/O/OC, forcing the Home tab continuously. Run it once on the prefix change and add a regression test. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 6 ++++-- crates/ui-egui/tests/keytips.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f5664fb..a271c48 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -519,8 +519,10 @@ impl SheetApp { // Leave pointer cancellation to the popup so clicking a menu item still works. egui::Popup::close_all(ctx); } - // The legacy Edit/Format paths lead to the same existing Home menus. - if matches!(self.keytips.prefix(), Some("E" | "O" | "OC")) { + // The legacy Edit/Format paths lead to the same existing Home menus. Switch once, when + // the prefix changes, not every frame (that would reset a tab the user picked meanwhile). + let after = self.keytips.prefix(); + if after != before.as_deref() && matches!(after, Some("E" | "O" | "OC")) { self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"})); self.ui.ribbon_collapsed = false; } diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 619254b..2fe718b 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -208,6 +208,20 @@ fn ribbon_and_legacy_autofit_sequences_resize_the_selected_column() { } } +#[test] +fn legacy_prefix_switches_to_home_once_not_every_frame() { + let mut h = harness(blank()); + h.state_mut().ui.ribbon_tab = "Insert".into(); + sequence(&mut h, &[Key::E], false); + assert_eq!(h.state().keytips.prefix(), Some("E")); + assert_eq!(h.state().ui.ribbon_tab, "Home"); + // While the prefix stays the same, later frames must not keep forcing the Home tab. + h.state_mut().ui.ribbon_tab = "Insert".into(); + h.run_steps(3); + assert_eq!(h.state().keytips.prefix(), Some("E")); + assert_eq!(h.state().ui.ribbon_tab, "Insert"); +} + #[test] fn escape_backs_out_without_clearing_copy_and_f10_toggles_off() { let mut h = harness(copied_row()); From 86328ec843b6eca261f2a951a856697932e2da7f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:18:59 -0400 Subject: [PATCH 13/19] Review fix-up: ignore the wall-clock scaling test by default; check its values every run recalc_with_range_formulas_scales_linearly is flaky under load, so it is #[ignore]d (run with --ignored in release). A small range_formulas_recalc_and_bulk_edit_correctly test keeps the helper's value check in every run. The helper reads Instant::now, so it allows clippy::disallowed_methods (required once #125's clippy.toml lands). Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/recalc.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 35c77d2..dbf532c 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -953,6 +953,7 @@ mod tests { } /// Opening (full recalc), changing every value of a column, and re-entering every formula. + #[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm fn open_and_bulk_edit(rows: u32) -> Duration { let mut wb = sum_rows(rows); let mut calc = Calc::new(); @@ -975,6 +976,14 @@ mod tests { } #[test] + fn range_formulas_recalc_and_bulk_edit_correctly() { + // The values behind the timing test below, checked on every run. + open_and_bulk_edit(500); + } + + #[test] + #[ignore = "timing-sensitive; run with --ignored in release"] + #[allow(clippy::disallowed_methods)] fn recalc_with_range_formulas_scales_linearly() { // Each row's SUM reads a range in the same columns. Recalculating on open used to walk // every row per range read (the used range), and finding a cell's dependents scanned From 4955382a07b5c81d8aedec81f7241278ce02dba1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:19:33 -0400 Subject: [PATCH 14/19] Review fix-up: control-protocol doc typo, tolerant XLSB phonetic-run tail PhRun is 10 bytes per [MS-XLSB] 2.5.103 (four u16 fields plus a 16-bit phType/alcH/unused1 word), so the documented size is kept; since the RichStr ends its record and phonetic data is not imported, bytes after the documented runs are now ignored instead of rejecting the file. Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft/src/main.rs | 2 +- crates/xlsx/src/tests/xlsb_tests.rs | 20 ++++++++++++++++++++ crates/xlsx/src/xlsb.rs | 6 ++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index b6f1eaf..e621a15 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -4,7 +4,7 @@ //! //! `--control ` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control //! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. -//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methxlsb. +//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] diff --git a/crates/xlsx/src/tests/xlsb_tests.rs b/crates/xlsx/src/tests/xlsb_tests.rs index 7c4311e..969a380 100644 --- a/crates/xlsx/src/tests/xlsb_tests.rs +++ b/crates/xlsx/src/tests/xlsb_tests.rs @@ -304,3 +304,23 @@ fn xlsb_declared_counts_and_decoded_text_are_bounded() { } assert!(read_xlsb(&file(&worksheet(&data, &[]))).unwrap_err().to_string().contains("64 MiB")); } + +#[test] +fn xlsb_phonetic_runs_use_documented_size_and_tolerate_padding() { + // [MS-XLSB] 2.5.103: a PhRun is 10 bytes. Phonetic data is not imported, so + // padding after the documented runs (e.g. 12-byte runs) is ignored, while + // a run shorter than documented is still rejected. + let phonetic = |runs: u32, bytes: usize| { + let mut out = vec![2]; + out.extend(wide("ruby")); + out.extend(wide("ルビ")); + out.extend(runs.to_le_bytes()); + out.extend(vec![0; bytes]); + out + }; + for padded in [phonetic(1, 10), phonetic(1, 12), phonetic(2, 24)] { + let (wb, _) = read_xlsb(&file(&worksheet(&[row(0), cell(62, 0, &padded)].concat(), &[]))).unwrap(); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("ruby")); + } + assert!(read_xlsb(&file(&worksheet(&[row(0), cell(62, 0, &phonetic(2, 12))].concat(), &[]))).is_err()); +} diff --git a/crates/xlsx/src/xlsb.rs b/crates/xlsx/src/xlsb.rs index 5574e46..897a40b 100644 --- a/crates/xlsx/src/xlsb.rs +++ b/crates/xlsx/src/xlsb.rs @@ -470,7 +470,13 @@ fn rich_string(b: &mut Bytes<'_>) -> Result { if count > 32767 { return Err(invalid("too many phonetic runs")); } + // [MS-XLSB] 2.5.103 PhRun: ichFirst, ichMom, cchMom, ifnt (u16 each) + // then phType/alcH/unused1 (16 bits) = 10 bytes. A RichStr is always + // the last field of its record (BrtSSTItem, BrtCellRichSt) and + // phonetic data is not imported, so tolerate writers that pad runs: + // require the documented size, then drop whatever follows. b.take(count as usize * 10)?; + b.rest = &[]; } Ok(text) } From 20e39fa3cd9583fa634abc323ad396ba7021a621 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:22:51 -0400 Subject: [PATCH 15/19] Review fix-up: clip blank ODS repeats past the sheet edge, struct-update Cell literal Blank cell and row repeats that run past 16384 columns / 1048576 rows (padding from writers with larger sheets) are clipped instead of rejecting the file; any data or merge past the edge is still an error. convert() builds its Cell with ..Cell::value(value) so new Cell fields take their defaults. Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft-web/src/web.rs | 6 ++++-- crates/engine/src/cmd/file.rs | 3 +-- crates/xlsx/src/lib.rs | 2 +- crates/xlsx/src/ods.rs | 29 ++++++++++++++++++----------- crates/xlsx/src/tests/ods_tests.rs | 24 ++++++++++++++++++++++-- 5 files changed, 46 insertions(+), 18 deletions(-) diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 43d9cf5..479efcf 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -97,8 +97,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let inbox = open_inbox.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { - let Some(file) = - rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]).pick_file().await + let Some(file) = rfd::AsyncFileDialog::new() + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .pick_file() + .await else { return; }; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 34c1ad3..d52418b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -47,8 +47,7 @@ fn open(s: &mut Session, p: &Json) -> Result { return ok(); }; let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?; - let imported = - matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb)) + let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb)) || matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb); let title = if imported { format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book")) diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 7346cf5..5245e56 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -34,8 +34,8 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use csv::{read_csv, write_csv}; -pub use xlsb::read_xlsb; pub use ods::read_ods; +pub use xlsb::read_xlsb; /// Errors from reading or writing files. #[derive(Debug, thiserror::Error)] diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs index c41bec6..4f63fd1 100644 --- a/crates/xlsx/src/ods.rs +++ b/crates/xlsx/src/ods.rs @@ -327,7 +327,7 @@ impl Import { } Tag::Row if self.sheet.as_ref().is_some_and(|s| self.stack.get(s.depth + 1..).is_some_and(|p| p.iter().all(|t| *t == Tag::Rows))) => { self.row = - Some(RowDraft { depth, repeat: positive(attr(reader, e, TABLE, b"number-rows-repeated")?, 1, MAX_ROWS)?, col: 0, cells: vec![] }); + Some(RowDraft { depth, repeat: positive(attr(reader, e, TABLE, b"number-rows-repeated")?, 1, u32::MAX)?, col: 0, cells: vec![] }); } Tag::Cell | Tag::Covered if self.row.as_ref().is_some_and(|r| depth == r.depth + 1) => { let kind = if attr(reader, e, CALC_EXT, b"value-type")?.as_deref() == Some("error") { @@ -344,7 +344,7 @@ impl Import { }; self.cell = Some(CellDraft { depth, - repeat: positive(attr(reader, e, TABLE, b"number-columns-repeated")?, 1, MAX_COLS)?, + repeat: positive(attr(reader, e, TABLE, b"number-columns-repeated")?, 1, u32::MAX)?, width: positive(attr(reader, e, TABLE, b"number-columns-spanned")?, 1, MAX_COLS)?, height: positive(attr(reader, e, TABLE, b"number-rows-spanned")?, 1, MAX_ROWS)?, covered: t == Tag::Covered, @@ -460,12 +460,15 @@ impl Import { let cell = self.convert(&draft); let (width, height) = if draft.covered { (1, 1) } else { (draft.width, draft.height) }; let Some(row) = self.row.as_mut() else { return Ok(()) }; - let end = row - .col - .checked_add(draft.repeat) - .filter(|n| *n <= MAX_COLS) - .ok_or_else(|| IoError::TooLarge("ODS row exceeds worksheet width".into()))?; - if !cell.is_blank() || width > 1 || height > 1 { + let kept = !cell.is_blank() || width > 1 || height > 1; + // Writers pad rows with blank repeats up to their own (possibly wider) sheet edge; + // blank cells past ours carry no data, so clip them instead of rejecting the file. + let end = match row.col.checked_add(draft.repeat).filter(|n| *n <= MAX_COLS) { + Some(end) => end, + None if !kept => MAX_COLS, + None => return Err(IoError::TooLarge("ODS row exceeds worksheet width".into())), + }; + if kept { row.cells.push(RepeatedCell { col: row.col, repeat: draft.repeat, width, height, cell }); } row.col = end; @@ -516,14 +519,18 @@ impl Import { }, None => StyleId::DEFAULT, }; - Cell { value, formula: None, style } + Cell { style, ..Cell::value(value) } } fn finish_row(&mut self) -> Result<(), IoError> { let Some(row) = self.row.take() else { return Ok(()) }; let Some(sheet) = self.sheet.as_mut() else { return Ok(()) }; - let end = - sheet.row.checked_add(row.repeat).filter(|n| *n <= MAX_ROWS).ok_or_else(|| IoError::TooLarge("ODS exceeds worksheet height".into()))?; + // Blank trailing row repeats past the sheet edge (e.g. from larger-sheet writers) are clipped. + let end = match sheet.row.checked_add(row.repeat).filter(|n| *n <= MAX_ROWS) { + Some(end) => end, + None if row.cells.is_empty() => MAX_ROWS, + None => return Err(IoError::TooLarge("ODS exceeds worksheet height".into())), + }; for entry in row.cells { let count = u64::from(entry.repeat) * u64::from(row.repeat); if !entry.cell.is_blank() { diff --git a/crates/xlsx/src/tests/ods_tests.rs b/crates/xlsx/src/tests/ods_tests.rs index 69f92c2..c0f2e0e 100644 --- a/crates/xlsx/src/tests/ods_tests.rs +++ b/crates/xlsx/src/tests/ods_tests.rs @@ -165,9 +165,11 @@ fn ods_rejects_bad_packages_encryption_and_truncated_xml() { #[test] fn ods_expansion_bounds_stop_small_repeat_bombs() { for cells in [ - r#""#, + r#""#, + r#""#, r#""#, - r#""#, + r#""#, + r#""#, r#""#, r#""#, ] { @@ -216,3 +218,21 @@ fn ods_extension_error_cache_never_imports_its_placeholder_zero() { assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("#DIV/0!")); assert!(report.warnings.iter().any(|w| w.contains("unsupported or missing"))); } + +#[test] +fn ods_blank_repeats_past_the_sheet_edge_are_clipped() { + // Writers with larger sheets pad to their own edge; blank padding past ours carries no data. + let bytes = ods(r#" + + + + "#); + let (wb, _) = read_ods(&bytes).unwrap(); + assert_eq!(wb.sheets[0].cells.len(), 1); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), Value::Number(1.0)); + // Data after the clipped edge is still an error, never silently dropped. + let late = ods( + r#""#, + ); + assert!(matches!(read_ods(&late), Err(IoError::TooLarge(_)))); +} From c67931cff650ac9a3ae62a3328a3aa8abe0ce9db Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:23:51 -0400 Subject: [PATCH 16/19] Review fix-up: protect and cap text box edits shape.setText refuses edits on a protected sheet (drawing objects are locked; there is no edit-objects allowance yet) and text longer than 32,767 characters, like a cell. The editor doesn't open on a protected sheet and its TextEdit carries the same character limit. Tests for both. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/insert.rs | 13 +++++++++- crates/engine/src/tests.rs | 30 ++++++++++++++++++++++++ crates/ui-egui/src/text_box.rs | 10 +++++++- crates/ui-egui/tests/text_box_editing.rs | 11 +++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index d2f0e35..9c383fa 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -777,16 +777,27 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { }) } +/// A text box holds at most as many characters as a cell. +const SHAPE_TEXT_MAX_CHARS: usize = 32_767; + fn shape_set_text(s: &mut Session, p: &Json) -> Result { let id = p.get("id").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()).ok_or_else(|| bad("shape.setText", "`id` must be a text box id"))?; let text = str_param(p, "text").ok_or_else(|| bad("shape.setText", "`text` must be a string"))?; + if text.chars().nth(SHAPE_TEXT_MAX_CHARS).is_some() { + return Err(bad("shape.setText", "`text` is longer than 32,767 characters")); + } let d = s.doc()?; let sheet = d.wb.active_sheet; - let shape = d.wb.active().and_then(|sh| sh.shapes.iter().find(|shape| shape.id == id)).ok_or_else(|| bad("shape.setText", "no such text box"))?; + let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?; + let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?; if shape.kind != ShapeKind::TextBox { return Err(bad("shape.setText", "the shape is not a text box")); } + // Sheet protection locks drawing objects (there is no "edit objects" allowance yet). + if sh.is_protected() { + return Err(EngineError::Other(PROTECTED.into())); + } if shape.text == text { return ok(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5ecaea7..b6111a2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -168,6 +168,36 @@ fn shape_text_rejects_invalid_targets_and_params() { assert!(s.doc().unwrap().wb.active().unwrap().shapes.is_empty()); } +#[test] +fn shape_text_respects_sheet_protection() { + let mut s = s(); + let id = s.execute("insert.textBox", json!({"text": "Original"})).unwrap()["shape"].clone(); + s.execute("review.protectSheet", json!({})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + let undo_len = s.doc().unwrap().undo.len(); + assert!(s.execute("shape.setText", json!({"id": id, "text": "Changed"})).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before)); + assert_eq!(s.doc().unwrap().undo.len(), undo_len); + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("shape.setText", json!({"id": id, "text": "Changed"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0].text, "Changed"); +} + +#[test] +fn shape_text_is_capped_like_a_cell() { + let mut s = s(); + let id = s.execute("insert.textBox", json!({"text": "Original"})).unwrap()["shape"].clone(); + // The cap counts characters, not bytes. + let longest = "é".repeat(32_767); + s.execute("shape.setText", json!({"id": id, "text": longest})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0].text.chars().count(), 32_767); + let undo_len = s.doc().unwrap().undo.len(); + let too_long = "x".repeat(32_768); + assert!(matches!(s.execute("shape.setText", json!({"id": id, "text": too_long})), Err(crate::EngineError::BadParams { .. }))); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0].text.chars().count(), 32_767); + assert_eq!(s.doc().unwrap().undo.len(), undo_len); +} + #[test] fn shape_text_edit_survives_xlsx_roundtrip() { let mut s = s(); diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs index f9d8dec..1e83f62 100644 --- a/crates/ui-egui/src/text_box.rs +++ b/crates/ui-egui/src/text_box.rs @@ -31,9 +31,15 @@ impl SheetApp { return false; } let Some(d) = self.session.active() else { return false }; - let Some(shape) = d.wb.active().and_then(|sh| sh.shapes.iter().find(|s| s.id == id && s.kind == ShapeKind::TextBox)) else { + let Some(sh) = d.wb.active() else { return false }; + let Some(shape) = sh.shapes.iter().find(|s| s.id == id && s.kind == ShapeKind::TextBox) else { return false; }; + // shape.setText refuses edits on a protected sheet; don't open an editor that can't commit. + if sh.is_protected() { + self.message = Some(("Text Box".into(), "The cell or chart you're trying to change is on a protected sheet.".into())); + return false; + } self.text_box_editor = Some(EditState { id, text: shape.text.clone(), @@ -150,6 +156,8 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr .margin(egui::Margin::ZERO) .desired_width(rect.width()) .desired_rows(1) + // shape.setText's limit (a cell's). + .char_limit(32_767) .min_size(rect.size()) .lock_focus(true), ); diff --git a/crates/ui-egui/tests/text_box_editing.rs b/crates/ui-egui/tests/text_box_editing.rs index 64e4ccb..1d3b053 100644 --- a/crates/ui-egui/tests/text_box_editing.rs +++ b/crates/ui-egui/tests/text_box_editing.rs @@ -228,3 +228,14 @@ fn saving_and_switching_context_commit_to_the_original_text_box() { assert_eq!(shape_text(&docs[1].wb, 0, other_doc_id), "Other workbook"); assert_eq!(docs[0].wb.sheet(0).unwrap().value(at("B2")), Value::text("cell stays")); } + +#[test] +fn protected_sheet_refuses_to_open_the_text_box_editor() { + let (mut h, id) = fixture(); + h.state_mut().session.execute("review.protectSheet", json!({})).unwrap(); + edit_box(&mut h, id); + assert!(h.state().text_box_editor.is_none(), "a protected sheet's text box must not open an editor"); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("protected sheet"))); + assert_eq!(shape(&h, id).text, ORIGINAL); + assert_cell_unchanged(&h); +} From efcfd08822153ebcf5e5812fd56c7c458f391ca6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:29:05 -0400 Subject: [PATCH 17/19] Review fix-up: align the riscv64 job with the other Linux jobs - Pin actions/checkout and upload-artifact to the SHAs the other jobs use (v7.0.1, v7.0.2); add rust-cache; use the same version-override command. - Run on ubuntu-24.04 (ubuntu-26.04 is not a known hosted label) and take the ports suite from /etc/os-release; 22.04's riscv64 ports lag its amd64 updates and the multiarch install fails there. - Add linux-riscv64 to the release job's needs so release-linux-riscv64 is downloaded before the draft release is built. - Drop the unused libasound2 dependency, the linker -L flags and the qemu-static symlink; package.sh's emulator check is the smoke test (QEMU_LD_PREFIX), and quote $EMULATOR there. - Move the job above the FreeBSD comment; list riscv64 in docs/releasing.md. Verified: the job's steps in an ubuntu:24.04 container build, package and run gridcraft-cli --version under qemu-riscv64. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 62 +++++++++++++++++------------------ docs/releasing.md | 2 +- packaging/linux/package.sh | 4 +-- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8db6b3..6c9050a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -316,34 +316,40 @@ jobs: path: dist/release/* if-no-files-found: error - # FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true, - # uploads the release-freebsd artifact (gridcraft--freebsd-x86_64.tar.gz). It needs no - # secrets, so the called workflow runs without the release environment (and in dry runs). linux-riscv64: name: Linux riscv64 needs: version - runs-on: ubuntu-26.04 + # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release + # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's + # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the + # CLI is smoke-tested under QEMU. + runs-on: ubuntu-24.04 + # No `environment: release`: this job signs nothing, so it gets no release secrets. env: GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: - toolchain: stable targets: riscv64gc-unknown-linux-gnu + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: release-linux-riscv64 - name: Apply version override if: inputs.version != '' - run: cargo xtask version set "$GRIDCRAFT_VERSION" + run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - name: Install cross-compilation dependencies run: | + . /etc/os-release sudo dpkg --add-architecture riscv64 + # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources fi @@ -351,43 +357,37 @@ jobs: sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list fi - cat << 'EOF' | sudo tee /etc/apt/sources.list.d/riscv64.list - deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute main universe restricted multiverse - deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute-updates main universe restricted multiverse - deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports resolute-security main universe restricted multiverse - EOF + for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do + echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" + done | sudo tee /etc/apt/sources.list.d/riscv64.list sudo apt-get update - sudo apt-get install -y --allow-downgrades --no-install-recommends \ + sudo apt-get install -y --no-install-recommends \ gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \ - libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - if command -v qemu-riscv64 >/dev/null 2>&1 && ! command -v qemu-riscv64-static >/dev/null 2>&1; then - sudo ln -s "$(command -v qemu-riscv64)" /usr/local/bin/qemu-riscv64-static - fi - - name: Build and package + libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ + libxi-dev:riscv64 libgl1-mesa-dev:riscv64 + - name: Build and package (tar.gz) env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C link-arg=-L/usr/riscv64-linux-gnu/lib -C link-arg=-L/usr/lib/riscv64-linux-gnu" PKG_CONFIG_SYSROOT_DIR: / PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig PKG_CONFIG_ALLOW_CROSS: "1" CROSS_ARCH: riscv64 CROSS_TARGET: riscv64gc-unknown-linux-gnu CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64-static - run: | - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - packaging/linux/package.sh --formats "tar" - - name: Smoke test CLI under QEMU - run: | - tar -xzf dist/release/*.tar.gz - qemu-riscv64-static -L /usr/riscv64-linux-gnu gridcraft-*/bin/gridcraft-cli --version - - uses: actions/upload-artifact@v4 + EMULATOR: qemu-riscv64 + QEMU_LD_PREFIX: /usr/riscv64-linux-gnu + run: packaging/linux/package.sh --formats tar + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-linux-riscv64 path: dist/release/*.tar.gz if-no-files-found: error + # FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true, + # uploads the release-freebsd artifact (gridcraft--freebsd-x86_64.tar.gz). It needs no + # secrets, so the called workflow runs without the release environment (and in dry runs). freebsd: name: FreeBSD needs: version @@ -399,7 +399,7 @@ jobs: release: name: Draft GitHub Release - needs: [version, macos, windows, linux, flatpak, web, freebsd] + needs: [version, macos, windows, linux, linux-riscv64, flatpak, web, freebsd] # Belt and braces for branch dry runs: only the release branch drafts a release (the # environment's branch rule refuses this job and the signing jobs anywhere else, too). if: github.ref == 'refs/heads/release' diff --git a/docs/releasing.md b/docs/releasing.md index de94b21..0e0abfa 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -82,7 +82,7 @@ and upload their artifacts, the environment refuses macOS and Windows, and no re | Windows 11 ARM64 | `gridcraft--windows-arm64.msi`, `…-portable.zip` (cross-compiled, signed like x64) | `windows-latest`; installed and run on `windows-11-arm` by `windows-arm64.yml` | | Linux x86_64 | `gridcraft--linux-x86_64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `gridcraft--linux-aarch64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | -| Linux riscv64 | `gridcraft--linux-riscv64.tar.gz` | cross-compiled on `ubuntu-26.04` | +| Linux riscv64 | `gridcraft--linux-riscv64.tar.gz` (cross-compiled, glibc >= 2.39; CLI smoke-tested under QEMU) | `ubuntu-24.04` | | Flatpak x86_64, aarch64 | `gridcraft--linux-.flatpak` (repackages the Linux tarball) | `ubuntu-24.04`, `ubuntu-24.04-arm` | | FreeBSD 14 x86_64 | `gridcraft--freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM (`freebsd.yml`, called by `release.yml`) | | Web | `gridcraft-web-.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index e712303..9fde2c5 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and package GridCraft for Linux ( is x86_64 or aarch64): +# Build and package GridCraft for Linux ( is x86_64 or aarch64, or riscv64 when cross-compiling): # # $DIST/gridcraft--linux-.AppImage any distro with glibc >= the build host's # $DIST/gridcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) @@ -143,7 +143,7 @@ fi if [ -z "${CROSS_TARGET:-}" ]; then "$STAGE/usr/bin/gridcraft-cli" --version elif [ -n "${EMULATOR:-}" ]; then - $EMULATOR "$STAGE/usr/bin/gridcraft-cli" --version + "$EMULATOR" "$STAGE/usr/bin/gridcraft-cli" --version fi echo "==> done" ls -lh "$DIST" From d2cc499c1d5cec61a9e619e6590d0ffbd91ffccb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:27:41 -0400 Subject: [PATCH 18/19] Review fix-up: return copy/cut HTML only when requested edit.copy and edit.cut build and return the HTML table only with {"html": true}; the UI asks for it when the host can publish HTML, so MCP and control-channel responses are not bloated by up to 4 MB. Tests cover the default, html:false and html:true. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 8 +++++--- crates/engine/src/tests.rs | 29 ++++++++++++++++++++++------- crates/ui-egui/src/lib.rs | 3 ++- crates/ui-egui/tests/clipboard.rs | 2 ++ 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 78d08dd..1c5e54a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -40,8 +40,8 @@ pub fn specs() -> Vec { cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special), cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo), - cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?}", has_doc, copy), - cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?}", has_doc, cut), + cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, copy), + cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, cut), cmd!( "edit.paste", "Paste", @@ -611,7 +611,9 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { range }; let text = crate::display::range_text(&d.wb, sheet, range); - let html = crate::io::range_html(&d.wb, sheet, range, &text); + // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and + // scripts get it only when they ask for it. + let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None }; let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() }; s.clipboard = Some(clip); let mut result = json!({"range": range.a1(), "text": text}); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ac1d487..6d39839 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -212,7 +212,7 @@ fn rich_clipboard_copies_selected_displayed_cells_and_styles() { s.execute("home.italic", json!({"range": "B2", "on": true})).unwrap(); s.execute("home.fontColor", json!({"range": "B2", "color": "#123456"})).unwrap(); s.execute("home.fillColor", json!({"range": "B2", "color": "#FEDCBA"})).unwrap(); - let copied = s.execute("edit.copy", json!({"range": "B2:C3"})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "B2:C3", "html": true})).unwrap(); let html = copied["html"].as_str().expect("copy publishes HTML as well as plain text"); assert_eq!(copied["text"], "12.5%\t\"&\"\"\nnext\"\n25.0%\tlast\n"); assert!(html.starts_with("
next")); + for content in ["12.5%", "25.0%", "font-weight:bold;", "font-style:italic;", "color:#123456;", "background:#FEDCBA;", "text-align:right;"] { + assert!(html.contains(content), "missing {content}"); + } + assert!(!html.contains("outside selection")); + assert!(!html.contains("").count(), 2); + assert_eq!(html.matches("merged
side
x"))); + } +} + #[test] fn rich_clipboard_clips_merges_and_skips_hidden_rows() { let mut s = s(); @@ -241,7 +256,7 @@ fn rich_clipboard_clips_merges_and_skips_hidden_rows() { s.execute("home.mergeCenter", json!({"range": "B2:D5"})).unwrap(); s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); s.execute("cell.set", json!({"cell": "E4", "input": "side"})).unwrap(); - let copied = s.execute("edit.copy", json!({"range": "B2:C4"})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "B2:C4", "html": true})).unwrap(); let html = copied["html"].as_str().expect("merged copy publishes HTML"); assert_eq!(copied["text"], "merged\t\n\t\n"); assert_eq!(html.matches("").count(), 2); @@ -251,7 +266,7 @@ fn rich_clipboard_clips_merges_and_skips_hidden_rows() { // A selection beginning inside a merge must keep its shape without copying // the original anchor's value from outside the selected rectangle. - let copied = s.execute("edit.copy", json!({"range": "C3:E5"})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "C3:E5", "html": true})).unwrap(); let html = copied["html"].as_str().expect("partially selected merge publishes HTML"); assert_eq!(html.matches("").count(), 2); assert_eq!(html.matches("
Date: Sat, 10 Oct 2026 12:58:22 -0400 Subject: [PATCH 19/19] Fix clippy on main: allow the clock read in native-only import tests #101/#100 added tests that name temp dirs with SystemTime::now(); #125's disallowed-methods lint (wasm has no clock) landed alongside them. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5367f23..6074ddb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -618,6 +618,7 @@ fn ods_import_keeps_cached_values_and_reports_limits() { #[test] #[cfg(not(target_arch = "wasm32"))] fn ods_import_never_reuses_source_as_save_target() { + #[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 dir = std::env::temp_dir().join(format!("gridcraft-ods-{}-{nonce}", std::process::id())); std::fs::create_dir(&dir).unwrap(); @@ -780,6 +781,7 @@ fn xlsb_import_keeps_cached_values_and_reports_limits() { #[test] #[cfg(not(target_arch = "wasm32"))] fn xlsb_import_never_reuses_source_as_save_target() { + #[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 dir = std::env::temp_dir().join(format!("gridcraft-xlsb-{}-{nonce}", std::process::id())); std::fs::create_dir(&dir).unwrap();