diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0c09905d..eba6ece89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,3 +42,4 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} diff --git a/Cargo.lock b/Cargo.lock index 93356b73c..20dcf28ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1969,7 +1969,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.15.0" +version = "0.16.0" dependencies = [ "arboard", "chrono", @@ -1996,7 +1996,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -2019,7 +2019,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -2031,7 +2031,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.15.0" +version = "0.16.0" dependencies = [ "chrono", "js-sys", @@ -2042,7 +2042,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -2065,7 +2065,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-core", "serde", @@ -2074,7 +2074,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-core", "regex", @@ -2082,7 +2082,7 @@ dependencies = [ [[package]] name = "gridcraft-math" -version = "0.15.0" +version = "0.16.0" dependencies = [ "ratex-font", "ratex-layout", @@ -2092,7 +2092,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-engine", "log", @@ -2101,7 +2101,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -2114,18 +2114,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.15.0" +version = "0.16.0" [[package]] name = "gridcraft-ui-egui" -version = "0.15.0" +version = "0.16.0" dependencies = [ "craft-ui", "craft-update", @@ -2150,7 +2150,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.15.0" +version = "0.16.0" dependencies = [ "eframe", "egui", @@ -2167,7 +2167,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.15.0" +version = "0.16.0" dependencies = [ "aes", "gridcraft-core", @@ -6618,7 +6618,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 8aa7ec8e7..594b29273 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.15.0" +version = "0.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/ROADMAP.md b/ROADMAP.md index 0c13aa7fb..fc48c2cbf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -22,7 +22,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| | 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) | -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 277 / 292 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 281 / 296 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | @@ -135,6 +135,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-11-02 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `` and VML), alongside the form controls GridCraft keeps unchanged | +| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors | | 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run | | 2026-11-02 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges | | 2026-11-02 | Equations: Insert › Equation (a split button with a gallery of examples) and an editor with quick-insert buttons, the error underlined at its position and a live preview; `insert.equation` and `equation.edit`; LaTeX laid out by RaTeX inside the new `gridcraft-math` crate, capped and panic-guarded, and cached on the shape so that painting never runs the layout engine; xlsx writes Office Math with a plain-text fallback and the source in `descr`, and reads Office Math back into LaTeX; PDF prints equations with the standard Symbol font and `[?]` for anything else | @@ -257,6 +259,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row | +| 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs | | 2026-11-02 | minor | Progress log: Python mode in the formula bar | | 2026-11-02 | minor | Progress log: Python cells as data | | 2026-11-02 | minor | Progress log: equations (Insert › Equation, the editor, gridcraft-math) | diff --git a/apps/gridcraft-cli/src/check/calc.rs b/apps/gridcraft-cli/src/check/calc.rs index a3f33d296..8ce378071 100644 --- a/apps/gridcraft-cli/src/check/calc.rs +++ b/apps/gridcraft-cli/src/check/calc.rs @@ -31,20 +31,41 @@ fn is_volatile(e: &Expr, volatile_names: &HashSet) -> bool { } /// Upper-cased defined names whose formula is volatile, directly or through other names. +/// +/// Volatility flows backwards along name references: each name is parsed and walked once, and a +/// worklist carries volatility from a name to the names that use it. Rescanning every name until +/// nothing changes was quadratic: a file listing its names so that each pass marked only one more +/// did one full pass per name. pub(super) fn volatile_names(wb: &Workbook) -> HashSet { - let parsed: Vec<(String, Option)> = - wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect(); let mut set = HashSet::new(); - // Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point. - for _ in 0..=parsed.len() { - let before = set.len(); - for (name, e) in &parsed { - if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) { - set.insert(name.clone()); + // Upper-cased name -> the names whose formulas refer to it. + let mut users: HashMap> = HashMap::new(); + let mut queue = Vec::new(); + let none = HashSet::new(); + for n in &wb.names { + let name = n.name.to_ascii_uppercase(); + let Ok(e) = gridcraft_engine::formula::parse(&n.formula) else { continue }; + if is_volatile(&e, &none) { + if set.insert(name.clone()) { + queue.push(name.clone()); } + continue; } - if set.len() == before { - break; + let mut used = BTreeSet::new(); + e.walk(&mut |x| { + if let Expr::Name(r) = x { + used.insert(r.to_ascii_uppercase()); + } + }); + for r in used { + users.entry(r).or_default().push(name.clone()); + } + } + while let Some(v) = queue.pop() { + for user in users.remove(&v).unwrap_or_default() { + if set.insert(user.clone()) { + queue.push(user); + } } } set diff --git a/apps/gridcraft-cli/src/check/package.rs b/apps/gridcraft-cli/src/check/package.rs index 6b97fa495..c0def7037 100644 --- a/apps/gridcraft-cli/src/check/package.rs +++ b/apps/gridcraft-cli/src/check/package.rs @@ -6,6 +6,10 @@ const MAX_XML_PART: u64 = 64 * 1024 * 1024; /// Most zip entries looked at while validating a written package. const MAX_ENTRIES: usize = 200_000; +/// Decompressed bytes that comparing one package's relationship kinds may read in total: the +/// relationship parts themselves and the targets whose content is checked. +const RELATIONSHIP_BUDGET: u64 = 512 * 1024 * 1024; + /// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is /// a cache Excel rebuilds, and GridCraft never writes it. pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"]; @@ -117,6 +121,30 @@ fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result, String> { Ok(buf) } +enum PartError { + /// Reading the part would spend more than the remaining budget. + OverBudget, + Unreadable, +} + +/// Reads a part like [`read_zip_part`], charging the decompressed bytes to `budget`. +fn read_zip_part_within(zip: &mut Zip<'_>, name: &str, budget: &mut u64) -> Result, PartError> { + let f = zip.by_name(name).map_err(|_| PartError::Unreadable)?; + // One byte past what is left shows that the part doesn't fit. + let limit = MAX_XML_PART.min(budget.saturating_add(1)); + let mut buf = Vec::new(); + // Bytes read before a decompression error still count against the budget. + let res = f.take(limit).read_to_end(&mut buf); + let read = buf.len() as u64; + if read > *budget { + *budget = 0; + return Err(PartError::OverBudget); + } + *budget -= read; + res.map_err(|_| PartError::Unreadable)?; + Ok(buf) +} + /// Whether the producer flagged its cached results as stale with ``: /// libraries that don't calculate write it, and store 0 or nothing as every formula's value. pub(super) fn full_calc_on_load(bytes: &[u8]) -> bool { @@ -232,19 +260,30 @@ pub fn validate_package(bytes: &[u8]) -> Result, String> { /// nothing are left out, as a save may rightly drop them: a link to a place in the workbook /// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it. pub(super) fn relationship_kinds(bytes: &[u8]) -> Result, String> { + relationship_kinds_within(bytes, RELATIONSHIP_BUDGET) +} + +/// [`relationship_kinds`] reading at most `budget` decompressed bytes. `Err` once the relationship +/// parts alone need more: their kinds can't be listed completely. +fn relationship_kinds_within(bytes: &[u8], mut budget: u64) -> Result, String> { let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; let names = zip_names(&mut zip); let by_key: HashMap = names.iter().map(|n| (part_key(n), n)).collect(); // Several relationships name the same part; each fresh check decompressed it again, so // repeated references to one highly compressible part multiplied the work. The verdict is // remembered per (part, named content) and the decompression the package can cause is - // budgeted as a whole (finding: repeated decompression). + // budgeted as a whole, the relationship parts included: many large, unreferenced rels parts + // would otherwise each spend a full per-part allowance. let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new(); - let mut budget: u64 = 512 * 1024 * 1024; let mut kinds = BTreeMap::new(); for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) { let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue }; - let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue }; + let xml = match read_zip_part_within(&mut zip, n, &mut budget) { + Ok(xml) => xml, + Err(PartError::OverBudget) => return Err(format!("{n}: relationship check stopped at its decompression limit")), + Err(PartError::Unreadable) => continue, + }; + let Ok(els) = xml_elements(&xml) else { continue }; for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") { let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue }; let target = attr(attrs, "Target").unwrap_or(""); @@ -329,3 +368,36 @@ fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, buf.clear(); } } + +#[cfg(test)] +mod tests { + use std::io::Write as _; + + use super::*; + + /// A package with `count` relationship parts of `size` bytes each, none of them describing an + /// existing part. + fn many_rels(count: usize, size: usize) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let head = r#""#; + let tail = ""; + let filler = " ".repeat(size.saturating_sub(head.len() + tail.len())); + for i in 0..count { + z.start_file(format!("xl/_rels/part{i}.xml.rels"), opts).unwrap(); + z.write_all(head.as_bytes()).unwrap(); + z.write_all(filler.as_bytes()).unwrap(); + z.write_all(tail.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() + } + + #[test] + fn relationship_parts_count_against_the_decompression_budget() { + // Each part fits the per-part allowance, but together they need more than the budget. + let bytes = many_rels(8, 64 * 1024); + assert!(relationship_kinds_within(&bytes, 256 * 1024).is_err()); + let kinds = relationship_kinds_within(&bytes, 1024 * 1024).unwrap(); + assert_eq!(kinds.get("custom").map(String::as_str), Some("custom")); + } +} diff --git a/apps/gridcraft-cli/src/check/tests.rs b/apps/gridcraft-cli/src/check/tests.rs index 68c6f8fb5..57baa2198 100644 --- a/apps/gridcraft-cli/src/check/tests.rs +++ b/apps/gridcraft-cli/src/check/tests.rs @@ -178,6 +178,28 @@ fn volatile_names_propagate() { assert_eq!(check(&wb, &Options::default()).calc.volatile, 1); } +#[test] +fn volatile_names_propagate_through_a_long_chain_in_one_walk() { + // Chain0 uses Chain1, Chain1 uses Chain2, ... and only the last is volatile: listed in this order, a rescan + // until nothing changes marked one name per pass (quadratic). Every name in the chain is + // volatile; a name off the chain is not. + const LEN: usize = 5_000; + let mut wb = book(&[("A1", "=1", n(1.0))]); + let name = + |name: String, formula: String| gridcraft_engine::model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false }; + for i in 0..LEN { + let formula = if i + 1 == LEN { "NOW()".to_string() } else { format!("Chain{}+1", i + 1) }; + wb.names.push(name(format!("Chain{i}"), formula)); + } + wb.names.push(name("Plain".into(), "Chain0*0+Other".into())); + wb.names.push(name("Other".into(), "1".into())); + let set = volatile_names(&wb); + assert!((0..LEN).all(|i| set.contains(&format!("CHAIN{i}")))); + assert!(set.contains("PLAIN")); + assert!(!set.contains("OTHER")); + assert_eq!(set.len(), LEN + 1); +} + #[test] fn round_trip_of_a_simple_book_is_clean() { let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]); diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 10e17dc44..33dc84936 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -193,7 +193,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let Some(file) = rfd::AsyncFileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"]) .pick_file() .await else { diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index aaa012934..a7472821f 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -37,13 +37,13 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-update = { workspace = true } rfd = { workspace = true } -arboard = { workspace = true } +arboard = { workspace = true, features = ["image-data"] } gridcraft-mcp = { workspace = true } # The control channel's token (OS random numbers); already in the tree. getrandom = "0.4" [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] -arboard = { workspace = true, features = ["wayland-data-control"] } +arboard = { workspace = true, features = ["image-data", "wayland-data-control"] } [target.'cfg(target_os = "windows")'.dependencies] # Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe. diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3aab1ccfc..2aa98b6eb 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -154,7 +154,7 @@ fn read_clipboard( fn services() -> Services { // Keep the owner alive: X11/Wayland serve clipboard data from this handle. let clipboard: Rc>> = Rc::default(); - let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone()); + let (html_clipboard, text_clipboard, image_clipboard) = (clipboard.clone(), clipboard.clone(), clipboard.clone()); Services { copy_html: Some(Box::new(move |html, text| { let mut clipboard = clipboard.borrow_mut(); @@ -165,9 +165,13 @@ fn services() -> Services { })), paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))), paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))), + paste_image: Some(Box::new(move || { + let image = read_clipboard(&image_clipboard, |clipboard| clipboard.get_image())?; + image::RgbaImage::from_raw(u32::try_from(image.width).ok()?, u32::try_from(image.height).ok()?, image.bytes.into_owned()) + })), pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"]) .add_filter("Excel Workbook", &["xlsx", "xlsm"]) .add_filter("Excel 97-2003 Workbook (import)", &["xls"]) .add_filter("Excel Binary Workbook (data import)", &["xlsb"]) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 4972623fc..7f10354da 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -325,6 +325,11 @@ impl<'h> Evaluator<'h> { Expr::Array(rows) => { let h = rows.len(); let w = rows.first().map_or(0, Vec::len); + // A ragged literal is an error either way; one wide row and many empty ones must + // not reserve their rows-times-width product first. + if rows.iter().any(|row| row.len() != w) { + return err(CellError::Value); + } let mut data = Vec::with_capacity(h * w); for row in rows { for el in row { @@ -359,9 +364,10 @@ impl<'h> Evaluator<'h> { return v.clone(); } let wb = self.host.workbook(); - // Sheet-qualified `Sheet1!Name`. - let (scope_sheet, bare) = match n.split_once('!') { - Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) { + // Sheet-qualified `Sheet1!Name`. The parser stores the sheet unquoted, so it is used as + // is (`' Sales '` keeps its spaces) and split at the last `!` (`'a!b'!Name`). + let (scope_sheet, bare) = match n.rsplit_once('!') { + Some((s, b)) => match wb.sheet_index(s) { Some(i) => (i, b), None => return err(CellError::Name), }, diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs index 67662c83c..39a6e0f53 100644 --- a/crates/calc/src/eval/array.rs +++ b/crates/calc/src/eval/array.rs @@ -86,6 +86,30 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value } } +/// [`zip2`] that stops at the first cell `f` refuses (`None`), before the rest is built: the +/// result grows as cells are made rather than being reserved for the whole broadcast up front. +fn try_zip2(a: &Value, b: &Value, mut f: impl FnMut(&Value, &Value) -> Option) -> Option { + Some(match (a, b) { + (Value::Array(x), Value::Array(y)) => { + let rows = broadcast_dim(x.rows, y.rows); + let cols = broadcast_dim(x.cols, y.cols); + if broadcast_len(rows, cols).is_none() { + return Some(Value::Error(CellError::Num)); + } + let mut data = Vec::new(); + for r in 0..rows { + for c in 0..cols { + data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))?); + } + } + Value::from(Array { rows, cols, data }) + } + (Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect::>()? }), + (s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect::>()? }), + (x, y) => f(x, y)?, + }) +} + /// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. /// Each operand passes its own size cap, but their broadcast product can be far larger /// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. @@ -135,10 +159,36 @@ pub(super) fn single(v: Value) -> Option { } } +/// Text bytes that one `&` over arrays may produce in all. Each piece is capped on its own, but a +/// compact formula can broadcast a long text across millions of cells +/// (`SEQUENCE(4000,4000)&REPT("x",30000)` is 480 GB): past this the whole result is `#NUM!`, the +/// error for a result too large to hold, before the rest is built. +pub(super) const MAX_CONCAT_ARRAY_BYTES: usize = 512 * 1024 * 1024; + pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { + if op == BinOp::Concat && (matches!(x, Value::Array(_)) || matches!(y, Value::Array(_))) { + return concat_arrays(x, y, sys, MAX_CONCAT_ARRAY_BYTES); + } zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) } +/// `x & y` with at least one array operand, giving `#NUM!` once the texts built so far pass `budget` +/// bytes. +fn concat_arrays(x: &Value, y: &Value, sys: gridcraft_core::DateSystem, budget: usize) -> Value { + let mut used = 0usize; + try_zip2(x, y, |a, b| { + let v = binary_scalar(BinOp::Concat, a, b, sys); + if let Value::Text(t) = &v { + used = used.saturating_add(t.len()); + if used > budget { + return None; + } + } + Some(v) + }) + .unwrap_or(Value::Error(CellError::Num)) +} + fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value { if let Value::Error(e) = a { return Value::Error(*e); @@ -187,7 +237,35 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste #[cfg(test)] mod tests { - use super::{MAX_CELLS, broadcast_len}; + use super::{Array, BinOp, CellError, MAX_CELLS, Value, broadcast_len, concat_arrays, try_zip2}; + + /// The broadcast stops at the first refused cell: nothing after it is visited. + #[test] + fn a_refused_cell_stops_the_broadcast() { + let col = Value::from(Array::column((0..4_000).map(|i| Value::number(i as f64)).collect())); + let row = Value::from(Array { rows: 1, cols: 4_000, data: (0..4_000).map(|i| Value::number(i as f64)).collect() }); + let mut calls = 0; + let r = try_zip2(&col, &row, |a, _| { + calls += 1; + (calls <= 10).then(|| a.clone()) + }); + assert_eq!((r, calls), (None, 11)); + } + + #[test] + fn concatenating_arrays_stops_at_the_byte_budget() { + let sys = gridcraft_core::DateSystem::default(); + let col = Value::from(Array::column((0..100).map(|i| Value::number(i as f64)).collect())); + let long = Value::text("x".repeat(100)); + // 100 cells of 101 or 102 bytes each: about 10 KB in all. + let r = concat_arrays(&col, &long, sys, 20_000); + assert!(matches!(&r, Value::Array(a) if a.data.len() == 100), "{r:?}"); + assert_eq!(concat_arrays(&col, &long, sys, 5_000), Value::Error(CellError::Num)); + assert_eq!(concat_arrays(&long, &col, sys, 5_000), Value::Error(CellError::Num)); + // Through the operator, a small result is unaffected. + let r = super::binary_values(BinOp::Concat, &col, &Value::text("a"), sys); + assert!(matches!(&r, Value::Array(a) if a.data.first() == Some(&Value::text("0a"))), "{r:?}"); + } #[test] fn broadcast_len_caps_at_max_cells() { diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 63ac533de..160e0d60d 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -24,7 +24,7 @@ mod pass_host; #[cfg(test)] use graph::Intervals; pub use graph::{FxHasher, Graph, Key}; -use graph::{HashMap, HashSet, Node}; +use graph::{HashMap, HashSet, Node, Taken}; use pass_host::{PassHost, spills_as_before}; /// Calculation state kept beside a workbook. @@ -292,17 +292,16 @@ impl Calc { } } } - // Spill areas of changed anchors also change. + // Spill areas of changed anchors also change. They stay rectangles: one seed per spilled + // cell listed the formulas reading the area once per cell. + let mut areas: Vec<(usize, RangeRef)> = cleared; for &k in changed { if let Some(r) = wb.sheet(k.0).and_then(|s| s.spill_ranges.get(&k.1)) { - seeds.extend(r.iter().map(|c| (k.0, c))); + areas.push((k.0, *r)); } } - for (si, r) in &cleared { - seeds.extend(r.iter().map(|c| (*si, c))); - } let t0 = prof_now(); - let dirty = self.dirty_closure(wb, &seeds); + let dirty = self.dirty_closure(wb, &seeds, &areas); let t1 = prof_now(); let n = dirty.len(); self.run(wb, dirty, false); @@ -311,16 +310,39 @@ impl Calc { } } - fn dirty_closure(&self, _wb: &Workbook, seeds: &[Key]) -> Vec { + /// The formulas to recalculate after `seeds` and every cell of `areas` changed: the seeds + /// that are formulas, and everything that reads them, directly or through other formulas. + fn dirty_closure(&self, wb: &Workbook, seeds: &[Key], areas: &[(usize, RangeRef)]) -> Vec { let mut seen: HashSet = HashSet::default(); let mut queue: VecDeque = VecDeque::new(); + // Each range-index entry is listed once in the whole walk: many changed cells under one + // broad reference (or many formulas inside one) would otherwise list every formula reading + // it once per cell, which squared the work. + let mut taken = Taken::default(); + let mut deps = Vec::new(); for k in seeds { if self.graph.nodes.contains_key(k) && seen.insert(*k) { queue.push_back(*k); } - let mut deps = Vec::new(); - self.graph.dependents(k.0, k.1, &mut deps); - for d in deps { + deps.clear(); + self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps); + for d in deps.drain(..) { + if seen.insert(d) { + queue.push_back(d); + } + } + } + for &(si, r) in areas { + if let Some(sh) = wb.sheet(si) { + for (c, _) in sh.cells.iter_range(r) { + if self.graph.nodes.contains_key(&(si, c)) && seen.insert((si, c)) { + queue.push_back((si, c)); + } + } + } + deps.clear(); + self.graph.dependents_of_range(si, r, &mut deps); + for d in deps.drain(..) { if seen.insert(d) { queue.push_back(d); } @@ -331,10 +353,9 @@ impl Calc { queue.push_back(*k); } } - let mut deps = Vec::new(); while let Some(k) = queue.pop_front() { deps.clear(); - self.graph.dependents(k.0, k.1, &mut deps); + self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps); for d in deps.drain(..) { if seen.insert(d) { queue.push_back(d); @@ -411,7 +432,7 @@ impl Calc { if spill_changes.is_empty() { break; } - dirty = self.dirty_closure(wb, &spill_changes); + dirty = self.dirty_closure(wb, &spill_changes, &[]); } // Loops among formulas the run did not evaluate stay listed while they are still formulas. // Iterative calculation reports none. @@ -691,6 +712,149 @@ pub(crate) mod tests { assert!(visited < 64, "visited {visited} nodes for no match"); } + #[test] + fn untaken_queries_list_each_interval_once_in_all() { + // 1,000 ranges over every row of 5,000: point queries at every row used to list all 1,000 + // each time (5 million matches); sharing `taken`, each range is listed once in all. + let n = 5_000u32; + let mut iv: Intervals = Intervals::default(); + for t in 0..1_000 { + iv.insert(0, n, t); + } + for r in 0..n { + iv.insert(r, r, 10_000 + r); + } + iv.settle(); + let mut taken = Vec::new(); + let mut listed = Vec::new(); + for row in 0..=n + 3 { + iv.query_untaken(row, row, &mut taken, &mut |t| { + listed.push(*t); + true + }); + } + listed.sort_unstable(); + let want: Vec = (0..1_000).chain(10_000..10_000 + n).collect(); + assert_eq!(listed, want); + // An entry the caller declines is offered again. + let mut taken = Vec::new(); + let mut offered = 0; + for _ in 0..3 { + iv.query_untaken(7, 7, &mut taken, &mut |t| { + if *t == 10_007 { + offered += 1; + } + *t != 10_007 + }); + } + assert_eq!(offered, 3); + } + + #[test] + fn dirty_closure_matches_a_walk_of_every_cell() { + // Random formulas over single cells, narrow ranges and wide (over 64 columns) ranges, some + // removed or left unsettled: the closure equals the one found by listing every dependent + // of every changed cell, area cells included, as before `taken` and areas existed. + use super::{HashSet, Node}; + use crate::eval::Area; + use std::collections::VecDeque; + let mut s = 0x2545_F491_4F6C_DD1D; + for case in 0..40 { + let mut wb = Workbook::new(); + let mut calc = Calc::new(); + let pick = |s: &mut u64, n: u64| (rng(s) % n) as u32; + let mut keys: Vec = Vec::new(); + for _ in 0..300 { + let k = (0, CellRef::new(pick(&mut s, 120), pick(&mut s, 150))); + let mut areas = Vec::new(); + for _ in 0..1 + pick(&mut s, 3) { + let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150)); + let end = match pick(&mut s, 3) { + 0 => start, + 1 => CellRef::new(start.row + pick(&mut s, 40), start.col + pick(&mut s, 5)), + _ => CellRef::new(start.row + pick(&mut s, 20), start.col + 65 + pick(&mut s, 30)), + }; + areas.push(Area { sheet: 0, range: RangeRef::new(start, end) }); + } + if let Some(sh) = wb.sheet_mut(0) { + sh.cells.set(k.1, Cell::formula(Formula::new("=1"))); + } + calc.graph.insert(k, Node { areas, dynamic: false }); + keys.push(k); + if rng(&mut s).is_multiple_of(7) { + calc.graph.settle(); + } + } + for _ in 0..30 { + let k = keys[(rng(&mut s) % keys.len() as u64) as usize]; + calc.graph.remove(k); + if let Some(sh) = wb.sheet_mut(0) { + sh.cells.remove(k.1); + } + } + if case % 2 == 0 { + calc.graph.settle(); + } + let seeds: Vec = (0..1 + pick(&mut s, 25)).map(|_| (0, CellRef::new(pick(&mut s, 140), pick(&mut s, 220)))).collect(); + let areas: Vec<(usize, RangeRef)> = (0..pick(&mut s, 3)) + .map(|_| { + let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150)); + (0, RangeRef::new(start, CellRef::new(start.row + pick(&mut s, 30), start.col + pick(&mut s, 80)))) + }) + .collect(); + let got: HashSet = calc.dirty_closure(&wb, &seeds, &areas).into_iter().collect(); + // The walk before: every area cell is a seed, and every cell lists all its dependents. + let mut all = seeds.clone(); + for (si, r) in &areas { + all.extend(r.iter().map(|c| (*si, c))); + } + let mut want: HashSet = HashSet::default(); + let mut queue: VecDeque = VecDeque::new(); + let mut deps = Vec::new(); + for k in &all { + if calc.graph.nodes.contains_key(k) && want.insert(*k) { + queue.push_back(*k); + } + deps.clear(); + calc.graph.dependents(k.0, k.1, &mut deps); + queue.extend(deps.iter().copied().filter(|d| want.insert(*d))); + } + while let Some(k) = queue.pop_front() { + deps.clear(); + calc.graph.dependents(k.0, k.1, &mut deps); + queue.extend(deps.iter().copied().filter(|d| want.insert(*d))); + } + assert_eq!(got, want, "case {case}"); + } + } + + #[test] + fn clearing_a_spill_recalculates_formulas_reading_it_once_each() { + // 300 formulas read the area an anchor spills into; clearing the anchor changes all of it + // and every one of them is recalculated (they were listed once per spilled cell). + let mut wb = Workbook::new(); + let mut calc = Calc::new(); + let set = |wb: &mut Workbook, calc: &mut Calc, at: CellRef, input: &str| { + if let Some(sh) = wb.sheet_mut(0) { + if input.is_empty() { + sh.cells.remove(at); + } else { + sh.cells.set(at, Cell::formula(Formula::new(input))); + } + } + calc.cells_changed(wb, &[(0, at)]); + }; + set(&mut wb, &mut calc, CellRef::new(0, 0), "=SEQUENCE(2000)"); + for r in 0..300 { + set(&mut wb, &mut calc, CellRef::new(r, 2), "=COUNT(A1:A2000)"); + } + assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(299, 2))), Some(Value::Number(2000.0))); + set(&mut wb, &mut calc, CellRef::new(0, 0), ""); + for r in 0..300 { + assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(r, 2))), Some(Value::Number(0.0)), "row {r}"); + } + } + /// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K. fn sum_rows(rows: u32) -> Workbook { let mut wb = Workbook::new(); diff --git a/crates/calc/src/recalc/graph.rs b/crates/calc/src/recalc/graph.rs index ab034f8cc..f8a100eb3 100644 --- a/crates/calc/src/recalc/graph.rs +++ b/crates/calc/src/recalc/graph.rs @@ -151,6 +151,54 @@ impl Intervals { self.walk(1, 0, self.max_end.len() / 2, p, a, f) } + /// [`Intervals::query`] that skips entries an earlier call with the same `taken` already + /// took (and whole subtrees of them), so that a series of queries lists each interval once in + /// all. `f` answers whether it took the entry; one it declines is offered again later. + /// `taken` belongs to this index as it is now; start it empty. Pending insertions (a few at + /// most after [`Intervals::settle`]) are offered every time. + pub(super) fn query_untaken(&self, a: u32, b: u32, taken: &mut Vec, f: &mut dyn FnMut(&T) -> bool) { + for it in &self.pending { + if it.0 <= b && it.1 >= a { + f(&it.2); + } + } + let p = self.items.partition_point(|it| it.0 <= b); + if p == 0 { + return; + } + if taken.len() != self.max_end.len() { + *taken = vec![false; self.max_end.len()]; + } + self.walk_untaken(1, 0, self.max_end.len() / 2, p, a, taken, f); + } + + /// Returns whether everything under `node` has now been reported (or is dead). + #[allow(clippy::too_many_arguments)] + fn walk_untaken(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, taken: &mut [bool], f: &mut dyn FnMut(&T) -> bool) -> bool { + if taken.get(node).copied().unwrap_or(true) { + return true; + } + if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { + return false; + } + let done = if hi - lo <= 1 { + match (self.items.get(lo), self.dead.get(lo)) { + (Some(it), Some(false)) if it.1 >= a => f(&it.2), + (Some(_), Some(true)) => true, + _ => false, + } + } else { + let mid = lo + (hi - lo) / 2; + let left = self.walk_untaken(2 * node, lo, mid, p, a, taken, f); + let right = self.walk_untaken(2 * node + 1, mid, hi, p, a, taken, f); + left && right + }; + if done && let Some(slot) = taken.get_mut(node) { + *slot = true; + } + done + } + fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize { if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { return 1; @@ -168,6 +216,13 @@ impl Intervals { } } +/// Range-index entries one dependency walk has already listed (see [`Graph::dependents_untaken`]). +#[derive(Default)] +pub(super) struct Taken { + cols: HashMap<(usize, u32), Vec>, + wide: HashMap>, +} + #[derive(Clone, Debug, Default)] pub struct Graph { pub(super) nodes: HashMap, @@ -302,6 +357,32 @@ impl Graph { } } + /// [`Graph::dependents`] leaving out the formulas whose range reads an earlier call with the + /// same `taken` already listed. A walk over many changed cells that a few broad ranges cover + /// then lists those formulas once, instead of once per cell (which squared the work). The + /// graph must not change while `taken` is in use. + pub(super) fn dependents_untaken(&self, sheet: usize, c: CellRef, taken: &mut Taken, out: &mut Vec) { + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend(v.iter().copied()); + } + if let Some(v) = self.col_deps.get(&(sheet, c.col)) { + v.query_untaken(c.row, c.row, taken.cols.entry((sheet, c.col)).or_default(), &mut |k| { + out.push(*k); + true + }); + } + if let Some(v) = self.wide_deps.get(&sheet) { + // A wide entry is taken once it covers the cell's column too. + v.query_untaken(c.row, c.row, taken.wide.entry(sheet).or_default(), &mut |(c0, c1, k)| { + let hit = *c0 <= c.col && c.col <= *c1; + if hit { + out.push(*k); + } + hit + }); + } + } + /// Formulas that read any cell of `range` (a formula can be listed more than once). pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { if range.count() <= 4096 { diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs index ce0bbebb0..7fae90a9f 100644 --- a/crates/chart/src/render/cartesian.rs +++ b/crates/chart/src/render/cartesian.rs @@ -1,6 +1,6 @@ use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; use super::legend::{cat_label, plot_kind}; -use super::lines::{decimate, draw_markers, draw_runs}; +use super::lines::{decimate_area, draw_markers, draw_runs}; use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline}; use super::{ Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT, @@ -96,8 +96,11 @@ fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Ve } else { None }; - let mut row = Vec::with_capacity(ncat); - for j in 0..ncat { + // A series without values has no spans: 255 empty series over 20,000 categories would + // otherwise fill ~5 million entries. + let n = if s.values.is_empty() { 0 } else { ncat }; + let mut row = Vec::with_capacity(n); + for j in 0..n { let v = s.values.get(j).copied().flatten(); let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { @@ -268,10 +271,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]]; } } - let top = decimate(top, MAX_LINE_PTS * 2); - let base = decimate(base, MAX_LINE_PTS * 2); - let mut poly = top; - poly.extend(base.into_iter().rev()); + let poly = decimate_area(top, base, MAX_LINE_PTS * 2); if poly.len() >= 3 { ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None }); } @@ -453,3 +453,18 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) } draw_trend_labels(ctx, plot, &trend_labels); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_series_get_no_span_entries() { + let empty = SeriesData::default(); + let full = SeriesData { values: vec![Some(1.0); 3], ..SeriesData::default() }; + let data = ChartData { series: vec![empty.clone(), full, empty], ..ChartData::default() }; + let fams = [Fam::Col; 3]; + let spans = series_spans(&data, &fams, &[false; 3], MAX_CATS); + assert_eq!(spans.iter().map(Vec::len).collect::>(), [0, MAX_CATS, 0]); + } +} diff --git a/crates/chart/src/render/kinds.rs b/crates/chart/src/render/kinds.rs index 54ce6bec8..10286107b 100644 --- a/crates/chart/src/render/kinds.rs +++ b/crates/chart/src/render/kinds.rs @@ -203,12 +203,11 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne // ---------------------------------------------------------------- treemap -fn worst(row: &[f64], side: f64) -> f64 { - let s: f64 = row.iter().sum(); +/// Worst aspect ratio of a row with the given sum, smallest and largest area. +fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 { if s <= 0.0 || side <= 0.0 { return f64::INFINITY; } - let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); let s2 = s * s; let w2 = side * side; (w2 * mx / s2).max(s2 / (w2 * mn)) @@ -225,6 +224,8 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { let mut out = Vec::with_capacity(items.len()); let mut rem = r; let mut row: Vec<(usize, f64)> = Vec::new(); + // The row's sum, smallest and largest area, kept as items join so each step is O(1). + let (mut sum, mut mn, mut mx) = (0.0f64, f64::INFINITY, 0.0f64); let mut i = 0; let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { let s: f64 = row.iter().map(|x| x.1).sum(); @@ -256,15 +257,14 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { while let Some(&(j, v)) = items.get(i) { let a = v * scale; let side = rem.w.min(rem.h) as f64; - let cur: Vec = row.iter().map(|x| x.1).collect(); - let mut with = cur.clone(); - with.push(a); - if row.is_empty() || worst(&with, side) <= worst(&cur, side) { + if row.is_empty() || worst(sum + a, mn.min(a), mx.max(a), side) <= worst(sum, mn, mx, side) { row.push((j, a)); + (sum, mn, mx) = (sum + a, mn.min(a), mx.max(a)); i += 1; } else { layout(&row, &mut rem, &mut out); row.clear(); + (sum, mn, mx) = (0.0, f64::INFINITY, 0.0); } } if !row.is_empty() { @@ -279,6 +279,7 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); items.sort_by(|a, b| b.1.total_cmp(&a.1)); let f = Fmt::new(&s.number_format); + let value_of: std::collections::HashMap = items.iter().copied().collect(); for (j, b) in squarify(&items, area) { let fill = point_color(data, j); ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); @@ -286,7 +287,7 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top)); if chart.data_labels && b.h > LABEL_SIZE * 3.2 { - let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); + let v = value_of.get(&j).copied().unwrap_or(0.0); let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top)); } diff --git a/crates/chart/src/render/lines.rs b/crates/chart/src/render/lines.rs index ae0229cfb..770aabe02 100644 --- a/crates/chart/src/render/lines.rs +++ b/crates/chart/src/render/lines.rs @@ -54,6 +54,86 @@ pub(super) fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { out } +/// Decimates the top and baseline of an area chart together at the same horizontal indices so +/// `area_mesh`'s paired vertical-strip topology (`pts[i]` ↔ `pts[2n - 1 - i]`) is preserved. +pub(super) fn decimate_area(top: Vec<[f32; 2]>, base: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { + let n = top.len().min(base.len()); + if n == 0 { + return Vec::new(); + } + if n <= max { + let mut poly = top; + poly.truncate(n); + poly.extend(base.into_iter().take(n).rev()); + return poly; + } + let mut cols: Vec<[usize; 6]> = Vec::new(); + let mut keep = Vec::with_capacity(max * 2); + let mut i = 0; + while i < n { + let col = top[i][0].floor(); + let (start, mut t_mn, mut t_mx, mut b_mn, mut b_mx) = (i, i, i, i, i); + let mut k = i + 1; + while k < n && top[k][0].floor() == col { + if top[k][1] < top[t_mn][1] { + t_mn = k; + } + if top[k][1] > top[t_mx][1] { + t_mx = k; + } + if base[k][1] < base[b_mn][1] { + b_mn = k; + } + if base[k][1] > base[b_mx][1] { + b_mx = k; + } + k += 1; + } + let entry = [start, t_mn, t_mx, b_mn, b_mx, k - 1]; + cols.push(entry); + let mut idx = entry; + idx.sort_unstable(); + let mut last = usize::MAX; + for ix in idx { + if ix != last { + keep.push(ix); + last = ix; + } + } + i = k; + } + if keep.len() > max * 4 { + let max_groups = (max * 4 / 6).max(1); + let group_size = cols.len().div_ceil(max_groups); + keep.clear(); + for ch in cols.chunks(group_size) { + let start = ch[0][0]; + let end = ch.last().map_or(start, |c| c[5]); + let t_mn = ch.iter().map(|c| c[1]).min_by(|&a, &b| top[a][1].total_cmp(&top[b][1])).unwrap_or(start); + let t_mx = ch.iter().map(|c| c[2]).max_by(|&a, &b| top[a][1].total_cmp(&top[b][1])).unwrap_or(start); + let b_mn = ch.iter().map(|c| c[3]).min_by(|&a, &b| base[a][1].total_cmp(&base[b][1])).unwrap_or(start); + let b_mx = ch.iter().map(|c| c[4]).max_by(|&a, &b| base[a][1].total_cmp(&base[b][1])).unwrap_or(start); + let mut idx = [start, t_mn, t_mx, b_mn, b_mx, end]; + idx.sort_unstable(); + let mut last = usize::MAX; + for ix in idx { + if ix != last { + keep.push(ix); + last = ix; + } + } + } + } + let mut poly = Vec::with_capacity(keep.len() * 2); + for &ix in &keep { + poly.push(top[ix]); + } + for &ix in keep.iter().rev() { + poly.push(base[ix]); + } + poly +} + /// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box), /// each run is interpolated with a Catmull-Rom spline so the line curves through the data points. pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option) { @@ -155,4 +235,57 @@ mod smooth_tests { assert!(out.len() > 5); assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1]))); } + + #[test] + fn decimate_area_preserves_paired_vertical_strips() { + let top: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, ((i * 37) % 100) as f32]).collect(); + let base: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, 100.0]).collect(); + let poly = decimate_area(top, base, 64); + assert_eq!(poly.len() % 2, 0); + let half = poly.len() / 2; + for i in 0..half { + assert_eq!(poly[i][0], poly[poly.len() - 1 - i][0]); + assert_eq!(poly[poly.len() - 1 - i][1], 100.0); + } + } + + #[test] + fn decimate_area_preserves_extrema_across_3000_columns() { + // 3,000 occupied pixel columns with 6 points per column (18,000 points total), + // where each column has distinct top and baseline extrema so the initial per-column + // index list exceeds `MAX_LINE_PTS * 2 * 4` (6,400). + let max = MAX_LINE_PTS * 2; + let mut top = Vec::with_capacity(18_000); + let mut base = Vec::with_capacity(18_000); + for col in 0..3_000 { + let y_top = [50.0, 20.0, 80.0, 50.0, 50.0, 50.0]; + let y_base = [150.0, 150.0, 150.0, 120.0, 180.0, 150.0]; + for s in 0..6 { + let x = col as f32 + s as f32 * 0.1; + top.push([x, y_top[s]]); + base.push([x, y_base[s]]); + } + } + // Plant sharp spikes on both top and baseline in interior columns. + top[777 * 6 + 1][1] = 1.0; + top[1999 * 6 + 2][1] = 99.0; + base[1234 * 6 + 3][1] = 101.0; + base[2501 * 6 + 4][1] = 199.0; + + let poly = decimate_area(top, base, max); + assert_eq!(poly.len() % 2, 0); + let half = poly.len() / 2; + assert!(half <= max * 4, "retained {half} paired vertices, limit is {}", max * 4); + for i in 0..half { + assert_eq!(poly[i][0], poly[poly.len() - 1 - i][0]); + } + let top_kept = &poly[..half]; + let base_kept = &poly[half..]; + assert_eq!(top_kept.first().map(|p| p[0]), Some(0.0)); + assert_eq!(top_kept.last().map(|p| p[0]), Some(2999.5)); + assert!(top_kept.iter().any(|p| p[1] == 1.0), "top min spike preserved"); + assert!(top_kept.iter().any(|p| p[1] == 99.0), "top max spike preserved"); + assert!(base_kept.iter().any(|p| p[1] == 101.0), "base min spike preserved"); + assert!(base_kept.iter().any(|p| p[1] == 199.0), "base max spike preserved"); + } } diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 6eee201ae..de3cdef1b 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -179,9 +179,10 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> ( /// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on /// that sheet), the way the evaluator looks names up. fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool { - match t.split_once('!') { - Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()), - None => wb.name(t, sheet).is_some(), + // `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012). + match gridcraft_core::split_sheet(t) { + (Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()), + (None, _) => wb.name(t, sheet).is_some(), } } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 46d047afc..6e423802c 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -1258,3 +1258,75 @@ fn box_whisker_draws_each_series_quartile_method() { // The boxes span 3.5 and 2.5 data units on the same value axis. assert!((heights[0] / heights[1] - 1.4).abs() < 0.001); } + +#[test] +fn a_series_named_by_a_name_on_a_sheet_with_an_apostrophe_resolves() { + // #2012: `'Bob''s'!Title` kept the doubled quote, so the sheet wasn't found and the series + // was named by the literal text. + let mut wb = Workbook::new(); + { + let s = wb.sheet_mut(0).unwrap(); + s.name = "Bob's".into(); + s.set_value(CellRef::new(0, 0), Value::from("Takings")); + s.set_value(CellRef::new(1, 0), Value::Number(4.0)); + s.set_value(CellRef::new(2, 0), Value::Number(9.0)); + } + wb.names.push(gridcraft_model::DefinedName { + name: "Title".into(), + scope: Some(0), + formula: "'Bob''s'!$A$1".into(), + comment: String::new(), + hidden: false, + }); + let mut c = chart(ChartKind::ColumnClustered); + c.series = vec![Series { + name: Some("'Bob''s'!Title".into()), + categories: None, + values: "'Bob''s'!$A$2:$A$3".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + quartiles: Default::default(), + smooth: false, + trendlines: Vec::new(), + }]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings")); +} + +#[test] +fn a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves() { + // `'a!b'!Title`: the sheet name holds a `!`, so the evaluator must split the parsed name at + // the last one, not the first. + let mut wb = Workbook::new(); + { + let s = wb.sheet_mut(0).unwrap(); + s.name = "a!b".into(); + s.set_value(CellRef::new(0, 0), Value::from("Takings")); + s.set_value(CellRef::new(1, 0), Value::Number(4.0)); + s.set_value(CellRef::new(2, 0), Value::Number(9.0)); + } + wb.names.push(gridcraft_model::DefinedName { + name: "Title".into(), + scope: Some(0), + formula: "'a!b'!$A$1".into(), + comment: String::new(), + hidden: false, + }); + let mut c = chart(ChartKind::ColumnClustered); + c.series = vec![Series { + name: Some("'a!b'!Title".into()), + categories: None, + values: "'a!b'!$A$2:$A$3".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + quartiles: Default::default(), + smooth: false, + trendlines: Vec::new(), + }]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings")); +} diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 9d10ea87c..c8ddf7460 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -188,6 +188,12 @@ impl RangeRef { end: CellRef::new(self.end.row.max(o.end.row), self.end.col.max(o.end.col)), } } + /// The top-left cell of a list of areas: the lowest row and the lowest column across all of + /// them, whatever order they are listed in. Excel writes the relative formulas of a + /// multi-area validation or conditional format for this cell. `None` for no areas. + pub fn top_left(ranges: &[RangeRef]) -> Option { + ranges.iter().copied().reduce(|a, b| a.union(&b)).map(|r| r.start) + } /// Row-major iterator over the cells. Callers must bound huge ranges themselves. pub fn iter(&self) -> impl Iterator + '_ { let (r0, r1, c0, c1) = (self.start.row, self.end.row, self.start.col, self.end.col); @@ -307,6 +313,16 @@ mod tests { Ok(()) } + #[test] + fn top_left_spans_every_area_in_any_order() { + let areas: Vec = ["H62:H68", "H26:H27", "H93:H95"].iter().filter_map(|a| RangeRef::parse(a)).collect(); + assert_eq!(RangeRef::top_left(&areas), CellRef::parse("H26")); + // Lowest row and lowest column, even when they come from different areas. + let areas: Vec = ["C5:D6", "E2", "B9"].iter().filter_map(|a| RangeRef::parse(a)).collect(); + assert_eq!(RangeRef::top_left(&areas), CellRef::parse("B2")); + assert_eq!(RangeRef::top_left(&[]), None); + } + #[test] fn sheet_quoting_follows_excel() { for quoted in [ diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 45455e00b..234e92e58 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -275,6 +275,11 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[ ("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"), ("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"), ("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"), + // Developer (Insert › Controls in GridCraft) + ("insert.button", "Developer", "Controls", "Button (Form Control)"), + ("insert.spinButton", "Developer", "Controls", "Spin Button (Form Control)"), + ("insert.slider", "Developer", "Controls", "Scroll Bar (Form Control)"), + ("control.format", "Developer", "Controls", "Properties"), // Table Design ("table.rename", "Table Design", "Properties", "Table Name"), ("table.resize", "Table Design", "Properties", "Resize Table"), diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index 4c9325e36..38f5bb1fe 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -244,7 +244,7 @@ impl CfCache { let v = sh.value(c); let no_stats = RuleStats::default(); for (idx, cf) in rules { - let first = cf.ranges.first().map(|r| r.start).unwrap_or(c); + let first = RangeRef::top_left(&cf.ranges).unwrap_or(c); let st: &RuleStats = match stats_need(&cf.rule) { Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)), None => &no_stats, diff --git a/crates/engine/src/clip_html.rs b/crates/engine/src/clip_html.rs index e1d217c9c..fa9822390 100644 --- a/crates/engine/src/clip_html.rs +++ b/crates/engine/src/clip_html.rs @@ -17,11 +17,13 @@ const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize; /// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source. const MAX_HTML_CELLS: usize = 1_000_000; -/// One table cell: its visible text and the number the source recorded for it, if any. +/// One table cell: its visible text, the number the source recorded for it, if any, and whether +/// it is a header cell (``). #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct HtmlCell { pub text: String, pub num: Option, + pub header: bool, } /// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of @@ -88,7 +90,7 @@ pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec().ok()).filter(|n| n.is_finite()); let colspan = span(attrs, "colspan", MAX_SPAN); let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN); - cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan }); + cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num, header: name == "th" }, colspan, rowspan }); } ("td" | "th", true) | ("tr", true) => grid.finish(&mut cell), ("br", _) => { @@ -376,18 +378,18 @@ mod tests { 2.50 & upx"#; let rows = table_cells(excel, 2, 3); assert_eq!(rows.len(), 2); - assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }); - assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None }); + assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }); + assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None, ..HtmlCell::default() }); assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column"); assert_eq!(rows[1][0].text, "2.50 & up"); assert_eq!(rows[1][2].text, "x"); let calc = r#"
Rp10,000.00
"#; - assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]); + assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }]]); let quoted_gt = r#"
1500
"#; - assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]); + assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0), ..HtmlCell::default() }]]); } #[test] @@ -434,7 +436,7 @@ mod tests { assert!(cells <= MAX_HTML_CELLS, "{cells} cells"); assert!(rows.iter().all(|r| r.len() <= MAX_SPAN)); assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width"); - assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) }); + assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0), ..HtmlCell::default() }); // Bounded by the pasted text's shape, the same markup yields just that shape. let rows = table_cells(&html, 3, 2); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index e6068941b..a0186d5f4 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -2,6 +2,7 @@ //! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`). pub mod analysis; +pub mod controls; pub mod data; pub mod draw; pub mod edit; @@ -166,6 +167,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(format::specs()); v.extend(sheet::specs()); v.extend(insert::specs()); + v.extend(controls::specs()); v.extend(equation::specs()); v.extend(data::specs()); v.extend(formulas::specs()); @@ -437,6 +439,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) } else if !changed.is_empty() { d.calc.cells_changed(&mut wb, &changed); } + controls::sync_linked_values(&mut wb); if let Some(t0) = t0 { eprintln!("commit: + recalc {:?}", since(t0)); } @@ -461,8 +464,11 @@ pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000; pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; /// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or -/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or -/// deleting a sheet isn't an edit of its cells. +/// formula), or that unlocked it, whichever command made it. Sheets are matched by name, so +/// moving, renaming or deleting a sheet isn't an edit of its cells. A sort or duplicate removal +/// that moves an unlocked cell with equal content onto a locked one changes no content but would +/// leave the cell editable, so the effective lock (row and column formats included) is compared +/// as well. fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> { for sh in &old.sheets { if !sh.is_protected() { @@ -473,7 +479,8 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String) continue; } for c in sh.cells.diff(&after.cells) { - if !same_content(sh.cell(c), after.cell(c)) + let unlocked_now = !new.styles.get(after.style_id(c)).protection.locked; + if (unlocked_now || !same_content(sh.cell(c), after.cell(c))) && old.styles.get(sh.style_id(c)).protection.locked && let Some(m) = locked_cell_error(sh, c, unlocked) { @@ -501,10 +508,14 @@ pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick: pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option { let mut needs_password = None; for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) { - if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) { + // A range limited to Windows users or groups (a security descriptor) can't be checked + // here, so without a password it stays locked rather than opening to every editor. + if (r.password.is_none() && r.security_descriptor.is_none()) || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) { return None; } - needs_password.get_or_insert(r.title.as_str()); + if r.password.is_some() { + needs_password.get_or_insert(r.title.as_str()); + } } Some(match needs_password { Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."), diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs new file mode 100644 index 000000000..f1064ce33 --- /dev/null +++ b/crates/engine/src/cmd/controls.rs @@ -0,0 +1,711 @@ +//! Form controls (Insert › Controls): buttons that run a macro or a command, and sliders and spin +//! buttons that hold a whole number from 0 to 30000 and write it to a linked cell. +//! +//! A slider or spin button's value lives on the control and in its linked cell. Setting it goes +//! through `cell.set`, so the cell's dependents recalculate and Undo takes both back as one step; +//! editing the linked cell moves the control (see [`sync_linked_values`]). Pressing a button runs +//! its action through the macro policy with the document's trust, so a button from a file someone +//! else made asks before it runs anything. + +use gridcraft_core::CellRef; +use gridcraft_model::{ + Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MAX_MACRO_NAME, MacroInput, ObjKind, Sheet, Workbook, +}; +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, stash, str_param, u32_param, +}; +use crate::macros::policy::{Policy, macro_policy}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "insert.button", + "Button", + ["Insert", "Controls"], + None, + "{at?: \"B2\" (or cell), dx?, dy?, width?, height?, text?, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\", command?: id, params?: {..}} places a button; pressing it runs the macro or the command → {control: id}", + has_doc, + insert_button + ), + cmd!( + "insert.slider", + "Slider", + ["Insert", "Controls"], + None, + "{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\" | \"Sheet2!$B$1\", min?: 0, max?: 100, step?: 1, page?: 10, value?, horizontal?: true} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min, page ≥ step → {control: id}", + has_doc, + |s, p| insert_number_control(s, p, ControlKind::Slider) + ), + cmd!( + "insert.spinButton", + "Spin Button", + ["Insert", "Controls"], + None, + "{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\", min?: 0, max?: 100, step?: 1, value?} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min → {control: id}", + has_doc, + |s, p| insert_number_control(s, p, ControlKind::Spin) + ), + cmd!( + "control.format", + "Format Control", + [], + None, + "{id, text?, name?, link?: \"$B$1\" | \"\" (no link), min?, max?, step?, page?, value?, horizontal?, print?, locked?} → the control (as control.list)", + has_doc, + format + ), + cmd!( + "control.assign", + "Assign Action", + [], + None, + "{id, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\" | command?: id, params?: {..} | clear?: true} sets what a button runs → the control", + has_doc, + assign + ), + cmd!( + "control.setValue", + "Change Control Value", + [], + None, + "{id, value} clamps the value to min..max, snaps it to the step, and writes it to the linked cell → {value}", + has_doc, + set_value + ), + cmd!( + "control.press", + "Press Button", + [], + None, + "{id, approve?: bool, onConflict?, overwrite?} runs a button's macro or command (through the macro policy, with the workbook's trust; a workbook from a file asks first) → what the macro or command returned", + has_doc, + press + ), + cmd!(query "control.list", "Form Controls", [], None, "{} → the active sheet's controls: [{id, kind, name, text, at, link?, value, min, max, step, page, horizontal, action?}]", has_doc, list), + ] +} + +/// The longest caption or name a control takes. +const MAX_TEXT: usize = 1_024; + +fn control_json(c: &FormControl) -> Json { + let mut o = json!({ + "id": c.id, + "kind": match c.kind { ControlKind::Button => "button", ControlKind::Slider => "slider", ControlKind::Spin => "spinButton" }, + "name": c.display_name(), + "at": c.anchor.cell.a1(), + "width": c.anchor.width, + "height": c.anchor.height, + "print": c.print, + "locked": c.locked, + }); + if c.kind == ControlKind::Button { + o["text"] = json!(c.text); + if let Some(a) = &c.action { + o["action"] = serde_json::to_value(a).unwrap_or(Json::Null); + } + } else { + o["value"] = json!(c.value); + o["min"] = json!(c.min); + o["max"] = json!(c.max); + o["step"] = json!(c.step); + if c.kind == ControlKind::Slider { + o["page"] = json!(c.page); + o["horizontal"] = json!(c.horizontal); + } + } + if let Some(l) = &c.link { + o["link"] = json!(l); + } + o +} + +fn list(s: &mut Session, _: &Json) -> Result { + let sh = s.doc()?.wb.active().ok_or(EngineError::NoDocument)?; + Ok(Json::Array(sh.controls.iter().map(control_json).collect())) +} + +/// A whole number from 0 to 30000 named `key`, if given. Numbers in text (`"12"`) count; anything +/// else is refused with a message that names the parameter. +fn whole(cmd: &str, p: &Json, key: &str) -> Result> { + let v = match p.get(key) { + None | Some(Json::Null) => return Ok(None), + Some(Json::Number(n)) => n.as_f64(), + Some(Json::String(t)) => t.trim().parse::().ok(), + Some(_) => None, + }; + match v { + Some(x) if x.is_finite() && x.fract() == 0.0 && (0.0..=CONTROL_MAX as f64).contains(&x) => Ok(Some(x as u32)), + _ => Err(bad(cmd, format!("`{key}` must be a whole number from 0 to {CONTROL_MAX}"))), + } +} + +fn text_param(cmd: &str, p: &Json, key: &str) -> Result> { + match p.get(key) { + None | Some(Json::Null) => Ok(None), + Some(Json::String(t)) if t.chars().count() <= MAX_TEXT => Ok(Some(t.clone())), + Some(Json::String(_)) => Err(bad(cmd, format!("`{key}` is longer than {MAX_TEXT} characters"))), + Some(_) => Err(bad(cmd, format!("`{key}` must be text"))), + } +} + +/// The cell a link names, from the control's sheet `host`: `(sheet index, cell)`. `None` when it +/// doesn't name one cell of an existing sheet (a link into a deleted sheet reads `#REF!`). +pub fn link_target(wb: &Workbook, host: usize, link: &str) -> Option<(usize, CellRef)> { + let text = link.trim().trim_start_matches('='); + let (sheet, body) = split_sheet(text); + let si = match sheet { + Some(n) => wb.sheet_index(&n)?, + None => host, + }; + wb.sheet(si)?; + Some((si, CellRef::parse(body.trim())?)) +} + +/// A link parameter in its stored form: `$B$2` on the host sheet, `'My Sheet'!$B$2` elsewhere. +/// An empty text removes the link. +fn link_param(cmd: &str, wb: &Workbook, host: usize, p: &Json) -> Result>> { + let text = match p.get("link") { + None => return Ok(None), + Some(Json::Null) => return Ok(Some(None)), + Some(Json::String(t)) => t.trim(), + Some(_) => return Err(bad(cmd, "`link` must be a cell reference such as $B$1")), + }; + if text.is_empty() { + return Ok(Some(None)); + } + let (si, c) = link_target(wb, host, text) + .ok_or_else(|| bad(cmd, format!("`{text}` isn't a cell of this workbook; the link must be one cell, such as $B$1")))?; + let abs = format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1); + Ok(Some(Some(if si == host { + abs + } else { + let name = wb.sheet(si).map(|sh| sh.name.clone()).unwrap_or_default(); + format!("{}!{abs}", gridcraft_formula::quote_sheet(&name)) + }))) +} + +/// The anchor of a new control: `at` or `cell` (default the active cell), with its size. +fn new_anchor(s: &Session, p: &Json, size: (f32, f32)) -> Result { + let cell = match cell_param(s, p, "at")? { + Some(c) => c, + None => cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active), + }; + let dim = |k: &str, v: f32| f64_param(p, k).filter(|x| x.is_finite()).map_or(v, |x| x.clamp(4.0, 10_000.0) as f32); + let off = |k: &str| f64_param(p, k).filter(|x| x.is_finite()).map_or(0.0, |x| x.clamp(-10_000.0, 10_000.0) as f32); + Ok(Anchor { + cell, + dx: off("dx"), + dy: off("dy"), + width: dim("width", size.0), + height: dim("height", size.1), + mode: anchor_mode_param(p).unwrap_or(AnchorMode::MoveOnly), + }) +} + +fn insert_button(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "insert.button"; + let anchor = new_anchor(s, p, (96.0, 28.0))?; + let action = action_param(s, CMD, p)?; + let d = s.doc()?; + let id = d.wb.next_object_id(); + let text = text_param(CMD, p, "text")?.unwrap_or_else(|| format!("Button {id}")); + let sheet = d.wb.active_sheet(); + let control = FormControl { id, kind: ControlKind::Button, anchor, text, action, ..FormControl::default() }; + edit(s, |cx| { + cx.sheet_mut(sheet)?.controls.push(control); + Ok(json!({"control": id})) + }) +} + +fn insert_number_control(s: &mut Session, p: &Json, kind: ControlKind) -> Result { + let cmd = if kind == ControlKind::Slider { "insert.slider" } else { "insert.spinButton" }; + let horizontal = bool_param(p, "horizontal").unwrap_or(true); + let size = match kind { + ControlKind::Slider if horizontal => (144.0, 18.0), + ControlKind::Slider => (18.0, 144.0), + _ => (18.0, 36.0), + }; + let anchor = new_anchor(s, p, size)?; + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let id = d.wb.next_object_id(); + let mut c = FormControl { id, kind, anchor, horizontal, ..FormControl::default() }; + numbers(cmd, p, &mut c)?; + if let Some(link) = link_param(cmd, &d.wb, sheet, p)? { + c.link = link; + } + let given = whole(cmd, p, "value")?; + // Without a value, a control starts at the number its linked cell holds (or its minimum). + let start = given.map(f64::from).or_else(|| linked_number(&d.wb, sheet, &c)).unwrap_or(c.min as f64); + c.value = c.snap(start); + let value = c.value; + let link = c.link.clone(); + s.grouped(&format!("Insert {}", kind.label()), |s| { + edit(s, |cx| { + cx.sheet_mut(sheet)?.controls.push(c); + Ok(()) + })?; + if given.is_some() { + write_link(s, sheet, link.as_deref(), value)?; + } + Ok(json!({"control": id})) + }) +} + +/// Applies `min`, `max`, `step`, `page` and `value` from `p` to `c` and checks the result. +fn numbers(cmd: &str, p: &Json, c: &mut FormControl) -> Result<()> { + for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] { + if let Some(v) = whole(cmd, p, key)? { + *slot = v; + } + } + // A new slider's page follows a larger step unless it was given. + if p.get("page").is_none() && c.page < c.step { + c.page = c.step; + } + c.check_numbers().map_err(|e| bad(cmd, e)) +} + +/// The number the control's linked cell holds, if it holds one. +fn linked_number(wb: &Workbook, host: usize, c: &FormControl) -> Option { + let (si, cell) = link_target(wb, host, c.link.as_deref()?)?; + wb.sheet(si)?.cell(cell)?.value.as_f64() +} + +/// Writes `value` to the linked cell through `cell.set` (nothing without a link, or when the cell +/// already shows `value`, formula or not: Format Control's OK sends the value back every time, and +/// a write that changes nothing must not turn a formula into a constant or fail on a locked cell +/// of a protected sheet; a real change replaces a formula with the constant, as in Excel). +fn write_link(s: &mut Session, host: usize, link: Option<&str>, value: u32) -> Result<()> { + let Some(link) = link else { return Ok(()) }; + let Some((si, cell)) = link_target(&s.doc()?.wb, host, link) else { return Ok(()) }; + let holds = s.doc()?.wb.sheet(si).and_then(|sh| sh.cell(cell)).and_then(|c| c.value.as_f64()); + if holds == Some(value as f64) { + return Ok(()); + } + s.execute("cell.set", json!({"sheet": si, "cell": cell.a1(), "input": value.to_string()}))?; + Ok(()) +} + +/// The control `id` names on the active sheet: (sheet, its index in `controls`, a copy). +fn find(s: &Session, cmd: &str, p: &Json) -> Result<(usize, usize, FormControl)> { + let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (a control id from control.list)"))?; + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let i = sh.controls.iter().position(|c| c.id == id).ok_or_else(|| bad(cmd, format!("there is no control {id} on this sheet")))?; + let c = sh.controls.get(i).cloned().ok_or_else(|| bad(cmd, "no such control"))?; + Ok((sheet, i, c)) +} + +/// Drawing objects on a protected sheet can't be changed while they are locked. +/// For the object commands (`object.move`, `object.delete`, `object.setAnchorMode`, `arrange.*`, +/// `arrange.align`): refuses to change form controls `ids` on `sheet` when one is locked and the +/// sheet is protected, as `control.format` and `control.assign` do. Other kinds pass. +pub(crate) fn check_objects_unlocked(s: &Session, sheet: usize, kind: ObjKind, ids: &[u32]) -> Result<()> { + if kind != ObjKind::Control { + return Ok(()); + } + let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Ok(()) }; + if sh.is_protected() && sh.controls.iter().any(|c| c.locked && ids.contains(&c.id)) { + return Err(EngineError::Other(PROTECTED.into())); + } + Ok(()) +} + +fn check_unlocked(s: &Session, sheet: usize, c: &FormControl) -> Result<()> { + if c.locked && s.doc()?.wb.sheet(sheet).is_some_and(Sheet::is_protected) { + return Err(EngineError::Other(PROTECTED.into())); + } + Ok(()) +} + +fn format(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "control.format"; + let (sheet, i, mut c) = find(s, CMD, p)?; + check_unlocked(s, sheet, &c)?; + if let Some(t) = text_param(CMD, p, "text")? { + c.text = t; + } + if let Some(n) = text_param(CMD, p, "name")? { + c.name = n.trim().to_string(); + } + for (key, slot) in [("print", &mut c.print), ("locked", &mut c.locked), ("horizontal", &mut c.horizontal)] { + if let Some(v) = bool_param(p, key) { + *slot = v; + } + } + let mut given = None; + if c.kind != ControlKind::Button { + for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] { + if let Some(v) = whole(CMD, p, key)? { + *slot = v; + } + } + c.check_numbers().map_err(|e| bad(CMD, e))?; + given = whole(CMD, p, "value")?; + } + if let Some(link) = link_param(CMD, &s.doc()?.wb, sheet, p)? { + c.link = link; + } + // New bounds pull the value in; a given value is snapped to them. + c.value = c.snap(given.map_or(c.value as f64, f64::from)); + let number = c.kind != ControlKind::Button; + let write = number && (given.is_some() || p.get("min").is_some() || p.get("max").is_some() || p.get("step").is_some()); + let (value, link) = (c.value, c.link.clone()); + s.grouped("Format Control", |s| { + edit(s, |cx| { + if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) { + *slot = c; + } + Ok(()) + })?; + if write { + write_link(s, sheet, link.as_deref(), value)?; + } + Ok(()) + })?; + // The reply is the control as committed: a new link to a cell holding another number has + // moved its value there. + let (_, _, c) = find(s, CMD, p)?; + Ok(control_json(&c)) +} + +/// Commands a button never runs, beyond the macro policy's lists: a button has no way to show +/// what its command would open, so a web page is refused when the action is assigned or comes in +/// from a file. A command that turns out to open a page or a dialog when it runs is refused +/// then, by [`press`]. +const BUTTON_DENIED: &[(&str, &str)] = &[("link.open", "buttons don't open web pages")]; + +/// The macro policy for a button's command, with the button's own refusals first. +fn button_policy(id: &str, params: &Json, trust: crate::Trust) -> Policy { + match BUTTON_DENIED.iter().find(|(d, _)| *d == id) { + Some((_, why)) => Policy::Deny((*why).into()), + None => macro_policy(id, params, trust), + } +} + +/// Drops the UI requests a button's command queued from `queued` on, keeping messages for the +/// person, and says why the press is refused when one would open a dialog or a web page, which +/// buttons don't do (`name` is the button's). A cell edit the command asked for is dropped too. +pub(crate) fn take_requests(s: &mut Session, queued: usize, name: &str) -> Option { + use crate::UiRequest; + let mut blocked = None; + let asked: Vec = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect(); + for q in asked { + match q { + UiRequest::Dialog(d, _) => blocked = Some(format!("'{name}' would open the {d} dialog; give its command the parameters it needs.")), + UiRequest::OpenUrl(_) => blocked = Some(format!("'{name}' would open a web page, which buttons don't do.")), + UiRequest::EditCell(_) | UiRequest::InsertName(_) => {} + m @ UiRequest::Message(_) => s.ui_requests.push(m), + } + } + blocked +} + +/// The stored name of the workbook's macro `name` when it exists and declares no inputs: a fixed +/// input range would make every press fail (`macro.run` refuses inputs it doesn't take), so such +/// an assignment is refused when it is made and dropped to the selection when a file brings one +/// in. A macro the workbook doesn't have (yet) is not checked: the assignment is kept as given. +fn takes_no_inputs(wb: &Workbook, name: &str) -> Option { + crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).filter(|m| m.inputs.is_empty()).map(|m| m.name.clone()) +} + +/// A button action from `macro` (with `input` and `output`) or `command` (with `params`). +fn action_param(s: &Session, cmd: &str, p: &Json) -> Result> { + if let Some(name) = str_param(p, "macro").map(str::trim).filter(|n| !n.is_empty()) { + if name.chars().count() > MAX_MACRO_NAME { + return Err(bad(cmd, "`macro` is not a macro name")); + } + let input = match str_param(p, "input").map(str::trim) { + None | Some("") => MacroInput::Selection, + Some(t) if t.eq_ignore_ascii_case("selection") => MacroInput::Selection, + Some(t) => { + let (_, body) = split_sheet(t); + if gridcraft_core::RangeRef::parse(body).is_none() { + return Err(bad(cmd, format!("`input` must be \"selection\" or a range such as B2:D9, not `{t}`"))); + } + if let Some(stored) = takes_no_inputs(&s.doc()?.wb, name) { + return Err(bad(cmd, format!("macro '{stored}' takes no inputs, so `input` must be \"selection\""))); + } + MacroInput::Fixed(t.to_string()) + } + }; + let output = str_param(p, "output").map(str::trim).filter(|o| !o.is_empty()).map(str::to_string); + if let Some(o) = &output + && gridcraft_core::RangeRef::parse(split_sheet(o).1).is_none() + { + return Err(bad(cmd, format!("`output` must be a cell such as F2, not `{o}`"))); + } + return Ok(Some(ControlAction::Macro { name: name.to_string(), input, output })); + } + if let Some(id) = str_param(p, "command").map(str::trim).filter(|n| !n.is_empty()) { + if find_command(id).is_none() { + return Err(bad(cmd, format!("there is no command `{id}`"))); + } + let params = p.get("params").cloned().unwrap_or_else(|| json!({})); + if !params.is_object() { + return Err(bad(cmd, "`params` must be an object")); + } + let trust = s.doc()?.trust; + if let Policy::Deny(why) = button_policy(id, ¶ms, trust) { + return Err(EngineError::Other(format!("A button can't run {id}: {why}."))); + } + return Ok(Some(ControlAction::Command { id: id.to_string(), params })); + } + Ok(None) +} + +fn assign(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "control.assign"; + let (sheet, i, mut c) = find(s, CMD, p)?; + check_unlocked(s, sheet, &c)?; + if c.kind != ControlKind::Button { + return Err(bad(CMD, "only buttons run actions; a slider or spin button writes its value to its linked cell")); + } + let action = action_param(s, CMD, p)?; + if action.is_none() && bool_param(p, "clear") != Some(true) { + return Err(bad(CMD, "pass `macro` or `command` (or `clear: true` to remove the action)")); + } + c.action = action; + let out = control_json(&c); + edit(s, |cx| { + if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) { + *slot = c; + } + Ok(out) + }) +} + +fn set_value(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "control.setValue"; + let (sheet, i, c) = find(s, CMD, p)?; + if c.kind == ControlKind::Button { + return Err(bad(CMD, "a button has no value; use control.press")); + } + let v = match p.get("value") { + Some(Json::Number(n)) => n.as_f64(), + Some(Json::String(t)) => t.trim().parse::().ok(), + _ => None, + } + .filter(|v| v.is_finite()) + .ok_or_else(|| bad(CMD, "`value` must be a number"))?; + let value = c.snap(v); + let label = format!("Change {}", c.kind.label()); + s.grouped(&label, |s| { + if c.value != value { + edit(s, |cx| { + if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) { + slot.value = value; + } + Ok(()) + })?; + } + write_link(s, sheet, c.link.as_deref(), value)?; + Ok(json!({"value": value})) + }) +} + +fn press(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "control.press"; + let (_, _, c) = find(s, CMD, p)?; + if c.kind != ControlKind::Button { + return Err(bad(CMD, "only buttons can be pressed; use control.setValue for a slider or spin button")); + } + let name = c.display_name(); + match c.action { + None => Err(EngineError::Other(format!("'{name}' has no action. Right-click it and choose Assign Action."))), + Some(ControlAction::Macro { name: mac, input, output }) => { + let mut params = json!({"name": mac}); + if let MacroInput::Fixed(r) = input { + params["inputs"] = json!(r); + } + if let Some(o) = output { + params["output"] = json!(o); + } + for key in ["approve", "onConflict", "overwrite", "timeLimit"] { + if let Some(v) = p.get(key) { + params[key] = v.clone(); + } + } + // The macro runner gates every step with the document's trust. + crate::macros::run::run(s, &mac, ¶ms) + } + Some(ControlAction::Command { id, params }) => { + let d = s.doc()?; + let (doc, trust) = (d.uid, d.trust); + let policy = button_policy(&id, ¶ms, trust); + let file_command = matches!(policy, Policy::NeedsPermission(_)); + let why = match policy { + Policy::Allow => None, + Policy::Ask(why) | Policy::NeedsPermission(why) => Some(why), + Policy::Deny(why) => return Err(EngineError::Other(format!("'{name}' can't run {id}: {why}."))), + }; + if let Some(why) = why { + let key = (doc, approval_key(&id, ¶ms)); + if !s.macro_approvals.contains(&key) { + if bool_param(p, "approve") != Some(true) { + return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}{}.", path_note(¶ms)))); + } + s.macro_approvals.insert(key); + } + } + // As a macro step: only an approved file command reaches the file system, so a + // command that reads or writes a file under another parameter name can't. + let _sandbox = (!file_command).then(crate::io::ScriptSandbox::enter); + s.grouped(&name, |s| { + let queued = s.ui_requests.len(); + let r = s.execute(&id, params.clone()); + // As a macro step: a command that would open a dialog or a web page is refused + // and its group rolled back; messages for the person stay queued. + let blocked = take_requests(s, queued, &name); + let r = r?; + match blocked { + Some(why) => Err(EngineError::Other(why)), + None => Ok(r), + } + }) + } + } +} + +/// The file a button's command names in its `path`, as ` (path)` for its approval question, so +/// the person sees what it reads or writes before saying yes. Empty without a `path`; a long one +/// is shortened in the middle. +fn path_note(params: &Json) -> String { + let text = match params.get("path") { + None | Some(Json::Null) => return String::new(), + Some(Json::String(t)) => t.clone(), + Some(other) => other.to_string(), + }; + let text: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); + let n = text.chars().count(); + let shown = if n > 200 { + let head: String = text.chars().take(100).collect(); + let tail: String = text.chars().skip(n - 99).collect(); + format!("{head}…{tail}") + } else { + text + }; + format!(" ({shown})") +} + +/// The approval key of a button's command: its id and parameters, with a prefix that keeps it +/// apart from a macro's fingerprint (canonical JSON, which starts with `{`). +fn approval_key(id: &str, params: &Json) -> String { + format!("control-command\u{0}{id}\u{0}{params}") +} + +/// After an edit: each slider and spin button whose linked cell holds a number takes that number +/// (clamped and snapped), so editing the cell moves the control. Sheets without such a control +/// are left untouched. +pub(crate) fn sync_linked_values(wb: &mut Workbook) { + for si in 0..wb.sheets.len() { + let updates: Vec<(usize, u32)> = match wb.sheet(si) { + Some(sh) if sh.controls.iter().any(|c| c.link.is_some()) => sh + .controls + .iter() + .enumerate() + .filter(|(_, c)| c.kind != ControlKind::Button) + .filter_map(|(i, c)| linked_number(wb, si, c).map(|n| (i, c.snap(n))).filter(|(_, v)| *v != c.value)) + .collect(), + _ => continue, + }; + if updates.is_empty() { + continue; + } + if let Some(sh) = wb.sheet_mut(si) { + for (i, v) in updates { + if let Some(c) = sh.controls.get_mut(i) { + c.value = v; + } + } + } + } +} + +// ---------------------------------------------------------------- xlsx + +/// The hidden-data key of the button actions xlsx has no place for. +pub(crate) 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, with +/// the button's name and anchor cell to check it is still the same button when the file comes +/// back. `None` when there is nothing to store. +pub(crate) fn with_action_names(wb: &Workbook) -> Result> { + let mut items = Vec::new(); + for sh in &wb.sheets { + for (i, c) in sh.controls.iter().enumerate() { + if let Some(a) = c.action.as_ref().filter(|a| !plain(a)) { + items.push(json!({"sheet": sh.name, "index": i, "name": c.display_name(), "at": c.anchor.cell.a1(), "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 parse, or don't match a button, are dropped: an entry must carry the +/// button's name and anchor cell, the button at the stored place must have that name and anchor +/// cell (another application may have added, removed or reordered controls), and it must not +/// have been given another macro since (a stored macro action comes back only on a button that +/// still runs that macro, a stored command only on one the file gives no macro). A stored fixed +/// input for a macro that takes no inputs becomes the selection (see [`takes_no_inputs`]). +pub(crate) fn load_action_names(wb: &mut Workbook) { + let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return }; + stash::remove(wb, ACTIONS_KEY); + for item in items.as_array().into_iter().flatten().take(100_000) { + let (Some(sheet), Some(index)) = (item.get("sheet").and_then(Json::as_str), item.get("index").and_then(Json::as_u64)) else { continue }; + let Ok(action) = serde_json::from_value::(item.get("action").cloned().unwrap_or(Json::Null)) else { continue }; + let valid = match &action { + ControlAction::Macro { name, .. } => !name.trim().is_empty() && name.chars().count() <= MAX_MACRO_NAME, + ControlAction::Command { id, params } => !id.is_empty() && id.len() <= 200 && params.is_object(), + }; + let Some(si) = wb.sheet_index(sheet) else { continue }; + let Ok(index) = usize::try_from(index) else { continue }; + let text = |k: &str| item.get(k).and_then(Json::as_str); + let same = |c: &FormControl| { + c.kind == ControlKind::Button + && text("name").is_some_and(|n| n == c.display_name()) + && text("at").is_some_and(|a| a == c.anchor.cell.a1()) + && match (&c.action, &action) { + (None, ControlAction::Command { .. }) => true, + (Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept), + _ => false, + } + }; + if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index).is_some_and(same)) { + continue; + } + let action = match action { + // The macro takes no inputs now: the stored range would fail every press, so the + // button runs the macro on the selection, as the file itself assigns it. + ControlAction::Macro { name, input: MacroInput::Fixed(_), output } if takes_no_inputs(wb, &name).is_some() => { + ControlAction::Macro { name, input: MacroInput::Selection, output } + } + other => other, + }; + if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index)) { + c.action = Some(action); + } + } +} diff --git a/crates/engine/src/cmd/data/filter.rs b/crates/engine/src/cmd/data/filter.rs index 1b52e4537..437bd7887 100644 --- a/crates/engine/src/cmd/data/filter.rs +++ b/crates/engine/src/cmd/data/filter.rs @@ -270,6 +270,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet(); + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; if let Some(a) = sh.autofilter.as_mut() { @@ -283,6 +284,7 @@ pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result { pub(super) fn reapply(s: &mut Session, _: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet(); + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; edit(s, |cx| { apply_filter(&mut cx.wb, sheet); cx.structural = true; diff --git a/crates/engine/src/cmd/data/grouping.rs b/crates/engine/src/cmd/data/grouping.rs index d2e183ae1..03761b454 100644 --- a/crates/engine/src/cmd/data/grouping.rs +++ b/crates/engine/src/cmd/data/grouping.rs @@ -21,6 +21,15 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { } } +/// Outline commands change line visibility and grouping: a protected sheet must allow formatting that axis. +fn refuse_unless_outline_allowed(s: &Session, sheet: usize, rows: bool) -> Result<()> { + if rows { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows) + } else { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns) + } +} + pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { let si = target_sheet(s, p)?; // The selection belongs to the active sheet, so another sheet needs its lines. @@ -28,6 +37,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); } let (rows, a, b) = outline_lines(s, p)?; + refuse_unless_outline_allowed(s, si, rows)?; edit(s, |cx| { let sh = cx.sheet_mut(si)?; let before = outline::summary_before(sh, rows); @@ -58,6 +68,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { /// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed. pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { let (rows, a, b) = outline_lines(s, p)?; + refuse_unless_outline_allowed(s, s.doc()?.wb.active_sheet(), rows)?; let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8); edit(s, |cx| { let si = cx.sheet_index(); @@ -123,6 +134,12 @@ pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result { msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(), }); } + let active = s.doc()?.wb.active_sheet(); + for (n, is_rows) in [(rows, true), (cols, false)] { + if n.is_some() { + refuse_unless_outline_allowed(s, active, is_rows)?; + } + } edit(s, |cx| { let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; diff --git a/crates/engine/src/cmd/data/tests.rs b/crates/engine/src/cmd/data/tests.rs index 5b848e5c0..3c2782444 100644 --- a/crates/engine/src/cmd/data/tests.rs +++ b/crates/engine/src/cmd/data/tests.rs @@ -480,6 +480,45 @@ fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() { assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules"); } +#[test] +fn a_relative_list_source_is_read_from_the_cell_being_checked() { + // As in Excel, `=$B1:$B2` on A3:A4,A1:A2 is written for A1 (the top-left cell of all the + // areas) and lists B4:B5 at A4. It used to list B1:B2 in every cell. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "B1:B5", "values": [["a"], ["b"], ["c"], ["d"], ["e"]]})).unwrap(); + s.execute("data.validation", json!({"range": "A3:A4,A1:A2", "type": "list", "formula1": "=$B1:$B2"})).unwrap(); + let wb = s.doc().unwrap().wb.clone(); + let dv = wb.active().unwrap().validations[0].clone(); + for (cell, items) in [("A1", ["a", "b"]), ("A2", ["b", "c"]), ("A3", ["c", "d"]), ("A4", ["d", "e"])] { + let at = CellRef::parse(cell).unwrap(); + assert_eq!(list_items(&wb, 0, at, &dv), items, "{cell}"); + for v in ["a", "b", "c", "d", "e"] { + assert_eq!(check_validation(&wb, 0, at, v).is_none(), items.contains(&v), "{cell} with {v}"); + } + } +} + +#[test] +fn a_list_source_that_takes_in_the_cell_does_not_list_the_value_being_typed() { + // `=$A1:$A2` on A2:A3 is A1:A2 at A2 and A2:A3 at A3. The list is the one before the entry, as + // in Excel; a new value used to land in its own source and pass. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:A2", "values": [["x"], ["y"]]})).unwrap(); + s.execute("data.validation", json!({"range": "A2:A3", "type": "list", "formula1": "=$A1:$A2"})).unwrap(); + let wb = &s.doc().unwrap().wb; + for (cell, accepted, refused) in [("A2", &["x", "y"][..], &["new"][..]), ("A3", &["y"], &["x", "new"])] { + let at = CellRef::parse(cell).unwrap(); + for v in accepted { + assert!(check_validation(wb, 0, at, v).is_none(), "{cell} refuses {v}"); + } + for v in refused { + assert!(check_validation(wb, 0, at, v).is_some(), "{cell} accepts {v}"); + } + } +} + #[test] fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() { // A typo used to give a rule that validates nothing (`type="none"`) or the wrong check, diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs index ce0d7e3b8..2217a372a 100644 --- a/crates/engine/src/cmd/data/validation.rs +++ b/crates/engine/src/cmd/data/validation.rs @@ -38,11 +38,18 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { } /// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges. +/// When the rule's top-left cell goes, its relative formulas are rewritten for the new top-left +/// cell, so the cells left keep checking the same cells. pub(in super::super) fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { for dv in validations.iter_mut() { + let before = RangeRef::top_left(&dv.ranges); for cut in cuts { dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect(); } + if let (Some(from), Some(to)) = (before, RangeRef::top_left(&dv.ranges)) { + dv.f1 = move_rule_formula(&dv.f1, from, to); + dv.f2 = dv.f2.take().map(|f| move_rule_formula(&f, from, to)); + } } validations.retain(|d| !d.ranges.is_empty()); } @@ -145,8 +152,10 @@ fn list_source(f: &str) -> String { format!("\"{}\"", t.replace('"', "\"\"")) } -/// List entries of a list validation (literal or from a range). -pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { +/// List entries of a list validation (literal or from a range) for the cell `at`. A relative +/// source is written for the rule's top-left cell and read from `at`, as in Excel (`=$B1:$B3` on +/// A1:A9 lists B5:B7 at A5). +pub fn list_items(wb: &Workbook, sheet: usize, at: CellRef, dv: &Validation) -> Vec { let f = dv.f1.trim(); if is_literal_list(f) { // A quoted list is a string literal: `""` inside stands for one `"`. @@ -156,8 +165,8 @@ pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { }; return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); } - let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); - match gridcraft_calc::evaluate(wb, sheet, at, f) { + let first = RangeRef::top_left(&dv.ranges).unwrap_or(at); + match gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)) { Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(), v if !v.is_empty() && !v.is_error() => vec![v.display()], _ => vec![], @@ -175,9 +184,10 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - return None; } let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; - // The formulas are written for the first cell of the range and see the value being entered, + // The formulas are written for the top-left cell of all the areas and see the value being entered, // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). - let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); + let first = RangeRef::top_left(&dv.ranges).unwrap_or(at); + let original = wb; let entered; let wb = if sh.value(at) == v { wb @@ -193,7 +203,9 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)); let num = |f: &str| eval(f).to_number().ok(); let ok = match dv.kind { - ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), + // The list is the one the dropdown showed before the entry, as in Excel: a source that takes + // in the cell itself (`=$A1:$A2` on A2:A3 is A2:A3 at A3) doesn't list the value being typed. + ValidationKind::List => list_items(original, sheet, at, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false), ValidationKind::TextLength => { let n = input.chars().count() as f64; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2aa9d8fe3..83133c01c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ use cell::{cell_set, range_fill, range_set_values}; pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula}; #[cfg(test)] pub(crate) use clipboard::parse_tsv; -use clipboard::{clear_clipboard, copy, cut, paste}; +use clipboard::{clear_clipboard, copy, cut, paste, range_move}; use fill::{auto_fill, fill_dir, fill_series, flash_fill}; #[cfg(test)] use find::replace_ci; @@ -36,7 +36,7 @@ use go_to::go_to_special; use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts}; use paste_special::paste_special; pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs}; -use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set}; +use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_next_corner, selection_set}; pub fn specs() -> Vec { vec![ @@ -62,6 +62,15 @@ pub fn specs() -> Vec { cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set), cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move), cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next), + cmd!( + noundo "selection.nextCorner", + "Next Corner of Selection", + [], + Some("Ctrl+."), + "{} (the active cell moves clockwise to the next corner of the selected area, which stays selected)", + has_doc, + selection_next_corner + ), cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} (current region first, then the whole sheet)", has_doc, select_all), cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region), cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row), @@ -81,6 +90,15 @@ pub fn specs() -> Vec { has_doc, paste ), + cmd!( + "range.move", + "Drag and Drop", + [], + None, + "{range?, to: \"C3\", overwrite?: bool} (drag the selection's border: a cut and paste that asks before replacing data unless overwrite)", + has_doc, + range_move + ), CommandSpec { shortcut_mac: Some("Ctrl+Cmd+V"), ..cmd!( @@ -126,13 +144,13 @@ pub fn specs() -> Vec { fill_series ), cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill), - cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find), + cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook|selection} (selection: only the selected cells; Find Next keeps them selected and moves the active cell)", has_doc, find), cmd!( "edit.replace", "Replace…", ["Home", "Editing", "Find & Select"], Some("Ctrl+H"), - "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)", + "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook|selection} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)", has_doc, replace ), @@ -244,21 +262,36 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }) }) }; + // Notes and links aren't cell contents, so the cell comparison that guards a protected sheet + // doesn't see them go: a locked cell's are refused here, stored cell or not. + if matches!(what, "all" | "comments" | "hyperlinks") { + let d = s.doc()?; + if let Some(sh) = d.wb.sheet(sheet).filter(|sh| sh.is_protected()) { + for &whole in &ranges { + let skip = filtered_rows(sh, whole); + let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten(); + let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten(); + for &c in notes.chain(links).filter(|c| whole.contains(**c) && !skip.contains(&c.row)) { + if d.wb.styles.get(sh.style_id(c)).protection.locked + && let Some(m) = locked_cell_error(sh, c, &d.unlocked_ranges) + { + return Err(EngineError::Other(m)); + } + } + } + } + } edit(s, |cx| { - for r in &ranges { - let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) { - Some(u) => match r.intersection(&u) { - Some(x) => x, - None => continue, - }, - None => continue, + for &whole in &ranges { + // Cells are only stored inside the used range. Notes and links are kept apart from + // them, so they are cleared over the whole range: one from a file can sit on a cell + // with nothing stored in it. + let used = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| whole.intersection(&u)); + let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, whole)).unwrap_or_default(); + let cells: Vec<(CellRef, Cell)> = match (cx.wb.sheet(sheet), used) { + (Some(sh), Some(r)) => sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect(), + _ => Vec::new(), }; - let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default(); - let cells: Vec<(CellRef, Cell)> = cx - .wb - .sheet(sheet) - .map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect()) - .unwrap_or_default(); // Report every stored cell we touch, even beyond a million coordinates in a sparse // selection. Removed formula anchors must also reach calc so their spills disappear. cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c))); @@ -278,6 +311,18 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { } } } + let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row); + // A removed note or link changes its cell even with nothing stored there. + let metadata: Vec = cx + .wb + .sheet(sheet) + .map(|sh| { + let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten(); + let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten(); + notes.chain(links).copied().filter(|c| cleared(*c)).collect() + }) + .unwrap_or_default(); + cx.changed.extend(metadata.into_iter().map(|c| (sheet, c))); let sh = cx.sheet_mut(sheet)?; for (c, mut cell) in cells { match what { @@ -294,14 +339,15 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { _ => {} } } - let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row); if matches!(what, "all" | "comments") { sh.comments.retain(|c, _| !cleared(*c)); } if matches!(what, "all" | "hyperlinks") { sh.hyperlinks.retain(|c, _| !cleared(*c)); } - if matches!(what, "all" | "formats") { + if let Some(r) = used + && matches!(what, "all" | "formats") + { let mut unmerged = false; sh.merges.retain(|m| { let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some(); diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 3a1cd0739..da34c017a 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -1,7 +1,8 @@ use super::cell::input_to_cell; use super::paste_special::paste_special; use super::{ - Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet, + Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges, + target_sheet, }; // ---------------------------------------------------------------- clipboard @@ -163,6 +164,40 @@ pub(super) fn paste(s: &mut Session, p: &Json) -> Result { paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)})) } +/// Drag and drop (dragging the selection's border): moves `range` so its top-left cell lands on +/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data or show a +/// spilled value (other than the moved cells themselves); run it again with `overwrite: true` to replace them. A +/// refused or failed move leaves the clipboard as it was. +pub(super) fn range_move(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let to = cell_param_on(s, p, "to", sheet)?.ok_or_else(|| bad("range.move", "missing `to`"))?; + let kept = s.clipboard.take(); + let moved = cut(s, p).and_then(|_| { + let src = s.clipboard.as_ref().map(|c| c.range).unwrap_or_default(); + let dest = to.offset(i64::from(src.height()) - 1, i64::from(src.width()) - 1).map(|end| RangeRef::new(to, end)); + if bool_param(p, "overwrite") != Some(true) + && let Some(dest) = dest + && let Some(sh) = s.doc()?.wb.sheet(sheet) + && (sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content()) + || sh.spill.range(dest.start..=dest.end).any(|(c, v)| dest.contains(*c) && !src.contains(*c) && *v != Value::Empty)) + { + return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into())); + } + // On the range's sheet, which `paste` would leave to the active one. The selection + // belongs to the active sheet: it follows the moved cells only when they are on it. + let selection = s.doc()?.selection.clone(); + let pasted = paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet}))?; + if sheet != s.doc()?.wb.active_sheet() { + s.doc_mut()?.selection = selection; + } + Ok(pasted) + }); + if moved.is_err() { + s.clipboard = kept; + } + moved +} + fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { let sheet = target_sheet(s, p)?; let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); @@ -226,16 +261,55 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { let mut row = Vec::new(); let mut field = String::new(); let mut chars = text.chars().peekable(); + // Text read again as unquoted, ahead of the rest of the input: a field that opened with a + // quote but turned out not to be quoted. + let mut replay: std::collections::VecDeque = std::collections::VecDeque::new(); + // Text is replayed at most as much as the input is long; past that, quotes are text, so + // hostile input full of fields that only look quoted reads in linear time. + let mut replay_budget = text.len(); let mut quoted = false; let mut at_start = true; - while let Some(c) = chars.next() { + // A quoted field's text as it stands in the input, for a field that turns out not to be one. + let mut raw = String::new(); + loop { + let c = match replay.pop_front().or_else(|| chars.next()) { + Some(c) => c, + // A quote that never closes: the field is its text as written. + None if quoted => { + quoted = false; + field.clear(); + field.push('"'); + replay_budget = replay_budget.saturating_sub(raw.len()); + replay.extend(raw.chars().skip(1)); + raw.clear(); + continue; + } + None => break, + }; if quoted { + raw.push(c); if c == '"' { - if chars.peek() == Some(&'"') { + let next = replay.front().copied().or_else(|| chars.peek().copied()); + if next == Some('"') { field.push('"'); - chars.next(); + raw.push('"'); + if replay.pop_front().is_none() { + chars.next(); + } } else { quoted = false; + // A field is quoted only when its closing quote ends it, as Excel reads it: + // `"hi" there` is that text, quotes included, and a tab or line break inside + // it still ends the cell, as if the leading quote weren't there. + if !matches!(next, None | Some('\t' | '\n' | '\r')) { + field.clear(); + field.push('"'); + replay_budget = replay_budget.saturating_sub(raw.len()); + let rest: Vec = std::mem::take(&mut raw).chars().skip(1).collect(); + for ch in rest.into_iter().rev() { + replay.push_front(ch); + } + } } } else { field.push(c); @@ -243,9 +317,10 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { continue; } match c { - '"' if at_start => { + '"' if at_start && replay_budget > 0 => { quoted = true; at_start = false; + raw = String::from('"'); } '\t' => { row.push(std::mem::take(&mut field)); diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs index 0d4656320..ddb9ecaf7 100644 --- a/crates/engine/src/cmd/edit/find.rs +++ b/crates/engine/src/cmd/edit/find.rs @@ -62,6 +62,8 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let by_cols = bool_param(p, "byColumns").unwrap_or(false); let all = bool_param(p, "all").unwrap_or(false); let workbook = str_param(p, "within") == Some("workbook"); + // `selection`: only the selected cells of the active sheet, as Excel searches a selected range. + let in_selection = str_param(p, "within") == Some("selection"); s.last_find = Some(p.clone()); let d = s.doc()?; let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; @@ -71,6 +73,7 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let mut cells: Vec<(CellRef, String)> = sh .cells .iter() + .filter(|(c, _)| !in_selection || d.selection.contains(*c)) .filter_map(|(c, cell)| { let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { crate::display::cell_text(&d.wb, sh, c) @@ -100,7 +103,12 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; let dd = s.doc_mut()?; dd.wb_switch_sheet(si); - dd.selection = crate::Selection::at(c); + if in_selection { + // The range stays selected and the active cell moves through it, as in Excel. + dd.selection.active = c; + } else { + dd.selection = crate::Selection::at(c); + } Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()})) } @@ -114,6 +122,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { let whole = bool_param(p, "wholeCell").unwrap_or(false); let all = bool_param(p, "all").unwrap_or(true); let workbook = str_param(p, "within") == Some("workbook"); + let selection = (str_param(p, "within") == Some("selection")).then(|| s.doc().map(|d| d.selection.clone())).transpose()?; let active = s.doc()?.selection.active; let mut skipped = 0usize; let n = edit(s, |cx| { @@ -123,7 +132,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { let cells: Vec<(CellRef, String)> = cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default(); for (c, text) in cells { - if !all && c != active { + if (!all && c != active) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) { continue; } if !matches(&text, &what, case, whole) { diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index 2477dc1a6..352c8e201 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -284,7 +284,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); if let Some(mut dv) = rule { // Relative references move with the rule, as pasted formulas do. - let first = dv.ranges.first().map_or(sc, |r| r.start); + let first = RangeRef::top_left(&dv.ranges).unwrap_or(sc); dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest); dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest)); dv.ranges = vec![RangeRef::new(dest, dest)]; @@ -396,14 +396,15 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: & } /// Runs `f` over every reference a floating object holds (a chart's series values, categories, -/// name and bubble sizes, and the source block it was made from). Every edit that moves +/// name and bubble sizes, and the source block it was made from, and a form control's linked +/// cell). Every edit that moves /// references calls it: inserting and deleting lines and cells and cut-moves (through /// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an /// object on the sheet named `host` to its new form; text that does not parse is kept. A source /// block that no longer exists is dropped. pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) { for si in 0..wb.sheets.len() { - let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue }; + let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty() || !s.controls.is_empty()).map(|s| s.name.clone()) else { continue }; let Some(sh) = wb.sheet_mut(si) else { continue }; let fix = |text: &str| -> String { match gridcraft_formula::parse(text) { @@ -420,6 +421,9 @@ pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn( se.bubble_sizes = se.bubble_sizes.as_deref().map(fix); } } + for c in sh.controls.iter_mut() { + c.link = c.link.as_deref().map(fix); + } } } diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs index ede66a34d..8115f6390 100644 --- a/crates/engine/src/cmd/edit/selection.rs +++ b/crates/engine/src/cmd/edit/selection.rs @@ -137,6 +137,23 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { Ok(json!({"active": d.selection.active.a1()})) } +pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + // A corner in a merged cell lands on its top-left cell. One in a hidden row or column, one + // the sheet's protection doesn't let you select, and a merged cell that starts outside the + // selected area are skipped. + let area = d.selection.current(); + let stop = |c: CellRef| { + Some(sheet.merge_at(c).map_or(c, |m| m.start)) + .filter(|c| area.contains(*c) && !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col) && cell_selectable(&d.wb, sheet, *c)) + }; + let mut next = d.selection.clone(); + next.next_corner_by(stop); + d.selection = next; + Ok(json!({"active": d.selection.active.a1(), "selection": d.selection.a1()})) +} + pub(super) fn select_all(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3f3a2fb6d..49317caa0 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -307,6 +307,9 @@ fn tsv() { assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]); assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]); assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]); + // #2225: a quote that doesn't close the field leaves it as written, as Excel pastes it. + assert_eq!(parse_tsv("\"hi\" there\tx\n"), vec![vec!["\"hi\" there", "x"]]); + assert_eq!(parse_tsv("say \"hi\"\n"), vec![vec!["say \"hi\""]]); } #[test] @@ -353,6 +356,74 @@ fn cut_paste_that_overflows_the_grid_moves_nothing() { assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0)); } +#[test] +fn drag_and_drop_asks_before_replacing_data() { + // #3136: dragging column B's border onto column A replaced A without asking. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B3", "values": [["Apple", "Kiwi"], ["Pear", "Plum"], ["Fig", "Lime"]]})).unwrap(); + s.execute("edit.copy", json!({"range": "D9"})).unwrap(); + let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["input"].clone(); + let r = s.execute("range.move", json!({"range": "B:B", "to": "A1"})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert_eq!(q, "There's already data here. Do you want to replace it?"); + // Nothing moved, and the copy made before is still on the clipboard. + assert_eq!(text(&mut s, "A1"), json!("Apple")); + assert_eq!(text(&mut s, "B1"), json!("Kiwi")); + assert!(s.clipboard.as_ref().is_some_and(|c| !c.cut && c.range.a1() == "D9")); + // Confirmed, column B replaces column A. + s.execute("range.move", json!({"range": "B:B", "to": "A1", "overwrite": true})).unwrap(); + assert_eq!(text(&mut s, "A1"), json!("Kiwi")); + assert_eq!(text(&mut s, "A3"), json!("Lime")); + assert_eq!(text(&mut s, "B1"), json!("")); + // Onto empty cells, or overlapping only the moved cells themselves, nothing is asked. + s.execute("range.move", json!({"range": "A1:A3", "to": "A2"})).unwrap(); + assert_eq!(text(&mut s, "A4"), json!("Lime")); + s.execute("range.move", json!({"range": "A2:A4", "to": "C2"})).unwrap(); + assert_eq!(text(&mut s, "C2"), json!("Kiwi")); + assert!(s.execute("range.move", json!({"range": "C2"})).is_err(), "`to` is required"); +} + +#[test] +fn drag_and_drop_asks_before_replacing_spilled_values() { + // Cells showing a dynamic array's spilled values hold no cell of their own, but they are data. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap(); + s.execute("range.setValues", json!({"range": "C2:C3", "values": [["Kiwi"], ["Plum"]]})).unwrap(); + let r = s.execute("range.move", json!({"range": "C2:C3", "to": "A2"})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert_eq!(q, "There's already data here. Do you want to replace it?"); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(2, 2)), Value::text("Plum")); + s.execute("range.move", json!({"range": "C2:C3", "to": "A2", "overwrite": true})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(1, 0)), Value::text("Kiwi")); +} + +#[test] +fn drag_and_drop_on_another_sheet_stays_on_that_sheet() { + // The move was checked on the range's sheet but pasted on the active one, replacing its data + // without asking. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("range.setValues", json!({"range": "B1:B2", "values": [["Kiwi"], ["Plum"]]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "Keep"})).unwrap(); + s.execute("selection.set", json!({"range": "C5"})).unwrap(); + s.execute("range.move", json!({"range": "Two!B1:B2", "to": "A1"})).unwrap(); + // Sheet1 stays active with its own selection; the moved cells are on Two. + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); + assert_eq!(s.doc().unwrap().selection.a1(), "C5"); + let wb = &s.doc().unwrap().wb; + let two = wb.sheet_index("Two").unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(0, 0)), Value::text("Keep")); + assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 0)), Value::text("Kiwi")); + assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(1, 0)), Value::text("Plum")); + assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 1)), Value::Empty); +} + #[test] fn replace_case_insensitive() { assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); @@ -371,3 +442,141 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { let sh = s.doc().unwrap().wb.active().unwrap(); assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); } + +#[test] +fn next_corner_of_selection_goes_clockwise_and_anchors_there() { + // #1442, as Excel does it: B2 → D2 → D4 → B4 → B2, the area stays selected, and Shift+arrows + // then move the corner opposite the new active cell. + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "B2:D4", "active": "B2"})).unwrap(); + let mut seen = Vec::new(); + for _ in 0..4 { + let r = s.execute("selection.nextCorner", json!({})).unwrap(); + assert_eq!(r["selection"], "B2:D4"); + seen.push(r["active"].as_str().unwrap().to_string()); + } + assert_eq!(seen, ["D2", "D4", "B4", "B2"]); + s.execute("selection.nextCorner", json!({})).unwrap(); + s.execute("selection.nextCorner", json!({})).unwrap(); + s.execute("selection.move", json!({"dr": 0, "dc": 1, "extend": true})).unwrap(); + assert_eq!(s.doc().unwrap().selection.current().a1(), "C2:D4"); + // One row goes end to end; a single cell stays. + s.execute("selection.set", json!({"range": "B2:D2", "active": "B2"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "D2"); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2"); + s.execute("selection.set", json!({"range": "C5"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C5"); +} + +#[test] +fn next_corner_skips_corners_the_sheet_protection_does_not_let_you_select() { + // With locked cells unselectable, Ctrl+. goes on to the next corner it may select. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.lockCell", json!({"range": "B2:C3", "on": false})).unwrap(); + s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap(); + s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap(); + let r = s.execute("selection.nextCorner", json!({})).unwrap(); + assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("B3"), Some("B2:D3")), "D2 and D3 are locked"); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2"); +} + +#[test] +fn next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell() { + // A corner inside a merged cell is that merged cell, whose active cell is its top-left one. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.mergeCells", json!({"range": "C2:D2"})).unwrap(); + s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap(); + let seen: Vec = (0..4).map(|_| s.execute("selection.nextCorner", json!({})).unwrap()["active"].as_str().unwrap().to_string()).collect(); + assert_eq!(seen, ["C2", "D3", "B3", "B2"]); + // A selection that is one merged cell has no other corner. + s.execute("selection.set", json!({"range": "C2:D2", "active": "C2"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C2"); +} + +#[test] +fn next_corner_skips_hidden_corners_and_stays_inside_the_selected_area() { + // A corner in a hidden column is passed over, as keyboard moves pass over hidden cells. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.hideColumns", json!({"cols": "D:D"})).unwrap(); + s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B3"); + // Column C selected with B1:C1 merged: its top corner's merged cell starts in column B, + // outside the selection, so the active cell doesn't go there. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.mergeCells", json!({"range": "B1:C1"})).unwrap(); + s.execute("selection.set", json!({"cell": "C5"})).unwrap(); + s.execute("selection.column", json!({})).unwrap(); + let r = s.execute("selection.nextCorner", json!({})).unwrap(); + assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("C1048576"), Some("C:C"))); +} + +#[test] +fn copy_quotes_only_cells_with_tabs_or_line_breaks() { + // #2225: a cell holding a quote was wrapped in quotes on copy; Excel quotes only text with a + // tab or line break, and pastes the result back as the same cells. + let mut s = Session::new(); + s.new_workbook(); + for (cell, input) in [("A1", "say \"hi\""), ("A2", "plain"), ("A3", "\"hi\" there"), ("A4", "x \"q\"\ny"), ("A5", "tab\tinside")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let text = s.execute("edit.copy", json!({"range": "A1:A5"})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(text, "say \"hi\"\nplain\n\"hi\" there\n\"x \"\"q\"\"\ny\"\n\"tab\tinside\"\n"); + let rows = parse_tsv(&text); + let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect(); + assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]); +} + +#[test] +fn a_field_that_is_not_really_quoted_keeps_its_separators() { + // A field opening with a quote is quoted only if a closing quote ends it; when it doesn't (or + // never closes), the text is read as written, and the tabs and line breaks inside it still + // separate cells, as they would have without the leading quote. + assert_eq!(parse_tsv("\"a\tb\"x\tc"), vec![vec!["\"a", "b\"x", "c"]]); + assert_eq!(parse_tsv("\"a\tb\nc"), vec![vec!["\"a", "b"], vec!["c"]]); + assert_eq!(parse_tsv("\"a\tb\tc\n"), vec![vec!["\"a", "b", "c"]]); + // Cells copied without quotes (they hold no tab or line break) read back as the same cells. + let mut s = Session::new(); + s.new_workbook(); + for (cell, input) in [("A1", "'\"a"), ("B1", "b\"x"), ("C1", "c")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let text = s.execute("edit.copy", json!({"range": "A1:C1"})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(parse_tsv(&text), vec![vec!["\"a", "b\"x", "c"]], "{text:?}"); + // Many fields that only look quoted: each is replayed at most once (the replay budget is the + // input's length), and every one is still its own cell. + let hostile = "\"x\t".repeat(50_000); + assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001)); +} + +/// `within: selection` searches only the selected cells, as Excel does with a range selected +/// (#3224): Find All lists them, Find Next moves the active cell through them and keeps the range +/// selected, and Replace changes only them. +#[test] +fn find_and_replace_within_the_selection() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B4", "values": [["pear", "pear"], ["plum", "pear"], ["pear", "fig"], ["pear", "pear"]]})) + .unwrap(); + s.execute("selection.set", json!({"range": "B1:B4"})).unwrap(); + let all = s.execute("edit.find", json!({"what": "pear", "all": true, "within": "selection"})).unwrap(); + let cells: Vec<&str> = all["results"].as_array().unwrap().iter().filter_map(|r| r["cell"].as_str()).collect(); + assert_eq!(cells, ["B1", "B2", "B4"], "only column B"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B2"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B4"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B1", "wraps inside the range"); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.a1(), sel.active.a1()), ("B1:B4".to_string(), "B1".to_string()), "the range stays selected"); + assert!(s.execute("edit.find", json!({"what": "plum", "within": "selection"})).is_err(), "A2 is outside the range"); + assert_eq!(s.execute("edit.replace", json!({"what": "pear", "with": "kiwi", "within": "selection"})).unwrap()["replaced"], 3); + let sh = s.doc().unwrap().wb.active().unwrap(); + let at = |a: &str| sh.value(CellRef::parse(a).unwrap()); + assert_eq!((at("A1"), at("B1"), at("B4"), at("A4")), (Value::text("pear"), Value::text("kiwi"), Value::text("kiwi"), Value::text("pear"))); + // The sheet scope is unchanged. + let all = s.execute("edit.find", json!({"what": "pear", "all": true})).unwrap(); + assert_eq!(all["count"], 3, "A1, A3 and A4 on the whole sheet"); +} diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index a6f66b41b..6325de45a 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -97,7 +97,7 @@ pub fn specs() -> Vec { "Bring Forward", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer|equation, id, toFront?: bool}", + "{kind: chart|image|shape|slicer|equation|control, id, toFront?: bool}", has_doc, |s, p| arrange(s, p, true) ), @@ -106,7 +106,7 @@ pub fn specs() -> Vec { "Send Backward", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer|equation, id, toBack?: bool}", + "{kind: chart|image|shape|slicer|equation|control, id, toBack?: bool}", has_doc, |s, p| arrange(s, p, false) ), @@ -116,7 +116,7 @@ pub fn specs() -> Vec { "Align", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer|equation, ids: [..], align: left|center|right|top|middle|bottom}", + "{kind: chart|image|shape|slicer|equation|control, ids: [..], align: left|center|right|top|middle|bottom}", has_doc, align ), diff --git a/crates/engine/src/cmd/extra/objects.rs b/crates/engine/src/cmd/extra/objects.rs index fa5a5f438..112b530ac 100644 --- a/crates/engine/src/cmd/extra/objects.rs +++ b/crates/engine/src/cmd/extra/objects.rs @@ -57,6 +57,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result let kind = kind_param(p, "arrange")?; let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false); let si = s.doc()?.wb.active_sheet(); + crate::cmd::controls::check_objects_unlocked(s, si, kind, &[id])?; fn reorder(v: &mut Vec, i: usize, forward: bool, extreme: bool) { if i >= v.len() { return; @@ -81,6 +82,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result ObjKind::Shape | ObjKind::Equation => { sh.shapes.iter().position(|c| c.id == id && shape_is(kind, c)).map(|i| reorder(&mut sh.shapes, i, forward, extreme)) } + ObjKind::Control => sh.controls.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.controls, i, forward, extreme)), }; found.ok_or_else(|| bad("arrange", "no such object"))?; Ok(Json::Null) @@ -106,6 +108,12 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result { let name = if c.caption.is_empty() { &c.column } else { &c.caption }; v.push(json!({"kind": "slicer", "id": c.id, "name": name, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); } + // Form controls paint above everything else. + for c in &sh.controls { + v.push( + json!({"kind": "control", "id": c.id, "name": c.display_name(), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}), + ); + } Ok(Json::Array(v)) } @@ -115,6 +123,7 @@ fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> { ObjKind::Chart => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), ObjKind::Image => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), ObjKind::Slicer => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + ObjKind::Control => sh.controls.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), ObjKind::Shape | ObjKind::Equation => sh.shapes.iter_mut().filter(|c| shape_is(kind, c)).map(|c| (c.id, &mut c.anchor)).collect(), } } @@ -133,6 +142,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result { }; let how = str_param(p, "align").unwrap_or("left").to_string(); let si = s.doc()?.wb.active_sheet(); + crate::cmd::controls::check_objects_unlocked(s, si, kind, &ids)?; edit(s, |cx| { let sheet = cx.sheet_mut(si)?; let geo: Vec<(u32, f64, f64, f32, f32)> = { diff --git a/crates/engine/src/cmd/format/sizes.rs b/crates/engine/src/cmd/format/sizes.rs index 3335d6100..c79327678 100644 --- a/crates/engine/src/cmd/format/sizes.rs +++ b/crates/engine/src/cmd/format/sizes.rs @@ -9,6 +9,17 @@ use super::{ /// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active /// sheet, so another sheet needs explicit lines. fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> { + let (sheet, lines) = resolve_lines(s, p, rows)?; + // Sizes and visibility are formatting of the line, which a protected sheet must allow. + if rows { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)?; + } else { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)?; + } + Ok((sheet, lines)) +} + +fn resolve_lines(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> { let key = if rows { "rows" } else { "cols" }; let mut sheet = target_sheet(s, p)?; if let Some(t) = str_param(p, key) { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 47d87a4e7..bcaf6ed40 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -146,13 +146,13 @@ pub fn specs() -> Vec { &with(p, "kind", json!("textBox")) )), cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text), - cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation, id}", has_doc, delete_object), + cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation|control, id}", has_doc, delete_object), cmd!( "object.move", "Move Object", [], None, - "{kind: chart|image|shape|slicer|equation, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}", + "{kind: chart|image|shape|slicer|equation|control, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}", has_doc, move_object ), @@ -161,7 +161,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{kind: chart|image|shape|slicer|equation, id, mode: moveAndSize|moveOnly|absolute}", + "{kind: chart|image|shape|slicer|equation|control, id, mode: moveAndSize|moveOnly|absolute}", has_doc, set_anchor_mode ), @@ -179,7 +179,7 @@ pub fn specs() -> Vec { "Open Hyperlink", [], None, - "{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); only http, https and mailto are opened, any other scheme is an error; the UI asks before opening", + "{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); a reference selects; a web or e-mail address is an error unless the host allows it: the user opens those by clicking the link and confirming; any scheme but http, https and mailto is always an error", has_doc, open_link ), @@ -1100,6 +1100,10 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { .unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() }); let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240)); let d = s.doc()?; + // Sheet protection locks drawing objects, as for a text box's text. + if d.wb.active().is_some_and(|sh| sh.is_protected()) { + return Err(EngineError::Other(PROTECTED.into())); + } let id = d.wb.next_object_id(); let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let scale = (640.0 / w.max(1) as f32).min(1.0); @@ -1237,13 +1241,16 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { /// objects are errors, so a stale id never succeeds silently. fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> { let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; - let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or equation"))?; + let kind = + str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer, equation or control"))?; let d = s.doc()?; let sheet = d.wb.active_sheet(); let o = ObjRef::new(kind, id); if !d.wb.sheet(sheet).is_some_and(|sh| sh.has_object(o)) { return Err(bad(cmd, "no such object")); } + // A locked form control on a protected sheet stays as it is. + super::controls::check_objects_unlocked(s, sheet, kind, &[id])?; Ok((sheet, o)) } @@ -1342,9 +1349,11 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { }) } -/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the -/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no -/// dialog; the interactive click asks for confirmation first, in the UI. +/// Follows a link: a reference selects it; anything else is refused ([`crate::links::classify`]). +/// An `http`/`https`/`mailto` address is refused too unless the host set +/// [`crate::links::LinkPolicy::Allow`]: every caller of a command may be a script or an agent, so +/// the user opens such links by clicking and confirming, and the UI then calls +/// [`Session::open_confirmed_link`] (#2181). Opens no dialog. fn open_link(s: &mut Session, p: &Json) -> Result { let target = match str_param(p, "url").or_else(|| str_param(p, "target")) { Some(t) => t.to_string(), @@ -1360,6 +1369,9 @@ fn open_link(s: &mut Session, p: &Json) -> Result { match crate::links::classify(&target) { crate::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})), crate::links::LinkKind::External(u) => { + if s.link_policy != crate::links::LinkPolicy::Allow { + return Err(EngineError::Other(crate::links::NEEDS_CONFIRMATION.into())); + } s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone())); Ok(json!({"opened": u})) } @@ -1739,7 +1751,13 @@ fn clear_cf(s: &mut Session, p: &Json) -> Result { sh.cond_formats.clear(); } else { for cf in sh.cond_formats.iter_mut() { + let before = RangeRef::top_left(&cf.ranges); cf.ranges.retain(|r| !ranges.iter().any(|x| x.intersects(r))); + // The relative formulas are written for the top-left cell: when it goes, they're + // rewritten for the new one, so the areas left keep reading the same cells. + if let (Some(from), Some(to)) = (before, RangeRef::top_left(&cf.ranges)) { + rebase_cf_formulas(&mut cf.rule, from, to); + } } sh.cond_formats.retain(|c| !c.ranges.is_empty()); } @@ -1747,6 +1765,19 @@ fn clear_cf(s: &mut Session, p: &Json) -> Result { }) } +/// Moves the relative references of a rule's per-cell formulas written for `from` to `to`. +fn rebase_cf_formulas(rule: &mut CfRule, from: CellRef, to: CellRef) { + let mv = |f: &str| crate::cmd::data::move_rule_formula(f, from, to); + match rule { + CfRule::CellIs { a, b, .. } => { + *a = mv(a); + *b = b.as_deref().map(mv); + } + CfRule::Expression { formula, .. } => *formula = mv(formula), + _ => {} + } +} + fn manage_cf(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; if p.get("delete").is_none() && p.get("moveUp").is_none() && p.get("moveDown").is_none() { diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 396e0e28f..37f400f05 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { "Run Macro", [], None, - "{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}", + "{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", output?: \"F2\" (instead of the macro's output anchor), onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}", has_doc, run ), @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Check Macro", [], None, - "{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}", + "{name, inputs?, output?: \"F2\" (as macro.run), onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}", has_doc, check ), @@ -55,7 +55,7 @@ pub fn specs() -> Vec { "Step Into", [], None, - "{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}", + "{action: start|step|continue|runTo|stop|state, name, inputs?, output?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}", has_doc, |s, p| crate::macros::debug::debug(s, p) ), @@ -170,7 +170,21 @@ fn rename(s: &mut Session, p: &Json) -> Result { renamed.name = to.clone(); // The whole check, size included: a longer name can take a macro past what a file holds. crate::macros::check(&renamed).map_err(EngineError::Other)?; - *m = renamed; + let from = std::mem::replace(m, renamed).name; + // Buttons that run the macro follow the new name. + let runs = |c: &gridcraft_model::FormControl| matches!(&c.action, Some(gridcraft_model::ControlAction::Macro { name, .. }) if name.eq_ignore_ascii_case(&from)); + for si in 0..cx.wb.sheets.len() { + if !cx.wb.sheet(si).is_some_and(|sh| sh.controls.iter().any(runs)) { + continue; + } + for c in cx.sheet_mut(si)?.controls.iter_mut() { + if let Some(gridcraft_model::ControlAction::Macro { name, .. }) = &mut c.action + && name.eq_ignore_ascii_case(&from) + { + *name = to.clone(); + } + } + } Ok(()) })?; Ok(json!({"name": to})) diff --git a/crates/engine/src/cmd/pivot/create.rs b/crates/engine/src/cmd/pivot/create.rs index ce5f97257..0cec82b54 100644 --- a/crates/engine/src/cmd/pivot/create.rs +++ b/crates/engine/src/cmd/pivot/create.rs @@ -67,9 +67,6 @@ fn field_list(v: Option<&Json>) -> Vec { } pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result { - if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) { - return Err(other("The workbook is protected and cannot be changed.")); - } let source = source_param(s, p)?; let active = s.doc()?.wb.active_sheet(); // Validate the source now (header names, size). @@ -98,6 +95,10 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result { let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?; Some((si, at)) }; + // Decided on the resolved destination: `New`, ` new ` and an empty one all make a sheet. + if target.is_none() && s.doc()?.wb.protected_structure { + return Err(other("The workbook is protected and cannot be changed.")); + } let replace = bool_param(p, "replace").unwrap_or(false); let rows = field_list(p.get("rows")); let columns = field_list(p.get("columns")); diff --git a/crates/engine/src/cmd/pivot/fields.rs b/crates/engine/src/cmd/pivot/fields.rs index 48c25657c..55036f541 100644 --- a/crates/engine/src/cmd/pivot/fields.rs +++ b/crates/engine/src/cmd/pivot/fields.rs @@ -507,9 +507,13 @@ pub(super) fn value_settings(s: &mut Session, p: &Json) -> Result { let mut v = cur.clone(); if let Some(f) = func { v.func = f; - // An automatic caption follows the function. - let auto = auto_name(cur.func, &cur.source_col); - if cur.name.eq_ignore_ascii_case(&auto) || cur.name.strip_prefix(auto.as_str()).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) { + // An automatic caption follows the function. Count Numbers was captioned "Count + // Numbers of …" before it took Excel's "Count of …", and saved workbooks still carry it. + let is_auto = |base: &str| { + cur.name.eq_ignore_ascii_case(base) || cur.name.strip_prefix(base).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) + }; + let legacy = cur.func == PivotFunc::CountNumbers && is_auto(&format!("Count Numbers of {}", cur.source_col)); + if is_auto(&auto_name(cur.func, &cur.source_col)) || legacy { v.name = unique_value_name(pt, &auto_name(f, &cur.source_col), Some(i)); } } diff --git a/crates/engine/src/cmd/pivot/tests.rs b/crates/engine/src/cmd/pivot/tests.rs index 4e4f6870c..3748d0705 100644 --- a/crates/engine/src/cmd/pivot/tests.rs +++ b/crates/engine/src/cmd/pivot/tests.rs @@ -168,6 +168,19 @@ fn filters_and_report_filter_rows() { assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err()); } +/// A selection of the most labels allowed (100,000) is looked up once per record, not scanned: the +/// report still filters case-insensitively. +#[test] +fn a_huge_filter_selection_is_matched_through_a_set() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); + let mut selected: Vec = (0..99_999).map(|i| format!("Region {i}")).collect(); + selected.push("EAST".into()); + s.execute("pivot.filter", json!({"field": "Region", "selected": selected})).unwrap(); + let gt = find_row(&s, "Grand Total").unwrap(); + assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0)); +} + #[test] fn show_values_as() { let mut s = sales(); @@ -1013,3 +1026,36 @@ fn detail_cells_are_the_cells_show_details_takes() { let pt = &d.wb.active().unwrap().pivots[0]; assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date")); } + +#[test] +fn rank_ties_are_dense_and_count_numbers_is_captioned_count_of() { + // #1448 items 10 and 12: Excel ranks 5, 4, 3, 3, 4 as 1, 2, 3, 3, 2 (GridCraft gave 1, 2, 4, + // 4, 2), and captions a Count Numbers field "Count of Val". + let mut s = Session::new(); + s.new_workbook(); + let rows = json!([["Key", "Val"], ["A", 5], ["B", 4], ["C", 3], ["D", 3], ["E", 4]]); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$6", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap(); + s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "rank"})).unwrap(); + let ranks: Vec = ["E4", "E5", "E6", "E7", "E8"].iter().map(|a| val(&s, a)).collect(); + assert_eq!(ranks, [1.0, 2.0, 3.0, 3.0, 2.0].map(Value::Number)); + s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "normal", "func": "countNumbers"})).unwrap(); + assert_eq!(text(&s, "E3"), "Count of Val"); +} + +#[test] +fn legacy_count_numbers_caption_follows_the_function() { + // Workbooks saved before Count Numbers was captioned "Count of" carry "Count Numbers of Val"; + // that is still an automatic caption, so it follows a new function. A custom name stays. + let mut s = Session::new(); + s.new_workbook(); + let rows = json!([["Key", "Val"], ["A", 5], ["B", 4]]); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap(); + for (legacy, after) in [("Count Numbers of Val", "Sum of Val"), ("Count Numbers of Val2", "Sum of Val"), ("Numbers counted", "Numbers counted")] { + s.execute("pivot.valueSettings", json!({"index": 0, "func": "countNumbers", "name": legacy})).unwrap(); + assert_eq!(text(&s, "E3"), legacy); + s.execute("pivot.valueSettings", json!({"index": 0, "func": "sum"})).unwrap(); + assert_eq!(text(&s, "E3"), after, "{legacy}"); + } +} diff --git a/crates/engine/src/cmd/print/header_footer.rs b/crates/engine/src/cmd/print/header_footer.rs index 0f973d7fb..2dede3a2c 100644 --- a/crates/engine/src/cmd/print/header_footer.rs +++ b/crates/engine/src/cmd/print/header_footer.rs @@ -153,6 +153,17 @@ pub struct HfSection { pub size: Option, } +/// Most bytes one section's expanded text may hold. A code like `&A` adds the whole sheet name +/// (or file path) each time it appears, and a JSON workbook's names have no length limit. +const MAX_SECTION_BYTES: usize = 64 * 1024; + +/// Appends `s` unless the section would pass [`MAX_SECTION_BYTES`]; what doesn't fit is left out. +fn push_field(text: &mut String, s: &str) { + if text.len().saturating_add(s.len()) <= MAX_SECTION_BYTES { + text.push_str(s); + } +} + /// Expands a header/footer string into its left, centre and right sections. pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] { let mut out: [HfSection; 3] = Default::default(); @@ -187,14 +198,14 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] { i += 1 + digits.len(); } } - sec.text.push_str(&n.to_string()); + push_field(&mut sec.text, &n.to_string()); } - 'N' | 'n' => sec.text.push_str(&cx.pages.to_string()), - 'D' | 'd' => sec.text.push_str(&cx.date), - 'T' | 't' => sec.text.push_str(&cx.time), - 'A' | 'a' => sec.text.push_str(&cx.sheet), - 'F' | 'f' => sec.text.push_str(&cx.file), - 'Z' | 'z' => sec.text.push_str(&cx.path), + 'N' | 'n' => push_field(&mut sec.text, &cx.pages.to_string()), + 'D' | 'd' => push_field(&mut sec.text, &cx.date), + 'T' | 't' => push_field(&mut sec.text, &cx.time), + 'A' | 'a' => push_field(&mut sec.text, &cx.sheet), + 'F' | 'f' => push_field(&mut sec.text, &cx.file), + 'Z' | 'z' => push_field(&mut sec.text, &cx.path), '&' => sec.text.push('&'), 'B' | 'b' => sec.bold = !sec.bold, 'I' | 'i' => sec.italic = !sec.italic, diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index cd25606b9..323060470 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -414,6 +414,12 @@ fn pdf_contains_text_and_header_codes_expand() { assert_eq!(r.text, "P4/9 9:00 AM"); assert_eq!(expand_header_footer("plain", &cx)[1].text, "plain"); assert_eq!(expand_header_footer("&", &cx)[1].text, ""); + // A megabyte sheet name repeated by 5,000 `&A` codes stops at the section's 64 KiB. + let long = HfContext { sheet: "S".repeat(1024 * 1024), ..cx }; + let [_, c, _] = expand_header_footer(&"&A".repeat(5_000), &long); + assert!(c.text.is_empty(), "the name alone is over the limit"); + let short = HfContext { sheet: "S".repeat(1000), ..Default::default() }; + assert_eq!(expand_header_footer(&"&A".repeat(5_000), &short)[1].text.len(), 65_000); } #[test] @@ -695,6 +701,79 @@ fn bands_unit() { assert!(w.len() > 2); } +/// The wrapper `wrap_lines` replaced, re-measuring strings, as the reference for its lines. +fn wrap_lines_remeasuring(text: &str, font: Font, size: f32, width: f32) -> Vec { + let mut out = Vec::new(); + for para in text.split('\n') { + let mut line = String::new(); + for word in para.split(' ') { + let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; + if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { + line = cand; + } else { + out.push(std::mem::take(&mut line)); + line = word.to_string(); + } + while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { + let chars: Vec = line.chars().collect(); + let (mut lo, mut hi) = (1usize, chars.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + let cut: String = chars[..mid].iter().collect(); + if gridcraft_pdf::text_width(font, size, &cut) <= width { + lo = mid; + } else { + hi = mid - 1; + } + } + if lo == chars.len() { + break; + } + out.push(chars[..lo].iter().collect()); + line = chars[lo..].iter().collect(); + } + if out.len() > 2000 { + return out; + } + } + out.push(line); + } + out +} + +#[test] +fn wrap_lines_matches_the_remeasuring_wrapper() { + let texts = [ + "the quick brown fox jumps", + "Supercalifragilistic", + "a b c\n\nverylongwordthatneedsbreaking short\nend ", + "Wide WWWWWWWWWW words, iiiiiiii narrow ones; mixed MMMMiiiiMMMM", + "€ ü ß — unicode Ünïcödé text", + "", + " ", + ]; + for text in texts { + for font in [Font::Helvetica, Font::HelveticaBold, Font::Courier] { + for (size, width) in [(10.0, 50.0), (10.0, 20.0), (11.0, 1.0), (8.0, 300.0), (12.0, 0.0)] { + assert_eq!(wrap_lines(text, font, size, width), wrap_lines_remeasuring(text, font, size, width), "{text:?} {font:?} {size} {width}"); + } + } + } +} + +#[test] +fn wrapping_a_long_word_in_a_narrow_box_stops_at_the_line_limit() { + // One character per line: the old wrapper re-measured and copied the rest of the word for + // each of the 100,000 lines before checking the limit. + let word = "w".repeat(100_000); + let lines = wrap_lines(&word, Font::Helvetica, 10.0, 1.0); + assert!(lines.len() <= 2002, "{}", lines.len()); + assert!(lines.iter().all(|l| l == "w")); + // A long paragraph in a very wide box is one line. + let para = vec!["word"; 50_000].join(" "); + assert_eq!(wrap_lines(¶, Font::Helvetica, 10.0, 1.0e9), vec![para.clone()]); +} + #[test] fn center_across_spans_following_empty_center_across_cells() { let mut styles = StyleTable::default(); diff --git a/crates/engine/src/cmd/print/text.rs b/crates/engine/src/cmd/print/text.rs index 6f71699ce..0523aa4ff 100644 --- a/crates/engine/src/cmd/print/text.rs +++ b/crates/engine/src/cmd/print/text.rs @@ -3,42 +3,57 @@ use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet const PAD_PX: f32 = 2.0; +/// Most lines [`wrap_lines`] returns (it may return one more, the rest of a paragraph). +const MAX_WRAP_LINES: usize = 2000; + /// Word-wraps `text` to `width` points. +/// +/// Widths are kept as running sums of glyph widths (the sum `text_width` makes), so each +/// character is measured once: re-measuring the line for every word, or the rest of an +/// over-long word for every piece cut from it, made a long paragraph or word quadratic. pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { + let units_of = |s: &str| -> u64 { s.chars().map(|c| u64::from(font.char_width(c))).sum() }; + let fits = |units: u64| units as f32 * size / 1000.0 <= width; + let space = units_of(" "); let mut out = Vec::new(); for para in text.split('\n') { let mut line = String::new(); + let mut line_units: u64 = 0; for word in para.split(' ') { - let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { - line = cand; + let word_units = units_of(word); + if line.is_empty() { + line.push_str(word); + line_units = word_units; + } else if fits(line_units + space + word_units) { + line.push(' '); + line.push_str(word); + line_units += space + word_units; } else { out.push(std::mem::take(&mut line)); - line = word.to_string(); + line.push_str(word); + line_units = word_units; } - // Break a single over-long word by characters. The cut that fits is found by binary - // search (each try measures one candidate prefix), so an over-long word costs - // O(n log n) measurements instead of re-measuring the whole cut per character for - // every emitted line (`a` × 100k pasted text would be cubic worst case). - while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { - let chars: Vec = line.chars().collect(); - let (mut lo, mut hi) = (1usize, chars.len()); - while lo < hi { - let mid = lo + (hi - lo).div_ceil(2); - let cut: String = chars[..mid].iter().collect(); - if gridcraft_pdf::text_width(font, size, &cut) <= width { - lo = mid; - } else { - hi = mid - 1; + // Break a single over-long word by characters: each piece is the longest that fits + // (at least one character). + if !fits(line_units) && line.chars().nth(1).is_some() { + let mut piece = String::new(); + let mut units: u64 = 0; + for c in line.chars() { + let w = u64::from(font.char_width(c)); + if !piece.is_empty() && !fits(units + w) { + out.push(std::mem::take(&mut piece)); + units = 0; + if out.len() > MAX_WRAP_LINES { + return out; + } } + piece.push(c); + units += w; } - if lo == chars.len() { - break; - } - out.push(chars[..lo].iter().collect()); - line = chars[lo..].iter().collect(); + line = piece; + line_units = units; } - if out.len() > 2000 { + if out.len() > MAX_WRAP_LINES { return out; } } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 69e04dae6..95a0c93f5 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -398,10 +398,10 @@ fn move_columns(s: &mut Session, p: &Json) -> Result { { return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into())); } - // The checks must authorize the sheet this command modifies (`target_sheet`), not whatever - // sheet is active: both carry the command's own `sheet` parameter on. - check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?; - check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?; + // The checks must authorize the sheet this command modifies, not whatever sheet is active: + // both get the index `target_sheet` resolved (from `sheet`, or a `Sheet!` range or cell). + check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": sheet}), Axis::Cols, before, count, true)?; + check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": sheet}), Axis::Cols, from, count, false)?; let moved_from = if before <= from { from + count } else { from }; if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) { diff --git a/crates/engine/src/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index 0c3925d22..d21851553 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -170,7 +170,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result { // Object ids are unique across the workbook (selection and the `{kind, id}` commands // rely on it), so the copied objects get fresh ones (free ones, even when a file used // the largest id). - let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len(); + let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len() + sh.controls.len(); let ids = cx.wb.free_object_ids(count); if ids.len() < count { return Err(bad("sheet.move", "no free object ids are left for the copied objects")); @@ -181,6 +181,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result { sh.images.iter_mut().for_each(|o| o.id = fresh()); sh.shapes.iter_mut().for_each(|o| o.id = fresh()); sh.slicers.iter_mut().for_each(|o| o.id = fresh()); + sh.controls.iter_mut().for_each(|o| o.id = fresh()); let at = to.min(n); // The copy gets its own copies of the sheet's names, referring to the copy. let copies: Vec = cx diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index c508890c3..3631d0e56 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -40,6 +40,27 @@ pub fn specs() -> Vec { cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")), cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")), cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects), + cmd!( + "view.showSheetTabs", + "Show Sheet Tabs", + [], + None, + "{on?} (File › Options › Advanced, this workbook; toggles without `on`)", + has_doc, + |s, p| { workbook_view_flag(s, p, "sheetTabs") } + ), + cmd!( + "view.showHorizontalScrollBar", + "Show Horizontal Scroll Bar", + [], + None, + "{on?} (this workbook; toggles without `on`)", + has_doc, + |s, p| { workbook_view_flag(s, p, "horizontal") } + ), + cmd!("view.showVerticalScrollBar", "Show Vertical Scroll Bar", [], None, "{on?} (this workbook; toggles without `on`)", has_doc, |s, p| { + workbook_view_flag(s, p, "vertical") + }), cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")), cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom), cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))), @@ -189,6 +210,21 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result { }) } +/// File › Options › Advanced › Display options for this workbook: the sheet tabs and the two +/// scroll bars, saved with the workbook (``). +fn workbook_view_flag(s: &mut Session, p: &Json, which: &str) -> Result { + edit(s, |cx| { + let view = &mut cx.wb.view; + let slot = match which { + "sheetTabs" => &mut view.show_sheet_tabs, + "horizontal" => &mut view.show_horizontal_scroll, + _ => &mut view.show_vertical_scroll, + }; + *slot = bool_param(p, "on").unwrap_or(!*slot); + Ok(json!({"on": *slot})) + }) +} + /// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown. fn show_objects(s: &mut Session, p: &Json) -> Result { use gridcraft_model::ShowObjects; diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index 2189d6c64..06a0fa587 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -156,7 +156,9 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { } first = false; let t = cell_text(wb, sh, CellRef::new(row, col)); - if t.contains(['\t', '\n', '"']) { + // Like Excel, only text with a tab or line break is quoted (its quotes doubled); + // `say "hi"` is copied as it is (#2225). + if t.contains(['\t', '\n', '\r']) { out.push('"'); out.push_str(&t.replace('"', "\"\"")); out.push('"'); diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index c63d7c983..efa09310a 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -575,20 +575,9 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result< match (unit, d) { ("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v), ("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v), - ("weekday", Some(_)) => { - let mut n = v; - let mut left = step.abs() as i64; - while left > 0 { - n += step.signum(); - let Some(day) = datetime_from_serial(sys, n) else { - return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into())); - }; - if day.weekday != 0 && day.weekday != 6 { - left -= 1; - } - } - n - } + // Whole weeks at a time: a huge step costs no more than a small one. + ("weekday", Some(_)) => add_weekdays(sys, v, step as i64) + .ok_or_else(|| crate::EngineError::Other("The weekday series runs past the supported date range.".into()))?, _ => v + step, } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index ce1fb40b0..6cd39f839 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,4 +1,4 @@ -//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export. +//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, SYLK import, CSV/TSV, JSON (debug), HTML export. use std::fmt::Write as _; @@ -7,6 +7,8 @@ use gridcraft_model::{Sheet, Workbook}; use crate::{EngineError, Result}; +mod sylk; + /// File formats GridCraft reads or writes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FileKind { @@ -19,6 +21,8 @@ pub enum FileKind { Tsv, Json, Html, + /// SYLK symbolic link text (import only). + Sylk, } impl FileKind { @@ -33,6 +37,7 @@ impl FileKind { "tsv" | "tab" | "txt" => FileKind::Tsv, "json" | "scjson" => FileKind::Json, "html" | "htm" => FileKind::Html, + "slk" => FileKind::Sylk, _ => return None, }) } @@ -118,12 +123,13 @@ 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 -/// and adopts the Python cells they hold (see [`adopt_python`]) within the limits of -/// [`enforce_python_limits`]. +/// 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, and adopts the Python cells they hold (see +/// [`adopt_python`]) within the limits of [`enforce_python_limits`]. fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec)> { let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?; warnings.extend(crate::macros::load(&mut wb)); + crate::cmd::controls::load_action_names(&mut wb); warnings.extend(adopt_python(&mut wb)); warnings.extend(enforce_python_limits(&mut wb)); Ok((wb, warnings)) @@ -291,6 +297,9 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, })?; return Ok((wb, report.warnings)); } + if let Some(read) = read_html_table(name, kind, bytes, csv_as_text) { + return read; + } match kind { Some(FileKind::Json) => { let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?; @@ -310,6 +319,11 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, } Ok((wb, warnings)) } + Some(FileKind::Sylk) => { + let (mut wb, warnings) = sylk::read(name, bytes)?; + rename_first_sheet(&mut wb, name); + Ok((wb, warnings)) + } Some(FileKind::Tsv) => { let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() }; let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; @@ -346,9 +360,112 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String { ) } +/// How much of a text file is read to tell whether it is a web page. +const HTML_SNIFF_BYTES: usize = 64 << 10; + +/// A web page that holds a table, whatever its extension: a web app's "Export to Excel" is often +/// HTML saved as `.xls` (#3166). Excel opens its table as a sheet (after warning that the format +/// and extension don't match): the cells read like typed input (or as text with `csv_as_text`), +/// header cells (``) are bold and centred, and the sheet takes the name the page gives it +/// (``) or the file's. `None` when the bytes aren't such a page. +fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text: bool) -> Option)>> { + // Every other text file (CSV, TSV, JSON) passes through here: look at the start only, and decode + // the whole file once it is known to be a page within the size a text import takes. + if matches!(kind, Some(FileKind::Json)) || bytes.len() > gridcraft_xlsx::MAX_CSV_BYTES { + return None; + } + let start = gridcraft_xlsx::decode_text(bytes.get(..HTML_SNIFF_BYTES).unwrap_or(bytes)); + // Comments an exporter writes before the page (``) don't make it something else. + let mut rest = start.trim_start_matches('\u{feff}').trim_start(); + while let Some(after) = rest.strip_prefix("").and_then(|end| after.get(end + 3..)) { + Some(more) => rest = more.trim_start(), + None => break, + } + } + let head: String = rest.chars().take(16).collect::().to_ascii_lowercase(); + if ![" = row + .iter() + .map(|cell| { + // The number the page recorded (`x:num`) only when values are read; as text, the + // cell keeps the text it shows. + let field = match cell.num.filter(|_| !csv_as_text) { + Some(n) => gridcraft_core::number_to_text(n), + // Whitespace is already collapsed; non-breaking spaces stay, as in Excel. + None => cell.text.clone(), + }; + if field.contains(['\t', '\n', '\r', '"']) { format!("\"{}\"", field.replace('"', "\"\"")) } else { field } + }) + .collect(); + tsv.push_str(&fields.join("\t")); + tsv.push('\n'); + } + let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() }; + let read = gridcraft_xlsx::read_csv_with_report(tsv.as_bytes(), &opts).map_err(|e| EngineError::Other(e.to_string())); + Some(read.map(|(mut wb, report)| { + wb.csv_delimiter = None; + // Header cells are bold and centred on top of the style the CSV reader gave them, so a + // date in a header keeps its date format. + let headers: Vec = rows + .iter() + .enumerate() + .flat_map(|(r, row)| row.iter().enumerate().filter(|(_, cell)| cell.header && !cell.text.trim().is_empty()).map(move |(c, _)| CellRef::new(r as u32, c as u32))) + .collect(); + let mut bold: std::collections::HashMap = std::collections::HashMap::new(); + for at in headers { + let Some(base) = wb.sheet(0).map(|sh| sh.style_id(at)) else { continue }; + let id = *bold.entry(base).or_insert_with(|| { + wb.styles.derive(base, |st| { + st.font.bold = true; + st.align.h = gridcraft_model::HAlign::Center; + }) + }); + if let Some(sh) = wb.sheet_mut(0) { + sh.set_style(at, id); + } + } + let page_name = find_between(&text, "", "").map(|n| crate::clip_html::normalized(n.trim())); + match page_name.filter(|n| !n.is_empty()) { + Some(sheet) => set_first_sheet_name(&mut wb, &sheet), + None => rename_first_sheet(&mut wb, name), + } + let mut warnings = report.warnings; + if !matches!(kind, Some(FileKind::Html)) { + warnings.insert(0, format!("'{name}' is a web page (HTML), not a workbook in the format its name says: its table was opened as a sheet. Save as .xlsx to keep your changes.")); + } + (wb, warnings) + })) +} + +/// The text between the first `open` and the `close` after it, matched case-insensitively. +fn find_between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> { + let lower = text.to_ascii_lowercase(); + let start = lower.find(&open.to_ascii_lowercase())?.checked_add(open.len())?; + let len = lower.get(start..)?.find(&close.to_ascii_lowercase())?; + text.get(start..start.checked_add(len)?) +} + /// A CSV opens as a sheet named after the file (like Excel). fn rename_first_sheet(wb: &mut Workbook, path: &str) { let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1"); + set_first_sheet_name(wb, stem); +} + +/// Names the first sheet `name`, without the characters a sheet name can't hold and cut to 31 +/// characters; an empty or reserved result keeps the default name. +fn set_first_sheet_name(wb: &mut Workbook, stem: &str) { let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); let clean = clean.trim_matches('\'').to_string(); // "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead. @@ -377,9 +494,13 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result", + 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!("{} { + 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!( + "{}{}{}, {}, {}, {}, {}, {}, {}, {}", + object_type(c.kind), + // These two elements mean the object does *not* move (size) with its cells. + if c.anchor.mode == AnchorMode::Absolute { "" } else { "" }, + if c.anchor.mode == AnchorMode::MoveAndSize { "" } else { "" }, + 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("False"); + } + if !c.locked { + data.push_str("False"); + } + data.push_str("False"); + match c.kind { + ControlKind::Button => { + if let Some(m) = macro_ref(c) { + let _ = write!(data, "{}", esc(&m)); + } + data.push_str("CenterCenter"); + } + ControlKind::Slider | ControlKind::Spin => { + if let Some(l) = &c.link { + let _ = write!(data, "{}", esc(l)); + } + let _ = write!(data, "{}{}{}{}", c.value, c.min, c.max, c.step); + if c.kind == ControlKind::Slider { + let _ = write!(data, "{}", c.page); + if c.horizontal { + data.push_str(""); + } + } + data.push_str("22"); + } + } + data.push_str(""); + let text = if c.kind == ControlKind::Button { + format!("
{}
", esc(&c.text)) + } else { + String::new() + }; + format!( + "{text}{data}", + 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 = ""; + +// ---------------------------------------------------------------- reading + +/// What a control's properties say, from `formControlPr` or VML `x:ClientData`. +#[derive(Clone, Default)] +struct Props { + kind: Option, + link: Option, + min: Option, + max: Option, + inc: Option, + page: Option, + val: Option, + horiz: Option, + macro_: Option, + text: String, + print: Option, + locked: Option, +} + +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 { + 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::().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 `
` and ``), up to a +/// caption's length; `
` 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, 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 `` elements one child of a `` element holds: the child itself, or those +/// inside its `mc:AlternateContent` (the Choice, else the Fallback). +fn controls_of_child(k: &El) -> Vec<&El> { + match k.name.as_str() { + "control" => vec![k], + "AlternateContent" => k + .child("Choice") + .filter(|c| c.child("control").is_some()) + .or_else(|| k.child("Fallback")) + .map(|inner| inner.kids("control").collect()) + .unwrap_or_default(), + _ => Vec::new(), + } +} + +/// The `` elements under a `` element, looking through `mc:AlternateContent` +/// (the Choice, else the Fallback). +fn controls_in(e: &El) -> Vec<&El> { + e.children.iter().flat_map(controls_of_child).collect() +} + +/// The `` 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. +pub(crate) fn shape_number(id: &str) -> Option { + id.trim().trim_start_matches("_x0000_s").parse().ok() +} + +/// A VML shape's number: from its `id` (`_x0000_s1025`), else from `o:spid`, where producers that +/// give the shape a named id (`id="Button 1"`) keep it. +pub(crate) fn number_of(shape: &El) -> Option { + shape.attr("id").and_then(shape_number).or_else(|| shape.attr("spid").and_then(shape_number)) +} + +/// What [`read`] took from a sheet's form controls, for pass-through. +#[derive(Debug, Default)] +pub struct ReadControls { + /// The VML shape numbers of the controls read into the model: GridCraft writes those, so they + /// are left out of the kept content. + pub modelled: HashSet, + /// The VML drawing also holds a control that the sheet doesn't list and the model doesn't + /// hold (a check box only the drawing has): the drawing is kept for it even when no + /// `` element is left to keep. + pub vml_only_unmodelled: bool, +} + +/// Whether a VML shape the sheet doesn't list is a control that only pass-through can keep: it +/// has client data that is neither a note (GridCraft writes notes) nor an embedded object's +/// picture (`Pict`, which means nothing without its sheet entry). +fn unlisted_kept(shape: &El) -> bool { + object_type_of(shape).is_some_and(|t| !t.eq_ignore_ascii_case("note") && !t.eq_ignore_ascii_case("pict")) +} + +/// A VML shape's client data `ObjectType`, trimmed (`Note`, `Button`, `Checkbox`). +fn object_type_of(shape: &El) -> Option<&str> { + shape.child("ClientData").and_then(|d| d.attr("ObjectType")).map(str::trim) +} + +/// Whether a VML shape's client data is a note's: its `ObjectType` is `Note` in any case and with +/// spaces around it. Only that attribute counts, not text elsewhere in the shape (a caption). +pub(crate) fn is_note_data(data: &El) -> bool { + data.attr("ObjectType").is_some_and(|t| t.trim().eq_ignore_ascii_case("note")) +} + +/// Reads a sheet's buttons, scroll bars and spin buttons: each `` with its properties +/// part, else its VML shape, and VML shapes the sheet doesn't list (files that only have VML). +/// The shapes come from the VML drawing with relationship id `vml_rid` (``). +/// 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>, vml_rid: Option<&str>, rels: &[Rel], sheet: &mut Sheet) -> ReadControls { + let mut read = HashSet::new(); + let vml = match vml_rid.and_then(|id| rels.iter().find(|r| r.id == id && 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(); + // Looked up once per listed control: indexes keep a sheet of many controls linear. + let mut by_number: HashMap = HashMap::with_capacity(shapes.len()); + for s in &shapes { + if let Some(n) = number_of(s) { + by_number.entry(n).or_insert(s); + } + } + let shape_of = |n: u64| by_number.get(&n).copied(); + // The relationships likewise: one per control, looked up by id (the first of a repeated id wins, + // as a scan would find it). + let mut rel_by_id: HashMap<&str, &Rel> = HashMap::with_capacity(rels.len()); + for r in rels { + rel_by_id.entry(r.id.as_str()).or_insert(r); + } + // Each properties part is read and parsed once, however many controls name it: every read is + // charged to the package's budget, so a file whose controls all point at one part would + // otherwise spend it on that part alone. A part that fails to read, or isn't a + // `formControlPr`, is remembered as nothing and not read again. + let mut props_by_part: HashMap<&str, Option> = HashMap::new(); + // Every shape the sheet lists, read here or not. + let mut listed: HashSet = 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| rel_by_id.get(id)).map(|r| r.target.as_str()); + let file_props = match prop_part { + Some(t) => props_by_part + .entry(t) + .or_insert_with(|| cx.pkg.read_xml(t).ok().flatten().filter(|e| e.name == "formControlPr").map(|e| props_from_ctrl_prop(&e))) + .clone(), + 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. The ones not read (a check box, or past the limit) stay + // in the kept drawing. + let mut vml_only_unmodelled = false; + for s in &shapes { + let n = number_of(s); + if n.is_some_and(|n| listed.contains(&n)) { + continue; + } + let modelled = n.filter(|_| sheet.controls.len() < MAX_CONTROLS).and_then(|n| { + let p = props_from_vml(s)?; + let (kind, anchor) = (p.kind?, vml_anchor(s, sheet)?); + let id = cx.next_object_id(); + sheet.controls.push(build(p, kind, anchor, None, id)); + read.insert(n); + Some(()) + }); + if modelled.is_none() && unlisted_kept(s) { + vml_only_unmodelled = true; + } + } + ReadControls { modelled: read, vml_only_unmodelled } +} + +fn anchor_from_markers(a: &El, sheet: &Sheet) -> Option { + 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 { + let data = shape.child("ClientData")?; + let nums: Vec = data.child("Anchor")?.text.split(',').filter_map(|t| t.trim().parse::().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, + }) +} diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 692a8a4da..8cd1d353c 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -24,7 +24,7 @@ fn emu_px(v: Option) -> 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::().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; diff --git a/crates/xlsx/src/feature_bags.rs b/crates/xlsx/src/feature_bags.rs index 71a6514a5..db2e73e91 100644 --- a/crates/xlsx/src/feature_bags.rs +++ b/crates/xlsx/src/feature_bags.rs @@ -25,10 +25,13 @@ pub const XF_EXT_URI: &str = "{C7286773-470A-42A8-94C5-96B5CB345126}"; /// The bags for one checkbox cell control, referenced as `xfComplement i="0"`: Checkbox (0), /// XFControls → 0 (1), XFComplement → 1 (2), XFComplements mapping index 0 → 2 (3). Every -/// reference points to an earlier bag, as [MS-XLSX] 2.6.230 requires. +/// reference points to an earlier bag, as [MS-XLSX] 2.6.230 requires. The XFComplements bag is +/// the one `xfComplement` indexes into, and Excel finds it by its `extRef`: without +/// `extRef="XFComplementsMapperExtRef"` it repairs the workbook ("Format from /xl/styles.xml") +/// and drops every checkbox (#2630). pub fn checkbox_part() -> String { format!( - "\n012" + "\n012" ) } @@ -73,6 +76,9 @@ mod tests { fn written_chain_resolves_and_hostile_chains_do_not() { let root = crate::xml::parse(checkbox_part().as_bytes()).unwrap_or_default(); assert_eq!(checkbox_complements(&root), vec![true]); + // #2630: Excel repairs the file unless the mapping bag carries this extRef. + let mapper = root.kids("bag").find(|b| b.attr("type") == Some("XFComplements")); + assert_eq!(mapper.and_then(|b| b.attr("extRef")), Some("XFComplementsMapperExtRef")); // Out-of-range, wrong-type and self references resolve to false without panicking. let bad = format!( "99114294967295x" diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 5b94531c4..4d5038012 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -15,6 +15,7 @@ mod array_formula; mod cfb; mod chart; mod chartex; +mod controls; mod crypto; mod csv; mod drawing; @@ -47,7 +48,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use crypto::{DecryptError, check_encryption, decrypt}; -pub use csv::{MAX_CSV_BYTES, read_csv, read_csv_with_report, write_csv, write_csv_limited}; +pub use csv::{MAX_CSV_BYTES, decode_text, read_csv, read_csv_with_report, write_csv, write_csv_limited}; pub use ods::read_ods; pub use package::MAX_PART; pub use passthrough::rebase_links; diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs index 8415ce395..b64930211 100644 --- a/crates/xlsx/src/ods.rs +++ b/crates/xlsx/src/ods.rs @@ -317,7 +317,7 @@ impl Import { return Err(IoError::TooLarge("ODS contains more than 1024 sheets".into())); } let original = attr(reader, e, TABLE, b"name")?.unwrap_or_default(); - let mut name = crate::read::unique_name(&self.wb, original.trim_matches('\''), self.wb.sheets.len()); + let mut name = crate::read::SheetNames::of(&self.wb).unique(original.trim_matches('\''), self.wb.sheets.len()); if self.wb.check_sheet_name(&name, None).is_err() { name = self.wb.next_sheet_name(); } @@ -470,6 +470,14 @@ impl Import { None => return Err(IoError::TooLarge("ODS row exceeds worksheet width".into())), }; if kept { + // Charge the text a row keeps before the row is done, so a row of many long cells is + // refused as it grows; finish_row charges the row's repeats beyond the first. + if let Value::Text(t) = &cell.value { + self.text = self.text.saturating_add(u64::from(draft.repeat).saturating_mul(t.len() as u64)); + if self.text > MAX_TEXT { + return Err(IoError::TooLarge("ODS expands beyond the import limit (64 MiB of text)".into())); + } + } row.cells.push(RepeatedCell { col: row.col, repeat: draft.repeat, width, height, cell }); } row.col = end; @@ -537,7 +545,8 @@ impl Import { if !entry.cell.is_blank() { self.cells = self.cells.saturating_add(count); if let Value::Text(t) = &entry.cell.value { - self.text = self.text.saturating_add(count.saturating_mul(t.len() as u64)); + let repeats = count.saturating_sub(u64::from(entry.repeat)); + self.text = self.text.saturating_add(repeats.saturating_mul(t.len() as u64)); } } let merged = entry.width > 1 || entry.height > 1; diff --git a/crates/xlsx/src/package.rs b/crates/xlsx/src/package.rs index 9b4160dfc..002a2bced 100644 --- a/crates/xlsx/src/package.rs +++ b/crates/xlsx/src/package.rs @@ -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 { + let mut v: Vec = self.names.keys().cloned().collect(); + v.sort(); + v + } + pub fn has(&self, part: &str) -> bool { self.names.contains_key(&normalize(part)) } diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index 3e2fea105..9d85e03b2 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -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 ``, 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 ``, `` and `` 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 ``, `` and `` XML, the `r:id` //! of ``, the legacy VML shapes those controls need and the pictures of the headers and //! footers, `&G`, with their ``). The workbook also keeps its //! `` and the calcFeatures extension, which Excel needs to trust the saved values. diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs index 6be20d76d..dde7aac5f 100644 --- a/crates/xlsx/src/passthrough/read.rs +++ b/crates/xlsx/src/passthrough/read.rs @@ -283,8 +283,10 @@ 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> { + /// Sheet-level content of the sheet part `part`. `controls` says which form controls the + /// sheet reader modelled (their VML shapes are left out of the kept content) and whether the + /// VML drawing holds others that only it lists. + pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, controls: &crate::controls::ReadControls) -> Option> { if !self.on { return None; } @@ -305,13 +307,24 @@ impl KeptReader { // Notes have one VML drawing per sheet; another one holds the header and footer pictures. 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); + if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes || controls.vml_only_unmodelled { + self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen, 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) { + #[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, + controls: &crate::controls::ReadControls, + ) { + let read_controls = &controls.modelled; let scan = match cx.pkg.read(part) { Ok(Some(bytes)) => scan_sheet(&bytes), _ => None, @@ -344,10 +357,16 @@ impl KeptReader { Err(why) => cx.warn(format!("sheet \"{name}\": header and footer pictures were dropped: {why}")), } } - let mut frags = scan.fragments; - // 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) { + // 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. Controls only + // the drawing lists need it too, whether or not a `` element is left. + if frags.iter().any(|(slot, _)| slot != "picture") || controls.vml_only_unmodelled { + match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen, read_controls) { Ok((vml, parts)) => { p.parts.extend(parts); p.vml = Some(vml); @@ -376,21 +395,22 @@ impl KeptReader { Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")), } } - if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") { + if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") || (controls.vml_only_unmodelled && p.vml.is_some()) { cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving")); } else { p.vml = None; } } - /// 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, + read_controls: &HashSet, ) -> Result<(PreservedPart, Vec), 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 +427,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 = vrels.iter().filter(|r| !r.external).cloned().collect(); @@ -557,13 +577,20 @@ 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 and the shapes numbered in `drop`, each judged as the control +/// reader judges it, from the shape's own tags and never from text elsewhere in it: a note by its +/// client data's `ObjectType` ([`crate::controls::is_note_data`]), its number from its `id`, else +/// its `o:spid` ([`crate::controls::number_of`]). Each tag is read on its own ([`xml::first_start`]), +/// so a caption's unclosed `
` doesn't hide the client data after it. A shape with no number +/// that isn't a note is kept. +fn strip_shapes(vml: &str, drop: &HashSet) -> 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 shape = vml.get(a..b).unwrap_or("").as_bytes(); + let note = xml::first_start(shape, "ClientData").is_some_and(|d| crate::controls::is_note_data(&d)); + let numbered = xml::first_start(shape, "shape").and_then(|s| crate::controls::number_of(&s)).is_some_and(|n| drop.contains(&n)); + if note || numbered { out.push_str(vml.get(last..a).unwrap_or("")); last = b; } @@ -595,19 +622,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("") 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("")) - { - add.push_str(ours.get(i..i + j + "".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("")).map(|j| i + j + "".len()) else { break }; + let ty = ours.get(i..j).unwrap_or(""); + let id = attr_value(ty, "id").unwrap_or(""); + if !id.is_empty() && !kept.contains(&format!("id=\"{id}\"")) && !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 +658,134 @@ 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("(x: &'a str, name: &str) -> Option<&'a str> { + let mut from = 0; + while let Some(i) = x.get(from..)?.find(name).map(|i| i + from) { + from = i + name.len(); + let starts_token = x.get(..i).is_some_and(|before| before.ends_with(|c: char| c.is_whitespace() || c == '<')); + let Some(rest) = x.get(from..).map(str::trim_start).and_then(|r| r.strip_prefix('=')).map(str::trim_start) else { continue }; + if !starts_token { + continue; + } + if let Some(q) = rest.chars().next().filter(|q| *q == '"' || *q == '\'') { + let body = rest.get(q.len_utf8()..)?; + return body.find(q).and_then(|e| body.get(..e)); + } + } + None +} + +/// A kept `` fragment without the controls whose `shapeId` is in `drop` (they are +/// written from the model): a bare `` child goes when it is one of them, and within an +/// `mc:AlternateContent` child only those controls go, in each of its branches, the wrapper +/// staying while a control remains. `None` when nothing is left. A fragment that doesn't parse +/// is returned unchanged. +fn without_controls(frag: &str, drop: &HashSet) -> Option { + if drop.is_empty() { + return Some(frag.to_string()); + } + 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()) }; + let text = frag.get(start..end).unwrap_or(""); + let Some((kept, controls, dropped)) = controls_kept(text, drop) else { return Some(frag.to_string()) }; + if dropped == 0 { + // Nothing of ours in it: the child stays as it is. + left += 1; + } else { + out.push_str(frag.get(last..start).unwrap_or("")); + if controls > 0 { + out.push_str(&kept); + left += 1; + } + last = end; + } + } + out.push_str(frag.get(last..).unwrap_or("")); + (left > 0).then_some(out) +} + +/// One child of a `` element with the `` elements numbered in `drop` taken +/// out, wherever they are in it (a bare control, or the Choice and Fallback branches of an +/// `mc:AlternateContent`), as (the text, the controls that remain, the controls taken out). The +/// `shapeId` is read as the control reader reads it (an attribute, in either quote style). `None` +/// when the child doesn't parse. +fn controls_kept(child: &str, drop: &HashSet) -> Option<(String, usize, usize)> { + let mut reader = Reader::from_str(child); + let mut out = String::with_capacity(child.len()); + let (mut last, mut kept, mut dropped) = (0usize, 0usize, 0usize); + loop { + let start = usize::try_from(reader.buffer_position()).ok()?; + let (e, has_end) = match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) => (e, true), + Event::Empty(e) => (e, false), + _ => continue, + }; + if e.local_name().as_ref() != b"control" { + // A wrapper or a branch: its controls are the events that follow. + continue; + } + let number = + e.try_get_attribute("shapeId").ok().flatten().and_then(|a| std::str::from_utf8(&a.value).ok().and_then(crate::controls::shape_number)); + if has_end { + reader.read_to_end(e.name()).ok()?; + } + let end = usize::try_from(reader.buffer_position()).ok()?; + if number.is_some_and(|n| drop.contains(&n)) { + out.push_str(child.get(last..start)?); + last = end; + dropped += 1; + } else { + kept += 1; + } + } + out.push_str(child.get(last..)?); + Some((out, kept, dropped)) +} + /// 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. diff --git a/crates/xlsx/src/passthrough/rebase.rs b/crates/xlsx/src/passthrough/rebase.rs index d758f86d6..e9232658d 100644 --- a/crates/xlsx/src/passthrough/rebase.rs +++ b/crates/xlsx/src/passthrough/rebase.rs @@ -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 { + 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,21 +58,71 @@ 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 `` 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) = controls_insert_at(&x) + { + x.insert_str(i, controls); + placed = true; + } + s.push_str(&x); + } } } } + // `` comes last of the kept elements. + if !placed { + s.push_str(&crate::controls::controls_element(controls)); + } s } } +/// Where GridCraft's control entries go in a kept `` fragment: just before the close +/// tag of the `` element the reader reads (`controls::controls_of`), whatever its +/// namespace prefix. In an `mc:AlternateContent` with separate `` elements in its +/// Choice and Fallback branches that is the Choice's, the one an application that knows the +/// x14 extensions reads; the Fallback's is left as the file had it. With no Choice, or a Choice +/// without a `` element, it is the first `` element (a plain fragment, or a +/// Fallback alone). +fn controls_insert_at(x: &str) -> Option { + let from = open_tag(x, "Choice").unwrap_or(0); + close_tag_from(x, from, "controls") +} + +/// The local name of the tag whose text starts at `name` (`x:controls` → `controls`). +fn local_name(name: &str) -> &str { + name.rsplit(':').next().unwrap_or(name) +} + +/// The byte offset of the first open tag of `local` in `x`, with any namespace prefix. +fn open_tag(x: &str, local: &str) -> Option { + x.match_indices('<').find_map(|(i, _)| { + let rest = x.get(i + 1..)?; + let end = rest.find(|c: char| c.is_whitespace() || c == '>' || c == '/')?; + (local_name(rest.get(..end)?) == local).then_some(i) + }) +} + +/// The byte offset of the first close tag of `local` at or after `from` in `x`, with any +/// namespace prefix (``, ``). +fn close_tag_from(x: &str, from: usize, local: &str) -> Option { + x.get(from..)?.match_indices("')?)?.trim_end(); + (local_name(name) == local).then_some(from + i) + }) +} + // ---------------------------------------------------------------- linked workbooks /// `wb` as it is saved into the folder `to` (absolute): the relative paths of its linked diff --git a/crates/xlsx/src/passthrough/write.rs b/crates/xlsx/src/passthrough/write.rs index 8b8d24591..cf11d0216 100644 --- a/crates/xlsx/src/passthrough/write.rs +++ b/crates/xlsx/src/passthrough/write.rs @@ -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) { diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 3cc2a6655..93efb7f02 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -1,5 +1,6 @@ //! Reading the workbook part and everything hanging off it. +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use gridcraft_core::{DateSystem, RangeRef}; @@ -200,6 +201,7 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { if p.flag("date1904", false) { cx.date_system = DateSystem::D1904; } + wb.remove_personal_info = p.flag("filterPrivacy", false); wb.show_objects = match p.attr("showObjects") { Some("none") => ShowObjects::None, Some("placeholders") => ShowObjects::Placeholders, @@ -221,6 +223,7 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { wb.calc.set_max_change(d); } wb.calc.precision_as_displayed = !c.flag("fullPrecision", true); + wb.calc.r1c1 = c.attr("refMode") == Some("R1C1"); } if let Some(p) = xml.child("workbookProtection") { wb.protected_structure = p.flag("lockStructure", false); @@ -304,6 +307,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> let mut file_to_model: Vec> = vec![]; let mut pivot_caches = crate::pivot::CachesIn::new(&wb_xml, &wb_rels); let sheet_els: Vec = wb_xml.child("sheets").map(|s| s.kids("sheet").cloned().collect()).unwrap_or_default(); + let mut names = SheetNames::default(); for (i, se) in sheet_els.iter().enumerate() { let name = se.attr("name").unwrap_or("").to_string(); let rel = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).cloned(); @@ -322,13 +326,13 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> file_to_model.push(None); continue; } - let name = unique_name(&wb, &name, i); - let mut sheet = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) { + let name = names.unique(&name, i); + 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) { @@ -336,7 +340,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> 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, @@ -455,15 +459,44 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> Ok((wb, cx.report)) } -pub(crate) fn unique_name(wb: &Workbook, name: &str, i: usize) -> String { - let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); - if base.trim().is_empty() { - base = format!("Sheet{}", i + 1); +/// The sheet names an import has assigned so far, for repairing repeated ones. +#[derive(Default)] +pub(crate) struct SheetNames { + /// Assigned names, ASCII-lowercased. + taken: HashSet, + /// For each lowercased stem, the next suffix to try: a file full of repeated names would + /// otherwise retry every occupied suffix for every sheet. + next: HashMap, +} + +impl SheetNames { + pub(crate) fn of(wb: &Workbook) -> Self { + SheetNames { taken: wb.sheets.iter().map(|s| s.name.to_ascii_lowercase()).collect(), next: HashMap::new() } } - if wb.sheet_index(&base).is_none() { - return base; + + /// A valid name for the i-th sheet, unique among the names assigned so far, which it joins. + pub(crate) fn unique(&mut self, name: &str, i: usize) -> String { + let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); + if base.trim().is_empty() { + base = format!("Sheet{}", i + 1); + } + let name = if self.taken.contains(&base.to_ascii_lowercase()) { + let stem: String = base.chars().take(25).collect(); + let next = self.next.entry(stem.to_ascii_lowercase()).or_insert(2); + let found = (*next..).find(|n| !self.taken.contains(&format!("{stem} ({n})").to_ascii_lowercase())); + match found { + Some(n) => { + *next = n + 1; + format!("{stem} ({n})") + } + None => base, + } + } else { + base + }; + self.taken.insert(name.to_ascii_lowercase()); + name } - (2..).map(|n| format!("{} ({n})", base.chars().take(25).collect::())).find(|n| wb.sheet_index(n).is_none()).unwrap_or(base) } /// Strips a sheet qualifier (`'My Sheet'!$A$1` → `$A$1`). diff --git a/crates/xlsx/src/richdata.rs b/crates/xlsx/src/richdata.rs index 8a4f6ec0a..29c31e5dc 100644 --- a/crates/xlsx/src/richdata.rs +++ b/crates/xlsx/src/richdata.rs @@ -349,7 +349,9 @@ fn metadata(count: usize, dynamic: bool) -> String { } xml.push_str(""); if dynamic { - xml.push_str(""); + // Lower case, as the dynamic-array-only metadata in `write.rs` spells it: Excel refuses + // a workbook whose XLDAPR extension URI is in upper case (#2339). + xml.push_str(""); } let _ = write!(xml, ""); for i in 0..count { diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 73c98386a..f2047b6ee 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -157,7 +157,9 @@ fn read_sheet_format(e: &El, sheet: &mut Sheet) { } } -pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result { +/// 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, crate::controls::ReadControls), 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); @@ -309,6 +311,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result {} } } + let read_controls = read_form_controls(cx, &root, &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)? { @@ -330,7 +333,17 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result` list (also inside +/// `mc:AlternateContent`) with its properties parts, else the shapes of the VML drawing that +/// `` names (not the header and footer drawing of ``). +/// Returns the VML shape numbers read, so pass-through keeps only the others. +fn read_form_controls(cx: &mut Ctx<'_>, root: &El, rels: &[Rel], sheet: &mut Sheet) -> crate::controls::ReadControls { + let controls = root.children.iter().filter_map(crate::controls::controls_of).next_back(); + let vml = root.child("legacyDrawing").and_then(|e| e.attr("id")); + if controls.is_some() || vml.is_some() { crate::controls::read(cx, controls, vml, rels, sheet) } else { Default::default() } } /// A protection password hash: the iterated attributes (`algorithmName`, `hashValue`, diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index a32e6bee6..4a0f4fd03 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -281,29 +281,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::>().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, ""); } else if let Some(rid) = kept.lone_vml(rels, out) { let _ = write!(s, ""); } - 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) { @@ -370,7 +392,12 @@ fn write_sheet_printing<'a>(s: &mut String, sheet: &'a Sheet, rels: &mut Rels, o } // Unmodelled content kept from the source file (printer settings, controls, OLE objects…). let kept = crate::passthrough::SheetKept::write(sheet, rels, out); - let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64); + // Margins are kept as f32: widen each through its shortest decimal, so 0.3 is written as + // `0.3`, as Excel writes it, and not as `0.30000001192092896` (#1609). + let m = |i: usize| { + let inches = p.margins.get(i).copied().unwrap_or(0.75); + num(inches.to_string().parse::().unwrap_or(f64::from(inches))) + }; let _ = write!(s, "", m(0), m(1), m(2), m(3), m(4), m(5)); s.push_str(", n: u32) -> Stri pub(super) fn table_columns(t: &Table) -> Vec { let w = t.range.width() as usize; let mut used = HashSet::new(); + // The next suffix to try per lowercased base, so repeated headers don't retry every suffix + // already handed out. + let mut next: HashMap = HashMap::new(); (0..w) .map(|i| { let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1)); - let mut name = base.clone(); - let mut k = 2; - while !used.insert(name.to_lowercase()) { - name = format!("{base}{k}"); - k += 1; + let lower = base.to_lowercase(); + if used.insert(lower.clone()) { + return base; + } + let k = next.entry(lower).or_insert(2); + loop { + let name = format!("{base}{k}"); + *k += 1; + if used.insert(name.to_lowercase()) { + return name; + } } - name }) .collect() } @@ -348,13 +358,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!( - "" + "" ); 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 +379,42 @@ pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String { if c.visible { "" } else { "" } ); } + s.push_str(controls); s.push_str(""); s } + +#[cfg(test)] +mod tests { + use gridcraft_model::TableColumn; + + use super::*; + + #[test] + fn repeated_headers_are_renamed_without_retrying_suffixes() { + let col = |n: &str| TableColumn { name: n.into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }; + let mut columns: Vec = (0..10_000).map(|_| col("Total")).collect(); + columns[1] = col("total3"); + let t = Table { + id: 1, + name: "T".into(), + range: RangeRef::parse("A1:NTP2").unwrap(), + header_row: true, + totals_row: false, + columns, + style: String::new(), + banded_rows: false, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: false, + filter: vec![], + table_type: None, + connection_id: None, + }; + let names = table_columns(&t); + assert_eq!(names.len(), 10_000); + assert_eq!(&names[..4], ["Total", "total3", "Total2", "Total4"]); + assert_eq!(names.iter().map(|n| n.to_lowercase()).collect::>().len(), 10_000); + } +} diff --git a/crates/xlsx/src/sheet_write/rules.rs b/crates/xlsx/src/sheet_write/rules.rs index 821ec4cef..00b0ca524 100644 --- a/crates/xlsx/src/sheet_write/rules.rs +++ b/crates/xlsx/src/sheet_write/rules.rs @@ -52,8 +52,10 @@ pub(super) fn x14_cfvo(v: &CfValueKind) -> String { format!("{}", esc(&val)) } +/// One `cfRule` element. The formulas generated for text, blank, error and date rules refer to the +/// top-left cell of all the rule's areas, the cell Excel reads relative references from. pub(super) fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { - let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); + let tl = RangeRef::top_left(&cf.ranges).map(|c| c.a1()).unwrap_or_else(|| "A1".into()); let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; let mut dxf = |st: &Style| dxf_id(&mut out.dxfs, st); let f = |x: &str| format!("{}", esc(&text_to_file(x))); diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs index 91d04f281..94b78ff65 100644 --- a/crates/xlsx/src/tests.rs +++ b/crates/xlsx/src/tests.rs @@ -7,6 +7,7 @@ mod chart_bool_default; mod chart_combo_kinds; mod chart_data_table; mod chart_ref_offsets; +mod controls_tests; mod csv_tests; mod encrypted_tests; mod equation_tests; diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs index 14dc9d243..10edf45a4 100644 --- a/crates/xlsx/src/tests/chart_book_names.rs +++ b/crates/xlsx/src/tests/chart_book_names.rs @@ -139,3 +139,41 @@ fn chart_names_are_written_qualified() { let refs = |x: &str| x.split("").skip(1).filter_map(|p| p.split_once("").map(|(f, _)| f.to_string())).collect::>(); assert_eq!(refs(&again), refs(&xml)); } + +/// A quoted sheet name keeps its outer spaces: `' Sales '!Data` is not `Sales!Data`, so the +/// chart doesn't switch to the other sheet's name on save. +#[test] +fn chart_names_keep_spaces_around_a_quoted_sheet_name() { + let mut wb = Workbook::default(); + let mut s = Sheet::new("Sales"); + s.charts.push(Chart { + id: 1, + kind: ChartKind::BarStacked, + anchor: Anchor { cell: gridcraft_core::CellRef::new(0, 3), width: 480.0, height: 300.0, ..Anchor::default() }, + title: None, + series: vec![series("' Sales '!Label", "' Sales '!Data")], + legend: LegendPos::Bottom, + data_labels: false, + data_table: None, + gridlines: true, + style: 2, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + wb.sheets = vec![Arc::new(s), Arc::new(Sheet::new(" Sales "))]; + wb.names = vec![ + name("Items", None, "Sales!$A$2:$A$4"), + name("Data", Some(0), "Sales!$B$2:$B$4"), + name("Data", Some(1), "' Sales '!$B$2:$B$4"), + name("Label", Some(0), "Sales!$B$1"), + name("Label", Some(1), "' Sales '!$B$1"), + ]; + let bytes = write_xlsx(&wb).unwrap(); + let mut xml = String::new(); + zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut xml).unwrap(); + assert!(xml.contains("' Sales '!Data"), "{xml}"); + assert!(xml.contains("' Sales '!Label"), "{xml}"); +} diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs new file mode 100644 index 000000000..ee56c9421 --- /dev/null +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -0,0 +1,832 @@ +//! 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 & 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(""), "{sheet}"); + // Each control's shape id is a VML shape of the same kind; the note keeps its own id. + let ids: Vec = sheet.split("").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 & 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##" +2, 0, 3, 0, 5, 0, 4, 0$B$150000-5999990-3 +1, 0, 1, 0, 1, 20, 3, 0799 +
Go
1, 0, 6, 0, 3, 0, 7, 0[0]!Tidy
+1, 0, 1, 0, 2, 0, 2, 0 +junk +
"##; + +#[test] +fn vml_only_controls_are_read_and_clamped() { + let rels = r#""#; + let bytes = minimal( + r#""#, + &[("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 })); +} + +/// A producer that quotes `shapeId` with single quotes: the control is read, and the kept +/// `` fragment drops it as it drops a double-quoted one, so a save holds the control +/// once and no kept copy points at a VML shape that was taken out. +#[test] +fn a_single_quoted_shape_id_is_dropped_from_the_kept_controls_too() { + let rels = r#""#; + let vml = r##"2, 0, 3, 0, 5, 0, 4, 0$B$1400100110"##; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", vml)], + "", + "", + ); + let (wb, report) = read_xlsx(&bytes).unwrap(); + // The minimal package has no styles part; nothing about the control is reported. + assert!(report.warnings.iter().all(|w| w == "no styles part"), "{:?}", report.warnings); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1); + assert_eq!((sh.controls[0].kind, sh.controls[0].value, sh.controls[0].name.as_str()), (ControlKind::Slider, 40, "Scroll 1")); + let kept = sh.preserved.as_ref().and_then(|k| k.elements.iter().find(|e| e.slot == "controls")).map(|e| e.xml.clone()); + assert_eq!(kept, None, "nothing of the modelled control is kept for pass-through"); + // The saved file holds the control once, with its one VML shape, and reads back once. + let out = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + // One element with one control (each control is written as a Choice with a + // Fallback inside the element's own AlternateContent), and no kept copy. + assert_eq!(sheet.matches("").count(), 1, "{sheet}"); + assert!(!sheet.contains("shapeId='1025'"), "the kept copy would point at a shape that is gone: {sheet}"); + assert_eq!(text(&mut pkg, "xl/drawings/vmlDrawing1.vml").matches(""#; + let prop_a = r#""#; + let prop_b = r#""#; + let vml = r##"2, 0, 3, 0, 5, 0, 4, 0$B$14001001102, 0, 6, 0, 5, 0, 9, 0$D$1:$D$32"##; + let anchor = |from: u32, to: u32| { + format!( + r#"20{from}050{to}0"# + ) + }; + let controls = format!( + r#"{}{}"#, + anchor(3, 4), + anchor(6, 9) + ); + let bytes = minimal( + &format!(r#"{controls}"#), + &[ + ("xl/worksheets/_rels/sheet1.xml.rels", rels), + ("xl/ctrlProps/a.xml", prop_a), + ("xl/ctrlProps/b.xml", prop_b), + ("xl/drawings/vmlDrawing1.vml", vml), + ], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1, "the scroll bar is modelled, the list box is not"); + assert_eq!((sh.controls[0].kind, sh.controls[0].name.as_str()), (ControlKind::Slider, "Scroll 1")); + let kept = sh.preserved.as_ref().and_then(|k| k.elements.iter().find(|e| e.slot == "controls")).map(|e| e.xml.clone()).unwrap_or_default(); + assert!(kept.contains("List 1") && kept.contains(""), "the list box stays, in its wrapper: {kept}"); + assert!(!kept.contains("Scroll 1") && !kept.contains("shapeId=\"1025\""), "the scroll bar leaves every branch: {kept}"); + // Saved: the scroll bar once (one Fallback of ours), the list box in the kept wrapper with its + // shape and its properties part. + let out = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + assert_eq!(sheet.matches(" = pkg.names().into_iter().filter(|n| n.starts_with("xl/ctrlprops/")).collect(); + assert_eq!(props.len(), 2, "ours and the kept one: {props:?}"); + assert!(props.iter().any(|n| text(&mut pkg, n).contains("objectType=\"List\"")), "the list box's properties part survives: {props:?}"); + // Read back: the one modelled control and the kept one. + let (back, _) = read_xlsx(&out).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1); + let kept = sh.preserved.as_ref().and_then(|k| k.elements.iter().find(|e| e.slot == "controls")).map(|e| e.xml.clone()).unwrap_or_default(); + assert!(kept.contains("List 1") && !kept.contains("Scroll 1"), "{kept}"); +} + +/// A producer that prefixes the main namespace (``): the kept element is still the +/// sheet's one `` element, so a modelled control is written into it rather than into a +/// second one, which the worksheet schema does not allow. +#[test] +fn a_modelled_control_joins_a_kept_controls_element_with_a_namespace_prefix() { + use gridcraft_model::{Anchor, ControlKind, FormControl}; + let rels = r#""#; + let prop_b = r#""#; + let vml = r##"2, 0, 6, 0, 5, 0, 9, 0$D$1:$D$32"##; + let controls = r#"20605090"#; + let bytes = minimal( + &format!(r#"{controls}"#), + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/b.xml", prop_b), ("xl/drawings/vmlDrawing1.vml", vml)], + "", + "", + ); + let (mut wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.sheet(0).unwrap().controls.is_empty(), "the list box stays with pass-through"); + let kept = wb + .sheet(0) + .unwrap() + .preserved + .as_ref() + .and_then(|k| k.elements.iter().find(|e| e.slot == "controls")) + .map(|e| e.xml.clone()) + .unwrap_or_default(); + assert!(kept.contains(""), "kept with its prefix: {kept}"); + let anchor = Anchor { cell: gridcraft_core::CellRef::new(2, 1), width: 120.0, height: 20.0, ..Default::default() }; + wb.sheet_mut(0).unwrap().controls.push(FormControl { + id: 900, + kind: ControlKind::Slider, + anchor, + link: Some("$B$1".into()), + value: 40, + ..Default::default() + }); + let out = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + let opened = sheet.matches("").unwrap(); + let inner = &sheet[start..end]; + assert!(inner.contains("name=\"List 1\"") && inner.contains(""), "both controls inside the one element: {sheet}"); + let (back, _) = read_xlsx(&out).unwrap(); + assert_eq!(back.sheet(0).unwrap().controls.len(), 1, "the slider reads back; the list box stays kept"); +} + +/// A kept `mc:AlternateContent` with separate `` elements in its Choice and Fallback: +/// a modelled control is written into the Choice's element, the one the reader (and an +/// application that knows x14) reads, so it comes back with its name and its properties part; +/// the Fallback's element is left as the file had it. +#[test] +fn a_modelled_control_joins_the_choice_controls_element_of_a_kept_alternate_content() { + use gridcraft_model::{Anchor, ControlKind, FormControl}; + let rels = r#""#; + let prop_b = r#""#; + let vml = r##"2, 0, 6, 0, 5, 0, 9, 0$D$1:$D$32"##; + let list = r#"20605090"#; + let controls = format!( + r#"{list}"# + ); + let bytes = minimal( + &format!(r#"{controls}"#), + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/b.xml", prop_b), ("xl/drawings/vmlDrawing1.vml", vml)], + "", + "", + ); + let (mut wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.sheet(0).unwrap().controls.is_empty(), "the list box stays with pass-through"); + let anchor = Anchor { cell: gridcraft_core::CellRef::new(2, 1), width: 120.0, height: 20.0, ..Default::default() }; + wb.sheet_mut(0).unwrap().controls.push(FormControl { + id: 900, + kind: ControlKind::Slider, + anchor, + name: "Speed".into(), + link: Some("$B$1".into()), + value: 40, + ..Default::default() + }); + let out = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + // The Choice's comes first in the sheet, the Fallback's last. + let (first, last) = (sheet.find(" +
Go
1, 0, 6, 0, 3, 0, 7, 0[0]!Tidy
+1, 0, 1, 0, 1, 20, 3, 079 +
"##; + +#[test] +fn vml_shapes_are_also_found_by_their_spid() { + let rels = r#""#; + // The button is listed in without a properties part; the spin button is only in + // the VML. + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", VML_SPID)], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let c = &wb.sheet(0).unwrap().controls; + assert_eq!(c.len(), 2, "{c:?}"); + assert_eq!((c[0].kind, c[0].name.as_str(), c[0].text.as_str()), (ControlKind::Button, "Go Button", "Go")); + assert_eq!(c[0].action, Some(ControlAction::Macro { name: "Tidy".into(), input: MacroInput::Selection, output: None })); + assert_eq!((c[1].kind, c[1].value, c[1].max), (ControlKind::Spin, 7, 9)); + // Saved again, each is written once and nothing of them is kept twice. + let out = write_xlsx(&wb).unwrap(); + let (back, _) = read_xlsx(&out).unwrap(); + assert_eq!(back.sheet(0).unwrap().controls.len(), 2); +} + +#[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("500", "9").into_bytes(); + } + parts.push((name, data)); + } + let zipped = super::make_zip(&parts.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect::>()); + let (back, _) = read_xlsx(&zipped).unwrap(); + assert_eq!(back.sheet(0).unwrap().controls[0].max, 500); +} + +/// A hostile sheet: more controls than a sheet keeps, properties parts that are not XML or hold +/// numbers no control can take, shape ids that are not numbers, and oversized names, captions and +/// links. Reading must not panic, must keep at most the cap, and every control it keeps must pass +/// the model's rules. +#[test] +fn hostile_form_controls_are_capped_and_repaired() { + let n = 13_000; + let mut controls = String::from(""); + let anchor = r#"1-99999999999999999999990-4020"#; + for i in 0..n { + let rid = match i % 10 { + 0 => "rIdB", + 1 => "rIdC", + _ => "rIdA", + }; + let _ = std::fmt::Write::write_fmt( + &mut controls, + format_args!(r#"{anchor}"#, 2000 + i, "n".repeat(400)), + ); + } + controls + .push_str(r#""#); + let rels = r#""#; + let link = format!("$B${}", "9".repeat(500)); + let prop_a = format!( + r#""# + ); + let body = format!(r#"{controls}"#) + .replace("", r#""#); + let bytes = minimal( + &body, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/a.xml", &prop_a), ("xl/ctrlProps/b.xml", "\u{0}\u{1}not xml <<<")], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let c = &wb.sheet(0).unwrap().controls; + assert_eq!(c.len(), 10_000, "the cap of one sheet"); + for x in c { + assert_eq!(x.kind, ControlKind::Slider); + assert!(x.check_numbers().is_ok(), "{x:?}"); + assert!(x.value >= x.min && x.value <= x.max, "{x:?}"); + assert!(x.name.chars().count() <= 255 && x.link.is_none(), "{x:?}"); + assert!(x.anchor.width >= 1.0 && x.anchor.height >= 1.0 && x.anchor.cell.row < gridcraft_core::MAX_ROWS, "{x:?}"); + } + // Written back and read again, the kept controls stay valid. + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.sheet(0).unwrap().controls.len(), c.len()); +} + +/// Each listed control finds its VML shape through an index, so a sheet of many controls reads in +/// linear time (it used to scan every shape for every control). +#[test] +#[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm +fn many_listed_controls_read_in_linear_time() { + let mut wb = Workbook::default(); + if let Some(sh) = wb.sheet_mut(0) { + for i in 0..10_000u32 { + let a = anchor(&format!("B{}", i + 1), 60.0, 18.0); + sh.controls.push(FormControl { id: i + 1, kind: ControlKind::Spin, anchor: a, ..Default::default() }); + } + } + let bytes = write_xlsx(&wb).unwrap(); + let t0 = std::time::Instant::now(); + let (back, _) = read_xlsx(&bytes).unwrap(); + let took = t0.elapsed(); + assert_eq!(back.sheet(0).unwrap().controls.len(), 10_000); + // About 0.4 s here; scanning every shape for every control took over 8 s. + assert!(took < std::time::Duration::from_secs(3), "{took:?}"); +} + +/// The sheet relates each control properties part with the relationship type MS-XLSX defines and +/// Excel writes (in the officeDocument namespace); Excel finds the part by that type. +#[test] +fn the_properties_parts_use_the_ctrl_prop_relationship_type() { + let bytes = write_xlsx(&book()).unwrap(); + let mut pkg = Package::open(&bytes).unwrap(); + let rels = text(&mut pkg, "xl/worksheets/_rels/sheet1.xml.rels"); + let types: Vec<&str> = rels.split("Type=\"").skip(1).filter_map(|r| r.split('"').next()).filter(|t| t.ends_with("/ctrlProp")).collect(); + assert_eq!(types.len(), 4, "{rels}"); + assert!(types.iter().all(|t| *t == "http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp"), "{types:?}"); +} + +/// A sheet with a header and footer drawing and a drawing for its controls: the controls come +/// from the drawing `` names, whichever relationship is listed first. +#[test] +fn controls_come_from_the_legacy_drawing_not_the_header_and_footer_drawing() { + let hf = r##""##; + let rels = r#""#; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawingHF1.vml", hf), ("xl/drawings/vmlDrawing1.vml", VML_ONLY)], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let c = &wb.sheet(0).unwrap().controls; + assert_eq!(c.len(), 3, "{c:?}"); + assert_eq!(c[2].text, "Go"); +} + +/// Every listed control that names the same properties part shares one read of it. Each read is +/// charged to the package's 1 GB budget, so 10,000 reads of a 200 KB part would spend it, drop the +/// controls after that, and fail the parts read after the controls (here the sheet's notes). A +/// hostile file can list that many controls against one part. +#[test] +fn controls_naming_one_properties_part_read_it_once() { + let n = 13_000; + let mut controls = String::from(r#""#); + for i in 0..n { + let _ = std::fmt::Write::write_fmt( + &mut controls, + format_args!( + r#"10{i}020{}0"#, + 2000 + i, + i + 1 + ), + ); + } + controls.push_str(""); + let rels = r#""#; + // 200 KB of attribute whitespace: read once per control, 10,000 reads would be 2 GB. + let prop_a = format!( + r#""#, + " ".repeat(200 * 1024) + ); + let notes = r#"Annread after the controls"#; + let bytes = minimal( + &format!("{controls}"), + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/a.xml", &prop_a), ("xl/comments1.xml", notes)], + "", + "", + ); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 10_000, "the cap of one sheet, every control read from the one part"); + assert!(sh.controls.iter().all(|c| { + c.kind == ControlKind::Slider && c.link.as_deref() == Some("$B$1") && (c.min, c.max, c.step, c.page, c.value) == (0, 200, 5, 20, 45) + })); + assert_eq!(sh.comments.len(), 1, "a part read after the controls still reads: {:?}", report.warnings); +} + +/// A legacy VML drawing with a button (`1025`, modelled), a check box (`1026`) and an option +/// button (`1027`, with a picture through the drawing's own relationship) that only the drawing +/// lists, plus `note` (a note shape for A1 whose client data carries that `ObjectType` attribute, +/// with its comments part) when given. `listed`: the sheet's +/// `` lists the button. +fn vml_only_others(listed: bool, button: bool, note: Option<&str>) -> Vec { + const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + let mut rels = format!(r#""#); + if listed { + rels.push_str(&format!(r#""#)); + } + if note.is_some() { + rels.push_str(&format!(r#""#)); + } + let rels = + format!(r#"{rels}"#); + let vml_rels = format!( + r#""# + ); + let prop_a = r#""#; + let mut shapes = String::new(); + if let Some(attr) = note { + shapes.push_str(&format!(r##"1, 15, 0, 2, 3, 15, 4, 400"##)); + } + if button { + shapes.push_str(r##"
Go
2, 0, 3, 0, 4, 0, 5, 0[0]!Go
"##); + } + shapes.push_str(r##"
Paid
2, 0, 7, 0, 4, 0, 8, 0$B$71
"##); + shapes.push_str(r##"2, 0, 9, 0, 4, 0, 10, 0$B$9"##); + let vml = format!( + r#"{shapes}"# + ); + let controls = if listed { + r#"20304050"# + } else { + "" + }; + let notes = r#"Anncheck the totals"#; + let mut parts: Vec<(&str, &str)> = vec![ + ("xl/worksheets/_rels/sheet1.xml.rels", &rels), + ("xl/drawings/vmlDrawing1.vml", &vml), + ("xl/drawings/_rels/vmlDrawing1.vml.rels", &vml_rels), + ("xl/media/image1.png", "not really a png"), + ]; + if listed { + parts.push(("xl/ctrlProps/a.xml", prop_a)); + } + if note.is_some() { + parts.push(("xl/comments1.xml", notes)); + } + // As `minimal` builds it, with content types for the picture and the drawing. + let ct = super::CT.replace( + "", + r#""#, + ); + let base = minimal(&format!(r#"{controls}"#), &parts, "", ""); + let mut zip = Package::open(&base).unwrap(); + let mut files: Vec<(String, Vec)> = vec![("[Content_Types].xml".into(), ct.into_bytes())]; + for n in zip.names() { + if n != "[Content_Types].xml" { + let data = zip.read(&n).unwrap().unwrap(); + files.push((n, data)); + } + } + super::make_zip(&files.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect::>()) +} + +/// Checks a saved package keeps the check box and option button shapes once each, in the sheet's +/// one VML drawing, with that drawing's one relationship to its one picture; returns the drawing. +fn kept_vml_only_others(out: &[u8]) -> String { + let mut pkg = Package::open(out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + assert_eq!(sheet.matches(" = names.iter().filter(|n| n.ends_with(".vml")).collect(); + assert_eq!(vmls.len(), 1, "{names:?}"); + let vml_name = vmls[0].clone(); + let vml = text(&mut pkg, &vml_name); + assert_eq!(vml.matches("ObjectType=\"Checkbox\"").count(), 1, "the check box is kept once: {vml}"); + assert_eq!(vml.matches("ObjectType=\"Radio\"").count(), 1, "the option button is kept once: {vml}"); + let (dir, file) = vml_name.rsplit_once('/').unwrap(); + let vml_rels = text(&mut pkg, &format!("{dir}/_rels/{file}.rels")); + assert_eq!(vml_rels.matches(" = names.iter().filter(|n| n.starts_with("xl/media/")).collect(); + assert_eq!(media.len(), 1, "the option button's picture once: {names:?}"); + vml +} + +/// The sheet's `` lists only a button, which is modelled; a check box and an option +/// button in the same VML drawing have no worksheet entry. With the button's entry gone from the +/// kept content no `` element is left, but the drawing still holds the two, so it is +/// kept: the save writes them beside the regenerated button, and they survive a second round trip. +#[test] +fn vml_only_controls_survive_when_the_last_listed_control_is_modelled() { + let bytes = vml_only_others(true, true, None); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1, "the button is modelled"); + assert_eq!(sh.controls[0].kind, ControlKind::Button); + assert!(sh.preserved.as_ref().is_some_and(|p| p.vml.is_some()), "the drawing is kept: {:?}", report.warnings); + assert!(report.warnings.iter().any(|w| w.contains("form controls are not shown")), "{:?}", report.warnings); + let out = write_xlsx(&wb).unwrap(); + let vml = kept_vml_only_others(&out); + assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "the button once, written from the model: {vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1); + assert_eq!(sh.controls[0].text, "Go"); + // Saved again: still once each. + let again = write_xlsx(&back).unwrap(); + let vml = kept_vml_only_others(&again); + assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "{vml}"); +} + +/// A sheet whose only controls are a check box and an option button that only the VML drawing +/// lists (no ``, no notes, nothing modelled): the drawing is kept on its own. +#[test] +fn a_sheet_whose_only_controls_are_vml_only_and_unmodelled_keeps_them() { + let bytes = vml_only_others(false, false, None); + let (wb, report) = read_xlsx(&bytes).unwrap(); + assert!(wb.sheet(0).unwrap().controls.is_empty()); + let out = write_xlsx(&wb).unwrap(); + let vml = kept_vml_only_others(&out); + assert!(!vml.contains("ObjectType=\"Button\""), "{vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + let again = write_xlsx(&back).unwrap(); + kept_vml_only_others(&again); + assert!(report.warnings.iter().any(|w| w.contains("form controls are not shown")), "{:?}", report.warnings); +} + +/// The same with a note in the drawing (one VML drawing for one comments part) and a VML-only +/// button that is modelled: the note and the button are written from the model, the check box and +/// option button stay, all in the one drawing. +#[test] +fn vml_only_controls_beside_a_note_are_kept() { + let bytes = vml_only_others(false, true, Some(r#"ObjectType="Note""#)); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!((sh.controls.len(), sh.comments.len()), (1, 1), "{:?}", report.warnings); + let out = write_xlsx(&wb).unwrap(); + let vml = kept_vml_only_others(&out); + assert_eq!(vml.matches("ObjectType=\"Note\"").count(), 1, "{vml}"); + assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "{vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!((sh.controls.len(), sh.comments.len()), (1, 1)); + let vml = kept_vml_only_others(&write_xlsx(&back).unwrap()); + assert_eq!(vml.matches("ObjectType=\"Note\"").count(), 1, "{vml}"); +} + +#[test] +fn a_note_spelled_in_another_case_or_quote_is_not_kept_beside_the_written_one() { + // The note's client data reads ObjectType='note': still a note, so the kept drawing drops it + // and the note GridCraft writes for the comment is the only one. + for attr in ["ObjectType='note'", "ObjectType = \"NOTE\"", "ObjectType=\" Note \""] { + let (wb, report) = read_xlsx(&vml_only_others(false, true, Some(attr))).unwrap(); + assert_eq!(wb.sheet(0).unwrap().comments.len(), 1, "{attr}: {:?}", report.warnings); + let out = write_xlsx(&wb).unwrap(); + let vml = kept_vml_only_others(&out); + assert_eq!(note_shapes(&vml), 1, "{attr}: {vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + assert_eq!(back.sheet(0).unwrap().comments.len(), 1, "{attr}"); + } +} + +/// The note shapes in VML text, counted as the reader tells a note: by each shape's client data +/// `ObjectType`, whatever its spelling. +fn note_shapes(vml: &str) -> usize { + crate::xml::parse(vml.as_bytes()).unwrap().kids("shape").filter(|s| s.child("ClientData").is_some_and(crate::controls::is_note_data)).count() +} + +/// The saved package's one VML drawing. +fn only_vml(out: &[u8]) -> String { + let mut pkg = Package::open(out).unwrap(); + let names: Vec = pkg.names().into_iter().filter(|n| n.ends_with(".vml")).collect(); + assert_eq!(names.len(), 1, "{names:?}"); + text(&mut pkg, &names[0]) +} + +/// A sheet's VML drawing, as the only part beside the sheet's relationships. +fn with_vml(shapes: &str) -> Vec { + let rels = r#""#; + let vml = format!( + r#"{shapes}"# + ); + minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", &vml)], + "", + "", + ) +} + +/// Text that reads like a note's `ObjectType` in a check box's caption or in another attribute of +/// an option button doesn't make either a note: both are kept, and a modelled button with that +/// caption keeps it. +#[test] +fn a_control_whose_text_reads_like_a_note_object_type_is_kept() { + let bytes = with_vml(concat!( + r##"
ObjectType="Note"
2, 0, 3, 0, 4, 0, 5, 0
"##, + r##"
ObjectType="Note"
2, 0, 7, 0, 4, 0, 8, 0$B$7
"##, + r##"2, 0, 9, 0, 4, 0, 10, 0"##, + )); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1, "{:?}", report.warnings); + assert_eq!(sh.controls[0].text, "ObjectType=\"Note\""); + let mut out = write_xlsx(&wb).unwrap(); + for _ in 0..2 { + let vml = only_vml(&out); + assert_eq!(vml.matches("ObjectType=\"Checkbox\"").count(), 1, "the check box is kept: {vml}"); + assert_eq!(vml.matches("ObjectType=\"Radio\"").count(), 1, "the option button is kept: {vml}"); + assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "the button once: {vml}"); + assert_eq!(note_shapes(&vml), 0, "{vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1); + assert_eq!(sh.controls[0].text, "ObjectType=\"Note\""); + out = write_xlsx(&back).unwrap(); + } +} + +/// Kept VML leaves out the modelled controls by the number the reader gave them: the `id`, else +/// the `o:spid`, wherever either comes in the shape's tag. A button whose `o:spid` comes before a +/// different `id`, and one with a plain numeric `id`, are written once, from the model; a check +/// box whose `o:spid` is a modelled button's number but whose `id` is its own is kept; a button +/// with no number isn't modelled and is kept; notes are dropped whatever their numbers. +#[test] +fn kept_vml_leaves_out_modelled_controls_by_the_readers_shape_number() { + let bytes = with_vml(concat!( + r##"
A
2, 0, 1, 0, 4, 0, 2, 0
"##, + r##"
B
2, 0, 3, 0, 4, 0, 4, 0
"##, + r##"
Paid
2, 0, 5, 0, 4, 0, 6, 0
"##, + r##"
C
2, 0, 7, 0, 4, 0, 8, 0
"##, + r##"00"##, + r##"10"##, + )); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let texts: Vec<&str> = wb.sheet(0).unwrap().controls.iter().map(|c| c.text.as_str()).collect(); + assert_eq!(texts, ["A", "B"], "{:?}", report.warnings); + let mut out = write_xlsx(&wb).unwrap(); + for _ in 0..2 { + let vml = only_vml(&out); + for caption in [">A
", ">B", ">C", ">Paid"] { + assert_eq!(vml.matches(caption).count(), 1, "each control once, kept or written: {caption} {vml}"); + } + assert_eq!(vml.matches("ObjectType=\"Checkbox\"").count(), 1, "the check box is kept: {vml}"); + assert_eq!(note_shapes(&vml), 0, "{vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + assert_eq!(back.sheet(0).unwrap().controls.len(), 2); + out = write_xlsx(&back).unwrap(); + } +} diff --git a/crates/xlsx/src/tests/ods_tests.rs b/crates/xlsx/src/tests/ods_tests.rs index 47c258657..bac55efeb 100644 --- a/crates/xlsx/src/tests/ods_tests.rs +++ b/crates/xlsx/src/tests/ods_tests.rs @@ -200,6 +200,18 @@ fn ods_expansion_bounds_stop_small_repeat_bombs() { "x".repeat(1024) ); assert!(matches!(read_ods(&ods(&text)), Err(IoError::TooLarge(_)))); + // One row of cells each just under the 1 MiB cell limit, past 64 MiB in all: refused while + // the row is read, before it holds them all. The row's end is never reached. + let long = format!(r#""#, "x".repeat(1024 * 1024 - 1)); + let cells = ods(&format!(r#"{}"#, long.repeat(65))); + assert!(matches!(read_ods(&cells), Err(IoError::TooLarge(_)))); + // A row repeated once more than its cells' text allows is refused too, and one that fits reads. + let cell = format!(r#""#, "y".repeat(1024)); + let rows = |n: u32| { + ods(&format!(r#"{cell}"#)) + }; + assert!(read_ods(&rows(65_536)).is_ok()); + assert!(matches!(read_ods(&rows(65_537)), Err(IoError::TooLarge(_)))); } #[test] diff --git a/crates/xlsx/src/tests/page_order.rs b/crates/xlsx/src/tests/page_order.rs index f7aa40eb1..b74dc6f96 100644 --- a/crates/xlsx/src/tests/page_order.rs +++ b/crates/xlsx/src/tests/page_order.rs @@ -61,3 +61,15 @@ fn down_then_over_writes_no_page_order() { wb.sheet_mut(0).unwrap().print.page_order = PageOrder::DownThenOver; assert!(!sheet_xml(&write_xlsx(&wb).unwrap()).contains("pageOrder")); } + +#[test] +fn page_margins_are_written_as_their_decimals() { + // #1609 item 7: the f32 margins were widened to f64, so 0.3 came out as 0.30000001192092896. + let body = r#""#; + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + let xml = sheet_xml(&write_xlsx(&wb).unwrap()); + assert!(xml.contains(r#""#), "{xml}"); + // The default sheet writes Excel's default margins exactly. + let xml = sheet_xml(&write_xlsx(&Workbook::new()).unwrap()); + assert!(xml.contains(r#""#), "{xml}"); +} diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 28113be0d..a71b34422 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -849,6 +849,10 @@ const LOGO: &[u8] = b"\x89PNG\r\n\x1a\n letterhead logo"; const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n footer signature"; const HF_VML: &str = r##""##; +/// The VML drawing of one note and nothing else. [`VML`] also holds a check box that only the +/// drawing lists, which is kept with the drawing (and its source shape block). +const NOTE_VML: &str = r##"
old note
00
"##; + /// #1257: a letterhead in the header and a picture in the footer (`&G`), drawn from a VML part /// that `` names, next to a note whose VML drawing GridCraft rewrites. fn header_picture_package() -> Vec { @@ -875,7 +879,7 @@ fn header_picture_package() -> Vec { ("xl/worksheets/sheet1.xml", sheet.as_bytes()), ("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels.as_bytes()), ("xl/comments1.xml", COMMENTS.as_bytes()), - ("xl/drawings/vmlDrawing1.vml", VML.as_bytes()), + ("xl/drawings/vmlDrawing1.vml", NOTE_VML.as_bytes()), ("xl/drawings/vmlDrawing2.vml", HF_VML.as_bytes()), ("xl/drawings/_rels/vmlDrawing2.vml.rels", hf_rels.as_bytes()), ("xl/media/image1.png", LOGO), @@ -938,3 +942,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(" = sx.split(" RangeRef { fn sample() -> Workbook { let mut wb = Workbook::new(); wb.date_system = DateSystem::D1904; - wb.calc = CalcSettings { mode: CalcMode::Manual, iterative: true, max_iterations: 42, max_change: 0.5, precision_as_displayed: true }; + wb.calc = CalcSettings { mode: CalcMode::Manual, iterative: true, max_iterations: 42, max_change: 0.5, precision_as_displayed: true, r1c1: true }; wb.protected_structure = true; wb.structure_password = Some(gridcraft_model::PasswordHash::new_sha512("s3cret", b"0123456789abcdef")); wb.props = DocProps { @@ -1679,3 +1679,66 @@ fn workbook_view_writer_clamps_out_of_range_model_values() { assert_eq!(back.view.tab_ratio, 1000); } } + +#[test] +fn generated_rule_formulas_use_the_top_left_cell_of_every_area() { + // Issue #3152: the areas aren't in top-to-bottom order; the formula is written for C2, not C5. + let mut wb = Workbook::new(); + let red = Box::new(Style::default()); + wb.sheet_mut(0).unwrap().cond_formats.push(CondFormat { + ranges: vec![rr("C5:C6"), rr("C2:C3")], + rule: CfRule::ContainsText { text: "x".into(), style: red }, + priority: 1, + stop_if_true: false, + }); + let bytes = write_xlsx(&wb).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + z.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut xml).unwrap(); + assert!(xml.contains("NOT(ISERROR(SEARCH(\"x\",C2)))"), "{xml}"); +} + +#[test] +fn r1c1_reference_style_is_kept() { + // #2809: `` was dropped on open, so a workbook saved by Excel in R1C1 + // style came back in A1 style. + let bytes = super::minimal("", &[], "", r#""#); + let (wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.calc.r1c1); + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("calcPr").and_then(|c| c.attr("refMode")), Some("R1C1")); + // A1 is the default and isn't written. + let saved = write_xlsx(&Workbook::new()).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("calcPr").and_then(|c| c.attr("refMode")), None); +} + +#[test] +fn remove_personal_information_is_kept_and_honoured() { + // #3089: `` was dropped, so a workbook cleaned of personal + // information came back with it switched off, and GridCraft wrote the author and company back. + let bytes = super::minimal("", &[], "", r#""#); + let (mut wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.remove_personal_info); + wb.props.author = "Jane Example".into(); + wb.props.company = "Example Co".into(); + wb.props.title = "Budget".into(); + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("workbookPr").and_then(|p| p.attr("filterPrivacy")), Some("1")); + let core = String::from_utf8(pkg.read("docProps/core.xml").unwrap().unwrap()).unwrap(); + let app = String::from_utf8(pkg.read("docProps/app.xml").unwrap().unwrap()).unwrap(); + assert!(!core.contains("Jane Example") && !app.contains("Example Co"), "{core}\n{app}"); + assert!(core.contains("Budget"), "other properties stay: {core}"); + // Off (the default), nothing changes. + wb.remove_personal_info = false; + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("workbookPr").and_then(|p| p.attr("filterPrivacy")), None); + assert!(String::from_utf8(pkg.read("docProps/core.xml").unwrap().unwrap()).unwrap().contains("Jane Example")); +} diff --git a/crates/xlsx/src/tests/sheet_names.rs b/crates/xlsx/src/tests/sheet_names.rs index ed1e49e57..37a8b90f7 100644 --- a/crates/xlsx/src/tests/sheet_names.rs +++ b/crates/xlsx/src/tests/sheet_names.rs @@ -35,3 +35,17 @@ fn sheet_ids_survive_a_save() { let (again, _) = crate::read_xlsx(&write_xlsx(&read).unwrap()).unwrap(); assert_eq!(again.sheets.iter().map(|s| s.file_id).collect::>(), ids); } + +/// Repairing repeated names takes one set lookup per candidate, so thousands of sheets sharing +/// a name are renamed without rescanning the workbook for every suffix tried. +#[test] +fn repeated_sheet_names_are_repaired_without_rescans() { + let mut names = crate::read::SheetNames::default(); + assert_eq!(names.unique("Data", 0), "Data"); + assert_eq!(names.unique("data", 1), "data (2)"); + assert_eq!(names.unique("", 2), "Sheet3"); + assert_eq!(names.unique("Data (3)", 3), "Data (3)"); + for i in 4..100_000 { + assert_eq!(names.unique("Data", i), format!("Data ({i})")); + } +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 2c1e8f60f..b072043af 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -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, pub table_id: u32, @@ -255,6 +259,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, @@ -293,6 +300,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) if wb.date_system == gridcraft_core::DateSystem::D1904 { w.push_str(" date1904=\"1\""); } + if wb.remove_personal_info { + w.push_str(" filterPrivacy=\"1\""); + } w.push_str("/>"); if wb.protected_structure { let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default(); @@ -326,6 +336,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) if wb.calc.precision_as_displayed { w.push_str(" fullPrecision=\"0\""); } + if wb.calc.r1c1 { + w.push_str(" refMode=\"R1C1\""); + } if out.needs_calc { w.push_str(" fullCalcOnLoad=\"1\""); } @@ -399,13 +412,16 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) // Document properties. let p = &wb.props; + // "Remove personal information on save": the author and company are not saved (#3089). + let private = |v: &String| if wb.remove_personal_info { String::new() } else { v.clone() }; + let (author, company) = (private(&p.author), private(&p.company)); let mut core = format!( "{XML_DECL}" ); for (tag, v) in [ ("dc:title", &p.title), ("dc:subject", &p.subject), - ("dc:creator", &p.author), + ("dc:creator", &author), ("cp:keywords", &p.keywords), ("dc:description", &p.description), ] { @@ -417,8 +433,8 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) out.part("docProps/core.xml", Some("application/vnd.openxmlformats-package.core-properties+xml"), core.into_bytes()); let mut app = String::from(XML_DECL); app.push_str("GridCraft"); - if !p.company.is_empty() { - let _ = write!(app, "{}", esc(&p.company)); + if !company.is_empty() { + let _ = write!(app, "{}", esc(&company)); } app.push_str(""); out.part("docProps/app.xml", Some("application/vnd.openxmlformats-officedocument.extended-properties+xml"), app.into_bytes()); diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index 940a66261..d9ff04f51 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -213,8 +213,33 @@ pub fn parse(xml: &[u8]) -> Result { 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. +/// The first element named `local_name` (namespace prefix dropped) in `xml`, read from its start tag +/// alone: no children, and nothing after the tag is read, so unbalanced markup around it (a VML +/// caption's unclosed `
`) neither hides nor moves it. `None` when there is none, or when the +/// markup before it is malformed. +pub fn first_start(xml: &[u8], local_name: &str) -> Option { + let mut reader = Reader::from_reader(xml); + { + let cfg = reader.config_mut(); + cfg.check_end_names = false; + cfg.allow_unmatched_ends = true; + cfg.allow_dangling_amp = true; + } + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf).ok()? { + Event::Start(e) | Event::Empty(e) if local(e.name().as_ref()) == local_name => return Some(start_el(&e)), + Event::Eof => return None, + _ => {} + } + buf.clear(); + } +} + +/// 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 { let data = to_utf8(xml); let mut reader = Reader::from_reader(data.as_ref()); @@ -230,8 +255,11 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res let mut root: Option = None; let mut buf = Vec::new(); // Elements kept in the tree so far, so a pathological part that passes the byte limits can - // still be refused before the tree itself outgrows memory. + // still be refused before the tree itself outgrows memory. Elements inside a `split` element + // are handed out with it and do not stay in the tree, so a streamed worksheet or shared-string + // table of any size passes: `kept_at_start` remembers the count when each open element began. let mut kept = 0usize; + let mut kept_at_start: Vec = Vec::new(); // Attach a finished element to its parent (or emit it / make it the root). fn finish( stack: &mut [El], @@ -240,11 +268,17 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res split: &[&str], cb: &mut dyn FnMut(El) -> Result<(), IoError>, kept: &mut usize, + kept_at_start: Option, ) -> 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)?; + // The element and everything parsed inside it have left the tree. + if let Some(k) = kept_at_start { + *kept = k; + } } else { *kept = kept.saturating_add(1); if *kept > MAX_XML_NODES { @@ -269,14 +303,16 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res return Err(IoError::Xml("XML nested too deeply".into())); } stack.push(start_el(&e)); + kept_at_start.push(kept); } Event::Empty(e) => { let el = start_el(&e); - finish(&mut stack, &mut root, el, split, cb, &mut kept)?; + finish(&mut stack, &mut root, el, split, cb, &mut kept, None)?; } Event::End(_) => { if let Some(el) = stack.pop() { - finish(&mut stack, &mut root, el, split, cb, &mut kept)?; + let at_start = kept_at_start.pop(); + finish(&mut stack, &mut root, el, split, cb, &mut kept, at_start)?; } } Event::Text(t) => { @@ -315,7 +351,8 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res } // Unclosed elements (truncated part): close them so we keep what we have. while let Some(el) = stack.pop() { - finish(&mut stack, &mut root, el, split, cb, &mut kept)?; + let at_start = kept_at_start.pop(); + finish(&mut stack, &mut root, el, split, cb, &mut kept, at_start)?; } root.ok_or_else(|| IoError::Xml("no root element".into())) } @@ -559,6 +596,26 @@ mod tests { } } + #[test] + fn a_streamed_part_may_hold_more_elements_than_the_cap() { + // A worksheet of ordinary size: 5001 rows of 100 cells is 1,000,200 `c` and `v` elements, + // more than the cap, but the rows are streamed out one by one and never pile up in the + // tree, so the part is read whole. Kept in a tree (no split), the same part is refused. + let row = format!("{}", "1".repeat(100)); + let xml = format!("{}", row.repeat(5001)); + let mut rows = 0usize; + let root = parse_streaming(xml.as_bytes(), &["row"], &mut |r| { + assert_eq!(r.children.len(), 100); + rows += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(rows, 5001); + assert!(root.children.is_empty()); + let err = parse(xml.as_bytes()).unwrap_err(); + assert!(err.to_string().contains("elements"), "{err}"); + } + #[test] fn a_part_of_endless_empty_elements_is_refused() { assert!(parse(b"").is_ok()); diff --git a/docs/cell-pictures.md b/docs/cell-pictures.md index 586615422..0803128a4 100644 --- a/docs/cell-pictures.md +++ b/docs/cell-pictures.md @@ -17,9 +17,11 @@ The command API accepts the same placement: {"command":"insert.picture","params":{"path":"picture.png","placement":"cell","at":"B2","alt":"Product photo"}} ``` -This initial feature handles static pictures. Pasting an image from the operating system's -clipboard, converting between floating and cell pictures, and the `IMAGE()` function remain -separate work. Formula references and arithmetic do not carry pictures: the calculation +This initial feature handles static pictures. A picture on the operating system's clipboard (a +screenshot, an image copied in a browser) pastes with Cmd/Ctrl+V or Paste as a floating picture +at the active cell, as in Excel (desktop builds only: the web build's paste receives clipboard +text alone, so a picture with no text pastes nothing there). Pasting it into a cell, converting between floating and cell +pictures, and the `IMAGE()` function remain separate work. Formula references and arithmetic do not carry pictures: the calculation engine uses a `#VALUE!` fallback, including when a picture is referenced from another cell. Text-only exports also use that fallback. The picture cell still counts as occupied and blocks a formula's spill range. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 354e88e09..c4ca5072f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -65,7 +65,7 @@ connection; nothing sent after it runs. Blank lines are skipped. | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB | | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `ui.ribbon` | `{tab}` | Switch ribbon tab | -| `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs; `confirm` runs the dialog's command with the same parameters as its OK button | +| `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs; `confirm` runs the dialog's command with the same parameters as its OK button. `confirm` applies to a dialog whose OK runs a command (Solver, Data Analysis, the form dialogs); a dialog that presses its own OK, such as Goal Seek, answers `this dialog has no OK command; drive it with its own commands` and is driven with that command (`data.goalSeek`). That command does not go back to the dialog's sheet: after a pick on another sheet, read the fields (`ui.inspect` reports the dialog's `values`), `cancel` the dialog (which shows its sheet again), then run `data.goalSeek` with those values, so references without a sheet name mean the dialog's sheet. Once the dialog's own sheet is deleted, `cancel` leaves the sheet shown as it is and replies `{"sheetDeleted": true}` (the sheet is found by identity, so a new sheet given its name does not count); the script then stops instead of running `data.goalSeek`, whose references without a sheet name would mean the sheet shown. A dialog whose reference boxes (RefEdit) picked on another sheet goes back to its own sheet: `cancel` puts the selection from before picking back; `confirm` puts it back before the command runs (so references without a sheet name mean the dialog's sheet). Both fail, with the dialog left open, when a text-box draft on the picked sheet cannot be saved (its worksheet is no longer the active one), and `confirm` also once the dialog's sheet was deleted; the window's own Cancel closes the dialog in that case and shows the reason. | | `ui.pythonMode` (via `engine.execute`) | `{on?}` | Python mode on the active cell, in the formula bar (an edit in progress keeps its text); `on: false` turns it off; without `on` it switches. A start that is refused (a locked cell on a protected sheet, a cell or draft inside another cell's spill, code hidden on a protected sheet) is an error with the reason. Opens no dialog | | `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|account\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | | `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state. On the native app it works while hidden or occluded when the platform still reports the current content size (the request asks for a hidden paint); a minimized window has no capturable size, so its request stays queued until the window is restored and otherwise reports the 8 s timeout. In a **hidden browser tab** the web runner does not implement that paint request, so the capture waits until the tab is shown again or reports the 8 s timeout. Up to 4 outstanding requests; retained images are limited to 128 MiB each and 256 MiB in total (each request reserves the largest image the ±1-pixel rounding rule can retain), one capture's transient encoding working set to 256 MiB, a base64 reply to 64 MiB, and retained images plus one capture's encoding working set to 512 MiB; a request that would exceed a budget is refused without evicting earlier ones. See [Screenshot budgets](#screenshot-budgets). A resize/DPI/zoom/visibility/occlusion change during capture is retried within the bounded retry budget or reported instead of saving stale pixels; the identity intentionally includes zoom and occlusion even though the expected physical size may be unchanged, so a capture never crosses layout generations. Every reply — success, refusal or timeout — carries the request `token` as `request` | @@ -93,12 +93,14 @@ include `window` or `doc` to run it in that window, as with `engine.execute`; th them as their own parameters. `ui.inspect` reports the selected floating object as `selectedObject: {kind, id}` (or `null`), -where `kind` is `chart`, `image`, `shape`, `equation` or `slicer`; the same names go in the `kind` parameter +where `kind` is `chart`, `image`, `shape`, `equation`, `slicer` or `control` (a form button, slider or spin +button); the same names go in the `kind` parameter of `object.delete`, `object.move`, `object.setAnchorMode` and the `arrange.*` commands. An equation is a shape too, so its id works under `shape`; a plain shape's id given as `equation`, a `kind` that is present but not one of these names (`null` included), or an id of no object of the kind, is an error. `arrange.align` and `arrange.group` check every id in `ids` the same way; with fewer than two ids they change nothing. +Form controls are worked with `control.setValue` and `control.press`, and listed by `control.list`. `object.move` and `chart.set` cap `width` and `height` at 10,000 points, or at the object's current size when a file made it larger: such an object keeps its size when it is moved and shrinks by what a resize asks, but does not grow further. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..9e1fa6e30 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `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; earlier: `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; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `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; earlier: `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; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **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** @@ -76,7 +76,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), 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: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), form controls (buttons, scroll bars and spin buttons: control properties parts, `` and VML `x:ClientData`; a VML-only control is read too; a button's command, fixed input range or output cell travels in a hidden defined name and comes back only onto the same button while the file still assigns its macro, or none for a command), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), other form controls (check boxes, list boxes, drop-downs; written in the same `` element and VML drawing as the modelled ones, also those only the VML drawing lists, with no `` entry), ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), 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` | @@ -106,8 +106,11 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection; per-column day/month date-order detection for `/` and `-` dates) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | -| `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | +| `.slk` SYLK | R/W | **yes, partial** (#1481) | no (Save As `.xlsx`) | Values (numbers, text, booleans, errors), formulas converted from R1C1 to A1 as Excel does (relative and absolute references, ranges, whole rows and columns, shared formulas) with their cached results, number formats (`P`/`F;P`), column widths (`F;W`); the sheet is named after the file. Not read: fonts and cell styles, row heights, names, comments. Non-UTF-8 files are read as Latin-1 | engine `io/tests.rs` (`sylk_*`) | +| `.dif` Data Interchange Format | R/W | no | no | Small, legacy | | `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | +| `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | +| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes: opening reads the page's first table as a sheet (header cells bold and centred, values read as typed, the sheet named by ``), also when the page is saved as `.xls`/`.xlsx` as web apps' "Export to Excel" does, with a warning that the format and name don't match (#3166); saving writes one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | | `.mht` / `.mhtml` single-file web page | R/W (Windows) | no | no | | | `.json` | via Power Query | GridCraft's own workbook JSON (debug) and JSON import | GridCraft JSON | Not Power Query's JSON shaping; notes and hyperlinks break export (#126) | | `.xml` with XML maps | Developer ▸ Import/Export | no | no | | @@ -148,13 +151,19 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Legacy `.xls` (BIFF8): exact theme colours (XFExt), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | | Encrypted workbooks: save with password (#11) | 4–6 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | -| CSV/text: encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 4–7 | +| CSV/text: encoding choice, `.prn`, `.dif`, XML Spreadsheet 2003 | 4–7 | | PDF: embed subsetted system fonts so every script exports | (counted in [localization](localization-parity.md) script work) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) | +| 2026-11-03 | minor | SYLK (`.slk`) import: values, R1C1 formulas as A1 (shared formulas too) with cached results, number formats and column widths; import only (#1481) | +| 2026-11-03 | minor | Opening a web page (HTML) reads its first table as a sheet, whatever the file is called: a web app's "Export to Excel" saved as `.xls` no longer opens as lines of markup. Values read as in a CSV (or as text with `csvAsText`), `` cells are bold and centred, `` names the sheet, and a page not named `.htm`/`.html` opens with a warning (#3166) | +| 2026-11-03 | minor | XLSX `` (Remove personal information from file properties on save) is read and written back, and while it is on GridCraft saves no author (`dc:creator`) and no company, as Excel leaves them empty (#3089, file part) | +| 2026-11-03 | minor | XLSX `` (a workbook saved in R1C1 reference style) is read into the calculation settings and written back; GridCraft itself still shows A1 references (#2809, file part) | +| 2026-11-03 | minor | XLSX `pageMargins` are written as the decimals they were read or set as (`0.3`, as Excel writes them), not as the f64 widening of the stored f32 (`0.30000001192092896`) (#1609 item 7) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source | diff --git a/docs/function-parity.md b/docs/function-parity.md index 7e730d78c..97bd5401a 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells are edited in the formula bar; earlier: the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!`, and a Python in cells row; earlier: YEARFRAC basis 0 February-end rule, MOD quotient limit, PERCENTRANK significance; earlier: edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-11-03 · **Change:** minor (GROUPBY total depth 2 or more adds subtotals; earlier: Python cells are edited in the formula bar; earlier: the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!`, and a Python in cells row; earlier: YEARFRAC basis 0 February-end rule, MOD quotient limit, PERCENTRANK significance; earlier: edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -134,6 +134,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | GROUPBY: a total depth of 2 or more adds a subtotal line for each group of the leading key columns (after the group, or before it for a negative depth) and labels the last line Grand Total; a depth past the key columns, or above 1 with `field_relationship` 1, is `#VALUE!`, as in Excel (#1456 item 1) | | 2026-11-01 | minor | Python in cells row: Python mode in the formula bar | | 2026-11-01 | minor | Python in cells (stored, not run): `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!` done, with their `ERROR.TYPE` numbers; `PY` still counted as missing | | 2026-10-31 | minor | BIN2*, HEX2* and OCT2* reject surrounding spaces and tabs in source strings with `#NUM!`, matching Excel; plain unpadded values remain valid | diff --git a/docs/gaps.md b/docs/gaps.md index 7612fbebf..ef9626a35 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -157,7 +157,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- [ui-parity.md](ui-parity.md#accessibility) ### 19. XLSB/ODS beyond values; minor formats (FF) -- XLSB and ODS import values only, no write; no `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003; CSV +- XLSB and ODS import values only, no write; no `.prn`, `.dif`, XML Spreadsheet 2003 (`.slk` imports values, formulas, number formats and widths); CSV writes raw instead of displayed values; templates saved with the workbook content type. **Estimate:** 15–23 h. diff --git a/docs/macros.md b/docs/macros.md index 1cd2cfbb5..586800045 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-11-01 · **Change:** minor (a close that waits on the step editor's question runs in the editor's window, and a further close asked meanwhile waits for the answer or is refused; earlier: closing a workbook or quitting asks about the step editor's unsaved draft first; recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) +> **Last updated:** 2026-11-02 · **Change:** minor (form buttons run macros, `macro.run` takes `output`, and a button action kept in a hidden name comes back on reopen only while the file still agrees with it; earlier: a close that waits on the step editor's question runs in the editor's window, and a further close asked meanwhile waits for the answer or is refused; earlier: closing a workbook or quitting asks about the step editor's unsaved draft first; recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro @@ -98,12 +98,18 @@ refused rather than read as the header. ## Running -`macro.run {name, inputs?, onConflict?, overwrite?, approve?, timeLimit?}`: +`macro.run {name, inputs?, output?, onConflict?, overwrite?, approve?, timeLimit?}` (`output` +replaces the macro's output anchor for this run, as a form button that sends the output elsewhere +does; `macro.check` and the debugger take it too). The policy and the approval go by the macro as +it is stored; the footprint, the conflict rule and the check of each step work on the cells +`output` names, so data there is protected as at the macro's own anchor. An `output` that isn't +reference text of at most 1,024 characters, or that names no cell (a bad reference, a sheet that +doesn't exist, a cell outside the grid), is refused before anything runs: 1. **Policy.** File commands other than Properties, exports and printing (such as Open, Save and Close), app, window (including New Window, Hide and Unhide), automate and macro commands, - and Undo and Redo, never run in a macro. File › Properties runs, and so do exports and - printing (`file.exportPdf`, `exportCsv`, `exportHtml`, `print`, `printPreview`, `saveBytes`) + Undo and Redo, and pressing a form control (`control.press`), never run in a macro. File › + Properties runs, and so do exports and printing (`file.exportPdf`, `exportCsv`, `exportHtml`, `print`, `printPreview`, `saveBytes`) that only produce bytes. Reading or writing a file (`data.getData`, `data.fromTextCsv`, `insert.picture` or `pageLayout.background` with a `path`, or an export or print with a `path`) needs the command in the macro's `permissions`. Any `path` other than `null` counts, @@ -138,9 +144,9 @@ refused rather than read as the header. do; a step that deletes one of them, or changes the size of a range a Repeat is going over, stops the run. -`macro.check {name, inputs?, onConflict?, overwrite?, timeLimit?}` reports the bound inputs, the -output anchor, any approval needed and the footprint, and puts back everything the dry run -changed. `ok` says whether `macro.run` with the same parameters would go ahead without a question; +`macro.check {name, inputs?, output?, onConflict?, overwrite?, timeLimit?}` reports the bound +inputs, the output anchor (`output` when given), any approval needed and the footprint, and puts +back everything the dry run changed. `ok` says whether `macro.run` with the same parameters would go ahead without a question; when it wouldn't, `errors` says why: the run would fail at a step, a Stop step rolls it back, it would write over data under `ask` or `fail`, shifting can't make room (the check shifts and checks again, then puts everything back), or the run would go past @@ -246,6 +252,35 @@ kept but does not run the macro. {name, to}`. Saving under a name a macro has is refused unless `replace: true` is passed, and renaming to one is refused; names compare ignoring the case of the letters A to Z. A renamed macro is checked against every limit again, its size included. Saving, renaming and deleting are undoable. +Renaming a macro also renames it in the form buttons that run it. + +## Form buttons + +A form button (Insert › Controls › Button, `insert.button`) runs a macro or one command when it is +pressed (`control.press {id, approve?, onConflict?}`). `control.assign {id, macro, input?, +output?}` makes it run a macro, on the selection when it is pressed (`input: "selection"`, the +default) or always on one range (`input: "B2:D9"`), with an optional output cell; `control.assign +{id, command, params}` makes it run a command with fixed parameters. A macro runs through +`macro.run` with its checks; a command goes through the same policy as a macro step and runs, as +a step does, inside the scripts' file sandbox unless it is an approved file command, so a button +in a workbook opened from a file asks before it runs anything. Like a step, a button's command +never opens a web page or a dialog: `link.open` is refused when it is assigned or comes in from +a file, and a command that would open a dialog or a page once it runs (a command given no +parameters opens its dialog) is refused then, with the request dropped and nothing done. +In the window, a press that asks (approve the action, or overwrite data in the macro's way) opens +a prompt whose answer applies only to what it asked about: if the workbook, the sheet, the +button, its action, its macro or the selection changes while the prompt is open (an agent +reassigns the button), the answer runs nothing and the next press asks again. + +In xlsx a button carries its macro as Excel writes it (`macro="[0]!Name"`, a macro run on the +selection). A command, a fixed input range or an output cell has no place there and is kept in a +hidden defined name (`_gc_control_actions_`), keyed by the sheet, the button's place, name and +anchor cell. When the file is opened the hidden name is removed and each kept action goes back +onto its button only when the file still agrees with it: a kept macro action needs the file to +assign that macro still, so an assignment removed in another application stays removed and a +button given another macro keeps that one; a kept command needs the file to assign no macro. A +button whose name, anchor cell or place changed, or which another application removed, gets no +kept action. Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden defined names (`_gc_macro__`, the macro's JSON split into string literals within Excel's diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 10259f303..4100ec630 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,13 +4,13 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 278 / 292 commands implemented (95.2%).** +**Overall: 281 / 296 commands implemented (94.9%).** | Tab | Implemented | Total | Parity | |---|---|---|---| | File | 11 | 11 | 100% | | Home | 94 | 94 | 100% | -| Insert | 19 | 25 | 76% | +| Insert | 18 | 25 | 72% | | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | @@ -18,6 +18,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Review | 15 | 18 | 83% | | View | 21 | 21 | 100% | | Automate | 4 | 4 | 100% | +| Developer | 4 | 4 | 100% | | Table Design | 13 | 13 | 100% | | Chart Design | 8 | 8 | 100% | | Format | 3 | 3 | 100% | @@ -139,7 +140,7 @@ Implemented: - `edit.goToSpecial` Go To Special (Editing) - `home.addIns` Add-ins (Add-ins) -## Insert (19 / 25, 76%) +## Insert (18 / 25, 72%) Implemented: @@ -158,7 +159,6 @@ Implemented: - `review.newComment` Comment (Comments) - `insert.textBox` Text Box (Text) - `pageLayout.headerFooter` Header & Footer (Text) -- `insert.wordArt` WordArt (Text) - `insert.equation` Equation (Symbols) - `insert.symbol` Symbol (Symbols) - `insert.checkbox` Checkbox (Cell Controls) @@ -169,6 +169,7 @@ Missing: - `insert.screenshot` Screenshot (Illustrations) - `insert.map` Maps (Charts) - `insert.timeline` Timeline (Filters) +- `insert.wordArt` WordArt (Text) - `insert.signatureLine` Signature Line (Text) - `insert.object` Object (Text) @@ -334,6 +335,15 @@ Implemented: - `automate.recordActions` Record Actions (Scripting Tools) - `automate.trustScripts` Trust Scripts (Scripting Tools) +## Developer (4 / 4, 100%) + +Implemented: + +- `insert.button` Button (Form Control) (Controls) +- `insert.spinButton` Spin Button (Form Control) (Controls) +- `insert.slider` Scroll Bar (Form Control) (Controls) +- `control.format` Properties (Controls) + ## Table Design (13 / 13, 100%) Implemented: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a4f2edb3f..69719496d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (insert objects: equations; earlier: what-if and analysis: Solver Sensitivity report; no score change; earlier: chart resizing no longer listed as missing; no score change; earlier: what-if and analysis: Analysis ToolPak has all 19 tools; earlier: page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (form controls; catalog 281 / 296; earlier: insert objects: equations; earlier: what-if and analysis: Solver Sensitivity report; no score change; earlier: chart resizing no longer listed as missing; no score change; earlier: what-if and analysis: Analysis ToolPak has all 19 tools; earlier: page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **277 / 292 (94.9%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **281 / 296 (94.9%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | @@ -54,11 +54,12 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with COPILOT and PY: **523 functions**. Ours: every `f!(…)` spec in `crates/functions/src/*.rs` plus `SPECIAL_FUNCTIONS` in `crates/calc/src/eval.rs` (evaluator special forms), minus the test-only `ADD2`: **505**, all of them Excel names. -- **Ribbon catalog:** `crates/engine/src/catalog.rs` (292 unique ids across 15 tabs) against the - 313 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and +- **Ribbon catalog:** `crates/engine/src/catalog.rs` (296 unique ids across 16 tabs) against the + 321 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and identical to the committed `parity-checklist.md`. The catalog is our own transcription of Excel's main ribbon tabs; it leaves out most contextual tabs (Picture Format, Shape Format, - Header & Footer, Sparkline, Slicer, Timeline), the Developer and Help tabs and sub-menu items, so + Header & Footer, Sparkline, Slicer, Timeline), most of the Developer tab (its form controls are + in), the Help tab and sub-menu items, so 89% of it is roughly **60–65% of Excel's full command surface** (estimated). - **Depth and readiness:** source reading (the model types in `crates/model/src/features.rs`, readers and writers in `crates/xlsx/src`, the evaluator), 478 `#[test]` functions, and the @@ -91,9 +92,9 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (all 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer, Sensitivity and Limits reports; Load/Save Model, Reset All and Solver Options; no Population report), Forecast Sheet. Missing: Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | -| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, WordArt (10 own styles; no warps or gradient fills), own icon set, ink with Ink to Shape, hyperlinks, checkboxes, equations (LaTeX with a live preview, Office Math in xlsx, Symbol font in PDF). Missing: SmartArt, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | +| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, WordArt (10 own styles; no warps or gradient fills), own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons, equations (LaTeX with a live preview, Office Math in xlsx, Symbol font in PDF). Missing: SmartArt, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | -| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility | +| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog, a Ctrl+Shift shortcut or a form button (which can also run one command). VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | ## Dimensions @@ -334,6 +335,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Form controls (buttons, scroll bars, spin buttons) in Insert objects and Automation; catalog 277 / 292 → 281 / 296 (Developer › Controls) | | 2026-11-02 | minor | Insert objects: equations; catalog count 277 / 292 (no score change) | | 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report (no score change) | | 2026-11-02 | minor | Charts move and resize from eight handles like every floating object (#190): the Essentials chart work is polish only (5–8 h), and the chart rows and the interaction discount no longer list resizing as missing; the Mainstream chart row no longer lists trendlines as missing (no score change) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ba97eea4d..e68946466 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,10 +1,10 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (form controls: buttons, sliders and spin buttons on the sheet; earlier: Highlight the active cell's row, a global preference in File › Options › General; earlier: a linked cell's menu edits, opens or removes the link; earlier: Display options for this workbook: sheet tabs and scroll bars; earlier: dragging over row or column headers in point mode extends the reference; earlier: a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: every Paste Special option has its access key; earlier: the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (277 / 292). +separately in [parity-checklist.md](parity-checklist.md) (281 / 296). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes @@ -22,6 +22,8 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | +| Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; dragging its bottom edge sets the height in whole lines (up to half the window; one line collapses it), and the chevron or Ctrl+Shift+U expands it back to that height (five lines until it is dragged); the in-cell editor shows every line | done; the Name Box width is fixed | +| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus the Excel 2003 menu keys (Alt+I, E, O, D, V, T and Alt+F, U, such as Alt+I, R or Alt+D, S); Alt+I, D, V and T, which have no ribbon tab, paint no badges, as in Excel (#941); other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | @@ -54,10 +56,11 @@ this logic, with one undo step. | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | +| Highlight the active cell's row (no Excel equivalent; Focus Cell tints the row and the column) | done (11-03): File › Options › General, off by default; a preference for every sheet and workbook, nothing saved in the file. Tints over the fills and under the text, in Normal and Page Layout views (`options.highlightActiveRow`) | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing | -| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | +| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | border drag moves (M3) and asks "There's already data here. Do you want to replace it?" before replacing cells that hold data (`range.move`, #3136); Ctrl/Option to copy and Shift to insert are not wired to the border drag yet (reorder whole columns by their headers instead) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | -| Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | +| Point mode (click/drag/arrow references), reference colouring, F4 | done; dragging over row or column headers points at whole rows or columns (`A:C`, `2:5`); Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | | Number-format colors (`[Green]`, `[Color1]`–`[Color56]`) | Rendered in grid and PDF from shared RGB mappings (#1452) | | Point mode (click/drag/arrow references), reference colouring, F4 | done | | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | @@ -70,6 +73,7 @@ this logic, with one undo step. | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes, equations and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing | +| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file (an answer runs only the action it asked about: if an agent changes the button, its macro or the selection while the prompt is open, nothing runs); the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter (a button gives the keyboard back as soon as the grid takes a key or a right-click; Space on a slider or spin button does what it does with no control focused: it toggles a checkbox cell or starts a cell edit like any other character); Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | | Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) | @@ -125,6 +129,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | — | Cmd+Shift+H, Cmd+Shift+R, Cmd+Shift+K / J, Control+L, Control+U | replace, sort, group / ungroup, Define Name, edit cell | **added** | | Alt+Shift+Right / Left | Option+Right / Left is next/previous sheet | group / ungroup | **added** | | Ctrl+Shift+* (Ctrl+Shift+8), Ctrl+Shift+Space | Cmd+Shift+Space | current region, select all | **added** | +| Ctrl+. | Control+. | Next Corner of Selection: the active cell goes clockwise to the next corner and anchors there (`selection.nextCorner`, #1442) | **added** | | Ctrl+Shift+Home / End, Shift+Home | same | extend to A1 / last used cell / row start | **added** | | Ctrl+P | Cmd+P | print | **added** | | Ctrl+Shift+> / < | Cmd+Shift+> / < | font size | **added** | @@ -173,7 +178,46 @@ the references entered before; a double-click goes to one. Its Reference box tak or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) and selects them all. New Formatting Rule's formula box points like the formula bar: a click on the grid writes the cell's absolute reference at the caret (`=$B$2`) and a drag a range, leaving the selection the rule -applies to as it is (#2348). Other dialogs' reference fields don't take clicks yet. +applies to as it is (#2348). + +Reference boxes (RefEdit) take a range by selection on the grid: while a box is the one being +picked into, clicking or dragging on the sheet writes the absolute reference into it (with the +sheet name, quoted when it needs quotes, when the selection is on another sheet: +`'Rates Table'!$A$2:$A$4`), and typing still edits it. The box's collapse button shrinks the +dialog to the box while the person selects; Escape, Enter or the expand button restore it (Enter +there does not press OK, which is not shown while collapsed). Cancel, Escape, a ribbon command +that opens another dialog over it, and a scripted `ui.dialog.cancel` put the sheet and selection +from before picking back; OK (and `ui.dialog.confirm`, for a dialog whose OK runs a command; +Goal Seek presses its own OK and is scripted with `data.goalSeek`) puts them back before its +command runs and then stays where the command leaves things, so a Data Analysis report written +to a new sheet stays in view. Solver's Reset All, Load, Save and Replace go back the same way +before they run, since they act on the dialog's sheet, and are refused like OK once that sheet +was deleted. A text-box draft left open on the sheet picked on is saved before +the sheet changes, as any sheet switch saves it; when it cannot be saved (a script showed +another sheet meanwhile), OK does not run its command and the dialog stays open with the +reason, while Cancel still closes the dialog, shows the reason and leaves the draft and the +selection where they are (a scripted `ui.dialog.cancel` fails and keeps the dialog instead, so +the script can act). While collapsed, the arrow keys move the selection on the grid, +Shift+arrow extends it and Ctrl+arrow (Cmd on a Mac) jumps to the edge of the data, and the box +follows; expanded, the arrows edit the box. F4 in a focused box cycles the anchors of its +reference (`A1`, `$A$1`, `A$1`, `$A1`; a whole column or row between `A:A` and `$A:$A`). A click +elsewhere (the Name Box, the ribbon) neither expands nor closes a collapsed dialog, and the box +keeps picking, while the arrow keys and Escape belong to the Name Box as long as it has the +keyboard; a command that moves the selection (Undo, Go To) moves the reference with it. A +box the dialog stops showing (Solver's Options view, the Model Range of a hidden Load/Save area, +the constraint value once the operator is int or bin) is no longer picked into: a selection +made meanwhile does not land in it when it is shown again. +The dialog follows the sheet it was opened on by the sheet's identity while the app runs +(fresh for every sheet added, inserted, copied or read from a file; kept through renames, +moves, undo and redo), wherever it is moved and whatever it is renamed to. A sheet deleted and +another inserted in its place or given its own name, or a copy of it, even in one script batch +or one undo step, is not taken for it: OK is refused and Cancel leaves the selection where it +is; an undo that brings the sheet back makes it the dialog's again. The collapse button is +reachable from the keyboard and named for screen readers. Goal Seek, Solver +(objective, variable cells, both sides of a new constraint and the Model Range of Load/Save) and +Data Analysis (input, variable, X and Y, bin and output ranges) use reference boxes; the other +dialogs that take ranges (Forecast Sheet, Allow Edit Ranges, Define Name, Sparklines, Advanced +Filter) still take typed references. ## Accessibility @@ -198,6 +242,21 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Form controls: buttons, sliders (scroll bars) and spin buttons placed, worked, selected and edited on the sheet (Insert › Controls, Assign Action, Format Control); catalog 281 / 296 | +| 2026-11-03 | minor | Highlight the active cell's row: a global preference that tints the whole row, off by default | +| 2026-11-03 | minor | The formula bar's bottom edge is a resize handle: dragging it sets the bar's height in whole lines, up to half the window, and dragging it to one line collapses it; expanding (the chevron, Ctrl+Shift+U) returns to the dragged height, which is kept with the UI preferences. `view.expandFormulaBar {lines}` sets it (#1246) | +| 2026-11-03 | minor | Cell context menu on a cell with a link: Edit Hyperlink… (the link dialog, titled Edit Hyperlink and filled with the link), Open Hyperlink and Remove Hyperlink, as in Excel, instead of Link… (#2120) | +| 2026-11-03 | minor | File › Options › Display options for this workbook: Show horizontal scroll bar, Show vertical scroll bar and Show sheet tabs (`view.showHorizontalScrollBar`, `view.showVerticalScrollBar`, `view.showSheetTabs`), saved with the workbook; with the sheet tabs off the scroll bar takes the bar, with the scroll bar off the tabs do, with both off the bar goes, and without the vertical scroll bar the grid takes its width (#3101) | +| 2026-11-03 | minor | Find and Replace with a range selected searches only that range, as in Excel: Find Next moves the active cell through it and keeps it selected, Find All lists its matches, Replace and Replace All change only them; one selected cell still searches the sheet. `edit.find` / `edit.replace` take `within: selection` (#3224) | +| 2026-11-03 | minor | Point mode: dragging over row or column headers extends the whole-row or whole-column reference, as it does over cells | +| 2026-11-03 | minor | Frozen panes: text in frozen columns overflows only into its own pane and keeps its whole width while the sheet scrolls; frozen gridlines and merged-cell borders reach the freeze line, and the freeze handle measures the last frozen column itself (#2544). Page Layout view gridlines stop at each page's last column | +| 2026-11-03 | minor | Excel 2003 menu access keys: 43 legacy sequences (Insert, Edit, Format, Data, View, Tools and File ▸ Page Setup, e.g. Alt+I, R inserts rows, Alt+E, A, A clears all, Alt+D, S sorts, Alt+T, O opens Options) run the existing commands and dialogs; Alt+I, D, V and T paint no badges, as in Excel (#941, #2311) | +| 2026-11-03 | minor | Paste Special: every option has Excel's access key (A, F, V, T, C, N, X, W, R, U; O, D, S, M, I; B, E; L pastes the link at once), not only F, V, T, C and E (#1482) | +| 2026-11-03 | minor | Draw tab: the stroke drawn under the pointer while dragging a pen is the ink that will be committed, in the pen's colour and thickness (translucent for a highlighter), instead of always blue at 2 pt (#1786) | +| 2026-11-03 | minor | Copy puts a cell that holds a quote on the clipboard as it is (`say "hi"`), quoting only text with a tab or line break, as Excel does; pasting text reads a field as quoted only when its closing quote ends it, so `"hi" there` pastes with its quotes (#2225) | +| 2026-11-03 | minor | Ctrl+. (Control+. on a Mac): Next Corner of Selection, `selection.nextCorner`: the active cell moves clockwise to the next corner of the selected area, which stays selected, and becomes the anchor, as in Excel (#1442) | +| 2026-11-02 | minor | Dragging the selection's border onto cells that hold data asks "There's already data here. Do you want to replace it?" (OK replaces, Cancel leaves the sheet and clipboard as they were) through the new `range.move` command (#3136); the row no longer claims Ctrl/Option copy and Shift insert for the border drag, which are not wired yet | +| 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | | 2026-11-02 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection | @@ -268,6 +327,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | | 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | +| 2026-11-03 | minor | A picture on the system clipboard (a screenshot, an image copied in a browser or an image editor) pastes with Cmd/Ctrl+V, the ribbon's Paste and the Paste menu items as a floating picture whose top-left corner is the active cell, as Excel does; text on the clipboard still wins. Desktop only: the web build's paste receives clipboard text alone. Oversized or malformed pictures are refused with a message (#699, #3018, #3182) | | 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste (ribbon, menus or Cmd/Ctrl+V) doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers | | 2026-10-11 | minor | Ribbon: scroll arrows on a tab wider than the window, Formulas › AutoSum as a split button with its function menu, View › Show in two columns so Headings is no longer cut off | | 2026-10-11 | minor | Theme tokens for icon, reference, on-accent and sheet-control colours; WCAG 2 contrast test over both themes (dark danger red and selected header text adjusted) | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 5b7137455..b662dd101 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -323,6 +323,11 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String { id = e.target, ), }, + // Options is a Backstage page rather than a dialog box. + "dialog" if e.target == "options" => format!( + "sequence(&mut h, {seq:?}, false);\n assert!(h.state().backstage.is_some(), \"{seq} must open the Options page\");", + seq = e.seq, + ), "dialog" => format!( "sequence(&mut h, {seq:?}, false);\n assert!(h.state().dialog.is_some(), \"{seq} must open the {dialog} dialog\");", seq = e.seq,