diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 360b26f..9f2565a 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -876,10 +876,16 @@ fn new_script(s: &mut Session, p: &Json) -> Result { } fn all_scripts(s: &mut Session, _: &Json) -> Result { - let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME); - Ok(Json::Array( - all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(), - )) + let wb = &s.doc()?.wb; + let all = stash_get(wb, SCRIPTS_NAME); + let mut list: Vec = + all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(); + // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on + // save but never executed here, so it has no step count. + if let Some(bytes) = &wb.vba_project { + list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false})); + } + Ok(Json::Array(list)) } fn run_script(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index e78a6ea..1f14014 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -21,17 +21,23 @@ pub fn specs() -> Vec { fn dictionary() -> &'static HashSet { static DICT: OnceLock> = OnceLock::new(); DICT.get_or_init(|| { - let mut set = HashSet::new(); - #[cfg(not(target_arch = "wasm32"))] - for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] { - if let Ok(text) = std::fs::read_to_string(path) { - for w in text.lines().take(2_000_000) { - set.insert(w.trim().to_lowercase()); - } - break; - } + #[cfg(target_arch = "wasm32")] + { + HashSet::new() + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut set = HashSet::new(); + for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] { + if let Ok(text) = std::fs::read_to_string(path) { + for w in text.lines().take(2_000_000) { + set.insert(w.trim().to_lowercase()); + } + break; + } + } + set } - set }) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 52ee2d1..e388f87 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -113,6 +113,13 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) { pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { let sheet = wb.active_sheet; match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) { + // A macro-free .xlsx can't hold a VBA project (Excel refuses such a file), so only a .xlsm + // keeps the macros; any other name saves without them, as Excel does. + FileKind::Xlsx if wb.vba_project.is_some() && !path.to_ascii_lowercase().ends_with(".xlsm") => { + let mut plain = wb.clone(); + plain.vba_project = None; + gridcraft_xlsx::write_xlsx(&plain).map_err(|e| EngineError::Other(e.to_string())) + } FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())), FileKind::Xls => { Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into())) @@ -366,6 +373,20 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> { mod tests { use super::*; + #[test] + fn macros_are_saved_only_into_xlsm() { + let mut wb = Workbook::new(); + wb.vba_project = Some(b"vba".to_vec()); + let has_macros = |path: &str| { + let bytes = save_bytes(&wb, path).unwrap(); + gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some() + }; + assert!(has_macros("book.xlsm")); + assert!(has_macros("BOOK.XLSM")); + assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project"); + assert!(wb.vba_project.is_some(), "the open workbook keeps its macros"); + } + /// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a /// root entry and the given stream names. fn cfb(streams: &[&str]) -> Vec { diff --git a/crates/model/src/b64.rs b/crates/model/src/b64.rs index 6354952..30db4b4 100644 --- a/crates/model/src/b64.rs +++ b/crates/model/src/b64.rs @@ -49,6 +49,26 @@ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> decode(&s).ok_or_else(|| serde::de::Error::custom("invalid base64")) } +/// Base64 for optional byte blobs (VBA projects): `null`/missing stays `None`. +pub mod opt { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(v: &Option>, s: S) -> Result { + match v { + Some(data) => s.serialize_str(&super::encode(data)), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + let s = Option::::deserialize(d)?; + match s { + None => Ok(None), + Some(s) => super::decode(&s).map(Some).ok_or_else(|| serde::de::Error::custom("invalid base64")), + } + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 3447c41..3f4d365 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -71,6 +71,11 @@ pub struct Workbook { pub custom_lists: Vec>, /// Named cell styles (Cell Styles gallery): name → style. pub cell_styles: Vec<(String, crate::style::Style)>, + /// VBA project (`xl/vbaProject.bin`) carried through unchanged. GridCraft does not run + /// macros; the blob is preserved so that saving a macro-enabled workbook never destroys the + /// macros it arrived with. base64 in JSON. `None` = the workbook has no macros. + #[serde(default, with = "crate::b64::opt")] + pub vba_project: Option>, /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON. #[serde(skip)] pub preserved: Option>, @@ -97,6 +102,7 @@ impl Workbook { protected_structure: false, custom_lists: vec![], cell_styles: vec![], + vba_project: None, preserved: None, } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 196d47a..caf656d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -694,6 +694,11 @@ impl Dialog { ) } "journal" => Dialog::custom("journal", "Action Journal", json!({})), + "macros" => { + let mut d = Dialog::custom("macros", "Macros", json!({})); + d.result = app.session.run("view.macros", json!({})).ok(); + d + } "statistics" => { let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({})); d.result = app.session.run("review.workbookStatistics", json!({})).ok(); @@ -846,7 +851,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let width = match d.name.as_str() { "formatCells" => 560.0, "about" => 660.0, - "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0, + "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0, "recommendedCharts" => 600.0, "analysisToolPak" => 420.0, "solver" => 460.0, @@ -893,6 +898,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } }); } + "macros" => macros(ui, &mut d), "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { let r = if d.name == "comments" { app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) @@ -1285,6 +1291,34 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) { } } +/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the +/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is +/// preserved and not executed. +fn macros(ui: &mut egui::Ui, d: &mut Dialog) { + ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); + ui.add_space(6.0); + let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); + if items.is_empty() { + ui.weak("This workbook has no scripts or macros."); + return; + } + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for it in &items { + let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); + let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba"); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).strong()); + if is_vba { + ui.weak("— VBA project (preserved, not run)"); + } else { + let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); + ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" })); + } + }); + } + }); +} + fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { ui.horizontal(|ui| { for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 820acf7..cf12658 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2272,6 +2272,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})), Key::Space if cmd && !shift => app.run_or_alert("selection.column", json!({})), Key::Space if shift && !cmd => app.run_or_alert("selection.row", json!({})), + Key::F8 if m.alt => { + app.open_dialog("macros", json!({})); + } Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), Key::F4 if !cmd => {} _ if cmd => crate::ribbon::shortcut(app, key, m), @@ -2335,7 +2338,12 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) let Some(sh) = wb.sheet(si) else { return }; let _ = geo; // Tables get an autofilter on demand. - let table_range = sh.tables.first().filter(|t| col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range); + let table_range = sh + .tables + .iter() + .find(|t| t.range.start.row <= at.y as u32 || true) + .filter(|t| col >= t.range.start.col && col <= t.range.end.col) + .map(|t| t.range); if sh.autofilter.is_none() && let Some(r) = table_range { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 144622b..ae47249 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -132,6 +132,23 @@ pub struct SheetView { pub scroll: egui::Vec2, } +/// Severity of a notice shown in the info bar under the ribbon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoticeKind { + Info, + Warning, +} + +/// A dismissible bar under the ribbon, in the spirit of Excel's message bar. Used to tell the +/// user what an opened file carried that GridCraft handled differently (e.g. preserved macros). +#[derive(Clone, Debug, PartialEq)] +pub struct Notice { + pub text: String, + pub kind: NoticeKind, + /// Optional button: `(label, dialog name)` — opens that dialog when clicked. + pub action: Option<(String, String)>, +} + /// Host services the platform shell provides (file pickers, opening URLs). #[derive(Default)] pub struct Services { @@ -183,6 +200,8 @@ pub struct SheetApp { pub backstage: Option, pub message: Option<(String, String)>, pub toast: Option<(String, f64)>, + /// Dismissible info bar under the ribbon (open-time notices). + pub notice: Option, pub services: Services, /// Synthetic input from agents, one batch per frame (drags need several frames). pub synthetic: std::collections::VecDeque>, @@ -220,6 +239,7 @@ impl SheetApp { backstage: None, message: None, toast: None, + notice: None, services, synthetic: Default::default(), control_rx: None, @@ -474,6 +494,18 @@ impl SheetApp { self.message = None; Ok(Json::Null) } + "ui.notice" => Ok(match &self.notice { + Some(n) => json!({ + "text": n.text, + "kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" }, + "action": n.action, + }), + None => Json::Null, + }), + "ui.notice.dismiss" => { + self.notice = None; + Ok(Json::Null) + } _ => return None, }; Some(r) @@ -557,16 +589,48 @@ impl SheetApp { pub fn open_path(&mut self, path: &str) { match self.run("file.open", json!({"path": path})) { - Ok(_) => { + Ok(r) => { self.ui.recent.retain(|p| p != path); self.ui.recent.insert(0, path.to_string()); self.ui.recent.truncate(20); + self.show_open_warnings(&r); } Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); } + /// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the + /// most important one (a preserved VBA project outranks, say, a skipped optional part). + /// Kept deliberately generic: any open-time warning can reach the user this way. + pub fn show_open_warnings(&mut self, r: &Json) { + let Some(list) = r.get("warnings").and_then(Json::as_array) else { + self.notice = None; + return; + }; + let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect(); + if msgs.is_empty() { + // A clean open clears any notice left over from the previous document. + self.notice = None; + return; + } + let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect(); + if !vba.is_empty() { + self.notice = Some(Notice { + text: "This workbook contains VBA macros. GridCraft keeps them intact when you save, but does not run them.".into(), + kind: NoticeKind::Warning, + action: Some(("What this means".into(), "macros".into())), + }); + } else { + let text = if msgs.len() == 1 { + msgs[0].to_string() + } else { + format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0]) + }; + self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None }); + } + } + pub fn view_key(&self) -> Option<(u64, usize)> { self.session.active().map(|d| (d.uid, d.wb.active_sheet)) } @@ -742,7 +806,10 @@ impl SheetApp { let b64 = gridcraft_engine::io::base64_encode(&bytes); match self.run("file.open", json!({"name": name, "base64": b64})) { // A file picked from File › Open (web) arrives here: show it, not the Backstage. - Ok(_) => self.backstage = None, + Ok(r) => { + self.backstage = None; + self.show_open_warnings(&r); + } Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); @@ -779,6 +846,7 @@ impl SheetApp { backstage::show(self, ui); } else { ribbon::show(self, ui); + widgets::notice_bar(self, ui); if self.session.prefs.formula_bar { formula_bar::show(self, ui); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index f91c8d3..01965b6 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1844,6 +1844,9 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { } fn automate(app: &mut SheetApp, ui: &mut Ui) { + if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { + app.open_dialog("macros", json!({})); + } if big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false).clicked() { app.open_dialog("commandSearch", json!({})); } diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 6e68ab5..4c977b7 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -229,6 +229,10 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { }; ui.label(egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim)); if let Some(d) = app.session.active() { + if d.wb.vba_project.is_some() { + ui.add_space(12.0); + ui.label(egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger)); + } if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual { ui.add_space(12.0); ui.label(egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim)); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 19221b9..6774154 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -45,6 +45,11 @@ pub struct Tokens { pub menu_bg: Color32, pub shadow: Color32, pub danger: Color32, + /// Info-bar (notice strip) fills, by severity. + pub notice_warn_bg: Color32, + pub notice_warn_border: Color32, + pub notice_info_bg: Color32, + pub notice_info_border: Color32, } impl Tokens { @@ -83,6 +88,10 @@ impl Tokens { menu_bg: Color32::WHITE, shadow: Color32::from_black_alpha(40), danger: Color32::from_rgb(0xC4, 0x2B, 0x1C), + notice_warn_bg: Color32::from_rgb(0xFF, 0xF4, 0xCE), + notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E), + notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB), + notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC), } } @@ -121,6 +130,10 @@ impl Tokens { menu_bg: Color32::from_rgb(0x30, 0x30, 0x30), shadow: Color32::from_black_alpha(90), danger: Color32::from_rgb(0xF1, 0x70, 0x5F), + notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E), + notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30), + notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44), + notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72), } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 7369892..a006c6b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -236,3 +236,51 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) { ); ctx.request_repaint_after(std::time::Duration::from_millis(200)); } + +/// The info bar under the ribbon (Excel's message-bar slot). Shows an open-time notice with an +/// optional action button and a dismiss ✕. Returns nothing; dismissing clears `app.notice`. +pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) { + let Some(notice) = app.notice.clone() else { return }; + let t = Tokens::get(ui.ctx()); + let (bg, border) = match notice.kind { + crate::NoticeKind::Warning => (t.notice_warn_bg, t.notice_warn_border), + crate::NoticeKind::Info => (t.notice_info_bg, t.notice_info_border), + }; + let mut dismiss = false; + let mut action: Option<(String, String)> = None; + egui::Panel::top("notice_bar").frame(egui::Frame::NONE.fill(bg).inner_margin(egui::Margin::symmetric(12, 0))).show(ui, |ui| { + // Bottom border as a 1px strip spanning the bar. + let bar = ui.max_rect(); + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, border), StrokeKind::Inside); + ui.horizontal_centered(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + icons::paint( + ui.painter(), + r, + match notice.kind { + crate::NoticeKind::Warning => Icon::Lock, + crate::NoticeKind::Info => Icon::Note, + }, + border, + ); + ui.add_space(6.0); + ui.label(egui::RichText::new(¬ice.text).font(theme::ui_font(12.5)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icon_button(ui, Icon::Close, t.text_dim, "Dismiss", vec2(20.0, 20.0)).clicked() { + dismiss = true; + } + if let Some((label, dialog)) = ¬ice.action + && ui.button(egui::RichText::new(label).font(theme::ui_font(12.5))).clicked() + { + action = Some((label.clone(), dialog.clone())); + } + }); + }); + }); + if let Some((_, dialog)) = action { + app.open_dialog(&dialog, serde_json::json!({})); + } + if dismiss { + app.notice = None; + } +} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 260b80a..7093933 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -384,6 +384,7 @@ fn dialogs_open_and_close() { "goTo", "pivotCalculatedField", "about", + "macros", "analysisToolPak", "solver", ] { @@ -484,6 +485,90 @@ fn column_autocomplete_completes_on_enter() { assert_eq!(value(&h, "A5"), Value::from("Ea")); } +/// An .xlsm carrying a VBA project, saved to bytes. +fn macro_workbook_bytes() -> Vec { + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec()); + gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap() +} + +#[test] +fn opening_a_macro_workbook_shows_a_notice() { + // The reader's "macros kept but not executed" warning must reach a dismissible info bar, + // not be silently dropped (issue #6 follow-up: Excel-style message bar). + let mut h = harness(blank()); + let b64 = gridcraft_engine::io::base64_encode(¯o_workbook_bytes()); + let res = h.state_mut().session.run("file.open", json!({"name": "macros.xlsm", "base64": b64})).unwrap(); + assert!( + res.get("warnings").and_then(|w| w.as_array()).is_some_and(|a| a.iter().any(|m| m.as_str().is_some_and(|s| s.contains("macros")))), + "engine reports the macro warning: {res}" + ); + // The UI-side handler turns that open result into the notice (open_path does this in the app). + h.state_mut().show_open_warnings(&res); + let n = h.state().notice.clone().expect("a notice was shown"); + assert!(n.text.contains("VBA"), "{}", n.text); + assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Warning); + assert_eq!(n.action.as_ref().map(|a| a.1.as_str()), Some("macros"), "action opens the Macros dialog"); + + // The notice bar renders without panic; `ui.notice` surfaces it; dismissing clears it. + h.run_steps(2); + let got = h.state_mut().run("ui.notice", json!({})).unwrap(); + assert_eq!(got.get("kind").and_then(|k| k.as_str()), Some("warning")); + h.state_mut().run("ui.notice.dismiss", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().notice.is_none()); +} + +#[test] +fn plain_open_warning_becomes_an_info_notice() { + let mut h = harness(blank()); + h.state_mut().show_open_warnings(&json!({"warnings": ["links to external workbooks are not supported"]})); + let n = h.state().notice.clone().unwrap(); + assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Info); + assert!(n.action.is_none()); + assert!(n.text.contains("external")); + // No warnings → no notice, no panic. + h.state_mut().show_open_warnings(&json!({"warnings": []})); + assert!(h.state().notice.is_none()); +} + +#[test] +fn macros_dialog_lists_vba_and_scripts() { + let mut s = blank(); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]); + s.active_mut().unwrap().wb = std::sync::Arc::new(wb); + let mut h = harness(s); + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(3); + let d = h.state().dialog.as_ref().expect("macros dialog open"); + assert_eq!(d.name(), "macros"); + let items = d.result.as_ref().and_then(|r| r.as_array()).cloned().unwrap_or_default(); + assert!(items.iter().any(|i| i.get("kind").and_then(|k| k.as_str()) == Some("vba")), "{items:?}"); + h.state_mut().dialog = None; +} + +#[test] +fn status_bar_shows_macro_badge() { + let mut s = blank(); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]); + s.active_mut().unwrap().wb = std::sync::Arc::new(wb); + // Rendering the status bar must not panic with a VBA project present. + let mut h = harness(s); + h.run_steps(3); + h.state_mut().ui.dark = true; + h.run_steps(2); +} + +#[test] +fn alt_f8_opens_the_macros_dialog() { + let mut h = harness(blank()); + key(&mut h, Key::F8, Modifiers::ALT); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("macros")); + h.state_mut().dialog = None; +} + #[test] fn general_numbers_fit_by_the_cells_own_font() { // 9pt marks in narrow columns (a stored width of 4, i.e. 28px; common in mark sheets): "100" fits. diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 7c00d01..a7227cf 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -313,8 +313,18 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { wb.props.company = x.child("Company").map(|e| e.text.trim().to_string()).unwrap_or_default(); } - if wb_rels.iter().any(|r| r.kind == "vbaProject") { - cx.warn("macros (VBA project) are not supported and were dropped"); + // A VBA project is carried through unchanged: GridCraft never runs macros, but preserving the + // binary keeps macro-enabled workbooks from being silently stripped on save. + if let Some(r) = find_rel(&wb_rels, "vbaProject").cloned() { + match cx.pkg.read(&r.target) { + Ok(Some(data)) => { + wb.vba_project = Some(data); + cx.warn("macros (VBA project) were kept but not executed"); + } + Ok(None) => cx.warn("macros (VBA project) reference a missing part and were dropped"), + Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)), + Err(e) => cx.warn(format!("macros (VBA project) could not be read and were dropped: {e}")), + } } wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml); // Calls of LAMBDA names are spelled like the names (the parser upper-cases them). diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 2385b8e..9e14d19 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -621,6 +621,56 @@ fn writing_repairs_invalid_models() { assert_eq!(s.cell(at("B2")).unwrap().style, StyleId::DEFAULT); } +#[test] +fn vba_project_roundtrip() { + // A VBA project blob is preserved byte-identically through write and read, and the package + // carries the macro-enabled content type plus the vbaProject relationship. + let vba = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec(); + let mut wb = Workbook::new(); + wb.vba_project = Some(vba.clone()); + let bytes = write_xlsx(&wb).unwrap(); + assert_eq!(write_xlsx(&wb).unwrap(), bytes, "output is deterministic"); + + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap(); + let names: Vec = (0..z.len()).map(|i| z.name_for_index(i).unwrap().to_string()).collect(); + assert!(names.iter().any(|x| x == "xl/vbaProject.bin"), "missing vbaProject.bin"); + let mut bin = vec![]; + z.by_name("xl/vbaProject.bin").unwrap().read_to_end(&mut bin).unwrap(); + assert_eq!(bin, vba, "vbaProject.bin written byte-identically"); + let mut ct = String::new(); + z.by_name("[Content_Types].xml").unwrap().read_to_string(&mut ct).unwrap(); + assert!(ct.contains("macroEnabled.main+xml"), "macro-enabled main content type: {ct}"); + assert!(ct.contains("/xl/vbaProject.bin"), "vbaProject content type override: {ct}"); + let mut rels = String::new(); + z.by_name("xl/_rels/workbook.xml.rels").unwrap().read_to_string(&mut rels).unwrap(); + assert!(rels.contains("relationships/vbaProject"), "vbaProject relationship: {rels}"); + + let (back, rep) = read_xlsx(&bytes).unwrap(); + assert_eq!(back.vba_project.as_deref(), Some(vba.as_slice()), "vbaProject survives the read"); + assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings); + + // A workbook without macros stays macro-free and free of VBA warnings. + let (plain, rp) = read_xlsx(&write_xlsx(&Workbook::new()).unwrap()).unwrap(); + assert_eq!(plain.vba_project, None); + assert!(rp.warnings.iter().all(|w| !w.contains("VBA")), "{:?}", rp.warnings); +} + +#[test] +fn vba_project_read_from_foreign_file() { + // An .xlsm built by another producer is captured on read (not dropped with a plain warning). + let vba: &[u8] = b"PK\x03\x04-not-really-but-opaque-bytes"; + let bytes = super::minimal( + "", + &[("xl/vbaProject.bin", "ignored-as-text")], + r#""#, + "", + ); + let (wb, rep) = read_xlsx(&bytes).unwrap(); + assert!(wb.vba_project.is_some(), "VBA blob captured"); + assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings); + let _ = vba; +} + fn sheet1_xml(bytes: &[u8]) -> (String, bool) { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut s = String::new(); diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index dc2ddc4..75461fb 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -20,6 +20,11 @@ pub const CT_DRAWING: &str = "application/vnd.openxmlformats-officedocument.draw pub const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; pub const CT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"; pub const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"; +/// Main workbook part: macro-free and macro-enabled variants (Excel refuses a `.xlsm` whose main +/// part uses the macro-free content type, and vice versa). +pub const CT_WORKBOOK_MAIN: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"; +pub const CT_WORKBOOK_MAIN_MACRO: &str = "application/vnd.ms-excel.sheet.macroEnabled.main+xml"; +pub const CT_VBA: &str = "application/vnd.ms-office.vbaProject"; pub const CT_THREADED_COMMENTS: &str = "application/vnd.ms-excel.threadedcomments+xml"; pub const CT_PERSONS: &str = "application/vnd.ms-excel.person+xml"; pub const REL_THREADED_COMMENT: &str = "http://schemas.microsoft.com/office/2017/10/relationships/threadedComment"; @@ -39,6 +44,9 @@ impl Rels { let id = format!("rId{}", self.items.len() + 1); let ty = match kind { "core-properties" => "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties".to_string(), + // The VBA project relationship lives in the Microsoft-documented namespace, not the + // OpenXML one; Excel rejects the file if this type is wrong. + "vbaProject" => "http://schemas.microsoft.com/office/2006/relationships/vbaProject".to_string(), k => format!("{NS_REL}/{k}"), }; self.items.push((id.clone(), ty, target.to_string(), false)); @@ -337,7 +345,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { out.part("xl/persons/person.xml", Some(CT_PERSONS), x.into_bytes()); wb_rels.add_uri(REL_PERSON, "persons/person.xml"); } - out.part("xl/workbook.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), w.into_bytes()); + out.part("xl/workbook.xml", Some(if wb.vba_project.is_some() { CT_WORKBOOK_MAIN_MACRO } else { CT_WORKBOOK_MAIN }), w.into_bytes()); + if let Some(data) = &wb.vba_project { + wb_rels.add("vbaProject", "vbaProject.bin"); + out.part("xl/vbaProject.bin", Some(CT_VBA), data.clone()); + } out.part("xl/_rels/workbook.xml.rels", None, wb_rels.xml().into_bytes()); // Document properties.