From 099349d91deb84e3cc0532597db0e42d11263206 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:44:41 -0500 Subject: [PATCH] Form controls: a macro assignment removed in another application stays removed on reopen load_action_names gave every kept action back to a button the file assigned no macro. A button that ran a macro with a fixed input range or an output cell, whose macro another application removed while keeping the button's name and place, so came back with the assignment. Now a kept macro action comes back only onto a button the file still assigns that macro, and a kept command only onto a button the file assigns no macro, as the function's comment already said. Tests: a_macro_assignment_removed_in_another_application_stays_removed (the saved file with the controlPr macro attribute and the VML FmlaMacro removed reopens with no action and no hidden name; unedited, with the whole action), a_macro_assigned_in_another_application_replaces_a_stored_command (the file's macro wins over a kept command), and the cleared case in a_kept_button_action_comes_back_only_on_the_same_button. macros.md and file-format-parity.md describe the hidden name and the reopen rule. --- crates/engine/src/cmd/controls.rs | 2 +- crates/engine/src/tests/controls.rs | 103 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 4 +- docs/macros.md | 12 +++- 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index f68effc9e..773262480 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -627,7 +627,7 @@ pub(crate) fn load_action_names(wb: &mut Workbook) { && text("name").is_none_or(|n| n == c.display_name()) && text("at").is_none_or(|a| a == c.anchor.cell.a1()) && match (&c.action, &action) { - (None, _) => true, + (None, ControlAction::Command { .. }) => true, (Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept), _ => false, } diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs index ba770b598..8dce29fcd 100644 --- a/crates/engine/src/tests/controls.rs +++ b/crates/engine/src/tests/controls.rs @@ -420,6 +420,109 @@ fn a_kept_button_action_comes_back_only_on_the_same_button() { "{i}" ); } + // Another application cleared the macro of "Fixed": the stored action (with its fixed input) + // does not bring the assignment back. The command on a button given no macro still does. + let mut cleared = kept(&stored); + cleared.sheet_mut(0).unwrap().controls[2].action = None; + load_action_names(&mut cleared); + assert_eq!(cleared.sheets[0].controls[2].action, None, "the removed assignment stays removed"); + assert_eq!(cleared.sheets[0].controls[0].action, actions(&wb)[0], "the command comes back"); +} + +/// The saved xlsx bytes of `s`'s workbook. +fn saved_xlsx(s: &mut Session) -> Vec { + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap() +} + +/// `bytes` with `edit` applied to the text of every part, as another application that rewrote +/// those parts would leave the file. +fn edit_parts(bytes: &[u8], mut edit: impl FnMut(&str, String) -> String) -> Vec { + use std::io::{Cursor, Read, Write}; + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..zip.len() { + let mut part = zip.by_index(i).unwrap(); + let name = part.name().to_string(); + let mut data = Vec::new(); + part.read_to_end(&mut data).unwrap(); + let data = match String::from_utf8(data) { + Ok(text) => edit(&name, text).into_bytes(), + Err(e) => e.into_bytes(), + }; + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&data).unwrap(); + } + out.finish().unwrap().into_inner() +} + +/// Opens `bytes` as `name`, checks the hidden name of the button actions is taken back out, and +/// lists the controls. +fn open_and_list(s: &mut Session, name: &str, bytes: &[u8]) -> Json { + s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(bytes)})).unwrap(); + assert!(s.doc().unwrap().wb.names.iter().all(|n| !n.name.contains("control_actions")), "{name}: the hidden name is taken back out"); + s.execute("control.list", json!({})).unwrap() +} + +/// The macro reference xlsx writes for a button that runs `Mark`. +const MARK: &str = "[0]!Mark"; + +/// Another application removed a button's macro assignment and kept the button with its name and +/// place: the hidden name's copy of the action (with the fixed input and the output cell) does not +/// bring the assignment back. The same file, unedited, reopens with the whole action. +#[test] +fn a_macro_assignment_removed_in_another_application_stays_removed() { + let mut s = s(); + s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}], "output": {"onConflict": "overwrite"}})).unwrap(); + s.execute("insert.button", json!({"at": "D2", "text": "Run", "macro": "Mark", "input": "F1:G2", "output": "H1"})).unwrap(); + let before = s.execute("control.list", json!({})).unwrap()[0].clone(); + let full = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Fixed("F1:G2".into()), output: Some("H1".into()) }; + assert_eq!(before["action"], serde_json::to_value(&full).unwrap()); + let bytes = saved_xlsx(&mut s); + // xlsx assigns the macro in the sheet's `` and in the VML shape's + // `` (the parts the reader takes it from); clearing the assignment drops both. + let (attr, fmla) = (format!(" macro=\"{MARK}\""), format!("{MARK}")); + let mut edited = Vec::new(); + let cleared = edit_parts(&bytes, |name, text| { + if text.contains(&attr) || text.contains(&fmla) { + edited.push(name.to_string()); + } + text.replace(&attr, "").replace(&fmla, "") + }); + edited.sort(); + assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]); + let list = open_and_list(&mut s, "cleared.xlsx", &cleared); + assert_eq!(list.as_array().unwrap().len(), 1); + for key in ["kind", "name", "at", "text"] { + assert_eq!(list[0][key], before[key], "{key}: the same button"); + } + assert_eq!(list[0].get("action"), None, "the removed assignment stays removed"); + // Unedited, the file still assigns the macro, and the action comes back whole. + let list = open_and_list(&mut s, "kept.xlsx", &bytes); + assert_eq!(list[0]["action"], before["action"]); +} + +/// Another application assigned a macro to a button that ran a command (kept in the hidden name): +/// the file's macro wins, as it does when a macro button is given another macro. +#[test] +fn a_macro_assigned_in_another_application_replaces_a_stored_command() { + let mut s = s(); + s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap(); + s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap(); + let bytes = saved_xlsx(&mut s); + let (pr, data) = (""); + let mut edited = Vec::new(); + let assigned = edit_parts(&bytes, |name, text| { + if text.contains(pr) || text.contains(data) { + edited.push(name.to_string()); + } + text.replace(pr, &format!("{MARK}")) + }); + edited.sort(); + assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]); + let list = open_and_list(&mut s, "assigned.xlsx", &assigned); + let plain = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Selection, output: None }; + assert_eq!(list[0]["action"], serde_json::to_value(&plain).unwrap(), "the file's macro, not the stored command"); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 697dfc696..1cc453a28 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -76,7 +76,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), form controls (buttons, scroll bars and spin buttons: control properties parts, `` and VML `x:ClientData`; a VML-only control is read too), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), other form controls (check boxes, list boxes, drop-downs; written in the same `` element and VML drawing as the modelled ones), ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), form controls (buttons, scroll bars and spin buttons: control properties parts, `` and VML `x:ClientData`; a VML-only control is read too; a button's command, fixed input range or output cell travels in a hidden defined name and comes back only onto the same button while the file still assigns its macro, or none for a command), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), other form controls (check boxes, list boxes, drop-downs; written in the same `` element and VML drawing as the modelled ones), ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), pictures in headers and footers (`&G`, #1257: their VML drawing and images are written back unchanged; they are not shown, and can't be inserted), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | @@ -145,7 +145,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through | +| 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) | | 2026-11-01 | minor | Chart data tables preserve showKeys, showHorzBorder, showVertBorder and showOutline through native c:dTable XLSX import/export; synthetic tests cover all 16 flag combinations and boolean defaults. Excel-side verification remains outstanding. | | 2026-11-01 | minor | XLSX reads and writes Page Setup's page order: `` for Over, then down, and no attribute for Down, then over (the default), as Excel writes them; PDF export and printing number and order the pages by it | | 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) | diff --git a/docs/macros.md b/docs/macros.md index 11e1030a8..8073b2fcd 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-11-02 · **Change:** minor (form buttons run macros; `macro.run` takes `output`; earlier: a close that waits on the step editor's question runs in the editor's window, and a further close asked meanwhile waits for the answer or is refused; earlier: closing a workbook or quitting asks about the step editor's unsaved draft first; recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) +> **Last updated:** 2026-11-02 · **Change:** minor (form buttons run macros, `macro.run` takes `output`, and a button action kept in a hidden name comes back on reopen only while the file still agrees with it; earlier: a close that waits on the step editor's question runs in the editor's window, and a further close asked meanwhile waits for the answer or is refused; earlier: closing a workbook or quitting asks about the step editor's unsaved draft first; recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro @@ -265,6 +265,16 @@ default) or always on one range (`input: "B2:D9"`), with an optional output cell a step does, inside the scripts' file sandbox unless it is an approved file command, so a button in a workbook opened from a file asks before it runs anything. +In xlsx a button carries its macro as Excel writes it (`macro="[0]!Name"`, a macro run on the +selection). A command, a fixed input range or an output cell has no place there and is kept in a +hidden defined name (`_gc_control_actions_`), keyed by the sheet, the button's place, name and +anchor cell. When the file is opened the hidden name is removed and each kept action goes back +onto its button only when the file still agrees with it: a kept macro action needs the file to +assign that macro still, so an assignment removed in another application stays removed and a +button given another macro keeps that one; a kept command needs the file to assign no macro. A +button whose name, anchor cell or place changed, or which another application removed, gets no +kept action. + Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden defined names (`_gc_macro__`, the macro's JSON split into string literals within Excel's limits), which other spreadsheet applications keep but do not run. A visible name of that shape