Merge g3 into g4

# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/edit.rs
#	crates/engine/src/cmd/formulas.rs
#	crates/engine/src/cmd/inspect.rs
#	crates/engine/src/cmd/pivot.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/tabs.rs
#	docs/gaps.md
#	docs/ui-parity.md
This commit is contained in:
aiongg
2026-10-26 02:45:41 +00:00
353 changed files with 24668 additions and 3425 deletions
+1
View File
@@ -273,6 +273,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("automate.newScript", "Automate", "Scripting Tools", "New Script"),
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
// Table Design
("table.rename", "Table Design", "Properties", "Table Name"),
("table.resize", "Table Design", "Properties", "Resize Table"),
+1 -1
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap;
use gridcraft_core::{CellRef, RangeRef, Value, compare};
use gridcraft_model::*;
use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
/// The conditional look of one cell.
#[derive(Clone, Debug, Default, PartialEq)]
+22 -1
View File
@@ -267,6 +267,12 @@ fn attr<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
}
}
/// Entities are at most `&` + 8 chars + `;` (a decimal code point at most 7 digits deep), so
/// the semicolon is only looked for in the first 12 bytes — and `unwrap_or(rest)` never falls
/// back to scanning the remainder of the text either (that turn re-scans per `&`, quadratic
/// when a paste holds many bare `&`).
const ENTITY_WINDOW: usize = 12;
/// HTML text to plain text: entities decoded, whitespace runs (and `<br>`) collapsed to one
/// space, trimmed. The text only serves to match a cell with the tab-separated text.
fn decode(raw: &str) -> String {
@@ -275,7 +281,9 @@ fn decode(raw: &str) -> String {
while let Some(amp) = rest.find('&') {
out.push_str(rest.get(..amp).unwrap_or_default());
rest = rest.get(amp..).unwrap_or_default();
let semi = rest.get(..12).unwrap_or(rest).find(';');
// `.min(len)` (not an `unwrap_or(rest)`) — a `get(..12)` that fails on a multibyte
// boundary used to fall back to the complete suffix, re-scanning per '&'.
let semi = rest.get(..ENTITY_WINDOW.min(rest.len())).and_then(|w| w.find(';'));
let entity = semi.and_then(|e| rest.get(1..e));
let ch = entity.and_then(|e| match e {
"amp" => Some('&'),
@@ -382,6 +390,19 @@ mod tests {
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
}
#[test]
fn entities_recognized_only_within_the_twelve_byte_window() {
// The window never scans the rest of the text for a semicolon: a bare '&' followed by
// long plain text stays a bare '&', and one '&' after another is linear.
assert_eq!(decode("a&amplifier; b"), "a&amplifier; b");
assert_eq!(decode("&ampampampampAMP;"), "&ampampampampAMP;");
// Inside the window the usual forms still decode, and a trailing bare '&' too.
assert_eq!(decode("&#38;"), "&");
assert_eq!(decode("&#x266A;"), "\u{266A}");
assert_eq!(decode("a&amp;b&nbsp;c"), "a&b\u{a0}c");
assert_eq!(decode("amp&"), "amp&");
}
#[test]
fn hostile_markup_does_not_panic() {
for html in [
+214 -8
View File
@@ -106,6 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
show_levels
),
cmd!(
"data.outlineSettings",
"Outline Settings",
["Data", "Outline"],
None,
"{sheet?, summaryBelow?: bool, summaryRight?: bool} (summary rows below their detail, summary columns right of it; off puts them above / left) → {summaryBelow, summaryRight}",
has_doc,
outline_settings
),
cmd!(
"data.subtotal",
"Subtotal",
@@ -652,6 +661,24 @@ fn filter_blank(v: &Value) -> bool {
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
}
/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342).
/// A table's own filter is left to the table, and a row typed after a blank row does not count.
/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc.
pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) {
return;
}
let Some(af) = sh.autofilter.as_ref() else { return };
let r = af.range;
if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) {
return;
}
let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col));
if let Some(af) = sh.autofilter.as_mut() {
af.range = grown;
}
}
/// Hides rows of the autofilter range that fail any criterion.
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let Some(sh) = wb.sheet(sheet) else { return };
@@ -1311,6 +1338,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let (rows, a, b) = outline_lines(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let b = b.min(a.saturating_add(1_048_575));
for i in a..=b {
@@ -1321,9 +1349,10 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
}
}
// A summary line whose group is gone has nothing left to collapse.
let summary = if before { a.checked_sub(1) } else { b.checked_add(1) };
if !on
&& !outline::groups(map).iter().any(|g| g.end == b)
&& let Some(e) = map.get_mut(&b.saturating_add(1))
&& !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b })
&& let Some(e) = summary.and_then(|i| map.get_mut(&i))
{
e.collapsed = false;
}
@@ -1341,8 +1370,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let all = outline::groups(map);
let all = outline::groups_with(map, before);
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
@@ -1351,13 +1381,13 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
};
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
if chosen.is_empty() {
chosen = pick(&|g| g.summary() >= a && g.summary() <= b);
chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b);
}
for g in chosen {
// Inner groups collapsed on their own (their summary marked), before this one changes.
let inner: Vec<outline::OutlineGroup> = all
.iter()
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.summary() != g.summary())
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary())
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
.copied()
.collect();
@@ -1366,6 +1396,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
}
}
if !g.has_summary() {
continue;
}
if hide {
map.entry(g.summary()).or_default().collapsed = true;
} else if let Some(e) = map.get_mut(&g.summary()) {
@@ -1401,15 +1434,16 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
for (map, n) in [(&mut sh.rows, rows), (&mut sh.cols, cols)] {
let before = (outline::summary_before(sh, true), outline::summary_before(sh, false));
for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] {
let Some(n) = n else { continue };
let groups = outline::groups(map);
let groups = outline::groups_with(map, before);
for e in map.values_mut().filter(|e| e.outline > 0) {
set_hidden(e, e.outline >= n);
}
// Inner groups come first, so a summary line shared with the group around it ends up
// as that outer group says.
for g in groups {
for g in groups.into_iter().filter(|g| g.has_summary()) {
map.entry(g.summary()).or_default().collapsed = g.level >= n;
}
}
@@ -1418,6 +1452,57 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new
/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost
/// group whose detail is all hidden, or the outermost when none is.
fn move_marks(map: &mut std::collections::BTreeMap<u32, LineInfo>, was_before: bool, on: bool) {
let mut marked: Vec<outline::OutlineGroup> =
outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
}
fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] {
let Some(on) = bool_param(p, key) else { continue };
let was_before = outline::summary_before(sh, rows);
if rows {
sh.summary_below = on;
} else {
sh.summary_right = on;
}
if was_before != on {
continue;
}
move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on);
}
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
})
}
fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.subtotal")?;
let header = bool_param(p, "header").unwrap_or(true);
@@ -1458,6 +1543,11 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
start = row;
}
}
// The totals go below each group, so the outline puts summary rows below their detail (the
// Subtotal dialog's "Summary below data"), in the same undo step.
if !sh.summary_below {
s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?;
}
// Insert a total row after each group, bottom-up so indices stay valid.
let mut inserted = 0;
for (g0, g1, name) in groups.iter().rev() {
@@ -1522,6 +1612,28 @@ mod tests {
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
}
#[test]
fn subtotal_puts_summary_rows_below_their_detail() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.summary_below, "the totals sit below their groups");
// East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing.
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row);
assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden");
assert!(!hidden(&s, 3), "East Total stays visible");
// One undo takes the subtotals and the setting back.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!(!sh.summary_below);
assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West");
}
#[test]
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
let mut s = Session::new();
@@ -1554,6 +1666,33 @@ mod tests {
}
}
#[test]
fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:B3"})).unwrap();
let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1());
s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap();
assert_eq!(range(&s), Some("A1:B4".to_string()));
s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A row typed after a blank row does not grow it.
s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A cell outside the filter's columns does not either.
s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// Empty text is a blank entry, not a value.
s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A formula and Ctrl+Enter (range.fill) are entries too.
s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap();
assert_eq!(range(&s), Some("A1:B6".to_string()));
s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap();
assert_eq!(range(&s), Some("A1:B7".to_string()));
}
#[test]
fn list_validation_without_formula1_is_an_error() {
let mut s = Session::new();
@@ -1726,6 +1865,73 @@ mod tests {
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
}
#[test]
fn summary_rows_above_the_detail_take_the_collapsed_mark() {
let mut s = nested_rows();
let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true}));
// Row 4 (index 3) is the outer group's summary, row 5 the inner one's.
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]);
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
// Hide Detail on the summary row collapses the group below it.
s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap();
assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]);
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]);
// Ungrouping the outer group clears its mark on the row above.
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap();
assert_eq!(row_state(&s, 3..=3), [(false, false)]);
}
#[test]
fn changing_the_summary_direction_moves_collapsed_marks() {
let mut s = nested_rows();
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap();
let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed);
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
assert!(col_marked(&s, 1) && !col_marked(&s, 5));
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [false, false, false, false, false, true]);
assert!(col_marked(&s, 1), "columns keep their direction");
// One undo step per change; no params leaves the settings as they are.
s.execute("edit.undo", json!({})).unwrap();
assert!(row_state(&s, 3..=3)[0].1);
assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false}));
// Nested groups that end on one row share its mark: it moves to the outer group only.
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
s.execute("data.group", json!({"rows": "6:8"})).unwrap();
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
// With only the inner group collapsed, its mark goes to row 5, and the outer group's
// Hide and Show Detail keep it collapsed.
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
// A group on the first row has no row above it to mark.
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "1:3"})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap();
assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]);
}
#[test]
fn column_sort_rejects_row_key_zero_without_panicking() {
let mut s = Session::new();
+360 -19
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Values",
[],
None,
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}",
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
has_doc,
range_set_values
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
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),
cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col),
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name}", has_doc, go_to),
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name | \"Sheet2!Name\" | \"B14:C22,F19:G30\" | \"Price, Type\", record?: bool} (record: keep a reference without names among the recent ones) or {list: true} → {names, recent}", has_doc, go_to),
cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AutoFill",
[],
None,
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend|days|weekdays|months|years}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up; days, weekdays, months, years = Fill Days, Fill Weekdays, Fill Months, Fill Years: dates step by one unit, or by the difference between the first two dates in that unit, and weekdays skip Saturday and Sunday)",
has_doc,
auto_fill
),
@@ -272,6 +272,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
sh.remove_cell(at);
}
}
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
Ok(Json::Null)
})?;
@@ -299,6 +300,15 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let start = target_range(s, p)?.start;
let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) };
// A string is run through typed input by default, which reads it with en-US rules: a caller
// sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence
// (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas
// are unaffected either way.
let parse = match p.get("parse") {
None | Some(Json::Null) => true,
Some(Json::Bool(b)) => *b,
Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))),
};
if rows.len() > MAX_ROWS as usize {
return Err(bad("range.setValues", "too many rows"));
}
@@ -321,6 +331,17 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
Json::Bool(b) => {
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
}
// Text as given: no date or number reading, and a leading `'` is part of the
// text rather than the marker that makes it text. A formula is still a
// formula, which is what the ticket asks for and what `=` means in a cell.
Json::String(text) if !parse && !text.starts_with('=') => {
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
// Line breaks still turn on Wrap Text, as typed input does.
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
}
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
let sh = cx.sheet_mut(sheet)?;
@@ -330,6 +351,7 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
sh.remove_cell(at);
}
}
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
count += 1;
}
@@ -370,7 +392,9 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64);
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
}
cx.sheet_mut(sheet)?.set_cell(at, cell);
let sh = cx.sheet_mut(sheet)?;
sh.set_cell(at, cell);
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
}
}
@@ -548,26 +572,154 @@ fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
Ok(json!({"selection": d.selection.a1()}))
}
/// How many references the Go To dialog keeps for going back to.
const GO_TO_RECENT: usize = 10;
/// The most areas one Go To selects.
const GO_TO_MAX_AREAS: usize = 2000;
/// How deep a name that refers to other names is followed.
const GO_TO_NAME_DEPTH: usize = 8;
pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "list") == Some(true) {
let d = s.doc()?;
return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet()), "recent": d.go_to_recent}));
}
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
// A defined name or table?
let d = s.doc()?;
let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() {
let sheet = d.wb.active_sheet();
if let Some(n) = d.wb.name(reference, sheet) {
n.formula.clone()
} else if let Some((si, ti)) = d.wb.table(reference) {
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t)
} else {
let mut target: Option<usize> = None;
let mut areas: Vec<RangeRef> = Vec::new();
let mut named = false;
for part in reference_parts(reference) {
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else {
return Err(EngineError::Other("Reference isn't valid.".into()));
};
// One selection holds the areas of one sheet.
if target.is_some_and(|t| t != sheet) {
return Err(EngineError::Other("Reference isn't valid.".into()));
}
} else {
reference.to_string()
target = Some(sheet);
named |= by_name;
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas.")));
}
}
let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) };
let range = areas.iter().map(RangeRef::a1).collect::<Vec<_>>().join(",");
let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?;
if bool_param(p, "record") == Some(true) && !named {
let d = s.doc_mut()?;
let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default());
let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::<Vec<_>>().join(",");
d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry));
d.go_to_recent.insert(0, entry);
d.go_to_recent.truncate(GO_TO_RECENT);
}
Ok(out)
}
/// The parts of a Go To reference, split at the commas outside quoted sheet names and
/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller
/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text.
fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false);
let mut chars = text.char_indices();
std::iter::from_fn(move || {
if done {
return None;
}
for (i, c) in chars.by_ref() {
match c {
'\'' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => depth = depth.saturating_sub(1),
',' if !quoted && depth == 0 => {
let part = text.get(start..i).unwrap_or("").trim();
start = i + 1;
return Some(part);
}
_ => {}
}
}
done = true;
Some(text.get(start..).unwrap_or("").trim())
})
}
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
return None;
}
let (prefix, body) = split_sheet(part);
let on = match &prefix {
Some(n) => wb.sheet_index(n)?,
None => sheet,
};
let resolved = resolved.replace('$', "");
selection_set(s, &json!({"range": resolved}))
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
return Some((on, vec![r], false));
}
let name = match prefix {
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)),
None => wb.name(body, sheet),
};
if let Some(n) = name {
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
}
if prefix.is_none()
&& let Some((si, ti)) = wb.table(body)
{
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = wb.sheet(si)?.tables.get(ti)?;
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
}
None
}
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
/// reference.
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
let from = n.scope.unwrap_or(sheet);
let formula = n.formula.trim().trim_start_matches('=');
let mut target: Option<usize> = None;
let mut areas = Vec::new();
for p in reference_parts(formula) {
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
if target.is_some_and(|t| t != s) {
return None;
}
target = Some(s);
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return None;
}
}
target.map(|t| (t, areas))
}
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`,
/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that
/// do not refer to a range are left out.
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
let own: std::collections::HashSet<String> =
wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut out: Vec<String> = wb
.names
.iter()
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
.filter_map(|n| match n.scope {
None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None,
None => Some(n.name.clone()),
Some(s) if s == sheet => Some(n.name.clone()),
Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)),
})
.collect();
out.sort_by_cached_key(|n| gridcraft_formula::fold_name(n));
out.dedup_by(|a, b| gridcraft_formula::same_name(a, b));
out
}
fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
@@ -712,7 +864,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
// scripts get it only when they ask for it.
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected };
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust };
s.clipboard = Some(clip);
let mut result = json!({"range": range.a1(), "text": text});
if let Some(html) = html {
@@ -1235,6 +1387,16 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
*cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] };
Ok(())
})?;
// Formulas and validation from an untrusted file leave the destination untrusted; plain values,
// formats (cell styles and merged areas, no conditional formats), column widths and notes
// carry nothing active. A linked paste writes a formula into every cell whatever `what` says,
// except for column widths and notes, which write no cells at all.
let passive =
matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats));
if !same_doc && !passive {
let d = s.doc_mut()?;
d.trust = d.trust.receive(clip.trust);
}
if cut_move {
s.clipboard = None;
}
@@ -1423,6 +1585,10 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
"extend" => crate::fill::FillMode::Extend,
"days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day),
"weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday),
"months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month),
"years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year),
_ => crate::fill::FillMode::Series,
};
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
@@ -1747,6 +1913,132 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
mod tests {
use super::*;
/// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a
/// formula and a hidden name.
fn go_to_workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap();
s.execute("selection.set", json!({"sheet": 0})).unwrap();
let define = |s: &mut Session, name: &str, refers: &str, scope: &str| {
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
};
define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook");
define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook");
define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook");
define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2");
define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1");
define(&mut s, "Rate", "=0.07", "Workbook");
define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook");
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
wb.names.push(gridcraft_model::DefinedName {
name: "Secret".into(),
scope: None,
formula: "Sheet1!$Z$9".into(),
comment: String::new(),
hidden: true,
});
s
}
/// "The Go to popup window shows named ranges on every worksheet in your workbook." Names
/// that are not ranges, and hidden names, are not listed.
#[test]
fn go_to_lists_the_named_ranges_of_every_sheet() {
let mut s = go_to_workbook();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}");
assert_eq!(list["recent"], json!([]), "{list}");
// Seen from Sheet2, its own name is bare and Sheet1's is qualified.
s.execute("selection.set", json!({"sheet": 1})).unwrap();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
}
#[test]
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
let mut s = go_to_workbook();
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap();
assert_eq!(out["selection"], "C3", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 1);
// A name of several areas selects all of them.
let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap();
assert_eq!(out["selection"], "A1,C3:C4", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
}
/// A name matches in any case and any script, as a formula matches it.
#[test]
fn go_to_matches_a_sheet_name_in_any_case() {
let mut s = go_to_workbook();
s.execute("formulas.defineName", json!({"name": "Größe", "refersTo": "=Sheet2!$D$4", "scope": "Sheet2"})).unwrap();
s.execute("formulas.defineName", json!({"name": "GRÖSSE", "refersTo": "=Sheet1!$F$6", "scope": "Workbook"})).unwrap();
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!GRÖSSE"})).unwrap();
assert_eq!(out["selection"], "D4", "{out}");
// Seen from Sheet2, its own name hides the workbook name it shares.
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Größe", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
}
/// "You can enter multiple named ranges or cell references in the Reference box. Separating
/// each with a comma ... Excel will highlight all the ranges."
#[test]
fn go_to_selects_every_reference_and_name_in_a_list() {
let mut s = go_to_workbook();
let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap();
assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}");
let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap();
assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}");
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap();
assert_eq!(out["selection"], "A1,B2", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 1);
// Areas on two sheets, and a part that is not a reference, leave the selection alone.
for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] {
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}");
assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}");
}
}
#[test]
fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() {
let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)");
assert_eq!(parts.next(), Some("A1"));
assert_eq!(parts.next(), Some("'Q1, Q2'!B2"));
assert_eq!(parts.next(), Some("SUM(C3,D4)"));
assert_eq!(parts.next(), None);
assert_eq!(reference_parts("").collect::<Vec<_>>(), vec![""]);
let mut s = go_to_workbook();
let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err();
assert!(err.to_string().contains("at most"), "{err}");
let bad = format!("Total{}", ",".repeat(1_000_000));
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err());
}
/// "The Go to box keeps track of ranges as you enter them, and you can return to any of them."
#[test]
fn go_to_keeps_the_references_entered_newest_first() {
let mut s = go_to_workbook();
s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap();
s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap();
// Names are listed already; a call without `record` (the Name Box) is not kept.
s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap();
s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap();
s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}");
// Going back from Sheet2 to a recent reference returns to its sheet.
s.execute("selection.set", json!({"sheet": 1})).unwrap();
let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap();
assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
for i in 0..GO_TO_RECENT + 2 {
s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap();
}
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}");
assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}");
}
#[test]
fn cell_set_takes_numbers_for_input_and_value() {
// #697: a JSON number for `input` used to clear the cell instead of entering it.
@@ -1764,6 +2056,55 @@ mod tests {
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
}
/// Every string in `values` went through typed input, read with en-US rules, in silence: a
/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and
/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is.
#[test]
fn set_values_can_store_strings_as_the_text_they_are() {
let mut s = Session::new();
s.new_workbook();
let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"];
let row: Vec<Json> = strings.iter().map(|v| json!(v)).collect();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
// The default is unchanged: the dates swap, the thousands separator is a decimal point.
s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy");
assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal");
assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage");
// With `parse: false` each string is the text it was given, and a formula is still a
// formula.
s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap();
for (i, want) in strings.iter().enumerate() {
let cell = format!("{}3", (b'A' + i as u8) as char);
let got = get(&mut s, &cell);
if *want == "=1+1" {
assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}");
assert_eq!(got["formula"], "=1+1", "{got}");
} else {
assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}");
assert!(got["formula"].is_null(), "{cell} is not a formula: {got}");
}
}
// Numbers and booleans are untouched either way.
s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap();
assert_eq!(get(&mut s, "A5")["value"], json!(12.5));
assert_eq!(get(&mut s, "B5")["value"], json!(true));
assert_eq!(get(&mut s, "C5")["text"], "");
// `parse` is a flag, and anything else is a usage error rather than a quiet default.
s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap();
assert_eq!(get(&mut s, "A7")["value"], json!(2.5));
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err());
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err());
// A line break still turns on Wrap Text.
s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap();
assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo"));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
}
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
+112 -9
View File
@@ -209,8 +209,34 @@ pub fn specs() -> Vec<CommandSpec> {
new_script
),
cmd!(query "automate.allScripts", "All Scripts", ["Automate", "Scripting Tools"], None, "{} → saved scripts", has_doc, all_scripts),
cmd!("automate.runScript", "Run Script", [], None, "{name}", has_doc, run_script),
cmd!(
"automate.runScript",
"Run Script",
[],
None,
"{name, trusted?: bool (runs a script from an untrusted workbook this once)} — refuses scripts in a workbook opened from a file until it is trusted, and steps that reach outside the workbook (file.*, link.*, any `path` param)",
has_doc,
run_script
),
cmd!(
noundo "automate.trustScripts",
"Trust Scripts",
["Automate", "Scripting Tools"],
None,
"{trusted?: bool (default true)} → {trusted}: lets this workbook's scripts run for the rest of the session",
has_doc,
trust_scripts
),
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
cmd!(
noundo "automate.stopAction",
"Stop Action",
[],
None,
"{} → {stopped: bool}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began",
has_doc,
stop_action
),
// Cell controls, themes, borders, styles
cmd!(
"insert.checkbox",
@@ -955,7 +981,7 @@ fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
return Err(EngineError::Other("Nothing to import.".into()));
}
let name = str_param(p, "sheet").map(str::to_string).or_else(|| {
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(|f| f.to_str()).map(|f| f.chars().take(31).collect()))
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect()))
});
let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?;
let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
@@ -1017,10 +1043,13 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
}
fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
let wb = &s.doc()?.wb;
let d = s.doc()?;
let (wb, trusted) = (&d.wb, d.scripts_trusted);
let all = stash_get(wb, SCRIPTS_NAME);
let mut list: Vec<Json> =
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default();
let mut list: Vec<Json> = all
.as_object()
.map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect())
.unwrap_or_default();
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
// save but never executed here, so it has no step count.
if let Some(bytes) = &wb.vba_project {
@@ -1029,22 +1058,96 @@ fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
Ok(Json::Array(list))
}
/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted).
pub(crate) fn has_scripts(wb: &Workbook) -> bool {
stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty())
}
/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the
/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data,
/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run
/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by
/// command id and parameter name, so it is only the up-front refusal: the steps also run inside
/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and
/// [`run_script`] drops any `OpenUrl` request a step raises.
fn denied_script_step(id: &str, params: &Json) -> Option<String> {
if id.starts_with("file.") || id.starts_with("link.") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
}
// Scripts are scoped to their workbook: a step that switches the active window would
// point every later step at another open document, so those are refused too.
if id.starts_with("window.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
}
if params.get("path").is_some() {
return Some(format!("scripts can't pass a file `path` to `{id}`"));
}
None
}
fn trust_scripts(s: &mut Session, p: &Json) -> Result<Json> {
// Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent.
let trusted = match p.get("trusted") {
None => true,
Some(Json::Bool(b)) => *b,
Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")),
};
s.doc_mut()?.scripts_trusted = trusted;
Ok(json!({"trusted": trusted}))
}
/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is
/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that
/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs.
fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?;
let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME);
let d = s.doc()?;
let all = stash_get(&d.wb, SCRIPTS_NAME);
let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?;
if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) {
return Err(bad(
"automate.runScript",
"scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)",
));
}
let steps: Vec<Json> = steps.into_iter().take(100_000).collect();
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if let Some(why) = denied_script_step(id, &st["params"]) {
return Err(bad("automate.runScript", why));
}
}
let _sandbox = crate::io::ScriptSandbox::enter();
let mut n = 0;
for st in steps.iter().take(100_000) {
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if id.starts_with("automate.") {
continue;
}
s.execute(id, st["params"].clone())?;
let before = s.ui_requests.len();
let ran = s.execute(id, st["params"].clone());
// A step that asks the host to open a URL leaves the app: drop the request and stop.
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
let mut i = 0;
s.ui_requests.retain(|r| {
i += 1;
i <= before || !matches!(r, crate::UiRequest::OpenUrl(_))
});
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
}
ran?;
n += 1;
}
Ok(json!({"ran": n}))
}
/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and
/// Redo work there again.
fn stop_action(s: &mut Session, _: &Json) -> Result<Json> {
let uid = s.doc()?.uid;
Ok(json!({"stopped": s.abort_groups(uid)}))
}
fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
edit(s, |cx| {
@@ -1207,7 +1310,7 @@ fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?;
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={name}"))));
s.ui_requests.push(crate::UiRequest::InsertName(name.to_string()));
ok()
}
+6 -5
View File
@@ -36,7 +36,7 @@ fn new_workbook(s: &mut Session, p: &Json) -> Result<Json> {
Some(name) => {
let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?;
let title = crate::sample::title(name);
s.add_document(DocState::new(wb, None, title))
s.add_document(DocState::new(wb, None, title, crate::Trust::Local))
}
None => s.new_workbook(),
};
@@ -71,9 +71,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
let title = if imported {
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book"))
} else {
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Book").to_string()
};
// An import has no save target: ordinary Save must ask for a new destination. Neither does a
// workbook decrypted with its password, so Save never replaces the encrypted original with
@@ -85,7 +85,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
{
s.close_document(0);
}
let mut doc = DocState::new(wb, path, title);
// Someone else may have made the file: its active content asks before it runs.
let mut doc = DocState::new(wb, path, title, crate::Trust::FromFile);
if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx {
doc.content_kind = Some(crate::io::FileKind::Xlsx);
}
@@ -115,7 +116,7 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
};
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
// document keeps its file and its unsaved changes.
if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
if std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
return super::print::export_pdf(s, &json!({"path": path}));
}
let d = s.doc_mut()?;
+16
View File
@@ -233,10 +233,23 @@ fn with(p: &Json, key: &str, v: Json) -> Json {
pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
let guarded = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.is_some());
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
}
edit(s, |cx| {
// Formatting on a protected sheet keeps the cell's permission flags: a format call
// that flips `locked` from the format permission would authorize content edits that
// the password should still gate, so those flags may not change (Excel keeps that
// checkbox disabled while the sheet is protected).
const FLAG_GUARD: &str = "You can't change cell protection on a protected sheet. Unprotect it first.";
macro_rules! guard_flags {
($old:expr, $new:expr) => {
if guarded && cx.wb.styles.get($old).protection != cx.wb.styles.get($new).protection {
return Err(EngineError::Other(FLAG_GUARD.into()));
}
};
}
let mut cache: std::collections::HashMap<StyleId, StyleId> = std::collections::HashMap::new();
for r in &ranges {
let full_rows = r.is_full_rows();
@@ -251,6 +264,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
info.and_then(|i| i.style).unwrap_or_default()
};
let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f));
guard_flags!(cur, new);
let sh = cx.sheet_mut(sheet)?;
let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols };
map.entry(line).or_default().style = Some(new);
@@ -260,6 +274,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
for (c, st) in &cells {
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
guard_flags!(*st, new);
cx.sheet_mut(sheet)?.set_style(*c, new);
}
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
@@ -276,6 +291,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
if new != st {
guard_flags!(st, new);
cx.sheet_mut(sheet)?.set_style(c, new);
}
}
+340 -40
View File
@@ -1,7 +1,7 @@
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{CalcMode, DefinedName, Formula};
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
use serde_json::{Value as Json, json};
use super::*;
@@ -32,21 +32,38 @@ pub fn specs() -> Vec<CommandSpec> {
"Define Name…",
["Formulas", "Defined Names"],
None,
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)",
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; a hidden name GridCraft keeps its own data in, `_gc_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
has_doc,
define_name
),
cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name),
cmd!(
"formulas.deleteName",
"Delete Name",
[],
None,
"{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (a hidden name GridCraft keeps its own data in, `_gc_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
has_doc,
delete_name
),
cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager),
cmd!(
"formulas.createFromSelection",
"Create from Selection",
["Formulas", "Defined Names"],
Some("Cmd+Shift+F3"),
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}",
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`), force?: false (labels of the shape GridCraft keeps its own data in, `_gc_<key>_<n>` and `_sc_<key>`, are listed in `skipped` unless force is true)}",
has_doc,
create_from_selection
),
cmd!(
"formulas.pasteName",
"Paste Names…",
["Formulas", "Defined Names"],
Some("F3"),
"{name?: \"Sales\" (one of the names listed; OK: puts the name at the caret of the cell being edited, or starts `=Sales` in the active cell), list?: true (Paste List: writes each name and what it refers to in two columns, from `at`), at?: cell (default the active cell)} → {names: [{name, refersTo}]}: the names the Paste Name dialog lists (workbook names and the active sheet's own, hidden names left out, in alphabetical order). With neither nothing changes; the menu opens the dialog.",
has_doc,
paste_name
),
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
@@ -79,42 +96,73 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
// A block: totals below each column (and to the right when a blank column is included).
// Columns past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), and all totals are one edit (one undo step).
let Some(last_col) = sh.cells.used_range().map(|u| u.end.col.min(sel.end.col)) else { return ok() };
let last_row_blank = (sel.start.col..=last_col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
/// Whether a cell on the active sheet is empty.
fn blank(sh: &gridcraft_model::sheet::Sheet, row: u32, col: u32) -> bool {
sh.value(CellRef::new(row, col)).is_empty()
}
fn sum_formula(func: &str, from: CellRef, to: CellRef) -> String {
format!("={func}({})", RangeRef::new(from, to).a1())
}
/// Totals for a multi-cell selection as `(cell, formula)` writes. `None` when the sheet has no data.
fn selection_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, func: &str) -> Result<Option<Vec<(CellRef, String)>>> {
// A selection: totals go in its empty last row or column, as Excel's do, and otherwise
// past it: to the right of a single row, below each column of anything taller. Columns
// past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), nor do rows past it, and all totals are one edit (one undo step).
let Some(used) = sh.cells.used_range() else { return Ok(None) };
if sel.start.row == sel.end.row { single_row_totals(sh, sel, used, func).map(Some) } else { block_totals(sh, sel, used, func).map(Some) }
}
/// One row: its total goes in its last cell when that is empty, else just after it.
fn single_row_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let row = sel.start.row;
let last_col = used.end.col.min(sel.end.col);
let end_col = if sel.is_full_rows() { last_col } else { sel.end.col };
let (data_end, out) = if end_col > sel.start.col && blank(sh, row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) };
if out >= gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There's no room to the right of the selection for the total.".into()));
}
Ok(vec![(CellRef::new(row, out), sum_formula(func, CellRef::new(row, sel.start.col), CellRef::new(row, data_end)))])
}
/// A block of rows: each column's total below it, and each row's total in an empty last column.
fn block_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let mut writes: Vec<(CellRef, String)> = Vec::new();
let last_col = used.end.col.min(sel.end.col);
// An empty last column takes each row's total; rows past the data get none.
let rows_end = sel.end.row.min(used.end.row);
let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| blank(sh, r, sel.end.col));
let data_end_col = if right_blank { sel.end.col - 1 } else { last_col };
let last_row_blank = (sel.start.col..=last_col).all(|c| blank(sh, sel.end.row, c));
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
// Each column's total, below it: unless only the last column is there to take totals.
if last_row_blank || !right_blank {
if out_row >= gridcraft_core::MAX_ROWS {
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
}
let writes: Vec<(CellRef, String)> = (sel.start.col..=last_col)
.map(|c| {
(CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1()))
})
.collect();
let sheet = d.wb.active_sheet();
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
for c in sel.start.col..=data_end_col.min(last_col) {
writes.push((CellRef::new(out_row, c), sum_formula(func, CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c))));
}
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
if right_blank {
for r in sel.start.row..=data_end_row.min(rows_end) {
writes.push((CellRef::new(r, sel.end.col), sum_formula(func, CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col))));
}
// Both empty: the corner takes the total of the whole block.
if last_row_blank {
writes.push((
CellRef::new(sel.end.row, sel.end.col),
sum_formula(func, CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)),
));
}
}
Ok(writes)
}
/// The range a single cell's AutoSum guesses: the numbers above it, else the numbers to its left.
fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<RangeRef> {
let numeric = |c: CellRef| sh.value(c).is_number();
let mut range = None;
if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) {
@@ -136,7 +184,33 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
}
range = Some(RangeRef::new(CellRef::new(at.row, left), CellRef::new(at.row, at.col - 1)));
}
let text = match range {
range
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
let Some(writes) = selection_totals(sh, sel, &func)? else { return ok() };
let sheet = d.wb.active_sheet();
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
let text = match guessed_range(sh, at) {
Some(r) => format!("={func}({})", r.a1()),
None => {
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={func}()"))));
@@ -268,6 +342,9 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
if !valid_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() {
refuse_stash_name(&s.doc()?.wb, p, n)?;
}
let d = s.doc()?;
let scope = match str_param(p, "scope") {
None | Some("Workbook") => None,
@@ -374,8 +451,29 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
}
}
/// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]):
/// defining, renaming or deleting one would replace or remove part of an item (a stored macro,
/// script or view) and leave the rest behind. `force: true` lets the call go ahead.
/// Whether `name` is one the workbook holds GridCraft's own data in: a hidden workbook-scope
/// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and
/// the name commands may change it.
fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool {
// The stash's own rule, so the two never disagree: a legacy `_sc_` item is read visible or not.
stash::holds_item(wb, name)
}
fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> {
if holds_gridcraft_data(wb, name) && !bool_param(p, "force").unwrap_or(false) {
return Err(EngineError::Other(format!(
"'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway."
)));
}
Ok(())
}
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
refuse_stash_name(&s.doc()?.wb, p, &name)?;
let scope = match str_param(p, "scope") {
None => None,
Some("Workbook") => Some(None),
@@ -461,7 +559,11 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
// Excel asks before replacing an existing name; here it's kept unless `replace` is set, and
// reported.
let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none());
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n));
// A label that names one of GridCraft's own hidden data names would replace part of an item
// (see `refuse_stash_name`).
let force = bool_param(p, "force").unwrap_or(false);
let ours = |n: &str| !force && holds_gridcraft_data(&d.wb, n);
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n)));
let skipped: Vec<String> = skipped.into_iter().map(|(n, _)| n).collect();
let n = made.len();
edit(s, |cx| {
@@ -474,6 +576,61 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// The names Paste Names lists, as (name, refers-to text with `=`): the workbook's names and the
/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the
/// workbook name it shares.
pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> {
let sheet = wb.active_sheet();
let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet));
let local: std::collections::HashSet<String> = shown(true).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect();
for n in shown(false) {
if !local.contains(&gridcraft_formula::fold_name(&n.name)) {
out.push((n.name.clone(), format!("={}", n.formula)));
}
}
out.sort_by_cached_key(|(n, _)| n.to_lowercase());
out
}
fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let names = pasteable_names(&d.wb);
let listed: Vec<Json> = names.iter().map(|(n, r)| json!({"name": n, "refersTo": r})).collect();
if !bool_param(p, "list").unwrap_or(false) {
if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
let Some((listed_name, _)) = names.iter().find(|(n, _)| gridcraft_formula::same_name(n, name)) else {
return Err(EngineError::Other(format!("There's no name \"{name}\" to paste.")));
};
s.ui_requests.push(crate::UiRequest::InsertName(listed_name.clone()));
}
return Ok(json!({"names": listed}));
}
if names.is_empty() {
return Err(EngineError::Other("This workbook has no names to paste.".into()));
}
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let rows = u32::try_from(names.len()).unwrap_or(u32::MAX);
if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into()));
}
let sheet = d.wb.active_sheet();
let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1));
edit(s, |cx| {
for (row, (name, refers)) in (at.row..).zip(&names) {
for (col, text) in [(at.col, name), (at.col + 1, refers)] {
let c = CellRef::new(row, col);
let sh = cx.sheet_mut(sheet)?;
let style = sh.style_id(c);
// The second column holds the refers-to text as a text value, with no formula.
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
cx.touch(sheet, c);
}
}
Ok(json!({"names": listed, "range": range.a1()}))
})
}
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
@@ -628,8 +785,12 @@ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
}
}
}
let circ: Vec<String> =
d.calc.circular.iter().map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| s.name.clone()).unwrap_or_default(), c.a1())).collect();
let circ: Vec<String> = d
.calc
.circular
.iter()
.map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| gridcraft_formula::quote_sheet(&s.name)).unwrap_or_default(), c.a1()))
.collect();
Ok(json!({"errors": out, "circular": circ}))
}
@@ -742,6 +903,57 @@ mod tests {
assert_eq!(r["refersTo"], "=Sheet1!$A$1");
}
#[test]
fn the_name_commands_leave_gridcraft_data_alone() {
// Defining, renaming or deleting a name of a stored item's shape would replace or remove
// one of its chunks and leave the rest behind: refused unless `force` is true.
let mut s = Session::new();
s.new_workbook();
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
let text = "m".repeat(9_000);
stash::set(wb, "macro_1", &text).unwrap();
wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true });
// A legacy item is read whether or not it is hidden, so a visible one is data too.
wb.names.push(DefinedName { name: "_sc_old".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: false });
let names = |s: &Session| s.doc().unwrap().wb.names.clone();
let before = names(&s);
assert!(before.len() >= 3, "the item takes two chunks");
for params in [
json!({"name": "_gc_macro_1_0", "refersTo": "=1"}),
json!({"name": "_SC_Watch", "refersTo": "=1"}),
json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}),
json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}),
json!({"name": "Mine2", "refersTo": "=1", "oldName": "_SC_old"}),
] {
let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string();
assert!(err.contains("force"), "{params}: {err}");
}
for name in ["_gc_macro_1_1", "_sc_watch", "_sc_old"] {
let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string();
assert!(err.contains("force"), "{name}: {err}");
}
s.execute("range.setValues", json!({"range": "A1:B2", "values": [["_gc_macro_1_0", "Rate"], [1, 2]]})).unwrap();
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:B2", "top": true, "replace": true})).unwrap();
assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(1), json!(["_gc_macro_1_0"])));
let after = names(&s);
assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::<Vec<_>>(), before);
assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()));
// A visible name of the shape is the person's own (the stash never reads it): the name
// commands change and remove it without `force`, as Name Manager cannot pass one.
let own = DefinedName { name: "_gc_rate_0".into(), scope: None, formula: "=1".into(), comment: String::new(), hidden: false };
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own.clone());
s.execute("formulas.defineName", json!({"name": "Rate0", "refersTo": "=2", "oldName": "_gc_rate_0"})).unwrap();
s.execute("formulas.deleteName", json!({"name": "Rate0"})).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own);
s.execute("formulas.deleteName", json!({"name": "_gc_rate_0"})).unwrap();
assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()), "the hidden item is untouched");
// Ordinary names and forced calls go ahead.
s.execute("formulas.deleteName", json!({"name": "Rate"})).unwrap();
s.execute("formulas.defineName", json!({"name": "_gc_macro_1_1", "refersTo": "=1", "force": true})).unwrap();
s.execute("formulas.deleteName", json!({"name": "_gc_macro_1_1", "force": true})).unwrap();
assert!(!names(&s).iter().any(|n| n.name == "_gc_macro_1_1"));
}
#[test]
fn arabic_defined_names_count_characters_not_bytes() {
let mut s = Session::new();
@@ -790,4 +1002,92 @@ mod tests {
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
}
/// A workbook with `Sales` and `rate`, a hidden name, a name of Sheet1 that hides a workbook
/// `Tax`, and a name of another sheet.
fn named_workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap();
s.execute("formulas.defineName", json!({"name": "rate", "refersTo": "=0.07"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2"})).unwrap();
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
let name = |name: &str, scope, formula: &str, hidden| DefinedName {
name: name.into(),
scope,
formula: formula.into(),
comment: String::new(),
hidden,
};
wb.names.push(name("_Filter", None, "Sheet1!$A$1:$B$9", true));
wb.names.push(name("TAX", Some(0), "0.25", false));
wb.names.push(name("Other", Some(1), "1", false));
s
}
fn text_at(s: &Session, cell: &str) -> Value {
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
}
#[test]
fn paste_names_lists_the_names_the_active_sheet_can_use() {
let mut s = named_workbook();
let r = s.execute("formulas.pasteName", json!({})).unwrap();
assert_eq!(
r["names"],
json!([
{"name": "rate", "refersTo": "=0.07"},
{"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"},
{"name": "TAX", "refersTo": "=0.25"},
])
);
assert_eq!(text_at(&s, "A1"), Value::Empty, "listing the names writes nothing");
}
#[test]
fn paste_names_and_use_in_formula_ask_the_ui_to_insert_the_name() {
let mut s = named_workbook();
s.take_ui_requests();
s.execute("formulas.pasteName", json!({"name": "Sales"})).unwrap();
s.execute("formulas.useInFormula", json!({"name": "rate"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into()), crate::UiRequest::InsertName("rate".into())]);
assert_eq!(text_at(&s, "A1"), Value::Empty, "the cell changes only when the edit is entered");
// A name Paste Name doesn't list is refused; one typed in another case is pasted as listed.
assert!(s.execute("formulas.pasteName", json!({"name": "NoSuchName"})).is_err());
s.execute("formulas.pasteName", json!({"name": "SALES"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into())]);
}
#[test]
fn paste_list_writes_each_name_and_what_it_refers_to() {
let mut s = named_workbook();
s.execute("selection.set", json!({"range": "D2"})).unwrap();
let r = s.execute("formulas.pasteName", json!({"list": true})).unwrap();
assert_eq!(r["range"], json!("D2:E4"));
let rows = [("D2", "rate"), ("E2", "=0.07"), ("D3", "Sales"), ("E3", "=Sheet1!$A$1:$A$3"), ("D4", "TAX"), ("E4", "=0.25")];
for (cell, text) in rows {
assert_eq!(text_at(&s, cell), Value::text(text), "{cell}");
}
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("E3").unwrap()).is_some_and(|c| c.formula.is_none()), "refers-to is text");
s.execute("edit.undo", json!({})).unwrap();
for (cell, _) in rows {
assert_eq!(text_at(&s, cell), Value::Empty, "{cell} after Undo");
}
s.execute("formulas.pasteName", json!({"list": true, "at": "B7"})).unwrap();
assert_eq!(text_at(&s, "B7"), Value::text("rate"));
}
#[test]
fn paste_list_needs_names_and_room() {
let mut s = named_workbook();
let last = format!("A{}", gridcraft_core::MAX_ROWS - 1);
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last})).is_err(), "three rows don't fit below the second-to-last row");
let last_col = format!("{}1", gridcraft_core::col_to_letters(gridcraft_core::MAX_COLS - 1));
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last_col})).is_err(), "two columns don't fit in the last column");
let mut empty = Session::new();
empty.new_workbook();
assert!(empty.execute("formulas.pasteName", json!({"list": true})).is_err());
assert_eq!(empty.execute("formulas.pasteName", json!({})).unwrap()["names"], json!([]));
}
}
+32 -5
View File
@@ -26,9 +26,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Format as Table",
["Home", "Styles"],
None,
"{range?, style: \"TableStyleMedium2\", header?}",
"{range?, style: \"TableStyleMedium2\", header?} (inside a table: restyles it)",
has_doc,
insert_table
format_as_table
),
cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag(
s, p, "totals"
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Chart Properties",
["Chart Design"],
None,
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
has_doc,
chart_set
),
@@ -351,6 +351,21 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one.
fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
// Without a range, the active area decides (as in insert_table), not the first one.
let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() };
let sheet = target_sheet(s, p)?;
let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range));
match inside {
Some((table, range)) => {
table_style(s, &with(p, "table", json!(table)))?;
Ok(json!({"table": table, "range": range.a1()}))
}
None => insert_table(s, p),
}
}
fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
if let Some(n) = str_param(p, "table") {
@@ -567,6 +582,7 @@ pub fn chart_kind(t: &str, sub: &str) -> ChartKind {
("pie", _) => ChartKind::Pie,
("doughnut", _) => ChartKind::Doughnut,
("area", "stacked") => ChartKind::AreaStacked,
("area", "stacked100") => ChartKind::AreaStacked100,
("area", _) => ChartKind::Area,
("scatter", "lines") => ChartKind::ScatterLines,
("scatter", _) => ChartKind::Scatter,
@@ -1301,9 +1317,20 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
let named = str_param(p, "author").map(str::to_string);
let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
cx.sheet_mut(sheet)?.comments.insert(
let comments = &mut cx.sheet_mut(sheet)?.comments;
// Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the
// replies, the dates and who wrote it stay. A note replaces a comment and vice versa.
if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) {
c.text = text.clone();
if let Some(author) = named.clone() {
c.author = author;
}
return Ok(json!({"cell": at.a1()}));
}
comments.insert(
at,
Comment {
author: author.clone(),
+8 -5
View File
@@ -7,11 +7,11 @@ use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, history", has_doc, inspect),
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect),
cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read),
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get),
cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats),
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels", has_doc, history),
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history),
cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands),
]
}
@@ -63,6 +63,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
"title": d.display_title(),
"path": d.path,
"dirty": d.is_dirty(),
"trust": d.trust,
"activeSheet": wb.active_sheet(),
"sheets": sheets,
"selection": d.selection.a1(),
@@ -148,9 +149,11 @@ fn sel_stats(s: &mut Session, _: &Json) -> Result<Json> {
fn history(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
Ok(
json!({"undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(), "redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>()}),
)
Ok(json!({
"undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(),
"redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(),
"grouping": s.in_group(d.uid),
}))
}
fn list_commands(s: &mut Session, p: &Json) -> Result<Json> {
+33 -2
View File
@@ -19,6 +19,7 @@ pub mod sheet;
pub mod slicer;
pub mod solver;
pub mod spelling;
pub mod stash;
pub mod view;
pub mod whatif;
@@ -96,12 +97,19 @@ pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
}
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
not_grouping(s)?;
if s.active().is_some_and(|d| !d.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
}
pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
not_grouping(s)?;
if s.active().is_some_and(|d| !d.redo.is_empty()) { Ok(()) } else { Err("nothing to redo".into()) }
}
/// Undo and Redo wait while an undo group is open on the active document (an action that spans
/// several calls is still running).
fn not_grouping(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) }
}
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.clipboard.is_some() { Ok(()) } else { Err("the clipboard is empty".into()) }
@@ -157,7 +165,7 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
test_commands().iter().find(|c| c.id == id).copied()
}
static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
static TEST_COMMANDS: std::sync::Mutex<Vec<&CommandSpec>> = std::sync::Mutex::new(Vec::new());
/// Test-only: register an extra command so UI integration tests can prove a pending
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
@@ -179,6 +187,23 @@ pub fn register_test_command(id: &'static str, run: Run) {
test_commands().push(spec);
}
/// Test-only: an undoable command, to exercise how `execute` records its step.
#[cfg(test)]
pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
id,
label: id,
menu: &[],
shortcut: None,
params: "",
enabled: |_| Ok(()),
run,
journal: false,
undoable: true,
}));
test_commands().push(spec);
}
fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
#[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery
match TEST_COMMANDS.lock() {
@@ -527,9 +552,15 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
return;
}
let mut need: f32 = default;
// The cell loop must not rescan every merge per cell: the merges touching this row are
// picked out once, and per cell the candidates are re-checked (normally none or a couple;
// a sheet with many merges and many saved columns would otherwise scan all of them per
// cell, rows × merges × columns).
let row_merges: Vec<gridcraft_core::RangeRef> =
sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect();
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {
continue;
}
let st = wb.styles.get(cell.style);
+338 -11
View File
@@ -179,6 +179,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
change_source
),
cmd!(
"pivot.showDetails",
"Show Details",
[],
None,
"{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}. An error when the PivotTable's Enable show details option is off (`pivot.options` `{enableShowDetails: true}` turns it on).",
has_doc,
show_details
),
cmd!(
"pivot.options",
"PivotTable Options",
["PivotTable Analyze", "PivotTable"],
None,
"{pivot?, enableShowDetails?: bool (Data › Enable show details: off, Show Details and double-clicking a value cell list no records)} → {enableShowDetails}",
has_doc,
options
),
cmd!(
"pivot.delete",
"Delete PivotTable",
@@ -235,7 +253,7 @@ fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
}
fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
match v.map(|x| x.to_ascii_lowercase()) {
match v.map(str::to_ascii_lowercase) {
Some(a) if a == "rows" || a == "row" => Ok("rows"),
Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"),
Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"),
@@ -654,16 +672,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
let (si, anchor) = match target {
Some(t) => t,
None => {
let at = cx.wb.active_sheet().min(cx.wb.sheets.len());
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
let at = insert_sheet_before_active(cx);
new_sheet_at = Some(at);
(at, CellRef::new(2, 0))
}
@@ -726,6 +735,135 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
Ok(res)
}
/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a
/// Show Details sheet); returns its index.
fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
let at = cx.wb.active_sheet().min(cx.wb.sheets.len());
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
at
}
/// Show Details (double-click a value cell): the source records behind it, as values with their
/// column's number format, under the source headers on a new sheet.
fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let active = d.wb.active_sheet();
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let pt = sh
.pivots
.iter()
.find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))
.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col));
// With the option off, a report that is out of date or a source that cannot be read is
// reported as the option; only a cell that is not a value cell keeps its own message.
if !pt.enable_show_details && !matches!(found, Ok(None)) {
return Err(other(SHOW_DETAILS_OFF));
}
let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
if d.wb.protected_structure {
return Err(other("The workbook is protected and cannot be changed."));
}
let count = records.len();
let mut new_sheet_at: Option<usize> = None;
let res = edit(s, |cx| {
let si = insert_sheet_before_active(cx);
new_sheet_at = Some(si);
let base = cx.wb.styles.get(StyleId::default()).clone();
let fmts: Vec<StyleId> = src
.formats
.iter()
.map(|f| {
let mut st = base.clone();
st.num_fmt = NumFmt::new(f);
cx.wb.styles.intern(st)
})
.collect();
// Columns wide enough for their text, as the PivotTable report's are (dates would show
// as ####); the first rows are enough to judge a column.
let font = base.font.clone();
let widths: Vec<f32> = src
.headers
.iter()
.enumerate()
.map(|(c, h)| {
let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General");
let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c)));
let widest = texts
.map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false))
.fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max);
(widest + 14.0).min(320.0)
})
.collect();
let sh = cx.sheet_mut(si)?;
for (c, w) in widths.into_iter().enumerate() {
if w > sh.col_width(c as u32) {
sh.cols.entry(c as u32).or_default().size = Some(w);
}
}
for (c, h) in src.headers.iter().enumerate() {
sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() });
}
for (r, i) in records.iter().enumerate() {
let Some(rec) = src.rows.get(*i) else { continue };
for (c, v) in rec.iter().enumerate() {
let style = fmts.get(c).copied().unwrap_or_default();
if v.is_empty() && style == StyleId::default() {
continue;
}
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style });
}
}
let name = sh.name.clone();
cx.wb.set_active_sheet(si);
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(json!({"sheet": name, "records": count}))
})?;
if let Some(at) = new_sheet_at
&& let Ok(d) = s.doc_mut()
{
d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default());
}
Ok(res)
}
/// The message Show Details gives while Enable show details is off.
const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable.";
/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not
/// refreshed. Without params it returns the current setting.
fn options(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = find_pivot(s, p)?;
let enable = bool_param(p, "enableShowDetails");
if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() {
return Err(bad("pivot.options", "`enableShowDetails` must be true or false"));
}
let current = |s: &Session| -> Result<Json> {
let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?;
Ok(json!({"enableShowDetails": pt.enable_show_details}))
};
let Some(on) = enable else { return current(s) };
if current(s)?["enableShowDetails"] == json!(on) {
return current(s);
}
edit(s, |cx| {
let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?;
pt.enable_show_details = on;
Ok(json!({"enableShowDetails": on}))
})
}
fn summarize_table(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let name = match str_param(p, "table") {
@@ -763,6 +901,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
"valuesOnRows": pt.values_on_rows,
"valuesPosition": pt.values_position,
"enableShowDetails": pt.enable_show_details,
})
}
@@ -2236,6 +2375,8 @@ mod tests {
"data.refreshAll",
"pivot.changeSource",
"pivot.delete",
"pivot.showDetails",
"pivot.options",
];
let hostile = [
json!({}),
@@ -2363,4 +2504,190 @@ mod tests {
assert_eq!(l[0]["anchor"], "A3");
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
}
/// The active sheet's first five columns as shown, down to the first blank row.
fn sheet_rows(s: &mut Session) -> Vec<Vec<String>> {
let mut out = Vec::new();
for r in 0..30u32 {
let row: Vec<String> = (0..5)
.map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string())
.collect();
if row.iter().all(String::is_empty) {
break;
}
out.push(row);
}
out
}
#[test]
fn show_details_lists_a_value_cells_records_on_a_new_sheet() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// B5 is East × Apples (100 + 60); the active cell is the default.
s.execute("selection.set", json!({"range": "B5"})).unwrap();
let r = s.execute("pivot.showDetails", json!({})).unwrap();
assert_eq!(r["records"], 2);
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets.len(), 3);
assert_eq!(d.wb.active_sheet(), 0, "the new sheet goes before the PivotTable's");
assert_eq!(d.wb.active().unwrap().name, "Sheet3");
assert_eq!(
sheet_rows(&mut s),
vec![
vec!["Region", "Product", "Date", "Units", "Sales"],
vec!["East", "Apples", "1/15/2026", "10", "100"],
vec!["East", "Apples", "9/9/2026", "6", "60"],
]
);
// The dates fit their column instead of showing ####.
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5));
// One undo step takes the sheet away.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
assert_eq!(val(&s, "B5"), Value::Number(160.0));
}
#[test]
fn show_details_on_a_total_takes_every_record_it_adds_up() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
let records = |s: &mut Session, cell: &str| {
let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap();
s.execute("edit.undo", json!({})).unwrap();
n
};
assert_eq!(records(&mut s, "E5"), 5, "East's row total");
assert_eq!(records(&mut s, "C8"), 4, "Pears' column total");
assert_eq!(records(&mut s, "E8"), 12, "the grand total");
// A subtotal at the top of its group, in a two-level row axis.
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
assert_eq!(text(&s, "A4"), "East");
assert_eq!(records(&mut s, "B4"), 5);
assert_eq!(text(&s, "A6"), "Pears");
assert_eq!(records(&mut s, "B6"), 2);
}
#[test]
fn show_details_leaves_out_the_records_the_report_filters_out() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap();
s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap();
s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap();
let gt = find_row(&s, "Grand Total").unwrap();
let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap();
assert_eq!(r["records"], 2);
let rows = sheet_rows(&mut s);
assert_eq!(rows.len(), 3);
assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}");
}
#[test]
fn show_details_refuses_a_report_its_source_has_moved_on_from() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// B5 still shows East × Apples (160), but the first East record is now Central's.
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
assert_eq!(val(&s, "B5"), Value::Number(160.0));
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
assert!(e.contains("out of date"), "{e}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
// After a refresh East × Apples (now B6) has the one record left.
s.execute("pivot.refresh", json!({})).unwrap();
assert_eq!(text(&s, "A6"), "East");
assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1);
}
#[test]
fn show_details_refuses_a_report_that_would_now_be_shorter() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// North goes away: the source now gives one row fewer, so row 8 is no longer the Grand Total.
s.execute("range.setValues", json!({"range": "Sheet1!A5", "values": [["East"]]})).unwrap();
s.execute("range.setValues", json!({"range": "Sheet1!A8", "values": [["East"]]})).unwrap();
s.execute("range.setValues", json!({"range": "Sheet1!A11", "values": [["East"]]})).unwrap();
assert_eq!(text(&s, "A8"), "Grand Total");
let e = s.execute("pivot.showDetails", json!({"cell": "E8"})).unwrap_err().to_string();
assert!(e.contains("out of date"), "{e}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
}
#[test]
fn show_details_needs_a_value_cell() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] {
assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}");
}
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
}
#[test]
fn enable_show_details_off_stops_show_details() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true);
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
assert!(!pt(&s).enable_show_details);
assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false);
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
assert_eq!(e, "Show Details is turned off for this PivotTable.");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
// A label still reports that it is not a value cell.
let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string();
assert!(!e.contains("turned off"), "{e}");
// Setting the same value again adds no undo step.
let after = s.doc().unwrap().revision;
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
assert_eq!(s.doc().unwrap().revision, after);
assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err());
// One undo turns the option back on.
s.execute("edit.undo", json!({})).unwrap();
assert!(pt(&s).enable_show_details);
assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2);
}
#[test]
fn enable_show_details_off_is_reported_before_an_out_of_date_report() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
// The source changes after the report was written, so the report is out of date.
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
assert!(e.contains("turned off") && !e.contains("out of date"), "{e}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
}
#[test]
fn detail_cells_are_the_cells_show_details_takes() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
for layout in 0..2 {
if layout == 1 {
// A two-level row axis, with subtotals at the top of each group.
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
}
let d = s.doc().unwrap();
let si = d.wb.active_sheet();
let pt = &d.wb.active().unwrap().pivots[0];
let r = pt.last_range.unwrap();
let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap();
let mut values = 0;
for row in 0..r.height() {
for col in 0..r.width() {
let one = crate::pivot::detail_records(&d.wb, pt, si, row, col).unwrap();
assert_eq!(cells.contains(&(row, col)), one.is_some(), "layout {layout}, cell ({row}, {col})");
values += usize::from(one.is_some());
}
}
assert!(values > 4, "layout {layout} has value cells");
}
// An out-of-date report answers as Show Details does.
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
let d = s.doc().unwrap();
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"));
}
}
+175 -94
View File
@@ -139,8 +139,26 @@ impl SheetLayout {
let [l, r, t, b, ..] = self.margins;
((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0))
}
/// Left and right margins, in points, that header and footer sections line up with: the
/// page's side margins with Align with Page Margins on, otherwise 0.7 inch on each side, at
/// most a quarter of the page width.
pub(crate) fn hf_sides(&self) -> (f32, f32) {
if self.settings.hf_align_with_margins {
(self.margins[0], self.margins[1])
} else {
let side = HF_FIXED_SIDE_MARGIN.min(self.page_w / 4.0);
(side, side)
}
}
/// Scale of header and footer text: the print scale with Scale with Document on, otherwise 1.
pub(crate) fn hf_text_scale(&self) -> f32 {
if self.settings.hf_scale_with_doc { self.scale } else { 1.0 }
}
}
/// Side margin of headers and footers, in points, when Align with Page Margins is off.
const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0;
/// Options shared by the print commands.
#[derive(Clone, Debug, Default)]
pub struct PrintOptions {
@@ -599,7 +617,7 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
i += rest.chars().count() + 1;
}
d if d.is_ascii_digit() => {
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(|c| c.is_ascii_digit())).take(3).collect();
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect();
i += digits.len() - 1;
if let Ok(v) = digits.parse::<f32>() {
sec.size = Some(v.clamp(1.0, 409.0));
@@ -784,22 +802,27 @@ fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
out.push(std::mem::take(&mut line));
line = word.to_string();
}
// Break a single over-long word by characters.
// 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 mut cut = String::new();
let mut rest = String::new();
for ch in line.chars() {
if rest.is_empty() && gridcraft_pdf::text_width(font, size, &format!("{cut}{ch}")) <= width || cut.is_empty() {
cut.push(ch);
let chars: Vec<char> = 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 {
rest.push(ch);
hi = mid - 1;
}
}
if rest.is_empty() {
if lo == chars.len() {
break;
}
out.push(cut);
line = rest;
out.push(chars[..lo].iter().collect());
line = chars[lo..].iter().collect();
}
if out.len() > 2000 {
return out;
@@ -1306,7 +1329,16 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u3
}
/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page.
fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) {
/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each
/// picture again, but the decode happens once).
fn draw_objects(
pg: &mut Page,
wb: &Workbook,
si: usize,
sh: &Sheet,
b: &Block,
pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>,
) {
let block = b.range();
let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row));
let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1)));
@@ -1316,6 +1348,42 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block)
let place = |r: (f64, f64, f64, f64)| (bx + ((r.0 - sx0) as f32) * b.k, by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k);
pg.save();
pg.clip_rect(bx, by, bw, bh);
draw_sparklines(pg, wb, si, sh, b, block);
for (imi, im) in sh.images.iter().enumerate() {
let r = anchor_rect(sh, &im.anchor);
if !visible(r) {
continue;
}
let (x, y, w, h) = place(r);
// About 150 dpi is plenty for print.
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
if let Some((px, pw, ph)) = decoded.as_ref() {
pg.image_rgba(x, y, w, h, px, *pw, *ph);
}
}
for shp in &sh.shapes {
let r = anchor_rect(sh, &shp.anchor);
if !visible(r) {
continue;
}
let (x, y, w, h) = place(r);
draw_shape(pg, wb, shp, b, (x, y, w, h));
}
for ch in &sh.charts {
let r = anchor_rect(sh, &ch.anchor);
if !visible(r) {
continue;
}
let (x, y, _, _) = place(r);
let data = gridcraft_chart::resolve(wb, si, ch);
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
}
pg.restore();
}
fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, block: RangeRef) {
for sp in &sh.sparklines {
if !block.contains(sp.cell) {
continue;
@@ -1330,90 +1398,64 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block)
let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx);
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
}
for im in &sh.images {
let r = anchor_rect(sh, &im.anchor);
if !visible(r) {
continue;
}
fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Block, (x, y, w, h): (f32, f32, f32, f32)) {
let fill = rgb_of(shp.fill, wb);
let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25)));
match shp.kind {
ShapeKind::Ellipse => {
let pts: Vec<[f32; 2]> = (0..72)
.map(|i| {
let a = i as f32 / 72.0 * std::f32::consts::TAU;
[x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()]
})
.collect();
pg.polygon(&pts, fill, line);
}
let (x, y, w, h) = place(r);
// About 150 dpi is plenty for print.
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
if let Some((px, pw, ph)) = decode_image(&im.data, mw, mh) {
pg.image_rgba(x, y, w, h, &px, pw, ph);
ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line),
ShapeKind::Line | ShapeKind::Arrow => {
let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]);
pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c);
if shp.kind == ShapeKind::Arrow {
let ang = h.atan2(w);
let al = 8.0 * b.k;
let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()];
pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None);
}
}
_ => {
pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line);
}
}
for shp in &sh.shapes {
let r = anchor_rect(sh, &shp.anchor);
if !visible(r) {
continue;
}
let (x, y, w, h) = place(r);
let fill = rgb_of(shp.fill, wb);
let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25)));
match shp.kind {
ShapeKind::Ellipse => {
let pts: Vec<[f32; 2]> = (0..72)
.map(|i| {
let a = i as f32 / 72.0 * std::f32::consts::TAU;
[x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()]
})
.collect();
pg.polygon(&pts, fill, line);
}
ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line),
ShapeKind::Line | ShapeKind::Arrow => {
let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]);
pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c);
if shp.kind == ShapeKind::Arrow {
let ang = h.atan2(w);
let al = 8.0 * b.k;
let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()];
pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None);
}
}
_ => {
pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line);
}
}
if !shp.text.is_empty() {
use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor};
let fm = &shp.format;
let text_box = shp.kind == ShapeKind::TextBox;
let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale;
let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic);
let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]);
let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size));
let block_h = lines.len().min(500) as f32 * size * 1.2;
let mut yy = match anchor {
ShapeTextAnchor::Top => y + 2.0 * b.k,
ShapeTextAnchor::Middle => y + (h - block_h) / 2.0,
ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h,
} + size * 0.8;
for l in lines.iter().take(500) {
let lw = gridcraft_pdf::text_width(font, size, l);
let lx = match align {
ShapeTextAlign::Left => x + 2.0 * b.k,
ShapeTextAlign::Center => x + (w - lw) / 2.0,
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
};
pg.text(lx, yy, size, font, color, l);
yy += size * 1.2;
}
}
if shp.text.is_empty() {
return;
}
for ch in &sh.charts {
let r = anchor_rect(sh, &ch.anchor);
if !visible(r) {
continue;
}
let (x, y, _, _) = place(r);
let data = gridcraft_chart::resolve(wb, si, ch);
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor};
let fm = &shp.format;
let text_box = shp.kind == ShapeKind::TextBox;
let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale;
let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic);
let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]);
let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size));
let block_h = lines.len().min(500) as f32 * size * 1.2;
let mut yy = match anchor {
ShapeTextAnchor::Top => y + 2.0 * b.k,
ShapeTextAnchor::Middle => y + (h - block_h) / 2.0,
ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h,
} + size * 0.8;
for l in lines.iter().take(500) {
let lw = gridcraft_pdf::text_width(font, size, l);
let lx = match align {
ShapeTextAlign::Left => x + 2.0 * b.k,
ShapeTextAlign::Center => x + (w - lw) / 2.0,
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
};
pg.text(lx, yy, size, font, color, l);
yy += size * 1.2;
}
pg.restore();
}
fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: (f32, f32), size: (f32, f32)) {
@@ -1452,7 +1494,9 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at:
}
fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) {
let [l, r, _, _, hm, fm] = lay.margins;
let [_, _, _, _, hm, fm] = lay.margins;
let (l, r) = lay.hf_sides();
let text_scale = lay.hf_text_scale();
let pw = lay.page_w;
let ph = lay.page_h;
for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] {
@@ -1464,7 +1508,7 @@ fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::
if sec.text.trim().is_empty() {
continue;
}
let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0);
let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0) * text_scale;
let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic);
let lines: Vec<&str> = sec.text.split('\n').take(20).collect();
let n = lines.len() as f32;
@@ -1528,6 +1572,9 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let mut page_no = 0usize;
let mut pending_links = Vec::new();
let mut printed_blocks = Vec::new();
// Decoded pictures are reused across the pages their anchor covers: a floating picture
// spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
let mut pics: std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>> = std::collections::HashMap::new();
for lay in layouts {
let Some(sh) = wb.sheet(lay.sheet) else { continue };
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
@@ -1564,7 +1611,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
y += seg_size(sh, *rs, true) * k;
}
if let Some(b) = &body {
draw_objects(pg, wb, lay.sheet, sh, b);
draw_objects(pg, wb, lay.sheet, sh, b, &mut pics);
}
if lay.settings.headings {
let fill = [0xF2, 0xF2, 0xF2];
@@ -1994,6 +2041,40 @@ mod tests {
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
}
/// The x position and font size of the first `(text) Tj` in a PDF.
fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> {
let s = String::from_utf8_lossy(pdf);
let before = &s[..s.find(&format!("({text}) Tj"))?];
let tf = before.rfind(" Tf")?;
let size = before[..tf].rsplit(' ').next()?.parse().ok()?;
let tm = before.rfind(" Tm")?;
let mut nums = before[..tm].rsplit([' ', '\n']);
let _y = nums.next()?;
let x = nums.next()?.parse().ok()?;
Some((x, size))
}
#[test]
fn headers_scale_with_the_sheet_and_align_with_its_margins_unless_turned_off() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
s.execute("pageLayout.pageSetup", json!({"scale": 50, "left": 1.5, "right": 1.5})).unwrap();
s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&C&14Middle"})).unwrap();
// Both on (the default): the text prints at half size, from the 1.5-inch left margin.
let (pdf, _) = pdf_of(&mut s, json!({}));
assert_eq!(text_at(&pdf, "Left"), Some((108.0, 5.0)));
assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(7.0));
let r = s.execute("pageLayout.headerFooter", json!({"scaleWithDoc": false, "alignWithMargins": false})).unwrap();
assert_eq!((&r["scaleWithDoc"], &r["alignWithMargins"]), (&json!(false), &json!(false)));
// Both off: full size, 0.7 inch from the paper's left edge.
let (pdf, _) = pdf_of(&mut s, json!({}));
assert_eq!(text_at(&pdf, "Left"), Some((50.4, 10.0)));
assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(14.0));
s.execute("edit.undo", json!({})).unwrap();
let pr = &s.doc().unwrap().wb.sheet(0).unwrap().print;
assert!(pr.hf_scale_with_doc && pr.hf_align_with_margins);
}
#[test]
fn pdf_prints_cjk_text_instead_of_question_marks() {
let mut s = s();
+44 -24
View File
@@ -7,7 +7,10 @@ use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, Ctx, EngineError, PROTECTED, Result, Session, bad, bool_param, cell_param_on, cmd, edit, has_clipboard, has_doc, ok, parse_lines,
str_param, target_range, target_sheet, u32_param,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -203,11 +206,13 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
for (o, a) in sh.shapes.iter_mut().zip(new_shapes) {
o.anchor = a;
}
for sp in sh.sparklines.iter_mut() {
if let Some(c) = map_cell(sp.cell) {
sh.sparklines.retain_mut(|sp| match map_cell(sp.cell) {
Some(c) => {
sp.cell = c;
true
}
}
None => false,
});
if let Some(pa) = sh.print.print_area {
sh.print.print_area = map_range(pa);
}
@@ -270,6 +275,29 @@ fn cancel_copy_mode(s: &mut Session) {
}
}
fn inherit_line_format(sh: &mut Sheet, axis: Axis, at: u32, count: u32) {
if at == 0 {
return;
}
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
if let Some(info) = prev {
for i in at..at + count {
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
}
}
}
fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u32, limit: u32) -> Result<()> {
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
}
}
Ok(())
}
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?;
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
@@ -279,13 +307,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
}
check_lines_protection(s, p, axis, at, count, true)?;
let sheet = target_sheet(s, p)?;
// Refuse to push data off the sheet.
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
}
}
check_push_off_sheet(s, sheet, axis, at, count, limit)?;
let out = edit(s, |cx| {
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
cx.protection_checked = true;
@@ -297,15 +319,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
}
shift_sheet_features(sh, axis, at, count, true);
// New lines take the format of the line before (Excel's "Format Same As Above").
if at > 0 {
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
if let Some(info) = prev {
for i in at..at + count {
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
}
}
}
inherit_line_format(sh, axis, at, count);
}
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
cx.structural = true;
@@ -401,8 +415,10 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
{
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
}
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?;
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?;
// 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)?;
let moved_from = if before <= from { from + count } else { from };
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
@@ -586,11 +602,15 @@ fn insert_copied_cells(s: &mut Session, p: &Json) -> Result<Json> {
}
let revision = d.revision;
let before = (d.wb.clone(), d.selection.clone());
// Inside an undo group the nested steps add no undo entries of their own.
let nested_entries = !s.in_group(d.uid);
let result = insert_copied_steps(s, &clip, sheet, at, shift, cut);
let d = s.doc_mut()?;
// The nested steps collapse into this command's single undo step.
for _ in 0..d.revision.saturating_sub(revision) {
d.undo.pop();
if nested_entries {
for _ in 0..d.revision.saturating_sub(revision) {
d.undo.pop();
}
}
match result {
Ok((final_range, out)) => {
+24 -24
View File
@@ -28,8 +28,10 @@ use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, DefinedName, Sheet, Workbook};
use serde_json::{Value as Json, json};
use super::check_arrays;
use super::whatif::Trial;
use super::*;
use super::{CommandSpec, Ctx, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, str_param};
use gridcraft_core::split_sheet;
const CMD: &str = "data.solver";
const REPORT_CMD: &str = "data.solverReport";
@@ -1705,6 +1707,26 @@ fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
3
}
fn format_constraint_row(sh: &Sheet, c: &Constraint, cell: CellRef, v: f64, rhs: &[f64], k: usize) -> Vec<Value> {
let (formula, status, slack) = match (c.rel, &c.rhs) {
(Rel::Int, _) => (format!("{}=Integer", abs_ref(cell)), "Binding", 0.0),
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(cell)), "Binding", 0.0),
(rel, rhs_cells) => {
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
let rhs_text = match rhs_cells {
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
_ => gridcraft_core::number_to_text(b),
};
let slack = (v - b).abs();
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
let slack = if binding { 0.0 } else { slack };
(format!("{}{}{rhs_text}", abs_ref(cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
}
};
let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) };
vec![Value::text(abs_ref(cell)), Value::text(cell_name(sh, cell)), num(v), Value::text(formula), Value::text(status), num(slack)]
}
fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
let m = inp.model;
let mut trial = Trial::new(d);
@@ -1768,29 +1790,7 @@ fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
Rhs::None => vec![],
};
for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() {
let (formula, status, slack) = match (c.rel, &c.rhs) {
(Rel::Int, _) => (format!("{}=Integer", abs_ref(*cell)), "Binding", 0.0),
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(*cell)), "Binding", 0.0),
(rel, rhs_cells) => {
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
let rhs_text = match rhs_cells {
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
_ => gridcraft_core::number_to_text(b),
};
let slack = (v - b).abs();
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
let slack = if binding { 0.0 } else { slack };
(format!("{}{}{rhs_text}", abs_ref(*cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
}
};
rows.push(vec![
Value::text(abs_ref(*cell)),
Value::text(cell_name(sh, *cell)),
num(*v),
Value::text(formula),
Value::text(status),
num(slack),
]);
rows.push(format_constraint_row(sh, c, *cell, *v, &rhs, k));
}
}
table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows);
+606
View File
@@ -0,0 +1,606 @@
//! GridCraft data kept in hidden workbook-scope defined names, so that it travels inside the xlsx
//! file.
//!
//! An item `key` is stored as names `_gc_<key>_0`, `_gc_<key>_1`, …, each a formula of string
//! literals joined with `&`: `"…"&"…"`. Excel limits a string literal to 255 characters and a
//! formula to 8,192, so a long value is split over as many names as it needs and the file stays
//! one that Excel accepts. Older items (custom views, scripts, the watch list and scenarios) keep
//! one name each, `_sc_<key>`; reading accepts that form as well, so those items can move to this
//! format later, and a write of the same key replaces it.
//!
//! XML can't carry every character (controls other than tab, line feed and carriage return, and
//! U+FFFE and U+FFFF), and the xlsx writer drops those. In `_gc_` items each of them, and the
//! marker itself, is written as [`ESCAPE`] followed by its code as four upper-case hex digits
//! (`U+0001` becomes `\u{FDD0}0001`), and reading turns them back. The marker is a Unicode
//! noncharacter, which XML carries but real text does not use, so the escape costs nothing on
//! ordinary text. Legacy `_sc_` items are read as they are.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use gridcraft_model::{DefinedName, Workbook};
use serde_json::Value as Json;
use crate::{EngineError, Result};
const PREFIX: &str = "_gc_";
const LEGACY_PREFIX: &str = "_sc_";
/// Characters (UTF-16 units, as Excel counts them) in one string literal.
const LITERAL_UNITS: usize = 255;
/// Characters in one name's formula.
const FORMULA_UNITS: usize = 8_192;
/// The longest key: `_gc_<key>_<k>` stays well inside Excel's 255-character names.
const MAX_KEY: usize = 200;
/// The longest item: 4,194,304 characters.
pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024;
/// The most bytes the stored (escaped) form of an item can take: an escaped one-byte character
/// takes seven (the three-byte marker and four digits).
const MAX_STORED_BYTES: usize = MAX_ITEM_CHARS * 7;
/// Starts an escaped character in a `_gc_` item: U+FDD0, a noncharacter.
pub const ESCAPE: char = '\u{FDD0}';
/// The most items [`items`] and [`keys`] return: the first this many keys in sorted order. A
/// file can carry any number of hidden names; the rest are not listed.
pub const MAX_ITEMS: usize = 10_000;
/// The names that hold one item: its `_gc_` chunks by number (the first of each number) and its
/// legacy `_sc_` name.
#[derive(Default)]
struct Parts<'a> {
chunks: BTreeMap<u64, &'a str>,
legacy: Option<&'a str>,
}
impl<'a> Parts<'a> {
fn add(&mut self, chunk: Option<u64>, formula: &'a str) {
match chunk {
Some(i) => {
self.chunks.entry(i).or_insert(formula);
}
None => self.legacy = self.legacy.or(Some(formula)),
}
}
/// Whether the names make an item: a later chunk on its own does not.
fn is_item(&self) -> bool {
self.chunks.contains_key(&0) || self.legacy.is_some()
}
}
#[cfg(test)]
thread_local! {
/// Names [`group`] has looked at on this thread, so a test can bound the work of a listing.
static NAME_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The workbook's items whose lower-cased key starts with `prefix` (lower-cased), with the names
/// that hold them, in one pass over the names.
fn group<'a>(wb: &'a Workbook, prefix: &str) -> BTreeMap<String, Parts<'a>> {
let prefix = prefix.to_ascii_lowercase();
let mut items: BTreeMap<String, Parts<'a>> = BTreeMap::new();
for n in wb.names.iter() {
#[cfg(test)]
NAME_VISITS.with(|v| v.set(v.get().saturating_add(1)));
let Some((k, chunk)) = item_of(n) else { continue };
let k = k.to_ascii_lowercase();
if k.starts_with(&prefix) {
items.entry(k).or_default().add(chunk, &n.formula);
}
}
items
}
/// The item text stored under `key`, or `None` when there is none (or it is over the size limit).
pub fn get(wb: &Workbook, key: &str) -> Option<String> {
let mut parts = Parts::default();
for n in wb.names.iter() {
if let Some((k, chunk)) = item_of(n)
&& k.eq_ignore_ascii_case(key)
{
parts.add(chunk, &n.formula);
}
}
assemble(&parts)
}
/// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass
/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit.
pub fn items(wb: &Workbook, prefix: &str) -> BTreeMap<String, String> {
group(wb, prefix)
.into_iter()
.filter(|(_, parts)| parts.is_item())
.take(MAX_ITEMS)
.filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text)))
.collect()
}
/// The text of the item held by `parts`, as [`get`] reads it.
fn assemble(parts: &Parts) -> Option<String> {
let chunks = &parts.chunks;
let mut out = String::new();
if !chunks.contains_key(&0) {
out = string_literals(parts.legacy?);
} else {
// Chunks run 0, 1, 2, …; anything after a gap isn't part of the item.
for (want, (i, f)) in chunks.iter().enumerate() {
if *i != want as u64 {
break;
}
out.push_str(&string_literals(f));
if out.len() > MAX_STORED_BYTES {
return None;
}
}
out = unescape(&out);
}
(out.chars().count() <= MAX_ITEM_CHARS).then_some(out)
}
/// Whether `c` is stored escaped: XML can't carry it, or it is the marker.
fn needs_escape(c: char) -> bool {
c == ESCAPE || !matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
}
/// Turns [`ESCAPE`] and four hex digits back into the character. A marker not followed by four
/// hex digits naming a character (a hand-made name) stays as it is.
fn unescape(s: &str) -> String {
if !s.contains(ESCAPE) {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(at) = rest.find(ESCAPE) {
out.push_str(rest.get(..at).unwrap_or(""));
let after = rest.get(at + ESCAPE.len_utf8()..).unwrap_or("");
let code = after.get(..4).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u32::from_str_radix(h, 16).ok());
match code.and_then(char::from_u32) {
Some(c) => {
out.push(c);
rest = after.get(4..).unwrap_or("");
}
None => {
out.push(ESCAPE);
rest = after;
}
}
}
out.push_str(rest);
out
}
/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it.
pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> {
check_key(key)?;
if text.chars().count() > MAX_ITEM_CHARS {
return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024))));
}
let formulas = chunk_formulas(text);
// A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside
// it with the same name would be the one an xlsx file drops. Refuse before changing anything,
// in one pass over the names (a file can carry many thousands).
let chunks = formulas.len() as u64;
let taken = |n: &&DefinedName| {
n.scope.is_none() && !n.hidden && parse_name(&n.name).is_some_and(|(k, i)| k.eq_ignore_ascii_case(key) && i.is_some_and(|i| i < chunks))
};
if let Some(n) = wb.names.iter().find(taken) {
return Err(EngineError::Other(format!(
"The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.",
n.name
)));
}
remove(wb, key);
for (i, formula) in formulas.into_iter().enumerate() {
wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
}
Ok(())
}
/// Removes the item stored under `key`.
pub fn remove(wb: &mut Workbook, key: &str) {
wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| k.eq_ignore_ascii_case(key)));
}
/// The item under `key` as JSON (`None` when missing or not JSON).
pub fn get_json(wb: &Workbook, key: &str) -> Option<Json> {
serde_json::from_str(&get(wb, key)?).ok()
}
pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> {
set(wb, key, &v.to_string())
}
/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted
/// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass
/// where a [`get`] per key would take one each.
pub fn keys(wb: &Workbook, prefix: &str) -> Vec<String> {
group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect()
}
/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope
/// `_gc_<key>_<k>` name, or a workbook-scope legacy `_sc_<key>` name, which is read whether or not
/// it is hidden. A visible `_gc_` name is the person's own.
pub fn holds_item(wb: &Workbook, name: &str) -> bool {
wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(name) && item_of(n).is_some())
}
/// The item a hidden name belongs to, lower-cased: `_gc_<key>_<k>` and `_sc_<key>` give `<key>`.
pub fn item_key(name: &str) -> Option<String> {
parse_name(name).map(|(k, _)| k.to_ascii_lowercase())
}
/// The item a name holds a part of: a hidden workbook-scope `_gc_<key>_<k>` name, or a legacy
/// `_sc_<key>` one. A visible name of the `_gc_` shape is someone's own name, not an item: it is
/// neither read, nor listed, nor removed on write.
fn item_of(n: &DefinedName) -> Option<(&str, Option<u64>)> {
if n.scope.is_some() {
return None;
}
parse_name(&n.name).filter(|(_, chunk)| chunk.is_none() || n.hidden)
}
/// `_gc_<key>_<k>` → (`key`, `Some(k)`); `_sc_<key>` → (`key`, `None`).
fn parse_name(name: &str) -> Option<(&str, Option<u64>)> {
if let Some(rest) = strip_prefix_ci(name, PREFIX) {
let (key, k) = rest.rsplit_once('_')?;
// Only the chunk number as `set` writes it: `_gc_watch_00` is someone else's name, not
// chunk 0 under another spelling, so it is neither read nor in the way of `_gc_watch_0`.
let canonical = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()) && (k == "0" || !k.starts_with('0'));
if key.is_empty() || !canonical {
return None;
}
return k.parse().ok().map(|i| (key, Some(i)));
}
strip_prefix_ci(name, LEGACY_PREFIX).filter(|k| !k.is_empty()).map(|k| (k, None))
}
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let head = s.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix).then(|| s.get(prefix.len()..)).flatten()
}
fn check_key(key: &str) -> Result<()> {
let ok = !key.is_empty() && key.len() <= MAX_KEY && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.');
if ok { Ok(()) } else { Err(EngineError::Other(format!("`{key}` can't name workbook data: use letters, digits, `_` and `.`"))) }
}
/// `text` as name formulas: string literals of at most 255 characters joined with `&`, each formula
/// at most 8,192 characters, with the characters XML can't carry escaped (see [`ESCAPE`]).
/// Concatenating [`string_literals`] of each and turning the escapes back gives `text` back.
pub fn chunk_formulas(text: &str) -> Vec<String> {
let mut formulas = Vec::new();
let (mut formula, mut units) = (String::new(), 0usize);
let (mut literal, mut lit_units) = (String::from("\""), 0usize);
let mut flush_literal = |literal: &mut String, lit_units: &mut usize, formula: &mut String, units: &mut usize| {
literal.push('"');
let cost = literal.encode_utf16().count() + usize::from(!formula.is_empty());
if *units + cost > FORMULA_UNITS && !formula.is_empty() {
formulas.push(std::mem::take(formula));
*units = 0;
}
if !formula.is_empty() {
formula.push('&');
*units += 1;
}
*units += literal.encode_utf16().count();
formula.push_str(literal);
*literal = String::from("\"");
*lit_units = 0;
};
for ch in text.chars() {
// An escaped character is the marker and four digits, kept in one literal.
let escaped = needs_escape(ch);
let cost = if escaped { ESCAPE.len_utf16() + 4 } else { ch.len_utf16() };
if lit_units + cost > LITERAL_UNITS {
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
}
lit_units += cost;
if escaped {
literal.push(ESCAPE);
let _ = write!(literal, "{:04X}", u32::from(ch));
} else {
if ch == '"' {
literal.push('"');
}
literal.push(ch);
}
}
if lit_units > 0 {
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
}
if !formula.is_empty() {
formulas.push(formula);
}
formulas
}
/// Concatenates the string literals of a `"a"&"b""c"` formula (anything outside them is ignored).
pub fn string_literals(f: &str) -> String {
let mut out = String::new();
let mut it = f.chars().peekable();
let mut in_str = false;
while let Some(c) = it.next() {
if !in_str {
if c == '"' {
in_str = true;
}
continue;
}
if c == '"' {
if it.peek() == Some(&'"') {
it.next();
out.push('"');
} else {
in_str = false;
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn name(n: &str, formula: &str) -> DefinedName {
DefinedName { name: n.into(), scope: None, formula: formula.into(), comment: String::new(), hidden: true }
}
fn excel_limits_hold(wb: &Workbook) {
for n in &wb.names {
assert!(n.formula.encode_utf16().count() <= FORMULA_UNITS, "{} is {} long", n.name, n.formula.len());
// Every literal (quotes undoubled) is at most 255 characters.
for lit in n.formula.split("\"&\"") {
let inner = lit.trim_start_matches('"').trim_end_matches('"').replace("\"\"", "\"");
assert!(inner.encode_utf16().count() <= LITERAL_UNITS, "{}", inner.len());
}
}
}
#[test]
fn long_values_split_over_names_and_come_back() {
let mut wb = Workbook::new();
// Quotes double when written; characters outside the BMP count twice.
let text: String = (0..30_000).map(|i| ['a', '"', 'é', '\u{1D11E}', '&', '\n'][i % 6]).collect();
set(&mut wb, "big", &text).unwrap();
let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect();
assert!(names.len() > 5, "{names:?}");
assert_eq!(names[0], "_gc_big_0");
assert!(wb.names.iter().all(|n| n.hidden && n.scope.is_none()));
excel_limits_hold(&wb);
assert_eq!(get(&wb, "big").as_deref(), Some(text.as_str()));
assert_eq!(get(&wb, "BIG").as_deref(), Some(text.as_str()), "names are case-insensitive");
// Shorter again: the extra chunks go.
set(&mut wb, "big", "short").unwrap();
assert_eq!(wb.names.len(), 1);
assert_eq!(wb.names[0].formula, "\"short\"");
set(&mut wb, "big", "").unwrap();
assert!(wb.names.is_empty());
assert_eq!(get(&wb, "big"), None);
}
#[test]
fn chunks_and_literals_respect_the_boundaries() {
for len in [0, 1, 254, 255, 256, 8_000, 8_192, 8_193, 20_000] {
let text = "q".repeat(len);
let fs = chunk_formulas(&text);
assert_eq!(fs.iter().map(|f| string_literals(f)).collect::<String>(), text, "{len}");
assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS), "{len}");
}
// All quotes: each literal doubles in the formula.
let text = "\"".repeat(9_000);
let fs = chunk_formulas(&text);
assert_eq!(fs.iter().map(|f| string_literals(f)).collect::<String>(), text);
assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS));
}
#[test]
fn the_old_single_name_still_reads_and_is_replaced_on_write() {
let mut wb = Workbook::new();
wb.names.push(name("_sc_scripts", "\"{\"\"A\"\":[1]}\""));
wb.names.push(name("_sc_scenarios", "\"[1,\"&\"2]\""));
assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"A": [1]})));
assert_eq!(get_json(&wb, "scenarios"), Some(serde_json::json!([1, 2])));
set_json(&mut wb, "scripts", &serde_json::json!({"B": []})).unwrap();
let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["_sc_scenarios", "_gc_scripts_0"]);
assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"B": []})));
}
#[test]
fn hostile_names_read_safely() {
let mut wb = Workbook::new();
// A gap: chunk 2 without chunk 1.
wb.names.push(name("_gc_gap_0", "\"a\""));
wb.names.push(name("_gc_gap_2", "\"c\""));
assert_eq!(get(&wb, "gap").as_deref(), Some("a"));
// Only a later chunk: no item, not listed, and the legacy name is still read.
wb.names.push(name("_gc_orphan_2", "\"c\""));
assert_eq!(get(&wb, "orphan"), None);
assert_eq!(keys(&wb, "orphan"), Vec::<String>::new());
wb.names.push(name("_sc_orphan", "\"old\""));
assert_eq!(get(&wb, "orphan").as_deref(), Some("old"));
assert_eq!(keys(&wb, "orphan"), ["orphan"]);
// Chunks out of order, a duplicate, junk around the literals, an unterminated literal.
wb.names.push(name("_gc_mix_1", "\"b\"&A1&"));
wb.names.push(name("_gc_mix_0", "SUM(\"a\")"));
wb.names.push(name("_gc_mix_0", "\"ignored\""));
wb.names.push(name("_gc_mix_2", "\"c"));
assert_eq!(get(&wb, "mix").as_deref(), Some("abc"));
// Names that only look like chunks.
for odd in ["_gc_", "_gc__0", "_gc_x_", "_gc_x_-1", "_gc_x_99999999999999999999", "_sc_", "_gcx_0"] {
wb.names.push(name(odd, "\"zzz\""));
}
assert_eq!(get(&wb, "x"), None);
assert_eq!(get(&wb, ""), None);
// Not JSON, or JSON nested deeper than the parser allows.
wb.names.push(name("_gc_notjson_0", "\"{\""));
assert_eq!(get_json(&wb, "notjson"), None);
let deep = format!("\"{}{}\"", "[".repeat(10_000), "]".repeat(10_000));
wb.names.push(name("_gc_deep_0", &deep));
assert_eq!(get_json(&wb, "deep"), None);
// Sheet-scoped names are someone else's.
wb.names.push(DefinedName { scope: Some(0), ..name("_gc_scoped_0", "\"s\"") });
assert_eq!(get(&wb, "scoped"), None);
// Bad keys are refused.
for bad in ["", "has space", "quote\"", "a/b", &"k".repeat(201)] {
assert!(set(&mut wb, bad, "v").is_err(), "{bad}");
}
assert!(set(&mut wb, "big", &"x".repeat(MAX_ITEM_CHARS + 1)).is_err());
}
#[test]
fn oversized_items_are_not_read() {
let mut wb = Workbook::new();
let chunk = format!("\"{}\"", "x".repeat(250));
let big = vec![chunk.as_str(); 32].join("&");
for i in 0..(MAX_ITEM_CHARS / 8_000 + 2) {
wb.names.push(name(&format!("_gc_huge_{i}"), &big));
}
assert_eq!(get(&wb, "huge"), None);
}
#[test]
fn a_visible_name_of_the_item_shape_is_not_an_item() {
// Someone's own visible `_gc_watch_0` is not storage: it is not read or listed, and
// writing or removing the item leaves it alone.
let mut wb = Workbook::new();
let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") };
wb.names.push(own.clone());
assert_eq!(get(&wb, "watch"), None);
assert!(keys(&wb, "").is_empty());
// Writing the item would put a hidden name of the same name beside it, and an xlsx file
// keeps only one of them: refused, with the names as they were.
let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string();
assert!(err.contains("_gc_watch_0"), "{err}");
assert_eq!(wb.names, vec![own.clone()]);
assert_eq!(get(&wb, "watch"), None);
set(&mut wb, "other", "theirs").unwrap();
assert_eq!(get(&wb, "other").as_deref(), Some("theirs"));
remove(&mut wb, "watch");
remove(&mut wb, "other");
assert_eq!(wb.names, vec![own]);
}
#[test]
fn a_chunk_number_with_leading_zeros_is_another_name() {
// `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a
// visible one is not in the way of writing `_gc_watch_0`.
let mut wb = Workbook::new();
wb.names.push(name("_gc_watch_00", "\"theirs\""));
assert_eq!(get(&wb, "watch"), None);
assert!(keys(&wb, "").is_empty());
let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") };
wb.names.push(own.clone());
set(&mut wb, "watch", "ours").unwrap();
assert_eq!(get(&wb, "watch").as_deref(), Some("ours"));
assert_eq!(wb.names.len(), 3, "both other names stay");
remove(&mut wb, "watch");
assert_eq!(wb.names, vec![name("_gc_watch_00", "\"theirs\""), own]);
}
#[test]
fn keys_list_items_by_prefix() {
let mut wb = Workbook::new();
set(&mut wb, "macro_2", "b").unwrap();
set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap();
set(&mut wb, "scripts", "{}").unwrap();
assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]);
assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1"));
assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts"));
assert_eq!(item_key("Total"), None);
}
#[test]
fn listing_many_items_takes_one_pass_and_is_capped() {
// 20,000 hidden items, as a file can carry: a `get` per key looked at every name for each
// key (400 million visits); `items` looks at each name once, and lists at most MAX_ITEMS.
let mut wb = Workbook::new();
let total = 20_000;
for i in 0..total {
wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\"")));
}
set(&mut wb, "macro_big", &"b".repeat(9_000)).unwrap();
wb.names.push(name("_sc_macro_legacy", "\"old\""));
wb.names.push(name("_gc_macro_orphan_1", "\"no chunk 0\""));
wb.names.push(name("_gc_other_0", "\"not a macro\""));
NAME_VISITS.with(|v| v.set(0));
let all = items(&wb, "MACRO_");
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names");
assert_eq!(all.len(), MAX_ITEMS);
let first: Vec<&String> = all.keys().take(2).collect();
assert_eq!(first, ["macro_00000", "macro_00001"]);
assert_eq!(all.get("macro_09999").map(String::as_str), Some("m9999"));
assert!(!all.contains_key("macro_10000"), "past the cap");
NAME_VISITS.with(|v| v.set(0));
let listed = keys(&wb, "macro_");
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len());
assert_eq!(listed, all.keys().cloned().collect::<Vec<_>>(), "keys and items agree");
// Under the cap every item is there, read as `get` reads it.
wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1"));
let few = items(&wb, "macro_");
let want: BTreeMap<String, String> =
[("macro_big", "b".repeat(9_000)), ("macro_legacy", "old".into())].into_iter().map(|(k, t)| (k.to_string(), t)).collect();
assert_eq!(few, want);
for (k, text) in &few {
assert_eq!(get(&wb, k).as_ref(), Some(text));
}
assert_eq!(keys(&wb, "macro_"), ["macro_big", "macro_legacy"]);
assert_eq!(items(&wb, "").len(), 3, "the other item too");
}
#[test]
fn items_round_trip_through_xlsx() {
let mut wb = Workbook::new();
let text: String = (0..20_000).map(|i| ['x', '"', 'ü', '\u{1D11E}', ' '][i % 5]).collect();
set(&mut wb, "blob", &text).unwrap();
wb.names.push(name("_sc_watch", "\"[\"\"A1\"\"]\""));
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str()));
assert_eq!(get_json(&back, "watch"), Some(serde_json::json!(["A1"])));
// Characters XML can't carry, and the escape marker itself, come back too, in text and
// in JSON (which leaves U+FFFE and U+FFFF unescaped).
let mut wb = Workbook::new();
let odd: String = (0u32..0x20).filter_map(char::from_u32).chain(['\u{FFFE}', '\u{FFFF}', ESCAPE, 'a', '\u{7F}', '\u{FDD1}']).collect();
let text: String = odd.chars().cycle().take(odd.chars().count() * 300).collect();
set(&mut wb, "odd", &text).unwrap();
excel_limits_hold(&wb);
let json = serde_json::json!({"s": "x\u{FFFF}y\u{1}z\u{FDD0}0041"});
set_json(&mut wb, "json", &json).unwrap();
// All escapes: each literal holds whole escapes, within the limits.
let controls = "\u{1}".repeat(9_000);
set(&mut wb, "controls", &controls).unwrap();
excel_limits_hold(&wb);
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(get(&back, "odd").as_deref(), Some(text.as_str()));
assert_eq!(get_json(&back, "json"), Some(json));
assert_eq!(get(&back, "controls").as_deref(), Some(controls.as_str()));
}
#[test]
fn escapes_count_against_the_limits_and_odd_markers_read_as_they_are() {
// Excel's limits hold however many characters are escaped, and the longest item of
// characters that all need escaping still reads.
for len in [1, 50, 51, 52, 1_638, 1_639, 5_000] {
let text = "\u{2}".repeat(len);
let fs = chunk_formulas(&text);
assert_eq!(unescape(&fs.iter().map(|f| string_literals(f)).collect::<String>()), text, "{len}");
assert!(fs.iter().all(|f| f.encode_utf16().count() <= FORMULA_UNITS), "{len}");
}
let mut wb = Workbook::new();
let longest = "\u{0}".repeat(MAX_ITEM_CHARS);
set(&mut wb, "longest", &longest).unwrap();
excel_limits_hold(&wb);
assert_eq!(get(&wb, "longest").map(|t| t.len()), Some(MAX_ITEM_CHARS));
// A marker in a hand-made name that is not an escape stays as it is.
let mut wb = Workbook::new();
wb.names.push(name("_gc_hand_0", "\"a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004\u{FDD0}0041\u{FDD0}\""));
assert_eq!(get(&wb, "hand").as_deref(), Some("a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004A\u{FDD0}"));
}
}
+62 -1
View File
@@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")),
cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")),
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.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),
@@ -43,6 +44,15 @@ pub fn specs() -> Vec<CommandSpec> {
s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar);
Ok(json!({"on": s.prefs.formula_bar}))
}),
cmd!(
query "view.statusBar",
"Customize Status Bar",
[],
None,
"{average?, count?, numericalCount?, minimum?, maximum?, sum?: bool (the statistics shown for a selection; returns all six)}",
always,
status_bar
),
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
cmd!(
@@ -114,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Header & Footer",
["Insert", "Text"],
None,
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, firstHeader, firstFooter, evenHeader, evenFooter}",
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, scaleWithDoc?: bool, alignWithMargins?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, scaleWithDoc, alignWithMargins, firstHeader, firstFooter, evenHeader, evenFooter}",
has_doc,
header_footer
),
@@ -166,6 +176,7 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
"gridlines" => &mut sh.show_gridlines,
"headings" => &mut sh.show_headings,
"zeros" => &mut sh.show_zeros,
"outlineSymbols" => &mut sh.show_outline_symbols,
"rtl" => &mut sh.right_to_left,
"printGridlines" => &mut sh.print.gridlines,
_ => &mut sh.print.headings,
@@ -185,6 +196,30 @@ fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
})
}
fn status_bar(s: &mut Session, p: &Json) -> Result<Json> {
let st = &mut s.prefs.status_bar;
for (key, flag) in [
("average", &mut st.average),
("count", &mut st.count),
("numericalCount", &mut st.numerical_count),
("minimum", &mut st.minimum),
("maximum", &mut st.maximum),
("sum", &mut st.sum),
] {
if let Some(on) = bool_param(p, key) {
*flag = on;
}
}
Ok(json!({
"average": st.average,
"count": st.count,
"numericalCount": st.numerical_count,
"minimum": st.minimum,
"maximum": st.maximum,
"sum": st.sum,
}))
}
fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16;
let d = s.doc_mut()?;
@@ -390,6 +425,12 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(on) = bool_param(p, "differentOddEven") {
pr.different_odd_even = on;
}
if let Some(on) = bool_param(p, "scaleWithDoc") {
pr.hf_scale_with_doc = on;
}
if let Some(on) = bool_param(p, "alignWithMargins") {
pr.hf_align_with_margins = on;
}
if let Some((page, footer, idx, text)) = &section {
let spec = pr.hf_mut(*page, *footer);
let mut parts = super::print::split_header_footer(spec);
@@ -403,6 +444,8 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
"footer": pr.footer,
"differentFirst": pr.different_first,
"differentOddEven": pr.different_odd_even,
"scaleWithDoc": pr.hf_scale_with_doc,
"alignWithMargins": pr.hf_align_with_margins,
"firstHeader": pr.first_header,
"firstFooter": pr.first_footer,
"evenHeader": pr.even_header,
@@ -476,4 +519,22 @@ mod tests {
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
assert!(old.formula_bar);
}
#[test]
fn status_bar_statistics_default_to_average_count_and_sum_and_turn_on_one_at_a_time() {
let mut s = Session::new();
let all = json!({"average": true, "count": true, "numericalCount": false, "minimum": false, "maximum": false, "sum": true});
assert_eq!(s.execute("view.statusBar", json!({})).unwrap(), all);
let r = s.execute("view.statusBar", json!({"minimum": true, "sum": false, "count": "yes"})).unwrap();
assert_eq!(r, json!({"average": true, "count": true, "numericalCount": false, "minimum": true, "maximum": false, "sum": false}));
assert!(s.prefs.status_bar.minimum && !s.prefs.status_bar.sum);
// A prefs.json saved before the option existed, or with only some of the six, keeps the defaults for the rest.
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
assert_eq!(old.status_bar, crate::StatusBarStats::default());
let part: crate::Prefs = serde_json::from_str(r#"{"statusBar": {"maximum": true}}"#).unwrap();
assert!(part.status_bar.maximum && part.status_bar.average && part.status_bar.sum && !part.status_bar.minimum);
let saved = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(saved["statusBar"]["minimum"], true);
assert_eq!(saved["statusBar"]["numericalCount"], false);
}
}
+147 -8
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula};
use gridcraft_model::{Cell, Formula, Workbook};
use gridcraft_numfmt::{FormatKind, NumberFormat};
use crate::Result;
@@ -23,15 +23,43 @@ pub enum FillMode {
ValuesOnly,
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
Extend,
/// Fill Days, Fill Weekdays, Fill Months or Fill Years: a source of dates steps by that unit.
/// A lone date steps by one unit; two or more step by the difference between the first two,
/// counted in that unit (one unit when they fall in the same one). A source that isn't dates
/// extends like a plain drag.
Dates(DateUnit),
}
/// The step of a [`FillMode::Dates`] fill.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DateUnit {
Day,
/// Monday to Friday: Saturday and Sunday are skipped.
Weekday,
Month,
Year,
}
impl FillMode {
/// The modes that continue the source's series rather than copy it.
fn extends_series(self) -> bool {
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly)
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly | FillMode::Dates(_))
}
}
/// Whether a number format shows a calendar date (`YYYY-MM-DD` and `m/d/yyyy h:mm` do; `0 "days"`
/// and `h:mm` don't).
fn is_date_format(fmt: &str) -> bool {
NumberFormat::parse(fmt).kind() == FormatKind::Date
}
/// Whether the first cell of `src` holds a date, so a fill from it can step by days, weekdays,
/// months or years: a number (not a formula) shown in a date format.
pub fn starts_with_date(wb: &Workbook, sheet: usize, src: RangeRef) -> bool {
let Some(cell) = wb.sheet(sheet).and_then(|sh| sh.cell(src.start)) else { return false };
cell.formula.is_none() && cell.value.is_number() && is_date_format(wb.styles.get(cell.style).num_fmt.as_str())
}
/// Built-in custom lists.
fn lists() -> Vec<Vec<String>> {
let short_days: Vec<String> = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect();
@@ -102,7 +130,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
};
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
let first_fmt = src_cells.first().map(fmt).unwrap_or_default();
let dates = match mode {
FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, cx.wb.date_system, first_fmt),
_ => None,
};
let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &custom, cx.wb.date_system, first_fmt));
match (mode, detected) {
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
(_, p) => p,
@@ -130,7 +163,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
writes.push((*t, Some(existing)));
continue;
}
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => {
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend | FillMode::Dates(_) => {
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
if let Some(f) = &cell.formula {
if let Some(e) = f.expr() {
@@ -204,9 +237,63 @@ enum Pattern {
months: i64,
sys: gridcraft_core::DateSystem,
},
/// Dates stepping by weekdays, skipping Saturday and Sunday.
Weekdays {
last: f64,
first: f64,
step: i64,
sys: gridcraft_core::DateSystem,
},
}
impl Pattern {
/// The pattern of Fill Days, Fill Weekdays, Fill Months or Fill Years, or `None` when the
/// source isn't all dates (numbers, not formulas, under a date format in its first cell).
fn dates(cells: &[(CellRef, Option<Cell>)], unit: DateUnit, sys: gridcraft_core::DateSystem, fmt: &str) -> Option<Pattern> {
if !is_date_format(fmt) {
return None;
}
let mut nums = Vec::with_capacity(cells.len());
for (_, cell) in cells {
let cell = cell.as_ref()?;
if cell.formula.is_some() || !cell.value.is_number() {
return None;
}
nums.push(cell.value.as_f64()?);
}
let first = *nums.first()?;
let last = *nums.last()?;
let second = nums.get(1).copied();
// The difference between the first two dates in the unit, and one unit when they fall in
// the same one.
let at_least_one = |n: i64| if n == 0 { 1 } else { n };
Some(match unit {
DateUnit::Day => {
let days = second.map_or(1.0, |b| b.floor() - first.floor());
Pattern::Linear { last, first, step: if days == 0.0 { 1.0 } else { days } }
}
DateUnit::Weekday => {
let step = match second {
Some(b) => at_least_one(weekdays_between(sys, first, b)?),
None => 1,
};
Pattern::Weekdays { last, first, step, sys }
}
DateUnit::Month | DateUnit::Year => {
let months = match second {
Some(b) => {
let (a, b) = (datetime_from_serial(sys, first)?, datetime_from_serial(sys, b)?);
let years = b.year as i64 - a.year as i64;
if unit == DateUnit::Year { at_least_one(years) * 12 } else { at_least_one(years * 12 + b.month as i64 - a.month as i64) }
}
None if unit == DateUnit::Year => 12,
None => 1,
};
Pattern::Months { last, first, months, sys }
}
})
}
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
@@ -343,20 +430,72 @@ impl Pattern {
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
Some(Value::number(s))
}
Pattern::Weekdays { last, first, step, sys } => {
let base = if forward { *last } else { *first };
Some(Value::number(add_weekdays(*sys, base, step.checked_mul(k)?.checked_mul(sign)?)?))
}
}
}
}
/// Whether `serial` falls on Monday to Friday; `None` outside the supported date range.
fn is_weekday(sys: gridcraft_core::DateSystem, serial: f64) -> Option<bool> {
datetime_from_serial(sys, serial).map(|d| d.weekday != 0 && d.weekday != 6)
}
/// `serial` moved by `n` weekdays (back when `n` is negative), keeping its time of day. A start on
/// a Saturday or Sunday moves to the next (or previous) weekday for the first step.
fn add_weekdays(sys: gridcraft_core::DateSystem, serial: f64, n: i64) -> Option<f64> {
if n == 0 {
return Some(serial);
}
let dir = n.signum() as f64;
let mut d = serial;
let next_weekday = |d: &mut f64| -> Option<()> {
loop {
*d += dir;
if is_weekday(sys, *d)? {
return Some(());
}
}
};
next_weekday(&mut d)?;
// From a weekday, every seven days hold five weekdays.
let rest = n.unsigned_abs() - 1;
d += dir * (rest / 5 * 7) as f64;
for _ in 0..rest % 5 {
next_weekday(&mut d)?;
}
datetime_from_serial(sys, d)?;
Some(d)
}
/// How many weekdays lie after `a` up to and including `b` (negative when `b` comes first),
/// counting whole days; `None` outside the supported date range.
fn weekdays_between(sys: gridcraft_core::DateSystem, a: f64, b: f64) -> Option<i64> {
let (lo, hi, sign) = if b >= a { (a.floor(), b.floor(), 1) } else { (b.floor(), a.floor(), -1) };
let days = (hi - lo) as i64;
let mut count = days / 7 * 5;
let mut d = lo + (days / 7 * 7) as f64;
for _ in 0..days % 7 {
d += 1.0;
if is_weekday(sys, d)? {
count += 1;
}
}
Some(count * sign)
}
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30.
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. The time of day is kept.
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
let d = datetime_from_serial(sys, serial)?;
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
let year = total.div_euclid(12);
let month = total.rem_euclid(12) + 1;
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64))
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)).map(|day| day + serial - serial.floor())
}
fn round15(x: f64) -> f64 {
@@ -388,9 +527,9 @@ fn ordinal_suffix(n: i64, suffix: &str) -> String {
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
let chars: Vec<char> = t.chars().collect();
let end = chars.iter().rposition(|c| c.is_ascii_digit())?;
let end = chars.iter().rposition(char::is_ascii_digit)?;
let mut start = end;
while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_ascii_digit()) {
while start > 0 && chars.get(start - 1).is_some_and(char::is_ascii_digit) {
start -= 1;
}
let digits: String = chars.get(start..=end)?.iter().collect();
+191 -7
View File
@@ -209,7 +209,7 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
/// 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(|s| s.to_str()).unwrap_or("Sheet1");
let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1");
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.
@@ -226,7 +226,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
// An unknown or missing extension is refused rather than written as an XLSX under that name:
// a typo like `.cvs` would otherwise produce a file no other app recognises.
let Some(kind) = FileKind::from_path(path) else {
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path);
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
};
save_bytes_as(wb, path, kind)
@@ -239,7 +239,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
FileKind::Xlsx => {
let book = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or_default();
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
}
FileKind::Xls => {
@@ -448,6 +448,11 @@ impl std::fmt::Write for ClipboardHtml {
/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells.
const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
/// Rows × columns of one web-page table. Save as Web Page writes every kept row's full used
/// column span into one growing String, so a sparse sheet with a lone far-right cell (or a
/// tall, wide one) would otherwise be gigabytes of `<td></td>` before finishing (#2387).
const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000;
/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's
/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor.
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
@@ -479,6 +484,17 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
r.height()
)));
}
// The table is one growing String: every kept row writes a `<td>` for the whole used
// column span (unless the sheet has no row that is that wide). A tall, wide sheet
// (or a sparse one with a lone far-right cell) would be gigabytes before finishing,
// so the rectangle itself is bounded (#2387).
if u64::from(r.height()) * u64::from(r.width()) > HTML_EXPORT_MAX_CELLS {
return Err(EngineError::Other(format!(
"the sheet is too tall × wide for a web page ({} × {} cells, at most {HTML_EXPORT_MAX_CELLS}). Save as .xlsx or CSV instead.",
r.height(),
r.width()
)));
}
if fixed {
out.push_str("<colgroup>");
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
@@ -556,25 +572,98 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
Ok(out)
}
thread_local! {
/// How many [`ScriptSandbox`]es are alive on this thread.
static SCRIPT_SANDBOX: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a
/// stored script's steps run inside one (#2178), so no command reaches the file system from a
/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal.
pub(crate) struct ScriptSandbox(());
impl ScriptSandbox {
pub(crate) fn enter() -> Self {
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1)));
ScriptSandbox(())
}
}
impl Drop for ScriptSandbox {
fn drop(&mut self) {
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_sub(1)));
}
}
/// Refuses file access while a [`ScriptSandbox`] is alive (and, failing closed, while the
/// thread is being torn down).
fn script_sandbox_check(path: &str) -> Result<()> {
if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) {
return Err(EngineError::Other(format!("{path}: scripts can't read or write files")));
}
Ok(())
}
/// The largest native file the reader still touches: its own cap plus one, used to stop
/// reads whose bytes arrive faster than metadata can claim (device files, growing files).
const MAX_READ_BYTES: u64 = 2 * 1024 * 1024 * 1024;
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file(path: &str) -> Result<Vec<u8>> {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > 2 * 1024 * 1024 * 1024 {
use std::io::Read;
script_sandbox_check(path)?;
let f = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
// A symlink to an endless device reports length 0 but streams forever, so metadata
// alone never bounds the read: require a real file and cap the bytes actually taken.
if !f.metadata().map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
return Err(EngineError::Other(format!("{path}: only regular files can be opened")));
}
let mut buf = Vec::new();
let mut limited = (&f).take(MAX_READ_BYTES + 1);
limited.read_to_end(&mut buf).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if buf.len() as u64 > MAX_READ_BYTES {
return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
}
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
Ok(buf)
}
#[cfg(target_arch = "wasm32")]
pub fn read_file(path: &str) -> Result<Vec<u8>> {
script_sandbox_check(path)?;
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
use std::io::Write;
script_sandbox_check(path)?;
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
// The temporary file is created exclusively, so an existing file or symlink at that
// path (planted by another local user) makes the save fail instead of truncating the
// link's target; and it inherits the destination's access restrictions, so saving a
// private workbook doesn't widen who can read it.
let tmp = format!("{path}.sctmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
let prior = std::fs::metadata(path).ok().map(|m| m.permissions());
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
if let Some(p) = &prior {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
opts.mode(p.mode());
}
let mut f = opts.open(&tmp).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
EngineError::Other(format!("{path}: {e}"))
})?;
#[cfg(not(unix))]
if let Some(p) = &prior {
let _ = f.set_permissions(p.clone());
}
f.write_all(bytes).and_then(|()| f.flush()).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
EngineError::Other(format!("{path}: {e}"))
})?;
std::fs::rename(&tmp, path).map_err(|e| {
// A write or rename that fails (full disk, the workbook locked by another app) leaves
// no temporary file beside the user's workbook.
let _ = std::fs::remove_file(&tmp);
@@ -584,6 +673,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(target_arch = "wasm32")]
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
script_sandbox_check(path)?;
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
@@ -675,6 +765,74 @@ mod tests {
range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap()
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn save_fails_upfront_when_the_temp_name_is_taken() {
// Another local user who can write the directory must not be able to point the
// save's predictable temporary name at their own file: creation is exclusive.
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-temp-name-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let victim = dir.join("victim.txt");
std::fs::write(&victim, b"original").unwrap();
let dest = dir.join("book.xlsx");
std::os::unix::fs::symlink(&victim, dir.join("book.xlsx.sctmp")).unwrap();
let err = write_file(dest.to_str().unwrap(), b"new").unwrap_err();
assert_eq!(std::fs::read(&victim).unwrap(), b"original");
assert!(!dir.join("book.xlsx").exists(), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(unix)]
#[test]
fn save_keeps_the_destination_access_restrictions() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-permissions-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("private.xlsx");
std::fs::write(&dest, b"v1").unwrap();
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap();
write_file(dest.to_str().unwrap(), b"v2").unwrap();
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
// And a brand-new file is created with the restrictive, not permissive, default.
let fresh = dir.join("new.xlsx");
write_file(fresh.to_str().unwrap(), b"v").unwrap();
let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o777;
assert!(mode & 0o022 == 0, "mode {mode:o}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn open_refuses_directories_and_unknowable_lengths() {
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-open-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file");
assert!(read_file(dir.join("missing").to_str().unwrap()).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() {
// #2387: the used rectangle alone bounds the output (every row writes the whole
// column span), so a sparse sheet with one cell at A1 and another far right
// 300 rows down is 4.9M cells → refused before a byte of `<td>` is written.
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::Text("start".into()));
sh.set_value(CellRef::new(300, 16383), gridcraft_core::Value::Text("corner".into()));
let err = to_html(&wb, 0).unwrap_err();
assert!(err.to_string().contains("too tall × wide"), "{err}");
// A normal sheet is fine.
let mut ok = Workbook::new();
ok.sheet_mut(0).unwrap().set_value(CellRef::new(5, 2), gridcraft_core::Value::Text("t".into()));
assert!(to_html(&ok, 0).is_ok());
}
#[test]
fn html_export_embeds_images_as_data_uris() {
let mut wb = Workbook::new();
@@ -902,6 +1060,32 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// While a script runs (#2178), file reads and writes are refused whatever command or
/// parameter asked for them; nesting and dropping the sandbox restore access.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn script_sandbox_refuses_file_access_until_dropped() {
let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let existing = dir.join("in.csv");
std::fs::write(&existing, b"a,b\n").unwrap();
let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string());
{
let _outer = ScriptSandbox::enter();
{
let _inner = ScriptSandbox::enter();
assert!(read_file(&existing).is_err());
}
assert!(read_file(&existing).is_err(), "the outer sandbox still holds");
assert!(write_file(&out, b"leak").is_err());
assert!(!std::path::Path::new(&out).exists());
}
assert_eq!(read_file(&existing).unwrap(), b"a,b\n");
write_file(&out, b"ok").unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
+490 -10
View File
@@ -19,6 +19,7 @@ pub mod outline;
pub mod paper;
pub mod pivot;
pub mod pivot_calc;
pub mod record;
pub mod sample;
pub mod selection;
pub mod tables;
@@ -68,6 +69,51 @@ pub struct HistoryEntry {
pub retained_bytes: usize,
}
/// An undo group opened with [`Session::group_begin`]. Hand it back to [`Session::group_end`]
/// (keep the changes as one undo step) or [`Session::group_rollback`] (undo them all).
#[derive(Debug, PartialEq, Eq)]
#[must_use = "an undo group stays open until it is ended or rolled back"]
pub struct GroupToken {
id: u64,
doc: u64,
}
impl GroupToken {
/// The uid of the document the group belongs to.
pub fn doc(&self) -> u64 {
self.doc
}
}
/// An open undo group: what runs on `doc` until it ends becomes one undo step.
#[derive(Clone, Debug)]
struct OpenGroup {
id: u64,
doc: u64,
label: String,
/// Undo depth when the group began; the group's own steps are cut back to it.
start: usize,
wb: Arc<Workbook>,
selection: Selection,
/// The redo stack when the group began (a rollback puts it back).
redo: Vec<HistoryEntry>,
}
/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the
/// workbook and selection it ended at, the undo length then, and whether it made a step. A
/// command that ended the group tells its own later changes from the group's step by it, and
/// keeps the group's name only when the group's step is exactly its own (the entry holds the
/// workbook the step started from). Dropped when the outermost command finishes, so it keeps no
/// workbook alive between commands.
#[derive(Debug)]
struct GroupStep {
doc: u64,
after: Arc<Workbook>,
selection: Selection,
len: usize,
stepped: bool,
}
/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference.
#[derive(Clone, Debug)]
pub struct Clipboard {
@@ -86,6 +132,34 @@ pub struct Clipboard {
/// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid
/// together into one block at `range`. Empty for a single-range copy.
pub areas: Vec<RangeRef>,
/// Trust of the source document: pasting content from a file someone else made carries it.
pub trust: Trust,
}
/// Whether a document's active content (macros) may run without asking the person first. There
/// is no default: whoever makes a document says where it came from ([`DocState::new`]), so a new
/// way of building one from bytes can't come out trusted by omission.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Trust {
/// Made in this session: a new workbook or a sample.
Local,
/// Opened from a file, or holding content pasted from one: active content asks before it
/// runs.
FromFile,
}
impl Trust {
/// Whether active content may run without asking.
pub fn allows_code(self) -> bool {
self == Trust::Local
}
/// The trust of a document after it receives content from a document with trust `source`:
/// content from an untrusted file leaves the receiver untrusted.
pub fn receive(self, source: Trust) -> Trust {
if source == Trust::FromFile { Trust::FromFile } else { self }
}
}
/// In-cell edit mode as shown in the status bar.
@@ -119,6 +193,19 @@ pub struct DocState {
/// The format `path`'s content was recognised as when its extension doesn't name one (an
/// XLSX called `budget.dat`): Save writes it back in that format.
pub content_kind: Option<io::FileKind>,
/// Whether the scripts stored in this workbook may run (`automate.runScript`). A workbook
/// that arrives already carrying scripts (opened from a file) starts untrusted until the
/// user trusts it in this session (`automate.trustScripts`); one with no scripts starts
/// trusted, since any script it gains later was written here.
pub scripts_trusted: bool,
/// Where the workbook's content came from: whether its active content may run without
/// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
/// changed by a trust decision.
pub trust: Trust,
/// The references `edit.goTo` with `record: true` went to in this document, newest first,
/// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
pub go_to_recent: Vec<String>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -159,19 +246,45 @@ fn push_history_with_budget(
selection: Selection,
byte_budget: usize,
) {
push_entry(history, label, wb, next, selection);
trim_to_budget(history, byte_budget);
}
/// Adds a step without applying the limits: inside a command they wait for the outermost
/// command to collapse its steps (see [`Session::trim_history`]).
fn push_entry(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
let retained_bytes = history_retained_bytes(&wb, next);
history.push(HistoryEntry { label, wb, selection, retained_bytes });
}
/// Drops the oldest steps past [`UNDO_LIMIT`] or `byte_budget`, always keeping the latest one
/// (even if that one edit exceeds the budget).
fn trim_to_budget(history: &mut Vec<HistoryEntry>, byte_budget: usize) {
let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes));
// Always preserve the latest action, even if that one edit exceeds the budget.
while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) {
total = total.saturating_sub(history.remove(0).retained_bytes);
}
}
impl DocState {
pub fn new(mut wb: Workbook, path: Option<String>, title: String) -> DocState {
/// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything
/// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook
/// made in this session.
pub fn new(mut wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
let mut calc = Calc::new();
calc.recalc_all(&mut wb);
Self::with_calc(wb, calc, path, title, trust)
}
/// A workbook as its file saved it: the dependency graph is built but nothing is
/// evaluated, so every value is the one the file holds until an edit reaches it. `trust`
/// is as for [`DocState::new`].
pub fn from_saved(wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
let mut calc = Calc::new();
calc.rebuild(&wb);
Self::with_calc(wb, calc, path, title, trust)
}
fn with_calc(wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
let scripts_trusted = !cmd::extra::has_scripts(&wb);
let wb = Arc::new(wb);
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
DocState {
@@ -188,6 +301,9 @@ impl DocState {
sheet_selections: vec![],
unlocked_ranges: vec![],
content_kind: None,
scripts_trusted,
trust,
go_to_recent: vec![],
}
}
pub fn is_dirty(&self) -> bool {
@@ -250,6 +366,27 @@ pub struct Prefs {
pub user_dictionary: Vec<String>,
/// View › Show › Formula Bar (an application option in Excel, not saved in the file).
pub formula_bar: bool,
/// The statistics the status bar shows for a selection (right-click the status bar).
pub status_bar: StatusBarStats,
}
/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
/// Average, Count and Sum by default.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct StatusBarStats {
pub average: bool,
pub count: bool,
pub numerical_count: bool,
pub minimum: bool,
pub maximum: bool,
pub sum: bool,
}
impl Default for StatusBarStats {
fn default() -> Self {
StatusBarStats { average: true, count: true, numerical_count: false, minimum: false, maximum: false, sum: true }
}
}
impl Default for Prefs {
@@ -264,6 +401,7 @@ impl Default for Prefs {
r1c1: false,
user_dictionary: vec![],
formula_bar: true,
status_bar: StatusBarStats::default(),
}
}
}
@@ -275,6 +413,9 @@ pub enum UiRequest {
Message(String),
/// Start editing the active cell (F2) with optional initial text.
EditCell(Option<String>),
/// Put a defined name at the caret of the cell being edited, in place of selected text; with
/// no edit under way, start editing the active cell with `=` and the name.
InsertName(String),
OpenUrl(String),
}
@@ -312,8 +453,13 @@ pub struct Session {
pub last_find: Option<Json>,
/// View › Show › Ruler is off (rulers in Page Layout view; an application option).
pub hide_ruler: bool,
/// Journal position where action recording started.
/// Journal position where Automate › Record Actions started.
pub recording: Option<usize>,
/// The recording buffer while a recorder runs ([`Session::start_recording`]): top-level
/// commands with the place they ran in, kept apart from the journal.
pub recorder: Option<record::Recorder>,
/// How many `execute` calls are running (0 between top-level commands).
depth: u32,
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
pub draw_tool: String,
pub draw_color: String,
@@ -323,6 +469,17 @@ pub struct Session {
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
/// (like Excel's comment indicator option), so it's not saved in the file.
pub show_all_notes: bool,
/// Open undo groups, innermost last.
groups: Vec<OpenGroup>,
next_group: u64,
/// Counts group begins and ends, so `execute` can tell that a command opened or closed one.
group_epoch: u64,
/// Where the last group ended, so `execute` can tell a command's own changes from its group's.
group_step: Option<GroupStep>,
/// For each running command, innermost last: its document and that document's undo length
/// when it began. Steps before that length are not touched while the command runs (see
/// [`Session::bound_tail`]).
marks: Vec<(u64, usize)>,
/// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses
/// [`cmd::format::approx_text_width`].
pub text_width: Option<TextWidth>,
@@ -377,6 +534,7 @@ impl Session {
pub fn close_document(&mut self, i: usize) {
if i < self.docs.len() {
let d = self.docs.remove(i);
self.groups.retain(|g| g.doc != d.uid);
self.windows.retain(|w| w.doc_uid != d.uid);
if self.active_window.is_some_and(|w| self.window(w).is_none()) {
self.activate_recent();
@@ -393,7 +551,7 @@ impl Session {
let name = wb.next_sheet_name();
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name)));
}
self.add_document(DocState::new(wb, None, format!("Book{n}")))
self.add_document(DocState::new(wb, None, format!("Book{n}"), Trust::Local))
}
/// Journals a command, then trims the oldest entries past either cap. A recording in progress
@@ -432,7 +590,8 @@ impl Session {
command_specs().iter().map(|c| c.info(self)).collect()
}
/// Runs a command by id. Undoable commands record a history step when the workbook changed.
/// Runs a command by id. Undoable commands record a history step when the workbook changed,
/// unless an undo group is open on the document (the group then makes the step).
/// Panics inside commands are caught and reported; the workbook is restored.
pub fn execute(&mut self, id: &str, params: Json) -> Result<Json> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
@@ -441,36 +600,355 @@ impl Session {
}
let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone()));
let params = if params.is_null() { Json::Object(Default::default()) } else { params };
let (first_group, group_epoch) = (self.next_group, self.group_epoch);
// Whether a group was open on the document before the command, and how long its history was.
let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len())));
// A command that panics leaves the history as it found it: the steps commands nested in it
// made or undid, and the redo history they cleared, come back. A length is not enough,
// because a nested `edit.undo` removes entries. A query runs nothing that changes the
// history (`every_query_leaves_the_history_alone` runs each one), so it needs no
// copy; front ends run queries all the time.
let query = !spec.journal && !spec.undoable;
let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| {
#[cfg(test)]
tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1)));
self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone()))
});
// The groups open on the document before the command (usually none), for the same reason.
let groups_before: Vec<(u64, String, Arc<Workbook>, Selection)> = match &before {
Some((uid, ..)) => {
self.groups.iter().filter(|g| g.doc == *uid).map(|g| (g.id, g.label.clone(), g.wb.clone(), g.selection.clone())).collect()
}
None => Vec::new(),
};
// The recorder keeps top-level calls with the place they ran in.
let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() {
Some(d) => (d.uid, d.wb.active_sheet(), d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
None => (0, 0, String::new(), Selection::default()),
});
let doc_uid = before.as_ref().map(|(uid, ..)| *uid);
let requests_before = self.ui_requests.len();
let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len));
self.marks.extend(marked);
self.depth = self.depth.saturating_add(1);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, &params)));
self.depth = self.depth.saturating_sub(1);
if marked.is_some() {
self.marks.pop();
}
let result = match result {
Ok(r) => r,
Err(p) => {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
// Restore the workbook as it was.
let msg = p
.downcast_ref::<&str>()
.map(ToString::to_string)
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".into());
// Restore the workbook and its history as they were, and drop the undo groups the
// command opened.
self.groups.retain(|g| g.id < first_group);
let group_open = before.as_ref().is_some_and(|(uid, ..)| self.groups.iter().any(|g| g.doc == *uid));
if let Some((uid, wb, sel)) = &before
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid)
{
d.wb = wb.clone();
d.selection = sel.clone();
if let Some((undo, redo)) = history_before {
d.undo = undo;
d.redo = redo;
}
// A group open before the command and ended or rolled back inside it stays
// ended (its token went with the command), and keeps a step from its start to
// the restored workbook, so Undo still returns to the state before the group.
if !group_open
&& let Some((_, label, wb, selection)) = groups_before.into_iter().min_by_key(|g| g.0)
&& !Arc::ptr_eq(&wb, &d.wb)
{
push_entry(&mut d.undo, label, wb, &d.wb, selection);
d.redo.clear();
}
d.calc.rebuild(&d.wb);
}
if self.depth == 0 {
self.trim_history();
self.group_step = None;
}
return Err(EngineError::Internal(id.to_string(), msg));
}
};
// A command that fails leaves none of its own undo groups open (a `?` between
// `group_begin` and `group_end`): each is rolled back as `group_rollback` does. Only a
// command that succeeds keeps a group it opened (a debugger's start).
if result.is_err() {
self.rollback_groups_from(first_group);
}
if spec.journal && result.is_ok() {
// A command that started recording is not part of the recording; one that stopped it
// finds no recorder. A menu-style call that only opened a dialog (`file.open` with no
// file, `home.formatCells` with no style) is not recorded either: the dialog's own
// call is, so playing the recording back runs the command once, with its parameters.
let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..))));
if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog)
&& let Some(rec) = self.recorder.as_mut()
{
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(&params) });
}
self.push_journal(id, cmd::journal_params(&params));
}
// Inside an undo group the group owns the step. A command that ended a group open before
// it leaves the group its step and makes one of its own for what it changed after the end.
// A command that opened and closed its own groups makes one step of its own, which
// replaces the steps those groups made.
let epoch_changed = self.group_epoch != group_epoch;
let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid));
let was_open = start.is_some_and(|(was_open, _)| was_open);
if spec.undoable
&& let Some((uid, wb, sel)) = before
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid)
&& !Arc::ptr_eq(&d.wb, &wb)
{
push_history(&mut d.undo, spec.label.trim_end_matches('…').to_string(), wb, &d.wb, sel);
let label = spec.label.trim_end_matches('…').to_string();
if in_group_now {
// A group this command opened and left open (a debugger's start) takes the
// command's start as its own, so what the command changed before opening it is
// part of the group's step and not a step of its own.
if !was_open
&& let Some((_, len)) = start
&& let Some(g) = self.groups.iter_mut().filter(|g| g.doc == uid && g.id >= first_group).min_by_key(|g| g.id)
{
g.start = len;
g.wb = wb;
g.selection = sel;
}
} else if epoch_changed && was_open {
// The group it ended made its step; changes after the end, whether direct or by
// nested commands, are the command's own step from where the group ended.
if let Some(step) = self.group_step.as_ref().filter(|g| g.doc == uid)
&& (d.undo.len() > step.len || !Arc::ptr_eq(&step.after, &d.wb))
{
d.undo.truncate(step.len);
push_entry(&mut d.undo, label, step.after.clone(), &d.wb, step.selection.clone());
d.redo.clear();
}
} else {
let mut label = label;
if epoch_changed && let Some((_, len)) = start {
// A command whose only step is its group's keeps that name ("Run Macro: Totals").
// That is so only when the one entry the command added is a group's step from
// the command's own start to its own result: an entry a nested command made,
// a group step of an earlier command (undone since), or a group's step that
// the command edited past all name the step after the command instead.
if d.undo.len() == len + 1
&& let Some(last) = d.undo.last()
&& Arc::ptr_eq(&last.wb, &wb)
&& self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.stepped && Arc::ptr_eq(&g.after, &d.wb))
{
label = last.label.clone();
}
d.undo.truncate(len);
}
push_entry(&mut d.undo, label, wb, &d.wb, sel);
d.redo.clear();
}
// Inside a group the redo history waits for the group: `group_end` clears it when the
// group becomes a step, and `group_rollback` puts it back as it was.
d.revision += 1;
}
// The history limit applies once the outermost command has collapsed its steps, so the
// lengths it compared stay true. Meanwhile the steps made inside it are bounded on their own.
if self.depth == 0 {
self.trim_history();
self.group_step = None;
} else if let Some(uid) = doc_uid {
self.bound_tail(uid);
}
result
}
/// Runs `f` as one undo step labelled `label` on the active document: whatever commands `f`
/// runs, one Undo brings back the workbook as it was before. If `f` fails, every change it made
/// is rolled back and its error is returned. Groups nest; the outermost one makes the step.
pub fn grouped<R>(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
let token = self.group_begin(label)?;
let doc = token.doc();
// A panic in `f` rolls the group back before it goes on, so a caller that catches it
// (outside `execute`, which drops a command's groups itself) finds no group left open.
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) {
Ok(Ok(r)) => match self.group_end(token) {
Ok(_) => Ok(r),
// `f` closed the group's document (`file.close`, or `file.open` taking the place of
// an untouched new workbook), and the group went with it: nothing is left to undo.
Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r),
Err(e) => Err(e),
},
Ok(Err(e)) => {
// The error from `f` is the one to report. A rollback fails only when the group
// has already ended, and then there is nothing left to undo.
let _ = self.group_rollback(token);
Err(e)
}
Err(panic) => {
let _ = self.group_rollback(token);
std::panic::resume_unwind(panic)
}
}
}
/// Opens an undo group on the active document that can span several calls (for example a
/// debugger that runs one step per call). Until [`Session::group_end`] or
/// [`Session::group_rollback`], undoable commands on that document add no undo steps of their
/// own, and Undo and Redo are unavailable there.
pub fn group_begin(&mut self, label: &str) -> Result<GroupToken> {
let d = self.doc()?;
let g = OpenGroup {
id: self.next_group,
doc: d.uid,
label: label.trim_end_matches('…').to_string(),
start: d.undo.len(),
wb: d.wb.clone(),
selection: d.selection.clone(),
redo: d.redo.clone(),
};
let token = GroupToken { id: g.id, doc: g.doc };
self.next_group = self.next_group.saturating_add(1);
self.group_epoch = self.group_epoch.wrapping_add(1);
self.groups.push(g);
Ok(token)
}
/// Ends an undo group. If the workbook changed, the group becomes one undo step, unless it is
/// nested in another open group on the same document, which then owns the step. Groups opened
/// inside it and still open end with it. Returns whether the workbook changed.
pub fn group_end(&mut self, token: GroupToken) -> Result<bool> {
let g = self.take_group(&token)?;
let outer = self.in_group(g.doc);
let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(false) };
// Steps pushed directly while the group was open fold into the group's step.
d.undo.truncate(g.start);
let changed = !Arc::ptr_eq(&d.wb, &g.wb);
if changed && !outer {
push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection);
d.redo.clear();
d.revision += 1;
}
result
if !outer {
self.group_step =
Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: d.undo.len(), stepped: changed });
}
// Inside a command, the outermost command applies the history limit when it finishes.
if self.depth == 0 {
self.trim_history();
}
Ok(changed)
}
/// Stops the running action on the document with uid `doc`: every undo group open on it is
/// rolled back as [`Session::group_rollback`] does, putting back the workbook, selection, undo
/// and redo history as they were when the outermost one began. Their tokens are then spent
/// (ending or rolling one back reports that it has already ended). Returns whether a group
/// was open. `automate.stopAction` runs it on the active document.
pub fn abort_groups(&mut self, doc: u64) -> bool {
let Some(id) = self.groups.iter().filter(|g| g.doc == doc).map(|g| g.id).min() else { return false };
// Rolling back the outermost group ends the ones opened inside it.
self.group_rollback(GroupToken { id, doc }).is_ok()
}
/// Rolls back every open undo group numbered `first` or later (the groups a failed command
/// opened), outermost first on each document.
fn rollback_groups_from(&mut self, first: u64) {
let opened: Vec<(u64, u64)> = self.groups.iter().filter(|g| g.id >= first).map(|g| (g.id, g.doc)).collect();
for (id, doc) in opened {
// A group inside one rolled back before it is already gone.
if self.groups.iter().any(|g| g.id == id) {
let _ = self.group_rollback(GroupToken { id, doc });
}
}
}
/// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per
/// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest.
/// Only steps after the latest mark go: a running command's start, an open group's start and
/// the end of the last group are positions the command or group collapses its steps by when
/// it finishes, so what is before them stays as it is until [`Session::trim_history`].
fn bound_tail(&mut self, uid: u64) {
let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
let marks = self.marks.iter().filter(|(doc, _)| *doc == uid).map(|(_, len)| *len);
let groups = self.groups.iter().filter(|g| g.doc == uid).map(|g| g.start);
let step = self.group_step.as_ref().filter(|g| g.doc == uid).map(|g| g.len);
let mark = marks.chain(groups).chain(step).max().unwrap_or(0).min(d.undo.len());
let mut bytes = d.undo.iter().skip(mark).fold(0usize, |sum, e| sum.saturating_add(e.retained_bytes));
while d.undo.len() > mark + 1 && (d.undo.len() - mark > UNDO_LIMIT || bytes > UNDO_BYTES_LIMIT) {
bytes = bytes.saturating_sub(d.undo.remove(mark).retained_bytes);
}
}
/// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with
/// an open group waits for the group to end, because the group remembers where its steps start.
fn trim_history(&mut self) {
let groups = &self.groups;
for d in self.docs.iter_mut().filter(|d| !groups.iter().any(|g| g.doc == d.uid)) {
trim_to_budget(&mut d.undo, UNDO_BYTES_LIMIT);
}
}
/// Ends an undo group by putting back the workbook, selection, undo and redo history as they
/// were when it began.
pub fn group_rollback(&mut self, token: GroupToken) -> Result<()> {
let g = self.take_group(&token)?;
let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(()) };
if !Arc::ptr_eq(&d.wb, &g.wb) {
d.wb = g.wb;
d.calc.rebuild(&d.wb);
}
d.selection = g.selection;
d.undo.truncate(g.start);
d.redo = g.redo;
d.revision += 1;
// The record of the last group step stays when the document is back exactly where that
// step left it (this group began there); otherwise the rollback is where the group ended.
let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb));
if !kept && !self.groups.iter().any(|o| o.doc == g.doc) {
self.group_step = Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: g.start, stepped: false });
}
Ok(())
}
/// Starts recording top-level commands into an empty buffer, replacing any recording in
/// progress. Queries, failed commands and commands run by other commands are not recorded.
pub fn start_recording(&mut self) {
self.recorder = Some(record::Recorder::default());
}
/// Stops recording and returns what was recorded (`None` when nothing was recording).
pub fn stop_recording(&mut self) -> Option<record::Recorder> {
self.recorder.take()
}
/// Whether the recording buffer is collecting commands.
pub fn is_recording(&self) -> bool {
self.recorder.is_some()
}
/// How many commands are running: 0 between top-level commands, 1 inside one, 2 inside a
/// command run by a command, and so on.
pub fn depth(&self) -> u32 {
self.depth
}
/// Whether an undo group is open on the document with this uid.
pub fn in_group(&self, doc: u64) -> bool {
self.groups.iter().any(|g| g.doc == doc)
}
/// Removes the token's group and any group on the same document opened after it.
fn take_group(&mut self, token: &GroupToken) -> Result<OpenGroup> {
let ended = || EngineError::Other("That undo group has already ended.".into());
let at = self.groups.iter().position(|g| g.id == token.id).ok_or_else(ended)?;
let mut later = self.groups.split_off(at).into_iter();
let g = later.next().ok_or_else(ended)?;
self.groups.extend(later.filter(|o| o.doc != g.doc));
self.group_epoch = self.group_epoch.wrapping_add(1);
Ok(g)
}
pub fn run(&mut self, id: &str, params: Json) -> std::result::Result<Json, String> {
@@ -488,6 +966,8 @@ impl Session {
let r = self.execute(id, params);
let requests = self.take_ui_requests();
let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs"));
// A call that only opened a dialog is not recorded (see `execute`), so the recording
// has no step to take back here.
if r.is_ok() && asked_for_file {
let what = match id {
"file.open" => "`path` or `base64`",
+45 -9
View File
@@ -1,17 +1,18 @@
//! Outline groups (Data ▸ Group) of a sheet's rows or columns: the runs of lines at or above
//! each outline level, as the grid draws them (brackets, − / + buttons, level buttons) and the
//! Show / Hide Detail commands act on them. Summary rows sit below their detail and summary
//! columns right of it (Excel's defaults; the model has no setting for the other way round).
//! columns right of it, or above / left of it when the sheet's outline settings say so
//! ([`Sheet::summary_below`], [`Sheet::summary_right`]).
use std::collections::BTreeMap;
use gridcraft_model::LineInfo;
use gridcraft_model::{LineInfo, Sheet};
/// The deepest outline level XLSX allows.
pub const MAX_LEVEL: u8 = 7;
/// One group: lines `start..=end` at outline level `level` or deeper, with its summary line
/// just after `end`.
/// just after `end`, or just before `start` when `summary_before` is set.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OutlineGroup {
pub level: u8,
@@ -19,25 +20,43 @@ pub struct OutlineGroup {
pub end: u32,
/// Every detail line is hidden: the button shows +.
pub collapsed: bool,
/// The summary line is above (rows) or left of (columns) the detail.
pub summary_before: bool,
}
impl OutlineGroup {
/// The summary line, where the − / + button sits.
pub fn summary(&self) -> u32 {
self.end.saturating_add(1)
if self.summary_before { self.start.saturating_sub(1) } else { self.end.saturating_add(1) }
}
/// Whether the summary line exists: a group that starts at the first line (summary before)
/// or ends at the last one (summary after) has none.
pub fn has_summary(&self) -> bool {
!self.contains(self.summary())
}
pub fn contains(&self, line: u32) -> bool {
line >= self.start && line <= self.end
}
}
/// Whether the sheet's summary rows (`rows`) or summary columns sit before their detail.
pub fn summary_before(sh: &Sheet, rows: bool) -> bool {
if rows { !sh.summary_below } else { !sh.summary_right }
}
/// The deepest outline level used by these lines (0 = no outline).
pub fn max_level(lines: &BTreeMap<u32, LineInfo>) -> u8 {
lines.values().map(|l| l.outline.min(MAX_LEVEL)).max().unwrap_or(0)
}
/// Every group, ordered by where it ends (inner groups before the group around them).
/// Every group, with its summary line after the detail; see [`groups_with`].
pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
groups_with(lines, false)
}
/// Every group, ordered by where it ends (inner groups before the group around them), with its
/// summary line before the detail when `summary_before` is set.
pub fn groups_with(lines: &BTreeMap<u32, LineInfo>, summary_before: bool) -> Vec<OutlineGroup> {
let mut out = Vec::new();
// Open group per level (index level - 1): start line and whether all its lines are hidden.
let mut open: [Option<(u32, bool)>; MAX_LEVEL as usize] = [None; MAX_LEVEL as usize];
@@ -47,7 +66,7 @@ pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
if let Some(slot) = open.get_mut(level as usize - 1)
&& let Some((start, hidden)) = slot.take()
{
out.push(OutlineGroup { level, start, end, collapsed: hidden });
out.push(OutlineGroup { level, start, end, collapsed: hidden, summary_before });
}
}
};
@@ -102,9 +121,9 @@ mod tests {
assert_eq!(
g,
vec![
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false },
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false },
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true },
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false, summary_before: false },
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false, summary_before: false },
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true, summary_before: false },
]
);
assert_eq!(g[1].summary(), 8);
@@ -129,5 +148,22 @@ mod tests {
assert_eq!(g.len(), 7);
assert!(g.iter().all(|g| g.start == u32::MAX - 1 && g.end == u32::MAX));
assert_eq!(g[0].summary(), u32::MAX);
assert!(!g[0].has_summary());
}
#[test]
fn summary_before_the_detail() {
// Rows 5..8 at level 1 with 6..7 at level 2: the summaries are rows 4 and 5.
let l = lines(&[(4, 1, false), (5, 2, false), (6, 2, false), (7, 1, false), (0, 1, false)]);
let g = groups_with(&l, true);
let at = |level: u8, start: u32| g.iter().find(|g| g.level == level && g.start == start).copied();
assert_eq!(at(1, 4).map(|g| (g.summary(), g.has_summary())), Some((3, true)));
assert_eq!(at(2, 5).map(|g| (g.summary(), g.has_summary())), Some((4, true)));
// A group on the first line has no line before it.
assert_eq!(at(1, 0).map(|g| (g.summary(), g.has_summary())), Some((0, false)));
let mut sh = Sheet::new("S");
assert!(!summary_before(&sh, true) && !summary_before(&sh, false));
sh.summary_below = false;
assert!(summary_before(&sh, true) && !summary_before(&sh, false));
}
}
+38 -2
View File
@@ -29,6 +29,12 @@ pub struct PageGrid {
pub gap: f64,
/// Print scale (1.0 = 100%): screen sizes of cells are `zoom × scale`.
pub scale: f32,
/// Left and right margins of headers and footers in layout units (the side margins, or a
/// fixed 0.7 inch, at most a quarter of the page width, with Align with Page Margins off).
pub hf_sides: [f64; 2],
/// Layout units per sheet pixel of header and footer text: 1 with Scale with Document on,
/// otherwise `1 / scale`, so the text keeps its point size on paper.
pub hf_text_k: f32,
/// Paper size in points (after orientation), for the rulers.
pub paper_pt: (f32, f32),
pub row_bands: Vec<(u32, u32)>,
@@ -65,6 +71,8 @@ impl PageGrid {
let s = f64::from(lay.scale.clamp(0.1, 4.0));
let to_layout = |pt: f32| f64::from(pt) / f64::from(PX_TO_PT) / s;
let margins = lay.margins.map(to_layout);
let (hf_left, hf_right) = lay.hf_sides();
let hf_text_k = lay.hf_text_scale() / s as f32;
let band_range = |bands: &[(u32, u32)], lo: u32, hi: u32| {
let first = bands.iter().position(|b| b.1 >= lo).unwrap_or(0);
let last = bands.iter().rposition(|b| b.0 <= hi).unwrap_or(first).max(first);
@@ -79,6 +87,8 @@ impl PageGrid {
margins,
gap: f64::from(PAGE_GAP) / s,
scale: lay.scale,
hf_sides: [to_layout(hf_left), to_layout(hf_right)],
hf_text_k,
paper_pt: (lay.page_w, lay.page_h),
rows_complete: row_bands.last().is_some_and(|b| b.1 >= MAX_ROWS - 1),
cols_complete: col_bands.last().is_some_and(|b| b.1 >= MAX_COLS - 1),
@@ -174,11 +184,12 @@ impl PageGrid {
}
/// The header or footer section under the point: the top or bottom margin of a page, split
/// into thirds between the side margins.
/// into thirds between the header and footer side margins.
pub fn hf_zone_at(&self, x: f64, y: f64) -> Option<((usize, usize), HfZone)> {
let (i, j) = self.page_at(x, y)?;
let (px, py, _, _) = self.page_rect(i, j);
let [l, r, t, b, ..] = self.margins;
let [_, _, t, b, ..] = self.margins;
let [l, r] = self.hf_sides;
let footer = if y < py + t {
false
} else if y >= py + self.page_h - b {
@@ -301,6 +312,31 @@ mod tests {
assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0));
}
#[test]
fn header_zones_and_text_follow_scale_with_document_and_align_with_page_margins() {
let mut wb = wb_with(10, 3);
if let Some(sh) = wb.sheet_mut(0) {
sh.print.scale = 50;
sh.print.margins = [2.0, 2.0, 0.75, 0.75, 0.3, 0.3];
}
let g = PageGrid::build(&wb, 0, 50, 20).unwrap();
let (px, py, _, _) = g.page_rect(0, 0);
// Both on: the sections sit between the 2-inch side margins and the text scales with the cells.
assert_eq!(g.hf_sides, [g.margins[0], g.margins[1]]);
assert!((g.hf_text_k - 1.0).abs() < 1e-6);
assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(2));
if let Some(sh) = wb.sheet_mut(0) {
sh.print.hf_scale_with_doc = false;
sh.print.hf_align_with_margins = false;
}
let g = PageGrid::build(&wb, 0, 50, 20).unwrap();
// Both off: 0.7 inch (67.2 sheet pixels, 134.4 layout units at 50%) on each side, and the
// text keeps its point size on paper.
assert!(g.hf_sides.iter().all(|s| (s - 134.4).abs() < 1e-3), "{:?}", g.hf_sides);
assert!((g.hf_text_k - 2.0).abs() < 1e-6);
assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(1));
}
#[test]
fn print_scale_and_hostile_settings_stay_bounded() {
let mut wb = wb_with(3, 3);
+79 -1
View File
@@ -11,7 +11,10 @@ use std::collections::{HashMap, HashSet};
use gridcraft_core::date::datetime_from_serial;
pub use gridcraft_core::quote_sheet;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
use gridcraft_model::*;
use gridcraft_model::{
BorderLine, BorderStyle, Cell, Chart, Color, Fill, HAlign, NumFmt, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
PivotSort, PivotTable, PivotValue, Series, Style, StyleId, Workbook,
};
use crate::pivot_calc::CalcSet;
@@ -803,6 +806,70 @@ fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -
/// Computes a PivotTable report from the workbook.
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
compute_from(wb, pt, &src, None)
}
/// Why Show Details turns a cell down when it isn't a value cell of a PivotTable (the front end
/// edits that cell instead).
pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable";
/// Show Details: the source list and the indices of its records behind the value cell at
/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when
/// that cell holds no value (a label, a header, a blank). An error, for any cell, when the report
/// on the sheet is no longer what the source computes: the cell's records would be another cell's.
pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result<Option<(Source, Vec<usize>)>, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
let mut probe = Probe { at: Some((row as usize, col as usize)), hit: None, paths: Some(Vec::new()), cells: None };
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
// Checked first: in a stale report even a cell that computes as a label may show a value.
check_current(wb, pt, pivot_sheet, &out)?;
let Some((rp, cp)) = probe.hit else { return Ok(None) };
let records =
probe.paths.unwrap_or_default().into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
Ok(Some((src, records)))
}
/// The cells of the report grid (relative to its top-left) where [`detail_records`] finds
/// records: its value, subtotal and grand total cells, from one computation of the report. The
/// same errors as [`detail_records`].
pub fn detail_cells(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<HashSet<(u32, u32)>, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
let mut probe = Probe { at: None, hit: None, paths: None, cells: Some(HashSet::new()) };
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
check_current(wb, pt, pivot_sheet, &out)?;
Ok(probe.cells.unwrap_or_default().into_iter().map(|(r, c)| (r as u32, c as u32)).collect())
}
/// An error when the report on the sheet is not what `out` computes. Excel reads its PivotTable
/// cache; without one, the report written by the last refresh must still be what the source gives.
fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Output) -> Result<(), String> {
let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?;
let current = pt.last_range.is_some_and(|r| {
r.height() == out.height().max(1)
&& r.width() == out.width.max(1)
&& out.grid.iter().enumerate().all(|(ri, line)| {
line.iter().enumerate().all(|(ci, oc)| {
sh.value_ref(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)).unwrap_or(&Value::Empty) == &oc.value
})
})
});
if !current {
return Err("The PivotTable report is out of date. Refresh it first.".into());
}
Ok(())
}
/// What [`detail_records`] and [`detail_cells`] ask of a report computation: the row and column
/// item paths of the value cell at `at`, the paths of every record the filters let through (when
/// `paths` is set), and every value cell (when `cells` is set).
struct Probe {
at: Option<(usize, usize)>,
hit: Option<(Vec<u32>, Vec<u32>)>,
paths: Option<Vec<(usize, Vec<u32>, Vec<u32>)>>,
cells: Option<HashSet<(usize, usize)>>,
}
fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into());
let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
@@ -914,6 +981,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
if !col_leaves.contains(&cp[..shown]) {
col_leaves.insert(cp[..shown].to_vec());
}
if let Some(paths) = probe.as_deref_mut().and_then(|pr| pr.paths.as_mut()) {
paths.push((ri, rp.clone(), cp.clone()));
}
row_set.insert(rp);
col_set.insert(cp);
// Refuse a report that cannot fit before the accumulators for more column groups are allocated.
@@ -1278,6 +1348,14 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
_ => raw,
},
};
if let Some(pr) = probe.as_deref_mut() {
if pr.at == Some((r, l + j)) {
pr.hit = Some((rp.to_vec(), cp.to_vec()));
}
if let Some(cells) = pr.cells.as_mut() {
cells.insert((r, l + j));
}
}
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
}
}
+66
View File
@@ -0,0 +1,66 @@
//! The recording buffer: the commands a person (or an agent) ran while recording, each with the
//! document, sheet and selection it ran in.
//!
//! Only top-level calls are kept. A command that runs other commands (Custom Views › Show, a
//! script) is one step, so playing the recording back does not run the inner commands twice. The
//! buffer is separate from the journal, so trimming the journal never loses recorded steps, and
//! secret parameters are left out as they are in the journal ([`crate::cmd::journal_params`]).
use serde::Serialize;
use serde_json::Value as Json;
use crate::Selection;
/// The most steps one recording keeps; later ones are counted in [`Recorder::dropped`].
pub const RECORD_LIMIT: usize = 100_000;
/// The most bytes (steps serialized as JSON) one recording keeps. A step keeps its full
/// parameters so it can be played back, and a `file.open` holds the whole file as base64, so
/// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive.
pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024;
/// One recorded command and where it ran.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordedStep {
/// Uid of the document that was active when the command ran (0 when none was open).
pub doc: u64,
/// That document's active sheet (index and name) just before the command ran.
pub sheet: usize,
pub sheet_name: String,
/// That document's selection just before the command ran.
pub selection: Selection,
/// The command id and its parameters, without secrets.
pub id: String,
pub params: Json,
}
/// The steps recorded since recording started.
#[derive(Clone, Debug, Default)]
pub struct Recorder {
pub steps: Vec<RecordedStep>,
/// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`].
/// Once a step is dropped, every later one is too, so `steps` is always the beginning of
/// what ran, never a sequence with gaps.
pub dropped: usize,
/// The size of `steps` serialized as JSON.
bytes: usize,
}
impl Recorder {
/// Adds a step, or counts it as dropped once the buffer is full.
pub fn push(&mut self, step: RecordedStep) {
// Once a step has been dropped, or the step count is full, the rest are counted without
// being serialized (a `file.open` step holds a whole file).
if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT {
self.dropped = self.dropped.saturating_add(1);
return;
}
let size = serde_json::to_vec(&step).map_or(0, |v| v.len());
if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES {
self.bytes = self.bytes.saturating_add(size);
self.steps.push(step);
} else {
self.dropped = self.dropped.saturating_add(1);
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn build(name: &str) -> Option<Workbook> {
_ => return None,
};
}
s.add_document(DocState::new(wb, None, title(name)));
s.add_document(DocState::new(wb, None, title(name), crate::Trust::Local));
let ok = match name {
"budget" => budget(&mut s),
"sales" => sales(&mut s),
+1 -1
View File
@@ -2,7 +2,7 @@
//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::*;
use gridcraft_model::{BorderLine, BorderStyle, Cell, Color, Fill, Formula, PatternType, Style, Table, Workbook};
/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2).
pub fn parse_style_name(name: &str) -> (&'static str, u32) {
+450
View File
@@ -3,6 +3,11 @@ use serde_json::json;
use crate::Session;
thread_local! {
/// Copies of a document's undo and redo history `execute` has taken on this thread.
pub(crate) static HISTORY_COPIES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn s() -> Session {
let mut s = Session::new();
s.new_workbook();
@@ -936,6 +941,86 @@ fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() {
assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]);
}
#[test]
fn fill_days_weekdays_months_and_years_step_a_date_by_that_unit() {
let mut s = s();
let serials = |s: &Session, cells: &[&str]| cells.iter().map(|c| v(s, c)).collect::<Vec<_>>();
let n = |xs: &[f64]| xs.iter().map(|x| Value::Number(*x)).collect::<Vec<_>>();
// Friday 1/30/2026 is serial 46052.
s.execute("cell.set", json!({"cell": "A1", "input": "1/30/2026"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(46052.0), "setup: a date");
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "days"})).unwrap();
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46053.0, 46054.0, 46055.0]));
// Fill Weekdays skips Saturday 1/31 and Sunday 2/1.
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46055.0, 46056.0, 46057.0]));
// From Saturday 1/31, the first weekday is Monday 2/2.
s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46055.0, 46056.0]));
// Fill Months keeps the day, clamped to the month's last day: Feb 28, Mar 31, Apr 30.
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B4", "mode": "months"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3", "B4"]), n(&[46081.0, 46112.0, 46142.0]));
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "years"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46418.0, 46783.0]));
// Two dates step by their difference in the unit: 1/15 and 3/15 by two months.
s.execute("cell.set", json!({"cell": "C1", "input": "1/15/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "3/15/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "months"})).unwrap();
assert_eq!(serials(&s, &["C3", "C4"]), n(&[46157.0, 46218.0]));
// Monday 2/2 and Wednesday 2/4 step by two weekdays: Friday 2/6, Tuesday 2/10.
s.execute("cell.set", json!({"cell": "D1", "input": "2/2/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "D2", "input": "2/4/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["D3", "D4"]), n(&[46059.0, 46063.0]));
// Upward from Monday 2/2: Friday 1/30, Thursday 1/29.
s.execute("cell.set", json!({"cell": "E5", "input": "2/2/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "E5", "target": "E3:E5", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["E4", "E3"]), n(&[46052.0, 46051.0]));
// A number that isn't a date fills as a plain drag does: a lone number copies.
s.execute("cell.set", json!({"cell": "F1", "input": "7"})).unwrap();
s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "days"})).unwrap();
assert_eq!(v(&s, "F2"), Value::Number(7.0));
// An upper-case date format is a date: 1/31/2026 as YYYY-MM-DD fills by months.
s.execute("cell.set", json!({"cell": "G1", "input": "1/31/2026"})).unwrap();
s.execute("home.numberFormat", json!({"range": "G1", "code": "YYYY-MM-DD"})).unwrap();
s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G2", "mode": "months"})).unwrap();
assert_eq!(v(&s, "G2"), Value::Number(46081.0));
// A date with a time keeps the time: Fill Months and Fill Weekdays, a plain drag of two dates
// a month apart, and the Series dialog by months.
s.execute("cell.set", json!({"cell": "H1", "input": "1/31/2026 12:00"})).unwrap();
assert_eq!(v(&s, "H1"), Value::Number(46053.5), "setup: a date and time");
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "months"})).unwrap();
assert_eq!(v(&s, "H2"), Value::Number(46081.5));
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "weekdays"})).unwrap();
assert_eq!(v(&s, "H2"), Value::Number(46055.5));
s.execute("cell.set", json!({"cell": "I1", "input": "1/15/2026 6:00"})).unwrap();
s.execute("cell.set", json!({"cell": "I2", "input": "2/15/2026 6:00"})).unwrap();
s.execute("edit.autoFill", json!({"source": "I1:I2", "target": "I1:I3"})).unwrap();
assert_eq!(v(&s, "I3"), Value::Number(46096.25));
s.execute("cell.set", json!({"cell": "J1", "input": "1/31/2026 12:00"})).unwrap();
s.execute("edit.fillSeries", json!({"range": "J1:J2", "type": "date", "dateUnit": "month"})).unwrap();
assert_eq!(v(&s, "J2"), Value::Number(46081.5));
}
#[test]
fn only_a_number_in_a_date_format_offers_the_date_fills() {
let mut s = s();
let dates = |s: &Session, a: &str| crate::fill::starts_with_date(&s.doc().unwrap().wb, 0, gridcraft_core::RangeRef::parse(a).unwrap());
s.execute("cell.set", json!({"cell": "A1", "input": "1/31/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "7"})).unwrap();
s.execute("home.numberFormat", json!({"range": "A2", "code": "0 \"days\""})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": "46053"})).unwrap();
s.execute("home.numberFormat", json!({"range": "A3", "code": "YYYY-MM-DD"})).unwrap();
s.execute("cell.set", json!({"cell": "A4", "input": "=A1"})).unwrap();
s.execute("cell.set", json!({"cell": "A5", "input": "13:30"})).unwrap();
assert!(dates(&s, "A1") && dates(&s, "A3"), "m/d/yyyy and YYYY-MM-DD are dates");
assert!(!dates(&s, "A2"), "quoted text in a number format is not a date field");
assert!(!dates(&s, "A4"), "a formula is not offered");
assert!(!dates(&s, "A5"), "a time is not a date");
assert!(!dates(&s, "A6"), "an empty cell is not a date");
}
#[test]
fn insert_delete_cells_adjust() {
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
@@ -1093,6 +1178,131 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() {
s.execute("automate.runScript", json!({"name": "Lock"})).unwrap();
}
/// A session holding a workbook opened from file bytes whose `Fill` script sets B1 (#2178).
fn opened_with_script() -> Session {
let mut author = s();
author
.execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]}))
.unwrap();
let bytes = crate::io::save_bytes(&author.doc().unwrap().wb, "book.xlsx").unwrap();
let mut s = Session::new();
s.execute("file.open", json!({"name": "downloaded.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap();
s
}
#[test]
fn scripts_written_in_this_session_run_without_a_trust_step() {
let mut s = s();
assert!(s.doc().unwrap().scripts_trusted);
s.execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]}))
.unwrap();
assert_eq!(s.execute("automate.runScript", json!({"name": "Fill"})).unwrap()["ran"], 1);
assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
}
#[test]
fn untrusted_workbook_scripts_are_refused_until_trusted() {
let mut s = opened_with_script();
s.ui_requests.clear();
assert!(!s.doc().unwrap().scripts_trusted, "a workbook opened with scripts starts untrusted");
let listed = s.execute("automate.allScripts", json!({})).unwrap();
assert_eq!(listed[0]["trusted"], false, "{listed}");
let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string();
assert!(err.contains("trust"), "{err}");
assert_eq!(v(&s, "B1"), Value::Empty, "no step of an untrusted script runs");
// Trust can't be smuggled in as a string, and nothing opens a dialog.
assert!(s.execute("automate.runScript", json!({"name": "Fill", "trusted": "yes"})).is_err());
assert!(s.ui_requests.is_empty(), "{:?}", s.ui_requests);
// An explicit `trusted: true` runs it this once without trusting the workbook.
s.execute("automate.runScript", json!({"name": "Fill", "trusted": true})).unwrap();
assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
assert!(!s.doc().unwrap().scripts_trusted);
s.execute("cell.set", json!({"cell": "B1", "input": ""})).unwrap();
assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
// Trusting the workbook (the UI's Trust Scripts action) lets its scripts run this session.
assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true);
s.execute("automate.runScript", json!({"name": "Fill"})).unwrap();
assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
// A second window on the same workbook keeps the decision; withdrawing it disables them again.
s.execute("view.newWindow", json!({})).unwrap();
assert!(s.doc().unwrap().scripts_trusted);
s.execute("automate.trustScripts", json!({"trusted": false})).unwrap();
assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
}
#[test]
fn trust_scripts_only_defaults_to_trusted_when_the_flag_is_missing() {
let mut s = opened_with_script();
// A `trusted` that isn't a boolean is an error and leaves the workbook untrusted.
for wrong in [json!("false"), json!("true"), json!(0), json!(1), json!(null), json!([true])] {
let err = s.execute("automate.trustScripts", json!({"trusted": wrong})).unwrap_err().to_string();
assert!(err.contains("trusted"), "{wrong}: {err}");
assert!(!s.doc().unwrap().scripts_trusted, "{wrong} must not trust the workbook");
assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
}
// The refusal names where the Trust Scripts button actually is.
let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string();
assert!(err.contains("View › Macros") && err.contains("automate.trustScripts"), "{err}");
assert_eq!(s.execute("automate.trustScripts", json!({"trusted": false})).unwrap()["trusted"], false);
assert!(!s.doc().unwrap().scripts_trusted);
assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true);
assert!(s.doc().unwrap().scripts_trusted);
}
#[test]
fn scripts_leave_file_access_working_after_they_run() {
// The sandbox a script runs in is lifted afterwards, also when a step fails.
let dir = std::env::temp_dir().join(format!("gridcraft-script-sandbox-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let csv = dir.join("in.csv");
std::fs::write(&csv, b"a,b\n1,2\n").unwrap();
let csv = csv.to_string_lossy().to_string();
let mut s = s();
let bad_step = json!({"command": "cell.set", "params": {"cell": "not a cell", "input": "x"}});
s.execute("automate.newScript", json!({"name": "Bad", "commands": [bad_step]})).unwrap();
assert!(s.execute("automate.runScript", json!({"name": "Bad"})).is_err());
s.execute("automate.newScript", json!({"name": "Ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "1"}}]})).unwrap();
s.execute("automate.runScript", json!({"name": "Ok"})).unwrap();
s.execute("data.fromTextCsv", json!({"path": csv})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scripts_cannot_reach_outside_the_workbook_even_when_trusted() {
let dir = std::env::temp_dir().join(format!("gridcraft-script-deny-{}", std::process::id()));
let leak = dir.join("leak.xlsx");
let leak = leak.to_string_lossy().to_string();
let mut s = opened_with_script();
s.ui_requests.clear();
s.execute("automate.trustScripts", json!({})).unwrap();
let denied = [
json!({"command": "link.open", "params": {"url": "https://example.com/?d=secret"}}),
json!({"command": "file.saveAs", "params": {"path": leak}}),
json!({"command": "file.save", "params": {}}),
json!({"command": "file.exportPdf", "params": {"path": leak}}),
json!({"command": "file.open", "params": {"path": leak}}),
json!({"command": "data.getData", "params": {"path": leak}}),
json!({"command": "data.fromTextCsv", "params": {"path": leak}}),
json!({"command": "insert.picture", "params": {"path": leak}}),
];
for step in denied {
// The harmless first step proves the whole script is refused before anything runs.
let first = json!({"command": "cell.set", "params": {"cell": "C1", "input": "partial"}});
s.execute("automate.newScript", json!({"name": "Leak", "commands": [first, step]})).unwrap();
let r = s.execute("automate.runScript", json!({"name": "Leak", "trusted": true}));
assert!(r.is_err(), "{step} must be refused");
assert_eq!(v(&s, "C1"), Value::Empty, "{step}: no step runs");
}
assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "{:?}", s.ui_requests);
assert!(!std::path::Path::new(&leak).exists());
// Data a script supplies itself is still fine.
s.execute("automate.newScript", json!({"name": "Csv", "commands": [{"command": "data.fromTextCsv", "params": {"text": "a,b\n1,2"}}]})).unwrap();
s.execute("automate.runScript", json!({"name": "Csv"})).unwrap();
}
/// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected.
fn protected(extra: serde_json::Value) -> Session {
let mut s = s();
@@ -1790,6 +2000,69 @@ fn autosum_over_whole_rows_is_one_undo_step_within_the_data() {
assert_eq!(v(&s, "B6"), Value::Empty);
}
fn formula_at(s: &Session, a: &str) -> Option<String> {
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(a).unwrap()).and_then(|c| c.formula.as_ref().map(|f| f.text.clone()))
}
#[test]
fn autosum_over_a_row_totals_it_to_the_right() {
// As in Excel: a row selected and AutoSum puts one total to its right, not a one-cell total
// under each of its cells.
let mut s = s();
s.execute("range.setValues", json!({"range": "B2", "values": [[1, 2, 3, 4]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "B2:E2"})).unwrap();
assert_eq!(formula_at(&s, "F2").as_deref(), Some("SUM(B2:E2)"));
assert_eq!(v(&s, "F2"), Value::Number(10.0));
assert!(gridcraft_core::RangeRef::parse("B3:F3").unwrap().iter().all(|c| v(&s, &c.a1()) == Value::Empty), "nothing under the row");
assert_eq!(s.doc().unwrap().undo.len(), 2, "one undo step");
// A row whose last cell is empty takes its total there.
s.execute("range.setValues", json!({"range": "B4", "values": [[5, 6, 7]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "B4:E4"})).unwrap();
assert_eq!(formula_at(&s, "E4").as_deref(), Some("SUM(B4:D4)"));
assert_eq!(v(&s, "F4"), Value::Empty);
// AVERAGE and the rest go the same way.
s.execute("formulas.autoSum", json!({"range": "B2:E2", "function": "average"})).unwrap();
assert_eq!(formula_at(&s, "F2").as_deref(), Some("AVERAGE(B2:E2)"));
}
#[test]
fn autosum_over_a_whole_row_totals_it_after_the_data() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A2", "values": [[1, 2, 3]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "2:2"})).unwrap();
assert_eq!(formula_at(&s, "D2").as_deref(), Some("SUM(A2:C2)"));
assert_eq!(s.doc().unwrap().wb.active().unwrap().cells.used_range().unwrap().a1(), "A2:D2", "not out to the last column");
}
#[test]
fn autosum_fills_a_selections_empty_last_column_and_row() {
// As in Excel: an empty last column takes each row's total, an empty last row each column's,
// and with both the corner takes the total of the block.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "A1:C2"})).unwrap();
assert_eq!((formula_at(&s, "C1").as_deref(), formula_at(&s, "C2").as_deref()), (Some("SUM(A1:B1)"), Some("SUM(A2:B2)")));
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(3.0), Value::Number(7.0)));
assert!(gridcraft_core::RangeRef::parse("A3:C3").unwrap().iter().all(|c| v(&s, &c.a1()) == Value::Empty), "no totals under the rows");
s.execute("range.setValues", json!({"range": "E1", "values": [[1, 2], [3, 4]]})).unwrap();
let steps = s.doc().unwrap().undo.len();
s.execute("formulas.autoSum", json!({"range": "E1:G3"})).unwrap();
for (cell, formula, value) in
[("E3", "SUM(E1:E2)", 4.0), ("F3", "SUM(F1:F2)", 6.0), ("G1", "SUM(E1:F1)", 3.0), ("G2", "SUM(E2:F2)", 7.0), ("G3", "SUM(E1:F2)", 10.0)]
{
assert_eq!(formula_at(&s, cell).as_deref(), Some(formula), "{cell}");
assert_eq!(v(&s, cell), Value::Number(value), "{cell}");
}
assert_eq!(s.doc().unwrap().undo.len(), steps + 1, "one undo step");
// Neither empty: totals below each column, as before.
s.execute("range.setValues", json!({"range": "I1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "I1:J2"})).unwrap();
assert_eq!((formula_at(&s, "I3").as_deref(), formula_at(&s, "J3").as_deref()), (Some("SUM(I1:I2)"), Some("SUM(J1:J2)")));
assert_eq!(v(&s, "K1"), Value::Empty);
}
#[test]
fn insert_and_delete_cells_never_shift_part_of_a_table() {
// Found by the command-sequence fuzzer: Delete Cells moved part of a table's cells (so two
@@ -2598,6 +2871,21 @@ fn file_options_reports_settings_and_asks_for_the_options_page() {
assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook");
}
/// Ctrl+8 hides and shows the outline symbols; it's undoable and a sheet setting.
#[test]
fn show_outline_symbols_toggles() {
let mut s = s();
let on = |s: &Session| s.active().unwrap().wb.active().unwrap().show_outline_symbols;
assert!(on(&s));
assert_eq!(s.execute("view.showOutlineSymbols", json!({})).unwrap(), json!({"on": false}));
assert!(!on(&s));
s.execute("edit.undo", json!({})).unwrap();
assert!(on(&s));
s.execute("view.showOutlineSymbols", json!({"on": false})).unwrap();
reopen(&mut s);
assert!(!on(&s));
}
fn reopen(s: &mut Session) {
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
@@ -2642,6 +2930,42 @@ fn calculation_options_enable_iterative_calculation() {
near(&s, "A1", 10.0);
}
#[test]
fn error_checking_quotes_sheet_names_in_circular_references() {
let mut s = s();
s.execute("home.insertSheet", json!({"name": "Bob's Q1"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
let r = s.execute("formulas.errorChecking", json!({})).unwrap();
assert_eq!(r["circular"], json!(["'Bob''s Q1'!B2"]));
// The reference reads back in a formula and in Go To.
let reference = r["circular"][0].as_str().unwrap().to_string();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": format!("=ROW({reference})")})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(2.0));
s.execute("edit.goTo", json!({"reference": reference})).unwrap();
let d = s.doc().unwrap();
assert_eq!((d.wb.active_sheet(), d.selection.active.a1()), (1, "B2".to_string()));
}
/// Undo and redo put cells back without calculating them; the circular references listed are
/// those of the restored cells.
#[test]
fn undo_and_redo_list_the_circular_references_of_the_restored_cells() {
let mut s = s();
let listed = |s: &Session| s.doc().unwrap().calc.circular.iter().map(|(si, c)| (*si, c.a1())).collect::<Vec<_>>();
s.execute("cell.set", json!({"cell": "A5", "input": "=A5+1"})).unwrap();
assert_eq!(listed(&s), vec![(0, "A5".to_string())]);
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
assert_eq!(listed(&s), vec![(0, "A6".to_string())]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(listed(&s), vec![(0, "A5".to_string())]);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(listed(&s), vec![(0, "A6".to_string())]);
// Undoing the loop itself lists none.
s.execute("edit.undo", json!({"steps": 2})).unwrap();
assert!(listed(&s).is_empty());
}
fn chart_data(s: &mut Session) {
s.execute(
"range.setValues",
@@ -3125,6 +3449,19 @@ fn absolute_objects_ignore_sort_and_structural_edits() {
assert_eq!(image_cell(&s, pinned), Some(CellRef::parse("A3").unwrap()));
}
/// Deleting a row removes the sparkline located on it; later ones shift up.
#[test]
fn deleting_a_row_removes_its_sparklines() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]})).unwrap();
s.execute("insert.sparkline", json!({"range": "A2:C2", "location": "E2"})).unwrap();
s.execute("insert.sparkline", json!({"range": "A3:C3", "location": "E3"})).unwrap();
s.execute("home.deleteRows", json!({"rows": "2:2"})).unwrap();
let got: Vec<(String, String)> =
s.doc().unwrap().wb.active().unwrap().sparklines.iter().map(|sp| (sp.cell.to_string(), sp.source.clone())).collect();
assert_eq!(got, vec![("E2".to_string(), "A2:C2".to_string())]);
}
/// A "move but don't size" object follows its row across a sort but keeps its size.
#[test]
fn move_only_objects_follow_but_do_not_resize() {
@@ -3517,6 +3854,35 @@ fn notes_comments_and_links_survive_a_json_save() {
assert_eq!(after.cells, before.cells);
}
/// Writing a note or a comment on a cell that has one of the same kind edits its text: the
/// replies, the resolved state, the dates and the note's box stay. `comment` replaced the whole
/// thread, so editing a comment dropped its replies.
#[test]
fn editing_a_note_or_a_comment_keeps_its_thread() {
let mut s = s();
s.execute("review.newComment", json!({"cell": "B2", "text": "first", "author": "Ada"})).unwrap();
s.execute("review.replyComment", json!({"cell": "B2", "text": "reply", "author": "Bob"})).unwrap();
s.execute("review.resolveComment", json!({"cell": "B2", "resolved": true})).unwrap();
s.execute("review.newComment", json!({"cell": "B2", "text": "edited"})).unwrap();
s.execute("review.newNote", json!({"cell": "A1", "text": "note", "author": "Ada"})).unwrap();
s.execute("review.showNote", json!({"cell": "A1"})).unwrap();
s.execute("review.newNote", json!({"cell": "A1", "text": "changed"})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
let thread = &sh.comments[&CellRef::parse("B2").unwrap()];
assert_eq!((thread.author.as_str(), thread.text.as_str()), ("Ada", "edited"));
assert_eq!(thread.replies, vec![("Bob".to_string(), "reply".to_string())]);
assert!(thread.resolved && thread.threaded);
let note = &sh.comments[&CellRef::parse("A1").unwrap()];
assert_eq!((note.author.as_str(), note.text.as_str()), ("Ada", "changed"));
assert!(note.visible && !note.threaded);
// A note written over a comment replaces it.
s.execute("review.newNote", json!({"cell": "B2", "text": "plain"})).unwrap();
let d = s.doc().unwrap();
let replaced = &d.wb.active().unwrap().comments[&CellRef::parse("B2").unwrap()];
assert!(!replaced.threaded && replaced.replies.is_empty());
}
/// A cut moves the block's notes, hyperlinks and merged areas with it. The source's cells and
/// pictures were removed but the block's merges and notes were not, so one merged block became
/// two (an empty one left at the old place) and a note ended up on both the old and the new
@@ -4667,3 +5033,87 @@ fn open_replaces_an_untouched_blank_workbook_and_its_windows() {
assert_eq!(s.windows().len(), 1, "the blank workbook's windows closed with it");
assert_eq!(s.windows()[0].doc_uid, s.doc().unwrap().uid);
}
#[test]
fn format_cells_permission_on_a_protected_sheet_cannot_unlock_cells() {
// Formatting may be allowed on a protected sheet; the permission can't serve as a key to
// content edits, so the format cells may not drop a cell's `locked` flag.
let mut s = s();
s.execute("cell.set", json!({"cell": "B2", "input": "hi"})).unwrap();
s.execute("review.protectSheet", json!({"formatCells": true})).unwrap();
s.execute("home.fontName", json!({"name": "Arial"})).unwrap();
let err = s.execute("home.lockCell", json!({"range": "B2", "on": false})).expect_err("unlock through formatting");
assert!(err.to_string().contains("protected"), "{err}");
assert!(s.execute("cell.set", json!({"cell": "B2", "input": "x"})).is_err(), "B2 stays locked");
// The same calls run once the sheet is unprotected.
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "x"})).unwrap();
assert_eq!(v(&s, "B2"), Value::text("x"));
}
#[test]
fn move_columns_checks_the_protection_of_the_named_sheet() {
// The move's protection checks must authorize the sheet it modifies, not whichever sheet
// happens to be active while the command names another one.
let mut s = s();
s.execute("home.insertSheet", json!({"name": "P"})).unwrap();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
let err = s.execute("sheet.moveColumns", json!({"sheet": "P", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet");
assert!(err.to_string().contains("protected"), "{err}");
let pv: Vec<Value> = {
let d = s.doc().unwrap();
let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()];
(0..4).map(|c| sh.value(CellRef::new(0, c as u32))).collect()
};
assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]);
// The active, unprotected sheet moves fine.
s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D"]]})).unwrap();
s.execute("sheet.moveColumns", json!({"cols": "B:B", "before": 4})).unwrap();
assert_eq!(v(&s, "A1"), Value::text("A"));
}
#[test]
fn a_script_cannot_switch_windows() {
// Stored scripts are scoped to their workbook: a step that activates another window would
// point every later step at another open document, so it is refused before any step runs.
let mut s = s();
s.execute("automate.newScript", json!({"name": "t", "commands": [{"command": "window.activate", "params": {"index": 0}}]})).unwrap();
s.execute("automate.trustScripts", json!({"trusted": true})).unwrap();
let err = s.execute("automate.runScript", json!({"name": "t"})).expect_err("script switching windows");
assert!(err.to_string().contains("window.activate"), "{err}");
s.execute("automate.newScript", json!({"name": "sw", "commands": [{"command": "view.switchWindows", "params": {"doc": 1}}]})).unwrap();
let err = s.execute("automate.runScript", json!({"name": "sw"})).expect_err("script switching windows from the View tab");
assert!(err.to_string().contains("view.switchWindows"), "{err}");
// A benign script runs.
s.execute("automate.newScript", json!({"name": "ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "=1+1"}}]})).unwrap();
s.execute("automate.runScript", json!({"name": "ok"})).unwrap();
assert_eq!(v(&s, "A1"), Value::number(2.0));
}
mod seams;
#[test]
fn a_saved_workbook_keeps_its_values_until_an_edit_reaches_them() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=10"})).unwrap();
let mut wb = (*s.doc().unwrap().wb).clone();
// The values a file would have saved, not the ones the formulas give.
for (a, n) in [("B1", 99.0), ("C1", 7.0)] {
let sheet = std::sync::Arc::make_mut(&mut wb.sheets[0]);
let c = CellRef::parse(a).unwrap();
let mut cell = sheet.cells.get(c).unwrap().clone();
cell.value = Value::Number(n);
sheet.set_cell(c, cell);
}
let mut saved = Session::new();
saved.add_document(crate::DocState::from_saved(wb, None, "saved".into(), crate::Trust::FromFile));
assert_eq!(v(&saved, "B1"), Value::Number(99.0));
saved.execute("cell.set", json!({"cell": "A1", "input": "3"})).unwrap();
assert_eq!(v(&saved, "B1"), Value::Number(6.0));
assert_eq!(v(&saved, "C1"), Value::Number(7.0));
}
File diff suppressed because it is too large Load Diff