mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-08 03:22:59 +00: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:
1 parent
a0d991e903
commit
295c57535c
21 files changed
+1131
-69
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
//! Form controls (buttons, scroll bars, spin buttons) in xlsx, from the published ECMA-376 and
|
||||
//! MS-XLSX formats. A control is written three ways, as spreadsheet applications expect:
|
||||
//!
|
||||
//! - a control properties part (`xl/ctrlProps/ctrlPropN.xml`, `formControlPr`) with its type,
|
||||
//! linked cell, limits and value;
|
||||
//! - a `<control>` in the sheet's `<controls>`, inside `mc:AlternateContent` (it needs the 2010
|
||||
//! extensions), naming the properties part, the VML shape and the anchor;
|
||||
//! - a legacy VML shape with `x:ClientData`, in the sheet's VML drawing (shared with notes).
|
||||
//!
|
||||
//! Reading prefers the properties part and falls back to the VML. Values are clamped to the
|
||||
//! 0..30000 whole numbers sliders and spin buttons take. Controls of other kinds (check boxes,
|
||||
//! list boxes, drop-downs, ActiveX controls) are left to pass-through (`passthrough.rs`), which
|
||||
//! keeps them unchanged; a sheet can hold both, in one `<controls>` element and one VML drawing.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::{Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MacroInput, Sheet};
|
||||
|
||||
use crate::package::Rel;
|
||||
use crate::read::Ctx;
|
||||
use crate::tables::EMU_PER_PX;
|
||||
use crate::write::{Out, Rels};
|
||||
use crate::xml::{El, esc, esc_attr};
|
||||
|
||||
pub const CT_CTRL_PROP: &str = "application/vnd.ms-excel.controlproperties+xml";
|
||||
pub const REL_CTRL_PROP: &str = "http://schemas.microsoft.com/office/2006/relationships/ctrlProp";
|
||||
const NS_X14: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main";
|
||||
const NS_MC: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006";
|
||||
const NS_XDR: &str = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing";
|
||||
|
||||
/// The most controls read from one sheet.
|
||||
const MAX_CONTROLS: usize = 10_000;
|
||||
|
||||
fn object_type(k: ControlKind) -> &'static str {
|
||||
match k {
|
||||
ControlKind::Button => "Button",
|
||||
ControlKind::Slider => "Scroll",
|
||||
ControlKind::Spin => "Spin",
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_of(t: &str) -> Option<ControlKind> {
|
||||
match t.trim().to_ascii_lowercase().as_str() {
|
||||
"button" => Some(ControlKind::Button),
|
||||
"scroll" => Some(ControlKind::Slider),
|
||||
"spin" => Some(ControlKind::Spin),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The macro a button runs, as Excel writes it: `[0]!Name` (this workbook).
|
||||
fn macro_ref(c: &FormControl) -> Option<String> {
|
||||
match &c.action {
|
||||
Some(ControlAction::Macro { name, .. }) if !name.trim().is_empty() => Some(format!("[0]!{}", name.trim())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The macro name in `[0]!Name`, `Book1.xlsm!Name` or `Name`.
|
||||
fn macro_name(text: &str) -> Option<String> {
|
||||
let name = text.rsplit_once('!').map_or(text, |(_, n)| n).trim().trim_matches('\'');
|
||||
(!name.is_empty() && name.chars().count() <= gridcraft_model::MAX_MACRO_NAME).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// The cell under a sheet position and the offset into it: (cell, dx, dy).
|
||||
fn cell_at(sheet: &Sheet, x: f64, y: f64) -> (CellRef, f64, f64) {
|
||||
let (col, row) = (sheet.col_at(x), sheet.row_at(y));
|
||||
(CellRef::new(row, col), x - sheet.col_left(col), y - sheet.row_top(row))
|
||||
}
|
||||
|
||||
/// The two corners of a control: ((from, dx, dy), (to, dx, dy)), offsets in sheet pixels.
|
||||
fn corners(sheet: &Sheet, a: &Anchor) -> ((CellRef, f64, f64), (CellRef, f64, f64)) {
|
||||
let x1 = sheet.col_left(a.cell.col) + a.dx.max(0.0) as f64;
|
||||
let y1 = sheet.row_top(a.cell.row) + a.dy.max(0.0) as f64;
|
||||
let from = (a.cell, a.dx.max(0.0) as f64, a.dy.max(0.0) as f64);
|
||||
(from, cell_at(sheet, x1 + a.width.max(0.0) as f64, y1 + a.height.max(0.0) as f64))
|
||||
}
|
||||
|
||||
/// The sheet's controls written out: the `<control>` entries of the worksheet's `<controls>`
|
||||
/// element (each in its own `mc:AlternateContent`, with the namespaces it needs, so it can also
|
||||
/// join a kept `<controls>` element) and the VML shapes for its drawing.
|
||||
pub struct Written {
|
||||
pub entries: String,
|
||||
pub vml: String,
|
||||
}
|
||||
|
||||
/// The `<controls>` element for `entries` written by [`write`].
|
||||
pub fn controls_element(entries: &str) -> String {
|
||||
format!(
|
||||
"<mc:AlternateContent xmlns:mc=\"{NS_MC}\"><mc:Choice xmlns:x14=\"{NS_X14}\" Requires=\"x14\"><controls>{entries}</controls></mc:Choice></mc:AlternateContent>"
|
||||
)
|
||||
}
|
||||
|
||||
/// Writes the properties parts and relationships of the sheet's controls. `shape_id(i)` is
|
||||
/// control `i`'s VML shape id.
|
||||
pub fn write(out: &mut Out, rels: &mut Rels, sheet: &Sheet, shape_id: impl Fn(usize) -> u64) -> Written {
|
||||
let mut xml = String::new();
|
||||
let mut vml = String::new();
|
||||
for (i, c) in sheet.controls.iter().enumerate() {
|
||||
// Kept properties parts of another sheet may hold the next name.
|
||||
let n = loop {
|
||||
out.ctrl_props = out.ctrl_props.saturating_add(1);
|
||||
if out.kept.reserve(&format!("xl/ctrlProps/ctrlProp{}.xml", out.ctrl_props)) || out.ctrl_props == u32::MAX {
|
||||
break out.ctrl_props;
|
||||
}
|
||||
};
|
||||
let part = format!("xl/ctrlProps/ctrlProp{n}.xml");
|
||||
out.part(&part, Some(CT_CTRL_PROP), ctrl_prop(c).into_bytes());
|
||||
let rid = rels.add_uri(REL_CTRL_PROP, &format!("../ctrlProps/ctrlProp{n}.xml"));
|
||||
let id = shape_id(i);
|
||||
let ((from, fdx, fdy), (to, tdx, tdy)) = corners(sheet, &c.anchor);
|
||||
let emu = |v: f64| (v.max(0.0) * EMU_PER_PX).round() as i64;
|
||||
let marker = |tag: &str, cell: CellRef, dx: f64, dy: f64| {
|
||||
format!(
|
||||
"<{tag}><xdr:col>{}</xdr:col><xdr:colOff>{}</xdr:colOff><xdr:row>{}</xdr:row><xdr:rowOff>{}</xdr:rowOff></{tag}>",
|
||||
cell.col,
|
||||
emu(dx),
|
||||
cell.row,
|
||||
emu(dy)
|
||||
)
|
||||
};
|
||||
let mut pr = String::from(" defaultSize=\"0\" autoPict=\"0\"");
|
||||
if !c.print {
|
||||
pr.push_str(" print=\"0\"");
|
||||
}
|
||||
if !c.locked {
|
||||
pr.push_str(" locked=\"0\"");
|
||||
}
|
||||
if let Some(m) = macro_ref(c) {
|
||||
let _ = write!(pr, " macro=\"{}\"", esc_attr(&m));
|
||||
}
|
||||
let (move_cells, size_cells) = match c.anchor.mode {
|
||||
AnchorMode::MoveAndSize => (true, true),
|
||||
AnchorMode::MoveOnly => (true, false),
|
||||
AnchorMode::Absolute => (false, false),
|
||||
};
|
||||
let name = esc_attr(&c.display_name());
|
||||
let _ = write!(
|
||||
xml,
|
||||
"<mc:AlternateContent xmlns:mc=\"{NS_MC}\"><mc:Choice xmlns:x14=\"{NS_X14}\" Requires=\"x14\"><control shapeId=\"{id}\" r:id=\"{rid}\" name=\"{name}\"><controlPr{pr}><anchor{}{} xmlns:xdr=\"{NS_XDR}\">{}{}</anchor></controlPr></control></mc:Choice><mc:Fallback><control shapeId=\"{id}\" r:id=\"{rid}\" name=\"{name}\"/></mc:Fallback></mc:AlternateContent>",
|
||||
if move_cells { " moveWithCells=\"1\"" } else { "" },
|
||||
if size_cells { " sizeWithCells=\"1\"" } else { "" },
|
||||
marker("from", from, fdx, fdy),
|
||||
marker("to", to, tdx, tdy),
|
||||
);
|
||||
vml.push_str(&vml_shape(sheet, c, id, i));
|
||||
}
|
||||
Written { entries: xml, vml }
|
||||
}
|
||||
|
||||
/// The control properties part.
|
||||
fn ctrl_prop(c: &FormControl) -> String {
|
||||
let mut s = format!("{}<formControlPr xmlns=\"{NS_X14}\" objectType=\"{}\"", crate::write::XML_DECL, object_type(c.kind));
|
||||
match c.kind {
|
||||
ControlKind::Button => {
|
||||
s.push_str(" lockText=\"1\"");
|
||||
}
|
||||
ControlKind::Slider | ControlKind::Spin => {
|
||||
s.push_str(" dx=\"22\"");
|
||||
if let Some(l) = &c.link {
|
||||
let _ = write!(s, " fmlaLink=\"{}\"", esc_attr(l));
|
||||
}
|
||||
if c.kind == ControlKind::Slider && c.horizontal {
|
||||
s.push_str(" horiz=\"1\"");
|
||||
}
|
||||
let _ = write!(s, " inc=\"{}\" max=\"{}\" min=\"{}\"", c.step, c.max, c.min);
|
||||
if c.kind == ControlKind::Slider {
|
||||
let _ = write!(s, " page=\"{}\"", c.page);
|
||||
}
|
||||
let _ = write!(s, " val=\"{}\"", c.value);
|
||||
}
|
||||
}
|
||||
s.push_str("/>");
|
||||
s
|
||||
}
|
||||
|
||||
/// A control's legacy VML shape.
|
||||
fn vml_shape(sheet: &Sheet, c: &FormControl, id: u64, z: usize) -> String {
|
||||
let ((from, fdx, fdy), (to, tdx, tdy)) = corners(sheet, &c.anchor);
|
||||
let pt = |px: f64| px * 0.75;
|
||||
let x = sheet.col_left(from.col) + fdx;
|
||||
let y = sheet.row_top(from.row) + fdy;
|
||||
let mut data = format!(
|
||||
"<x:ClientData ObjectType=\"{}\">{}{}<x:Anchor>{}, {}, {}, {}, {}, {}, {}, {}</x:Anchor>",
|
||||
object_type(c.kind),
|
||||
// These two elements mean the object does *not* move (size) with its cells.
|
||||
if c.anchor.mode == AnchorMode::Absolute { "<x:MoveWithCells/>" } else { "" },
|
||||
if c.anchor.mode == AnchorMode::MoveAndSize { "" } else { "<x:SizeWithCells/>" },
|
||||
from.col,
|
||||
fdx.round() as i64,
|
||||
from.row,
|
||||
fdy.round() as i64,
|
||||
to.col,
|
||||
tdx.round() as i64,
|
||||
to.row,
|
||||
tdy.round() as i64
|
||||
);
|
||||
if !c.print {
|
||||
data.push_str("<x:PrintObject>False</x:PrintObject>");
|
||||
}
|
||||
if !c.locked {
|
||||
data.push_str("<x:Locked>False</x:Locked>");
|
||||
}
|
||||
data.push_str("<x:AutoFill>False</x:AutoFill>");
|
||||
match c.kind {
|
||||
ControlKind::Button => {
|
||||
if let Some(m) = macro_ref(c) {
|
||||
let _ = write!(data, "<x:FmlaMacro>{}</x:FmlaMacro>", esc(&m));
|
||||
}
|
||||
data.push_str("<x:TextHAlign>Center</x:TextHAlign><x:TextVAlign>Center</x:TextVAlign>");
|
||||
}
|
||||
ControlKind::Slider | ControlKind::Spin => {
|
||||
if let Some(l) = &c.link {
|
||||
let _ = write!(data, "<x:FmlaLink>{}</x:FmlaLink>", esc(l));
|
||||
}
|
||||
let _ = write!(data, "<x:Val>{}</x:Val><x:Min>{}</x:Min><x:Max>{}</x:Max><x:Inc>{}</x:Inc>", c.value, c.min, c.max, c.step);
|
||||
if c.kind == ControlKind::Slider {
|
||||
let _ = write!(data, "<x:Page>{}</x:Page>", c.page);
|
||||
if c.horizontal {
|
||||
data.push_str("<x:Horiz/>");
|
||||
}
|
||||
}
|
||||
data.push_str("<x:Dx>22</x:Dx>");
|
||||
}
|
||||
}
|
||||
data.push_str("</x:ClientData>");
|
||||
let text = if c.kind == ControlKind::Button {
|
||||
format!("<v:textbox style=\"mso-direction-alt:auto\" o:singleclick=\"f\"><div style=\"text-align:center\">{}</div></v:textbox>", esc(&c.text))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"<v:shape id=\"_x0000_s{id}\" type=\"#_x0000_t201\" style=\"position:absolute;margin-left:{:.2}pt;margin-top:{:.2}pt;width:{:.2}pt;height:{:.2}pt;z-index:{}\"{} o:insetmode=\"auto\"><o:lock v:ext=\"edit\" rotation=\"t\"/>{text}{data}</v:shape>",
|
||||
pt(x),
|
||||
pt(y),
|
||||
pt(c.anchor.width.max(0.0) as f64),
|
||||
pt(c.anchor.height.max(0.0) as f64),
|
||||
z + 1,
|
||||
if c.kind == ControlKind::Button { " o:button=\"t\"" } else { " stroked=\"f\" filled=\"f\"" },
|
||||
)
|
||||
}
|
||||
|
||||
/// The VML shape type form controls use.
|
||||
pub const SHAPETYPE: &str = "<v:shapetype id=\"_x0000_t201\" coordsize=\"21600,21600\" o:spt=\"201\" path=\"m,l,21600r21600,l21600,xe\"><v:stroke joinstyle=\"miter\"/><v:path shadowok=\"f\" o:extrusionok=\"f\" strokeok=\"f\" fillok=\"f\" o:connecttype=\"rect\"/><o:lock v:ext=\"edit\" shapetype=\"t\"/></v:shapetype>";
|
||||
|
||||
// ---------------------------------------------------------------- reading
|
||||
|
||||
/// What a control's properties say, from `formControlPr` or VML `x:ClientData`.
|
||||
#[derive(Default)]
|
||||
struct Props {
|
||||
kind: Option<ControlKind>,
|
||||
link: Option<String>,
|
||||
min: Option<i64>,
|
||||
max: Option<i64>,
|
||||
inc: Option<i64>,
|
||||
page: Option<i64>,
|
||||
val: Option<i64>,
|
||||
horiz: Option<bool>,
|
||||
macro_: Option<String>,
|
||||
text: String,
|
||||
print: Option<bool>,
|
||||
locked: Option<bool>,
|
||||
}
|
||||
|
||||
fn props_from_ctrl_prop(e: &El) -> Props {
|
||||
let int = |k: &str| e.attr_f64(k).map(|v| v.round() as i64);
|
||||
Props {
|
||||
kind: e.attr("objectType").and_then(kind_of),
|
||||
link: e.attr("fmlaLink").map(str::to_string),
|
||||
min: int("min"),
|
||||
max: int("max"),
|
||||
inc: int("inc"),
|
||||
page: int("page"),
|
||||
val: int("val"),
|
||||
horiz: e.attr_bool("horiz"),
|
||||
macro_: None,
|
||||
text: String::new(),
|
||||
print: None,
|
||||
locked: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn props_from_vml(shape: &El) -> Option<Props> {
|
||||
let data = shape.child("ClientData")?;
|
||||
let kind = data.attr("ObjectType").and_then(kind_of)?;
|
||||
let text_of = |n: &str| data.child(n).map(|c| c.text.trim().to_string());
|
||||
let int = |n: &str| text_of(n).and_then(|t| t.parse::<f64>().ok()).filter(|v| v.is_finite()).map(|v| v.round() as i64);
|
||||
let truth = |n: &str| text_of(n).map(|t| !t.eq_ignore_ascii_case("false"));
|
||||
let text = shape.child("textbox").map(|t| all_text(t, 0)).unwrap_or_default().trim().to_string();
|
||||
Some(Props {
|
||||
kind: Some(kind),
|
||||
link: text_of("FmlaLink").filter(|t| !t.is_empty()),
|
||||
min: int("Min"),
|
||||
max: int("Max"),
|
||||
inc: int("Inc"),
|
||||
page: int("Page"),
|
||||
val: int("Val"),
|
||||
horiz: data.child("Horiz").map(|_| true),
|
||||
macro_: text_of("FmlaMacro"),
|
||||
text,
|
||||
print: truth("PrintObject"),
|
||||
locked: truth("Locked"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The text inside `e` and its descendants (a VML text box holds `<div>` and `<font>`), up to a
|
||||
/// caption's length; `<br>` is a line break.
|
||||
fn all_text(e: &El, depth: usize) -> String {
|
||||
let mut s = e.text.clone();
|
||||
if depth < 16 {
|
||||
for k in &e.children {
|
||||
if k.name == "br" {
|
||||
s.push('\n');
|
||||
}
|
||||
s.push_str(&all_text(k, depth + 1));
|
||||
if s.len() > 4_096 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A control from what the file says, its numbers clamped to whole numbers 0..30000 that pass
|
||||
/// the model's rules (a hostile or odd file can't give a control it couldn't have).
|
||||
fn build(p: Props, kind: ControlKind, anchor: Anchor, name: Option<&str>, id: u32) -> FormControl {
|
||||
let clamp = |v: Option<i64>, d: u32| v.map_or(d, |v| v.clamp(0, CONTROL_MAX as i64) as u32);
|
||||
let mut c = FormControl { id, kind, anchor, ..FormControl::default() };
|
||||
c.min = clamp(p.min, 0);
|
||||
c.max = clamp(p.max, 100);
|
||||
if c.min >= c.max {
|
||||
if c.min >= CONTROL_MAX {
|
||||
c.min = CONTROL_MAX - 1;
|
||||
}
|
||||
c.max = c.min + 1;
|
||||
}
|
||||
c.step = clamp(p.inc, 1).clamp(1, c.max - c.min);
|
||||
c.page = clamp(p.page, 10).max(c.step);
|
||||
c.value = c.snap(clamp(p.val, c.min) as f64);
|
||||
// Only sliders have an orientation; `horiz` is left out for a vertical one.
|
||||
c.horizontal = kind != ControlKind::Slider || p.horiz.unwrap_or(false);
|
||||
c.link = p.link.map(|l| l.trim().trim_start_matches('=').to_string()).filter(|l| !l.is_empty() && l.chars().count() <= 300);
|
||||
c.text = p.text.chars().take(1_024).collect();
|
||||
if let Some(n) = name {
|
||||
c.name = n.chars().take(255).collect();
|
||||
}
|
||||
c.print = p.print.unwrap_or(true);
|
||||
c.locked = p.locked.unwrap_or(true);
|
||||
if kind == ControlKind::Button
|
||||
&& let Some(m) = p.macro_.as_deref().and_then(macro_name)
|
||||
{
|
||||
c.action = Some(ControlAction::Macro { name: m, input: MacroInput::Selection, output: None });
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The `<control>` elements under a `<controls>` element, looking through `mc:AlternateContent`
|
||||
/// (the Choice, else the Fallback).
|
||||
fn controls_in(e: &El) -> Vec<&El> {
|
||||
let mut out = Vec::new();
|
||||
for k in &e.children {
|
||||
match k.name.as_str() {
|
||||
"control" => out.push(k),
|
||||
"AlternateContent" => {
|
||||
if let Some(inner) = k.child("Choice").filter(|c| c.child("control").is_some()).or_else(|| k.child("Fallback")) {
|
||||
out.extend(inner.kids("control"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `<controls>` element of a worksheet child (`controls`, or one inside AlternateContent).
|
||||
pub fn controls_of(e: &El) -> Option<&El> {
|
||||
match e.name.as_str() {
|
||||
"controls" => Some(e),
|
||||
"AlternateContent" => e.child("Choice").and_then(|c| c.child("controls")).or_else(|| e.child("Fallback").and_then(|c| c.child("controls"))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape number of a VML shape id (`_x0000_s1025` → 1025) or a `shapeId` attribute.
|
||||
fn shape_number(id: &str) -> Option<u64> {
|
||||
id.trim().trim_start_matches("_x0000_s").parse().ok()
|
||||
}
|
||||
|
||||
/// Reads a sheet's buttons, scroll bars and spin buttons: each `<control>` with its properties
|
||||
/// part, else its VML shape, and VML shapes the sheet doesn't list (files that only have VML).
|
||||
/// Returns the shape numbers of the controls it read, so pass-through keeps only the others.
|
||||
pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut Sheet) -> HashSet<u64> {
|
||||
let mut read = HashSet::new();
|
||||
let vml = match rels.iter().find(|r| r.kind == "vmlDrawing") {
|
||||
Some(r) => cx.pkg.read_xml(&r.target).ok().flatten(),
|
||||
None => None,
|
||||
};
|
||||
let shapes: Vec<&El> = vml.as_ref().map(|v| v.kids("shape").collect()).unwrap_or_default();
|
||||
let shape_of = |n: u64| shapes.iter().copied().find(|s| s.attr("id").and_then(shape_number) == Some(n));
|
||||
// Every shape the sheet lists, read here or not.
|
||||
let mut listed: HashSet<u64> = HashSet::new();
|
||||
for ctl in controls.map(controls_in).unwrap_or_default() {
|
||||
let Some(n) = ctl.attr("shapeId").and_then(shape_number) else { continue };
|
||||
listed.insert(n);
|
||||
if sheet.controls.len() >= MAX_CONTROLS {
|
||||
continue;
|
||||
}
|
||||
let shape = shape_of(n);
|
||||
let vml_props = shape.and_then(props_from_vml);
|
||||
let prop_part = ctl.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone());
|
||||
let file_props = match prop_part {
|
||||
Some(t) => cx.pkg.read_xml(&t).ok().flatten().filter(|e| e.name == "formControlPr").map(|e| props_from_ctrl_prop(&e)),
|
||||
None => None,
|
||||
};
|
||||
let pr = ctl.child("controlPr");
|
||||
let mut p = match (file_props, vml_props) {
|
||||
(Some(mut f), v) => {
|
||||
// The properties part has no caption; the VML text box does.
|
||||
if let Some(v) = v {
|
||||
f.text = v.text;
|
||||
f.print = f.print.or(v.print);
|
||||
f.locked = f.locked.or(v.locked);
|
||||
f.macro_ = v.macro_;
|
||||
}
|
||||
f
|
||||
}
|
||||
(None, Some(v)) => v,
|
||||
(None, None) => continue,
|
||||
};
|
||||
if let Some(pr) = pr {
|
||||
if let Some(m) = pr.attr("macro").filter(|m| !m.trim().is_empty()) {
|
||||
p.macro_ = Some(m.to_string());
|
||||
}
|
||||
p.print = pr.attr_bool("print").or(p.print);
|
||||
p.locked = pr.attr_bool("locked").or(p.locked);
|
||||
}
|
||||
// Other kinds (a check box, a list box) stay with pass-through.
|
||||
let Some(kind) = p.kind else { continue };
|
||||
let anchor =
|
||||
pr.and_then(|pr| pr.child("anchor")).and_then(|a| anchor_from_markers(a, sheet)).or_else(|| shape.and_then(|s| vml_anchor(s, sheet)));
|
||||
let Some(anchor) = anchor else { continue };
|
||||
let id = cx.next_object_id();
|
||||
sheet.controls.push(build(p, kind, anchor, ctl.attr("name"), id));
|
||||
read.insert(n);
|
||||
}
|
||||
// Controls known only from the VML.
|
||||
for s in &shapes {
|
||||
let Some(n) = s.attr("id").and_then(shape_number) else { continue };
|
||||
if listed.contains(&n) || sheet.controls.len() >= MAX_CONTROLS {
|
||||
continue;
|
||||
}
|
||||
let Some(p) = props_from_vml(s) else { continue };
|
||||
let (Some(kind), Some(anchor)) = (p.kind, vml_anchor(s, sheet)) else { continue };
|
||||
let id = cx.next_object_id();
|
||||
sheet.controls.push(build(p, kind, anchor, None, id));
|
||||
read.insert(n);
|
||||
}
|
||||
read
|
||||
}
|
||||
|
||||
fn anchor_from_markers(a: &El, sheet: &Sheet) -> Option<Anchor> {
|
||||
let (cell, dx, dy) = crate::drawing::marker(a.child("from")?);
|
||||
let (to, tdx, tdy) = a.child("to").map(crate::drawing::marker).unwrap_or((cell, dx + 64.0, dy + 20.0));
|
||||
let x1 = sheet.col_left(cell.col) + dx as f64;
|
||||
let y1 = sheet.row_top(cell.row) + dy as f64;
|
||||
let x2 = sheet.col_left(to.col) + tdx as f64;
|
||||
let y2 = sheet.row_top(to.row) + tdy as f64;
|
||||
let mode = match (a.flag("moveWithCells", false), a.flag("sizeWithCells", false)) {
|
||||
(_, true) => AnchorMode::MoveAndSize,
|
||||
(true, false) => AnchorMode::MoveOnly,
|
||||
_ => AnchorMode::Absolute,
|
||||
};
|
||||
Some(Anchor { cell, dx, dy, width: (x2 - x1).clamp(1.0, 10_000.0) as f32, height: (y2 - y1).clamp(1.0, 10_000.0) as f32, mode })
|
||||
}
|
||||
|
||||
/// A VML `x:Anchor` (`LeftColumn, LeftOffset, TopRow, TopOffset, RightColumn, RightOffset,
|
||||
/// BottomRow, BottomOffset`, offsets in pixels).
|
||||
fn vml_anchor(shape: &El, sheet: &Sheet) -> Option<Anchor> {
|
||||
let data = shape.child("ClientData")?;
|
||||
let nums: Vec<i64> = data.child("Anchor")?.text.split(',').filter_map(|t| t.trim().parse::<i64>().ok()).collect();
|
||||
let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = nums.as_slice() else { return None };
|
||||
let col = |c: i64| c.clamp(0, gridcraft_core::MAX_COLS as i64 - 1) as u32;
|
||||
let row = |r: i64| r.clamp(0, gridcraft_core::MAX_ROWS as i64 - 1) as u32;
|
||||
let off = |v: i64| v.clamp(0, 10_000) as f64;
|
||||
let cell = CellRef::new(row(*r1), col(*c1));
|
||||
let x1 = sheet.col_left(cell.col) + off(*dx1);
|
||||
let y1 = sheet.row_top(cell.row) + off(*dy1);
|
||||
let x2 = sheet.col_left(col(*c2)) + off(*dx2);
|
||||
let y2 = sheet.row_top(row(*r2)) + off(*dy2);
|
||||
// The elements are there when the object does *not* move (size) with its cells.
|
||||
let mode = match (data.child("MoveWithCells").is_none(), data.child("SizeWithCells").is_none()) {
|
||||
(_, true) => AnchorMode::MoveAndSize,
|
||||
(true, false) => AnchorMode::MoveOnly,
|
||||
_ => AnchorMode::Absolute,
|
||||
};
|
||||
Some(Anchor {
|
||||
cell,
|
||||
dx: off(*dx1) as f32,
|
||||
dy: off(*dy1) as f32,
|
||||
width: (x2 - x1).clamp(1.0, 10_000.0) as f32,
|
||||
height: (y2 - y1).clamp(1.0, 10_000.0) as f32,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
@@ -19,7 +19,7 @@ fn emu_px(v: Option<i64>) -> f32 {
|
||||
v.map(|e| (e as f64 / EMU_PER_PX).clamp(-1e7, 1e7) as f32).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn marker(e: &El) -> (CellRef, f32, f32) {
|
||||
pub(crate) fn marker(e: &El) -> (CellRef, f32, f32) {
|
||||
let num = |n: &str| e.child(n).and_then(|c| c.text.trim().parse::<i64>().ok());
|
||||
let col = num("col").unwrap_or(0).clamp(0, MAX_COLS as i64 - 1) as u32;
|
||||
let row = num("row").unwrap_or(0).clamp(0, MAX_ROWS as i64 - 1) as u32;
|
||||
|
||||
@@ -15,6 +15,7 @@ mod array_formula;
|
||||
mod cfb;
|
||||
mod chart;
|
||||
mod chartex;
|
||||
mod controls;
|
||||
mod crypto;
|
||||
mod csv;
|
||||
mod drawing;
|
||||
|
||||
@@ -52,6 +52,14 @@ impl<'a> Package<'a> {
|
||||
Ok(Package { zip, names, total: 0 })
|
||||
}
|
||||
|
||||
/// Every part's (normalized) name, sorted.
|
||||
#[cfg(test)]
|
||||
pub fn names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.names.keys().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
pub fn has(&self, part: &str) -> bool {
|
||||
self.names.contains_key(&normalize(part))
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! relationship graph, per owner: the package root (custom document properties, ribbon
|
||||
//! customisation, add-in task panes), the workbook (custom XML, links to external workbooks with
|
||||
//! their `<externalReferences>`, data connections, XML maps) and each sheet (query tables, cells
|
||||
//! mapped to XML elements, form controls, ActiveX controls, OLE objects, the sheet background and
|
||||
//! printer settings, with the sheet's `<picture>`, `<oleObjects>` and `<controls>` XML, the `r:id`
|
||||
//! mapped to XML elements, form controls other than the buttons, scroll bars and spin buttons
|
||||
//! `controls.rs` models, ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's `<picture>`, `<oleObjects>` and `<controls>` XML, the `r:id`
|
||||
//! of `<pageSetup>`, the legacy VML shapes those controls need and the pictures of the headers and
|
||||
//! footers, `&G`, with their `<legacyDrawingHF>`). The workbook also keeps its
|
||||
//! `<fileVersion>` and the calcFeatures extension, which Excel needs to trust the saved values.
|
||||
|
||||
@@ -283,8 +283,9 @@ impl KeptReader {
|
||||
(!p.is_empty()).then(|| Arc::new(p))
|
||||
}
|
||||
|
||||
/// Sheet-level content of the sheet part `part`.
|
||||
pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option<Arc<Preserved>> {
|
||||
/// Sheet-level content of the sheet part `part`. `read_controls` are the VML shape numbers of
|
||||
/// the form controls the sheet reader modelled; they are left out of the kept content.
|
||||
pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, read_controls: &HashSet<u64>) -> Option<Arc<Preserved>> {
|
||||
if !self.on {
|
||||
return None;
|
||||
}
|
||||
@@ -306,12 +307,22 @@ impl KeptReader {
|
||||
let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count();
|
||||
let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count();
|
||||
if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes {
|
||||
self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen);
|
||||
self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen, read_controls);
|
||||
}
|
||||
(!p.is_empty()).then(|| Arc::new(p))
|
||||
}
|
||||
|
||||
fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet<String>) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sheet_elements(
|
||||
&mut self,
|
||||
cx: &mut Ctx<'_>,
|
||||
part: &str,
|
||||
name: &str,
|
||||
rels: &[PreservedRel],
|
||||
p: &mut Preserved,
|
||||
seen: &mut HashSet<String>,
|
||||
read_controls: &HashSet<u64>,
|
||||
) {
|
||||
let scan = match cx.pkg.read(part) {
|
||||
Ok(Some(bytes)) => scan_sheet(&bytes),
|
||||
_ => None,
|
||||
@@ -344,10 +355,15 @@ impl KeptReader {
|
||||
Err(why) => cx.warn(format!("sheet \"{name}\": header and footer pictures were dropped: {why}")),
|
||||
}
|
||||
}
|
||||
let mut frags = scan.fragments;
|
||||
// Form controls the sheet reader modelled are written from the model; the others stay.
|
||||
let mut frags: Vec<(String, String)> = scan
|
||||
.fragments
|
||||
.into_iter()
|
||||
.filter_map(|(slot, x)| if slot == "controls" { without_controls(&x, read_controls).map(|x| (slot, x)) } else { Some((slot, x)) })
|
||||
.collect();
|
||||
// Controls and OLE objects are drawn from shapes in the legacy VML drawing.
|
||||
if frags.iter().any(|(slot, _)| slot != "picture") {
|
||||
match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) {
|
||||
match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen, read_controls) {
|
||||
Ok((vml, parts)) => {
|
||||
p.parts.extend(parts);
|
||||
p.vml = Some(vml);
|
||||
@@ -383,14 +399,15 @@ impl KeptReader {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its
|
||||
/// shapes refer to (preview images).
|
||||
/// The sheet's VML drawing without its notes and the form controls read into the model
|
||||
/// (GridCraft writes those), and the parts its shapes refer to (preview images).
|
||||
fn vml(
|
||||
&mut self,
|
||||
cx: &mut Ctx<'_>,
|
||||
rels: &[PreservedRel],
|
||||
rid: Option<&str>,
|
||||
seen: &mut HashSet<String>,
|
||||
read_controls: &HashSet<u64>,
|
||||
) -> Result<(PreservedPart, Vec<PreservedPart>), String> {
|
||||
let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?;
|
||||
// Charged to the kept total like any other part: many sheets can point at one highly
|
||||
@@ -407,7 +424,7 @@ impl KeptReader {
|
||||
};
|
||||
self.budget = self.budget.saturating_sub(data.len());
|
||||
let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?;
|
||||
let text = strip_notes(&text);
|
||||
let text = strip_shapes(&text, read_controls);
|
||||
let vrels = raw_rels(cx, &rel.target)?;
|
||||
let mut q = Preserved::default();
|
||||
let roots: Vec<PreservedRel> = vrels.iter().filter(|r| !r.external).cloned().collect();
|
||||
@@ -557,13 +574,14 @@ fn shape_spans(vml: &str) -> Vec<(usize, usize)> {
|
||||
spans
|
||||
}
|
||||
|
||||
/// VML without its note shapes (`ObjectType="Note"`).
|
||||
fn strip_notes(vml: &str) -> String {
|
||||
/// VML without its note shapes (`ObjectType="Note"`) and the shapes numbered in `drop`.
|
||||
fn strip_shapes(vml: &str, drop: &HashSet<u64>) -> String {
|
||||
let mut out = String::with_capacity(vml.len());
|
||||
let mut last = 0;
|
||||
for (a, b) in shape_spans(vml) {
|
||||
let shape = vml.get(a..b).unwrap_or("");
|
||||
if shape.contains("ObjectType=\"Note\"") {
|
||||
let numbered = shape_numbers(shape).first().is_some_and(|n| drop.contains(n));
|
||||
if shape.contains("ObjectType=\"Note\"") || numbered {
|
||||
out.push_str(vml.get(last..a).unwrap_or(""));
|
||||
last = b;
|
||||
}
|
||||
@@ -595,19 +613,32 @@ pub(super) fn vml_block_max(vml: &str) -> u64 {
|
||||
used.max(claimed)
|
||||
}
|
||||
|
||||
/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh
|
||||
/// shape block so they can't collide with the kept shapes the sheet XML refers to.
|
||||
/// The first shape block free in kept VML: GridCraft's own shapes (notes, then form controls) are
|
||||
/// numbered from `block * 1024 + 1` when they join it ([`merge_vml`]).
|
||||
pub(super) fn first_free_block(kept: &str) -> u64 {
|
||||
shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1
|
||||
}
|
||||
|
||||
/// Kept VML (controls, OLE objects) plus GridCraft's note and form control shapes: ours get ids
|
||||
/// in fresh shape blocks so they can't collide with the kept shapes the sheet XML refers to.
|
||||
pub(super) fn merge_vml(kept: &str, ours: &str) -> String {
|
||||
let Some(close) = kept.rfind("</xml>") else { return ours.to_string() };
|
||||
let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1;
|
||||
let block = first_free_block(kept);
|
||||
let mut add = String::new();
|
||||
if !kept.contains("id=\"_x0000_t202\"")
|
||||
&& let Some(i) = ours.find("<v:shapetype")
|
||||
&& let Some(j) = ours.get(i..).and_then(|s| s.find("</v:shapetype>"))
|
||||
{
|
||||
add.push_str(ours.get(i..i + j + "</v:shapetype>".len()).unwrap_or(""));
|
||||
// Our shape types (notes, form controls) that the kept drawing doesn't define.
|
||||
let mut from = 0;
|
||||
while let Some(i) = ours.get(from..).and_then(|s| s.find("<v:shapetype")).map(|i| i + from) {
|
||||
let Some(j) = ours.get(i..).and_then(|s| s.find("</v:shapetype>")).map(|j| i + j + "</v:shapetype>".len()) else { break };
|
||||
let ty = ours.get(i..j).unwrap_or("");
|
||||
let id = ty.split("id=\"").nth(1).and_then(|r| r.split('"').next()).unwrap_or("");
|
||||
if !id.is_empty() && !kept.contains(&format!("id=\"{id}\"")) {
|
||||
add.push_str(ty);
|
||||
}
|
||||
from = j;
|
||||
}
|
||||
for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() {
|
||||
let spans = shape_spans(ours);
|
||||
let blocks = (spans.len() as u64 + 1).div_ceil(1024).max(1);
|
||||
for (k, (a, b)) in spans.into_iter().enumerate() {
|
||||
let shape = ours.get(a..b).unwrap_or("");
|
||||
let id = block.saturating_mul(1024).saturating_add(1 + k as u64);
|
||||
match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) {
|
||||
@@ -618,16 +649,70 @@ pub(super) fn merge_vml(kept: &str, ours: &str) -> String {
|
||||
}
|
||||
}
|
||||
let mut head = kept.get(..close).unwrap_or("").to_string();
|
||||
// Claim the block in the drawing's id map.
|
||||
// Claim the blocks in the drawing's id map.
|
||||
if let Some(i) = head.find("<o:idmap")
|
||||
&& let Some(d) = head.get(i..).and_then(|s| s.find("data=\"")).map(|d| i + d + "data=\"".len())
|
||||
&& let Some(e) = head.get(d..).and_then(|s| s.find('"')).map(|e| d + e)
|
||||
{
|
||||
head.insert_str(e, &format!(",{block}"));
|
||||
let claimed: String = (block..block.saturating_add(blocks)).map(|b| format!(",{b}")).collect();
|
||||
head.insert_str(e, &claimed);
|
||||
}
|
||||
format!("{head}{add}{}", kept.get(close..).unwrap_or(""))
|
||||
}
|
||||
|
||||
/// A kept `<controls>` fragment without the controls whose `shapeId` is in `drop` (they are
|
||||
/// written from the model). `None` when no control is left. A fragment that doesn't parse is
|
||||
/// returned unchanged.
|
||||
fn without_controls(frag: &str, drop: &HashSet<u64>) -> Option<String> {
|
||||
if drop.is_empty() {
|
||||
return Some(frag.to_string());
|
||||
}
|
||||
let numbered = |x: &str| {
|
||||
x.split("shapeId=\"").skip(1).any(|r| r.split('"').next().and_then(|n| n.trim().parse::<u64>().ok()).is_some_and(|n| drop.contains(&n)))
|
||||
};
|
||||
let mut reader = Reader::from_str(frag);
|
||||
let mut out = String::with_capacity(frag.len());
|
||||
let (mut last, mut left, mut inside) = (0usize, 0usize, false);
|
||||
loop {
|
||||
let Ok(start) = usize::try_from(reader.buffer_position()) else { return Some(frag.to_string()) };
|
||||
let ev = match reader.read_event() {
|
||||
Ok(ev) => ev,
|
||||
Err(_) => return Some(frag.to_string()),
|
||||
};
|
||||
let child = match ev {
|
||||
Event::Eof => break,
|
||||
Event::Start(e) if e.local_name().as_ref() == b"controls" => {
|
||||
inside = true;
|
||||
continue;
|
||||
}
|
||||
Event::End(e) if e.local_name().as_ref() == b"controls" => {
|
||||
inside = false;
|
||||
continue;
|
||||
}
|
||||
Event::Start(e) if inside => {
|
||||
if reader.read_to_end(e.name()).is_err() {
|
||||
return Some(frag.to_string());
|
||||
}
|
||||
true
|
||||
}
|
||||
Event::Empty(_) if inside => true,
|
||||
_ => false,
|
||||
};
|
||||
if !child {
|
||||
continue;
|
||||
}
|
||||
let Ok(end) = usize::try_from(reader.buffer_position()) else { return Some(frag.to_string()) };
|
||||
if numbered(frag.get(start..end).unwrap_or("")) {
|
||||
out.push_str(frag.get(last..start).unwrap_or(""));
|
||||
last = end;
|
||||
} else {
|
||||
left += 1;
|
||||
}
|
||||
}
|
||||
out.push_str(frag.get(last..).unwrap_or(""));
|
||||
(left > 0).then_some(out)
|
||||
}
|
||||
|
||||
/// Rewrites relationship-id attributes (`r:id`, `r:embed`…: any attribute in the relationships
|
||||
/// namespace) of an XML fragment through `map`. `None` when the fragment is damaged or `map` has
|
||||
/// no id for one of them.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::read::merge_vml;
|
||||
use super::read::{first_free_block, merge_vml};
|
||||
use super::write::{NameMap, attach, part_rels_xml, remap, write_parts};
|
||||
use super::*;
|
||||
|
||||
@@ -28,7 +28,15 @@ impl<'a> SheetKept<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// GridCraft's notes VML for part `name`, merged with the kept control and object shapes.
|
||||
/// The number GridCraft's own VML shapes start after when they join the kept drawing
|
||||
/// ([`SheetKept::notes_vml`] numbers them `base + 1`, `base + 2`…); `None` without one.
|
||||
pub fn shape_base(&self) -> Option<u64> {
|
||||
let vml = self.vml()?;
|
||||
Some(first_free_block(&String::from_utf8_lossy(&vml.data)).saturating_mul(1024))
|
||||
}
|
||||
|
||||
/// GridCraft's notes and form controls VML for part `name`, merged with the kept control and
|
||||
/// object shapes.
|
||||
pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String {
|
||||
let Some(vml) = self.vml() else { return ours };
|
||||
self.write_vml_rels(vml, name, out);
|
||||
@@ -50,17 +58,31 @@ impl<'a> SheetKept<'a> {
|
||||
self.ids.get(old).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The kept sheet elements, in schema order.
|
||||
pub fn elements(&self) -> String {
|
||||
let Some(p) = self.p else { return String::new() };
|
||||
/// The kept sheet elements, in schema order, with GridCraft's own form control entries
|
||||
/// (`controls`, see `controls::write`) in the sheet's one `<controls>` element.
|
||||
pub fn elements(&self, controls: &str) -> String {
|
||||
let mut s = String::new();
|
||||
for slot in SHEET_SLOTS {
|
||||
for e in p.elements.iter().filter(|e| e.slot == *slot) {
|
||||
if let Some(x) = remap(&e.xml, &self.ids) {
|
||||
s.push_str(&x);
|
||||
let mut placed = controls.is_empty();
|
||||
if let Some(p) = self.p {
|
||||
for slot in SHEET_SLOTS {
|
||||
for e in p.elements.iter().filter(|e| e.slot == *slot) {
|
||||
if let Some(mut x) = remap(&e.xml, &self.ids) {
|
||||
if *slot == "controls"
|
||||
&& !placed
|
||||
&& let Some(i) = x.rfind("</controls>")
|
||||
{
|
||||
x.insert_str(i, controls);
|
||||
placed = true;
|
||||
}
|
||||
s.push_str(&x);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `<controls>` comes last of the kept elements.
|
||||
if !placed {
|
||||
s.push_str(&crate::controls::controls_element(controls));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ pub struct KeptOut {
|
||||
}
|
||||
|
||||
impl KeptOut {
|
||||
/// Reserves a part name GridCraft writes itself, so kept parts don't take it. False when it is
|
||||
/// taken.
|
||||
pub fn reserve(&mut self, name: &str) -> bool {
|
||||
self.used.insert(name.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Reserves the VML shape blocks the kept header and footer drawings of `wb` use, so that the
|
||||
/// notes drawings GridCraft writes don't reuse their shape ids.
|
||||
pub fn reserve_vml_blocks(&mut self, wb: &Workbook) {
|
||||
|
||||
@@ -293,12 +293,12 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
continue;
|
||||
}
|
||||
let name = unique_name(&wb, &name, i);
|
||||
let mut sheet = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) {
|
||||
let (mut sheet, read_controls) = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) {
|
||||
Ok(s) => s,
|
||||
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
|
||||
Err(e) => {
|
||||
cx.warn(format!("sheet \"{name}\" could not be read: {e}"));
|
||||
Sheet::new(name.clone())
|
||||
(Sheet::new(name.clone()), Default::default())
|
||||
}
|
||||
};
|
||||
match crate::pivot::read_pivots(&mut cx, &mut pivot_caches, &rel.target, &name) {
|
||||
@@ -306,7 +306,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
|
||||
Err(e) => cx.warn(format!("PivotTables on sheet \"{name}\" could not be read: {e}")),
|
||||
}
|
||||
sheet.preserved = kept.sheet(&mut cx, &rel.target, &name);
|
||||
sheet.preserved = kept.sheet(&mut cx, &rel.target, &name, &read_controls);
|
||||
sheet.file_id = se.attr_u32("sheetId").unwrap_or(0);
|
||||
sheet.visibility = match se.attr("state") {
|
||||
Some("hidden") => Visibility::Hidden,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Reading worksheet parts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::date::{serial_from_ymd, time_fraction};
|
||||
@@ -115,7 +115,9 @@ fn read_sheet_format(e: &El, sheet: &mut Sheet) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoError> {
|
||||
/// Reads the sheet part `part`, with the VML shape numbers of the form controls read into the
|
||||
/// model (pass-through keeps the other controls).
|
||||
pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, HashSet<u64>), IoError> {
|
||||
let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?;
|
||||
let rels = cx.pkg.rels(part)?;
|
||||
let mut sheet = Sheet::new(name);
|
||||
@@ -139,6 +141,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
||||
let mut fit_to_page = false;
|
||||
let mut column_assignments_left = MAX_COLUMN_ASSIGNMENTS;
|
||||
cx.cf_ext_ids.clear();
|
||||
let mut controls: Option<&El> = None;
|
||||
for e in &root.children {
|
||||
match e.name.as_str() {
|
||||
"sheetPr" => {
|
||||
@@ -260,10 +263,12 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
||||
}
|
||||
}
|
||||
"extLst" => read_ext(cx, e, &mut sheet),
|
||||
"controls" | "AlternateContent" => controls = crate::controls::controls_of(e).or(controls),
|
||||
"protectedRanges" => read_protected_ranges(e, &mut sheet),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let read_controls = read_form_controls(cx, controls, &rels, &mut sheet);
|
||||
// Comments are related to the sheet without an element in it.
|
||||
for rel in rels.iter().filter(|r| r.kind == "comments") {
|
||||
if let Some(x) = cx.optional_xml(&rel.target)? {
|
||||
@@ -285,7 +290,13 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(sheet)
|
||||
Ok((sheet, read_controls))
|
||||
}
|
||||
|
||||
/// Buttons, scroll bars and spin buttons: the `<controls>` list with its properties parts, else
|
||||
/// the VML shapes. Returns the VML shape numbers read, so pass-through keeps only the others.
|
||||
fn read_form_controls(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut Sheet) -> HashSet<u64> {
|
||||
if controls.is_some() || rels.iter().any(|r| r.kind == "vmlDrawing") { crate::controls::read(cx, controls, rels, sheet) } else { HashSet::new() }
|
||||
}
|
||||
|
||||
/// A protection password hash: the iterated attributes (`algorithmName`, `hashValue`,
|
||||
|
||||
@@ -279,29 +279,51 @@ fn write_sheet_extensions(s: &mut String, sheet: &Sheet, prio: &[u32]) {
|
||||
}
|
||||
|
||||
fn write_sheet_notes(s: &mut String, sheet: &Sheet, si: usize, kept: &crate::passthrough::SheetKept<'_>, rels: &mut Rels, out: &mut Out) {
|
||||
// Notes: comments part + VML so Excel shows them. Threaded comments also get a legacy note
|
||||
// (its text with the replies as lines, for readers without threaded comments) linked by id.
|
||||
if !sheet.comments.is_empty() {
|
||||
out.comments += 1;
|
||||
let n = out.comments;
|
||||
let ids = thread_ids(sheet, si);
|
||||
out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes());
|
||||
if !ids.is_empty() {
|
||||
let x = threaded_xml(sheet, &ids, &mut out.persons);
|
||||
out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes());
|
||||
rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml"));
|
||||
// Notes and form controls share one legacy VML drawing: notes need it for Excel to show them,
|
||||
// and controls carry their `x:ClientData` there. 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
|
||||
// a control's `shapeId` names its VML shape. Threaded comments also get a legacy note (its
|
||||
// text with the replies as lines, for readers without threaded comments) linked by id.
|
||||
let mut controls = String::new();
|
||||
if !sheet.comments.is_empty() || !sheet.controls.is_empty() {
|
||||
let shapes = (sheet.comments.len() as u64).saturating_add(sheet.controls.len() as u64);
|
||||
let blocks = u32::try_from(shapes.saturating_add(1).div_ceil(1024)).unwrap_or(u32::MAX).max(1);
|
||||
let first = out.kept.notes_block(out.vml_block.saturating_add(1));
|
||||
out.vml_block = out.vml_block.saturating_add(blocks);
|
||||
let idmap = (first..first.saturating_add(blocks)).map(|b| b.to_string()).collect::<Vec<_>>().join(",");
|
||||
// With a kept drawing, our shapes join it in a block of their own.
|
||||
let base = kept.shape_base().unwrap_or(u64::from(first) * 1024);
|
||||
let notes = sheet.comments.len() as u64;
|
||||
let mut control_shapes = String::new();
|
||||
if !sheet.controls.is_empty() {
|
||||
let w = crate::controls::write(out, rels, sheet, |i| base + 1 + notes + i as u64);
|
||||
controls = w.entries;
|
||||
control_shapes = format!("{}{}", crate::controls::SHAPETYPE, w.vml);
|
||||
}
|
||||
if !sheet.comments.is_empty() {
|
||||
out.comments += 1;
|
||||
let n = out.comments;
|
||||
let ids = thread_ids(sheet, si);
|
||||
out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes());
|
||||
if !ids.is_empty() {
|
||||
let x = threaded_xml(sheet, &ids, &mut out.persons);
|
||||
out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes());
|
||||
rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml"));
|
||||
}
|
||||
rels.add("comments", &format!("../comments{n}.xml"));
|
||||
}
|
||||
out.vmls += 1;
|
||||
let v = out.vmls;
|
||||
out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing");
|
||||
let vml_name = format!("xl/drawings/vmlDrawing{n}.vml");
|
||||
let vml = kept.notes_vml(vml_xml(sheet, out.kept.notes_block(n)), &vml_name, out);
|
||||
let vml_name = format!("xl/drawings/vmlDrawing{v}.vml");
|
||||
let vml = kept.notes_vml(vml_xml(sheet, &idmap, base, &control_shapes), &vml_name, out);
|
||||
out.part(&vml_name, None, vml.into_bytes());
|
||||
rels.add("comments", &format!("../comments{n}.xml"));
|
||||
let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml"));
|
||||
let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{v}.vml"));
|
||||
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
|
||||
} else if let Some(rid) = kept.lone_vml(rels, out) {
|
||||
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
|
||||
}
|
||||
s.push_str(&kept.elements());
|
||||
s.push_str(&kept.elements(&controls));
|
||||
}
|
||||
|
||||
fn write_sheet_drawing(s: &mut String, wb: &Workbook, si: usize, sheet: &Sheet, rels: &mut Rels, out: &mut Out) {
|
||||
|
||||
@@ -348,13 +348,14 @@ fn note_anchor(cell: CellRef, b: Option<&NoteBox>) -> (String, (f64, f64)) {
|
||||
(format!("{}, {}", corner(&b.from), corner(&b.to)), b.size_pt.unwrap_or(DEFAULT_NOTE_SIZE_PT))
|
||||
}
|
||||
|
||||
/// Legacy VML shapes Excel uses to display notes.
|
||||
pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String {
|
||||
/// Legacy VML shapes Excel uses to display notes (numbered from `base + 1`), followed by the
|
||||
/// form control shapes `controls`. `idmap` lists the shape blocks the drawing uses.
|
||||
pub(super) fn vml_xml(sheet: &Sheet, idmap: &str, base: u64, controls: &str) -> String {
|
||||
let mut s = format!(
|
||||
"<xml xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><o:shapelayout v:ext=\"edit\"><o:idmap v:ext=\"edit\" data=\"{n}\"/></o:shapelayout><v:shapetype id=\"_x0000_t202\" coordsize=\"21600,21600\" o:spt=\"202\" path=\"m,l,21600r21600,l21600,xe\"><v:stroke joinstyle=\"miter\"/><v:path gradientshapeok=\"t\" o:connecttype=\"rect\"/></v:shapetype>"
|
||||
"<xml xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><o:shapelayout v:ext=\"edit\"><o:idmap v:ext=\"edit\" data=\"{idmap}\"/></o:shapelayout><v:shapetype id=\"_x0000_t202\" coordsize=\"21600,21600\" o:spt=\"202\" path=\"m,l,21600r21600,l21600,xe\"><v:stroke joinstyle=\"miter\"/><v:path gradientshapeok=\"t\" o:connecttype=\"rect\"/></v:shapetype>"
|
||||
);
|
||||
for (i, (cell, c)) in sheet.comments.iter().enumerate() {
|
||||
let id = n as u64 * 1024 + 1 + i as u64;
|
||||
let id = base + 1 + i as u64;
|
||||
let vis = if c.visible { "visible" } else { "hidden" };
|
||||
let (anchor, (w, h)) = note_anchor(*cell, c.note_box.as_ref());
|
||||
let _ = write!(
|
||||
@@ -368,6 +369,7 @@ pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String {
|
||||
if c.visible { "<x:Visible/>" } else { "" }
|
||||
);
|
||||
}
|
||||
s.push_str(controls);
|
||||
s.push_str("</xml>");
|
||||
s
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod chart_book_names;
|
||||
mod chart_bool_default;
|
||||
mod chart_combo_kinds;
|
||||
mod chart_ref_offsets;
|
||||
mod controls_tests;
|
||||
mod csv_tests;
|
||||
mod encrypted_tests;
|
||||
mod fixtures;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Form controls: the three parts written for each control agree, they read back, and files with
|
||||
//! only VML or with odd values give valid controls.
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::{Anchor, AnchorMode, Comment, ControlAction, ControlKind, FormControl, MacroInput, Sheet, Workbook};
|
||||
|
||||
use super::minimal;
|
||||
use crate::package::Package;
|
||||
use crate::{read_xlsx, write_xlsx};
|
||||
|
||||
fn at(a: &str) -> CellRef {
|
||||
CellRef::parse(a).unwrap()
|
||||
}
|
||||
|
||||
fn anchor(cell: &str, width: f32, height: f32) -> Anchor {
|
||||
Anchor { cell: at(cell), dx: 4.0, dy: 3.0, width, height, mode: AnchorMode::MoveOnly }
|
||||
}
|
||||
|
||||
fn note(author: &str, text: &str) -> Comment {
|
||||
Comment {
|
||||
author: author.into(),
|
||||
text: text.into(),
|
||||
replies: vec![],
|
||||
threaded: false,
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn book() -> Workbook {
|
||||
let mut wb = Workbook::default();
|
||||
wb.sheets.clear();
|
||||
let mut sh = Sheet::new("Data");
|
||||
sh.comments.insert(at("A1"), note("Me", "a note"));
|
||||
sh.controls.push(FormControl {
|
||||
id: 1,
|
||||
kind: ControlKind::Slider,
|
||||
anchor: anchor("C3", 150.0, 20.0),
|
||||
link: Some("$B$1".into()),
|
||||
min: 5,
|
||||
max: 500,
|
||||
step: 5,
|
||||
page: 50,
|
||||
value: 35,
|
||||
name: "Rate".into(),
|
||||
..Default::default()
|
||||
});
|
||||
sh.controls.push(FormControl {
|
||||
id: 2,
|
||||
kind: ControlKind::Spin,
|
||||
anchor: Anchor { mode: AnchorMode::Absolute, ..anchor("F3", 20.0, 40.0) },
|
||||
link: Some("'Other Sheet'!$A$2".into()),
|
||||
max: 30_000,
|
||||
step: 100,
|
||||
value: 30_000,
|
||||
print: false,
|
||||
..Default::default()
|
||||
});
|
||||
sh.controls.push(FormControl {
|
||||
id: 3,
|
||||
kind: ControlKind::Button,
|
||||
anchor: Anchor { mode: AnchorMode::MoveAndSize, ..anchor("C8", 100.0, 28.0) },
|
||||
text: "Run <it> & go".into(),
|
||||
action: Some(ControlAction::Macro { name: "Fill Down".into(), input: MacroInput::Selection, output: None }),
|
||||
locked: false,
|
||||
..Default::default()
|
||||
});
|
||||
sh.controls.push(FormControl { id: 4, kind: ControlKind::Slider, anchor: anchor("H3", 20.0, 120.0), horizontal: false, ..Default::default() });
|
||||
wb.sheets.push(sh.into());
|
||||
wb.sheets.push(Sheet::new("Other Sheet").into());
|
||||
wb
|
||||
}
|
||||
|
||||
fn text(pkg: &mut Package<'_>, part: &str) -> String {
|
||||
String::from_utf8(pkg.read(part).unwrap().unwrap_or_else(|| panic!("missing {part}"))).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_parts_agree_and_read_back() {
|
||||
let wb = book();
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml");
|
||||
let vml = text(&mut pkg, "xl/drawings/vmlDrawing1.vml");
|
||||
let types = text(&mut pkg, "[Content_Types].xml");
|
||||
assert!(sheet.contains("<mc:Choice xmlns:x14=") && sheet.contains("<controls>"), "{sheet}");
|
||||
// Each control's shape id is a VML shape of the same kind; the note keeps its own id.
|
||||
let ids: Vec<String> = sheet.split("<control shapeId=\"").skip(1).map(|s| s.split('"').next().unwrap().to_string()).collect();
|
||||
assert_eq!(ids.len(), 8, "a Choice and a Fallback per control");
|
||||
for (i, kind) in ["Scroll", "Spin", "Button", "Scroll"].iter().enumerate() {
|
||||
let id = &ids[i * 2];
|
||||
let shape = vml.split(&format!("id=\"_x0000_s{id}\"")).nth(1).unwrap_or_else(|| panic!("no shape {id}"));
|
||||
assert!(shape.starts_with(" type=\"#_x0000_t201\""), "{shape}");
|
||||
assert!(shape.split("</v:shape>").next().unwrap().contains(&format!("ObjectType=\"{kind}\"")), "{id}");
|
||||
}
|
||||
assert!(vml.contains("id=\"_x0000_s1025\"") && vml.contains("ObjectType=\"Note\""), "the note is shape 1025");
|
||||
assert!(!ids.contains(&"1025".to_string()));
|
||||
for n in 1..=4 {
|
||||
assert!(types.contains(&format!("/xl/ctrlProps/ctrlProp{n}.xml")), "content type {n}");
|
||||
}
|
||||
let prop = text(&mut pkg, "xl/ctrlProps/ctrlProp1.xml");
|
||||
assert!(
|
||||
prop.contains("objectType=\"Scroll\"") && prop.contains("fmlaLink=\"$B$1\"") && prop.contains("page=\"50\"") && prop.contains("horiz=\"1\""),
|
||||
"{prop}"
|
||||
);
|
||||
assert!(sheet.contains("macro=\"[0]!Fill Down\""));
|
||||
|
||||
let (back, report) = read_xlsx(&bytes).unwrap();
|
||||
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
|
||||
let got = &back.sheet(0).unwrap().controls;
|
||||
assert_eq!(got.len(), 4);
|
||||
for (a, b) in wb.sheet(0).unwrap().controls.iter().zip(got) {
|
||||
let same = |x: &FormControl| {
|
||||
(x.kind, x.link.clone(), x.min, x.max, x.step, x.value, x.horizontal, x.print, x.locked, x.action.clone(), x.anchor.cell, x.anchor.mode)
|
||||
};
|
||||
assert_eq!(same(a), same(b));
|
||||
assert!(
|
||||
(a.anchor.width - b.anchor.width).abs() < 1.0
|
||||
&& (a.anchor.height - b.anchor.height).abs() < 1.0
|
||||
&& (a.anchor.dx - b.anchor.dx).abs() < 1.0
|
||||
);
|
||||
if a.kind == ControlKind::Slider {
|
||||
assert_eq!(a.page, b.page);
|
||||
}
|
||||
}
|
||||
assert_eq!(got[0].name, "Rate");
|
||||
assert_eq!(got[2].text, "Run <it> & go");
|
||||
assert_eq!(back.sheet(0).unwrap().comments.len(), 1, "notes still read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_shapes_take_several_id_blocks() {
|
||||
let mut wb = Workbook::default();
|
||||
wb.sheets.clear();
|
||||
let mut sh = Sheet::new("Data");
|
||||
for i in 0..1100u32 {
|
||||
sh.controls.push(FormControl { id: i + 1, kind: ControlKind::Spin, anchor: anchor("C3", 20.0, 30.0), ..Default::default() });
|
||||
}
|
||||
wb.sheets.push(sh.into());
|
||||
wb.sheets.push(Sheet::new("Next").into());
|
||||
if let Some(s) = wb.sheet_mut(1) {
|
||||
s.comments.insert(at("B2"), note("x", "y"));
|
||||
}
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
assert!(text(&mut pkg, "xl/drawings/vmlDrawing1.vml").contains("data=\"1,2\""));
|
||||
let second = text(&mut pkg, "xl/drawings/vmlDrawing2.vml");
|
||||
assert!(second.contains("data=\"3\"") && second.contains("_x0000_s3073"), "the next sheet's ids don't collide");
|
||||
let (back, _) = read_xlsx(&bytes).unwrap();
|
||||
assert_eq!(back.sheet(0).unwrap().controls.len(), 1100);
|
||||
}
|
||||
|
||||
const VML_ONLY: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">
|
||||
<v:shape id="_x0000_s1025" type="#_x0000_t201"><x:ClientData ObjectType="Scroll"><x:Anchor>2, 0, 3, 0, 5, 0, 4, 0</x:Anchor><x:FmlaLink>$B$1</x:FmlaLink><x:Val>50000</x:Val><x:Min>-5</x:Min><x:Max>99999</x:Max><x:Inc>0</x:Inc><x:Page>-3</x:Page><x:Horiz/></x:ClientData></v:shape>
|
||||
<v:shape id="_x0000_s1026" type="#_x0000_t201"><x:ClientData ObjectType="Spin"><x:Anchor>1, 0, 1, 0, 1, 20, 3, 0</x:Anchor><x:Val>7</x:Val><x:Min>9</x:Min><x:Max>9</x:Max></x:ClientData></v:shape>
|
||||
<v:shape id="_x0000_s1027" o:button="t"><v:textbox><div>Go</div></v:textbox><x:ClientData ObjectType="Button"><x:Anchor>1, 0, 6, 0, 3, 0, 7, 0</x:Anchor><x:FmlaMacro>[0]!Tidy</x:FmlaMacro></x:ClientData></v:shape>
|
||||
<v:shape id="_x0000_s1028"><x:ClientData ObjectType="Drop"><x:Anchor>1, 0, 1, 0, 2, 0, 2, 0</x:Anchor></x:ClientData></v:shape>
|
||||
<v:shape id="_x0000_s1029"><x:ClientData ObjectType="Scroll"><x:Anchor>junk</x:Anchor></x:ClientData></v:shape>
|
||||
</xml>"##;
|
||||
|
||||
#[test]
|
||||
fn vml_only_controls_are_read_and_clamped() {
|
||||
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/></Relationships>"#;
|
||||
let bytes = minimal(
|
||||
r#"<sheetData/><legacyDrawing r:id="rId9"/>"#,
|
||||
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", VML_ONLY)],
|
||||
"",
|
||||
"",
|
||||
);
|
||||
let (wb, report) = read_xlsx(&bytes).unwrap();
|
||||
assert!(report.warnings.iter().all(|w| !w.contains("form controls are not supported")), "{:?}", report.warnings);
|
||||
let c = &wb.sheet(0).unwrap().controls;
|
||||
assert_eq!(c.len(), 3, "the drop-down and the shape without an anchor are skipped");
|
||||
assert_eq!((c[0].kind, c[0].min, c[0].max, c[0].step, c[0].value, c[0].horizontal), (ControlKind::Slider, 0, 30_000, 1, 30_000, true));
|
||||
assert!(c[0].page >= c[0].step && c[0].check_numbers().is_ok());
|
||||
assert_eq!((c[1].min, c[1].max), (9, 10), "a range with no width gets one");
|
||||
assert!(c[1].check_numbers().is_ok());
|
||||
assert_eq!(c[2].text, "Go");
|
||||
assert_eq!(c[2].action, Some(ControlAction::Macro { name: "Tidy".into(), input: MacroInput::Selection, output: None }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_properties_part_wins_over_the_vml() {
|
||||
let mut wb = book();
|
||||
if let Some(s) = wb.sheet_mut(0) {
|
||||
s.comments.clear();
|
||||
s.controls.truncate(1);
|
||||
}
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
// Rewrite the VML's numbers: the reader still takes the properties part's.
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
let mut parts = Vec::new();
|
||||
for name in pkg.names() {
|
||||
let mut data = pkg.read(&name).unwrap().unwrap();
|
||||
if name.ends_with(".vml") {
|
||||
data = String::from_utf8(data).unwrap().replace("<x:Max>500</x:Max>", "<x:Max>9</x:Max>").into_bytes();
|
||||
}
|
||||
parts.push((name, data));
|
||||
}
|
||||
let zipped = super::make_zip(&parts.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect::<Vec<_>>());
|
||||
let (back, _) = read_xlsx(&zipped).unwrap();
|
||||
assert_eq!(back.sheet(0).unwrap().controls[0].max, 500);
|
||||
}
|
||||
@@ -938,3 +938,64 @@ fn notes_take_shape_blocks_the_header_pictures_dont_use() {
|
||||
}
|
||||
assert!(notes_ids.iter().all(|b| notes_map.contains(b)), "note ids outside their drawing's blocks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelled_form_controls_join_kept_ones_in_one_controls_element() {
|
||||
use gridcraft_model::{Anchor, ControlKind, FormControl};
|
||||
let (mut wb, _) = read_xlsx(&rich_package()).unwrap();
|
||||
assert!(wb.sheet(0).unwrap().controls.is_empty(), "a check box stays with pass-through");
|
||||
let anchor = Anchor { cell: gridcraft_core::CellRef::new(8, 1), width: 120.0, height: 20.0, ..Default::default() };
|
||||
let slider = FormControl { id: 900, kind: ControlKind::Slider, anchor, link: Some("$B$9".into()), value: 40, ..Default::default() };
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.controls.push(slider);
|
||||
}
|
||||
let out = write_xlsx(&wb).unwrap();
|
||||
let u = Unpacked::new(&out);
|
||||
u.assert_valid();
|
||||
let sheet = sheet_part(&u, "Data");
|
||||
let sx = u.text(&sheet);
|
||||
assert_eq!(sx.matches("<controls").count(), 1, "{sx}");
|
||||
// The kept check box and the slider, each with its own properties part.
|
||||
let ids: Vec<&str> = sx.split("<control shapeId=\"").skip(1).filter_map(|r| r.split('"').next()).collect();
|
||||
assert!(ids.contains(&"1026"), "{ids:?}");
|
||||
let ours = ids.iter().find(|i| **i != "1026").copied().unwrap_or_default();
|
||||
assert_eq!(u.find(CTRL_PROP.as_bytes()).len(), 1, "the kept properties part is written once");
|
||||
// One VML drawing holds the kept shapes, the note and the slider, in fresh blocks.
|
||||
let vml = u.text(&u.target(&sheet, &attr_of(&sx, "legacyDrawing", "r:id")).1);
|
||||
assert!(vml.contains("_x0000_s1025") && vml.contains("_x0000_s1026"), "{vml}");
|
||||
assert!(vml.contains(&format!("id=\"_x0000_s{ours}\"")) && vml.contains("ObjectType=\"Scroll\""), "{vml}");
|
||||
assert!(vml.contains("id=\"_x0000_t201\""), "the form control shape type is defined");
|
||||
assert_eq!(ours, "2050", "the note is 2049, the slider follows it");
|
||||
|
||||
// Read back: the slider is modelled again and the check box is kept, once.
|
||||
let (wb2, rep) = read_xlsx(&out).unwrap();
|
||||
assert!(!rep.warnings.iter().any(|w| w.contains("dropped")), "{:?}", rep.warnings);
|
||||
let data = wb2.sheet(0).unwrap();
|
||||
assert_eq!(data.controls.len(), 1);
|
||||
assert_eq!((data.controls[0].kind, data.controls[0].value, data.controls[0].link.as_deref()), (ControlKind::Slider, 40, Some("$B$9")));
|
||||
let kept = data.preserved.as_deref().unwrap();
|
||||
let controls = kept.elements.iter().find(|e| e.slot == "controls").map(|e| e.xml.clone()).unwrap_or_default();
|
||||
assert!(controls.contains("Check Box 1") && !controls.contains(&format!("shapeId=\"{ours}\"")), "{controls}");
|
||||
let kept_vml = String::from_utf8_lossy(&kept.vml.as_ref().unwrap().data).into_owned();
|
||||
assert!(!kept_vml.contains("ObjectType=\"Scroll\""), "the modelled slider's shape is not kept");
|
||||
// A second save writes the same controls again, without duplicates.
|
||||
let again = Unpacked::new(&write_xlsx(&wb2).unwrap());
|
||||
let sx2 = again.text(&sheet_part(&again, "Data"));
|
||||
assert_eq!(sx2.matches("<control shapeId=").count(), sx.matches("<control shapeId=").count(), "{sx2}");
|
||||
again.assert_valid();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_of_modelled_controls_keeps_nothing_for_them() {
|
||||
use gridcraft_model::{ControlKind, FormControl};
|
||||
let mut wb = gridcraft_model::Workbook::default();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.controls.push(FormControl { id: 1, kind: ControlKind::Button, text: "Go".into(), ..Default::default() });
|
||||
sh.controls.push(FormControl { id: 2, kind: ControlKind::Spin, ..Default::default() });
|
||||
}
|
||||
let (back, rep) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert!(rep.warnings.is_empty(), "{:?}", rep.warnings);
|
||||
let sh = back.sheet(0).unwrap();
|
||||
assert_eq!(sh.controls.len(), 2);
|
||||
assert!(sh.preserved.is_none(), "{:?}", sh.preserved);
|
||||
}
|
||||
@@ -120,6 +120,10 @@ pub struct Out {
|
||||
pub drawings: u32,
|
||||
pub tables: u32,
|
||||
pub comments: u32,
|
||||
/// VML drawings written, the last VML shape-id block used, and control properties parts.
|
||||
pub vmls: u32,
|
||||
pub vml_block: u32,
|
||||
pub ctrl_props: u32,
|
||||
/// Authors of threaded comments, in first-use order; written to `xl/persons/person.xml`.
|
||||
pub persons: Vec<String>,
|
||||
pub table_id: u32,
|
||||
@@ -231,6 +235,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>)
|
||||
drawings: 0,
|
||||
tables: 0,
|
||||
comments: 0,
|
||||
vmls: 0,
|
||||
vml_block: 0,
|
||||
ctrl_props: 0,
|
||||
persons: vec![],
|
||||
table_id: 0,
|
||||
dynamic_arrays: false,
|
||||
|
||||
@@ -213,8 +213,10 @@ pub fn parse(xml: &[u8]) -> Result<El, IoError> {
|
||||
parse_streaming(xml, &[], &mut |_| Ok(()))
|
||||
}
|
||||
|
||||
/// Parses a part; complete elements named in `split` (below the root) are handed to `cb` and not
|
||||
/// kept in the returned tree.
|
||||
/// Parses a part; complete elements named in `split` that are children or grandchildren of the
|
||||
/// root (`sst/si`, `worksheet/sheetData/row`) are handed to `cb` and not kept in the returned
|
||||
/// tree. Deeper elements of the same name (the `xdr:row` of a form control's anchor) stay in the
|
||||
/// tree.
|
||||
pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Result<(), IoError>) -> Result<El, IoError> {
|
||||
let data = to_utf8(xml);
|
||||
let mut reader = Reader::from_reader(data.as_ref());
|
||||
@@ -241,9 +243,10 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
|
||||
cb: &mut dyn FnMut(El) -> Result<(), IoError>,
|
||||
kept: &mut usize,
|
||||
) -> Result<(), IoError> {
|
||||
let shallow = stack.len() <= 2;
|
||||
match stack.last_mut() {
|
||||
Some(parent) => {
|
||||
if split.contains(&el.name.as_str()) {
|
||||
if shallow && split.contains(&el.name.as_str()) {
|
||||
cb(el)?;
|
||||
} else {
|
||||
*kept = kept.saturating_add(1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (form controls: buttons, scroll bars and spin buttons read and written) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
|
||||
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
|
||||
@@ -41,7 +41,7 @@ values) is still dropped.
|
||||
|
||||
| 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), 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), form controls, 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), 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` |
|
||||
|
||||
Reference in new issue
Block a user