mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 12:29:58 -06:00
XLSX: form controls (control properties, <controls> 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 <control> in the sheet's <controls> 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 <controls> element and VML drawing without them. On save, modelled controls join the kept <controls> 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 <row> elements, so an anchor's <xdr:row> inside <controls> stays in the tree.
This commit is contained in:
@@ -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<Option<Workbook>> {
|
||||
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::<ControlAction>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>)> {
|
||||
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<Vec<u8
|
||||
match kind {
|
||||
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
|
||||
// the VBA project, so any other name saves without it, as Excel does.
|
||||
// Macros travel in hidden names.
|
||||
// Macros, and button actions xlsx has no place for, travel in hidden names.
|
||||
FileKind::Xlsx => {
|
||||
let named = crate::macros::with_names(wb)?;
|
||||
let named = match crate::cmd::controls::with_action_names(named.as_ref().unwrap_or(wb))? {
|
||||
Some(w) => Some(w),
|
||||
None => named,
|
||||
};
|
||||
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
|
||||
gridcraft_xlsx::write_xlsx_named(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path), book)
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
|
||||
@@ -315,3 +315,63 @@ fn a_locked_control_on_a_protected_sheet_cannot_be_changed() {
|
||||
assert!(err(s.execute("control.format", json!({"id": c, "max": 50}))).contains("protected"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "command": "home.bold"}))).contains("protected"));
|
||||
}
|
||||
|
||||
fn reopen(s: &mut Session) {
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_and_action_survives_xlsx() {
|
||||
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.slider", json!({"at": "D2", "link": "B1", "min": 10, "max": 200, "step": 10, "page": 50, "value": 70})).unwrap();
|
||||
s.execute("insert.spinButton", json!({"at": "D6", "link": "B2", "max": 9, "value": 4})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D9", "text": "Plain", "macro": "Mark"})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D11", "text": "Fixed", "macro": "Mark", "input": "F1:G2", "output": "H1"})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D13", "text": "Command", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
|
||||
s.execute("review.newNote", json!({"cell": "A1", "text": "a note"})).unwrap();
|
||||
let before: Vec<Json> = s
|
||||
.execute("control.list", json!({}))
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut c = c.clone();
|
||||
c.as_object_mut().unwrap().remove("id");
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
reopen(&mut s);
|
||||
let after: Vec<Json> = s
|
||||
.execute("control.list", json!({}))
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut c = c.clone();
|
||||
c.as_object_mut().unwrap().remove("id");
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(before.len(), after.len());
|
||||
for (b, a) in before.iter().zip(&after) {
|
||||
for key in ["kind", "name", "at", "text", "link", "value", "min", "max", "step", "page", "horizontal", "action", "print", "locked"] {
|
||||
assert_eq!(b.get(key), a.get(key), "{key}: {b} vs {a}");
|
||||
}
|
||||
}
|
||||
assert!(s.doc().unwrap().wb.names.iter().all(|n| !n.name.contains("control_actions")), "the hidden name is taken back out");
|
||||
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
|
||||
// A workbook from a file: its command button asks before it runs.
|
||||
let cmd = after.iter().position(|c| c["text"] == json!("Command")).unwrap();
|
||||
let id = s.execute("control.list", json!({})).unwrap()[cmd]["id"].as_u64().unwrap();
|
||||
assert!(matches!(s.execute("control.press", json!({"id": id})), Err(EngineError::Approve(_))));
|
||||
s.execute("control.press", json!({"id": id, "approve": true})).unwrap();
|
||||
assert_eq!(v(&s, "A9"), Value::text("cmd"));
|
||||
// The linked cells still move the controls.
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "120"})).unwrap();
|
||||
assert_eq!(s.execute("control.list", json!({})).unwrap()[0]["value"], json!(120));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user