mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 21:56:53 -06:00
Main adds UiRequest::InsertName (Paste Name, Use in Formula), which puts a name in the cell editor; a macro step drops it, as it drops a request to start editing a cell.
This commit is contained in:
@@ -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",
|
||||
@@ -1329,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 {
|
||||
@@ -1339,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;
|
||||
}
|
||||
@@ -1359,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();
|
||||
@@ -1369,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();
|
||||
@@ -1384,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()) {
|
||||
@@ -1419,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;
|
||||
}
|
||||
}
|
||||
@@ -1436,6 +1452,53 @@ 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.
|
||||
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;
|
||||
}
|
||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -1476,6 +1539,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() {
|
||||
@@ -1540,6 +1608,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();
|
||||
@@ -1771,6 +1861,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();
|
||||
|
||||
+274
-16
@@ -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
|
||||
),
|
||||
@@ -577,26 +577,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> {
|
||||
@@ -1462,6 +1590,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 {
|
||||
@@ -1786,6 +1918,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.
|
||||
|
||||
@@ -1034,6 +1034,11 @@ 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.") {
|
||||
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}`"));
|
||||
}
|
||||
@@ -1270,7 +1275,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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, Workbook};
|
||||
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
@@ -55,6 +55,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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),
|
||||
@@ -510,6 +519,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);
|
||||
@@ -664,8 +728,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}))
|
||||
}
|
||||
|
||||
@@ -877,4 +945,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!([]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
@@ -582,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,
|
||||
|
||||
@@ -566,9 +566,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);
|
||||
|
||||
@@ -184,10 +184,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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}",
|
||||
"{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",
|
||||
@@ -755,8 +764,13 @@ fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.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)).map_err(other)?;
|
||||
let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
||||
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."));
|
||||
}
|
||||
@@ -824,6 +838,32 @@ fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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") {
|
||||
@@ -861,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2335,6 +2376,7 @@ mod tests {
|
||||
"pivot.changeSource",
|
||||
"pivot.delete",
|
||||
"pivot.showDetails",
|
||||
"pivot.options",
|
||||
];
|
||||
let hostile = [
|
||||
json!({}),
|
||||
@@ -2580,4 +2622,72 @@ mod tests {
|
||||
}
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
+174
-93
@@ -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 {
|
||||
@@ -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();
|
||||
|
||||
@@ -401,8 +401,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) {
|
||||
|
||||
@@ -44,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!(
|
||||
@@ -115,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
|
||||
),
|
||||
@@ -187,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()?;
|
||||
@@ -392,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);
|
||||
@@ -405,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,
|
||||
@@ -478,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);
|
||||
}
|
||||
}
|
||||
|
||||
+145
-6
@@ -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 {
|
||||
|
||||
+126
-4
@@ -460,6 +460,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> {
|
||||
@@ -491,6 +496,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)) {
|
||||
@@ -600,14 +616,27 @@ fn script_sandbox_check(path: &str) -> Result<()> {
|
||||
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>> {
|
||||
use std::io::Read;
|
||||
script_sandbox_check(path)?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() > 2 * 1024 * 1024 * 1024 {
|
||||
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")]
|
||||
@@ -618,10 +647,35 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
|
||||
#[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);
|
||||
@@ -723,6 +777,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();
|
||||
|
||||
@@ -209,6 +209,9 @@ pub struct DocState {
|
||||
/// While a macro's footprint is measured: every cell a command wrote, by sheet name, even
|
||||
/// where it wrote what was already there.
|
||||
pub touched: Option<Vec<(String, CellRef)>>,
|
||||
/// 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);
|
||||
@@ -300,6 +303,7 @@ impl DocState {
|
||||
scripts_trusted,
|
||||
trust,
|
||||
touched: None,
|
||||
go_to_recent: vec![],
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -362,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 {
|
||||
@@ -376,6 +401,7 @@ impl Default for Prefs {
|
||||
r1c1: false,
|
||||
user_dictionary: vec![],
|
||||
formula_bar: true,
|
||||
status_bar: StatusBarStats::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,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),
|
||||
}
|
||||
|
||||
|
||||
@@ -694,14 +694,15 @@ impl Machine {
|
||||
let prev = self.guard.as_ref().and_then(|_| s.docs.iter().find(|d| d.uid == self.doc)).map(|d| d.wb.clone());
|
||||
let queued = s.ui_requests.len();
|
||||
let r = s.execute(id, resolved);
|
||||
// Macros never open dialogs or web pages; messages for the person stay queued.
|
||||
// Macros never open dialogs or web pages, and never start editing a cell; messages for
|
||||
// the person stay queued.
|
||||
let mut blocked = None;
|
||||
let asked: Vec<UiRequest> = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect();
|
||||
for q in asked {
|
||||
match q {
|
||||
UiRequest::Dialog(name, _) => blocked = Some(format!("{id} would open the {name} dialog; give the step the parameters it needs.")),
|
||||
UiRequest::OpenUrl(_) => blocked = Some(format!("{id} would open a web page, which macros don't do.")),
|
||||
UiRequest::EditCell(_) => {}
|
||||
UiRequest::EditCell(_) | UiRequest::InsertName(_) => {}
|
||||
m @ UiRequest::Message(_) => s.ui_requests.push(m),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+39
-17
@@ -816,11 +816,30 @@ pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable";
|
||||
/// 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: (row as usize, col as usize), hit: None, paths: Vec::new() };
|
||||
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))?;
|
||||
// Excel reads its PivotTable cache; without one, the report written by the last refresh
|
||||
// must still be what the source gives. Checked first: in a stale report even a cell that
|
||||
// computes as a label may show a value.
|
||||
// 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)
|
||||
@@ -834,17 +853,17 @@ pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u
|
||||
if !current {
|
||||
return Err("The PivotTable report is out of date. Refresh it first.".into());
|
||||
}
|
||||
let Some((rp, cp)) = probe.hit else { return Ok(None) };
|
||||
let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
|
||||
Ok(Some((src, records)))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What [`detail_records`] asks of a report computation: the row and column item paths of the
|
||||
/// value cell at `at`, and the paths of every record the filters let through.
|
||||
/// 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: (usize, usize),
|
||||
at: Option<(usize, usize)>,
|
||||
hit: Option<(Vec<u32>, Vec<u32>)>,
|
||||
paths: Vec<(usize, 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> {
|
||||
@@ -959,8 +978,8 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<
|
||||
if !col_leaves.contains(&cp[..shown]) {
|
||||
col_leaves.insert(cp[..shown].to_vec());
|
||||
}
|
||||
if let Some(pr) = probe.as_deref_mut() {
|
||||
pr.paths.push((ri, rp.clone(), cp.clone()));
|
||||
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);
|
||||
@@ -1326,10 +1345,13 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<
|
||||
_ => raw,
|
||||
},
|
||||
};
|
||||
if let Some(pr) = probe.as_deref_mut()
|
||||
&& pr.at == (r, l + j)
|
||||
{
|
||||
pr.hit = Some((rp.to_vec(), cp.to_vec()));
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -941,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();
|
||||
@@ -2784,6 +2864,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",
|
||||
@@ -4604,5 +4720,61 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() {
|
||||
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
|
||||
assert!(!html.contains("<colgroup>") && !html.contains("table-layout") && !html.contains("<svg") && !html.contains("<img"), "{html}");
|
||||
}
|
||||
#[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}");
|
||||
// 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 macros;
|
||||
mod seams;
|
||||
|
||||
@@ -624,6 +624,12 @@ fn the_policy_keeps_macros_away_from_files_and_the_app() {
|
||||
let err = s.execute("macro.run", json!({"name": "Pic"})).unwrap_err().to_string();
|
||||
assert!(err.contains("would open the insertPicture dialog"), "{err}");
|
||||
assert!(s.ui_requests.is_empty());
|
||||
// A step that would start editing a cell, or put a name in the edit, runs without it.
|
||||
let (use_name, insert) = (json!({"name": "Rate"}), json!({"name": "SUM"}));
|
||||
let steps = json!([{"command": "formulas.useInFormula", "params": use_name}, {"command": "formulas.insertFunction", "params": insert}]);
|
||||
put(&mut s, json!({"name": "Edit", "output": {"onConflict": "overwrite"}, "steps": steps}));
|
||||
s.execute("macro.run", json!({"name": "Edit"})).unwrap();
|
||||
assert!(s.ui_requests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user