mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 04:06:52 -06:00
Preserve VBA macros on round-trip; never destroy a macro-enabled workbook (#6)
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<Vec<u8>>` (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.
This commit is contained in:
+6
-4
@@ -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
|
||||
|
||||
|
||||
@@ -786,10 +786,16 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
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<Json> =
|
||||
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<Json> {
|
||||
|
||||
@@ -49,6 +49,26 @@ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, 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<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
|
||||
match v {
|
||||
Some(data) => s.serialize_str(&super::encode(data)),
|
||||
None => s.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
|
||||
let s = Option::<String>::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]
|
||||
|
||||
@@ -69,6 +69,11 @@ pub struct Workbook {
|
||||
pub custom_lists: Vec<Vec<String>>,
|
||||
/// 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<Vec<u8>>,
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
+12
-2
@@ -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");
|
||||
|
||||
@@ -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<String> = (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(
|
||||
"<sheetData/>",
|
||||
&[("xl/vbaProject.bin", "ignored-as-text")],
|
||||
r#"<Relationship Id="rId9" Type="http://schemas.microsoft.com/office/2006/relationships/vbaProject" Target="vbaProject.bin"/>"#,
|
||||
"",
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<Vec<u8>, 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.
|
||||
|
||||
Reference in New Issue
Block a user