mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-28 04:36:51 -06:00
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:
@@ -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"),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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&lifier; b"), "a&lifier; b");
|
||||
assert_eq!(decode("&ampampampAMP;"), "&ampampampAMP;");
|
||||
// Inside the window the usual forms still decode, and a trailing bare '&' too.
|
||||
assert_eq!(decode("&"), "&");
|
||||
assert_eq!(decode("♪"), "\u{266A}");
|
||||
assert_eq!(decode("a&b c"), "a&b\u{a0}c");
|
||||
assert_eq!(decode("amp&"), "amp&");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_markup_does_not_panic() {
|
||||
for html in [
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!([]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
@@ -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)) = §ion {
|
||||
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
@@ -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
@@ -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
@@ -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, ¶ms)));
|
||||
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(¶ms) });
|
||||
}
|
||||
self.push_journal(id, cmd::journal_params(¶ms));
|
||||
}
|
||||
// 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`",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user