From 726a31740e63cb65de3d0c4cbdf8147d297224d4 Mon Sep 17 00:00:00 2001 From: justinjohn0306 <34035011+justinjohn0306@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:54:10 +0530 Subject: [PATCH] Preserve VBA macros on round-trip; never destroy a macro-enabled workbook (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening an .xlsm and saving it silently stripped xl/vbaProject.bin, turning a macro-enabled workbook into a macro-free one — data loss for anyone whose work depends on macros. GridCraft still does not run VBA, but it now carries the project through unchanged. - model: `Workbook.vba_project: Option>` (base64 in JSON via a new `b64::opt` helper). `None` = no macros. - xlsx read: capture the `vbaProject` relationship's bytes instead of warning and dropping them; warn that macros are kept but not executed. - xlsx write: when present, emit `xl/vbaProject.bin` byte-identical, add the `.../relationships/vbaProject` relationship, switch the main part to the macro-enabled content type (`...sheet.macroEnabled.main+xml`) and add the `application/vnd.ms-office.vbaProject` override. - engine: `view.macros` lists the preserved project alongside GridCraft's own scripts (`{"kind":"vba","executed":false}`), so callers can see it is there. - ROADMAP: scope now "preserve, don't execute" (was "out of scope"). Tests: round-trip byte-identical blob + correct content types/relationship; capture from a foreign-produced .xlsm; a macro-free workbook stays clean. --- ROADMAP.md | 10 +++--- crates/engine/src/cmd/extra.rs | 14 ++++++--- crates/model/src/b64.rs | 20 ++++++++++++ crates/model/src/workbook.rs | 6 ++++ crates/xlsx/src/read.rs | 14 +++++++-- crates/xlsx/src/tests/roundtrip.rs | 50 ++++++++++++++++++++++++++++++ crates/xlsx/src/write.rs | 14 ++++++++- 7 files changed, 117 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1b0cd3468..99c12375a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,10 +44,12 @@ without losing data, and not hit a crash in a normal session. The gates: weeks running continuously with 3–4 parallel agents), so **≈ 215 hours in total from today**. Breakdown under "What's left" below. -Scope decisions that affect the count: VBA is out of scope (GridCraft has its own recorded and -editable scripts instead), and features that need Microsoft cloud services (Copilot, live Stocks/ -Geography data types, co-authoring through OneDrive, Smart Lookup, Translate) will be replaced -by open equivalents or left out, not cloned. +Scope decisions that affect the count: VBA macro projects in opened workbooks are **preserved** +(GridCraft carries `xl/vbaProject.bin` through unchanged so saving a macro-enabled workbook never +destroys its macros) but **not executed** — GridCraft's own recorded and editable scripts are the +supported automation path. A VBA interpreter is not planned. Features that need Microsoft cloud +services (Copilot, live Stocks/Geography data types, co-authoring through OneDrive, Smart Lookup, +Translate) will be replaced by open equivalents or left out, not cloned. ### What's left diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 5ebc8b8ce..b6908661c 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -786,10 +786,16 @@ fn new_script(s: &mut Session, p: &Json) -> Result { } fn all_scripts(s: &mut Session, _: &Json) -> Result { - let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME); - Ok(Json::Array( - all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(), - )) + let wb = &s.doc()?.wb; + let all = stash_get(wb, SCRIPTS_NAME); + let mut list: Vec = + all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(); + // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on + // save but never executed here, so it has no step count. + if let Some(bytes) = &wb.vba_project { + list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false})); + } + Ok(Json::Array(list)) } fn run_script(s: &mut Session, p: &Json) -> Result { diff --git a/crates/model/src/b64.rs b/crates/model/src/b64.rs index 6354952f7..30db4b43d 100644 --- a/crates/model/src/b64.rs +++ b/crates/model/src/b64.rs @@ -49,6 +49,26 @@ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> decode(&s).ok_or_else(|| serde::de::Error::custom("invalid base64")) } +/// Base64 for optional byte blobs (VBA projects): `null`/missing stays `None`. +pub mod opt { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(v: &Option>, s: S) -> Result { + match v { + Some(data) => s.serialize_str(&super::encode(data)), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + let s = Option::::deserialize(d)?; + match s { + None => Ok(None), + Some(s) => super::decode(&s).map(Some).ok_or_else(|| serde::de::Error::custom("invalid base64")), + } + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 8652b20b3..ea404bae2 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -69,6 +69,11 @@ pub struct Workbook { pub custom_lists: Vec>, /// Named cell styles (Cell Styles gallery): name → style. pub cell_styles: Vec<(String, crate::style::Style)>, + /// VBA project (`xl/vbaProject.bin`) carried through unchanged. GridCraft does not run + /// macros; the blob is preserved so that saving a macro-enabled workbook never destroys the + /// macros it arrived with. base64 in JSON. `None` = the workbook has no macros. + #[serde(default, with = "crate::b64::opt")] + pub vba_project: Option>, } impl Default for Workbook { @@ -92,6 +97,7 @@ impl Workbook { protected_structure: false, custom_lists: vec![], cell_styles: vec![], + vba_project: None, } } pub fn sheet(&self, i: usize) -> Option<&Sheet> { diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 915d8e4f9..017658c0d 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -273,8 +273,18 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { wb.props.company = x.child("Company").map(|e| e.text.trim().to_string()).unwrap_or_default(); } - if wb_rels.iter().any(|r| r.kind == "vbaProject") { - cx.warn("macros (VBA project) are not supported and were dropped"); + // A VBA project is carried through unchanged: GridCraft never runs macros, but preserving the + // binary keeps macro-enabled workbooks from being silently stripped on save. + if let Some(r) = find_rel(&wb_rels, "vbaProject").cloned() { + match cx.pkg.read(&r.target) { + Ok(Some(data)) => { + wb.vba_project = Some(data); + cx.warn("macros (VBA project) were kept but not executed"); + } + Ok(None) => cx.warn("macros (VBA project) reference a missing part and were dropped"), + Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)), + Err(e) => cx.warn(format!("macros (VBA project) could not be read and were dropped: {e}")), + } } if wb_rels.iter().any(|r| r.kind == "externalLink") { cx.warn("links to external workbooks are not supported"); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index bfc83b92e..b747cbb2a 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -563,3 +563,53 @@ fn writing_repairs_invalid_models() { assert_eq!(s.tables[0].name, "Table1"); assert_eq!(s.cell(at("B2")).unwrap().style, StyleId::DEFAULT); } + +#[test] +fn vba_project_roundtrip() { + // A VBA project blob is preserved byte-identically through write and read, and the package + // carries the macro-enabled content type plus the vbaProject relationship. + let vba = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec(); + let mut wb = Workbook::new(); + wb.vba_project = Some(vba.clone()); + let bytes = write_xlsx(&wb).unwrap(); + assert_eq!(write_xlsx(&wb).unwrap(), bytes, "output is deterministic"); + + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap(); + let names: Vec = (0..z.len()).map(|i| z.name_for_index(i).unwrap().to_string()).collect(); + assert!(names.iter().any(|x| x == "xl/vbaProject.bin"), "missing vbaProject.bin"); + let mut bin = vec![]; + z.by_name("xl/vbaProject.bin").unwrap().read_to_end(&mut bin).unwrap(); + assert_eq!(bin, vba, "vbaProject.bin written byte-identically"); + let mut ct = String::new(); + z.by_name("[Content_Types].xml").unwrap().read_to_string(&mut ct).unwrap(); + assert!(ct.contains("macroEnabled.main+xml"), "macro-enabled main content type: {ct}"); + assert!(ct.contains("/xl/vbaProject.bin"), "vbaProject content type override: {ct}"); + let mut rels = String::new(); + z.by_name("xl/_rels/workbook.xml.rels").unwrap().read_to_string(&mut rels).unwrap(); + assert!(rels.contains("relationships/vbaProject"), "vbaProject relationship: {rels}"); + + let (back, rep) = read_xlsx(&bytes).unwrap(); + assert_eq!(back.vba_project.as_deref(), Some(vba.as_slice()), "vbaProject survives the read"); + assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings); + + // A workbook without macros stays macro-free and free of VBA warnings. + let (plain, rp) = read_xlsx(&write_xlsx(&Workbook::new()).unwrap()).unwrap(); + assert_eq!(plain.vba_project, None); + assert!(rp.warnings.iter().all(|w| !w.contains("VBA")), "{:?}", rp.warnings); +} + +#[test] +fn vba_project_read_from_foreign_file() { + // An .xlsm built by another producer is captured on read (not dropped with a plain warning). + let vba: &[u8] = b"PK\x03\x04-not-really-but-opaque-bytes"; + let bytes = super::minimal( + "", + &[("xl/vbaProject.bin", "ignored-as-text")], + r#""#, + "", + ); + let (wb, rep) = read_xlsx(&bytes).unwrap(); + assert!(wb.vba_project.is_some(), "VBA blob captured"); + assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings); + let _ = vba; +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 073dd36d0..c20c55427 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -20,6 +20,11 @@ pub const CT_DRAWING: &str = "application/vnd.openxmlformats-officedocument.draw pub const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; pub const CT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"; pub const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"; +/// Main workbook part: macro-free and macro-enabled variants (Excel refuses a `.xlsm` whose main +/// part uses the macro-free content type, and vice versa). +pub const CT_WORKBOOK_MAIN: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"; +pub const CT_WORKBOOK_MAIN_MACRO: &str = "application/vnd.ms-excel.sheet.macroEnabled.main+xml"; +pub const CT_VBA: &str = "application/vnd.ms-office.vbaProject"; /// A part's relationships. #[derive(Default)] @@ -33,6 +38,9 @@ impl Rels { let id = format!("rId{}", self.items.len() + 1); let ty = match kind { "core-properties" => "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties".to_string(), + // The VBA project relationship lives in the Microsoft-documented namespace, not the + // OpenXML one; Excel rejects the file if this type is wrong. + "vbaProject" => "http://schemas.microsoft.com/office/2006/relationships/vbaProject".to_string(), k => format!("{NS_REL}/{k}"), }; self.items.push((id.clone(), ty, target.to_string(), false)); @@ -273,7 +281,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { ); wb_rels.add("sheetMetadata", "metadata.xml"); } - out.part("xl/workbook.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), w.into_bytes()); + out.part("xl/workbook.xml", Some(if wb.vba_project.is_some() { CT_WORKBOOK_MAIN_MACRO } else { CT_WORKBOOK_MAIN }), w.into_bytes()); + if let Some(data) = &wb.vba_project { + wb_rels.add("vbaProject", "vbaProject.bin"); + out.part("xl/vbaProject.bin", Some(CT_VBA), data.clone()); + } out.part("xl/_rels/workbook.xml.rels", None, wb_rels.xml().into_bytes()); // Document properties.