Merge upstream main into fix/formula-arg-count-363

Claude-Session: https://claude.ai/code/session_01BGBBGBC2gRpQ7GDuwQ6WPt
This commit is contained in:
Shirish Veerabattini
2026-10-11 15:20:56 -05:00
109 changed files with 10108 additions and 388 deletions
+219 -25
View File
@@ -8,6 +8,7 @@ use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::*;
use crate::outline;
use crate::selection::current_region;
pub fn specs() -> Vec<CommandSpec> {
@@ -74,10 +75,37 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd),
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid),
cmd!("data.group", "Group", ["Data", "Outline"], Some("Cmd+Shift+K"), "{rows?: \"2:5\" | cols?: \"B:C\"}", has_doc, |s, p| group(s, p, true)),
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Cmd+Shift+J"), "{rows? | cols?}", has_doc, |s, p| group(s, p, false)),
cmd!("data.hideDetail", "Hide Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, true)),
cmd!("data.showDetail", "Show Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, false)),
cmd!(
"data.group",
"Group",
["Data", "Outline"],
Some("Alt+Shift+Right"),
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default)}",
has_doc,
|s, p| group(s, p, true)
),
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols}", has_doc, |s, p| group(
s, p, false
)),
cmd!(
"data.hideDetail",
"Hide Detail",
["Data", "Outline"],
None,
"{rows? | cols? | by?, level?: 1-7} (the innermost group, or the one at `level`, holding the lines or summarised by them)",
has_doc,
|s, p| detail(s, p, true)
),
cmd!("data.showDetail", "Show Detail", ["Data", "Outline"], None, "{rows? | cols? | by?, level?: 1-7}", has_doc, |s, p| detail(s, p, false)),
cmd!(
"data.showLevels",
"Show Outline Levels",
[],
None,
"{rows?: 1-8, cols?: 1-8} (1 shows only the top-level summaries, as the outline's level buttons)",
has_doc,
show_levels
),
cmd!(
"data.subtotal",
"Subtotal",
@@ -759,7 +787,7 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect());
let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false);
let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"');
let dest = cell_param(p, "destination").unwrap_or(r.start);
let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start);
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
let mut out: Vec<(CellRef, String)> = Vec::new();
@@ -952,10 +980,26 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -
return None;
}
let v = gridcraft_core::parse::parse_input(input, wb.date_system).value;
let num = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, f).to_number().ok();
// The formulas are written for the first cell of the range and see the value being entered,
// as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5).
let first = dv.ranges.first().map(|r| r.start).unwrap_or(at);
let entered;
let wb = if sh.value(at) == v {
wb
} else {
let mut w = wb.clone();
if let Some(sh) = w.sheet_mut(sheet) {
let style = sh.style_id(at);
sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() });
}
entered = w;
&entered
};
let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at));
let num = |f: &str| eval(f).to_number().ok();
let ok = match dv.kind {
ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)),
ValidationKind::Custom => gridcraft_calc::evaluate(wb, sheet, at, &dv.f1).to_bool().unwrap_or(false),
ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false),
ValidationKind::TextLength => {
let n = input.chars().count() as f64;
compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num))
@@ -982,6 +1026,21 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -
Some((dv.clone(), msg))
}
/// A formula written for `first`, as seen from `at` (relative references shifted).
fn formula_at(f: &str, first: CellRef, at: CellRef) -> String {
if first == at {
return f.to_string();
}
match gridcraft_formula::parse(f.trim_start_matches('=')) {
Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative(
e,
at.row as i64 - first.row as i64,
at.col as i64 - first.col as i64,
)),
Err(_) => f.to_string(),
}
}
fn compare_op(op: CfOperator, n: f64, a: Option<f64>, b: Option<f64>) -> bool {
let Some(a) = a else { return false };
match op {
@@ -998,7 +1057,7 @@ fn compare_op(op: CfOperator, n: f64, a: Option<f64>, b: Option<f64>) -> bool {
fn validate_cmd(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let input = str_param(p, "input").unwrap_or("");
match check_validation(&d.wb, d.wb.active_sheet, at, input) {
None => Ok(json!({"ok": true})),
@@ -1036,7 +1095,12 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
return Ok((false, r.start.col, r.end.col));
}
let r = s.doc()?.selection.current();
if r.is_full_cols() && !r.is_full_rows() { Ok((false, r.start.col, r.end.col)) } else { Ok((true, r.start.row, r.end.row)) }
match str_param(p, "by") {
Some("rows") => Ok((true, r.start.row, r.end.row)),
Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)),
_ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)),
_ => Ok((true, r.start.row, r.end.row)),
}
}
fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
@@ -1045,39 +1109,94 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let map = if rows { &mut sh.rows } else { &mut sh.cols };
for i in a..=b.min(a + 1_048_575) {
let b = b.min(a.saturating_add(1_048_575));
for i in a..=b {
let e = map.entry(i).or_default();
e.outline = if on { (e.outline + 1).min(7) } else { e.outline.saturating_sub(1) };
e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) };
if !on && e.outline == 0 {
e.hidden = false;
}
}
// A summary line whose group is gone has nothing left to collapse.
if !on
&& !outline::groups(map).iter().any(|g| g.end == b)
&& let Some(e) = map.get_mut(&b.saturating_add(1))
{
e.collapsed = false;
}
Ok(Json::Null)
})
}
/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or
/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the
/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed.
fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
let (rows, a, b) = outline_lines(s, p)?;
let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8);
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let map = if rows { &mut sh.rows } else { &mut sh.cols };
// Expand to the whole group around the selection.
let level = (a..=b).filter_map(|i| map.get(&i).map(|e| e.outline)).max().unwrap_or(0);
if level == 0 {
return Ok(Json::Null);
let all = outline::groups(map);
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0);
hits.into_iter().filter(|g| g.level == deepest).collect()
};
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
if chosen.is_empty() {
chosen = pick(&|g| g.summary() >= a && g.summary() <= b);
}
let mut lo = a;
while lo > 0 && map.get(&(lo - 1)).is_some_and(|e| e.outline >= level) {
lo -= 1;
for g in chosen {
// Inner groups collapsed on their own (their summary marked), before this one changes.
let inner: Vec<outline::OutlineGroup> = all
.iter()
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.summary() != g.summary())
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
.copied()
.collect();
for i in g.start..=g.end {
if let Some(e) = map.get_mut(&i) {
e.hidden = hide || inner.iter().any(|o| o.contains(i));
}
}
if hide {
map.entry(g.summary()).or_default().collapsed = true;
} else if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
let mut hi = b;
while map.get(&(hi + 1)).is_some_and(|e| e.outline >= level) {
hi += 1;
}
for i in lo..=hi {
if let Some(e) = map.get_mut(&i) {
e.hidden = hide;
cx.structural = true;
Ok(Json::Null)
})
}
/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the
/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed.
fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8);
let (rows, cols) = (level("rows"), level("cols"));
if rows.is_none() && cols.is_none() {
return Err(EngineError::BadParams {
cmd: "data.showLevels".into(),
msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(),
});
}
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
for (map, n) in [(&mut sh.rows, rows), (&mut sh.cols, cols)] {
let Some(n) = n else { continue };
let groups = outline::groups(map);
for e in map.values_mut().filter(|e| e.outline > 0) {
e.hidden = e.outline >= n;
}
// Inner groups come first, so a summary line shared with the group around it ends up
// as that outer group says.
for g in groups {
map.entry(g.summary()).or_default().collapsed = g.level >= n;
}
}
cx.structural = true;
@@ -1176,6 +1295,81 @@ mod tests {
assert!(!ok(&mut s, "B1", "150"));
}
/// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6).
fn nested_rows() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
s.execute("data.group", json!({"rows": "6:7"})).unwrap();
s
}
/// (hidden, collapsed) of 0-based rows.
fn row_state(s: &Session, rows: std::ops::RangeInclusive<u32>) -> Vec<(bool, bool)> {
let sh = s.doc().unwrap().wb.active().unwrap();
rows.map(|r| sh.rows.get(&r).map(|l| (l.hidden, l.collapsed)).unwrap_or_default()).collect()
}
#[test]
fn hide_detail_marks_the_summary_collapsed_and_show_detail_keeps_inner_groups_collapsed() {
let mut s = nested_rows();
s.execute("data.hideDetail", json!({"rows": "6:7", "level": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]);
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.showDetail", json!({"rows": "6:7", "level": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false); 5]);
// Each step is one undoable command.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(row_state(&s, 5..=7), [(true, false), (true, false), (false, true)]);
}
#[test]
fn hide_detail_on_the_summary_row_collapses_the_group_above_it() {
let mut s = nested_rows();
s.execute("data.hideDetail", json!({"rows": "9:9"})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, false), (false, true)]);
}
#[test]
fn show_levels_collapses_and_expands_to_a_level() {
let mut s = nested_rows();
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]);
s.execute("data.showLevels", json!({"rows": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false); 5]);
// Columns are separate; no level at all is an error rather than a guess.
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
s.execute("data.showLevels", json!({"cols": 1})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!((2..=4).all(|c| sh.cols.get(&c).is_some_and(|l| l.hidden)));
assert!(sh.cols.get(&5).is_some_and(|l| l.collapsed));
assert_eq!(row_state(&s, 4..=4), [(false, false)]);
assert!(s.execute("data.showLevels", json!({})).is_err());
}
#[test]
fn group_by_columns_for_a_block_of_cells() {
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
s.execute("data.group", json!({"by": "cols"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!((1..=2).all(|c| sh.cols.get(&c).is_some_and(|l| l.outline == 1)));
assert!(sh.rows.values().all(|l| l.outline == 0));
}
#[test]
fn group_shortcuts_are_the_windows_ones() {
let sc = |id: &str| specs().into_iter().find(|c| c.id == id).and_then(|c| c.shortcut);
assert_eq!(sc("data.group"), Some("Alt+Shift+Right"));
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
}
#[test]
fn split() {
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
+1
View File
@@ -94,6 +94,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
fill: Color::from_hex(&format!("#{:02X}0000", width.round().clamp(0.0, 255.0) as u8)).unwrap_or_default(),
line: Color::from_hex(&color).unwrap_or(Color::rgb(0x1F, 0x5F, 0xC9)),
text,
format: Default::default(),
};
edit(s, |cx| {
cx.sheet_mut(si)?.shapes.push(shape);
+6 -5
View File
@@ -214,7 +214,7 @@ fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = match cell_param(p, "cell") {
let at = match cell_param_on(s, p, "cell", sheet)? {
Some(c) => c,
None => s.doc()?.selection.active,
};
@@ -368,8 +368,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
s.doc_mut()?.wb_switch_sheet(i);
}
let ranges = target_ranges(s, p)?;
let active = cell_param(s, p, "active")?;
let d = s.doc_mut()?;
let active = cell_param(p, "active").or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default();
let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default();
let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges };
if let Some(sh) = d.wb.active() {
sel.expand_merges(sh);
@@ -788,7 +789,7 @@ fn paste(s: &mut Session, p: &Json) -> Result<Json> {
fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "at").unwrap_or(s.doc()?.selection.current().start);
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();
@@ -916,14 +917,14 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
let dest_sheet = target_sheet(s, p)?;
let sel = s.doc()?.selection.current();
let at = cell_param(p, "at").unwrap_or(sel.start);
let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start);
let src = clip.range;
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
// Tile the copy over a larger destination that is a multiple of its size.
let tile = sel.height() % h == 0
&& sel.width() % w == 0
&& !sel.is_single()
&& cell_param(p, "at").is_none()
&& cell_param_on(s, p, "at", dest_sheet)?.is_none()
&& !sel.is_full_cols()
&& !sel.is_full_rows()
&& sel.count() <= 4_000_000;
+13 -7
View File
@@ -444,7 +444,7 @@ fn move_chart(s: &mut Session, p: &Json) -> Result<Json> {
Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?),
None => None,
};
let at = cell_param(p, "at");
let at = cell_param_on(s, p, "at", target.unwrap_or(si))?;
edit(s, |cx| {
let mut chart = cx.sheet_mut(si)?.charts.remove(ci);
if let Some(a) = at {
@@ -876,10 +876,16 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
}
fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME);
Ok(Json::Array(
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(),
))
let wb = &s.doc()?.wb;
let all = stash_get(wb, SCRIPTS_NAME);
let mut list: Vec<Json> =
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default();
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
// save but never executed here, so it has no step count.
if let Some(bytes) = &wb.vba_project {
list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false}));
}
Ok(Json::Array(list))
}
fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
@@ -951,7 +957,7 @@ fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc()?;
let cells: Vec<CellRef> = match cell_param(p, "cell") {
let cells: Vec<CellRef> = match cell_param_on(s, p, "cell", si)? {
Some(c) => vec![c],
None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::<Vec<_>>()).collect(),
};
@@ -1023,7 +1029,7 @@ fn new_cell_style(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let c = cell_param(p, "fromCell").unwrap_or(d.selection.active);
let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active);
let style = d.wb.styles.get(sh.style_id(c)).clone();
edit(s, |cx| {
cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name));
+11 -5
View File
@@ -8,9 +8,9 @@ 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.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!(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/.pdf by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool}", has_doc, close),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
@@ -56,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 {
@@ -93,6 +94,11 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
return ok();
}
};
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
// document keeps its file and its unsaved changes.
if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
return super::print::export_pdf(s, &json!({"path": path}));
}
let d = s.doc_mut()?;
let bytes = crate::io::save_bytes(&d.wb, &path)?;
crate::io::write_file(&path, &bytes)?;
+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());
}
}
+37 -5
View File
@@ -253,7 +253,15 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
Some(sh) => Some(d.wb.sheet_index(sh).ok_or_else(|| bad("formulas.defineName", "no such sheet"))?),
};
let refers = match str_param(p, "refersTo") {
Some(r) => r.trim_start_matches('=').to_string(),
// As in Excel, relative references are relative to the active cell; names keep them
// relative to A1, as files do.
Some(r) => match gridcraft_formula::parse(r.trim_start_matches('=')) {
Ok(e) if gridcraft_formula::adjust::has_relative_refs(&e) => {
let at = d.selection.active;
gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative_wrapping(e, -(at.row as i64), -(at.col as i64)))
}
_ => r.trim_start_matches('=').to_string(),
},
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let r = d.selection.current();
@@ -444,7 +452,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at));
let v: Vec<Json> = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect();
Ok(Json::Array(v))
@@ -452,7 +460,7 @@ fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let mut deps = Vec::new();
d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps);
deps.sort();
@@ -493,7 +501,7 @@ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let text = match str_param(p, "formula") {
Some(f) => f.trim_start_matches('=').to_string(),
@@ -525,7 +533,7 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let f = str_param(p, "formula").ok_or_else(|| bad("formulas.evaluate", "missing `formula`"))?;
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let v = gridcraft_calc::evaluate(&d.wb, d.wb.active_sheet, at, f);
Ok(crate::cmd::inspect::value_json(&v))
}
@@ -564,3 +572,27 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"mode": format!("{:?}", cx.wb.calc.mode)}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_with_relative_references() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
// Defined from B2, `=Sheet1!A2` is "the cell to the left".
s.execute("selection.set", json!({"range": "B2"})).unwrap();
let r = s.execute("formulas.defineName", json!({"name": "LeftCell", "refersTo": "=Sheet1!A2"})).unwrap();
assert_eq!(r["refersTo"], "=Sheet1!XFD1");
s.execute("cell.set", json!({"cell": "B1", "input": "=LeftCell"})).unwrap();
s.execute("cell.set", json!({"cell": "B3", "input": "=LeftCell*10"})).unwrap();
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "B1"), json!(1.0));
assert_eq!(v(&mut s, "B3"), json!(30.0));
// Absolute references are kept as they are.
let r = s.execute("formulas.defineName", json!({"name": "First", "refersTo": "=Sheet1!$A$1"})).unwrap();
assert_eq!(r["refersTo"], "=Sheet1!$A$1");
}
}
+18 -12
View File
@@ -666,7 +666,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
let title = str_param(p, "title").map(str::to_string).or_else(|| {
if series.len() == 1 {
series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display())
@@ -722,6 +722,7 @@ fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
let at = cell_param_on(s, p, "at", si)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
@@ -755,7 +756,7 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(t) = p.get("yTitle") {
c.y_title = t.as_str().map(str::to_string);
}
if let Some(at) = cell_param(p, "at") {
if let Some(at) = at {
c.anchor.cell = at;
}
if let Some(w) = f64_param(p, "width") {
@@ -838,7 +839,7 @@ fn chart_delete(s: &mut Session, p: &Json) -> Result<Json> {
fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
let loc = cell_param(p, "location").ok_or_else(|| bad("insert.sparkline", "missing `location`"))?;
let loc = cell_param(s, p, "location")?.ok_or_else(|| bad("insert.sparkline", "missing `location`"))?;
let kind = match str_param(p, "type") {
Some("column") => SparklineKind::Column,
Some("winLoss") => SparklineKind::WinLoss,
@@ -897,7 +898,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240));
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or(d.selection.active);
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let scale = (640.0 / w.max(1) as f32).min(1.0);
let img = Image {
id,
@@ -960,7 +961,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
};
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or(d.selection.active);
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let fill =
super::format::color_param(p.get("fill")).unwrap_or(if kind == ShapeKind::TextBox { Color::rgb(255, 255, 255) } else { Color::Theme(4, 0) });
let line = super::format::color_param(p.get("line")).unwrap_or(if kind == ShapeKind::TextBox {
@@ -982,6 +983,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
fill,
line,
text: str_param(p, "text").unwrap_or("").into(),
format: Default::default(),
};
let sheet = d.wb.active_sheet;
edit(s, |cx| {
@@ -1030,6 +1032,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
match kind.as_str() {
"chart" => sh.charts.retain(|c| c.id != id),
"image" => sh.images.retain(|c| c.id != id),
"slicer" => sh.slicers.retain(|c| c.id != id),
_ => sh.shapes.retain(|c| c.id != id),
}
Ok(Json::Null)
@@ -1040,16 +1043,18 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet;
let at = cell_param(s, p, "at")?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => None,
};
let Some(a) = anchor else { return Err(bad("object.move", "no such object")) };
if let Some(at) = cell_param(p, "at") {
if let Some(at) = at {
a.cell = at;
}
if let Some(v) = f64_param(p, "dx") {
@@ -1096,7 +1101,7 @@ fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
if bool_param(p, "remove").unwrap_or(false) {
return edit(s, |cx| {
cx.sheet_mut(sheet)?.hyperlinks.remove(&at);
@@ -1128,7 +1133,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
@@ -1142,7 +1147,7 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
fn reply_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
@@ -1154,7 +1159,7 @@ fn reply_comment(s: &mut Session, p: &Json) -> Result<Json> {
fn resolve_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let v = bool_param(p, "resolved").unwrap_or(true);
edit(s, |cx| {
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
@@ -1175,7 +1180,7 @@ fn delete_comment(s: &mut Session, p: &Json) -> Result<Json> {
fn show_note(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
edit(s, |cx| {
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
c.visible = !c.visible;
@@ -1402,7 +1407,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32;
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or(d.selection.active);
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
let shape = Shape {
id,
@@ -1411,6 +1416,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
fill: color,
line: Color::Auto,
text: name,
format: Default::default(),
};
let sheet = d.wb.active_sheet;
edit(s, |cx| {
+1 -1
View File
@@ -119,7 +119,7 @@ fn cell_get(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 c = cell_param(p, "cell").unwrap_or(d.selection.active);
let c = cell_param_on(s, p, "cell", sheet)?.unwrap_or(d.selection.active);
let cell = sh.cell(c);
let style = d.wb.styles.get(sh.style_id(c));
Ok(json!({
+26 -2
View File
@@ -7,6 +7,7 @@ pub mod draw;
pub mod edit;
pub mod extra;
pub mod file;
pub mod forecast;
pub mod format;
pub mod formulas;
pub mod insert;
@@ -15,6 +16,7 @@ pub mod pivot;
pub mod print;
pub mod review;
pub mod sheet;
pub mod slicer;
pub mod solver;
pub mod spelling;
pub mod view;
@@ -137,7 +139,9 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(spelling::specs());
v.extend(solver::specs());
v.extend(pivot::specs());
v.extend(slicer::specs());
v.extend(whatif::specs());
v.extend(forecast::specs());
v.extend(analysis::specs());
v.extend(print::specs());
v
@@ -238,8 +242,28 @@ pub(crate) fn split_sheet(r: &str) -> (Option<String>, &str) {
}
}
pub(crate) fn cell_param(p: &Json, key: &str) -> Option<CellRef> {
str_param(p, key).and_then(|t| CellRef::parse(split_sheet(t).1))
/// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
/// silent fallback to some other cell.
pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result<Option<CellRef>> {
cell_param_on(s, p, key, s.doc()?.wb.active_sheet)
}
/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it).
pub(crate) fn cell_param_on(s: &Session, p: &Json, key: &str, sheet: usize) -> Result<Option<CellRef>> {
let t = match p.get(key) {
None | Some(Json::Null) => return Ok(None),
Some(Json::String(t)) => t,
Some(v) => return Err(bad("cell", format!("invalid cell reference `{v}` for `{key}`"))),
};
let (sh, body) = split_sheet(t);
let c = CellRef::parse(body).ok_or_else(|| bad("cell", format!("invalid cell reference `{t}` for `{key}`")))?;
if let Some(name) = sh
&& s.doc()?.wb.sheet_index(&name) != Some(sheet)
{
return Err(bad("cell", format!("`{t}` for `{key}` names a sheet other than the one this command works on")));
}
Ok(Some(c))
}
/// Applies an edit to a copy of the active workbook, then recalculates and commits it.
+2 -1
View File
@@ -1338,7 +1338,8 @@ fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
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 at =
cell_param(s, 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,
+72 -12
View File
@@ -1267,7 +1267,7 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block)
}
let (x, y, w, h) = place(r);
let fill = rgb_of(shp.fill, wb);
let line = rgb_of(shp.line, wb).map(|c| (c, (0.75 * b.k).max(0.25)));
let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25)));
match shp.kind {
ShapeKind::Ellipse => {
let pts: Vec<[f32; 2]> = (0..72)
@@ -1294,13 +1294,29 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block)
}
}
if !shp.text.is_empty() {
let size = 11.0 * b.scale;
let lines = wrap_lines(&shp.text, Font::Helvetica, size, (w - 4.0 * b.k).max(size));
let mut yy = y + 2.0 * b.k + size * 0.8;
use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor};
let fm = &shp.format;
let text_box = shp.kind == ShapeKind::TextBox;
let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale;
let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic);
let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]);
let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size));
let block_h = lines.len().min(500) as f32 * size * 1.2;
let mut yy = match anchor {
ShapeTextAnchor::Top => y + 2.0 * b.k,
ShapeTextAnchor::Middle => y + (h - block_h) / 2.0,
ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h,
} + size * 0.8;
for l in lines.iter().take(500) {
let lw = gridcraft_pdf::text_width(Font::Helvetica, size, l);
let lx = if shp.kind == ShapeKind::TextBox { x + 2.0 * b.k } else { x + (w - lw) / 2.0 };
pg.text(lx, yy, size, Font::Helvetica, [0, 0, 0], l);
let lw = gridcraft_pdf::text_width(font, size, l);
let lx = match align {
ShapeTextAlign::Left => x + 2.0 * b.k,
ShapeTextAlign::Center => x + (w - lw) / 2.0,
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
};
pg.text(lx, yy, size, font, color, l);
yy += size * 1.2;
}
}
@@ -1353,11 +1369,11 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at:
}
}
fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, cx: &HfContext) {
fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) {
let [l, r, _, _, hm, fm] = lay.margins;
let pw = lay.page_w;
let ph = lay.page_h;
for (spec, header) in [(&lay.settings.header, true), (&lay.settings.footer, false)] {
for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] {
if spec.is_empty() {
continue;
}
@@ -1433,7 +1449,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
let k = lay.k();
let (aw, ah) = lay.avail();
for (ri, ci) in &lay.pages {
for (pi, (ri, ci)) in lay.pages.iter().enumerate() {
page_no += 1;
let (Some(rband), Some(cband)) = (lay.row_bands.get(*ri).copied(), lay.col_bands.get(*ci).copied()) else { continue };
let rsegs = segments(lay.title_rows, rband);
@@ -1477,7 +1493,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
date: date.clone(),
time: time.clone(),
};
draw_header_footer(pg, lay, &cx);
draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx);
}
}
let n = doc.page_count();
@@ -1494,7 +1510,7 @@ fn build_pdf(s: &Session, p: &Json, cmd: &str) -> Result<(Vec<u8>, usize)> {
Ok(render_pdf(&d.wb, &lays, &d.display_title(), &path))
}
fn export_pdf(s: &mut Session, p: &Json) -> Result<Json> {
pub(crate) fn export_pdf(s: &mut Session, p: &Json) -> Result<Json> {
let (bytes, pages) = build_pdf(s, p, "file.exportPdf")?;
match str_param(p, "path").filter(|x| !x.is_empty()) {
Some(path) => {
@@ -1826,6 +1842,32 @@ mod tests {
assert_eq!(pv["pageList"][2]["range"], "K1:O45");
}
#[test]
fn first_and_even_pages_print_their_own_headers() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"], ["d"], ["e"], ["f"]]})).unwrap();
s.execute("pageLayout.pageSetup", json!({"rowBreaks": [2, 4]})).unwrap();
let r = s
.execute(
"pageLayout.headerFooter",
json!({"header": "&COdd &P", "evenHeader": "&CEven &P", "firstHeader": "&CCover", "differentFirst": true, "differentOddEven": true}),
)
.unwrap();
assert_eq!(r["firstHeader"], "&CCover");
let (pdf, pages) = pdf_of(&mut s, json!({}));
assert_eq!(pages, 3);
assert!(contains(&pdf, "(Cover) Tj") && contains(&pdf, "(Even 2) Tj") && contains(&pdf, "(Odd 3) Tj"));
assert!(!contains(&pdf, "(Odd 1) Tj"), "the first page has its own header");
// One section of the even-page footer, edited in place.
s.execute("pageLayout.headerFooter", json!({"page": "even", "part": "footer", "section": "right", "text": "&[Tab]"})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.even_footer, "&R&A");
assert!(s.execute("pageLayout.headerFooter", json!({"page": "last", "part": "header", "text": "x"})).is_err());
// Turned off, every page prints the odd header again.
s.execute("pageLayout.headerFooter", json!({"differentFirst": false, "differentOddEven": false})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
}
#[test]
fn pdf_contains_text_and_header_codes_expand() {
let mut s = s();
@@ -1881,6 +1923,24 @@ mod tests {
assert!(r["base64"].as_str().unwrap().len() > 100);
}
#[test]
fn save_as_pdf_writes_a_pdf_and_keeps_the_workbook_file() {
let mut s = s();
s.execute("cell.set", json!({"cell": "B2", "input": "hello"})).unwrap();
let dir = std::env::temp_dir().join(format!("gridcraft-save-pdf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Out.PDF");
let r = s.execute("file.saveAs", json!({"path": path.to_str().unwrap()})).unwrap();
assert_eq!(r["pages"], 1);
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
assert!(contains(&bytes, "(hello) Tj"));
// The PDF is a print, not the workbook's file: Save still asks where to put the workbook.
let d = s.doc().unwrap();
assert!(d.path.is_none() && d.is_dirty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rich_sheet_renders() {
let mut s = Session::new();
+105 -14
View File
@@ -19,7 +19,15 @@ pub fn specs() -> Vec<CommandSpec> {
protect_sheet
),
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!(
"review.protectWorkbook",
"Protect Workbook",
["Review", "Protect"],
None,
"{on?: bool (default: toggle), password?} (structure protection; unprotecting needs the password when one was set)",
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 {
@@ -124,9 +132,16 @@ fn convert_notes(s: &mut Session, _: &Json) -> Result<Json> {
fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
// Re-protecting a password-protected sheet would replace the password without knowing it:
// unprotect it first, as in Excel. One without a password can take new options directly.
let has_password = |pr: &SheetProtection| pr.password.is_some() || pr.legacy_password_hash.is_some();
if s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).is_some_and(has_password) {
return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into()));
}
let b = |k: &str| bool_param(p, k).unwrap_or(false);
let prot = SheetProtection {
password_hash: str_param(p, "password").filter(|x| !x.is_empty()).map(password_hash),
password: None,
legacy_password_hash: None,
select_locked: true,
select_unlocked: true,
format_cells: b("formatCells"),
@@ -139,6 +154,9 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
sort: b("sort"),
autofilter: b("autofilter"),
};
let d = s.doc()?;
let what = d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default();
let prot = SheetProtection { password: str_param(p, "password").filter(|x| !x.is_empty()).map(|pw| new_password_hash(d, &what, pw)), ..prot };
edit(s, |cx| {
cx.sheet_mut(sheet)?.protection = Some(prot.clone());
Ok(Json::Null)
@@ -147,11 +165,16 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ph = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).and_then(|pr| pr.password_hash);
if let Some(h) = ph
&& str_param(p, "password").map(password_hash) != Some(h)
{
return Err(EngineError::Other("The password you supplied is not correct.".into()));
if let Some(pr) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()) {
let pw = str_param(p, "password").unwrap_or("");
let ok = match (&pr.password, pr.legacy_password_hash) {
(Some(h), _) => h.verify(pw).map_err(EngineError::Other)?,
(None, Some(h)) => password_hash(pw) == h,
(None, None) => true,
};
if !ok {
return Err(EngineError::Other("The password you supplied is not correct.".into()));
}
}
edit(s, |cx| {
cx.sheet_mut(sheet)?.protection = None;
@@ -159,6 +182,15 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// A new Excel-compatible (SHA-512, salted) hash of `pw`. The salt mixes `what` is protected
/// with the document, a counter and the clock, so equal passwords get different hashes.
fn new_password_hash(d: &DocState, what: &str, pw: &str) -> PasswordHash {
static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let seed = format!("{what}\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()))
}
fn range_text(r: &EditRange) -> String {
r.ranges.iter().map(|x| x.a1()).collect::<Vec<_>>().join(",")
}
@@ -166,7 +198,6 @@ fn range_text(r: &EditRange) -> String {
/// 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());
@@ -180,11 +211,7 @@ fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
.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()))
};
let hash = |pw: &str, title: &str| new_password_hash(d, &format!("{sheet_name}\u{0}{title}"), pw);
if matches!(action, "add" | "modify" | "delete") && sh.is_protected() {
return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into()));
}
@@ -270,11 +297,28 @@ fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
))
}
/// Review › Protect Workbook: lock the workbook structure (sheets can't be added, moved,
/// renamed, hidden or deleted), optionally with a password that unprotecting must match.
fn protect_workbook(s: &mut Session, p: &Json) -> Result<Json> {
let cur = s.doc()?.wb.protected_structure;
let d = s.doc()?;
let cur = d.wb.protected_structure;
let on = bool_param(p, "on").unwrap_or(!cur);
if on == cur {
return Ok(json!({"on": on}));
}
let password = if on {
str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| new_password_hash(d, "workbook", pw))
} else {
if let Some(h) = &d.wb.structure_password
&& !h.verify(str_param(p, "password").unwrap_or("")).map_err(EngineError::Other)?
{
return Err(EngineError::Other("The password you supplied is not correct.".into()));
}
None
};
edit(s, |cx| {
cx.wb.protected_structure = on;
cx.wb.structure_password = password.clone();
Ok(json!({"on": on}))
})
}
@@ -355,6 +399,53 @@ mod tests {
assert_eq!(at(&s), (0, "A1".to_string()));
}
#[test]
fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() {
let mut s = book();
s.execute("review.protectSheet", json!({"password": "pa55"})).unwrap();
let prot = s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap();
assert!(matches!(&prot.password, Some(PasswordHash::Iterated { algorithm, .. }) if algorithm == "SHA-512"));
assert!(s.execute("review.unprotectSheet", json!({})).is_err());
assert!(s.execute("review.unprotectSheet", json!({"password": "pass"})).is_err());
// Protecting again can't swap the password out from under it.
assert!(s.execute("review.protectSheet", json!({})).is_err());
assert_eq!(s.doc().unwrap().wb.active().unwrap().protection.as_ref(), Some(&prot));
s.execute("review.unprotectSheet", json!({"password": "pa55"})).unwrap();
assert!(!s.doc().unwrap().wb.active().unwrap().is_protected());
// A GridCraft JSON workbook saved before carried the internal hash under `password_hash`.
let old = format!(
r#"{{"password_hash": {}, "select_locked": true, "select_unlocked": true, "format_cells": false, "format_columns": false, "format_rows": false, "insert_columns": false, "insert_rows": false, "delete_columns": false, "delete_rows": false, "sort": false, "autofilter": false}}"#,
password_hash("old")
);
let prot: SheetProtection = serde_json::from_str(&old).unwrap();
assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true));
let i = s.doc().unwrap().wb.active_sheet;
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot);
assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err());
s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap();
}
#[test]
fn protect_workbook_with_a_password_really_verifies() {
let mut s = book();
s.execute("review.protectWorkbook", json!({"password": "pa55"})).unwrap();
assert!(s.doc().unwrap().wb.protected_structure);
assert!(s.execute("home.insertSheet", json!({"name": "Three"})).is_err(), "the structure is locked");
// A second Protect keeps it protected; unprotecting needs the right password.
assert_eq!(s.execute("review.protectWorkbook", json!({"on": true})).unwrap()["on"], true);
assert!(s.execute("review.protectWorkbook", json!({})).is_err());
assert!(s.execute("review.protectWorkbook", json!({"on": false, "password": "pass"})).is_err());
assert!(s.doc().unwrap().wb.protected_structure);
s.execute("review.protectWorkbook", json!({"on": false, "password": "pa55"})).unwrap();
let wb = &s.doc().unwrap().wb;
assert!(!wb.protected_structure && wb.structure_password.is_none());
// Without a password it toggles freely.
s.execute("review.protectWorkbook", json!({})).unwrap();
s.execute("review.protectWorkbook", json!({})).unwrap();
assert!(!s.doc().unwrap().wb.protected_structure);
}
#[test]
fn convert_to_comments_turns_every_note_into_a_comment_and_undoes() {
let mut s = book();
+102 -4
View File
@@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
p,
Axis::Cols
)),
cmd!("sheet.moveColumns", "Move Columns", [], None, "{cols?: \"B:C\", before: zero-based column index}", has_doc, move_columns),
cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells),
cmd!(
"home.insertCopiedCells",
@@ -334,6 +335,105 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
Ok(out)
}
/// Reorders a contiguous block of whole columns without replacing the destination columns.
/// `before` is a boundary in the original sheet: moving B:C before E produces A,D,B,C,E.
fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
let (from, count) = lines(s, p, Axis::Cols)?;
let before = u32_param(p, "before").ok_or_else(|| bad("sheet.moveColumns", "missing `before`"))?;
let end =
from.checked_add(count).filter(|end| *end <= MAX_COLS).ok_or_else(|| bad("sheet.moveColumns", "source columns are outside the worksheet"))?;
if before >= MAX_COLS || before.checked_add(count).is_none_or(|end| end > MAX_COLS) {
return Err(bad("sheet.moveColumns", "destination columns are outside the worksheet"));
}
if (from..=end).contains(&before) {
return Ok(json!({"cols": RangeRef::cols(from, end.saturating_sub(1)).a1()}));
}
let sheet = target_sheet(s, p)?;
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range())
&& u.end.col >= before
&& u.end.col as u64 + count as u64 >= MAX_COLS as u64
{
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
}
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?;
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?;
let moved_from = if before <= from { from + count } else { from };
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
return Err(bad("sheet.moveColumns", "the move is too close to the worksheet boundary"));
}
let destination = before;
let delete_at = moved_from;
let final_start = if before > from { before - count } else { before };
let active_row = s.doc()?.selection.active.row;
let source = RangeRef::cols(moved_from, moved_from + count - 1);
let out = edit(s, |cx| {
let name = cx.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default();
cx.protection_checked = true;
{
let sh = cx.sheet_mut(sheet)?;
sh.cells.shift_cols(before, count as i64);
shift_sheet_features(sh, Axis::Cols, before, count, true);
}
rewrite(&mut cx.wb, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
let mut touched = Vec::new();
{
let sh = cx.sheet_mut(sheet)?;
let pictures: Vec<_> =
sh.cell_pictures.iter().filter(|(cell, _)| source.contains(**cell)).map(|(cell, picture)| (*cell, picture.clone())).collect();
let cells = sh.take_cells(source);
let moved_lines: Vec<_> = (0..count).filter_map(|offset| sh.cols.remove(&(moved_from + offset)).map(|info| (offset, info))).collect();
let movement = Edit::Move { from: source, to_row: 0, to_col: destination };
shift_cell_features(sh, &movement);
for object in sh
.charts
.iter_mut()
.map(|o| &mut o.anchor)
.chain(sh.images.iter_mut().map(|o| &mut o.anchor))
.chain(sh.shapes.iter_mut().map(|o| &mut o.anchor))
{
if object.mode != AnchorMode::Absolute && (moved_from..moved_from + count).contains(&object.cell.col) {
object.cell.col = destination + (object.cell.col - moved_from);
}
}
for (cell, value) in cells {
let dest = CellRef::new(cell.row, destination + (cell.col - moved_from));
sh.cells.set(dest, value);
touched.extend([cell, dest]);
}
for (cell, picture) in pictures {
let dest = CellRef::new(cell.row, destination + (cell.col - moved_from));
sh.cell_pictures.insert(dest, picture);
}
for (offset, info) in moved_lines {
sh.cols.insert(destination + offset, info);
}
}
for cell in touched {
cx.touch(sheet, cell);
}
rewrite(&mut cx.wb, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
{
let sh = cx.sheet_mut(sheet)?;
sh.cells.shift_cols(delete_at + count, -(count as i64));
shift_sheet_features(sh, Axis::Cols, delete_at, count, false);
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
*cx.sel = crate::Selection {
active: CellRef::new(active_row, final_start),
anchor: CellRef::new(active_row, final_start),
ranges: vec![RangeRef::cols(final_start, final_start + count - 1)],
};
cx.structural = true;
Ok(json!({"cols": RangeRef::cols(final_start, final_start + count - 1).a1()}))
})?;
cancel_copy_mode(s);
Ok(out)
}
fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
let shift = str_param(p, "shift").unwrap_or("down");
let r = target_range(s, p)?;
@@ -400,10 +500,7 @@ 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 at = cell_param_on(s, p, "at", sheet)?.unwrap_or(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)")));
@@ -704,6 +801,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
+230
View File
@@ -0,0 +1,230 @@
//! Slicers: on-sheet widgets that filter one column of a table by clicking value tiles.
//!
//! A slicer is a thin front-end over the existing filter machinery: its selection is written as a
//! [`FilterCriterion::Values`] into the sheet's [`AutoFilter`] (over the table's range) and
//! [`super::data::apply_filter`] hides the rows. Creating a slicer (`insert.slicer` /
//! `table.insertSlicer`) selects everything, i.e. applies no filter.
use gridcraft_core::CellRef;
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"insert.slicer",
"Slicer",
["Insert", "Filters"],
None,
"{table?, column?: \"Region\", columns?: 1, at?: \"H2\", width?, height?}",
has_doc,
insert_slicer
),
cmd!(
"table.insertSlicer",
"Insert Slicer",
["Table", "Tools"],
None,
"{table?, column?: \"Region\", columns?: 1, at?, width?, height?}",
has_doc,
insert_slicer
),
cmd!("slicer.toggle", "Toggle Slicer Value", [], None, "{slicer?: id, value: \"East\", multi?: false}", has_doc, slicer_toggle),
cmd!("slicer.selectAll", "Clear Slicer Filter", [], None, "{slicer?: id}", has_doc, slicer_select_all),
cmd!("slicer.clear", "Clear Slicer Filter", [], None, "{slicer?: id}", has_doc, slicer_select_all),
cmd!("slicer.delete", "Delete Slicer", [], None, "{slicer?: id}", has_doc, slicer_delete),
]
}
/// Distinct, non-blank display values of a table column, in first-seen order. Used to populate a
/// slicer's tiles and to detect "everything selected".
pub fn column_values(wb: &Workbook, sheet: usize, table: &str, column: &str) -> Vec<String> {
let Some(sh) = wb.sheet(sheet) else { return vec![] };
let Some(t) = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(table)) else { return vec![] };
let Some(ci) = t.column_index(column) else { return vec![] };
let Some(dr) = t.data_range() else { return vec![] };
let col = dr.start.col + ci as u32;
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for row in dr.start.row..=dr.end.row {
let text = crate::display::cell_text(wb, sh, CellRef::new(row, col));
if text.is_empty() {
continue;
}
if seen.insert(text.to_lowercase()) {
out.push(text);
}
}
out
}
/// A slicer's tiles as `(value, selected)`, for the UI. `selected` is true for every tile when the
/// slicer has no filter (`selected == None`).
pub fn slicer_items(wb: &Workbook, sheet: usize, sl: &Slicer) -> Vec<(String, bool)> {
column_values(wb, sheet, &sl.table, &sl.column)
.into_iter()
.map(|v| {
let on = match &sl.selected {
None => true,
Some(set) => set.iter().any(|x| x.eq_ignore_ascii_case(&v)),
};
(v, on)
})
.collect()
}
/// Writes the slicer's current selection into the sheet filter and re-applies it.
fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) {
let info = wb.sheet(sheet).and_then(|sh| {
let sl = sh.slicers.iter().find(|s| s.id == id)?;
let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?;
let ci = t.column_index(&sl.column)?;
Some((t.range, t.range.start.col + ci as u32, sl.selected.clone()))
});
let Some((table_range, abs_col, selected)) = info else { return };
{
let Some(sh) = wb.sheet_mut(sheet) else { return };
let af = sh.autofilter.get_or_insert_with(|| AutoFilter { range: table_range, criteria: vec![] });
let off = abs_col.saturating_sub(af.range.start.col);
af.criteria.retain(|(o, _)| *o != off);
if let Some(values) = selected {
af.criteria.push((off, FilterCriterion::Values { values, blanks: false }));
}
}
super::data::apply_filter(wb, sheet);
}
fn insert_slicer(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 t = match str_param(p, "table") {
Some(name) => sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name)),
None => sh.table_at(d.selection.active),
}
.ok_or_else(|| EngineError::Other("Select a cell inside a table first, or pass `table`.".into()))?;
let column = match str_param(p, "column") {
Some(c) => t
.columns
.iter()
.find(|tc| tc.name.eq_ignore_ascii_case(c))
.map(|tc| tc.name.clone())
.ok_or_else(|| bad("insert.slicer", format!("table has no column `{c}`")))?,
None => {
// Default to the active cell's column within the table, else the first column.
let idx = d.selection.active.col.checked_sub(t.range.start.col).map(|o| o as usize).filter(|o| *o < t.columns.len()).unwrap_or(0);
t.columns.get(idx).map(|tc| tc.name.clone()).ok_or_else(|| bad("insert.slicer", "the table has no columns"))?
}
};
let table = t.name.clone();
let at = cell_param(s, p, "at")?
.unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
let columns = u32_param(p, "columns").unwrap_or(1).clamp(1, 10);
let width = f64_param(p, "width").unwrap_or(168.0).clamp(60.0, 10000.0) as f32;
let height = f64_param(p, "height").unwrap_or(200.0).clamp(60.0, 10000.0) as f32;
let id = d.wb.next_object_id();
let slicer = Slicer {
id,
table,
column: column.clone(),
caption: column,
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width, height, mode: AnchorMode::MoveOnly },
selected: None,
columns,
};
edit(s, |cx| {
cx.sheet_mut(sheet)?.slicers.push(slicer);
Ok(json!({"slicer": id}))
})
}
/// The target slicer: by `slicer` id, else the last one on the active sheet.
fn find_slicer(s: &Session, p: &Json) -> Result<(usize, u32)> {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "slicer") {
Some(id) => sh.slicers.iter().position(|x| x.id == id),
None => sh.slicers.len().checked_sub(1),
};
idx.and_then(|i| sh.slicers.get(i).map(|x| (i, x.id))).ok_or_else(|| bad("slicer", "no slicer on this sheet"))
}
fn slicer_toggle(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string();
let multi = bool_param(p, "multi").unwrap_or(false);
let sheet = s.doc()?.wb.active_sheet;
let (table, column, current) = {
let d = s.doc()?;
let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?;
(sl.table.clone(), sl.column.clone(), sl.selected.clone())
};
let all = column_values(&s.doc()?.wb, sheet, &table, &column);
if !all.iter().any(|v| v.eq_ignore_ascii_case(&value)) {
return Err(bad("slicer.toggle", "value is not in the column"));
}
let new_selected: Option<Vec<String>> = if multi {
let mut set: Vec<String> = current.unwrap_or_else(|| all.clone());
match set.iter().position(|x| x.eq_ignore_ascii_case(&value)) {
Some(pos) => {
set.remove(pos);
}
None => set.push(value.clone()),
}
// Everything selected is the same as no filter.
if set.len() == all.len() && all.iter().all(|a| set.iter().any(|x| x.eq_ignore_ascii_case(a))) { None } else { Some(set) }
} else {
// Single click: show only this value; clicking the sole selected value clears the filter.
match &current {
Some(set) if set.len() == 1 && set.iter().any(|x| x.eq_ignore_ascii_case(&value)) => None,
_ => Some(vec![value.clone()]),
}
};
edit(s, |cx| {
if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) {
sl.selected = new_selected;
}
reapply_slicer(&mut cx.wb, sheet, id);
cx.structural = true;
Ok(Json::Null)
})
}
fn slicer_select_all(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let sheet = s.doc()?.wb.active_sheet;
edit(s, |cx| {
if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) {
sl.selected = None;
}
reapply_slicer(&mut cx.wb, sheet, id);
cx.structural = true;
Ok(Json::Null)
})
}
fn slicer_delete(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let sheet = s.doc()?.wb.active_sheet;
// Absolute column of the slicer, so its filter criterion can be removed too.
let abs_col = s.doc().ok().and_then(|d| {
let sh = d.wb.active()?;
let sl = sh.slicers.get(idx)?;
let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?;
let ci = t.column_index(&sl.column)?;
Some(t.range.start.col + ci as u32)
});
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
sh.slicers.retain(|x| x.id != id);
if let (Some(abs), Some(af)) = (abs_col, sh.autofilter.as_mut()) {
let off = abs.saturating_sub(af.range.start.col);
af.criteria.retain(|(o, _)| *o != off);
}
super::data::apply_filter(&mut cx.wb, sheet);
cx.structural = true;
Ok(Json::Null)
})
}
+14 -8
View File
@@ -21,17 +21,23 @@ pub fn specs() -> Vec<CommandSpec> {
fn dictionary() -> &'static HashSet<String> {
static DICT: OnceLock<HashSet<String>> = OnceLock::new();
DICT.get_or_init(|| {
let mut set = HashSet::new();
#[cfg(target_arch = "wasm32")]
{
HashSet::new()
}
#[cfg(not(target_arch = "wasm32"))]
for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] {
if let Ok(text) = std::fs::read_to_string(path) {
for w in text.lines().take(2_000_000) {
set.insert(w.trim().to_lowercase());
{
let mut set = HashSet::new();
for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] {
if let Ok(text) = std::fs::read_to_string(path) {
for w in text.lines().take(2_000_000) {
set.insert(w.trim().to_lowercase());
}
break;
}
break;
}
set
}
set
})
}
@@ -155,7 +161,7 @@ fn change(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "all").unwrap_or(false) {
return s.execute("edit.replace", json!({"what": word, "with": to, "matchCase": true, "all": true}));
}
let c = cell_param(p, "cell").ok_or_else(|| bad("review.changeSpelling", "missing `cell`"))?;
let c = cell_param(s, p, "cell")?.ok_or_else(|| bad("review.changeSpelling", "missing `cell`"))?;
let text = s.doc()?.wb.active().and_then(|sh| sh.cell(c)).map(|x| x.input_text()).unwrap_or_default();
s.execute("cell.set", json!({"cell": c.a1(), "input": text.replacen(&word, &to, 1)}))
}
+36 -7
View File
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Header & Footer",
["Insert", "Text"],
None,
"{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}",
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, firstHeader, firstFooter, evenHeader, evenFooter}",
has_doc,
header_footer
),
@@ -118,7 +118,7 @@ fn freeze_panes(s: &mut Session, p: &Json) -> Result<Json> {
if sh.freeze.is_some() && p.get("cell").is_none() {
return set_freeze(s, None);
}
let at = cell_param(p, "cell").unwrap_or(d.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let top = sh.view_top_left;
let rows = at.row.saturating_sub(top.row);
let cols = at.col.saturating_sub(top.col);
@@ -259,7 +259,7 @@ fn scale_fit(s: &mut Session, p: &Json) -> Result<Json> {
}
fn breaks(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let at = cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active);
let insert = bool_param(p, "insert").unwrap_or(false);
let remove = bool_param(p, "remove").unwrap_or(false);
let reset = bool_param(p, "reset").unwrap_or(false);
@@ -308,7 +308,11 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
"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(""))))
let page = match str_param(p, "page") {
None => gridcraft_model::HfPage::Odd,
Some(id) => gridcraft_model::HfPage::parse(id).ok_or_else(|| bad(CMD, format!("`page` must be odd, even or first, not `{id}`")))?,
};
Some((page, footer, idx, super::print::fields_to_codes(str_param(p, "text").unwrap_or(""))))
}
};
edit(s, |cx| {
@@ -319,15 +323,40 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(f) = str_param(p, "footer") {
pr.footer = cap(f);
}
if let Some((footer, idx, text)) = &section {
let spec = if *footer { &mut pr.footer } else { &mut pr.header };
for (key, slot) in [
("firstHeader", &mut pr.first_header),
("firstFooter", &mut pr.first_footer),
("evenHeader", &mut pr.even_header),
("evenFooter", &mut pr.even_footer),
] {
if let Some(t) = str_param(p, key) {
*slot = cap(t);
}
}
if let Some(on) = bool_param(p, "differentFirst") {
pr.different_first = on;
}
if let Some(on) = bool_param(p, "differentOddEven") {
pr.different_odd_even = on;
}
if let Some((page, footer, idx, text)) = &section {
let spec = pr.hf_mut(*page, *footer);
let mut parts = super::print::split_header_footer(spec);
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}))
Ok(json!({
"header": pr.header,
"footer": pr.footer,
"differentFirst": pr.different_first,
"differentOddEven": pr.different_odd_even,
"firstHeader": pr.first_header,
"firstFooter": pr.first_footer,
"evenHeader": pr.even_header,
"evenFooter": pr.even_footer,
}))
})
}
+31
View File
@@ -31,6 +31,37 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
format_value(v, &parsed(code), wb.date_system)
}
/// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a
/// constant number in a date or time format is shown as a date (as the grid shows it), not as its
/// serial. When the formatted text would not read back as the same date (for example
/// `dddd, mmmm d`), an ISO `yyyy-mm-dd` (with `hh:mm:ss` for a time of day; just `hh:mm:ss`
/// for a time alone) is used instead, and if even that would not read back, the serial.
pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
let raw = sheet.input_text(c);
let Some(cell) = sheet.cell(c) else { return raw };
let Value::Number(n) = &cell.value else { return raw };
let code = wb.styles.get(cell.style).num_fmt.as_str();
if cell.formula.is_some() || !parsed(code).is_date() {
return raw;
}
let reads_back = |s: &str| matches!(gridcraft_core::parse::parse_input(s, wb.date_system).value, Value::Number(m) if (m - n).abs() < 1e-9);
let shown = format(&cell.value, code, wb).text;
if reads_back(&shown) {
return shown;
}
let Some(dt) = gridcraft_core::date::datetime_from_serial(wb.date_system, *n) else { return raw };
let time = format!("{:02}:{:02}:{:02}", dt.hour, dt.minute, dt.second);
let date = format!("{:04}-{:02}-{:02}", dt.year, dt.month, dt.day);
let iso = if *n < 1.0 {
time
} else if n.fract() == 0.0 {
date
} else {
format!("{date} {time}")
};
if reads_back(&iso) { iso } else { raw }
}
/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a
/// protected sheet. A spilled cell follows its anchor.
pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
+36 -5
View File
@@ -1,4 +1,4 @@
//! Opening and saving files: XLSX, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
use std::fmt::Write as _;
@@ -11,6 +11,8 @@ use crate::{EngineError, Result};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileKind {
Xlsx,
/// Excel 97-2003 workbook (import only).
Xls,
Xlsb,
Ods,
Csv,
@@ -24,6 +26,7 @@ impl FileKind {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx,
"xls" | "xlt" => FileKind::Xls,
"xlsb" => FileKind::Xlsb,
"ods" => FileKind::Ods,
"csv" => FileKind::Csv,
@@ -45,9 +48,13 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
"'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again."
)));
}
if sniffed == gridcraft_xlsx::Format::Xls {
let (wb, report) = gridcraft_xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
if sniffed == gridcraft_xlsx::Format::LegacyBinary {
return Err(EngineError::Other(format!(
"'{name}' is an Excel 97-2003 (.xls) workbook or another legacy binary file. GridCraft doesn't open these yet; save it as .xlsx in Excel and try again."
"'{name}' is a legacy binary file but not an Excel workbook (no workbook stream). GridCraft can't open it."
)));
}
// Content wins over the extension: an ODS or XLSB package is imported as what it is,
@@ -106,7 +113,17 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
// A macro-free .xlsx can't hold a VBA project (Excel refuses such a file), so only a .xlsm
// keeps the macros; any other name saves without them, as Excel does.
FileKind::Xlsx if wb.vba_project.is_some() && !path.to_ascii_lowercase().ends_with(".xlsm") => {
let mut plain = wb.clone();
plain.vba_project = None;
gridcraft_xlsx::write_xlsx(&plain).map_err(|e| EngineError::Other(e.to_string()))
}
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Xls => {
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
}
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()),
@@ -356,6 +373,20 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
mod tests {
use super::*;
#[test]
fn macros_are_saved_only_into_xlsm() {
let mut wb = Workbook::new();
wb.vba_project = Some(b"vba".to_vec());
let has_macros = |path: &str| {
let bytes = save_bytes(&wb, path).unwrap();
gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some()
};
assert!(has_macros("book.xlsm"));
assert!(has_macros("BOOK.XLSM"));
assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project");
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
@@ -388,10 +419,10 @@ mod tests {
}
#[test]
fn legacy_xls_is_not_called_encrypted() {
let err = open_bytes("old.xls", &cfb(&["Workbook", "\u{5}SummaryInformation"])).unwrap_err();
fn other_compound_files_are_not_called_encrypted() {
let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("97-2003") && msg.contains(".xlsx"), "got: {msg}");
assert!(msg.contains("not an Excel workbook"), "got: {msg}");
assert!(!msg.contains("password"), "got: {msg}");
}
}
+30
View File
@@ -13,6 +13,7 @@ pub mod cmd;
pub mod display;
pub mod fill;
pub mod io;
pub mod outline;
pub mod paper;
pub mod pivot;
pub mod pivot_calc;
@@ -139,6 +140,35 @@ impl DocState {
pub fn sheet_index(&self) -> usize {
self.wb.active_sheet
}
/// Records the view state that is saved with the file (`sheetView topLeftCell`, the active
/// cell): the first visible cell of the scrolling area per sheet index in `tops`, and the
/// active sheet's active cell. View state never makes the workbook dirty.
pub fn store_view(&mut self, tops: &[(usize, CellRef)]) {
let active = self.selection.active;
let si = self.wb.active_sheet;
let changed = tops.iter().any(|(i, c)| self.wb.sheets.get(*i).is_some_and(|sh| sh.view_top_left != *c))
|| self.wb.sheets.get(si).is_some_and(|sh| sh.view_active != active);
if !changed {
return;
}
let dirty = self.is_dirty();
let wb = Arc::make_mut(&mut self.wb);
for (i, c) in tops {
if let Some(sh) = wb.sheets.get_mut(*i)
&& sh.view_top_left != *c
{
Arc::make_mut(sh).view_top_left = *c;
}
}
if let Some(sh) = wb.sheets.get_mut(si)
&& sh.view_active != active
{
Arc::make_mut(sh).view_active = active;
}
if !dirty {
self.saved = self.wb.clone();
}
}
pub fn display_title(&self) -> String {
self.path
.as_deref()
+133
View File
@@ -0,0 +1,133 @@
//! Outline groups (Data ▸ Group) of a sheet's rows or columns: the runs of lines at or above
//! each outline level, as the grid draws them (brackets, − / + buttons, level buttons) and the
//! Show / Hide Detail commands act on them. Summary rows sit below their detail and summary
//! columns right of it (Excel's defaults; the model has no setting for the other way round).
use std::collections::BTreeMap;
use gridcraft_model::LineInfo;
/// The deepest outline level XLSX allows.
pub const MAX_LEVEL: u8 = 7;
/// One group: lines `start..=end` at outline level `level` or deeper, with its summary line
/// just after `end`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OutlineGroup {
pub level: u8,
pub start: u32,
pub end: u32,
/// Every detail line is hidden: the button shows +.
pub collapsed: bool,
}
impl OutlineGroup {
/// The summary line, where the − / + button sits.
pub fn summary(&self) -> u32 {
self.end.saturating_add(1)
}
pub fn contains(&self, line: u32) -> bool {
line >= self.start && line <= self.end
}
}
/// The deepest outline level used by these lines (0 = no outline).
pub fn max_level(lines: &BTreeMap<u32, LineInfo>) -> u8 {
lines.values().map(|l| l.outline.min(MAX_LEVEL)).max().unwrap_or(0)
}
/// Every group, ordered by where it ends (inner groups before the group around them).
pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
let mut out = Vec::new();
// Open group per level (index level - 1): start line and whether all its lines are hidden.
let mut open: [Option<(u32, bool)>; MAX_LEVEL as usize] = [None; MAX_LEVEL as usize];
let mut prev: Option<u32> = None;
let close_from = |open: &mut [Option<(u32, bool)>; MAX_LEVEL as usize], from: u8, end: u32, out: &mut Vec<OutlineGroup>| {
for level in (from..=MAX_LEVEL).rev() {
if let Some(slot) = open.get_mut(level as usize - 1)
&& let Some((start, hidden)) = slot.take()
{
out.push(OutlineGroup { level, start, end, collapsed: hidden });
}
}
};
for (&i, info) in lines.iter().filter(|(_, l)| l.outline > 0) {
let level = info.outline.min(MAX_LEVEL);
match prev {
// A gap ends every group.
Some(p) if p.checked_add(1) != Some(i) => close_from(&mut open, 1, p, &mut out),
Some(p) if level < MAX_LEVEL => close_from(&mut open, level + 1, p, &mut out),
_ => {}
}
for slot in open.iter_mut().take(level as usize) {
match slot {
Some((_, hidden)) => *hidden &= info.hidden,
None => *slot = Some((i, info.hidden)),
}
}
prev = Some(i);
}
if let Some(p) = prev {
close_from(&mut open, 1, p, &mut out);
}
out
}
/// The group at `level` that holds `line`.
pub fn group_at(lines: &BTreeMap<u32, LineInfo>, line: u32, level: u8) -> Option<OutlineGroup> {
groups(lines).into_iter().find(|g| g.level == level && g.contains(line))
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(spec: &[(u32, u8, bool)]) -> BTreeMap<u32, LineInfo> {
spec.iter().map(|&(i, outline, hidden)| (i, LineInfo { outline, hidden, ..Default::default() })).collect()
}
#[test]
fn no_outline_has_no_groups() {
let l = lines(&[(3, 0, true)]);
assert_eq!(max_level(&l), 0);
assert!(groups(&l).is_empty());
}
#[test]
fn nested_and_separate_groups() {
// Rows 5..8 at level 1 with 6..7 at level 2; rows 12..13 at level 1 after a gap.
let l = lines(&[(4, 1, false), (5, 2, false), (6, 2, false), (7, 1, false), (11, 1, true), (12, 1, true)]);
assert_eq!(max_level(&l), 2);
let g = groups(&l);
assert_eq!(
g,
vec![
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false },
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false },
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true },
]
);
assert_eq!(g[1].summary(), 8);
assert_eq!(group_at(&l, 6, 1).map(|g| g.start), Some(4));
assert_eq!(group_at(&l, 4, 2), None);
}
#[test]
fn adjacent_groups_at_one_level_split_by_a_shallower_line() {
// Two level-2 groups inside one level-1 group, separated by a level-1 line.
let l = lines(&[(0, 2, true), (1, 2, true), (2, 1, false), (3, 2, false)]);
let g = groups(&l);
assert_eq!(g.iter().filter(|g| g.level == 2).count(), 2);
assert_eq!(g.iter().find(|g| g.level == 2 && g.start == 0).map(|g| g.collapsed), Some(true));
assert_eq!(g.iter().find(|g| g.level == 1).map(|g| (g.start, g.end, g.collapsed)), Some((0, 3, false)));
}
#[test]
fn deepest_level_and_last_line_do_not_overflow() {
let l = lines(&[(u32::MAX - 1, 9, false), (u32::MAX, 7, false)]);
let g = groups(&l);
assert_eq!(g.len(), 7);
assert!(g.iter().all(|g| g.start == u32::MAX - 1 && g.end == u32::MAX));
assert_eq!(g[0].summary(), u32::MAX);
}
}
+232 -1
View File
@@ -560,6 +560,58 @@ fn insert_delete_rows_adjust() {
assert_eq!(v(&s, "A1"), Value::Number(10.0));
}
#[test]
fn move_columns_reorders_without_overwriting_and_undoes_once() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4, 5, 6, 7, 8]]})).unwrap();
s.execute("cell.set", json!({"cell": "H2", "input": "=B1+D1"})).unwrap();
s.execute("cell.set", json!({"cell": "H3", "input": "=SUM(B:B)"})).unwrap();
s.execute("home.columnWidth", json!({"cols": "B:B", "width": 101})).unwrap();
s.execute("home.columnWidth", json!({"cols": "C:C", "width": 102})).unwrap();
let before = s.doc().unwrap().wb.clone();
s.doc_mut().unwrap().undo.clear();
let result = s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 4})).unwrap();
assert_eq!(result["cols"], "C:D");
assert_eq!(
(0..8).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::<Vec<_>>(),
vec![
Value::Number(1.0),
Value::Number(4.0),
Value::Number(2.0),
Value::Number(3.0),
Value::Number(5.0),
Value::Number(6.0),
Value::Number(7.0),
Value::Number(8.0),
]
);
assert_eq!(s.execute("cell.get", json!({"cell": "H2"})).unwrap()["formula"], "=C1+B1");
assert_eq!(s.execute("cell.get", json!({"cell": "H3"})).unwrap()["formula"], "=SUM(C:C)");
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.col_width(2), 101.0);
assert_eq!(sh.col_width(3), 102.0);
assert_eq!(s.doc().unwrap().selection.current().a1(), "C:D");
assert_eq!(s.doc().unwrap().undo.len(), 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.doc().unwrap().wb, *before);
}
#[test]
fn move_columns_can_reorder_left_and_ignores_drops_inside_the_selection() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap();
s.execute("sheet.moveColumns", json!({"cols": "D:E", "before": 1})).unwrap();
assert_eq!(
(0..5).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::<Vec<_>>(),
vec!["A".into(), "D".into(), "E".into(), "B".into(), "C".into()]
);
let undo = s.doc().unwrap().undo.len();
s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 2})).unwrap();
assert_eq!(s.doc().unwrap().undo.len(), undo);
}
#[test]
fn insert_copied_cells_inserts_rows_columns_and_blocks() {
// Issue #21: Insert Copied Cells.
@@ -803,7 +855,7 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() {
r.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}: {e}"));
let prot = r.doc().unwrap().wb.active().unwrap().protection.clone();
if id == "review.protectSheet" {
assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password_hash.is_none()), "{prot:?}");
assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password.is_none()), "{prot:?}");
}
}
assert!(r.doc().unwrap().wb.active().unwrap().protection.is_none());
@@ -966,6 +1018,59 @@ fn tables_and_structured_refs() {
assert_eq!(v(&s, "B4"), Value::Number(7.0));
}
#[test]
fn slicer_filters_table() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 2], ["West", 5], ["East", 3], ["North", 1]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
// A new slicer on the Region column filters nothing (all values selected).
let r = s.execute("insert.slicer", json!({})).unwrap();
let id = r["slicer"].as_u64().unwrap() as u32;
let hidden = |s: &Session| {
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
(1..=4).filter(|row| sh.is_row_hidden(*row)).count() // data rows are sheet rows 1..=4 (0-based header at 0)
};
assert_eq!(hidden(&s), 0);
{
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(sh.slicers.len(), 1);
assert_eq!(sh.slicers[0].column, "Region");
}
// Single-click "East": only the two East rows remain (West row 2 and North row 4 hidden).
s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap();
assert_eq!(hidden(&s), 2);
{
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert!(sh.is_row_hidden(2) && sh.is_row_hidden(4));
assert!(!sh.is_row_hidden(1) && !sh.is_row_hidden(3));
}
// Ctrl-click "West" adds it: East + West visible, only North hidden.
s.execute("slicer.toggle", json!({"slicer": id, "value": "West", "multi": true})).unwrap();
assert_eq!(hidden(&s), 1);
// Clear restores everything.
s.execute("slicer.clear", json!({"slicer": id})).unwrap();
assert_eq!(hidden(&s), 0);
assert!(s.doc().unwrap().wb.active().unwrap().slicers[0].selected.is_none());
// Deleting the slicer drops it and clears its filter.
s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap();
assert_eq!(hidden(&s), 2);
s.execute("slicer.delete", json!({"slicer": id})).unwrap();
assert_eq!(hidden(&s), 0);
assert!(s.doc().unwrap().wb.active().unwrap().slicers.is_empty());
// A bad value is rejected, not panicked.
assert!(s.execute("slicer.toggle", json!({"value": "Nowhere"})).is_err());
}
#[test]
fn create_names_from_selection() {
let names = |s: &Session| -> Vec<(String, String)> {
@@ -1191,6 +1296,19 @@ fn ods_import_never_reuses_source_as_save_target() {
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validation_formulas_are_relative_and_see_the_new_value() {
let mut s = s();
s.execute("range.setValues", json!({"range": "B1", "values": [[1], [100]]})).unwrap();
s.execute("data.validation", json!({"range": "A1:A2", "type": "custom", "formula1": "=A1>10"})).unwrap();
s.execute("data.validation", json!({"range": "C1:C2", "type": "whole", "operator": "lessOrEqual", "formula1": "=B1"})).unwrap();
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
assert!(ok(&mut s, "A1", "20"));
assert!(!ok(&mut s, "A2", "5"));
assert!(ok(&mut s, "C2", "50"));
assert!(!ok(&mut s, "C1", "50"));
}
#[test]
fn samples_build() {
for (name, _) in crate::sample::SAMPLES {
@@ -1629,6 +1747,91 @@ fn xlsb_import_never_reuses_source_as_save_target() {
std::fs::remove_dir_all(dir).unwrap();
}
/// A one-sheet Excel 97-2003 workbook ([MS-CFB] container, [MS-XLS] BIFF8 stream) built in code:
/// A1 = 42 and A2 = `A1*2` with its cached result.
fn xls_fixture() -> Vec<u8> {
fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
[&id.to_le_bytes()[..], &(payload.len() as u16).to_le_bytes(), payload].concat()
}
let bof = |dt: u16| rec(0x0809, &[&0x0600u16.to_le_bytes()[..], &dt.to_le_bytes(), &[0; 12]].concat());
let sheet = [
bof(0x0010),
rec(0x0203, &[&[0u8, 0, 0, 0, 0, 0][..], &42f64.to_le_bytes()].concat()),
rec(0x0006, &[&[1u8, 0, 0, 0, 0, 0][..], &84f64.to_le_bytes(), &[0; 6], &[9, 0, 0x44, 0, 0, 0, 0xC0, 0x1E, 2, 0, 0x05]].concat()),
rec(0x000A, &[]),
]
.concat();
let globals = |pos: u32| [bof(0x0005), rec(0x0085, &[&pos.to_le_bytes()[..], &[0, 0, 3, 0], b"Old"].concat()), rec(0x000A, &[])].concat();
let stream = [globals(globals(0).len() as u32), sheet].concat();
// Sectors: 0 FAT, 1 directory, 2 mini FAT, 3 mini stream container.
let mut b = vec![0u8; 512 * 5];
let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v);
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
put(26, &3u16.to_le_bytes());
put(30, &9u16.to_le_bytes());
put(32, &6u16.to_le_bytes());
put(44, &1u32.to_le_bytes());
put(48, &1u32.to_le_bytes());
put(56, &4096u32.to_le_bytes());
put(60, &2u32.to_le_bytes());
put(64, &1u32.to_le_bytes());
put(68, &0xFFFF_FFFEu32.to_le_bytes());
for i in 1..109 {
put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes());
}
for (i, v) in [0xFFFF_FFFDu32, 0xFFFF_FFFE, 0xFFFF_FFFE, 0xFFFF_FFFE].iter().enumerate() {
put(512 + i * 4, &v.to_le_bytes());
}
for i in 4..128 {
put(512 + i * 4, &0xFFFF_FFFFu32.to_le_bytes());
}
let minis = stream.len().div_ceil(64);
for i in 0..128 {
let next = if i + 1 < minis {
i as u32 + 1
} else if i + 1 == minis {
0xFFFF_FFFE
} else {
0xFFFF_FFFF
};
put(512 * 3 + i * 4, &next.to_le_bytes());
}
let entry = |k: usize, name: &str, kind: u8, child: u32, start: u32, size: u32, put: &mut dyn FnMut(usize, &[u8])| {
let at = 512 * 2 + k * 128;
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
put(at, &units);
put(at + 64, &(units.len() as u16 + 2).to_le_bytes());
put(at + 66, &[kind, 1]);
put(at + 68, &[0xFF; 8]);
put(at + 76, &child.to_le_bytes());
put(at + 116, &start.to_le_bytes());
put(at + 120, &size.to_le_bytes());
};
entry(0, "Root Entry", 5, 1, 3, 512, &mut put);
entry(1, "Workbook", 2, 0xFFFF_FFFF, 0, stream.len() as u32, &mut put);
put(512 * 4, &stream);
b
}
#[test]
fn xls_import_keeps_formulas_and_never_saves_back_to_xls() {
let bytes = xls_fixture();
assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xls);
let mut s = s();
let r = s.execute("file.open", json!({"name": "legacy.xls", "base64": crate::io::base64_encode(&bytes)})).unwrap();
assert!(r["warnings"][0].as_str().unwrap().contains("97-2003"));
assert_eq!(r["sheets"], json!(["Old"]));
let d = s.doc().unwrap();
assert!(d.path.is_none());
assert_eq!(d.display_title(), "legacy (imported).xlsx");
assert_eq!(v(&s, "A2"), Value::Number(84.0));
// The formula is live: editing its input recalculates it.
s.execute("cell.set", json!({"cell": "A1", "input": "50"})).unwrap();
assert_eq!(v(&s, "A2"), Value::Number(100.0));
assert!(s.execute("file.saveBytes", json!({"format": "xls"})).is_err());
assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok());
}
#[test]
fn malformed_xlsb_keeps_the_current_workbook() {
let mut s = s();
@@ -2168,3 +2371,31 @@ fn copying_areas_that_do_not_line_up_is_refused_with_a_message() {
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "E1"})).is_err());
assert!(v(&s, "E1").is_empty());
}
#[test]
fn editing_a_date_cell_shows_a_date_not_the_serial() {
let mut s = s();
let at = CellRef::parse("A1").unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "2026-03-04"})).unwrap();
{
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(crate::display::edit_text(&d.wb, sh, at), "2026-03-04");
}
// A format whose text cannot be typed back falls back to ISO.
s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "dddd, mmmm d"}})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(crate::display::edit_text(&d.wb, sh, at), "2026-03-04");
// A time alone falls back to a time, not to day 0 of 1900 ("9 AM" would lose the 36 minutes).
let mut s3 = self::s();
s3.execute("cell.set", json!({"cell": "A1", "input": "0.4"})).unwrap();
s3.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "h AM/PM"}})).unwrap();
let d = s3.doc().unwrap();
assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at), "09:36:00");
// Plain numbers are untouched.
let mut s2 = self::s();
s2.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
let d = s2.doc().unwrap();
assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at), "42");
}