Merge branch 'land' into fix-206

This commit is contained in:
Brandon Thomas
2026-10-11 14:23:51 -04:00
168 changed files with 23705 additions and 1534 deletions
File diff suppressed because it is too large Load Diff
+22 -4
View File
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"data.filter",
"Filter",
["Data", "Sort & Filter"],
Some("Cmd+Shift+F"),
Some("Ctrl+Shift+L"),
"{range?} toggles AutoFilter on the current region",
has_doc,
toggle_filter
@@ -248,7 +248,7 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX);
pos(va).cmp(&pos(vb))
} else if case && va.is_text() && vb.is_text() {
va.as_text().unwrap_or("").cmp(vb.as_text().unwrap_or(""))
sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or("")))
} else {
sort_compare(va, vb)
};
@@ -431,7 +431,16 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"filter": false}))
});
}
let r = data_range(s, p)?;
let mut r = data_range(s, p)?;
// A selected header row filters the data below it (#201).
if p.get("range").is_none() && r.height() == 1 {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let below = current_region(sh, r.start).end.row;
if below > r.end.row {
r = RangeRef::new(r.start, CellRef::new(below, r.end.col));
}
}
edit(s, |cx| {
cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] });
Ok(json!({"filter": true, "range": r.a1()}))
@@ -653,6 +662,12 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
}
/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts
/// before uppercase at the first letter that differs in case.
fn case_order(a: &str, b: &str) -> Ordering {
a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase()))
}
fn sort_key(v: &Value) -> (u8, i64, String) {
match v {
Value::Number(n) => (0, (n * 1e6) as i64, String::new()),
@@ -685,8 +700,11 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
let mut seen = std::collections::HashSet::new();
let mut keep: Vec<u32> = Vec::new();
// Cells are compared by the text they show, as in Excel: the same date in two formats
// isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are.
let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text;
for row in start..=r.end.row {
let key: Vec<String> = cols.iter().map(|c| sh.value(CellRef::new(row, *c)).display().to_lowercase()).collect();
let key: Vec<String> = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect();
if seen.insert(key) {
keep.push(row);
} else {
+175 -26
View File
@@ -1,6 +1,6 @@
//! Cell entry, selection, undo/redo, clipboard, clear, fill, find & replace.
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, Formula, StyleId};
use serde_json::{Value as Json, json};
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste",
["Home", "Clipboard"],
Some("Cmd+V"),
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\"}",
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\", html?: \"the clipboard's HTML flavour; cells the source recorded as numbers (x:num, sdval) stay numbers\"}",
has_doc,
paste
),
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AutoFill",
[],
None,
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|copy|formats|values}: the fill-handle drag",
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)",
has_doc,
auto_fill
),
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
fill_series
),
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Cmd+E"), "{range?}", has_doc, flash_fill),
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
cmd!(
"edit.replace",
@@ -196,13 +196,19 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
Some(c) => c,
None => s.doc()?.selection.active,
};
let input =
str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default();
let input = str_param(p, "input")
.or_else(|| str_param(p, "value"))
.map(str::to_string)
.or_else(|| p.get("value").map(json_to_input))
.ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
let array = bool_param(p, "array").unwrap_or(false);
let protected =
s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked));
if protected {
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
let d = s.doc()?;
if let Some(sh) = d.wb.sheet(sheet)
&& sh.is_protected()
&& d.wb.styles.get(sh.style_id(at)).protection.locked
&& let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges)
{
return Err(EngineError::Other(m));
}
edit(s, |cx| {
if array {
@@ -409,9 +415,9 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
if !extend && let Some(m) = sheet.merge_at(from) {
// Leaving a merged cell moves from its edge.
if dr > 0 {
t = CellRef::new((m.end.row + dr as u32).min(MAX_ROWS - 1), from.col);
t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col);
} else if dc > 0 {
t = CellRef::new(from.row, (m.end.col + dc as u32).min(MAX_COLS - 1));
t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col);
}
}
t
@@ -605,25 +611,36 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
// ---------------------------------------------------------------- clipboard
fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
let multiple = || EngineError::Other("This action won't work on multiple selections.".into());
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
if ranges.len() > 1 {
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
if ranges.len() > 1 && cut {
return Err(multiple());
}
let range = ranges.first().copied().unwrap_or_default();
// The selection as made (untrimmed), for Insert Copied Cells.
let selected = ranges.first().copied().unwrap_or_default();
let d = s.doc()?;
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let range = if range.is_full_cols() || range.is_full_rows() {
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
} else {
range
let trim = |range: RangeRef| {
if range.is_full_cols() || range.is_full_rows() {
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
} else {
range
}
};
let text = crate::display::range_text(&d.wb, sheet, range);
let (wb, range, areas) = if ranges.len() > 1 {
let areas: Vec<RangeRef> = ranges.iter().map(|r| trim(*r)).collect();
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
(std::sync::Arc::new(wb), block, areas)
} else {
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
};
let text = crate::display::range_text(&wb, sheet, range);
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
// scripts get it only when they ask for it.
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() };
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected };
s.clipboard = Some(clip);
let mut result = json!({"range": range.a1(), "text": text});
if let Some(html) = html {
@@ -632,6 +649,83 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
Ok(result)
}
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
/// into one block at the first area's place, and that block. Formulas travel as their values, as
/// in Excel. `None` when the areas don't line up.
fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> {
let first = *areas.first()?;
let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row);
let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col);
let mut sorted = areas.to_vec();
if across {
sorted.sort_by_key(|a| a.start.col);
} else if down {
sorted.sort_by_key(|a| a.start.row);
} else {
return None;
}
if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) {
return None;
}
let anchor = sorted.first()?.start;
let (h, w) = if across {
(first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add))
} else {
(sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width())
};
let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?);
let src = wb.sheet(sheet)?;
let mut out = wb.clone();
let dst = out.sheet_mut(sheet)?;
dst.take_cells(block);
dst.spill.retain(|c, _| !block.contains(*c));
dst.spill_ranges.retain(|c, _| !block.contains(*c));
dst.comments.retain(|c, _| !block.contains(*c));
dst.merges.retain(|m| !m.intersects(&block));
if across {
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
} else {
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
}
let mut next = if across { anchor.col } else { anchor.row };
for a in &sorted {
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
for c in cells {
let Some(to) = c.offset(dr, dc) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
if let Some(picture) = src.cell_pictures.get(&c) {
dst.cell_pictures.insert(to, picture.clone());
}
}
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
if let Some(to) = c.offset(dr, dc) {
dst.comments.insert(to, comment.clone());
}
}
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
dst.merges.push(RangeRef::new(s, e));
}
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
if across {
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
}
} else {
for (r, info) in src.rows.range(a.start.row..=a.end.row) {
dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info);
}
}
next = next.saturating_add(if across { a.width() } else { a.height() });
}
Some((out, block))
}
fn copy(s: &mut Session, p: &Json) -> Result<Json> {
copy_impl(s, p, false)
}
@@ -675,13 +769,18 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
let rows: Vec<Vec<String>> = parse_tsv(text);
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
let h = rows.len();
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
for (ci, v) in row.iter().enumerate() {
let Some(c) = at.offset(ri as i64, ci as i64) else { continue };
let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
if let Some(cl) = cell.as_mut() {
numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb);
}
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(cell) => sh.set_cell(c, cell),
@@ -701,6 +800,27 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
Ok(json!({"rows": h, "cols": w}))
}
/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the
/// source recorded for it in its HTML, with a format that shows the same text when one can be
/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated
/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing.
fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) {
let Some(hc) = html else { return };
let Some(n) = hc.num else { return };
if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" {
return;
}
if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) {
return;
}
cell.value = Value::number(n);
if wb.styles.get(cell.style).num_fmt.as_str() == "General"
&& let Some(code) = crate::clip_html::format_for(field, n)
{
cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code));
}
}
/// Tab-separated text with Excel-style quoting.
pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
let mut rows = Vec::new();
@@ -770,6 +890,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false);
let transpose = bool_param(p, "transpose").unwrap_or(false);
let link = bool_param(p, "link").unwrap_or(false);
if link && !clip.areas.is_empty() {
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
}
let dest_sheet = target_sheet(s, p)?;
let sel = s.doc()?.selection.current();
let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start);
@@ -1051,6 +1174,18 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
// Delete on checkboxes works in two passes (as in Excel): checked boxes are unchecked first,
// and only when every box in the selection is unchecked are the boxes removed.
let uncheck_only = what == "contents" && {
let d = s.doc()?;
d.wb.sheet(sheet).is_some_and(|sh| {
ranges.iter().any(|r| {
r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
})
})
})
};
edit(s, |cx| {
for r in &ranges {
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
@@ -1062,6 +1197,21 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
};
let cells: Vec<(CellRef, Cell)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
let mut cells = cells;
if what == "contents" {
for (_, cell) in cells.iter_mut() {
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
cell.formula = None;
if checkbox && uncheck_only {
cell.value = Value::Bool(false);
} else {
cell.value = Value::Empty;
if checkbox {
cell.style = cx.wb.styles.derive(cell.style, |st| st.num_fmt = gridcraft_model::NumFmt::new("General"));
}
}
}
}
let sh = cx.sheet_mut(sheet)?;
for (c, mut cell) in cells {
match what {
@@ -1069,8 +1219,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
sh.remove_cell(c);
}
"contents" => {
cell.value = Value::Empty;
cell.formula = None;
sh.set_cell(c, cell);
}
"formats" => {
@@ -1140,6 +1288,7 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) };
let mode = match str_param(p, "mode").unwrap_or("series") {
"copy" => crate::fill::FillMode::Copy,
"alternate" => crate::fill::FillMode::Alternate,
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
_ => crate::fill::FillMode::Series,
+128 -27
View File
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Data…",
["Chart Design", "Data"],
None,
"{chart?, range: \"Sheet1!A1:D7\", byRows?: bool}",
"{chart?, range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}",
has_doc,
select_data
),
@@ -116,9 +116,29 @@ pub fn specs() -> Vec<CommandSpec> {
rotate
),
// Views
cmd!(noundo "view.normal", "Normal", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "normal")),
cmd!(noundo "view.pageLayout", "Page Layout", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "pageLayout")),
cmd!(noundo "view.pageBreakPreview", "Page Break Preview", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "pageBreakPreview")),
cmd!(noundo "view.normal", "Normal", ["View", "Workbook Views"], None, "{sheet?} → {view} (per sheet, saved with the file)", has_doc, |s, p| set_view(
s,
p,
SheetViewMode::Normal
)),
cmd!(
noundo "view.pageLayout",
"Page Layout",
["View", "Workbook Views"],
None,
"{sheet?} → {view}: pages on paper with rulers; headers and footers are edited in place (per sheet, saved with the file)",
has_doc,
|s, p| set_view(s, p, SheetViewMode::PageLayout)
),
cmd!(
noundo "view.pageBreakPreview",
"Page Break Preview",
["View", "Workbook Views"],
None,
"{sheet?} → {view} (per sheet, saved with the file)",
has_doc,
|s, p| set_view(s, p, SheetViewMode::PageBreakPreview)
),
cmd!(noundo "view.customViews", "Custom Views…", ["View", "Workbook Views"], None, "{save?: name, show?: name, delete?: name} → list", has_doc, custom_views),
cmd!(noundo "view.split", "Split", ["View", "Window"], None, "{} (toggles a split at the active cell; shown as frozen panes in this version)", has_doc, split),
cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} opens a second window on the same workbook (a linked copy)", has_doc, new_window),
@@ -126,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{}", has_doc, hide_window),
cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{index?}", has_doc, unhide_window),
cmd!(query "view.switchWindows", "Switch Windows", ["View", "Window"], None, "{} → open windows", always, switch_windows),
cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?}", has_doc, |_, _| ok()),
cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?} → {on}: rulers in Page Layout view (an application option)", has_doc, ruler),
cmd!(query "view.macros", "Macros", ["View", "Macros"], None, "{} → scripts", has_doc, all_scripts),
// Table tools
cmd!("table.resize", "Resize Table", ["Table Design", "Properties"], None, "{table?, range: \"A1:F20\"}", has_doc, table_resize),
@@ -181,7 +201,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Checkbox",
["Insert", "Cell Controls"],
None,
"{range?} (cells hold TRUE/FALSE and draw as checkboxes; Space toggles)",
"{range?} (TRUE/FALSE and empty cells draw as checkboxes, empty ones become FALSE, other values show as they are; click or Space toggles; Delete unchecks, then removes; Clear Formats removes and keeps the values)",
has_doc,
checkbox
),
@@ -350,22 +370,70 @@ fn change_colors(s: &mut Session, p: &Json) -> Result<Json> {
}
fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
let range = str_param(p, "range").ok_or_else(|| bad("chart.selectData", "missing `range`"))?.to_string();
let (sheet_name, body) = split_sheet(&range);
let d = s.doc()?;
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
// A new data range rebuilds the series, as in Excel's Select Data › Chart data range.
let rebuilt = match str_param(p, "range") {
Some(range) => {
let range = range.trim_start_matches('=');
let (sheet_name, body) = split_sheet(range);
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
if r.count() > 1_000_000 {
return Err(bad("chart.selectData", "range too large for a chart"));
}
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
let series = super::insert::series_from_range(src, r, by_rows);
Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows))
}
None => None,
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
let series = super::insert::series_from_range(src, r, by_rows);
let source = format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1());
// Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears).
let categories = match str_param(p, "categories") {
None => None,
Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None),
Some(t) => {
let t = t.trim().trim_start_matches('=');
let (sheet_name, body) = split_sheet(t);
let si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end))))
}
};
let mut remove: Vec<usize> = p
.get("removeSeries")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect())
.unwrap_or_default();
remove.sort_unstable();
remove.dedup();
if rebuilt.is_none() && categories.is_none() && remove.is_empty() {
return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));
}
with_chart(s, p, move |c| {
c.series = series;
c.source = Some(source);
c.by_rows = by_rows;
if let Some((series, source, by_rows)) = rebuilt {
c.series = series;
c.source = Some(source);
c.by_rows = by_rows;
}
for i in remove.iter().rev() {
if *i < c.series.len() && c.series.len() > 1 {
c.series.remove(*i);
}
}
if let Some(cats) = categories {
for se in &mut c.series {
se.categories = cats.clone();
}
}
Ok(())
})
}
@@ -540,9 +608,26 @@ fn rotate(s: &mut Session, p: &Json) -> Result<Json> {
with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height))
}
fn set_view(s: &mut Session, mode: &str) -> Result<Json> {
s.view_mode = mode.to_string();
Ok(json!({"view": mode}))
/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it
/// neither records an undo step nor marks a clean workbook as changed.
fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc_mut()?;
let dirty = d.is_dirty();
let wb = std::sync::Arc::make_mut(&mut d.wb);
if let Some(sh) = wb.sheet_mut(si) {
sh.view = mode;
}
if !dirty {
d.saved = d.wb.clone();
}
Ok(json!({"view": mode.as_str()}))
}
fn ruler(s: &mut Session, p: &Json) -> Result<Json> {
let on = bool_param(p, "on").unwrap_or(s.hide_ruler);
s.hide_ruler = !on;
Ok(json!({"on": on}))
}
const VIEWS_NAME: &str = "_sc_custom_views";
@@ -828,6 +913,11 @@ fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
/// Number format that marks checkbox cells (TRUE/FALSE values drawn as boxes).
pub const CHECKBOX_FORMAT: &str = "\"☐\";\"☐\";\"☐\";[=1]\"☑\"";
/// Whether `c` has checkbox formatting.
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox"
}
fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let si = target_sheet(s, p)?;
@@ -843,9 +933,14 @@ fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
s.align.h = HAlign::Center;
});
let sh = cx.sheet_mut(si)?;
let v = sh.value(c);
let value = if matches!(v, Value::Bool(_)) { v } else { Value::Bool(false) };
sh.set_cell(c, Cell { value, formula: None, style: new });
// Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty
// cell starts unchecked.
let mut cell = sh.cell(c).cloned().unwrap_or_default();
if cell.value.is_empty() && cell.formula.is_none() {
cell.value = Value::Bool(false);
}
cell.style = new;
sh.set_cell(c, cell);
cx.touch(si, c);
}
}
@@ -862,12 +957,18 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
};
edit(s, |cx| {
for c in cells {
let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c));
let sh = cx.sheet_mut(si)?;
if let Some(cell) = sh.cells.get_mut(c)
&& let Value::Bool(b) = cell.value
&& cell.formula.is_none()
{
cell.value = Value::Bool(!b);
// An empty checkbox cell counts as FALSE.
let next = match cell.value {
Value::Bool(b) if checkbox => Value::Bool(!b),
Value::Empty if checkbox => Value::Bool(true),
_ => continue,
};
cell.value = next;
cx.changed.push((si, c));
}
}
+30 -4
View File
@@ -1,4 +1,4 @@
//! File menu: new, open, save, close, export, properties.
//! File menu: new, open, save, close, export, properties, options.
use serde_json::{Value as Json, json};
@@ -8,7 +8,7 @@ use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xlsb and ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
@@ -16,6 +16,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
cmd!(
query "file.options",
"Options",
["File"],
None,
"{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
always,
options
),
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window),
]
}
@@ -47,8 +56,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
return ok();
};
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb);
let imported =
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
let title = if imported {
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
} else {
@@ -156,6 +166,22 @@ fn properties(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// File › Options: reports the workbook-level settings and asks the interface to show its Options
/// page. Nothing changes here; every setting has its own command.
fn options(s: &mut Session, _: &Json) -> Result<Json> {
s.ui_requests.push(crate::UiRequest::Dialog("options".into(), json!({})));
let calculation = s.active().map(|d| {
let c = &d.wb.calc;
let mode = match c.mode {
gridcraft_model::CalcMode::Automatic => "automatic",
gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables",
gridcraft_model::CalcMode::Manual => "manual",
};
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change})
});
Ok(json!({"calculation": calculation}))
}
fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize;
if i >= s.documents().len() {
+546
View File
@@ -0,0 +1,546 @@
//! Data › Forecast › Forecast Sheet: a new sheet with the history, a FORECAST.ETS column, the
//! FORECAST.ETS.CONFINT confidence bounds, optional FORECAST.ETS.STAT statistics and a chart.
//!
//! Like Excel, the sheet holds a copy of the timeline and values (sorted by time) and live
//! formulas over that copy, so the forecast follows edits to the copied history.
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_functions::Timeline;
use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn};
use serde_json::{Value as Json, json};
use super::*;
use crate::selection::current_region;
const CMD: &str = "data.forecastSheet";
/// Most history rows a forecast sheet copies.
const MAX_HISTORY: u64 = 10_000;
/// Most forecast rows.
const MAX_PERIODS: u32 = 10_000;
/// Most history rows × forecast rows. Every forecast row holds three ETS formulas that each refit
/// the model over the whole history (about 3 µs per history point in a release build), so this
/// bounds a sheet's recalculation to a few seconds: 10,000 rows of history get 50 forecast rows,
/// 50 rows of history the full 10,000.
const MAX_WORK: u64 = 500_000;
/// Forecast rows by default: a third of the history, within [`max_periods`].
fn default_periods(history: usize) -> u32 {
u32::try_from(history.div_ceil(3)).unwrap_or(u32::MAX).clamp(1, max_periods(history))
}
/// Most forecast rows for `history` rows of history.
fn max_periods(history: usize) -> u32 {
let h = u64::try_from(history).unwrap_or(u64::MAX).max(1);
u32::try_from(MAX_WORK / h).unwrap_or(u32::MAX).clamp(1, MAX_PERIODS)
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"data.forecastSheet",
"Forecast Sheet",
["Data", "Forecast"],
None,
"{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
has_doc,
forecast_sheet
)]
}
/// The cells of a one-row or one-column range, at most `MAX_HISTORY + 1`.
fn line_cells(r: RangeRef) -> Result<Vec<CellRef>> {
if r.count() > MAX_HISTORY + 1 {
return Err(bad(CMD, format!("at most {MAX_HISTORY} rows of history")));
}
if r.width() == 1 {
Ok((r.start.row..=r.end.row).map(|row| CellRef::new(row, r.start.col)).collect())
} else if r.height() == 1 {
Ok((r.start.col..=r.end.col).map(|col| CellRef::new(r.start.row, col)).collect())
} else {
Err(bad(CMD, "the timeline and the values are each one row or one column"))
}
}
/// A range parameter on any sheet (whole columns trimmed to the used range).
fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> {
let d = s.doc()?;
let (sh, body) = split_sheet(t);
let si = match sh {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
None => d.wb.active_sheet,
};
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?;
let r = if r.is_full_cols() || r.is_full_rows() {
match d.wb.sheet(si).and_then(|s| s.used_range()) {
Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)),
None => RangeRef::cell(r.start),
}
} else {
r
};
Ok((si, r))
}
/// (sheet, timeline cells, value cells).
fn source(s: &Session, p: &Json) -> Result<(usize, Vec<CellRef>, Vec<CellRef>)> {
let text = |k: &str| str_param(p, k).map(str::trim).filter(|t| !t.is_empty());
match (text("timeline"), text("values")) {
(Some(t), Some(v)) => {
let (ts, tr) = range_on(s, t)?;
let (vs, vr) = range_on(s, v)?;
if ts != vs {
return Err(bad(CMD, "the timeline and the values must be on one sheet"));
}
Ok((ts, line_cells(tr)?, line_cells(vr)?))
}
(None, None) => {
let d = s.doc()?;
let (si, r) = match text("range") {
Some(t) => range_on(s, t)?,
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = d.selection.current();
(d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
}
};
if r.width() < 2 {
return Err(EngineError::Other("Select a timeline column and a values column for the forecast.".into()));
}
let col = |c: u32| RangeRef::new(CellRef::new(r.start.row, c), CellRef::new(r.end.row, c));
Ok((si, line_cells(col(r.start.col))?, line_cells(col(r.start.col + 1))?))
}
_ => Err(bad(CMD, "give both `timeline` and `values`, or neither")),
}
}
/// A number parameter given as a number or as text.
fn number(p: &Json, key: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Json::Null) => Ok(None),
Some(Json::Number(n)) => Ok(n.as_f64()),
Some(Json::String(t)) if t.trim().is_empty() => Ok(None),
Some(Json::String(t)) => {
t.trim().trim_end_matches('%').trim().parse::<f64>().map(Some).map_err(|_| bad(CMD, format!("`{key}` is not a number")))
}
Some(_) => Err(bad(CMD, format!("`{key}` is not a number"))),
}
}
struct Options {
seasonality: u32,
fill: u8,
aggregate: Option<u8>,
confidence: Option<f64>,
statistics: bool,
column_chart: bool,
}
fn options(p: &Json) -> Result<Options> {
let seasonality = match p.get("seasonality") {
Some(Json::String(t)) if t.trim().is_empty() || t.trim().eq_ignore_ascii_case("auto") => 1,
None | Some(Json::Null) => 1,
_ => match number(p, "seasonality")? {
Some(n) if n == n.trunc() && (0.0..=8760.0).contains(&n) => n as u32,
_ => return Err(bad(CMD, "`seasonality` is \"auto\", 0 (none) or a season length from 2 to 8,760")),
},
};
let fill = match str_param(p, "fillMissing").map(|t| t.trim().to_ascii_lowercase()) {
None => 1,
Some(t) if t.is_empty() || t == "interpolate" || t == "interpolation" => 1,
Some(t) if t == "zeros" || t == "zero" => 0,
Some(t) => return Err(bad(CMD, format!("`fillMissing` is interpolate or zeros, not `{t}`"))),
};
let aggregate = match str_param(p, "aggregate").map(|t| t.trim().to_ascii_lowercase()) {
None => None,
Some(t) => match t.as_str() {
"" | "average" => None,
"count" => Some(2),
"counta" => Some(3),
"max" => Some(4),
"median" => Some(5),
"min" => Some(6),
"sum" => Some(7),
_ => return Err(bad(CMD, format!("unknown `aggregate`: `{t}`"))),
},
};
let confidence = if bool_param(p, "confidenceInterval").unwrap_or(true) {
let c = number(p, "confidence")?.unwrap_or(0.95);
let c = if c >= 1.0 { c / 100.0 } else { c };
if !(c > 0.0 && c < 1.0) {
return Err(bad(CMD, "`confidence` is between 0 and 1 (or 0 and 100%)"));
}
Some(c)
} else {
None
};
let column_chart = match str_param(p, "chart").map(|t| t.trim().to_ascii_lowercase()) {
None => false,
Some(t) if t.is_empty() || t == "line" => false,
Some(t) if t == "column" => true,
Some(t) => return Err(bad(CMD, format!("`chart` is line or column, not `{t}`"))),
};
Ok(Options { seasonality, fill, aggregate, confidence, statistics: bool_param(p, "statistics").unwrap_or(false), column_chart })
}
fn col_letters(c: u32) -> String {
gridcraft_core::col_to_letters(c)
}
fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let opts = options(p)?;
let (si, tcells, vcells) = source(s, p)?;
if tcells.len() != vcells.len() {
return Err(bad(CMD, "the timeline and the values need the same number of cells"));
}
let d = s.doc()?;
if d.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
let sys = d.wb.date_system;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
// A text first cell is the header.
let header = tcells.first().is_some_and(|c| sh.value(*c).is_text()) || vcells.first().is_some_and(|c| sh.value(*c).is_text());
let name_of = |c: Option<&CellRef>, fallback: &str| match c.map(|c| sh.value(*c)) {
Some(v) if header && !v.is_empty() => v.display(),
_ => fallback.to_string(),
};
let (tname, vname) = (name_of(tcells.first(), "Timeline"), name_of(vcells.first(), "Values"));
let skip = usize::from(header);
let mut rows: Vec<(f64, Value)> = Vec::new();
for (t, v) in tcells.iter().zip(&vcells).skip(skip) {
let (tv, vv) = (sh.value(*t), sh.value(*v));
match tv {
Value::Number(x) => rows.push((x, if matches!(vv, Value::Number(_)) { vv } else { Value::Empty })),
Value::Empty if vv.is_empty() => {}
_ => return Err(EngineError::Other(format!("The timeline must hold numbers or dates ({} isn't one).", t.a1()))),
}
}
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
let times: Vec<f64> = rows.iter().map(|r| r.0).collect();
let timeline =
Timeline::detect(&times, sys).ok_or_else(|| EngineError::Other("The timeline needs at least two points a constant step apart.".into()))?;
if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) {
return Err(EngineError::Other("There are no values to forecast.".into()));
}
let end = match p.get("end") {
Some(Json::Number(n)) => n.as_f64(),
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
Value::Number(end) => Some(end),
_ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))),
},
_ => None,
};
let cap = max_periods(rows.len());
let periods = match end {
Some(end) => steps_to(&timeline, end, cap)?,
None => match number(p, "periods")? {
Some(n) if n >= 1.0 && n.ceil() <= f64::from(cap) => n.ceil() as u32,
Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {cap} for {} rows of history", rows.len()))),
None => default_periods(rows.len()),
},
};
let future: Vec<f64> =
(1..=periods).map(|k| timeline.after_last(k)).collect::<Option<_>>().ok_or_else(|| bad(CMD, "the forecast runs past the last date"))?;
let fmt_of = |c: Option<&CellRef>| c.map(|c| d.wb.styles.get(sh.style_id(*c)).num_fmt.clone()).unwrap_or_default();
let (tfmt, vfmt) = (fmt_of(tcells.get(skip)), fmt_of(vcells.get(skip)));
let sheet_name = d.wb.next_sheet_name();
let table_name = (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into());
let chart_id = d.wb.next_object_id();
let table_id = chart_id.saturating_add(1);
// Columns: A timeline, B values, C forecast, D/E bounds.
let n = rows.len() as u32;
let last = n + 1; // 1-based row of the last history point
let end_row = last + periods; // 1-based row of the last forecast
let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col));
let mut tail = format!("{},{}", opts.seasonality, opts.fill);
if let Some(a) = opts.aggregate {
tail.push_str(&format!(",{a}"));
}
let mut headers = vec![tname, vname.clone(), format!("Forecast({vname})")];
if opts.confidence.is_some() {
headers.push(format!("Lower Confidence Bound({vname})"));
headers.push(format!("Upper Confidence Bound({vname})"));
}
// Table column names are unique.
for i in 1..headers.len() {
let base = headers.get(i).cloned().unwrap_or_default();
let mut name = base.clone();
let mut k = 2;
while headers.iter().take(i).any(|h| h.eq_ignore_ascii_case(&name)) {
name = format!("{base}{k}");
k += 1;
}
if let Some(h) = headers.get_mut(i) {
*h = name;
}
}
let width = headers.len() as u32;
let stats_col = width + 1;
let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 };
let at = s.doc()?.wb.active_sheet;
let index = edit(s, |cx| {
let at = at.min(cx.wb.sheets.len());
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
let tstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = tfmt.clone());
let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone());
let mut sheet = Sheet::new(sheet_name.clone());
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
};
let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| {
let mut cell = Cell::formula(Formula::new(&text));
cell.style = style;
sheet.set_cell(CellRef::new(row, col), cell);
};
for (c, h) in headers.iter().enumerate() {
put(&mut sheet, 0, c as u32, Value::text(h.as_str()), StyleId::DEFAULT);
}
for (i, (t, v)) in rows.iter().enumerate() {
let r = i as u32 + 1;
put(&mut sheet, r, 0, Value::Number(*t), tstyle);
if !v.is_empty() {
put(&mut sheet, r, 1, v.clone(), vstyle);
}
}
// The last history point starts the forecast lines, so they join the values.
formula(&mut sheet, n, 2, format!("=B{last}"), vstyle);
if opts.confidence.is_some() {
formula(&mut sheet, n, 3, format!("=C{last}"), vstyle);
formula(&mut sheet, n, 4, format!("=C{last}"), vstyle);
}
let (tl, vl) = (hist(0), hist(1));
for (k, t) in future.iter().enumerate() {
let r = last + k as u32 + 1; // 1-based
put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle);
formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
if let Some(c) = opts.confidence {
let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
}
}
if opts.statistics {
put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold);
put(&mut sheet, 0, stats_col + 1, Value::text("Value"), bold);
for (k, label) in ["Alpha", "Beta", "Gamma", "MASE", "SMAPE", "MAE", "RMSE"].iter().enumerate() {
let r = k as u32 + 1;
put(&mut sheet, r, stats_col, Value::text(*label), StyleId::DEFAULT);
formula(&mut sheet, r, stats_col + 1, format!("=FORECAST.ETS.STAT({vl},{tl},{},{tail})", k + 1), StyleId::DEFAULT);
}
}
for c in 0..width {
sheet.cols.insert(
c,
gridcraft_model::LineInfo {
size: Some(match c {
0 | 1 => 90.0,
2 => 130.0,
_ => 230.0,
}),
..Default::default()
},
);
}
let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(end_row - 1, width - 1));
sheet.tables.push(Table {
id: table_id,
name: table_name.clone(),
range,
header_row: true,
totals_row: false,
columns: headers.iter().map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None }).collect(),
style: "TableStyleMedium2".into(),
banded_rows: true,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: true,
});
let q = gridcraft_formula::quote_sheet(&sheet_name);
let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c));
let series = (1..width)
.map(|c| Series {
name: Some(format!("{q}!${}$1", col_letters(c))),
categories: Some(col_ref(0)),
values: col_ref(c),
bubble_sizes: None,
color: None,
secondary: false,
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
smooth: false,
})
.collect();
sheet.charts.push(Chart {
id: chart_id,
kind: if opts.column_chart { ChartKind::ColumnClustered } else { ChartKind::Line },
anchor: Anchor { cell: CellRef::new(1, chart_col), dx: 0.0, dy: 0.0, width: 480.0, height: 288.0, mode: Default::default() },
title: None,
series,
legend: LegendPos::Bottom,
data_labels: false,
gridlines: true,
style: 1,
x_title: None,
y_title: None,
source: None,
by_rows: false,
pivot: None,
});
cx.wb.sheets.insert(at, Arc::new(sheet));
for nm in cx.wb.names.iter_mut() {
if let Some(sc) = nm.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
cx.wb.active_sheet = at;
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(at)
})?;
if let Ok(d) = s.doc_mut() {
d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default());
}
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods}))
}
/// Forecast rows needed to reach `end` (rounded up to whole steps).
fn steps_to(timeline: &Timeline, end: f64, cap: u32) -> Result<u32> {
let steps = timeline.steps_after_last(end).ok_or_else(|| bad(CMD, "`end` is not on the timeline"))?;
if steps < 1e-9 {
return Err(EngineError::Other("The forecast end must be after the last point of the timeline.".into()));
}
let steps = steps.ceil();
if steps > f64::from(cap) {
return Err(bad(CMD, format!("at most {cap} forecast steps for this much history")));
}
Ok(steps as u32)
}
#[cfg(test)]
mod tests {
use gridcraft_core::{CellRef, Value};
use serde_json::json;
use crate::Session;
fn session_with_history() -> Session {
let mut s = Session::new();
s.new_workbook();
let mut rows = vec![json!(["Month", "Sales"])];
// y = 10 + 2t with a four-step season.
let season = [3.0, -1.0, 4.0, -6.0];
for t in 1..=16 {
rows.push(json!([t, 10.0 + 2.0 * t as f64 + season[(t - 1) % 4]]));
}
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s
}
fn value(s: &Session, sheet: usize, a1: &str) -> Value {
s.doc().unwrap().wb.sheet(sheet).unwrap().value(CellRef::parse(a1).unwrap())
}
fn formula(s: &Session, sheet: usize, a1: &str) -> String {
let wb = &s.doc().unwrap().wb;
wb.sheet(sheet).unwrap().cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()).unwrap_or_default()
}
#[test]
fn forecast_sheet_builds_table_formulas_and_chart() {
let mut s = session_with_history();
s.execute("selection.set", json!({"range": "A1:B17"})).unwrap();
let r = s.execute("data.forecastSheet", json!({"periods": 4, "statistics": true})).unwrap();
assert_eq!(r["history"], 16);
assert_eq!(r["periods"], 4);
let wb = &s.doc().unwrap().wb;
// Inserted before the data sheet and activated.
assert_eq!(wb.active_sheet, 0);
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.name, r["sheet"].as_str().unwrap());
assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)"));
assert_eq!(value(&s, 0, "D1"), Value::text("Lower Confidence Bound(Sales)"));
assert_eq!(value(&s, 0, "E1"), Value::text("Upper Confidence Bound(Sales)"));
assert_eq!(formula(&s, 0, "C17"), "B17");
assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)");
assert_eq!(formula(&s, 0, "D18"), "C18-FORECAST.ETS.CONFINT(A18,$B$2:$B$17,$A$2:$A$17,0.95,1,1)");
assert_eq!(formula(&s, 0, "H2"), "FORECAST.ETS.STAT($B$2:$B$17,$A$2:$A$17,1,1,1)");
// Future timeline and the exact continuation of the seasonal series (t = 17 → 10 + 34 + 3).
assert_eq!(value(&s, 0, "A18"), Value::Number(17.0));
assert_eq!(value(&s, 0, "A21"), Value::Number(20.0));
let c18 = value(&s, 0, "C18").as_f64().unwrap();
assert!((c18 - 47.0).abs() < 1e-6, "{c18}");
let (lo, hi) = (value(&s, 0, "D19").as_f64().unwrap(), value(&s, 0, "E19").as_f64().unwrap());
assert!(lo <= hi);
assert_eq!(sh.tables.first().map(|t| t.range.a1()), Some("A1:E21".into()));
let chart = sh.charts.first().unwrap();
assert_eq!(chart.series.len(), 4);
assert_eq!(chart.series[1].values, format!("{}!$C$2:$C$21", r["sheet"].as_str().unwrap()));
// One undo step removes the sheet again.
let sheets = s.doc().unwrap().wb.sheets.len();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1);
}
#[test]
fn history_and_periods_are_capped() {
use super::{MAX_HISTORY, MAX_PERIODS, MAX_WORK, default_periods, max_periods};
// The default (a third of the history) never exceeds the caps.
assert_eq!(default_periods(16), 6);
assert_eq!(default_periods(1_200), 400);
assert_eq!(default_periods(3_000), 166);
assert_eq!(default_periods(100_000), 5);
assert_eq!(max_periods(10), MAX_PERIODS);
assert_eq!(max_periods(0), MAX_PERIODS);
for h in [1usize, 2, 3, 50, 51, 1_000, 1_300, 3_000, 10_000, 100_000, usize::MAX] {
let (d, m) = (default_periods(h), max_periods(h));
assert!(d >= 1 && d <= m && m <= MAX_PERIODS, "{h}: {d} {m}");
assert!(u64::try_from(h).unwrap_or(u64::MAX).saturating_mul(u64::from(m)) <= MAX_WORK.max(u64::try_from(h).unwrap_or(u64::MAX)), "{h}");
}
// Explicit periods or an end past the cap are refused before anything is built.
let mut s = Session::new();
s.new_workbook();
let mut rows = vec![json!(["t", "y"])];
rows.extend((1..=1_300).map(|t| json!([t, (t % 7) as f64 + t as f64 * 0.5])));
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
let sheets = s.doc().unwrap().wb.sheets.len();
let cap = max_periods(1_300); // 384
let err = |r: crate::Result<serde_json::Value>| r.err().map(|e| e.to_string()).unwrap_or_default();
assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": cap + 1}))).contains("from 1 to 384"));
assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "end": 1_300 + cap + 1}))).contains("at most 384"));
assert!(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": 10_000})).is_err());
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
// More history rows than MAX_HISTORY are refused.
let big = MAX_HISTORY + 2;
let r = s.execute("data.forecastSheet", json!({"timeline": format!("A1:A{big}"), "values": format!("B1:B{big}")}));
assert!(r.is_err());
}
#[test]
fn forecast_sheet_parameters_and_errors() {
let mut s = session_with_history();
let r = s
.execute(
"data.forecastSheet",
json!({"timeline": "A2:A17", "values": "B2:B17", "end": "22", "confidenceInterval": false, "seasonality": 4, "fillMissing": "zeros", "aggregate": "sum", "chart": "column"}),
)
.unwrap();
assert_eq!(r["periods"], 6);
assert_eq!(value(&s, 0, "B1"), Value::text("Values"));
assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,4,0,7)");
assert_eq!(value(&s, 0, "D1"), Value::Empty);
let sh = s.doc().unwrap().wb.sheet(0).unwrap();
assert_eq!(sh.charts.first().map(|c| c.kind), Some(gridcraft_model::ChartKind::ColumnClustered));
// Errors: end before the data, bad seasonality, uneven timeline, one column only.
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "end": 3})).is_err());
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "seasonality": 9000})).is_err());
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!B1:B17"})).is_err());
s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap();
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err());
}
}
+11 -5
View File
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
// Alignment
cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)),
cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)),
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], Some("Cmd+R"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)),
cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)),
cmd!("home.alignMiddle", "Middle Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Center)),
@@ -156,7 +156,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Row Height…",
["Home", "Cells", "Format"],
None,
"{rows?: \"2:5\", height: 20 (points on screen)}",
"{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}",
has_doc,
row_height
),
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Column Width…",
["Home", "Cells", "Format"],
None,
"{cols?: \"B:D\", width: 64 (points) | chars: 8.43}",
"{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}",
has_doc,
col_width
),
@@ -910,8 +910,14 @@ fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<Vec<(u32, u32)>> {
let key = if rows { "rows" } else { "cols" };
if let Some(t) = str_param(p, key) {
let r = RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?;
return Ok(vec![if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }]);
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
return t
.split(',')
.map(|part| {
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect();
}
Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())
}
+73 -2
View File
@@ -1,7 +1,7 @@
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::{CalcMode, DefinedName};
use gridcraft_model::{CalcMode, DefinedName, Formula};
use serde_json::{Value as Json, json};
use super::*;
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Define Name…",
["Formulas", "Defined Names"],
None,
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?}",
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)",
has_doc,
define_name
),
@@ -185,6 +185,7 @@ pub fn function_list() -> Vec<Json> {
("SHEET", "Information", "SHEET([value])", "Sheet number."),
("SHEETS", "Information", "SHEETS([reference])", "Number of sheets."),
("AREAS", "Lookup", "AREAS(reference)", "Number of areas in a reference."),
("TRIMRANGE", "Lookup", "TRIMRANGE(range, [trim_rows], [trim_cols])", "A range or array without its blank outer rows and columns."),
("ISOMITTED", "Information", "ISOMITTED(argument)", "TRUE when a LAMBDA argument was left out."),
];
for (n, c, sig, d) in specials {
@@ -265,7 +266,32 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
return Err(EngineError::Other("There's a problem with this formula.".into()));
}
let comment = str_param(p, "comment").unwrap_or("").to_string();
// Editing an existing name (Name Manager › Edit…): it must exist, and its new name must not
// collide with another name in the target scope.
let old = match str_param(p, "oldName") {
Some(old_name) => {
let old_scope = match str_param(p, "oldScope") {
None | Some("Workbook") => None,
Some(sh) => Some(d.wb.sheet_index(sh).ok_or_else(|| bad("formulas.defineName", "no such sheet"))?),
};
if !d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(old_name) && n.scope == old_scope) {
return Err(bad("formulas.defineName", "no such name to edit"));
}
let same = old_name.eq_ignore_ascii_case(&name) && old_scope == scope;
if !same && d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name) && n.scope == scope) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
Some((old_name.to_string(), old_scope))
}
None => None,
};
edit(s, |cx| {
if let Some((old_name, old_scope)) = &old {
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(old_name) && n.scope == *old_scope));
if !old_name.eq_ignore_ascii_case(&name) {
rename_name_in_formulas(&mut cx.wb, old_name, *old_scope, &name);
}
}
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(&name) && n.scope == scope));
cx.wb.names.push(DefinedName { name: name.clone(), scope, formula: refers.clone(), comment: comment.clone(), hidden: false });
cx.structural = true;
@@ -273,6 +299,51 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Formulas that use a renamed name follow it: cell formulas and other names' definitions, on
/// the sheets where `old` resolves to the renamed name (its own sheet for a sheet-scoped name;
/// every sheet without a same-named sheet-level name for a workbook-level one).
fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_scope: Option<usize>, new: &str) {
let shadowed: Vec<usize> = wb.names.iter().filter(|n| n.name.eq_ignore_ascii_case(old)).filter_map(|n| n.scope).collect();
let applies = |sheet: usize| match old_scope {
Some(s) => s == sheet,
None => !shadowed.contains(&sheet),
};
// The renamed expression, or `None` when the name doesn't occur in it.
let rename = |e: gridcraft_formula::Expr| {
let mut hit = false;
let out = e.map(&mut |x| match x {
gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(old) => {
hit = true;
gridcraft_formula::Expr::Name(new.to_string())
}
o => o,
});
hit.then_some(out)
};
for i in 0..wb.sheets.len() {
if !applies(i) {
continue;
}
let Some(sh) = wb.sheet_mut(i) else { continue };
let formulas: Vec<(CellRef, std::sync::Arc<Formula>)> = sh.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect();
for (c, f) in formulas {
let Some(e) = f.expr().and_then(rename) else { continue };
let mut nf = Formula::from_expr(e);
nf.array = f.array;
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = Some(std::sync::Arc::new(nf));
}
}
}
for n in wb.names.iter_mut() {
if n.scope.is_none_or(applies)
&& let Some(e) = gridcraft_formula::parse(&n.formula).ok().and_then(rename)
{
n.formula = gridcraft_formula::print(&e);
}
}
}
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
let scope = match str_param(p, "scope") {
+119 -20
View File
@@ -67,10 +67,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
insert_chart
),
cmd!("insert.recommendedCharts", "Recommended Charts", ["Insert", "Charts"], None, "{range?}", has_doc, |s, p| insert_chart(
s,
&with(p, "type", json!("auto"))
)),
cmd!(
"insert.recommendedCharts",
"Recommended Charts",
["Insert", "Charts"],
None,
"{range?, type?, subtype?} (no type: the first of insert.chartRecommendations; the ribbon button opens the dialog)",
has_doc,
|s, p| insert_chart(s, &if p.get("type").is_some() { p.clone() } else { with(p, "type", json!("auto")) })
),
cmd!(
query "insert.chartRecommendations",
"Chart Recommendations",
[],
None,
"{range?} → [{type, subtype, label}] chart types that suit the data's shape, best first",
has_doc,
|s, p| Ok(Json::Array(recommend_charts(s, p)?.into_iter().map(|(t, sub, label)| json!({"type": t, "subtype": sub, "label": label})).collect()))
),
cmd!(
"chart.set",
"Chart Properties",
@@ -143,8 +157,10 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
insert_link
),
cmd!("review.newNote", "New Note", ["Review", "Notes"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
cmd!("review.newComment", "New Comment", ["Review", "Comments"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, true)),
cmd!("review.newNote", "New Note", ["Review", "Notes"], Some("Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
cmd!("review.newComment", "New Comment", ["Review", "Comments"], Some("Cmd+Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(
s, p, true
)),
cmd!("review.replyComment", "Reply", [], None, "{cell?, text, author?}", has_doc, reply_comment),
cmd!("review.resolveComment", "Resolve Thread", [], None, "{cell?, resolved?: true}", has_doc, resolve_comment),
cmd!("review.deleteComment", "Delete Comment", ["Review", "Comments"], None, "{cell?}", has_doc, delete_comment),
@@ -518,7 +534,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
let header_row = r.height() > 1
&& (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text())
&& (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c)));
let label_col = r.width() > 1 && (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col)));
// A blank top-left cell above a header row marks the first column as the category labels even
// when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't
// plotted as a series of its own (#287).
let blank_corner = header_row && sh.value(r.start).is_empty();
let label_col =
r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col))));
let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end);
let mut out = Vec::new();
if !by_rows {
@@ -555,7 +576,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
/// Most cells a chart's source block may span (bounds the series it builds).
const MAX_CHART_CELLS: u64 = 1_000_000;
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for
/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per
/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
/// series → clustered and stacked columns and bars, line.
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
let r = block(s, p)?;
if r.count() > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
@@ -563,15 +589,78 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let by_rows = r.width() > r.height() + 1;
let series = series_from_range(sh, r, by_rows);
let ranges: Vec<RangeRef> = series
.iter()
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
Ok(gridcraft_formula::Expr::Ref(rf)) => Some(rf.range()),
_ => None,
})
.collect();
let points = ranges.first().map_or(0, RangeRef::count);
let (mut any_number, mut non_negative) = (false, true);
for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) {
any_number = true;
non_negative &= n >= 0.0;
}
if series.is_empty() || !any_number {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
let labelled = series.first().is_some_and(|x| x.categories.is_some());
const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column");
const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar");
const LINE: (&str, &str, &str) = ("line", "", "Line");
const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers");
const PIE: (&str, &str, &str) = ("pie", "", "Pie");
const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut");
const AREA: (&str, &str, &str) = ("area", "", "Area");
const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter");
const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column");
const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar");
let mut out = Vec::new();
if series.len() == 2 && !labelled && !by_rows {
out.extend([SCATTER, LINE_MARKERS, COLUMN]);
} else if series.len() == 1 {
if points > 12 {
out.extend([LINE, COLUMN, AREA]);
} else {
out.extend([COLUMN, BAR]);
if non_negative && points >= 2 {
out.extend([PIE, DOUGHNUT]);
}
out.push(LINE_MARKERS);
}
} else if points > 12 {
out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]);
} else {
out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]);
}
Ok(out)
}
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
/// goes on and the chart (previews in the Recommended Charts dialog).
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
let r = block(s, p)?;
if r.count() > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let mut t = str_param(p, "type").unwrap_or("column").to_string();
let mut sub = str_param(p, "subtype").unwrap_or("").to_string();
if t == "auto" {
let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", ""));
t = first.0.into();
sub = first.1.into();
}
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// Excel plots series by the longer dimension (more rows → series in columns).
let data_h = r.height();
let data_w = r.width();
let by_rows = data_w > data_h + 1;
if t == "auto" {
t = if data_h > 12 && data_w <= 3 { "line".into() } else { "column".into() };
}
let kind = chart_kind(&t, str_param(p, "subtype").unwrap_or(""));
let kind = chart_kind(&t, &sub);
let series = series_from_range(sh, r, by_rows);
if series.is_empty() {
return Err(EngineError::Other("Select data for the chart first.".into()));
@@ -606,7 +695,14 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
y_title: None,
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
by_rows,
pivot: None,
};
Ok((sheet, chart))
}
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, chart) = build_chart(s, p)?;
let id = chart.id;
edit(s, |cx| {
cx.sheet_mut(sheet)?.charts.push(chart);
Ok(json!({"chart": id}))
@@ -783,7 +879,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
};
let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at);
let style = sh.style_id(at);
if sh.is_protected() && d.wb.styles.get(style).protection.locked {
if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() {
return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into()));
}
let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() };
@@ -1038,9 +1134,10 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
cx.sheet_mut(sheet)?
.comments
.insert(at, Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false });
cx.sheet_mut(sheet)?.comments.insert(
at,
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
);
Ok(json!({"cell": at.a1()}))
})
}
@@ -1159,15 +1256,17 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
};
let rule = match kind.as_str() {
"cellIs" => {
// Excel's file and VBA names (`lessThan`, …) are accepted too.
let op = match g("operator").as_deref().unwrap_or("greater") {
"between" => CfOperator::Between,
"notBetween" => CfOperator::NotBetween,
"equal" => CfOperator::Equal,
"notEqual" => CfOperator::NotEqual,
"less" => CfOperator::Less,
"greaterOrEqual" => CfOperator::GreaterOrEqual,
"lessOrEqual" => CfOperator::LessOrEqual,
_ => CfOperator::Greater,
"less" | "lessThan" => CfOperator::Less,
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
"greater" | "greaterThan" => CfOperator::Greater,
other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))),
};
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
let b = rule.get("value2").map(super::edit::json_to_input);
+44 -4
View File
@@ -1,11 +1,13 @@
//! The command registry. Ids follow Excel's ribbon (`home.bold`, `insert.chart`,
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
pub mod analysis;
pub mod data;
pub mod draw;
pub mod edit;
pub mod extra;
pub mod file;
pub mod forecast;
pub mod format;
pub mod formulas;
pub mod insert;
@@ -14,6 +16,7 @@ pub mod pivot;
pub mod print;
pub mod review;
pub mod sheet;
pub mod solver;
pub mod spelling;
pub mod view;
pub mod whatif;
@@ -69,6 +72,21 @@ impl CommandSpec {
}
}
/// Params that hold a secret: `password`, or a name ending in `Password`.
pub fn is_secret_param(key: &str) -> bool {
key == "password" || key.ends_with("Password")
}
/// The params as the journal records them: secret params are left out, so a password never
/// reaches the journal dialog, `engine.journal` or a recorded script (saved in the workbook).
/// Replaying such an entry runs the command without that param.
pub fn journal_params(params: &Json) -> Json {
match params {
Json::Object(o) => Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
other => other.clone(),
}
}
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
@@ -118,8 +136,11 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(extra::specs());
v.extend(draw::specs());
v.extend(spelling::specs());
v.extend(solver::specs());
v.extend(pivot::specs());
v.extend(whatif::specs());
v.extend(forecast::specs());
v.extend(analysis::specs());
v.extend(print::specs());
v
})
@@ -277,7 +298,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
let r = f(&mut ctx)?;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
if !protection_checked {
check_protection(&d.wb, &wb)?;
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
}
if changed.len() <= 200_000 {
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
@@ -312,7 +333,7 @@ pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
/// deleting a sheet isn't an edit of its cells.
fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
for sh in &old.sheets {
if !sh.is_protected() {
continue;
@@ -322,14 +343,33 @@ fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
continue;
}
for c in sh.cells.diff(&after.cells) {
if !same_content(sh.cell(c), after.cell(c)) && old.styles.get(sh.style_id(c)).protection.locked {
return Err(EngineError::Other(PROTECTED.into()));
if !same_content(sh.cell(c), after.cell(c))
&& old.styles.get(sh.style_id(c)).protection.locked
&& let Some(m) = locked_cell_error(sh, c, unlocked)
{
return Err(EngineError::Other(m));
}
}
}
Ok(())
}
/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
/// Range without a password or one unlocked in this session, else the message to show.
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
let mut needs_password = None;
for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
return None;
}
needs_password.get_or_insert(r.title.as_str());
}
Some(match needs_password {
Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
None => PROTECTED.into(),
})
}
/// Two cells hold the same value or formula (formats aside).
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
+328 -5
View File
@@ -43,6 +43,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
summarize_table
),
cmd!(
"pivot.calculatedField",
"Calculated Field",
["PivotTable Analyze", "Calculations"],
None,
"{pivot?, name, formula?: \"=Sales*0.1\" (fields bare or 'quoted'; evaluated on each cell's sums), delete?: bool, values?: bool (add a new field to Values, default true)} adds, changes or deletes a calculated field; without `name` opens the dialog → {name, formula, calculatedFields}",
has_doc,
calculated_field
),
cmd!(
"insert.pivotChart",
"PivotChart",
["Insert", "Charts"],
None,
"{pivot?: name | id | a cell of the report (\"Sheet2!B5\"; default: the PivotTable at the active cell), type?: column|bar|line|pie|area|… (default column), subtype?, title?, at?: \"H2\", width?, height?} adds a chart bound to the PivotTable's report: categories are the innermost row items, series the column items × value fields; it follows refreshes and layout changes → {chart, pivot, series}",
has_doc,
insert_pivot_chart
),
cmd!(query "pivot.fields", "PivotTable Fields", [], None, "{pivot?} → source fields (name, numeric, date, samples, areas) and the current layout", has_doc, fields),
cmd!(query "pivot.fieldList", "Field List", ["PivotTable Analyze", "Show"], None, "{pivot?} (same as pivot.fields)", has_doc, fields),
cmd!(query "pivot.list", "PivotTables", [], None, "{} → every PivotTable in the workbook", has_doc, list),
@@ -320,6 +338,19 @@ fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
}
/// The calculated field named `field`, if any.
fn calc_field<'a>(pt: &'a PivotTable, field: &str) -> Option<&'a PivotCalcField> {
pt.calculated_fields.iter().find(|c| c.name.eq_ignore_ascii_case(field.trim()))
}
/// The canonical name of a source or calculated field.
fn canonical_any(cmd: &str, pt: &PivotTable, src: &pv::Source, field: &str) -> Result<String> {
match calc_field(pt, field) {
Some(c) if src.col(field).is_none() => Ok(c.name.clone()),
_ => canonical(cmd, src, field),
}
}
fn unique_value_name(pt: &PivotTable, base: &str, except: Option<usize>) -> String {
let taken = |n: &str| pt.values.iter().enumerate().any(|(i, v)| Some(i) != except && v.name.eq_ignore_ascii_case(n));
if !taken(base) {
@@ -343,7 +374,11 @@ fn add_to_area(
name: Option<&str>,
position: Option<usize>,
) -> Result<()> {
let field = canonical(cmd, src, field)?;
let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some();
if is_calc && area != "values" {
return Err(other("A calculated field can only be placed in the Values area."));
}
let field = canonical_any(cmd, pt, src, field)?;
match area {
"rows" | "columns" => {
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
@@ -369,7 +404,7 @@ fn add_to_area(
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
return Err(other(format!("A PivotTable can have at most {} value fields.", pv::MAX_VALUE_FIELDS)));
}
let numeric = src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
let numeric = is_calc || src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
let f = func.unwrap_or(if numeric { PivotFunc::Sum } else { PivotFunc::Count });
let base = match name {
Some(n) if !n.trim().is_empty() => {
@@ -663,6 +698,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
"subtotals": if !pt.subtotals { "none" } else if pt.subtotals_top { "top" } else { "bottom" },
"style": pt.style,
"showHeaders": pt.show_headers,
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
})
}
@@ -702,6 +738,11 @@ fn fields(s: &mut Session, p: &Json) -> Result<Json> {
.collect(),
Err(_) => vec![],
};
let mut list = list;
for c in &pt.calculated_fields {
let areas: Vec<&str> = if pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&c.name)) { vec!["values"] } else { vec![] };
list.push(json!({"name": c.name, "numeric": true, "date": false, "samples": [], "areas": areas, "calculated": true, "formula": c.formula}));
}
if let Some(m) = out.as_object_mut() {
m.insert("fields".into(), Json::Array(list));
if let Ok(src) = &src {
@@ -743,7 +784,76 @@ fn add_field(s: &mut Session, p: &Json) -> Result<Json> {
let pos = position_param(p);
update(s, p, |pt, src, _| {
add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?;
Ok(json!({"field": canonical(C, src, &field)?, "area": area}))
Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area}))
})
}
fn calculated_field(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.calculatedField";
let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else {
// Menu-style call: the Insert Calculated Field dialog.
let (si, pi) = find_pivot(s, p)?;
let d = s.doc()?;
let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?;
let n = (1..).map(|k| format!("Field{k}")).find(|n| calc_field(pt, n).is_none()).unwrap_or_else(|| "Field1".into());
let pivot = pt.name.clone();
s.ui_requests.push(crate::UiRequest::Dialog("pivotCalculatedField".into(), json!({"pivot": pivot, "name": n, "formula": "= 0"})));
return ok();
};
if name.is_empty() || name.chars().count() > 255 {
return Err(bad(C, "`name` must be 1 to 255 characters"));
}
let delete = bool_param(p, "delete").unwrap_or(false);
let formula = str_param(p, "formula").map(|f| {
let f = f.trim();
if f.starts_with('=') { f.to_string() } else { format!("={f}") }
});
let to_values = bool_param(p, "values").unwrap_or(true);
if !delete && formula.is_none() {
return Err(bad(C, "missing `formula`"));
}
update(s, p, |pt, src, _| {
let existing = pt.calculated_fields.iter().position(|c| c.name.eq_ignore_ascii_case(&name));
if delete {
let i = existing.ok_or_else(|| bad(C, format!("no calculated field `{name}`")))?;
let gone = pt.calculated_fields.remove(i);
pt.values.retain(|v| !v.source_col.eq_ignore_ascii_case(&gone.name));
return Ok(json!({"name": gone.name, "deleted": true}));
}
let formula = formula.clone().unwrap_or_default();
if src.col(&name).is_some() {
return Err(other("PivotTable field name already exists."));
}
let canon = match existing {
Some(i) => {
let c = pt.calculated_fields.get_mut(i).ok_or_else(|| other("There's no such calculated field."))?;
c.formula = formula.clone();
c.name.clone()
}
None => {
if pt.calculated_fields.len() >= crate::pivot_calc::MAX_CALC_FIELDS {
return Err(other(format!("A PivotTable can have at most {} calculated fields.", crate::pivot_calc::MAX_CALC_FIELDS)));
}
pt.calculated_fields.push(PivotCalcField { name: name.clone(), formula: formula.clone() });
name.clone()
}
};
let set = crate::pivot_calc::CalcSet::new(pt, &src.headers);
let i = set.index(&canon).ok_or_else(|| other("There's no such calculated field."))?;
if let Some(e) = set.error(i) {
return Err(bad(C, e.to_string()));
}
if set.is_cyclic(i) {
return Err(bad(C, format!("`{canon}` refers to itself through its formula")));
}
if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) {
add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?;
}
Ok(json!({
"name": canon,
"formula": formula,
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
}))
})
}
@@ -1171,6 +1281,18 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = find_pivot(s, p)?;
edit(s, |cx| {
// Its PivotCharts become ordinary charts.
let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone())));
if let Some((sheet, name)) = link {
for i in 0..cx.wb.sheets.len() {
let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name));
if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) {
for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) {
c.pivot = None;
}
}
}
}
pv::clear_output(&mut cx.wb, si, pi);
let sh = cx.sheet_mut(si)?;
if pi < sh.pivots.len() {
@@ -1181,6 +1303,80 @@ fn delete(s: &mut Session, p: &Json) -> Result<Json> {
})
}
// ---------------------------------------------------------------- PivotChart
/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report.
fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
if let Some(t) = str_param(p, "pivot") {
let d = s.doc()?;
let (sheet, addr) = match t.rsplit_once('!') {
Some((sh, a)) => {
let sh = sh.trim();
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
(d.wb.sheet_index(&sh), a)
}
None => (Some(d.wb.active_sheet), t),
};
if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) {
let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c));
return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable")));
}
}
find_pivot(s, p)
}
fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = chart_pivot(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?;
let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?;
let out = pv::compute(&d.wb, pt, si).map_err(other)?;
if out.chart_rows.is_empty() || out.chart_cols.is_empty() {
return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart."));
}
let series = pv::chart_series(&sh.name, area.start, &out);
let t = str_param(p, "type").unwrap_or("column");
let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or(""));
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
let title = match p.get("title") {
Some(Json::String(t)) => Some(t.clone()),
Some(Json::Null) => None,
_ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }),
};
let id = d.wb.next_object_id();
let link = pv::chart_link(&sh.name, &pt.name);
let pivot_name = pt.name.clone();
let n = series.len();
let chart = Chart {
id,
kind,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32,
height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
title,
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
y_title: None,
source: None,
by_rows: false,
pivot: Some(link),
};
edit(s, |cx| {
cx.sheet_mut(si)?.charts.push(chart);
Ok(json!({"chart": id, "pivot": pivot_name, "series": n}))
})
}
// ---------------------------------------------------------------- recommended
fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec<Json> {
@@ -1280,8 +1476,8 @@ fn recommended(s: &mut Session, p: &Json) -> Result<Json> {
#[cfg(test)]
mod tests {
use gridcraft_core::{CellRef, Value};
use serde_json::json;
use gridcraft_core::{CellError, CellRef, Value};
use serde_json::{Value as Json, json};
use crate::Session;
@@ -1550,6 +1746,133 @@ mod tests {
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
}
fn pivot_chart(s: &Session) -> gridcraft_model::Chart {
s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone()
}
fn plotted(s: &Session) -> Vec<Vec<Option<f64>>> {
let d = s.doc().unwrap();
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s));
data.series.into_iter().map(|x| x.values).collect()
}
#[test]
fn pivot_chart_follows_the_report() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
// Any cell of the report picks the PivotTable.
let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap();
assert_eq!(r["series"], 1);
let ch = pivot_chart(&s);
assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1"));
assert_eq!(ch.title.as_deref(), Some("Total"));
assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6");
assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6"));
assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]);
// A column field: one series per item, rebound by the layout change.
s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap();
let ch = pivot_chart(&s);
let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect();
assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]);
assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]);
// Nested row fields: the innermost items only, as a union around the parent rows.
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
let ch = pivot_chart(&s);
assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values);
let pts = plotted(&s);
assert_eq!(pts[0].len(), 9);
assert_eq!(pts[0].iter().flatten().sum::<f64>(), TOTAL);
// Survives an XLSX round trip as a PivotChart.
let wb = (*s.doc().unwrap().wb).clone();
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
let si = back.sheet_index("Sheet2").unwrap();
assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1"));
// Renaming the sheet keeps the link; a refresh still rebinds.
s.execute("sheet.rename", json!({"name": "Report"})).unwrap();
assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1"));
s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap();
assert_eq!(pivot_chart(&s).series.len(), 3);
// Deleting the PivotTable leaves an ordinary chart.
s.execute("pivot.delete", json!({})).unwrap();
assert_eq!(pivot_chart(&s).pivot, None);
}
#[test]
fn pivot_chart_errors() {
let mut s = sales();
// No PivotTable yet.
assert!(s.execute("insert.pivotChart", json!({})).is_err());
s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap();
// Not in the report; no values.
assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err());
assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values"));
s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap();
let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap();
assert_eq!(r["series"], 1);
let ch = pivot_chart(&s);
assert_eq!(ch.kind, gridcraft_model::ChartKind::Line);
assert_eq!(ch.title, None);
// One undo step.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty());
}
#[test]
fn calculated_field_on_sums() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
let r = s.execute("pivot.calculatedField", json!({"name": "Price", "formula": "Sales / Units"})).unwrap();
assert_eq!(r["formula"], "=Sales / Units");
assert_eq!(text(&s, "C3"), "Sum of Price");
assert_eq!(text(&s, "A4"), "East");
// East: 415 sales over 33 units, from the sums.
let east = val(&s, "C4").as_f64().unwrap();
assert!((east - 415.0 / 33.0).abs() < 1e-12, "{east}");
// The grand total applies the formula to the totals, not the sum of the rows.
assert_eq!(text(&s, "A7"), "Grand Total");
let total = val(&s, "C7").as_f64().unwrap();
assert!((total - TOTAL / 72.0).abs() < 1e-12, "{total}");
// Quoted names, functions, and another calculated field.
s.execute("pivot.calculatedField", json!({"name": "Rounded", "formula": "=ROUND('Price'*2,1)"})).unwrap();
assert_eq!(val(&s, "D4"), Value::Number(25.2));
// Division by zero shows #DIV/0! cells.
s.execute("pivot.calculatedField", json!({"name": "Zero", "formula": "=Sales/(Units-Units)"})).unwrap();
assert_eq!(val(&s, "E4"), Value::Error(CellError::Div0));
// Change and delete.
s.execute("pivot.calculatedField", json!({"name": "zero", "formula": "=Units*2"})).unwrap();
assert_eq!(val(&s, "E4"), Value::Number(66.0));
s.execute("pivot.calculatedField", json!({"name": "Zero", "delete": true})).unwrap();
assert_eq!(pt(&s).values.len(), 3);
assert_eq!(pt(&s).calculated_fields.len(), 2);
// The field list shows calculated fields.
let f = s.execute("pivot.fields", json!({})).unwrap();
assert!(f["fields"].as_array().unwrap().iter().any(|x| x["name"] == "Price" && x["calculated"] == true));
}
#[test]
fn calculated_field_errors() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
let mut e = |p: Json| s.execute("pivot.calculatedField", p).unwrap_err().to_string();
assert!(e(json!({"name": "Bad", "formula": "=Sales*Bogus"})).contains("Bogus"));
assert!(e(json!({"name": "Bad", "formula": "=A1*2"})).contains("cells"));
assert!(e(json!({"name": "Bad", "formula": "=Sales*("})).contains("error"));
assert!(e(json!({"name": "Sales", "formula": "=1"})).contains("already exists"));
assert!(e(json!({"name": "Bad"})).contains("formula"));
assert!(e(json!({"name": "Gone", "delete": true})).contains("Gone"));
// A cycle is refused.
s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Sales"})).unwrap();
s.execute("pivot.calculatedField", json!({"name": "Y", "formula": "=X*2"})).unwrap();
let err = s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Y"})).unwrap_err().to_string();
assert!(err.contains("itself"), "{err}");
// Calculated fields only go to Values; nothing changed after the failures.
assert!(s.execute("pivot.addField", json!({"field": "Y", "area": "rows"})).is_err());
assert_eq!(pt(&s).calculated_fields.len(), 2);
// Without a name the dialog opens (menu-style call).
s.ui_requests.clear();
s.execute("pivot.calculatedField", json!({})).unwrap();
assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog(n, _)) if n == "pivotCalculatedField"));
}
#[test]
fn replace_protection() {
let mut s = sales();
+222 -27
View File
@@ -58,7 +58,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// Sheet geometry is in pixels at 96 dpi; PDF units are points.
const PX_TO_PT: f32 = 0.75;
pub(crate) const PX_TO_PT: f32 = 0.75;
/// Most pages one export produces.
pub const MAX_PAGES: usize = 2000;
/// Cells in a page block above which only stored cells are visited (not every grid position).
@@ -110,13 +110,15 @@ pub struct SheetLayout {
/// Heading sizes in sheet pixels (0 when headings are off).
pub heading_w: f32,
pub heading_h: f32,
/// Manual page breaks apply (they don't when the sheet is fitted to pages).
pub manual_breaks: bool,
}
impl SheetLayout {
fn k(&self) -> f32 {
PX_TO_PT * self.scale
}
fn avail(&self) -> (f32, f32) {
pub(crate) fn avail(&self) -> (f32, f32) {
let [l, r, t, b, ..] = self.margins;
((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0))
}
@@ -271,30 +273,11 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
title_cols,
heading_w,
heading_h,
manual_breaks: fit.is_none(),
};
let paginate = |lay: &mut SheetLayout, manual: bool| {
let k = lay.k();
let (aw, ah) = lay.avail();
let (aw, ah) = (aw / k, ah / k);
let rb: Vec<u32> = if manual { st.row_breaks.clone() } else { vec![] };
let cb: Vec<u32> = if manual { st.col_breaks.clone() } else { vec![] };
let (mut rb, mut cb) = (rb, cb);
rb.sort_unstable();
cb.sort_unstable();
let title_h = |start: u32| -> f32 {
match title_rows {
Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(),
_ => 0.0,
}
};
let title_w = |start: u32| -> f32 {
match title_cols {
Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(),
_ => 0.0,
}
};
lay.row_bands = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - heading_h - title_h(start)).max(1.0), &rb, MAX_PAGES);
lay.col_bands = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - heading_w - title_w(start)).max(1.0), &cb, MAX_PAGES);
lay.manual_breaks = manual;
(lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES);
lay.pages.clear();
'outer: for ci in 0..lay.col_bands.len() {
for ri in 0..lay.row_bands.len() {
@@ -332,6 +315,37 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
Some(lay)
}
/// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each),
/// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The
/// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view.
pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) {
let k = lay.k();
let (aw, ah) = lay.avail();
let (aw, ah) = (aw / k, ah / k);
let st = &lay.settings;
let mut rb: Vec<u32> = if lay.manual_breaks { st.row_breaks.clone() } else { vec![] };
let mut cb: Vec<u32> = if lay.manual_breaks { st.col_breaks.clone() } else { vec![] };
rb.sort_unstable();
cb.sort_unstable();
let (title_rows, title_cols) = (lay.title_rows, lay.title_cols);
let title_h = |start: u32| -> f32 {
match title_rows {
Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(),
_ => 0.0,
}
};
let title_w = |start: u32| -> f32 {
match title_cols {
Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(),
_ => 0.0,
}
};
let area = lay.area;
let rows = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - lay.heading_h - title_h(start)).max(1.0), &rb, cap);
let cols = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - lay.heading_w - title_w(start)).max(1.0), &cb, cap);
(rows, cols)
}
/// Lays out every sheet a print command covers.
pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec<SheetLayout> {
let mut out = Vec::new();
@@ -375,6 +389,132 @@ pub struct HfContext {
pub time: String,
}
/// Today's date and the current time as headers and footers show them (`&D`, `&T`).
pub fn hf_date_time(wb: &Workbook) -> (String, String) {
let now = gridcraft_calc::now_serial();
let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text;
let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text;
(date, time)
}
/// Header/footer codes and the field names the Page Layout view shows for them while editing.
pub const HF_FIELDS: [(char, &str); 8] = [
('P', "&[Page]"),
('N', "&[Pages]"),
('D', "&[Date]"),
('T', "&[Time]"),
('Z', "&[Path]"),
('F', "&[File]"),
('A', "&[Tab]"),
('G', "&[Picture]"),
];
/// Splits a header/footer string into its raw left, centre and right sections (codes kept).
/// Text before the first `&L`/`&C`/`&R` belongs to the centre, as when printing.
pub fn split_header_footer(spec: &str) -> [String; 3] {
let mut out: [String; 3] = Default::default();
let mut cur = 1usize;
let chars: Vec<char> = spec.chars().take(10_000).collect();
let mut i = 0;
while let Some(&c) = chars.get(i) {
let next = chars.get(i + 1).copied();
let sec = cur.min(2);
match (c, next) {
('&', Some('L' | 'l')) => cur = 0,
('&', Some('C' | 'c')) => cur = 1,
('&', Some('R' | 'r')) => cur = 2,
('&', Some('"')) => {
// &"Font,Style" is kept whole.
let end = chars[i + 2..].iter().position(|ch| *ch == '"').map_or(chars.len(), |p| i + 3 + p);
out[sec].extend(&chars[i..end.min(chars.len())]);
i = end;
continue;
}
('&', Some(n)) => {
out[sec].push('&');
out[sec].push(n);
}
('&', None) => {}
(ch, _) => {
out[sec].push(ch);
i += 1;
continue;
}
}
i += 2;
}
out
}
/// Joins raw left, centre and right sections into one header/footer string.
pub fn join_header_footer(sections: &[String; 3]) -> String {
let mut s = String::new();
for (code, text) in ["&L", "&C", "&R"].iter().zip(sections) {
if !text.is_empty() {
s.push_str(code);
s.push_str(text);
}
}
s
}
/// A raw section as the user edits it: `&P` shows as `&[Page]`, `&N` as `&[Pages]`, and so on.
pub fn codes_to_fields(raw: &str) -> String {
let mut out = String::new();
let mut it = raw.chars().peekable();
while let Some(c) = it.next() {
if c != '&' {
out.push(c);
continue;
}
// A dangling `&` at the end is dropped (printing ignores it too).
if let Some(n) = it.next() {
match HF_FIELDS.iter().find(|(code, _)| *code == n.to_ascii_uppercase()) {
Some((_, field)) => out.push_str(field),
None => {
out.push('&');
out.push(n);
}
}
}
}
out
}
/// The inverse of [`codes_to_fields`]: field names become codes again; a dangling `&` at the end
/// becomes a literal ampersand so it can't swallow the next section's code.
pub fn fields_to_codes(text: &str) -> String {
let chars: Vec<char> = text.chars().take(10_000).collect();
let mut out = String::new();
let mut i = 0;
while let Some(&c) = chars.get(i) {
if c != '&' {
out.push(c);
i += 1;
continue;
}
if chars.get(i + 1) == Some(&'&') {
out.push_str("&&");
i += 2;
continue;
}
let rest: String = chars[i..].iter().take(12).collect();
if let Some((code, field)) = HF_FIELDS.iter().find(|(_, f)| rest.starts_with(f)) {
out.push('&');
out.push(*code);
i += field.chars().count();
continue;
}
if i + 1 == chars.len() {
out.push_str("&&");
} else {
out.push('&');
}
i += 1;
}
out
}
/// One header/footer section: its text and whether `&B` made it bold.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HfSection {
@@ -1286,9 +1426,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
doc.info.title = if wb.props.title.is_empty() { file.to_string() } else { wb.props.title.clone() };
doc.info.author = wb.props.author.clone();
let total: usize = layouts.iter().map(|l| l.pages.len()).sum();
let now = gridcraft_calc::now_serial();
let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text;
let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text;
let (date, time) = hf_date_time(wb);
let mut page_no = 0usize;
for lay in layouts {
let Some(sh) = wb.sheet(lay.sheet) else { continue };
@@ -1587,6 +1725,63 @@ mod tests {
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
}
#[test]
fn header_sections_split_join_and_field_names() {
let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D";
let parts = split_header_footer(spec);
assert_eq!(parts, ["&\"Arial,Bold\"Sales &&Co".to_string(), "Page &P of &N".into(), "&D".into()]);
assert_eq!(join_header_footer(&parts), spec);
// Text before any section code is the centre section, as when printing.
assert_eq!(split_header_footer("Report"), [String::new(), "Report".into(), String::new()]);
assert_eq!(codes_to_fields("Page &P of &N, &A (&F) &&"), "Page &[Page] of &[Pages], &[Tab] (&[File]) &&");
assert_eq!(fields_to_codes("Page &[Page] of &[Pages], &[Tab] (&[File]) &&"), "Page &P of &N, &A (&F) &&");
// A dangling ampersand stays literal instead of eating the next section's code.
assert_eq!(fields_to_codes("R&"), "R&&");
let joined = join_header_footer(&[fields_to_codes("R&"), "x".into(), String::new()]);
assert_eq!(split_header_footer(&joined)[0], "R&&");
let cx = HfContext { page: 2, pages: 5, sheet: "Data".into(), ..Default::default() };
let out = expand_header_footer(&join_header_footer(&[String::new(), fields_to_codes("&[Page] / &[Pages]"), fields_to_codes("&[Tab]")]), &cx);
assert_eq!((out[1].text.as_str(), out[2].text.as_str()), ("2 / 5", "Data"));
// Hostile input never panics.
for t in ["&", "&\"", "&\"unterminated", "&&&", "&[Pag", "\u{1F600}&[Page]"] {
let _ = join_header_footer(&split_header_footer(t));
let _ = fields_to_codes(&codes_to_fields(t));
}
}
#[test]
fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() {
let mut s = s();
s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&RRight"})).unwrap();
let r = s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "center", "text": "Page &[Page] of &[Pages]"})).unwrap();
assert_eq!(r["header"], "&LLeft&CPage &P of &N&RRight");
s.execute("pageLayout.headerFooter", json!({"part": "footer", "section": "right", "text": "&[Date]"})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.footer, "&R&D");
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.header, "&LLeft&RRight");
assert!(s.execute("pageLayout.headerFooter", json!({"part": "middle", "text": "x"})).is_err());
assert!(s.execute("pageLayout.headerFooter", json!({"section": "top"})).is_err());
let long = "x".repeat(1000);
s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "left", "text": long})).unwrap();
assert!(s.doc().unwrap().wb.sheet(0).unwrap().print.header.chars().count() <= 255);
// The view is per sheet and doesn't dirty a clean workbook.
let mut fresh = Session::new();
fresh.new_workbook();
fresh.execute("home.insertSheet", json!({})).unwrap();
fresh.doc_mut().unwrap().saved = fresh.doc().unwrap().wb.clone();
fresh.execute("view.pageLayout", json!({"sheet": 0})).unwrap();
let wb = &fresh.doc().unwrap().wb;
assert_eq!((wb.sheet(0).unwrap().view, wb.sheet(1).unwrap().view), (SheetViewMode::PageLayout, SheetViewMode::Normal));
assert!(!fresh.doc().unwrap().is_dirty(), "switching views is not an edit");
s.execute("view.pageLayout", json!({"sheet": 0})).unwrap();
s.execute("view.normal", json!({"sheet": 0})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().view, SheetViewMode::Normal);
assert_eq!(s.execute("view.ruler", json!({"on": false})).unwrap()["on"], false);
assert!(s.hide_ruler);
assert_eq!(s.execute("view.ruler", json!({})).unwrap()["on"], true);
}
#[test]
fn page_count_300_rows_letter_portrait() {
let mut s = s();
+348 -2
View File
@@ -1,6 +1,8 @@
//! Review tab: protection, spelling (basic), workbook statistics.
//! Review tab: protection, spelling (basic), workbook statistics, comment navigation and the
//! Comments pane, Show All Notes and Convert to Comments.
use gridcraft_model::{SheetProtection, password_hash};
use gridcraft_core::CellRef;
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, Visibility, password_hash};
use serde_json::{Value as Json, json};
use super::*;
@@ -19,10 +21,107 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("review.unprotectSheet", "Unprotect Sheet", ["Review", "Protect"], None, "{password?}", has_doc, unprotect_sheet),
cmd!("review.protectWorkbook", "Protect Workbook", ["Review", "Protect"], None, "{on?: bool}", has_doc, protect_workbook),
cmd!(query "review.workbookStatistics", "Workbook Statistics", ["Review", "Proofing"], None, "{}", has_doc, stats),
// Not journaled: the params can hold a password.
CommandSpec {
id: "review.allowEditRanges",
label: "Allow Edit Ranges…",
menu: &["Review", "Protect"],
shortcut: None,
params: "{action?: list (default) | add | modify | delete | unlock, title?, range?, password?, newTitle?, sheet?} → ranges",
enabled: has_doc,
run: allow_edit_ranges,
journal: false,
undoable: true,
},
cmd!(query "review.checkAccessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → issues", has_doc, accessibility),
cmd!(noundo "review.previousComment", "Previous Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| {
step_comment(s, false)
}),
cmd!(noundo "review.nextComment", "Next Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| step_comment(
s, true
)),
cmd!(query "review.showComments", "Show Comments", ["Review", "Comments"], None, "{on?: bool} (toggles the Comments pane)", has_doc, |s, p| {
s.comments_pane = bool_param(p, "on").unwrap_or(!s.comments_pane);
Ok(json!({"on": s.comments_pane}))
}),
cmd!(query "review.showAllNotes", "Show All Notes", ["Review", "Notes"], None, "{on?: bool}", has_doc, |s, p| {
s.show_all_notes = bool_param(p, "on").unwrap_or(!s.show_all_notes);
Ok(json!({"on": s.show_all_notes}))
}),
cmd!(
"review.convertToComments",
"Convert to Comments",
["Review", "Notes"],
None,
"{} (every note in the workbook) → {converted}",
has_doc,
convert_notes
),
]
}
/// Threaded comments in navigation order: visible sheets in tab order, then row by row.
fn comment_cells(s: &Session) -> Vec<(usize, CellRef)> {
let Some(d) = s.active() else { return vec![] };
d.wb.sheets
.iter()
.enumerate()
.filter(|(_, sh)| sh.visibility == Visibility::Visible)
// `CellRef` orders by row, then column: reading order.
.flat_map(|(i, sh)| sh.comments.iter().filter(|(_, c)| c.threaded).map(move |(at, _)| (i, *at)))
.collect()
}
fn has_comments(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if comment_cells(s).is_empty() { Err("there are no comments in this workbook".into()) } else { Ok(()) }
}
/// Selects the next (or previous) threaded comment after the active cell, moving on to the next
/// sheet and wrapping around at the end of the workbook.
fn step_comment(s: &mut Session, forward: bool) -> Result<Json> {
let list = comment_cells(s);
let d = s.doc()?;
let here = (d.wb.active_sheet, d.selection.active);
let target = if forward {
list.iter().find(|c| **c > here).or_else(|| list.first())
} else {
list.iter().rev().find(|c| **c < here).or_else(|| list.last())
};
let Some(&(sheet, at)) = target else {
return Err(EngineError::Other("There are no comments in this workbook.".into()));
};
let d = s.doc_mut()?;
d.wb_switch_sheet(sheet);
d.selection = crate::Selection::at(at);
if let Some(sh) = d.wb.active() {
d.selection.expand_merges(sh);
}
let name = d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default();
Ok(json!({"sheet": name, "cell": at.a1()}))
}
/// Review › Notes › Convert to Comments: every note in the workbook becomes a threaded comment
/// with the same author and text.
fn convert_notes(s: &mut Session, _: &Json) -> Result<Json> {
let sheets: Vec<usize> =
s.doc()?.wb.sheets.iter().enumerate().filter(|(_, sh)| sh.comments.values().any(|c| !c.threaded)).map(|(i, _)| i).collect();
if sheets.is_empty() {
return Ok(json!({"converted": 0}));
}
edit(s, |cx| {
let mut n = 0usize;
for i in &sheets {
for c in cx.sheet_mut(*i)?.comments.values_mut().filter(|c| !c.threaded) {
c.threaded = true;
c.visible = false;
n += 1;
}
}
Ok(json!({"converted": n}))
})
}
fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let b = |k: &str| bool_param(p, k).unwrap_or(false);
@@ -60,6 +159,117 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
})
}
fn range_text(r: &EditRange) -> String {
r.ranges.iter().map(|x| x.a1()).collect::<Vec<_>>().join(",")
}
/// Review › Allow Edit Ranges: list, add, modify or delete the ranges that stay editable on a
/// protected sheet, or unlock a password range for this session.
fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let sheet = target_sheet(s, p)?;
let action = str_param(p, "action").unwrap_or("list");
let title = str_param(p, "title").map(str::trim).filter(|t| !t.is_empty());
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let sheet_name = sh.name.clone();
let find = |t: Option<&str>| -> Result<usize> {
let t = t.ok_or_else(|| bad("review.allowEditRanges", "missing `title`"))?;
sh.edit_ranges
.iter()
.position(|r| r.title.eq_ignore_ascii_case(t))
.ok_or_else(|| bad("review.allowEditRanges", format!("no range titled `{t}`")))
};
let hash = |pw: &str, title: &str| {
let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let seed = format!("{sheet_name}\u{0}{title}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits());
PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes()))
};
if matches!(action, "add" | "modify" | "delete") && sh.is_protected() {
return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into()));
}
match action {
"list" => {}
"unlock" => {
let i = find(title)?;
let Some(r) = sh.edit_ranges.get(i) else { return Err(bad("review.allowEditRanges", "no such range")) };
if let Some(h) = &r.password {
let pw = str_param(p, "password").unwrap_or("");
if !h.verify(pw).map_err(EngineError::Other)? {
return Err(EngineError::Other("The password you supplied is not correct.".into()));
}
let key = (sheet_name.clone(), r.title.clone());
let d = s.doc_mut()?;
if !d.unlocked_ranges.contains(&key) {
d.unlocked_ranges.push(key);
}
}
}
"add" => {
let t = match title {
Some(t) => t.to_string(),
None => {
(1..).map(|n| format!("Range{n}")).find(|t| !sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(t))).unwrap_or_default()
}
};
if sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(&t)) {
return Err(bad("review.allowEditRanges", format!("a range titled `{t}` already exists")));
}
let ranges = target_ranges(s, p)?;
let password = str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| hash(pw, &t));
let r = EditRange { title: t, ranges, password, security_descriptor: None };
edit(s, |cx| {
cx.sheet_mut(sheet)?.edit_ranges.push(r.clone());
Ok(())
})?;
}
"modify" => {
let i = find(title)?;
let mut r = sh.edit_ranges.get(i).cloned().ok_or_else(|| bad("review.allowEditRanges", "no such range"))?;
if let Some(n) = str_param(p, "newTitle").map(str::trim).filter(|n| !n.is_empty()) {
if sh.edit_ranges.iter().enumerate().any(|(j, x)| j != i && x.title.eq_ignore_ascii_case(n)) {
return Err(bad("review.allowEditRanges", format!("a range titled `{n}` already exists")));
}
r.title = n.to_string();
}
if let Some(pw) = str_param(p, "password") {
r.password = (!pw.is_empty()).then(|| hash(pw, &r.title));
}
if p.get("range").is_some() {
r.ranges = target_ranges(s, p)?;
}
edit(s, |cx| {
if let Some(x) = cx.sheet_mut(sheet)?.edit_ranges.get_mut(i) {
*x = r.clone();
}
Ok(())
})?;
}
"delete" => {
let i = find(title)?;
edit(s, |cx| {
let list = &mut cx.sheet_mut(sheet)?.edit_ranges;
if i < list.len() {
list.remove(i);
}
Ok(())
})?;
}
other => return Err(bad("review.allowEditRanges", format!("unknown action `{other}`"))),
}
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
Ok(Json::Array(
sh.edit_ranges
.iter()
.map(|r| {
let unlocked = r.password.is_none() || d.unlocked_ranges.iter().any(|(s, t)| *s == sh.name && *t == r.title);
json!({"title": r.title, "range": range_text(r), "password": r.password.is_some(), "unlocked": unlocked})
})
.collect(),
))
}
fn protect_workbook(s: &mut Session, p: &Json) -> Result<Json> {
let cur = s.doc()?.wb.protected_structure;
let on = bool_param(p, "on").unwrap_or(!cur);
@@ -97,3 +307,139 @@ fn accessibility(s: &mut Session, _: &Json) -> Result<Json> {
}
Ok(Json::Array(issues))
}
#[cfg(test)]
mod tests {
use super::*;
fn book() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
for (sheet, cell) in [(0, "C1"), (0, "A3"), (1, "B2")] {
s.execute("review.newComment", json!({"sheet": sheet, "cell": cell, "text": "c", "author": "Ann"})).unwrap();
}
s.execute("review.newNote", json!({"sheet": 0, "cell": "A2", "text": "note", "author": "Bob"})).unwrap();
s
}
fn at(s: &Session) -> (usize, String) {
let d = s.doc().unwrap();
(d.wb.active_sheet, d.selection.active.a1())
}
#[test]
fn next_and_previous_comment_go_in_reading_order_across_sheets_and_wrap() {
let mut s = book();
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
// Notes are skipped: A2 holds a note, not a comment.
let order = [(0, "C1"), (0, "A3"), (1, "B2"), (0, "C1")];
for (sheet, cell) in order {
s.execute("review.nextComment", json!({})).unwrap();
assert_eq!(at(&s), (sheet, cell.to_string()));
}
for (sheet, cell) in [(1, "B2"), (0, "A3"), (0, "C1")] {
s.execute("review.previousComment", json!({})).unwrap();
assert_eq!(at(&s), (sheet, cell.to_string()));
}
}
#[test]
fn comment_navigation_is_disabled_without_comments() {
let mut s = Session::new();
s.new_workbook();
s.execute("review.newNote", json!({"cell": "A1", "text": "note"})).unwrap();
assert!(s.execute("review.nextComment", json!({})).is_err());
assert!(s.execute("review.previousComment", json!({})).is_err());
assert_eq!(at(&s), (0, "A1".to_string()));
}
#[test]
fn convert_to_comments_turns_every_note_into_a_comment_and_undoes() {
let mut s = book();
s.execute("review.newNote", json!({"sheet": 1, "cell": "D4", "text": "second", "author": "Cy"})).unwrap();
s.execute("review.showNote", json!({"sheet": 1, "cell": "D4"})).unwrap();
let r = s.execute("review.convertToComments", json!({})).unwrap();
assert_eq!(r["converted"], 2);
let wb = &s.doc().unwrap().wb;
let a2 = &wb.sheets[0].comments[&CellRef::parse("A2").unwrap()];
let d4 = &wb.sheets[1].comments[&CellRef::parse("D4").unwrap()];
assert!(a2.threaded && d4.threaded && !d4.visible);
assert_eq!((a2.author.as_str(), a2.text.as_str(), d4.author.as_str(), d4.text.as_str()), ("Bob", "note", "Cy", "second"));
// Nothing left to convert; one undo restores the notes.
assert_eq!(s.execute("review.convertToComments", json!({})).unwrap()["converted"], 0);
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.doc().unwrap().wb.sheets[0].comments[&CellRef::parse("A2").unwrap()].threaded);
}
#[test]
fn show_comments_and_show_all_notes_toggle() {
let mut s = book();
assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], true);
assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], false);
assert_eq!(s.execute("review.showComments", json!({"on": true})).unwrap()["on"], true);
assert!(s.comments_pane);
assert_eq!(s.execute("review.showAllNotes", json!({})).unwrap()["on"], true);
assert_eq!(s.execute("review.showAllNotes", json!({"on": false})).unwrap()["on"], false);
assert!(!s.show_all_notes);
}
}
#[cfg(test)]
mod edit_range_tests {
use super::*;
fn protected_book() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1:A3"})).unwrap();
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Budget", "range": "B1:B3,D1", "password": "pa55"})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s
}
#[test]
fn edit_ranges_open_locked_cells_and_passwords_really_verify() {
let mut s = protected_book();
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
assert!(s.execute("cell.set", json!({"cell": "C1", "input": "1"})).is_err());
let err = s.execute("cell.set", json!({"cell": "D1", "input": "1"})).unwrap_err().to_string();
assert!(err.contains("Budget"), "{err}");
// A paste or fill is checked the same way.
assert!(s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).is_err());
assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget", "password": "pass"})).is_err());
assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget"})).is_err());
let list = s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "budget", "password": "pa55"})).unwrap();
assert_eq!(list[1]["unlocked"], true);
s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).unwrap();
s.execute("cell.set", json!({"cell": "D1", "input": "2"})).unwrap();
// Ranges can't change while the sheet is protected.
assert!(s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Open"})).is_err());
}
#[test]
fn edit_ranges_add_modify_delete_list_and_undo() {
let mut s = Session::new();
s.new_workbook();
// Empty params list; nothing to list yet.
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
let list = s.execute("review.allowEditRanges", json!({"action": "add"})).unwrap();
assert_eq!(list, json!([{"title": "Range1", "range": "C3:D4", "password": false, "unlocked": true}]));
assert!(s.execute("review.allowEditRanges", json!({"action": "add", "title": "range1", "range": "A1"})).is_err());
let list = s
.execute("review.allowEditRanges", json!({"action": "modify", "title": "Range1", "newTitle": "Mine", "range": "E5", "password": "x"}))
.unwrap();
assert_eq!((list[0]["title"].as_str(), list[0]["range"].as_str(), list[0]["password"].as_bool()), (Some("Mine"), Some("E5"), Some(true)));
// Inserting a row above moves the range with its cells.
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["range"], "E6");
s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Mine"})).unwrap();
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["title"], "Mine");
assert!(s.execute("review.allowEditRanges", json!({"action": "nope"})).is_err());
assert!(s.execute("review.allowEditRanges", json!({"action": "delete"})).is_err());
}
}
+146
View File
@@ -36,6 +36,15 @@ pub fn specs() -> Vec<CommandSpec> {
Axis::Cols
)),
cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells),
cmd!(
"home.insertCopiedCells",
"Insert Copied Cells",
["Home", "Cells", "Insert"],
None,
"{at?: \"B3\", shift?: down|right}: inserts the copied cells at `at` (the active cell) and pastes them there. Whole copied rows or columns insert whole rows or columns; other blocks shift the cells at `at` down (default) or right. A cut of whole rows or columns moves them (Insert Cut Cells)",
has_clipboard,
insert_copied_cells
),
cmd!("home.deleteCells", "Delete Cells…", ["Home", "Cells", "Delete"], None, "{range?, shift: left|up|row|column}", has_doc, delete_cells),
cmd!("home.insertSheet", "Insert Sheet", ["Home", "Cells", "Insert"], Some("Shift+F11"), "{name?, before?: index}", has_doc, insert_sheet),
cmd!("home.deleteSheet", "Delete Sheet", ["Home", "Cells", "Delete"], None, "{sheet?}", has_doc, delete_sheet),
@@ -146,6 +155,10 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
for cf in sh.cond_formats.iter_mut() {
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r)).collect();
}
for er in sh.edit_ranges.iter_mut() {
er.ranges = er.ranges.iter().filter_map(|r| map_range(*r)).collect();
}
sh.edit_ranges.retain(|er| !er.ranges.is_empty());
sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
for dv in sh.validations.iter_mut() {
dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r)).collect();
@@ -381,6 +394,128 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
Ok(out)
}
/// Insert Copied Cells / Insert Cut Cells: makes room at the destination, then pastes (or, for a
/// cut, moves) the clipboard there, as one undo step.
fn insert_copied_cells(s: &mut Session, p: &Json) -> Result<Json> {
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let at = match str_param(p, "at") {
Some(t) => cell_param(p, "at").ok_or_else(|| bad("home.insertCopiedCells", format!("not a cell: {t}")))?,
None => d.selection.current().start,
};
let shift = str_param(p, "shift").unwrap_or("down");
if shift != "down" && shift != "right" {
return Err(bad("home.insertCopiedCells", format!("unknown `shift`: {shift} (down or right)")));
}
let sel = clip.selected;
let rows = sel.is_full_rows();
let cols = sel.is_full_cols();
if rows && cols {
return Err(EngineError::Other("The copy area is the whole sheet; there is no room to insert it.".into()));
}
let same_doc = d.uid == clip.doc_uid;
let cut = clip.cut && same_doc;
if cut && !rows && !cols {
return Err(EngineError::Other("Insert Cut Cells works on whole rows or columns; cut whole rows or columns, or use Paste.".into()));
}
// A moved block can't be inserted inside itself.
if cut && clip.sheet == sheet {
let inside = if rows { at.row > sel.start.row && at.row <= sel.end.row } else { at.col > sel.start.col && at.col <= sel.end.col };
if inside {
return Err(EngineError::Other("The cut rows or columns can't be inserted inside themselves.".into()));
}
}
let revision = d.revision;
let before = (d.wb.clone(), d.selection.clone());
let result = insert_copied_steps(s, &clip, sheet, at, shift, cut);
let d = s.doc_mut()?;
// The nested steps collapse into this command's single undo step.
for _ in 0..d.revision.saturating_sub(revision) {
d.undo.pop();
}
match result {
Ok((final_range, out)) => {
d.selection = crate::Selection { active: final_range.start, anchor: final_range.start, ranges: vec![final_range] };
// Like inserting cells, this ends copy mode (the copied block may have moved).
cancel_copy_mode(s);
Ok(out)
}
Err(e) => {
d.wb = before.0;
d.selection = before.1;
d.calc.rebuild(&d.wb);
s.clipboard = Some(clip);
Err(e)
}
}
}
fn insert_copied_steps(s: &mut Session, clip: &crate::Clipboard, sheet: usize, at: CellRef, shift: &str, cut: bool) -> Result<(RangeRef, Json)> {
let sel = clip.selected;
let rows = sel.is_full_rows();
let cols = sel.is_full_cols();
// Where the copied block's top-left lands, relative to the (untrimmed) copied lines.
let (inserted, paste_at) = if rows {
let h = sel.height();
let start = at.row;
let r = format!("{}:{}", start as u64 + 1, start as u64 + h as u64);
s.execute("home.insertRows", json!({"rows": r, "sheet": sheet}))?;
let dr = clip.range.start.row.saturating_sub(sel.start.row);
(RangeRef::rows(start, start.saturating_add(h - 1).min(MAX_ROWS - 1)), CellRef::new(start.saturating_add(dr), clip.range.start.col))
} else if cols {
let w = sel.width();
let start = at.col;
let end = start.saturating_add(w - 1).min(MAX_COLS - 1);
s.execute("home.insertColumns", json!({"cols": RangeRef::cols(start, end).a1(), "sheet": sheet}))?;
let dc = clip.range.start.col.saturating_sub(sel.start.col);
(RangeRef::cols(start, end), CellRef::new(clip.range.start.row, start.saturating_add(dc)))
} else {
let end = at
.offset(clip.range.height() as i64 - 1, clip.range.width() as i64 - 1)
.ok_or_else(|| EngineError::Other("The copied cells don't fit on the sheet at this position.".into()))?;
let block = RangeRef::new(at, end);
s.execute("home.insertCells", json!({"range": block.a1(), "shift": shift, "sheet": sheet}))?;
(block, at)
};
// Inserting ended copy mode; bring the clipboard back for the paste. A cut reads the live
// workbook, so it follows the source if the insert pushed it along.
let mut clip = clip.clone();
if cut {
let pushed = clip.sheet == sheet && if rows { sel.start.row >= at.row } else { sel.start.col >= at.col };
if pushed {
let (dr, dc) = if rows { (inserted.height() as i64, 0) } else { (0, inserted.width() as i64) };
clip.range = RangeRef::new(clip.range.start.offset_clamped(dr, dc), clip.range.end.offset_clamped(dr, dc));
clip.selected = RangeRef::new(sel.start.offset_clamped(dr, dc), sel.end.offset_clamped(dr, dc));
}
clip.wb = s.doc()?.wb.clone();
}
let source_lines = clip.selected;
s.clipboard = Some(clip.clone());
let out = s.execute("edit.pasteSpecial", json!({"what": "all", "at": paste_at.a1(), "sheet": sheet}))?;
if !cut {
return Ok((inserted, out));
}
// Close the gap the moved lines left behind.
let (removed, before_dest) = if rows {
s.execute(
"home.deleteRows",
json!({"rows": format!("{}:{}", source_lines.start.row as u64 + 1, source_lines.end.row as u64 + 1), "sheet": clip.sheet}),
)?;
(source_lines.height(), clip.sheet == sheet && source_lines.start.row < inserted.start.row)
} else {
s.execute("home.deleteColumns", json!({"cols": RangeRef::cols(source_lines.start.col, source_lines.end.col).a1(), "sheet": clip.sheet}))?;
(source_lines.width(), clip.sheet == sheet && source_lines.start.col < inserted.start.col)
};
let moved = if before_dest {
let (dr, dc) = if rows { (-(removed as i64), 0) } else { (0, -(removed as i64)) };
RangeRef::new(inserted.start.offset_clamped(dr, dc), inserted.end.offset_clamped(dr, dc))
} else {
inserted
};
Ok((moved, out))
}
fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
@@ -538,6 +673,12 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
se.categories = se.categories.as_deref().map(fix);
se.name = se.name.as_deref().map(fix);
}
// A PivotChart follows its PivotTable's sheet.
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
&& sheet.eq_ignore_ascii_case(&old)
{
ch.pivot = Some(crate::pivot::chart_link(&name, &pivot));
}
}
}
for n in cx.wb.names.iter_mut() {
@@ -563,6 +704,7 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone());
sh.name = name.chars().take(31).collect();
sh.tables.clear(); // table names must be unique
sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original
let at = to.min(n);
// The copy gets its own copies of the sheet's names, referring to the copy.
let copies: Vec<gridcraft_model::DefinedName> = cx
@@ -693,6 +835,10 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
for cf in sh.cond_formats.iter_mut() {
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
}
for er in sh.edit_ranges.iter_mut() {
er.ranges = er.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
}
sh.edit_ranges.retain(|er| !er.ranges.is_empty());
sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
for dv in sh.validations.iter_mut() {
dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
File diff suppressed because it is too large Load Diff
+66 -5
View File
@@ -28,6 +28,11 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
cmd!(query "view.formulaBar", "Formula Bar", ["View", "Show"], None, "{on?: bool}", always, |s, p| {
s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar);
Ok(json!({"on": s.prefs.formula_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!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper),
@@ -91,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Header & Footer",
["Insert", "Text"],
None,
"{header?: \"&C&P\", footer?: \"&CPage &P of &N\"}",
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\"} or one section: {sheet?, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer}",
has_doc,
header_footer
),
@@ -281,17 +286,48 @@ fn breaks(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Excel keeps at most 255 characters per header or footer.
const MAX_HEADER_FOOTER: usize = 255;
fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "pageLayout.headerFooter";
let si = target_sheet(s, p)?;
let cap = |t: &str| t.chars().take(MAX_HEADER_FOOTER).collect::<String>();
// One section edited in place (Page Layout view).
let section = match (str_param(p, "part"), str_param(p, "section")) {
(None, None) => None,
(part, sec) => {
let footer = match part.unwrap_or("header") {
"header" => false,
"footer" => true,
other => return Err(bad(CMD, format!("`part` must be header or footer, not `{other}`"))),
};
let idx = match sec.unwrap_or("center") {
"left" => 0usize,
"center" | "centre" => 1,
"right" => 2,
other => return Err(bad(CMD, format!("`section` must be left, center or right, not `{other}`"))),
};
Some((footer, idx, super::print::fields_to_codes(str_param(p, "text").unwrap_or(""))))
}
};
edit(s, |cx| {
let si = cx.sheet_index();
let pr = &mut cx.sheet_mut(si)?.print;
if let Some(h) = str_param(p, "header") {
pr.header = h.to_string();
pr.header = cap(h);
}
if let Some(f) = str_param(p, "footer") {
pr.footer = f.to_string();
pr.footer = cap(f);
}
Ok(Json::Null)
if let Some((footer, idx, text)) = &section {
let spec = if *footer { &mut pr.footer } else { &mut pr.header };
let mut parts = super::print::split_header_footer(spec);
if let Some(slot) = parts.get_mut(*idx) {
*slot = text.clone();
}
*spec = cap(&super::print::join_header_footer(&parts));
}
Ok(json!({"header": pr.header, "footer": pr.footer}))
})
}
@@ -336,3 +372,28 @@ fn theme(s: &mut Session, p: &Json) -> Result<Json> {
#[allow(dead_code)]
fn unused(_: CellRef) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zoom_100_resets_the_active_sheet_zoom() {
let mut s = Session::new();
s.new_workbook();
s.execute("view.zoom", json!({"percent": 150})).unwrap();
assert_eq!(s.execute("view.zoom100", json!({})).unwrap()["zoom"], 100);
assert_eq!(s.doc().unwrap().wb.active().unwrap().zoom, 100);
}
#[test]
fn formula_bar_toggles_without_a_workbook_and_defaults_to_shown() {
let mut s = Session::new();
assert!(s.prefs.formula_bar);
assert_eq!(s.execute("view.formulaBar", json!({})).unwrap()["on"], false);
assert_eq!(s.execute("view.formulaBar", json!({"on": true})).unwrap()["on"], true);
// A prefs.json saved before the option existed keeps the formula bar shown.
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
assert!(old.formula_bar);
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
/// A range text (`"A1:C9"`, `"Sheet2!A:C"`) resolved against the workbook. Whole rows/columns
/// are trimmed to the used range.
fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
let (sh, body) = split_sheet(t);
let si = sheet_named(wb, sh, wb.active_sheet, cmd)?;
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?;