Merge main into #853

# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
This commit is contained in:
Brandon Thomas
2026-10-13 00:08:58 -04:00
153 changed files with 10852 additions and 955 deletions
+1 -1
View File
@@ -184,7 +184,7 @@ impl CfCache {
},
CfRule::TimePeriod { period, style } => match v.as_f64() {
Some(n) => {
let today = gridcraft_calc::now_serial().floor();
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
let d = n.floor();
let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0);
let week_start = today - wd;
+138 -39
View File
@@ -198,27 +198,29 @@ fn sort(s: &mut Session, p: &Json) -> Result<Json> {
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
let by_cols = str_param(p, "orientation") == Some("columns");
let case = bool_param(p, "matchCase").unwrap_or(false);
let keys: Vec<SortKey> = p
.get("keys")
.and_then(Json::as_array)
.map(|a| {
a.iter()
.filter_map(|k| {
let col = if by_cols {
k.get("row").and_then(Json::as_u64).map(|r| r as u32 - 1).or_else(|| col_param(k.get("column"), r.start.row))?
} else {
col_param(k.get("column"), r.start.col)?
};
Some(SortKey {
col,
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
})
})
.collect()
})
.unwrap_or_default();
let mut keys: Vec<SortKey> = Vec::new();
for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() {
let col = if by_cols {
let row = match k.get("row").and_then(Json::as_u64) {
// Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate.
Some(n) => {
let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1));
Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?)
}
None => None,
};
row.or_else(|| col_param(k.get("column"), r.start.row))
} else {
col_param(k.get("column"), r.start.col)
};
let Some(col) = col else { continue };
keys.push(SortKey {
col,
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
});
}
if keys.is_empty() {
s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header})));
return ok();
@@ -454,14 +456,24 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
if has {
return edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
// Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden.
let filtered: Vec<u32> = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect();
sh.autofilter = None;
for info in sh.rows.values_mut() {
info.hidden = false;
for r in filtered {
if let Some(info) = sh.rows.get_mut(&r) {
info.hidden = false;
}
}
cx.structural = true;
Ok(json!({"filter": false}))
});
}
install_filter(s, p)
}
/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection.
fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let mut r = data_range(s, p)?;
// A selected header row filters the data below it (#201).
if p.get("range").is_none() && r.height() == 1 {
@@ -484,7 +496,7 @@ fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
let af = match &sh.autofilter {
Some(a) => a.clone(),
None => {
toggle_filter(s, &json!({}))?;
install_filter(s, &json!({}))?;
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))?
}
};
@@ -570,19 +582,28 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let Some(af) = sh.autofilter.clone() else { return };
let r = af.range;
let mut hide = Vec::new();
// Precompute per-column numbers for top10/average.
let mut col_stats: std::collections::HashMap<u32, (Vec<f64>, f64)> = std::collections::HashMap::new();
for (off, c) in &af.criteria {
// Precompute per-criterion top10 threshold and average once, not per row.
let mut col_stats: std::collections::HashMap<usize, (Option<f64>, f64)> = std::collections::HashMap::new();
for (i, (off, c)) in af.criteria.iter().enumerate() {
if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) {
let col = r.start.col + off;
let nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
let mut nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 };
col_stats.insert(*off, (nums, avg));
let thr = match c {
FilterCriterion::Top10 { bottom, percent, count } => {
nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal));
let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize };
let k = k.clamp(1, nums.len().max(1));
nums.get(k - 1).copied()
}
_ => None,
};
col_stats.insert(i, (thr, avg));
}
}
for row in r.start.row + 1..=r.end.row {
let mut visible = true;
for (off, crit) in &af.criteria {
for (i, (off, crit)) in af.criteria.iter().enumerate() {
let c = CellRef::new(row, r.start.col + off);
let v = sh.value(c);
let text = crate::display::cell_text(wb, sh, c);
@@ -604,18 +625,14 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
None => x,
}
}
FilterCriterion::Top10 { bottom, percent, count } => match (v.as_f64(), col_stats.get(off)) {
(Some(n), Some((nums, _))) => {
let mut sorted = nums.clone();
sorted.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal));
let k = if *percent { ((sorted.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize };
let k = k.clamp(1, sorted.len().max(1));
let thr = sorted.get(k - 1).copied().unwrap_or(n);
FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) {
(Some(n), Some((thr, _))) => {
let thr = thr.unwrap_or(n);
if *bottom { n <= thr } else { n >= thr }
}
_ => false,
},
FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(off)) {
FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) {
(Some(n), Some((_, avg))) => {
if *above {
n > *avg
@@ -896,7 +913,7 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
}
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
pub(super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
for dv in validations.iter_mut() {
for cut in cuts {
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
@@ -1046,6 +1063,18 @@ fn formula_at(f: &str, first: CellRef, at: CellRef) -> String {
}
}
/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to`
/// (pasting a rule); text without relative references (constants, literal lists) stays as typed.
pub(super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String {
if first == to {
return f.to_string();
}
let moved = formula_at(f, first, to);
let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved);
let eq = if f.starts_with('=') { "=" } else { "" };
if has_relative { format!("{eq}{moved}") } else { 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 {
@@ -1129,6 +1158,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
{
e.collapsed = false;
}
cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
@@ -1278,6 +1308,24 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() {
for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap();
s.execute("selection.set", sel.clone()).unwrap();
s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}");
assert!(sh.tables[0].filter_button, "{sel}");
assert!(!sh.is_row_hidden(1), "{sel}");
assert!(sh.is_row_hidden(2), "{sel}");
assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}");
}
}
#[test]
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
let mut s = Session::new();
@@ -1300,6 +1348,45 @@ mod tests {
assert!(!ok(&mut s, "B1", "150"));
}
#[test]
fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
s.execute("data.group", json!({"rows": "2:4"})).unwrap();
s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap();
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
let hidden = |s: &Session| -> Vec<u32> {
let sh = s.doc().unwrap().wb.active().unwrap();
sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect()
};
let before = hidden(&s);
assert_eq!(before, [1, 2, 3, 9]);
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none());
assert_eq!(hidden(&s), before);
assert_eq!(row_state(&s, 4..=4), [(false, true)]);
}
#[test]
fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap();
s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap();
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
s.execute("selection.set", json!({"range": "E1"})).unwrap();
// The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice.
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::<Vec<_>>(), [2, 9]);
}
/// 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();
@@ -1375,6 +1462,18 @@ mod tests {
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
}
#[test]
fn column_sort_rejects_row_key_zero_without_panicking() {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
for row in [0u64, 1 << 32] {
let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]});
let err = s.execute("data.sort", params).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}");
}
}
#[test]
fn split() {
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
+34 -13
View File
@@ -219,11 +219,8 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
Some(c) => c,
None => s.doc()?.selection.active,
};
let input = str_param(p, "input")
.or_else(|| str_param(p, "value"))
.map(str::to_string)
.or_else(|| p.get("value").map(json_to_input))
.ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
// Both spellings take a string, number or boolean, as typed (#697).
let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
let array = bool_param(p, "array").unwrap_or(false);
let d = s.doc()?;
if let Some(sh) = d.wb.sheet(sheet)
@@ -420,14 +417,7 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let sel = &mut d.selection;
// Extension moves the far corner, not the active cell.
let from = if extend {
let r = sel.current();
let far_row = if sel.anchor.row == r.start.row { r.end.row } else { r.start.row };
let far_col = if sel.anchor.col == r.start.col { r.end.col } else { r.start.col };
CellRef::new(far_row, far_col)
} else {
sel.active
};
let from = if extend { sel.moving_end() } else { sel.active };
// Skip hidden rows/columns and step over merged areas.
let mut to = if jmp {
jump(sheet, from, dr.signum(), dc.signum())
@@ -1111,6 +1101,20 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
}
}
// The copied cell's validation rule replaces whatever rule the destination had.
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
let sh = cx.sheet_mut(dest_sheet)?;
super::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
if let Some(mut dv) = rule {
// Relative references move with the rule, as pasted formulas do.
let first = dv.ranges.first().map_or(sc, |r| r.start);
dv.f1 = super::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| super::data::move_rule_formula(&f, first, dest));
dv.ranges = vec![RangeRef::new(dest, dest)];
sh.validations.push(dv);
}
}
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
@@ -1583,6 +1587,23 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn cell_set_takes_numbers_for_input_and_value() {
// #697: a JSON number for `input` used to clear the cell instead of entering it.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap();
s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!(123.5));
assert_eq!(get(&mut s, "A2")["value"], json!(77.0));
assert_eq!(get(&mut s, "A3")["value"], json!(true));
assert_eq!(get(&mut s, "A4")["value"], json!(200.5));
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
}
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
+29 -7
View File
@@ -701,6 +701,12 @@ fn stash_get(wb: &Workbook, key: &str) -> Json {
.unwrap_or(json!({}))
}
/// Like `stash_get`, for the name-keyed lists (views, scripts): a stored value that is not a JSON object is treated as empty.
fn stash_map(wb: &Workbook, key: &str) -> Json {
let v = stash_get(wb, key);
if v.is_object() { v } else { json!({}) }
}
fn stash_set(wb: &mut Workbook, key: &str, v: &Json) {
let text = v.to_string();
let formula = format!("\"{}\"", text.replace('"', "\"\""));
@@ -723,7 +729,7 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>
fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let mut views = stash_get(&d.wb, VIEWS_NAME);
let mut views = stash_map(&d.wb, VIEWS_NAME);
if let Some(name) = str_param(p, "save") {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
@@ -743,6 +749,17 @@ fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(z) = v["zoom"].as_u64() {
s.execute("view.zoom", json!({"percent": z}))?;
}
if let Some(f) = v.get("freeze") {
let freeze = match (f[0].as_u64(), f[1].as_u64()) {
(Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()),
_ => None,
};
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.freeze = freeze;
Ok(())
})?;
}
} else if let Some(name) = str_param(p, "delete") {
if let Some(o) = views.as_object_mut() {
o.remove(name);
@@ -812,7 +829,7 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = cx.sheet_mut(si)?;
let headers: Vec<String> = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect();
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
if r.start.row != t.range.start.row {
if r.start.row != t.range.start.row || !r.intersects(&t.range) {
return Err(EngineError::Other(
"The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(),
));
@@ -938,7 +955,7 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad("automate.newScript", "`commands` must be an array"));
}
edit(s, |cx| {
let mut all = stash_get(&cx.wb, SCRIPTS_NAME);
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
all[name.as_str()] = cmds.clone();
stash_set(&mut cx.wb, SCRIPTS_NAME, &all);
Ok(json!({"name": name}))
@@ -977,7 +994,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
edit(s, |cx| {
let mut all = stash_get(&cx.wb, SCRIPTS_NAME);
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
if let Some(o) = all.as_object_mut() {
o.remove(&name);
}
@@ -1052,8 +1069,13 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Copy of the params that is always a JSON object, so keys can be assigned; any other JSON value counts as no params (like null).
fn object_params(p: &Json) -> Json {
Json::Object(p.as_object().cloned().unwrap_or_default())
}
fn theme_colors(s: &mut Session, p: &Json) -> Result<Json> {
let mut params = p.clone();
let mut params = object_params(p);
if params.get("name").is_none() {
params["name"] = json!(s.doc()?.wb.theme.name.clone());
}
@@ -1074,7 +1096,7 @@ fn background(s: &mut Session, p: &Json) -> Result<Json> {
Ok(Json::Null)
});
}
let mut params = p.clone();
let mut params = object_params(p);
params["alt"] = json!("Sheet background");
params["at"] = json!("A1");
s.execute("insert.picture", params)
@@ -1090,7 +1112,7 @@ fn draw_border(s: &mut Session, p: &Json) -> Result<Json> {
"outline" => "outside",
_ => "all",
};
let mut params = p.clone();
let mut params = object_params(p);
params["preset"] = json!(preset);
s.execute("home.borders", params)
}
+13 -4
View File
@@ -75,7 +75,11 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
{
s.close_document(0);
}
let i = s.add_document(DocState::new(wb, path, title));
let mut doc = DocState::new(wb, path, title);
if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx {
doc.content_kind = Some(crate::io::FileKind::Xlsx);
}
let i = s.add_document(doc);
// The decrypted-workbook notice is for the info bar, not a message box.
let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect();
if !shown.is_empty() {
@@ -105,11 +109,16 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
return super::print::export_pdf(s, &json!({"path": path}));
}
let d = s.doc_mut()?;
let bytes = crate::io::save_bytes(&d.wb, &path)?;
// The document's own file keeps the format its content had when opened; any other path
// goes by its extension.
let kind = crate::io::FileKind::from_path(&path).or(d.content_kind.filter(|_| d.path.as_deref() == Some(path.as_str())));
let bytes = match kind {
Some(kind) => crate::io::save_bytes_as(&d.wb, &path, kind)?,
None => crate::io::save_bytes(&d.wb, &path)?,
};
crate::io::write_file(&path, &bytes)?;
let kind = crate::io::FileKind::from_path(&path).unwrap_or(crate::io::FileKind::Xlsx);
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
if matches!(kind, crate::io::FileKind::Xlsx | crate::io::FileKind::Json) {
if matches!(kind, Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) {
d.saved = d.wb.clone();
d.path = Some(path.clone());
}
+90 -20
View File
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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.",
"{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), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), 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
)]
@@ -223,13 +223,39 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
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 time_param = |key: &str| -> Result<Option<f64>> {
match p.get(key) {
Some(Json::Number(n)) => Ok(n.as_f64()),
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
Value::Number(x) => Ok(Some(x)),
_ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))),
},
None | Some(Json::Null) => Ok(None),
Some(Json::String(_)) => Ok(None),
Some(_) => Err(bad(CMD, format!("`{key}` is not a number or a date"))),
}
};
let end = time_param("end")?;
// Forecast Start: the index (into `rows`) of the first forecast point, or `rows.len()` for the
// usual forecast after the history.
let fit = match time_param("forecastStart")? {
None => rows.len(),
Some(start) => {
let last_t = times.last().copied().unwrap_or(f64::NAN);
let tol = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);
if tol(start, last_t) || timeline.after_last(1).is_some_and(|next| tol(start, next)) {
rows.len()
} else {
let i = times
.iter()
.position(|t| tol(*t, start))
.ok_or_else(|| bad(CMD, "`forecastStart` is not on the timeline (it is a point of the history, before its last point)"))?;
if rows.get(..i).is_none_or(|h| h.iter().filter(|r| matches!(r.1, Value::Number(_))).count() < 2) {
return Err(EngineError::Other("The forecast start needs at least two values before it to fit the forecast.".into()));
}
i
}
}
};
let cap = max_periods(rows.len());
let periods = match end {
@@ -252,8 +278,9 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
// 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 fit_last = fit as u32 + 1; // 1-based row of the last point the model is fit to
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 hist = |col: u32| format!("${0}$2:${0}${fit_last}", col_letters(col));
let mut tail = format!("{},{}", opts.seasonality, opts.fill);
if let Some(a) = opts.aggregate {
tail.push_str(&format!(",{a}"));
@@ -305,22 +332,30 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
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);
// The last point the model is fit to starts the forecast lines, so they join the values.
let join = fit_last - 1; // 0-based row
formula(&mut sheet, join, 2, format!("=B{fit_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);
formula(&mut sheet, join, 3, format!("=C{fit_last}"), vstyle);
formula(&mut sheet, join, 4, format!("=C{fit_last}"), vstyle);
}
let (tl, vl) = (hist(0), hist(1));
let forecast_row = |sheet: &mut Sheet, r: u32| {
formula(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(sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
formula(sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
}
};
// Hindcast: forecasts over the actuals from the start to the last history point.
for r in (fit_last + 1)..=last {
forecast_row(&mut sheet, r);
}
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);
}
forecast_row(&mut sheet, r);
}
if opts.statistics {
put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold);
@@ -407,7 +442,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
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}))
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods, "fitted": fit}))
}
/// Forecast rows needed to reach `end` (rounded up to whole steps).
@@ -545,4 +580,39 @@ mod tests {
s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap();
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err());
}
#[test]
fn forecast_start_hindcasts_over_the_actuals() {
let mut s = session_with_history(); // t = 1..16 in A2:A17
let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": 13, "periods": 2, "confidenceInterval": false})).unwrap();
assert_eq!(r["fitted"], 12);
// Fit to t = 1..12 (rows 2..13); the line joins at row 13 and forecasts rows 14..19.
assert_eq!(formula(&s, 0, "C13"), "B13");
assert_eq!(value(&s, 0, "C13"), value(&s, 0, "B13"));
assert_eq!(formula(&s, 0, "C14"), "FORECAST.ETS(A14,$B$2:$B$13,$A$2:$A$13,1,1)");
assert_eq!(formula(&s, 0, "C19"), "FORECAST.ETS(A19,$B$2:$B$13,$A$2:$A$13,1,1)");
// The exact seasonal series is recovered, and the actual is still shown next to it.
let c14 = value(&s, 0, "C14").as_f64().unwrap();
assert!((c14 - 39.0).abs() < 1e-6, "{c14}"); // t = 13: 10 + 26 + 3
assert!(value(&s, 0, "B14").as_f64().is_some());
// Start at the last point or one step later: as without forecastStart.
for start in [json!(16), json!(17)] {
let r = s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "forecastStart": start, "periods": 2})).unwrap();
assert_eq!(r["fitted"], 16);
}
assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)");
}
#[test]
fn forecast_start_invalid_values_are_errors() {
let mut s = session_with_history();
let sheets = s.doc().unwrap().wb.sheets.len();
for bad in [json!(0), json!(2), json!(3.5), json!(18), json!(1000), json!("abc"), json!([1]), json!(1e308)] {
assert!(s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": bad})).is_err(), "{bad}");
}
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
// Blank or null behaves like no start; empty params unchanged.
let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": ""})).unwrap();
assert_eq!(r["fitted"], 16);
}
}
+26 -3
View File
@@ -574,6 +574,16 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
let m = RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col));
if !m.is_single() {
sh.merges.push(m);
// As below: only the first value of each row survives.
let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
for c in others {
if let Some(mut cell) = sh.cells.get(c).cloned() {
cell.value = gridcraft_core::Value::Empty;
cell.formula = None;
sh.set_cell(c, cell);
}
touched.push((sheet, c));
}
}
}
}
@@ -953,6 +963,7 @@ fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
e.custom = true;
}
}
cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
@@ -992,9 +1003,7 @@ pub fn chars_to_points(c: f64) -> f64 {
}
}
pub fn points_to_chars(px: f64) -> f64 {
if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 }
}
pub use gridcraft_model::points_to_chars;
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
/// widths for the default font; the UI refines with real measurement when it autofits).
@@ -1069,6 +1078,7 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
}
cx.fit_rows.extend((a..=b).map(|r| (si, r)));
}
cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
@@ -1112,6 +1122,19 @@ fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn merge_across_keeps_the_first_value_of_each_row() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("cell.set", json!({"cell": "D1", "input": "=SUM(A1:B2)"})).unwrap();
s.execute("home.mergeAcross", json!({"range": "A1:B2"})).unwrap();
let v = |s: &mut crate::Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "B1"), Json::Null);
assert_eq!(v(&mut s, "B2"), Json::Null);
assert_eq!(v(&mut s, "D1"), json!(4.0));
}
#[test]
fn decimal_steps() {
let v = gridcraft_core::Value::Number(1.5);
+62 -13
View File
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Link",
["Insert", "Links"],
Some("Cmd+K"),
"{cell?, target: \"https://…\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}",
"{cell?, target: \"https://…\" | \"Book.xlsx\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}",
has_doc,
insert_link
),
@@ -240,7 +240,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name")
.map(str::to_string)
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
if name.is_empty() || name.contains(' ') || name.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&name).is_some() {
if !valid_table_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
@@ -349,6 +349,9 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
"totals" => {
let v = on.unwrap_or(!t.totals_row);
if v != t.totals_row {
if v && t.range.end.row.saturating_add(1) >= MAX_ROWS {
return Err(EngineError::Other("There's no room below the table for the total row.".into()));
}
t.totals_row = v;
if v {
t.range.end.row += 1;
@@ -448,14 +451,19 @@ fn table_style(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`,
/// starting with a letter, `_` or `\`. Anything else (a hyphen, say) would read as an operator inside a
/// structured reference, and the writer would rename the table on save.
fn valid_table_name(name: &str) -> bool {
name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
&& CellRef::parse(name).is_none()
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
}
fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string();
if name.is_empty()
|| name.contains(' ')
|| name.chars().next().is_some_and(|c| c.is_ascii_digit())
|| gridcraft_core::CellRef::parse(&name).is_some()
{
if !valid_table_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
if s.doc()?.wb.table(&name).is_some_and(|x| x != (si, ti)) {
@@ -470,6 +478,8 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
for i in 0..cx.wb.sheets.len() {
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
// A bare name that is also a defined name resolves to the defined name, so it is not the table.
let shadowed = cx.wb.name(&old, i).is_some();
let Some(sh) = cx.wb.sheet_mut(i) else { continue };
for (c, f) in keys {
let Some(e) = f.expr() else { continue };
@@ -478,7 +488,7 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
st.table = name.clone();
gridcraft_formula::Expr::Struct(st)
}
gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
gridcraft_formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
o => o,
});
if let Some(cell) = sh.cells.get_mut(c) {
@@ -1156,6 +1166,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
};
let text = str_param(p, "text").map(str::to_string);
let tooltip = str_param(p, "tooltip").map(str::to_string);
let external = links_outside(&s.doc()?.wb, sheet, &target);
edit(s, |cx| {
let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| {
st.font.color = Color::Theme(10, 0);
@@ -1167,12 +1178,30 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
} else {
sh.set_style(at, link_style);
}
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone() });
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external });
cx.touch(sheet, at);
Ok(Json::Null)
})
}
/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This
/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web
/// Page") is outside the workbook and saved as a relationship.
fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool {
let t = target.trim();
if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() {
return false;
}
let (on, body) = split_sheet(t);
// Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference,
// and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name.
let on_sheet = on.is_none_or(|name| {
wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name))
|| (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#']))
});
!(on_sheet && RangeRef::parse(body).is_some())
}
fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
@@ -1420,20 +1449,20 @@ fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
}
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let n = sh.cond_formats.len();
// The count is taken again after each step: a delete shortens the list.
if let Some(i) = u32_param(p, "delete").map(|v| v as usize)
&& i < n
&& i < sh.cond_formats.len()
{
sh.cond_formats.remove(i);
}
if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize)
&& i > 0
&& i < n
&& i < sh.cond_formats.len()
{
sh.cond_formats.swap(i, i - 1);
}
if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize)
&& i + 1 < n
&& i + 1 < sh.cond_formats.len()
{
sh.cond_formats.swap(i, i + 1);
}
@@ -1466,3 +1495,23 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"shape": id}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manage_rules_delete_then_move() {
let mut s = Session::new();
s.new_workbook();
for v in ["1", "2"] {
let rule = json!({"type": "cellIs", "operator": "greaterThan", "value": v, "preset": "lightRedFill"});
s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap();
}
// Deleting first left two rules' worth of indices for the move: it panicked.
s.execute("home.manageRules", json!({"delete": 0, "moveUp": 1})).unwrap();
s.execute("home.manageRules", json!({"delete": 0, "moveDown": 0})).unwrap();
let rules = s.execute("home.manageRules", json!({})).unwrap();
assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0));
}
}
+290 -21
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Field",
[],
None,
"{pivot?, field, from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}",
"{pivot?, field (\"Σ Values\" moves the value fields between rows and columns; in columns it stays after the last field), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}",
has_doc,
move_field
),
@@ -333,6 +333,71 @@ fn in_axes(pt: &PivotTable, field: &str) -> bool {
pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field))
}
/// Name of the Σ Values pseudo-field (where the value fields sit when there are two or more).
const VALUES_FIELD: &str = "Σ Values";
/// Whether `field` names Σ Values: `Σ Values`, or `Values` unless a source field has that name.
fn is_values_field(src: &pv::Source, field: &str) -> bool {
let f = field.trim();
f == VALUES_FIELD || (f.eq_ignore_ascii_case("values") && src.col(f).is_none())
}
/// Keeps Σ Values in place after a field left (`inserted` false) or arrived at index `i` of
/// Rows (`rows`) or Columns.
fn shift_values(pt: &mut PivotTable, rows: bool, i: usize, inserted: bool) {
if pt.values_on_rows != rows {
return;
}
let Some(p) = pt.values_position else { return };
// Σ Values stays just ahead of the field it preceded.
let p = match inserted {
true if i <= p => p + 1,
false if i < p => p - 1,
_ => p,
};
let n = if rows { pt.rows.len() } else { pt.columns.len() };
pt.values_position = (p < n).then_some(p);
}
/// Takes `field` out of Rows and Columns (Σ Values keeps its place); returns its first entry.
fn take_from_axes(pt: &mut PivotTable, field: &str) -> Option<PivotField> {
let mut first = None;
for rows in [true, false] {
loop {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let Some(i) = list.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)) else { break };
let f = list.remove(i);
shift_values(pt, rows, i, false);
first.get_or_insert(f);
}
}
first
}
/// Inserts `f` into Rows (`rows`) or Columns at `position` (default: the end).
fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: Option<usize>) {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let at = position.unwrap_or(list.len()).min(list.len());
list.insert(at, f);
shift_values(pt, rows, at, true);
}
/// Moves Σ Values between Rows and Columns, or within Rows, to `position` (default: the end).
/// In Columns it always comes after the last column field: the column layout doesn't place it
/// anywhere else yet.
fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option<usize>) -> Result<Json> {
let current = if pt.values_on_rows { "rows" } else { "columns" };
if from != current {
return Err(bad(cmd, format!("`{VALUES_FIELD}` isn't in the {from} area")));
}
if !matches!(to, "rows" | "columns") {
return Err(other("The field you are moving cannot be placed in that PivotTable area."));
}
pt.values_on_rows = to == "rows";
pt.values_position = position.filter(|p| pt.values_on_rows && *p < pt.rows.len());
Ok(json!({"field": VALUES_FIELD, "to": to}))
}
/// The canonical source field name for `field`.
fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
@@ -384,21 +449,11 @@ fn add_to_area(
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
return Err(other(format!("A PivotTable can have at most {} row and column fields.", pv::MAX_AXIS_FIELDS)));
}
let existing = pt
.rows
.iter()
.chain(pt.columns.iter())
.find(|f| f.source_col.eq_ignore_ascii_case(&field))
.cloned()
.unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() });
pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
let existing = take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() });
// A report filter that selects nothing in particular goes away; a selection stays
// as the field's hidden items.
pt.filters.retain(|f| !(f.source_col.eq_ignore_ascii_case(&field) && f.selected.is_none()));
let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns };
let at = position.unwrap_or(list.len()).min(list.len());
list.insert(at, existing);
insert_axis_field(pt, area == "rows", existing, position);
}
"values" => {
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
@@ -421,8 +476,7 @@ fn add_to_area(
pt.values.insert(at, v);
}
_ => {
pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
take_from_axes(pt, &field);
let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field));
let entry = match existing {
Some(i) => pt.filters.remove(i),
@@ -439,12 +493,14 @@ fn add_to_area(
fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result<String> {
match area {
"rows" | "columns" => {
let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns };
let rows = area == "rows";
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let i = list
.iter()
.position(|f| f.source_col.eq_ignore_ascii_case(field))
.ok_or_else(|| bad(cmd, format!("`{field}` isn't in the {area} area")))?;
let f = list.remove(i);
shift_values(pt, rows, i, false);
pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&f.source_col));
Ok(f.source_col)
}
@@ -699,6 +755,8 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
"style": pt.style,
"showHeaders": pt.show_headers,
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
"valuesOnRows": pt.values_on_rows,
"valuesPosition": pt.values_position,
})
}
@@ -890,18 +948,22 @@ fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
let to = area_param(C, str_param(p, "to"))?;
let pos = position_param(p);
update(s, p, |pt, src, _| {
if is_values_field(src, &field) {
return move_values_field(C, pt, from, to, pos);
}
if from == to {
// Reorder within the area.
match from {
"rows" | "columns" => {
let list = if from == "rows" { &mut pt.rows } else { &mut pt.columns };
let rows = from == "rows";
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let i = list
.iter()
.position(|f| f.source_col.eq_ignore_ascii_case(&field))
.ok_or_else(|| bad(C, format!("`{field}` isn't in the {from} area")))?;
let f = list.remove(i);
let at = pos.unwrap_or(list.len()).min(list.len());
list.insert(at, f);
shift_values(pt, rows, i, false);
insert_axis_field(pt, rows, f, pos);
}
"values" => {
let i = pt
@@ -1270,8 +1332,20 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
}
ok
};
pt.rows.retain(|f| keep(&f.source_col, &mut dropped));
pt.columns.retain(|f| keep(&f.source_col, &mut dropped));
// Axis fields leave one by one so Σ Values keeps its place among the rest.
for rows in [true, false] {
let mut i = 0;
loop {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let Some(f) = list.get(i) else { break };
if keep(&f.source_col, &mut dropped) {
i += 1;
} else {
list.remove(i);
shift_values(pt, rows, i, false);
}
}
}
pt.values.retain(|f| keep(&f.source_col, &mut dropped));
pt.filters.retain(|f| keep(&f.source_col, &mut dropped));
Ok(json!({"source": source, "dropped": dropped}))
@@ -1722,6 +1796,167 @@ mod tests {
assert_eq!(text(&s, "A5"), "Apples");
}
#[test]
fn values_on_rows_layouts() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap();
assert_eq!(text(&s, "C3"), "Sum of Units");
// Σ Values after Region: each region holds one line per value field.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
let p = pt(&s);
assert!(p.values_on_rows);
assert_eq!(p.values_position, None);
let r = p.last_range.unwrap();
assert_eq!(r.width(), 2);
// header + 3 × (region + 2 values) + 2 grand totals
assert_eq!(r.height(), 1 + 9 + 2);
assert_eq!(text(&s, "A4"), "East");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A6"), "Sum of Units");
assert_eq!(val(&s, "B6"), Value::Number(33.0));
assert_eq!(text(&s, "A13"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(TOTAL));
assert_eq!(text(&s, "A14"), "Total Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(72.0));
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Total");
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
// Σ Values before Region: the value fields are the outer items.
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "East");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A8"), "Sum of Units");
assert_eq!(text(&s, "A9"), "East");
assert_eq!(val(&s, "B9"), Value::Number(33.0));
assert_eq!(text(&s, "A12"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(72.0));
// Adding a row field in front keeps Σ Values ahead of Region.
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(1));
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
// No row field: one line per value field and no grand total.
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
let r = pt(&s).last_range.unwrap();
assert_eq!(r.height(), 1 + 2);
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Number(TOTAL));
assert_eq!(text(&s, "A5"), "Sum of Units");
assert_eq!(val(&s, "B5"), Value::Number(72.0));
// Back to Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
assert!(!pt(&s).values_on_rows);
assert_eq!(text(&s, "A3"), "Sum of Sales");
assert_eq!(text(&s, "B3"), "Sum of Units");
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
}
#[test]
fn collapsed_item_puts_values_in_the_values_column() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap();
for layout in ["tabular", "outline"] {
s.execute("pivot.layout", json!({"layout": layout})).unwrap();
assert_eq!(text(&s, "C3"), "Values", "{layout}");
let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap();
let first = if layout == "tabular" { east } else { east + 1 };
assert_eq!(text(&s, &format!("B{first}")), "", "{layout}");
assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}");
assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}");
assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}");
}
}
#[test]
fn values_stay_last_in_columns() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap();
let p = pt(&s);
assert!(!p.values_on_rows);
assert_eq!(p.values_position, None);
// Σ Values to Rows and back with a position: still last in Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, None);
assert_eq!(text(&s, "B5"), "Sum of Sales");
assert_eq!(text(&s, "C5"), "Sum of Units");
}
#[test]
fn change_source_keeps_values_ahead_of_its_field() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
// The new source has no Region: Σ Values stays ahead of Product.
s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap();
let p = pt(&s);
assert_eq!(p.rows.len(), 1);
assert_eq!(p.values_position, Some(0));
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(text(&s, "A5"), "Apples");
}
#[test]
fn values_on_rows_between_row_fields() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
// Compact: East, then per value field its products, then per-value subtotals of East.
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(text(&s, "A6"), "Apples");
assert_eq!(val(&s, "B6"), Value::Number(160.0));
assert_eq!(text(&s, "A9"), "Sum of Units");
assert_eq!(text(&s, "A10"), "Apples");
assert_eq!(val(&s, "B10"), Value::Number(16.0));
assert_eq!(text(&s, "A13"), "East Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(415.0));
assert_eq!(text(&s, "A14"), "East Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(33.0));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
// Outline: one column per level, "Values" heads the Σ Values column.
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
assert_eq!(pt(&s).last_range.unwrap().width(), 4);
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Product");
assert_eq!(text(&s, "B5"), "Sum of Sales");
assert_eq!(text(&s, "C6"), "Apples");
assert_eq!(val(&s, "D6"), Value::Number(160.0));
// Tabular: labels start on their parent's row; totals per value field below each group.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "B4"), "Sum of Sales");
assert_eq!(text(&s, "C4"), "Apples");
assert_eq!(val(&s, "D4"), Value::Number(160.0));
assert_eq!(text(&s, "B7"), "Sum of Units");
assert_eq!(text(&s, "C7"), "Apples");
assert_eq!(val(&s, "D7"), Value::Number(16.0));
assert_eq!(text(&s, "A10"), "East Sum of Sales");
assert_eq!(val(&s, "D10"), Value::Number(415.0));
assert_eq!(text(&s, "A11"), "East Sum of Units");
let r = pt(&s).last_range.unwrap();
let last = r.end.row + 1;
assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales");
assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units");
assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0));
}
#[test]
fn refresh_after_source_edits() {
let mut s = sales();
@@ -2039,4 +2274,38 @@ mod tests {
assert!(e.contains("field name is not valid"), "{e}");
assert!(s.execute("insert.pivotTable", json!({"source": "NoSuchTable"})).is_err());
}
#[test]
fn too_many_value_columns_are_refused() {
let mut s = Session::new();
s.new_workbook();
let mut records: Vec<Json> = vec![json!(["Cat", "Sub", "V"])];
for r in 0..64 {
for c in 0..64 {
records.push(json!([format!("c{r}"), format!("s{c}"), 1.0]));
}
}
s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap();
let req = |n: usize| {
let values: Vec<Json> = (0..n).map(|_| json!({"field": "V"})).collect();
json!({"source": "A1:C4097", "rows": ["Cat"], "columns": ["Sub"], "values": values})
};
let e = s.execute("insert.pivotTable", req(256)).unwrap_err().to_string();
assert!(e.contains("will not fit"), "{e}");
assert!(s.execute("insert.pivotTable", req(128)).is_ok());
}
#[test]
fn collapsed_column_items_count_as_one_column_group() {
let mut s = Session::new();
s.new_workbook();
let mut records: Vec<Json> = vec![json!(["Grp", "Sub", "V"])];
records.extend((0..64).map(|c| json!(["g", format!("s{c}"), 1.0])));
s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap();
let values: Vec<Json> = (0..256).map(|_| json!({"field": "V"})).collect();
s.execute("insert.pivotTable", json!({"source": "A1:C65", "columns": ["Grp"], "values": values})).unwrap();
s.execute("pivot.collapse", json!({"field": "Grp"})).unwrap();
// 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit.
s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap();
}
}
+26 -1
View File
@@ -391,7 +391,7 @@ pub struct HfContext {
/// Today's date and the current time as headers and footers show them (`&D`, `&T`).
pub fn hf_date_time(wb: &Workbook) -> (String, String) {
let now = gridcraft_calc::now_serial();
let now = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial());
let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text;
let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text;
(date, time)
@@ -1765,6 +1765,14 @@ mod tests {
}
}
#[test]
fn header_footer_date_follows_the_workbook_date_system() {
let mut wb = Workbook::new();
let date_1900 = hf_date_time(&wb).0;
wb.date_system = gridcraft_core::DateSystem::D1904;
assert_eq!(hf_date_time(&wb).0, date_1900);
}
#[test]
fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() {
let mut s = s();
@@ -2046,6 +2054,23 @@ mod tests {
assert!(s.execute("file.exportPdf", json!({})).is_ok());
}
#[test]
fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() {
let mut s = s();
for params in [json!({}), json!({"path": ""}), json!({"path": null})] {
let r = s.execute("file.exportPdf", params).unwrap();
assert!(r.get("path").is_none(), "no file is written without a path");
let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
}
assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog");
// Headless callers (CLI, MCP) get the same bytes, not an error.
assert!(s.execute_headless("file.exportPdf", json!({})).is_ok());
// With no workbook open it is a clear error, not a panic.
let mut none = Session::new();
assert!(none.execute("file.exportPdf", json!({})).is_err());
}
#[test]
fn bands_unit() {
let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100);
+8 -1
View File
@@ -249,10 +249,14 @@ fn cancel_copy_mode(s: &mut Session) {
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?;
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
if at.checked_add(count).is_none_or(|end| end > limit) {
let cmd = if axis == Axis::Rows { "home.insertRows" } else { "home.insertColumns" };
return Err(bad(cmd, "the inserted lines would extend past the end of the worksheet"));
}
check_lines_protection(s, p, axis, at, count, true)?;
let sheet = target_sheet(s, p)?;
// Refuse to push data off the sheet.
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
@@ -290,6 +294,9 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?;
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
// Deleting past the last line only removes up to the end of the worksheet.
let count = count.min(limit.saturating_sub(at)).max(1);
check_lines_protection(s, p, axis, at, count, false)?;
let sheet = target_sheet(s, p)?;
let out = edit(s, |cx| {
File diff suppressed because it is too large Load Diff
+20
View File
@@ -64,7 +64,14 @@ fn known(s: &Session, w: &str) -> bool {
false
}
/// Longest word we generate suggestions for. Candidate text grows with the square of the word length, so longer
/// tokens (no real word is this long) get no suggestions instead of an unbounded allocation.
const MAX_SUGGEST_LEN: usize = 64;
fn edits1(w: &str) -> Vec<String> {
if w.chars().count() > MAX_SUGGEST_LEN {
return Vec::new();
}
let chars: Vec<char> = w.chars().collect();
let mut out = Vec::new();
let letters = "abcdefghijklmnopqrstuvwxyz";
@@ -177,3 +184,16 @@ fn thesaurus(_: &mut Session, p: &Json) -> Result<Json> {
related.truncate(20);
Ok(json!(related))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edits1_is_capped_for_very_long_words() {
assert_eq!(edits1("helo").len(), 54 * 4 + 25);
assert_eq!(edits1(&"a".repeat(MAX_SUGGEST_LEN)).len(), 54 * MAX_SUGGEST_LEN + 25);
assert!(edits1(&"a".repeat(MAX_SUGGEST_LEN + 1)).is_empty());
assert!(edits1(&"a".repeat(1_000_000)).is_empty());
}
}
+87 -14
View File
@@ -6,6 +6,7 @@ use std::sync::Arc;
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula};
use gridcraft_numfmt::{FormatKind, NumberFormat};
use crate::Result;
use crate::cmd::Ctx;
@@ -65,21 +66,25 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
};
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) {
let detected = Pattern::detect(
&src_cells,
&custom,
cx.wb.date_system,
cx.wb.styles.get(src_cells.first().and_then(|c| c.1.as_ref()).map(|c| c.style).unwrap_or_default()).num_fmt.as_str(),
);
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected }
} else {
Pattern::Copy
};
// A source with no common pattern (`Product 1`, `On backorder`): each cell continues its
// own series, one step per repeat of the source.
let per_cell: Vec<Pattern> = if mode == FillMode::Series && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect()
} else {
Vec::new()
};
for (k, t) in targets.iter().enumerate() {
let step = k as i64 + 1;
let idx = k % src_cells.len().max(1);
let (sc, scell) = src_cells.get(if forward { idx } else { src_cells.len() - 1 - idx }).cloned().unwrap_or((src.start, None));
let src_i = if forward { idx } else { src_cells.len() - 1 - idx };
let (sc, scell) = src_cells.get(src_i).cloned().unwrap_or((src.start, None));
let mut cell = scell.clone().unwrap_or_default();
match mode {
FillMode::Formats => {
@@ -96,7 +101,10 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
cell.value = Value::Empty;
}
} else if let Some(v) = pattern.value(step, forward, src_cells.len()) {
} else if let Some(v) = match per_cell.get(src_i) {
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
None => pattern.value(step, forward, src_cells.len()),
} {
cell.value = v;
}
cell.style = style;
@@ -173,8 +181,14 @@ impl Pattern {
let last = nums.last().copied().unwrap_or(0.0);
let is_date = fmt.contains('d') || fmt.contains('y') || (fmt.contains('m') && !fmt.contains('h'));
if nums.len() == 1 {
// A single number copies; a single date increments by a day.
return if is_date { Pattern::Linear { last, first, step: 1.0 } } else { Pattern::Copy };
// A single number copies; a single date increments by a day, a single time by an hour.
return if is_date {
Pattern::Linear { last, first, step: 1.0 }
} else if NumberFormat::parse(fmt).kind() == FormatKind::Time {
Pattern::Linear { last, first, step: 1.0 / 24.0 }
} else {
Pattern::Copy
};
}
// Dates that differ by whole months.
if is_date && nums.len() >= 2 {
@@ -260,8 +274,12 @@ impl Pattern {
Pattern::TextNum { prefix, suffix, last, first, step, width } => {
let base = if forward { *last } else { *first };
let n = base + step * k * sign;
let n = n.abs();
Some(Value::text(format!("{prefix}{n:0width$}{suffix}", width = *width)))
// `Qtr3`, `Quarter 3` wrap after 4 like `Q3`.
let quarter = suffix.is_empty()
&& (1..=4).contains(&base)
&& ["q", "qtr", "quarter"].contains(&prefix.trim_end().to_ascii_lowercase().as_str());
let n = if quarter { (n - 1).rem_euclid(4) + 1 } else { n.abs() };
Some(Value::text(format!("{prefix}{n:0width$}{}", ordinal_suffix(n, suffix), width = *width)))
}
Pattern::List { list, last, first, step, upper, lower } => {
let len = list.len() as i64;
@@ -309,6 +327,23 @@ fn round15(x: f64) -> f64 {
if !p.is_finite() || p == 0.0 { x } else { (x * p).round() / p }
}
/// `1st Period` → `2nd Period`: an English ordinal ending is rewritten to suit the new number.
fn ordinal_suffix(n: i64, suffix: &str) -> String {
let (Some(end), Some(rest)) = (suffix.get(..2), suffix.get(2..)) else { return suffix.to_string() };
if !["st", "nd", "rd", "th"].contains(&end.to_ascii_lowercase().as_str()) || rest.starts_with(char::is_alphanumeric) {
return suffix.to_string();
}
let new = match (n % 10, n % 100) {
(_, 11..=13) => "th",
(1, _) => "st",
(2, _) => "nd",
(3, _) => "rd",
_ => "th",
};
let new = if end.chars().all(|c| c.is_ascii_uppercase()) { new.to_ascii_uppercase() } else { new.to_string() };
format!("{new}{rest}")
}
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
let chars: Vec<char> = t.chars().collect();
@@ -338,6 +373,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
let style = sh.style_id(first_cell);
let mut v = start;
for i in 1..len {
let prev = v;
v = match kind {
"growth" => v * step,
"date" => {
@@ -350,7 +386,10 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
let mut left = step.abs() as i64;
while left > 0 {
n += step.signum();
if datetime_from_serial(sys, n).is_some_and(|d| d.weekday != 0 && d.weekday != 6) {
let Some(day) = datetime_from_serial(sys, n) else {
return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into()));
};
if day.weekday != 0 && day.weekday != 6 {
left -= 1;
}
}
@@ -361,8 +400,9 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
}
_ => v + step,
};
// The stop value bounds the series in the direction it runs (a growth by 0.5 falls).
if let Some(s) = stop
&& ((step >= 0.0 && v > s) || (step < 0.0 && v < s))
&& ((v >= prev && v > s) || (v < prev && v < s))
{
break;
}
@@ -579,4 +619,37 @@ mod tests {
assert_eq!(split_num("Q003x"), Some(("Q".into(), 3, "x".into(), 3)));
assert_eq!(split_num("abc"), None);
}
#[test]
fn times_quarters_ordinals_and_mixed_sources() {
let mut s = crate::Session::new();
s.new_workbook();
let mut fill = |values: serde_json::Value, source: &str, target: &str| {
s.execute("range.setValues", serde_json::json!({"range": source.get(..2).unwrap(), "values": values})).unwrap();
s.execute("edit.autoFill", serde_json::json!({"source": source, "target": target})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let r = RangeRef::parse(target).unwrap();
r.iter().map(|c| crate::display::cell_text(&s.doc().unwrap().wb, sh, c)).collect::<Vec<_>>()
};
assert_eq!(fill(serde_json::json!([["9:00"]]), "A1", "A1:A3"), ["9:00", "10:00", "11:00"]);
assert_eq!(fill(serde_json::json!([["Qtr3"]]), "B1", "B1:B3"), ["Qtr3", "Qtr4", "Qtr1"]);
assert_eq!(fill(serde_json::json!([["Quarter 4"]]), "C1", "C1:C2"), ["Quarter 4", "Quarter 1"]);
assert_eq!(fill(serde_json::json!([["1st Period"]]), "D1", "D1:D4"), ["1st Period", "2nd Period", "3rd Period", "4th Period"]);
assert_eq!(
fill(serde_json::json!([["Product 1"], ["On backorder"]]), "E1:E2", "E1:E6"),
["Product 1", "On backorder", "Product 2", "On backorder", "Product 3", "On backorder"]
);
}
#[test]
fn falling_growth_series_stops_at_its_stop_value() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "5"})).unwrap();
s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "growth", "step": 0.5, "stop": 1})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
let growth: Vec<String> = (1..6).map(|r| crate::display::cell_text(&d.wb, sh, CellRef::new(r, 0))).collect();
assert_eq!(growth, ["2.5", "1.25", "", "", ""]);
}
}
+85 -9
View File
@@ -102,6 +102,8 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let import = match sniffed {
gridcraft_xlsx::Format::Ods => Some(FileKind::Ods),
gridcraft_xlsx::Format::Xlsb => Some(FileKind::Xlsb),
// An XLSX package is never read as ODS/XLSB just because of its name.
gridcraft_xlsx::Format::Xlsx => None,
_ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)),
};
if let Some(import) = import {
@@ -109,7 +111,8 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let (wb, report) = read(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
// Text named `.xlsx` is read as text.
if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) {
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
@@ -153,8 +156,19 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
/// Encodes a workbook in the format chosen by `path`'s extension.
pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
// An unknown or missing extension is refused rather than written as an XLSX under that name:
// a typo like `.cvs` would otherwise produce a file no other app recognises.
let Some(kind) = FileKind::from_path(path) else {
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
};
save_bytes_as(wb, path, kind)
}
/// Encodes a workbook as `kind`, whatever `path`'s extension says.
pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())),
@@ -167,7 +181,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Html => Ok(to_html(wb, sheet).into_bytes()),
FileKind::Html => to_html(wb, sheet).map(String::into_bytes),
}
}
@@ -297,9 +311,12 @@ impl std::fmt::Write for ClipboardHtml {
}
}
/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells.
const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
/// A sheet as a standalone HTML table (Save as Web Page).
pub fn to_html(wb: &Workbook, sheet: usize) -> String {
let Some(sh) = wb.sheet(sheet) else { return String::new() };
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) };
let mut out = String::new();
let _ = write!(
out,
@@ -307,7 +324,13 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String {
esc(&sh.name)
);
if let Some(r) = sh.used_range() {
for row in r.start.row..=r.end.row.min(r.start.row + 100_000) {
if r.height() > HTML_EXPORT_MAX_ROWS {
return Err(EngineError::Other(format!(
"the sheet is too tall for a web page ({} rows, at most {HTML_EXPORT_MAX_ROWS}). Save as .xlsx or CSV instead.",
r.height()
)));
}
for row in r.start.row..=r.end.row {
if sh.is_row_hidden(row) {
continue;
}
@@ -330,7 +353,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String {
}
}
out.push_str("</table></body></html>");
out
Ok(out)
}
#[cfg(not(target_arch = "wasm32"))]
@@ -351,8 +374,12 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
let tmp = format!("{path}.sctmp");
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
// A write or rename that fails (full disk, the workbook locked by another app) leaves
// no temporary file beside the user's workbook.
let _ = std::fs::remove_file(&tmp);
EngineError::Other(format!("{path}: {e}"))
})
}
#[cfg(target_arch = "wasm32")]
@@ -430,6 +457,20 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
mod tests {
use super::*;
#[test]
fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near"));
sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far"));
assert!(save_bytes(&wb, "book.html").is_err());
let sh = wb.sheet_mut(0).unwrap();
sh.remove_cell(CellRef::new(149_999, 0));
sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far"));
let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap();
assert!(html.contains(">near</td>") && html.contains(">far</td>"));
}
#[test]
fn macros_are_saved_only_into_xlsm() {
let mut wb = Workbook::new();
@@ -446,6 +487,26 @@ mod tests {
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
}
/// A save that can't be put in place (here a folder already has the workbook's name, as
/// with a file another app holds locked) fails without leaving `<name>.sctmp` behind.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn failed_save_leaves_no_temporary_file() {
let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let blocked = dir.join("Book1.xlsx");
std::fs::create_dir(&blocked).unwrap();
assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err());
assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed");
// An ordinary save still works and leaves only the workbook.
let ok = dir.join("Book2.xlsx");
write_file(ok.to_str().unwrap(), b"workbook").unwrap();
assert_eq!(std::fs::read(&ok).unwrap(), b"workbook");
assert!(!dir.join("Book2.xlsx.sctmp").exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
@@ -609,4 +670,19 @@ mod tests {
"{types}"
);
}
#[test]
fn content_wins_over_a_mismatched_extension() {
let mut wb = Workbook::new();
wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::Number(7.0));
let package = save_bytes(&wb, "book.xlsx").unwrap();
for name in ["book.xlsx", "book.ods", "book.xlsb", "book.csv"] {
let (opened, _) = open_bytes(name, &package).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 0)), gridcraft_core::Value::Number(7.0), "{name}");
}
for name in ["data.xlsx", "data.csv"] {
let (opened, _) = open_bytes(name, b"a,b\n1,2\n").unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}");
}
}
}
+4
View File
@@ -108,6 +108,9 @@ pub struct DocState {
pub sheet_selections: Vec<Selection>,
/// Allow Edit Ranges unlocked with their password in this session: (sheet name, title).
pub unlocked_ranges: Vec<(String, String)>,
/// The format `path`'s content was recognised as when its extension doesn't name one (an
/// XLSX called `budget.dat`): Save writes it back in that format.
pub content_kind: Option<io::FileKind>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -132,6 +135,7 @@ impl DocState {
title,
sheet_selections: vec![],
unlocked_ranges: vec![],
content_kind: None,
}
}
pub fn is_dirty(&self) -> bool {
+219 -34
View File
@@ -560,6 +560,8 @@ struct Line {
values: bool,
/// (column, value, indent, number format)
labels: Vec<(usize, Value, u8, Option<String>)>,
/// The value field of the line when Σ Values sits in Rows (`None`: one per data column).
vi: Option<usize>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -579,6 +581,10 @@ struct Ctx<'a> {
rows: Vec<&'a PivotField>,
row_items: Vec<FieldItems>,
row_fmts: Vec<String>,
/// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields).
vpos: Option<usize>,
/// Value field captions.
val_names: Vec<String>,
}
impl Ctx<'_> {
@@ -588,26 +594,105 @@ impl Ctx<'_> {
(it.value.clone(), fmt, it.label.clone())
}
fn flatten(&self, nodes: &[Node], depth: usize, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
/// (column, indent) of a row level (Σ Values counts as a level) in Compact/Outline form.
fn level_cell(&self, level: usize) -> (usize, u8) {
if self.pt.layout == PivotLayout::Outline { (level, 0) } else { (0, level.min(250) as u8) }
}
/// Σ Values comes right here, at row field depth `depth` (`vi`: the value field already chosen above).
fn values_here(&self, depth: usize, vi: Option<usize>) -> bool {
vi.is_none() && self.vpos == Some(depth)
}
/// Per-value subtotal lines of an item with Σ Values below it ("East Sum of Sales").
fn value_subtotals(&self, col: usize, indent: u8, label: &str, prefix: &[u32], out: &mut Vec<Line>) {
for (vi, name) in self.val_names.iter().enumerate() {
out.push(Line {
kind: LineKind::Subtotal,
leaf: false,
path: prefix.to_vec(),
values: true,
labels: vec![(col, Value::text(format!("{label} {name}")), indent, None)],
vi: Some(vi),
});
}
}
/// The Σ Values level at row field depth `depth`: one item per value field, each followed by
/// the row fields below it (`nodes`) or, innermost, holding that value.
fn flatten_values(&self, nodes: &[Node], depth: usize, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
let (col, indent) = self.level_cell(depth);
for (vi, name) in self.val_names.iter().enumerate() {
let labels = vec![(col, Value::text(name.as_str()), indent, None)];
let leaf = nodes.is_empty();
out.push(Line { kind: LineKind::Item(!leaf), leaf, path: prefix.clone(), values: leaf, labels, vi: Some(vi) });
if !leaf {
self.flatten(nodes, depth, Some(vi), prefix, out);
}
}
}
fn flatten(&self, nodes: &[Node], depth: usize, vi: Option<usize>, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
if self.values_here(depth, vi) {
self.flatten_values(nodes, depth, prefix, out);
return;
}
let n = self.rows.len();
let outline = self.pt.layout == PivotLayout::Outline;
// Σ Values still to come below this level: totals need one line per value field.
let values_below = vi.is_none() && self.vpos.is_some();
let (col, indent) = self.level_cell(depth + usize::from(vi.is_some() && self.vpos.is_some()));
for node in nodes {
prefix.push(node.item);
let (v, fmt, label) = self.label_cell(depth, node.item);
let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) };
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
if depth + 1 >= n || collapsed || node.children.is_empty() {
let leaf = depth + 1 >= n || collapsed || node.children.is_empty();
if leaf && !values_below {
out.push(Line {
kind: LineKind::Item(depth + 1 < n),
leaf: true,
path: prefix.clone(),
values: true,
labels: vec![(col, v, indent, fmt)],
vi,
});
} else if leaf {
out.push(Line {
kind: LineKind::Item(true),
leaf: false,
path: prefix.clone(),
values: false,
labels: vec![(col, v, indent, fmt)],
vi,
});
// Outline gives each level its own column, so the captions go in the Values
// column even when the levels between are collapsed away.
let level = if self.pt.layout == PivotLayout::Outline { self.vpos.unwrap_or(depth + 1) } else { depth + 1 };
self.flatten_values(&[], level, prefix, out);
} else if values_below {
// Subtotals per value field, below the group (one line can't hold them all).
out.push(Line {
kind: LineKind::Item(true),
leaf: false,
path: prefix.clone(),
values: false,
labels: vec![(col, v, indent, fmt)],
vi,
});
self.flatten(&node.children, depth + 1, None, prefix, out);
if self.pt.subtotals {
self.value_subtotals(col, indent, &label, prefix, out);
}
} else {
let top = self.pt.subtotals && self.pt.subtotals_top;
out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
self.flatten(&node.children, depth + 1, prefix, out);
out.push(Line {
kind: LineKind::Item(true),
leaf: false,
path: prefix.clone(),
values: top,
labels: vec![(col, v, indent, fmt)],
vi,
});
self.flatten(&node.children, depth + 1, vi, prefix, out);
if self.pt.subtotals && !self.pt.subtotals_top {
out.push(Line {
kind: LineKind::Subtotal,
@@ -615,6 +700,7 @@ impl Ctx<'_> {
path: prefix.clone(),
values: true,
labels: vec![(col, Value::text(format!("{label} Total")), indent, None)],
vi,
});
}
}
@@ -622,7 +708,8 @@ impl Ctx<'_> {
}
}
fn flatten_tabular(
/// Tabular form of [`Self::flatten_values`]: the first line also carries the parents' labels.
fn flatten_tabular_values(
&self,
nodes: &[Node],
depth: usize,
@@ -630,24 +717,57 @@ impl Ctx<'_> {
pending: &[(usize, Value, u8, Option<String>)],
out: &mut Vec<Line>,
) {
for (vi, name) in self.val_names.iter().enumerate() {
let mut labels: Vec<(usize, Value, u8, Option<String>)> = if vi == 0 { pending.to_vec() } else { vec![] };
labels.push((depth, Value::text(name.as_str()), 0, None));
if nodes.is_empty() {
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi: Some(vi) });
} else {
self.flatten_tabular(nodes, depth, Some(vi), prefix, &labels, out);
}
}
}
fn flatten_tabular(
&self,
nodes: &[Node],
depth: usize,
vi: Option<usize>,
prefix: &mut Vec<u32>,
pending: &[(usize, Value, u8, Option<String>)],
out: &mut Vec<Line>,
) {
if self.values_here(depth, vi) {
self.flatten_tabular_values(nodes, depth, prefix, pending, out);
return;
}
let n = self.rows.len();
let values_below = vi.is_none() && self.vpos.is_some();
let col = depth + usize::from(vi.is_some() && self.vpos.is_some());
for (i, node) in nodes.iter().enumerate() {
prefix.push(node.item);
let (v, fmt, label) = self.label_cell(depth, node.item);
let mut labels: Vec<(usize, Value, u8, Option<String>)> = if i == 0 { pending.to_vec() } else { vec![] };
labels.push((depth, v, 0, fmt));
labels.push((col, v, 0, fmt));
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
if depth + 1 >= n || collapsed || node.children.is_empty() {
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels });
if values_below {
self.flatten_tabular_values(&[], self.vpos.unwrap_or(depth + 1), prefix, &labels, out);
} else {
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi });
}
} else {
self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out);
if self.pt.subtotals {
self.flatten_tabular(&node.children, depth + 1, vi, prefix, &labels, out);
if self.pt.subtotals && values_below {
self.value_subtotals(col, 0, &label, prefix, out);
} else if self.pt.subtotals {
out.push(Line {
kind: LineKind::Subtotal,
leaf: false,
path: prefix.clone(),
values: true,
labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)],
labels: vec![(col, Value::text(format!("{label} Total")), 0, None)],
vi,
});
}
}
@@ -693,6 +813,12 @@ enum ValSrc {
Calc(usize),
}
/// True when `col_groups` distinct column leaves (plus the grand-total column) times `values` value fields
/// (at least one data column per group) already exceed the sheet width. This is a lower bound on the final column count.
fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -> bool {
(col_groups + usize::from(grand_total)).saturating_mul(values.max(1)) > MAX_COLS as usize
}
/// Computes a PivotTable report from the workbook.
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
@@ -745,6 +871,14 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let k = vals.len();
let empty = rows.is_empty() && cols.is_empty() && vals.is_empty();
// Σ Values in Rows (it exists with two or more value fields): its depth among the row fields
// found in the source.
let vpos: Option<usize> = (pt.values_on_rows && k > 1).then(|| {
let p = pt.values_position.unwrap_or(pt.rows.len());
pt.rows.iter().take(p).filter(|f| src.col(&f.source_col).is_some()).count()
});
let on_rows = vpos.is_some();
let row_levels = rows.len() + usize::from(on_rows);
// ---- aggregate
let mut row_items: Vec<FieldItems> = rows.iter().map(|_| FieldItems::default()).collect();
@@ -752,6 +886,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let mut aggs: AggMap = HashMap::new();
let mut row_set: HashSet<Vec<u32>> = HashSet::new();
let mut col_set: HashSet<Vec<u32>> = HashSet::new();
let mut col_leaves: HashSet<Vec<u32>> = HashSet::new();
let filter_fmts: Vec<String> = filters.iter().map(|f| fmt_of(f.0)).collect();
let row_fmts: Vec<String> = rows.iter().map(|(c, _)| fmt_of(*c)).collect();
let col_fmts: Vec<String> = cols.iter().map(|(c, _)| fmt_of(*c)).collect();
@@ -788,8 +923,22 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
}
}
// The column leaf this record lands in: its path cut after the first collapsed item (as in `flatten_cols`).
let shown = cols
.iter()
.zip(col_items.iter())
.zip(cp.iter())
.position(|(((_, f), fi), &it)| !f.collapsed_items.is_empty() && is_collapsed(f, &fi.label(it)))
.map_or(cp.len(), |d| d + 1);
if !col_leaves.contains(&cp[..shown]) {
col_leaves.insert(cp[..shown].to_vec());
}
row_set.insert(rp);
col_set.insert(cp);
// Refuse a report that cannot fit before the accumulators for more column groups are allocated.
if !cols.is_empty() && data_cols_exceed_sheet(col_leaves.len(), pt.grand_totals_rows, k) {
return Err("The PivotTable report will not fit on the sheet.".into());
}
if aggs.len() > 5_000_000 {
return Err("The PivotTable report is too large.".into());
}
@@ -806,24 +955,32 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
collect_siblings(&row_tree, &mut Vec::new(), &mut siblings);
let level0: Vec<u32> = row_tree.iter().map(|n| n.item).collect();
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() };
let val_names: Vec<String> = vals.iter().map(|(_, v)| v.name.clone()).collect();
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone(), vpos, val_names: val_names.clone() };
let mut lines: Vec<Line> = Vec::new();
if rows.is_empty() {
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] });
if row_levels == 0 {
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![], vi: None });
} else {
if pt.layout == PivotLayout::Tabular {
ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines);
ctx.flatten_tabular(&row_tree, 0, None, &mut Vec::new(), &[], &mut lines);
} else {
ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines);
ctx.flatten(&row_tree, 0, None, &mut Vec::new(), &mut lines);
}
if pt.grand_totals_cols {
lines.push(Line {
// With only Σ Values in Rows its items are the totals already.
if pt.grand_totals_cols && !rows.is_empty() {
let grand = |text: String, vi: Option<usize>| Line {
kind: LineKind::Grand,
leaf: false,
path: vec![],
values: true,
labels: vec![(0, Value::text("Grand Total"), 0, None)],
});
labels: vec![(0, Value::text(text), 0, None)],
vi,
};
if on_rows {
lines.extend(val_names.iter().enumerate().map(|(vi, name)| grand(format!("Total {name}"), Some(vi))));
} else {
lines.push(grand("Grand Total".into(), None));
}
}
}
let mut specs: Vec<ColSpec> = Vec::new();
@@ -837,7 +994,10 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
let mut data_cols: Vec<(usize, Option<usize>)> = Vec::new();
for (si, _) in specs.iter().enumerate() {
if k == 0 {
if on_rows {
// Each line names its value field; one data column per column item.
data_cols.push((si, None));
} else if k == 0 {
if !cols.is_empty() {
data_cols.push((si, None));
}
@@ -852,12 +1012,12 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
let compact = pt.layout == PivotLayout::Compact;
let l = if rows.is_empty() && cols.is_empty() {
let l = if row_levels == 0 && cols.is_empty() {
0
} else if compact {
1
} else {
rows.len().max(1)
row_levels.max(1)
};
let mut width = l + data_cols.len();
if !shown_filters.is_empty() {
@@ -903,14 +1063,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
let headers_on = pt.show_headers;
let row_header = |g: &mut Grid, r: usize| {
if rows.is_empty() || !headers_on {
if row_levels == 0 || !headers_on {
return;
}
if compact {
g.set(r, 0, Value::text("Row Labels"), CellKind::Header, 0, None);
} else {
for (i, f) in row_fields.iter().enumerate() {
g.set(r, i, Value::text(f.source_col.as_str()), CellKind::Header, 0, None);
// One column per row field, Σ Values ("Values") at its place.
let mut names: Vec<&str> = row_fields.iter().map(|f| f.source_col.as_str()).collect();
if let Some(p) = vpos {
names.insert(p.min(names.len()), "Values");
}
for (i, name) in names.into_iter().enumerate() {
g.set(r, i, Value::text(name), CellKind::Header, 0, None);
}
}
};
@@ -920,8 +1085,11 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let r = g.push_row(RowKind::Header)?;
row_header(&mut g, r);
for (j, (_, vi)) in data_cols.iter().enumerate() {
if let Some(vi) = vi {
g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None);
match vi {
Some(vi) => g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None),
// Values on rows, no column field: the one data column is the grand total.
None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None),
None => {}
}
}
} else {
@@ -933,7 +1101,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let caption = if compact { "Column Labels".to_string() } else { col_fields.first().map(|f| f.source_col.clone()).unwrap_or_default() };
g.set(cap, l, Value::text(caption), CellKind::Header, 0, None);
}
let levels = cols.len() + usize::from(k > 1);
let levels = cols.len() + usize::from(k > 1 && !on_rows);
let first_level = g.rows.len();
for _ in 0..levels {
g.push_row(RowKind::Header)?;
@@ -1048,17 +1216,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
&& (k > 1 || parts.is_empty())
{
parts.push(val_name(*vi));
} else if parts.is_empty() {
parts.push("Total".into());
}
Some(((l + j) as u32, parts.join(" - ")))
})
.filter(|_| k > 0)
.collect();
let label_col = if rows.is_empty() {
let label_col = if row_levels == 0 {
None
} else if compact {
Some(0)
} else {
Some(rows.len().saturating_sub(1) as u32)
Some(row_levels.saturating_sub(1) as u32)
};
for line in &lines {
let rk = match line.kind {
@@ -1082,8 +1252,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
continue;
}
let rp = line.path.as_slice();
for (j, (si, vi)) in data_cols.iter().enumerate() {
let (Some(spec), Some(vi)) = (specs.get(*si), vi) else { continue };
for (j, (si, col_vi)) in data_cols.iter().enumerate() {
let line_vi = col_vi.or(line.vi);
let (Some(spec), Some(vi)) = (specs.get(*si), &line_vi) else { continue };
let Some((_, vdef)) = vals.get(*vi) else { continue };
let cp = spec.path.as_slice();
let Some(raw) = get(rp, cp, *vi) else { continue };
@@ -1428,3 +1599,17 @@ pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
p.last_range = None;
}
}
#[cfg(test)]
mod fit_tests {
use super::data_cols_exceed_sheet;
#[test]
fn column_budget_counts_value_fields_and_grand_total() {
assert!(!data_cols_exceed_sheet(64, true, 128));
assert!(data_cols_exceed_sheet(64, true, 256));
assert!(!data_cols_exceed_sheet(64, false, 256));
assert!(!data_cols_exceed_sheet(16_384, false, 0));
assert!(data_cols_exceed_sheet(16_385, false, 0));
}
}
+7
View File
@@ -43,6 +43,13 @@ impl Selection {
pub fn is_single_cell(&self) -> bool {
self.ranges.len() == 1 && self.current().is_single()
}
/// The corner of the current area opposite the anchor: the end that Shift+arrows move.
pub fn moving_end(&self) -> CellRef {
let r = self.current();
let row = if self.anchor.row == r.start.row { r.end.row } else { r.start.row };
let col = if self.anchor.col == r.start.col { r.end.col } else { r.start.col };
CellRef::new(row, col)
}
/// Extends the current area from the anchor to `to` (Shift+click / Shift+arrows).
pub fn extend_to(&mut self, to: CellRef) {
let r = RangeRef::new(self.anchor, to);
+286
View File
@@ -805,6 +805,46 @@ fn sort_and_filter() {
assert_eq!(r["hiddenRows"], 1);
}
/// Top 10 keeps the top or bottom N items or percent, ties at the threshold included.
#[test]
fn filter_top10_items_and_percent() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Score"], [5], [1], [9], [3], [9], [7], ["x"]]})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
// Top 2 items: 9, 9 (the "x" text row is hidden too).
let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2}})).unwrap();
assert_eq!(r["hiddenRows"], 5);
// Bottom 2 items: 1, 3.
let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2, "bottom": true}})).unwrap();
assert_eq!(r["hiddenRows"], 5);
// Top 50 percent of 6 numbers = 3 items; the threshold 7 keeps 9, 9, 7.
let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 50, "percent": true}})).unwrap();
assert_eq!(r["hiddenRows"], 4);
}
/// Two Top 10 criteria on one column (possible in a native JSON file) each keep their own
/// cutoff: Top 3 and Bottom 4 of 1..5 keep 3 and 4.
#[test]
fn filter_top10_two_criteria_on_one_column() {
use gridcraft_model::FilterCriterion;
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["N"], [1], [2], [3], [4], [5]]})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
if let Some(af) = sh.autofilter.as_mut() {
af.criteria = vec![
(0, FilterCriterion::Top10 { bottom: false, percent: false, count: 3 }),
(0, FilterCriterion::Top10 { bottom: true, percent: false, count: 4 }),
];
}
s.execute("data.reapply", json!({})).unwrap();
let hidden: Vec<u32> = s.active().unwrap().wb.sheet(0).unwrap().rows.iter().filter(|(_, r)| r.hidden).map(|(k, _)| *k).collect();
// Rows are 0-based: values 1, 2 and 5 sit on rows 1, 2 and 5.
assert_eq!(hidden, vec![1, 2, 5]);
}
/// Turning the filter on with only the header row selected filters the data below it (#201).
#[test]
fn filter_on_a_selected_header_row_covers_the_data_below() {
@@ -1018,6 +1058,48 @@ fn tables_and_structured_refs() {
assert_eq!(v(&s, "B4"), Value::Number(7.0));
}
#[test]
fn structured_refs_to_columns_with_edge_spaces() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty "], ["a", 2], ["b", 3]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B3", "hasHeaders": true})).unwrap();
s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap();
assert_eq!(v(&s, "B4"), Value::Number(5.0));
for (cell, input) in [
("F1", "=SUM(Table1[Qty ])"),
("F2", "=Table1[[#Totals],[Qty ]]"),
("F3", "=SUBTOTAL(109,Table1[Qty ])"),
("F4", "=SUM(Table1[[Qty ]])"),
("F5", "=SUM(Table1[Qty])"),
("C2", "=Table1[@[Qty ]]*10"),
] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let n = Value::Number;
assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3"), v(&s, "F4")], [n(5.0), n(5.0), n(5.0), n(5.0)]);
assert_eq!(v(&s, "F5"), Value::Error(gridcraft_core::CellError::Ref));
assert_eq!(v(&s, "C2"), n(20.0));
assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])");
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])");
assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3")], [n(5.0), n(5.0), n(5.0)]);
fn table_rename_keeps_a_defined_name_with_the_same_name() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap();
s.execute("cell.set", json!({"cell": "D1", "input": "100"})).unwrap();
s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Data"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Data", "refersTo": "=Sheet1!$D$1"})).unwrap();
s.execute("cell.set", json!({"cell": "E1", "input": "=Data"})).unwrap();
s.execute("cell.set", json!({"cell": "F1", "input": "=SUM(Data[Qty])"})).unwrap();
s.execute("table.rename", json!({"table": "Data", "name": "Later"})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "E1"})).unwrap()["formula"], "=Data");
assert_eq!(s.execute("cell.get", json!({"cell": "F1"})).unwrap()["formula"], "=SUM(Later[Qty])");
assert_eq!((v(&s, "E1"), v(&s, "F1")), (Value::Number(100.0), Value::Number(7.0)));
}
#[test]
fn slicer_filters_table() {
let mut s = s();
@@ -1170,6 +1252,22 @@ fn insert_and_delete_cells_never_shift_part_of_a_table() {
assert!(e.to_string().contains("shift cells in a table"), "{e}");
}
#[test]
fn table_names_with_a_hyphen_are_rejected() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap();
let e = s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "A-B"})).unwrap_err();
assert!(e.to_string().contains("not valid"), "{e}");
assert!(s.doc().unwrap().wb.table("A-B").is_none());
s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Sales"})).unwrap();
let e = s.execute("table.rename", json!({"table": "Sales", "name": "A-B"})).unwrap_err();
assert!(e.to_string().contains("not valid"), "{e}");
assert!(s.doc().unwrap().wb.table("Sales").is_some());
assert!(s.execute("table.rename", json!({"table": "Sales", "name": ".AB"})).is_err());
s.execute("table.rename", json!({"table": "Sales", "name": "A_B"})).unwrap();
assert!(s.doc().unwrap().wb.table("A_B").is_some());
}
#[test]
fn undo_and_redo_keep_the_watch_window_list() {
// Found by the command-sequence fuzzer: the list lives in the workbook but isn't an edit, so
@@ -1519,6 +1617,65 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() {
std::fs::remove_dir_all(dir).unwrap();
}
/// A typo like `report.cvs` must not quietly write an XLSX under that name and adopt it as the
/// document's file.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn saving_to_an_unknown_extension_is_refused() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-unknown-ext-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
for name in ["report.cvs", "report"] {
let path = dir.join(name);
let e = s.execute("file.save", json!({"path": path})).unwrap_err().to_string();
assert!(e.contains(".xlsx"), "{name}: {e}");
assert!(!path.exists(), "{name} was written");
}
let d = s.doc().unwrap();
assert!(d.path.is_none());
assert!(d.is_dirty());
assert!(s.execute("file.saveBytes", json!({"format": "foo"})).is_err());
s.execute("file.save", json!({"path": dir.join("report.csv")})).unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
/// An XLSX opened by its content under an unknown extension (`budget.dat`) saves back to its own
/// file as XLSX; a Save As to another unknown extension is still refused.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn an_xlsx_opened_under_an_unknown_extension_saves_back_as_xlsx() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-dat-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let path = dir.join("budget.dat");
let mut src = s();
src.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
std::fs::write(&path, crate::io::save_bytes(&src.doc().unwrap().wb, "book.xlsx").unwrap()).unwrap();
let mut s = s();
s.execute("file.open", json!({"path": path})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
s.execute("file.save", json!({})).unwrap();
assert!(!s.doc().unwrap().is_dirty());
let bytes = std::fs::read(&path).unwrap();
assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xlsx);
let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
assert_eq!(wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::Number(2.0));
assert!(s.execute("file.saveAs", json!({"path": dir.join("copy.foo")})).is_err());
assert!(!dir.join("copy.foo").exists());
// A text file under an unknown extension opens as CSV: Save doesn't turn it into an XLSX.
let notes = dir.join("notes.dat");
std::fs::write(&notes, "a,b\r\n").unwrap();
s.execute("file.open", json!({"path": notes})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "c"})).unwrap();
assert!(s.execute("file.save", json!({})).is_err());
assert_eq!(std::fs::read_to_string(&notes).unwrap(), "a,b\r\n");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validation_formulas_are_relative_and_see_the_new_value() {
let mut s = s();
@@ -1617,6 +1774,17 @@ fn replacing_a_spilling_formula_clears_its_spill() {
assert_eq!((v(&s, "A2"), v(&s, "B1")), (Value::Empty, Value::Number(0.0)));
}
#[test]
fn clearing_a_spill_past_65536_cells_updates_a_late_dependent() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(65537)"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=A65536"})).unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "=A65537"})).unwrap();
assert_eq!(v(&s, "C2"), Value::Number(65537.0));
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(0.0), Value::Number(0.0)));
}
#[test]
fn range_ending_in_index_recalculates() {
let mut s = s();
@@ -1696,6 +1864,23 @@ fn every_command_survives_empty_params() {
}
}
#[test]
fn non_object_params_do_not_panic_in_wrapper_commands() {
// Issue #516.
for cmd in ["pageLayout.themeColors", "pageLayout.background", "home.drawBorder"] {
for params in [json!([]), json!(5), json!("x"), json!(true)] {
let mut s = s();
let r = s.execute(cmd, params.clone());
assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "{cmd} {params}: {r:?}");
}
}
// A saved script step forwards its params unchanged into the same call.
let mut s = s();
s.execute("automate.newScript", json!({"name": "bad", "commands": [{"command": "home.drawBorder", "params": []}]})).unwrap();
let r = s.execute("automate.runScript", json!({"name": "bad"}));
assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "script: {r:?}");
}
fn fmt_of(s: &Session, a: &str) -> String {
let wb = &s.doc().unwrap().wb;
let sh = wb.active().unwrap();
@@ -2505,6 +2690,15 @@ fn fill_series_month_clamps_to_month_end() {
assert_eq!(v(&s, "A4"), ymd(2026, 4, 15));
}
#[test]
fn fill_series_weekday_out_of_range_step_errors() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "45000"})).unwrap();
let r = s.execute("edit.fillSeries", json!({"range": "A1:A2", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": 1e12}));
assert!(r.is_err());
assert_eq!(v(&s, "A2"), Value::Empty);
}
#[test]
fn fill_series_month_leap_year() {
let s = series_date("2028-01-31", "A1:A3", "month");
@@ -2549,6 +2743,56 @@ fn notes_comments_and_links_survive_a_json_save() {
assert_eq!(after.cells, before.cells);
}
/// Insert › Link sorts a target as Excel's dialog does: a sheet reference, a defined name or `#…` is
/// a place in this document, anything else an existing file or web page. A file saved as a
/// `location` opens nothing in Excel (#800).
#[test]
fn links_to_files_are_saved_as_external_relationships() {
use std::io::Read;
let mut s = s();
s.execute("formulas.defineName", json!({"name": "Price", "refersTo": "=Sheet1!$A$1"})).unwrap();
s.execute("insert.table", json!({"range": "F1:G3", "name": "Sales"})).unwrap();
s.execute("home.insertSheet", json!({"name": "Q#1"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
let links = [
("A1", "Source.xlsx", true),
("A2", r"C:\Temp\Book.xlsx", true),
("A3", r"..\Reports\Q1.xlsx", true),
("A4", "Other.xlsx#Sheet1!A2", true),
("A5", "https://example.com/a.xlsx", true),
("A6", "Sheet1!B2", false),
("A7", "#Sheet1!B3", false),
("A8", "Price", false),
("A9", "'Sheet1'!C4:D5", false),
("A10", "Sales", false),
("A11", "'Q#1'!B2", false),
];
for (cell, target, _) in links {
s.execute("insert.link", json!({"cell": cell, "target": target})).unwrap();
}
let bytes = gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
let mut part = |name: &str| {
let mut text = String::new();
zip.by_name(name).unwrap().read_to_string(&mut text).unwrap();
text
};
let (sheet, rels) = (part("xl/worksheets/sheet1.xml"), part("xl/worksheets/_rels/sheet1.xml.rels"));
for (cell, target, external) in links {
let file = target.split_once('#').map_or(target, |(f, _)| f);
let as_rel = format!("Target=\"{file}\" TargetMode=\"External\"");
let as_location = format!("location=\"{}\"", target.trim_start_matches('#'));
assert_eq!(rels.contains(&as_rel), external, "{cell} {target}: {rels}");
assert_eq!(sheet.contains(&as_location), !external, "{cell} {target}: {sheet}");
}
assert!(sheet.contains("location=\"Sheet1!A2\""), "{sheet}");
let (back, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap();
for (cell, target, external) in links {
let h = &back.sheet(0).unwrap().hyperlinks[&CellRef::parse(cell).unwrap()];
assert_eq!((h.target.as_str(), h.external), (target.trim_start_matches('#'), external), "{cell}");
}
}
#[test]
fn vertical_alignment_commands_and_roundtrip() {
let mut s = s();
@@ -2795,3 +3039,45 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() {
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [4], [8]]})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=SUBTOTAL(109,A1:A4)"})).unwrap();
s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap();
assert_eq!(v(&s, "C1"), Value::Number(9.0));
// A non-zero height unhides the rows (the fuzz found the stale SUBTOTAL).
s.execute("home.rowHeight", json!({"rows": "2:3", "height": 60.0})).unwrap();
assert_eq!(v(&s, "C1"), Value::Number(15.0));
s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap();
assert_eq!(v(&s, "C1"), Value::Number(9.0));
s.execute("home.autofitRowHeight", json!({"rows": "2:3"})).unwrap();
assert_eq!(v(&s, "C1"), Value::Number(15.0));
fn paste_special_validation_copies_the_rule() {
let mut s = s();
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
s.execute("cell.set", json!({"cell": "C3", "input": "7"})).unwrap();
s.execute("data.validation", json!({"range": "A1", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "formats", "at": "E5"})).unwrap();
assert!(ok(&mut s, "E5", "-1"));
s.execute("edit.pasteSpecial", json!({"what": "validation", "at": "C3"})).unwrap();
assert!(!ok(&mut s, "C3", "-1"));
assert!(ok(&mut s, "C3", "1"));
assert!(!ok(&mut s, "A1", "-1"));
assert_eq!(s.execute("cell.get", json!({"cell": "C3"})).unwrap()["text"], json!("7"));
}
#[test]
fn paste_special_validation_moves_relative_references() {
let mut s = s();
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
s.execute("cell.set", json!({"cell": "A1", "input": "50"})).unwrap();
s.execute("data.validation", json!({"range": "A1", "type": "custom", "formula1": "=A1>10"})).unwrap();
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "validation", "at": "C3"})).unwrap();
assert!(!ok(&mut s, "C3", "5"), "the pasted rule checks C3, not A1");
assert!(ok(&mut s, "C3", "20"));
}