From 295c57535c3f2d42e70df2a1cf03ca9c309d49d1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:38:07 -0400 Subject: [PATCH] XLSX: form controls (control properties, and VML ClientData), alongside kept controls Buttons, scroll bars (sliders) and spin buttons are written the three ways the published formats describe: a control properties part (xl/ctrlProps/ctrlPropN.xml, formControlPr with objectType, fmlaLink, min, max, inc, page, val and horiz), a in the sheet's inside mc:AlternateContent with its anchor, print and locked flags and a button's macro="[0]!Name", and a VML shape with x:ClientData. Notes and controls share one VML drawing per sheet; shape ids come from 1024-id blocks listed in the drawing's idmap (after the blocks kept header and footer drawings use), notes first, so each control's shapeId names its VML shape. Reading prefers the properties part, takes the caption from the VML text box and falls back to the VML alone for files that only have it. Values are clamped to whole numbers 0..30000 and repaired to pass the model's rules. Form controls of other kinds (check boxes, list boxes, drop-downs) stay with pass-through: the reader returns the shape numbers it modelled, and pass-through keeps the rest of the element and VML drawing without them. On save, modelled controls join the kept element and take a fresh shape block in the kept drawing, so a sheet with both round-trips without duplicates. Properties part names are reserved so kept parts of other sheets don't collide with them. Button actions xlsx has no place for (commands, fixed inputs, output cells) travel in a chunked hidden name keyed by sheet and position, and are taken back out on open. The streaming sheet parser now splits only shallow elements, so an anchor's inside stays in the tree. --- crates/engine/src/cmd/controls.rs | 58 ++- crates/engine/src/io.rs | 10 +- crates/engine/src/tests/controls.rs | 60 +++ crates/xlsx/src/controls.rs | 506 +++++++++++++++++++++ crates/xlsx/src/drawing.rs | 2 +- crates/xlsx/src/lib.rs | 1 + crates/xlsx/src/package.rs | 8 + crates/xlsx/src/passthrough.rs | 4 +- crates/xlsx/src/passthrough/read.rs | 131 +++++- crates/xlsx/src/passthrough/rebase.rs | 40 +- crates/xlsx/src/passthrough/write.rs | 6 + crates/xlsx/src/read.rs | 6 +- crates/xlsx/src/sheet_read.rs | 17 +- crates/xlsx/src/sheet_write.rs | 54 ++- crates/xlsx/src/sheet_write/parts.rs | 10 +- crates/xlsx/src/tests.rs | 1 + crates/xlsx/src/tests/controls_tests.rs | 205 +++++++++ crates/xlsx/src/tests/passthrough_tests.rs | 61 +++ crates/xlsx/src/write.rs | 7 + crates/xlsx/src/xml.rs | 9 +- docs/file-format-parity.md | 4 +- 21 files changed, 1131 insertions(+), 69 deletions(-) create mode 100644 crates/xlsx/src/controls.rs create mode 100644 crates/xlsx/src/tests/controls_tests.rs diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 77193e091..94a4ef7ec 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -13,7 +13,7 @@ use serde_json::{Value as Json, json}; use super::{ CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, find_command, - has_doc, split_sheet, str_param, u32_param, + has_doc, split_sheet, stash, str_param, u32_param, }; use crate::macros::policy::{Policy, macro_policy}; @@ -530,3 +530,59 @@ pub(crate) fn sync_linked_values(wb: &mut Workbook) { } } } + +// ---------------------------------------------------------------- xlsx + +/// The hidden-data key of the button actions xlsx has no place for. +const ACTIONS_KEY: &str = "control_actions"; + +/// A button's action that xlsx carries itself: a macro on the selection (`macro="[0]!Name"`). +fn plain(a: &ControlAction) -> bool { + matches!(a, ControlAction::Macro { input: MacroInput::Selection, output: None, .. }) +} + +/// A copy of `wb` for xlsx with the button actions xlsx can't express (commands, fixed inputs, +/// output cells) in a hidden name, keyed by sheet name and the control's place on the sheet. +/// `None` when there is nothing to store. +pub(crate) fn with_action_names(wb: &Workbook) -> Result> { + let mut items = Vec::new(); + for sh in &wb.sheets { + for (i, c) in sh.controls.iter().enumerate() { + if let Some(a) = c.action.as_ref().filter(|a| !plain(a)) { + items.push(json!({"sheet": sh.name, "index": i, "action": a})); + } + } + } + let stale = stash::get(wb, ACTIONS_KEY).is_some(); + if items.is_empty() && !stale { + return Ok(None); + } + let mut out = wb.clone(); + stash::remove(&mut out, ACTIONS_KEY); + if !items.is_empty() { + stash::set_json(&mut out, ACTIONS_KEY, &Json::Array(items))?; + } + Ok(Some(out)) +} + +/// After a file is opened: gives buttons back the actions kept in the hidden name, and removes +/// it. Entries that don't match a button, or don't parse, are dropped. +pub(crate) fn load_action_names(wb: &mut Workbook) { + let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return }; + stash::remove(wb, ACTIONS_KEY); + for item in items.as_array().into_iter().flatten().take(100_000) { + let (Some(sheet), Some(index)) = (item.get("sheet").and_then(Json::as_str), item.get("index").and_then(Json::as_u64)) else { continue }; + let Ok(action) = serde_json::from_value::(item.get("action").cloned().unwrap_or(Json::Null)) else { continue }; + let valid = match &action { + ControlAction::Macro { name, .. } => !name.trim().is_empty() && name.chars().count() <= MAX_MACRO_NAME, + ControlAction::Command { id, params } => !id.is_empty() && id.len() <= 200 && params.is_object(), + }; + let Some(si) = wb.sheet_index(sheet) else { continue }; + if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index as usize).is_some_and(|c| c.kind == ControlKind::Button)) { + continue; + } + if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index as usize)) { + c.action = Some(action); + } + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 71060a4c9..8351bb3f4 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -118,10 +118,12 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String { } } -/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names. +/// Parses bytes that aren't encrypted, then takes the workbook's macros, and the button actions +/// xlsx has no place for, out of their hidden names. fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec)> { let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?; warnings.extend(crate::macros::load(&mut wb)); + crate::cmd::controls::load_action_names(&mut wb); Ok((wb, warnings)) } @@ -247,9 +249,13 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result