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.
This commit is contained in:
prxnvt committed 2026-11-02 01:47:15 -05:00
1 parent dcefd523eb
commit 099349d91d
4 files changed
+117 -4

No files matched your search

+1 -1
View File
@@ -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,
}
+103
View File
@@ -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<u8> {
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<u8> {
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 `<controlPr macro>` and in the VML shape's
// `<x:FmlaMacro>` (the parts the reader takes it from); clearing the assignment drops both.
let (attr, fmla) = (format!(" macro=\"{MARK}\""), format!("<x:FmlaMacro>{MARK}</x:FmlaMacro>"));
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) = ("<controlPr", "<x:ClientData ObjectType=\"Button\">");
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!("<controlPr macro=\"{MARK}\"")).replace(data, &format!("{data}<x:FmlaMacro>{MARK}</x:FmlaMacro>"))
});
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]
+2 -2
View File
@@ -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: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` 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, `<controls>` 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 `<controls>` 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: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` 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, `<controls>` 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 `<controls>` 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: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` 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: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` 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 `<a:font 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, `<controls>`, 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, `<controls>`, 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: `<pageSetup pageOrder="overThenDown">` 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) |
+11 -1
View File
@@ -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_<k>`), 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_<n>_<k>`, 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