Merge main into #2152

# Conflicts:
#	ROADMAP.md
#	docs/architecture.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
This commit is contained in:
prxnvt
2026-10-22 02:39:09 -04:00
253 changed files with 27555 additions and 2378 deletions
+47 -7
View File
@@ -782,15 +782,24 @@ fn forecast_grid(x: &[f64], f: &[Option<f64>], se_window: usize, p: &Json) -> Gr
g.put(idx(i), 0, fi.map(Value::number).unwrap_or_else(na));
}
if flag(p, "standardErrors") {
// A running sum of squared residuals over the last `se_window` points: O(n), not
// O(n·window). A point without a forecast restarts it, so every window touching it is #N/A.
let sq = |j: usize| Some((x.get(j)? - (*f.get(j)?)?).powi(2));
let (mut acc, mut run) = (0.0, 0usize);
for i in 0..f.len() {
let start = (i + 1).checked_sub(se_window);
let v = start.and_then(|s0| {
let mut acc = 0.0;
for j in s0..=i {
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
match sq(i) {
Some(t) => {
acc += t;
run += 1;
}
Some((acc / se_window as f64).sqrt())
});
None => (acc, run) = (0.0, 0),
}
if run > se_window {
acc -= i.checked_sub(se_window).and_then(sq).unwrap_or(0.0);
run = se_window;
}
// Rounding in the subtraction can leave a tiny negative sum.
let v = (run == se_window).then(|| (acc.max(0.0) / se_window as f64).sqrt());
g.put(idx(i), 1, v.map(Value::number).unwrap_or_else(na));
}
}
@@ -1190,6 +1199,37 @@ mod tests {
x
}
#[test]
fn forecast_standard_errors_match_the_full_window_rescan() {
// #1043: the running sum must give what re-adding every window gave, gaps included.
let x: [f64; 11] = [2.0, 4.0, 5.0, 4.0, 5.0, 7.0, 1.0, 3.0, 8.0, 6.0, 2.0];
let f = [None, Some(3.0), Some(4.5), None, Some(4.0), Some(6.5), Some(2.0), Some(2.5), Some(7.0), Some(5.0), Some(4.0)];
let rescan = |i: usize, w: usize| -> Value {
let v = (i + 1).checked_sub(w).and_then(|s0| {
let mut acc = 0.0;
for j in s0..=i {
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
}
Some((acc / w as f64).sqrt())
});
v.map(Value::number).unwrap_or(Value::Error(gridcraft_core::CellError::NA))
};
for w in 1..=5 {
let g = super::forecast_grid(&x, &f, w, &json!({"standardErrors": true}));
for i in 0..f.len() {
let (got, want) = (g.cells.get(&(i as u32, 1)).cloned().unwrap_or(Value::Empty), rescan(i, w));
match (got.as_f64(), want.as_f64()) {
(Some(a), Some(b)) => assert!(close(a, b, 1e-12), "window {w}, row {i}: {a} vs {b}"),
_ => assert_eq!(got, want, "window {w}, row {i}"),
}
}
}
// Row 3 has no forecast: windows through it are #N/A, the next full window after it is not.
let g = super::forecast_grid(&x, &f, 3, &json!({"standardErrors": true}));
assert_eq!(g.cells.get(&(5, 1)), Some(&Value::Error(gridcraft_core::CellError::NA)));
assert!(g.cells.get(&(6, 1)).and_then(Value::as_f64).is_some());
}
#[test]
fn descriptive_statistics_of_one_to_five() {
// 1..5: mean 3, s = √2.5, kurtosis −1.2, skewness 0 (textbook values).
+400 -53
View File
@@ -80,13 +80,13 @@ pub fn specs() -> Vec<CommandSpec> {
"Group",
["Data", "Outline"],
Some("Alt+Shift+Right"),
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default)}",
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
has_doc,
|s, p| group(s, p, true)
),
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols}", has_doc, |s, p| group(
s, p, false
)),
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols, sheet?}", has_doc, |s, p| {
group(s, p, false)
}),
cmd!(
"data.hideDetail",
"Hide Detail",
@@ -147,6 +147,23 @@ fn data_range(s: &Session, p: &Json) -> Result<RangeRef> {
Ok(current_region(sh, d.selection.active))
}
/// The sheet and range a data command works on: the sheet `sheet` or a `Sheet!` prefix on `range`
/// names, else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
fn data_target(s: &Session, p: &Json, cmd: &str) -> Result<(usize, RangeRef)> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
if let Some(t) = str_param(p, "range")
&& let (Some(name), _) = split_sheet(t)
&& d.wb.sheet_index(&name) != Some(sheet)
{
return Err(bad(cmd, format!("`{t}` names a sheet other than `sheet`")));
}
if sheet != d.wb.active_sheet && p.get("range").is_none() {
return Err(bad(cmd, "missing `range`: the selection is on the active sheet"));
}
Ok((sheet, data_range(s, p)?))
}
/// Excel's header guess: the first row is text over a column of numbers, or is bold.
fn guess_header(wb: &Workbook, sh: &Sheet, r: RangeRef) -> bool {
if let Some(t) = sh.table_at(r.start) {
@@ -182,19 +199,36 @@ struct SortKey {
list: Option<Vec<String>>,
}
/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names,
/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
if let Some(t) = str_param(p, "range")
&& let (Some(name), _) = split_sheet(t)
&& d.wb.sheet_index(&name) != Some(sheet)
{
return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`")));
}
if sheet != d.wb.active_sheet && p.get("range").is_none() {
return Err(bad("data.sort", "missing `range`: the selection is on the active sheet"));
}
Ok((sheet, data_range(s, p)?))
}
fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result<Json> {
let r = data_range(s, p)?;
let (sheet, r) = sort_target(s, p)?;
let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col);
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let header = guess_header(&d.wb, sh, r);
do_sort(s, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false)
do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false)
}
fn sort(s: &mut Session, p: &Json) -> Result<Json> {
let r = data_range(s, p)?;
let (sheet, r) = sort_target(s, p)?;
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
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);
@@ -228,10 +262,12 @@ fn sort(s: &mut Session, p: &Json) -> Result<Json> {
if keys.len() > 64 {
return Err(bad("data.sort", "too many sort levels (64 max)"));
}
do_sort(s, r, header, keys, by_cols, case)
do_sort(s, sheet, r, header, keys, by_cols, case)
}
fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_cols: bool, case: bool) -> Result<Json> {
fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<SortKey>, by_cols: bool, case: bool) -> Result<Json> {
// Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here.
super::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?;
let body = if by_cols {
if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r }
} else if header && r.height() > 1 {
@@ -242,7 +278,6 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
if body.count() > 20_000_000 {
return Err(bad("data.sort", "range too large"));
}
let sheet = s.doc()?.wb.active_sheet;
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) };
if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) {
@@ -295,6 +330,21 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
}
// Move whole lines (cells with formulas adjusted for relative references).
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
// Notes and hyperlinks hang off a cell, not a row, so they travel with the row they
// describe — the way cell pictures and row heights do. Collect them before inserting,
// so a swap can't resurrect one from a destination that is also a source.
let side: Vec<(CellRef, Comment)> = order
.iter()
.enumerate()
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
.filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone())))
.collect();
let links: Vec<(CellRef, Hyperlink)> = order
.iter()
.enumerate()
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
.filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone())))
.collect();
let mut moved: Vec<(CellRef, Option<Cell>)> = Vec::with_capacity(body.count() as usize);
let mut pictures = Vec::new();
for (dst_i, &src_i) in order.iter().enumerate() {
@@ -354,6 +404,18 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
}
}
}
// Every cell of the body is the destination of exactly one source cell, so clearing the
// body and inserting the permuted entries is the same permutation the cells went through.
for c in body.iter() {
shm.comments.remove(&c);
shm.hyperlinks.remove(&c);
}
for (at, cm) in side {
shm.comments.insert(at, cm);
}
for (at, h) in links {
shm.hyperlinks.insert(at, h);
}
shm.cell_pictures.extend(pictures);
for (i, h) in heights.into_iter().enumerate() {
let row = body.start.row + i as u32;
@@ -437,10 +499,12 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b
// ---------------------------------------------------------------- filter
fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let (sheet, r) = data_target(s, p, "data.filter")?;
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
// The active cell belongs to the active sheet: on another sheet the table is the one at `range`.
let at = if sheet == s.doc()?.wb.active_sheet { s.doc()?.selection.active } else { r.start };
let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some());
let in_table =
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(s.doc().map(|d| d.selection.active).unwrap_or_default()).map(|t| t.name.clone()));
let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone()));
if let Some(name) = in_table {
return edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
@@ -473,8 +537,7 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
/// 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)?;
let (sheet, mut r) = data_target(s, p, "data.filter")?;
// A selected header row filters the data below it (#201).
if p.get("range").is_none() && r.height() == 1 {
let d = s.doc()?;
@@ -492,6 +555,7 @@ fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let af = match &sh.autofilter {
Some(a) => a.clone(),
@@ -583,6 +647,11 @@ fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool {
}
}
/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result.
fn filter_blank(v: &Value) -> bool {
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
}
/// Hides rows of the autofilter range that fail any criterion.
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let Some(sh) = wb.sheet(sheet) else { return };
@@ -616,7 +685,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let text = crate::display::cell_text(wb, sh, c);
let ok = match crit {
FilterCriterion::Values { values, blanks } => {
if v.is_empty() {
if filter_blank(&v) {
*blanks
} else {
values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display()))
@@ -703,10 +772,11 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) {
let c = CellRef::new(row, col);
let v = sh.value(c);
let t = if v.is_empty() { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) };
let blank = filter_blank(&v);
let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) };
let on = match selected {
Some(FilterCriterion::Values { values, blanks }) => {
if v.is_empty() {
if blank {
*blanks
} else {
values.iter().any(|x| x.eq_ignore_ascii_case(&t))
@@ -714,7 +784,8 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
}
_ => true,
};
seen.entry((sort_key(&v), t)).or_insert(on);
let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) };
seen.entry((key, t)).or_insert(on);
}
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
}
@@ -738,9 +809,9 @@ fn sort_key(v: &Value) -> (u8, i64, String) {
// ---------------------------------------------------------------- tools
fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
let r = data_range(s, p)?;
let (sheet, r) = data_target(s, p, "data.removeDuplicates")?;
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// Rows and columns past the data are blank: only the part with data is compared (whole
// columns compared a million blank rows and reported them as removed duplicates).
let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start));
@@ -750,7 +821,12 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect())
.unwrap_or_else(|| (r.start.col..=r.end.col).collect());
let sheet = d.wb.active_sheet;
// With no column to compare, every row key is empty: the first row would survive and every
// other row would be deleted as a duplicate. A column outside the range compares data the
// user didn't select. Excel refuses both.
if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) {
return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range"));
}
let start = r.start.row + header as u32;
let mut removed = 0;
edit(s, |cx| {
@@ -776,6 +852,24 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
.filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone())))
})
.collect();
// Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that
// describe them move with them; the ones on removed rows are dropped with the row.
let notes: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone())))
})
.collect();
let links: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone())))
})
.collect();
let rows: Vec<Vec<Option<Cell>>> =
keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect();
let shm = cx.sheet_mut(sheet)?;
@@ -784,6 +878,13 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
shm.remove_cell(CellRef::new(row, c));
}
}
for row in start..=r.end.row {
for c in r.start.col..=r.end.col {
let at = CellRef::new(row, c);
shm.comments.remove(&at);
shm.hyperlinks.remove(&at);
}
}
for (i, cells) in rows.into_iter().enumerate() {
for (j, cell) in cells.into_iter().enumerate() {
if let Some(cell) = cell {
@@ -792,6 +893,12 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
}
}
shm.cell_pictures.extend(pictures);
for (at, cm) in notes {
shm.comments.insert(at, cm);
}
for (at, h) in links {
shm.hyperlinks.insert(at, h);
}
for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() {
cx.changed.push((sheet, c));
}
@@ -826,7 +933,10 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
Some(w) => {
let chars: Vec<char> = text.chars().collect();
let mut cuts = vec![0];
cuts.extend(w.iter().copied().filter(|x| *x < chars.len()));
let mut breaks: Vec<usize> = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect();
breaks.sort_unstable();
breaks.dedup();
cuts.extend(breaks);
cuts.push(chars.len());
cuts.windows(2)
.map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::<String>().trim().to_string()).unwrap_or_default())
@@ -951,13 +1061,43 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
"time" => ValidationKind::Time,
"textLength" => ValidationKind::TextLength,
"custom" => ValidationKind::Custom,
_ => ValidationKind::Any,
"any" => ValidationKind::Any,
// An unknown type used to give an "any value" rule, saved as `type="none"`: no validation.
other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))),
};
if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) {
return Err(bad("data.validation", "a list validation needs `formula1`"));
}
const OPERATORS: [&str; 12] = [
"between",
"notBetween",
"equal",
"notEqual",
"greater",
"greaterThan",
"less",
"lessThan",
"greaterOrEqual",
"greaterThanOrEqual",
"lessOrEqual",
"lessThanOrEqual",
];
if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) {
return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", "))));
}
let error_style = match str_param(p, "errorStyle") {
None | Some("stop") => ErrorStyle::Stop,
Some("warning") => ErrorStyle::Warning,
Some("information") => ErrorStyle::Information,
Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))),
};
let f1 = str_param(p, "formula1").unwrap_or("");
let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() };
let dv = Validation {
ranges: ranges.clone(),
kind,
op: op_param(str_param(p, "operator")),
f1: str_param(p, "formula1").unwrap_or("").trim_start_matches('=').to_string(),
f1,
f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()),
allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true),
in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true),
@@ -966,11 +1106,7 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
show_input: true,
error_title: str_param(p, "errorTitle").unwrap_or("").into(),
error_message: str_param(p, "errorMessage").unwrap_or("").into(),
error_style: match str_param(p, "errorStyle") {
Some("warning") => ErrorStyle::Warning,
Some("information") => ErrorStyle::Information,
_ => ErrorStyle::Stop,
},
error_style,
show_error: true,
};
edit(s, |cx| {
@@ -981,14 +1117,36 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather
/// than a reference or a name to evaluate.
fn is_literal_list(f: &str) -> bool {
f.starts_with('"')
|| (!f.contains('!')
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
}
/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's
/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves
/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a
/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before.
fn list_source(f: &str) -> String {
let t = f.trim();
if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) {
return f.trim_start_matches('=').to_string();
}
format!("\"{}\"", t.replace('"', "\"\""))
}
/// List entries of a list validation (literal or from a range).
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
let f = dv.f1.trim();
if f.starts_with('"')
|| (!f.contains('!')
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
{
return f.trim_matches('"').split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
if is_literal_list(f) {
// A quoted list is a string literal: `""` inside stands for one `"`.
let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) {
Some(body) => body.replace("\"\"", "\""),
None => f.trim_matches('"').to_string(),
};
return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
}
let at = dv.ranges.first().map(|r| r.start).unwrap_or_default();
match gridcraft_calc::evaluate(wb, sheet, at, f) {
@@ -1129,10 +1287,10 @@ fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
// ---------------------------------------------------------------- outline
fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
if let Some(r) = str_param(p, "rows").and_then(RangeRef::parse) {
if let Some(r) = str_param(p, "rows").and_then(parse_lines) {
return Ok((true, r.start.row, r.end.row));
}
if let Some(r) = str_param(p, "cols").and_then(RangeRef::parse) {
if let Some(r) = str_param(p, "cols").and_then(parse_lines) {
return Ok((false, r.start.col, r.end.col));
}
let r = s.doc()?.selection.current();
@@ -1145,9 +1303,13 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
}
fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let si = target_sheet(s, p)?;
// The selection belongs to the active sheet, so another sheet needs its lines.
if si != s.doc()?.wb.active_sheet && p.get("rows").is_none() && p.get("cols").is_none() {
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
}
let (rows, a, b) = outline_lines(s, p)?;
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let b = b.min(a.saturating_add(1_048_575));
@@ -1257,7 +1419,8 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
}
fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
let r = data_range(s, p)?;
let (sheet, r) = data_target(s, p, "data.subtotal")?;
let header = bool_param(p, "header").unwrap_or(true);
let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col);
let func = str_param(p, "function").unwrap_or("sum");
let code = match func {
@@ -1281,13 +1444,13 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
"product" => "Product",
_ => "Total",
};
let sheet = s.doc()?.wb.active_sheet;
// Group boundaries (header in the first row).
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let mut groups: Vec<(u32, u32, String)> = Vec::new();
let mut start = r.start.row + 1;
for row in r.start.row + 1..=r.end.row + 1 {
let first = r.start.row + u32::from(header);
let mut start = first;
for row in first..=r.end.row + 1 {
let cur = sh.value(CellRef::new(row, group_col)).display();
let prev = sh.value(CellRef::new(start, group_col)).display();
if row > r.end.row || cur != prev {
@@ -1298,25 +1461,28 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
// Insert a total row after each group, bottom-up so indices stay valid.
let mut inserted = 0;
for (g0, g1, name) in groups.iter().rev() {
s.execute("home.insertRows", json!({"rows": format!("{}:{}", g1 + 2, g1 + 2)}))?;
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?;
let row = g1 + 1;
let lbl = CellRef::new(row, group_col).a1();
s.execute("cell.set", json!({"cell": lbl, "input": format!("{name} {label}")}))?;
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?;
for c in &cols {
let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1();
s.execute("cell.set", json!({"cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
}
s.execute("home.bold", json!({"range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}))?;
s.execute("data.group", json!({"rows": format!("{}:{}", g0 + 1, g1 + 1)}))?;
s.execute(
"home.bold",
json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}),
)?;
s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?;
inserted += 1;
}
let last = r.end.row + inserted + 1;
let lbl = CellRef::new(last, group_col).a1();
s.execute("home.insertRows", json!({"rows": format!("{}:{}", last + 1, last + 1)}))?;
s.execute("cell.set", json!({"cell": lbl, "input": format!("Grand {label}")}))?;
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?;
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?;
for c in &cols {
let range = RangeRef::new(CellRef::new(r.start.row + 1, *c), CellRef::new(last - 1, *c)).a1();
s.execute("cell.set", json!({"cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1();
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
}
Ok(json!({"groups": inserted}))
}
@@ -1325,6 +1491,37 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn filter_blanks_include_empty_text_and_formula_results() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:B5"})).unwrap();
let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap();
assert_eq!(r["hiddenRows"], json!(2));
let vals = filter_values(&s.doc().unwrap().wb, 0, 1);
// The `=""` result joins (Blanks) instead of showing as its own empty entry.
assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}");
let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect();
assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]);
}
#[test]
fn subtotal_honours_header_false() {
// #131: with header:false the first data row was treated as a header and left out of its
// group's subtotal and the grand total.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap();
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!("East"));
assert_eq!(get(&mut s, "A3")["text"], json!("East Total"));
assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows");
assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total"));
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
}
#[test]
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
let mut s = Session::new();
@@ -1357,6 +1554,17 @@ mod tests {
}
}
#[test]
fn list_validation_without_formula1_is_an_error() {
let mut s = Session::new();
s.new_workbook();
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err());
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err());
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap();
assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true));
}
#[test]
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
let mut s = Session::new();
@@ -1530,10 +1738,149 @@ mod tests {
}
}
#[test]
fn single_row_and_column_parameters() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
s.execute("home.insertColumns", json!({"cols": "B"})).unwrap();
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "C3"), json!(4.0));
s.execute("home.hideRows", json!({"rows": "1"})).unwrap();
s.execute("data.group", json!({"cols": "C"})).unwrap();
assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err());
s.execute("home.deleteRows", json!({"rows": "2"})).unwrap();
s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap();
assert_eq!(v(&mut s, "B2"), json!(4.0));
}
#[test]
fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() {
let cases: [(Json, [&str; 4]); 5] = [
(json!([6, 3]), ["abc", "def", "ghij", ""]),
(json!([3, 3]), ["abc", "defghij", "", ""]),
(json!([0]), ["abcdefghij", "", "", ""]),
(json!([20]), ["abcdefghij", "", "", ""]),
(json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]),
];
for (widths, want) in cases {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap();
s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let got: Vec<String> = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect();
assert_eq!(got, want, "{widths}");
}
}
#[test]
fn split() {
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]);
assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]);
}
#[test]
fn remove_duplicates_without_a_valid_column_is_refused() {
// `columns: []` (or only unusable names) used to compare nothing and delete every row
// but the first.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap();
for columns in [json!([]), json!(["ZZZ"])] {
let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err();
assert!(err.to_string().contains("columns"), "{err}");
}
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a"));
assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c"));
}
#[test]
fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() {
// In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula.
// References, names and formulas stay unquoted, and every rule survives a save and reopen.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap();
s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap();
let rules = [
("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#),
("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#),
("C1", " 1,2,3 ", r#""1,2,3""#),
("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#),
("E1", "=$K$1:$K$3", "$K$1:$K$3"),
("F1", "$K$1:$K$3", "$K$1:$K$3"),
("G1", "=Colors", "Colors"),
("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"),
("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"),
];
for (cell, source, _) in rules {
s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap();
}
let accepted = [
("A1", "In progress"),
("B1", "Closed"),
("C1", "2"),
("D1", r#"Say "hi""#),
("E1", "Green"),
("F1", "Blue"),
("G1", "Red"),
("H1", "Green"),
];
let check = |wb: &Workbook| {
for (cell, input) in accepted {
let at = CellRef::parse(cell).unwrap();
assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}");
assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending");
}
};
let wb = s.doc().unwrap().wb.clone();
check(&wb);
let sheet_xml = |wb: &Workbook| {
let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap();
xml
};
let xml = sheet_xml(&wb);
for (cell, _, saved) in rules {
assert!(xml.contains(&format!(r#"sqref="{cell}"><formula1>{saved}</formula1>"#)), "{cell}: {saved} missing from {xml}");
}
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
check(&reopened);
assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
}
#[test]
fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() {
// A typo used to give a rule that validates nothing (`type="none"`) or the wrong check,
// with no error.
let mut s = Session::new();
s.new_workbook();
for params in [
json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}),
json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}),
json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}),
json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}),
] {
let e = s.execute("data.validation", params.clone()).unwrap_err().to_string();
assert!(e.contains("unknown"), "{params}: {e}");
}
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
for params in [
json!({"range": "A1", "type": "any"}),
json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}),
json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}),
] {
s.execute("data.validation", params).unwrap();
}
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert_eq!(sh.validations.len(), 3);
assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some());
}
}
+199 -27
View File
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AutoFill",
[],
None,
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)",
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
has_doc,
auto_fill
),
@@ -138,18 +138,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('));
if is_formula {
let body = input.strip_prefix('=').unwrap_or(input);
// Excel closes missing parentheses for you.
let mut text = body.to_string();
let mut parsed = gridcraft_formula::parse(&text);
for _ in 0..8 {
if parsed.is_ok() {
break;
}
text.push(')');
parsed = gridcraft_formula::parse(&text);
}
let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?;
let expr = wb.name_call_case(expr);
let expr = wb.name_call_case(parse_typed_formula(body)?);
check_arg_counts(&expr)?;
let mut cell = Cell::formula(Formula::from_expr(expr));
cell.style = style;
@@ -176,7 +165,17 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
}
return Ok(Some(cell));
}
let parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
// Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already
// formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions.
if parsed.format.is_none()
&& !input.contains('%')
&& wb.styles.get(style).num_fmt.as_str().ends_with('%')
&& let Value::Number(n) = parsed.value
&& n.abs() >= 1.0
{
parsed.value = Value::Number(n / 100.0);
}
let mut style = style;
if let Some(code) = parsed.format {
let cur = wb.styles.get(style).num_fmt.as_str().to_string();
@@ -191,11 +190,19 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
Ok(Some(Cell { value: parsed.value, formula: None, style }))
}
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
/// and refuses a formula that still doesn't parse.
pub(crate) fn parse_typed_formula(body: &str) -> Result<gridcraft_formula::Expr> {
// Excel closes missing parentheses for you.
let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse);
parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))
}
/// Like Excel, a formula that calls a built-in function with too few or too many arguments is
/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't
/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and
/// unknown functions are skipped.
fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
let mut bad = None;
expr.walk(&mut |e| {
if bad.is_none()
@@ -373,6 +380,12 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
// ---------------------------------------------------------------- selection
/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells.
fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool {
let Some(p) = sheet.protection.as_ref() else { return true };
if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked }
}
fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("sheet").is_some() {
let i = target_sheet(s, p)?;
@@ -390,6 +403,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges };
if let Some(sh) = d.wb.active() {
sel.expand_merges(sh);
if !cell_selectable(&d.wb, sh, sel.active) {
return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into()));
}
}
d.selection = sel;
Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()}))
@@ -458,6 +474,29 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
{
to = m.start;
}
if !extend && !cell_selectable(&d.wb, sheet, to) {
// Step on to the next selectable cell in the same direction; stay put if there is none.
let (sr, sc) = (dr.signum(), dc.signum());
let mut t = to;
let mut found = false;
if !jmp && (sr, sc) != (0, 0) {
for _ in 0..10_000 {
let n = t.offset_clamped(sr, sc);
if n == t {
break;
}
t = n;
if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) {
found = true;
break;
}
}
}
if !found {
return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}));
}
to = t;
}
if extend {
sel.extend_to(to);
sel.expand_merges(sheet);
@@ -525,7 +564,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
} else if let Some((si, ti)) = d.wb.table(reference) {
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
format!("'{}'!{}", d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), t)
format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t)
} else {
return Err(EngineError::Other("Reference isn't valid.".into()));
}
@@ -612,12 +651,13 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.undo.pop() else { break };
let cur_wb = d.wb.clone();
// Redo goes back to the undone edit's cells, not wherever the user clicked before undoing (#1855).
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: e.selection.clone() };
d.redo.push(cur);
let cur_selection = e.selection.clone();
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
@@ -632,11 +672,12 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.redo.pop() else { break };
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
d.undo.push(cur);
let cur_wb = d.wb.clone();
let cur_selection = d.selection.clone();
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
@@ -805,10 +846,11 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
let rows: Vec<Vec<String>> = parse_tsv(text);
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
let h = rows.len();
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
// The HTML flavour, when the host passed it: the number behind each cell's shown text. Read
// only as far as the text's own shape, so spans in the markup can't expand without bound.
let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default();
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
for (ci, v) in row.iter().enumerate() {
@@ -948,6 +990,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let src_wb = clip.wb.clone();
let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) };
let src_sheet_name = src_sheet.name.clone();
if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() {
// Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing.
return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into()));
}
edit(s, |cx| {
// Styles from another workbook must be re-interned.
let mut style_map = std::collections::HashMap::new();
@@ -964,6 +1010,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let source = cx.sheet_mut(clip.sheet)?;
moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect();
let taken = source.take_cells(src);
// Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them
// with the block: the destination is written from the clipboard snapshot taken
// before the cut, so removing them here loses nothing and leaves the old place
// clean instead of leaving one merged block and one note behind.
source.comments.retain(|c, _| !src.contains(*c));
source.hyperlinks.retain(|c, _| !src.contains(*c));
source.merges.retain(|m| !src.contains_range(m));
for (c, _) in &taken {
cx.touch(clip.sheet, *c);
}
@@ -1011,11 +1064,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
// The array range moves with its formula.
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
moved.dynamic = f.dynamic;
return Some(std::sync::Arc::new(moved));
}
let moved =
gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula::from_expr(moved)))
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
@@ -1142,6 +1196,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
}
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
@@ -1226,12 +1283,13 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
{
let mut nf = Formula::from_expr(new);
nf.array = array;
nf.dynamic = f.dynamic;
cell.formula = Some(std::sync::Arc::new(nf));
}
}
}
for n in wb.names.iter_mut() {
if let Ok(expr) = gridcraft_formula::parse(&n.formula) {
if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) {
// Names are workbook-level: unqualified refs don't occur; qualified ones adjust.
let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e);
n.formula = gridcraft_formula::print(&new);
@@ -1344,7 +1402,7 @@ fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
None => return Ok(Json::Null),
}
}
if r.count() > 5_000_000 {
if r.count() > crate::fill::MAX_FILL_CELLS {
return Err(bad("edit.fill", "range too large"));
}
// With one row selected, Fill Down copies from the row above (Excel behaviour).
@@ -1378,11 +1436,18 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
"alternate" => crate::fill::FillMode::Alternate,
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
"extend" => crate::fill::FillMode::Extend,
_ => crate::fill::FillMode::Series,
};
if target.count() > 5_000_000 || src.count() > 5_000_000 {
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
return Err(bad("edit.autoFill", "range too large"));
}
// Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill
// every source lane across the whole target extent: billions of cells for whole rows (`2:3`)
// into whole columns (`A:A`).
if !crate::fill::holds_source_in_corner(src, target) {
return Err(bad("edit.autoFill", "the target must contain the source in one of its corners"));
}
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, target, mode)?;
*cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] };
@@ -1567,10 +1632,24 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"replaced": n}))
}
/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so
/// byte positions in the folded text still line up with the original's character boundaries.
fn folds_in_place(s: &str) -> bool {
s.chars().all(|c| {
let mut folded = c.to_lowercase();
let first = folded.next();
folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8())
})
}
fn replace_ci(text: &str, what: &str, with: &str) -> String {
let lower = text.to_lowercase();
let w = what.to_lowercase();
if lower.len() != text.len() || w.is_empty() {
// Slicing `text` at positions found in `lower` is only sound when folding kept every byte
// boundary: a length check alone misses foldings that keep the total length while shifting
// boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used
// to be dropped. Those fall back to the case-sensitive replace, as length changes already do.
if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) {
return text.replace(what, with);
}
let mut out = String::new();
@@ -1595,6 +1674,63 @@ fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) };
let (h, w) = (src.height(), src.width());
let dest = if dest.is_single() { RangeRef::new(dest.start, dest.start.offset_clamped(h as i64 - 1, w as i64 - 1)) } else { dest };
// Whole rows or columns take a line format, as the ribbon's formatting commands give
// them, instead of a style on each of their cells (a column alone has a million). The
// line format comes from the first cell of the source pattern across the line; cells
// already stored follow the pattern. Row heights and column widths stay as they are.
let rows = dest.is_full_rows() && !dest.is_full_cols();
let cols = dest.is_full_cols() && !dest.is_full_rows();
let src_full = (rows && src.is_full_rows()) || (cols && src.is_full_cols());
if rows || cols {
let (first, src_first, n) = if rows { (dest.start.row, src.start.row, h) } else { (dest.start.col, src.start.col, w) };
let lines = if rows { dest.start.row..=dest.end.row } else { dest.start.col..=dest.end.col };
for line in lines {
let sl = src_first + (line - first) % n;
// The source cell for position `k` along the line.
let at = |k: u32| if rows { CellRef::new(sl, src.start.col + k % w) } else { CellRef::new(src.start.row + k % h, sl) };
let line_style = if src_full {
if rows { src_sheet.rows.get(&sl) } else { src_sheet.cols.get(&sl) }.and_then(|i| i.style)
} else {
Some(src_sheet.style_id(at(0)))
};
let line_style = line_style.map(|st| if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) });
let (dest_line, src_line) = if rows {
(RangeRef::rows(line, line), RangeRef::rows(sl, sl))
} else {
(RangeRef::cols(line, line), RangeRef::cols(sl, sl))
};
let mut cells: Vec<CellRef> =
cx.wb.sheet(dest_sheet).map(|sh| sh.cells.iter_range(dest_line).map(|(c, _)| c).collect()).unwrap_or_default();
if src_full {
cells.extend(
src_sheet
.cells
.iter_range(src_line)
.map(|(c, _)| if rows { CellRef::new(line, c.col) } else { CellRef::new(c.row, line) }),
);
}
if cols {
// A row format outranks a column format: the column's cells in formatted
// rows need a style of their own.
cells.extend(
cx.wb
.sheet(dest_sheet)
.into_iter()
.flat_map(|sh| sh.rows.iter().filter(|(_, i)| i.style.is_some()).map(|(r, _)| CellRef::new(*r, line))),
);
}
let sh = cx.sheet_mut(dest_sheet)?;
(if rows { &mut sh.rows } else { &mut sh.cols }).entry(line).or_default().style = line_style;
for c in cells {
let st = src_sheet.style_id(at(if rows { c.col } else { c.row }));
let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) };
cx.sheet_mut(dest_sheet)?.set_style(c, st);
cx.touch(dest_sheet, c);
}
}
*cx.sel = crate::Selection::range(dest);
return Ok(());
}
for c in dest.iter().take(2_000_000) {
let sc = CellRef::new(src.start.row + (c.row - dest.start.row) % h, src.start.col + (c.col - dest.start.col) % w);
let st = src_sheet.style_id(sc);
@@ -1713,8 +1849,44 @@ mod tests {
assert_eq!(get(&mut s, "E1")["value"], json!(5.0));
}
#[test]
fn cut_paste_that_overflows_the_grid_moves_nothing() {
// #997: the source was emptied first and the cells that fell off the grid were skipped.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
// Transposed, the 2 rows become 2 columns and the second one is past XFD.
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err());
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
// Not transposed, the second row would be past the last row.
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err());
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
// Not transposed it still fits in column XFD.
s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap();
assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0));
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
}
#[test]
fn replace_case_insensitive() {
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
}
#[test]
fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
// U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a
// combining dot (2→3 bytes): the folded text is the same length but its byte positions
// no longer line up with the original, and the old slicing dropped the tail.
let mut s = Session::new();
s.new_workbook();
let text = "\u{212A}\u{0130}\u{0130}";
s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap();
s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
}
}
+8 -2
View File
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rotate",
["Page Layout", "Arrange"],
None,
"{kind: shape, id, flip?: horizontal|vertical} (swaps width/height for 90° turns)",
"{kind: shape, id} (swaps width/height for a 90° turn; `flip` is rejected: shapes have no flip state)",
has_doc,
rotate
),
@@ -351,7 +351,7 @@ fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart
/// Element ▸ Trendline choices, plus the Format Trendline options).
fn trendline_param(p: &Json) -> Result<Trendline> {
let num = |k: &str| f64_param(p, k).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0);
let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0));
let mut t = Trendline {
forward: num("forward"),
backward: num("backward"),
@@ -662,6 +662,9 @@ fn align(s: &mut Session, p: &Json) -> Result<Json> {
}
fn rotate(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("flip").is_some_and(|f| !f.is_null()) {
return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state"));
}
with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height))
}
@@ -829,6 +832,9 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
edit(s, |cx| {
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();
if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) {
return Err(EngineError::Other("A table can't overlap another table.".into()));
}
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
if r.start.row != t.range.start.row || !r.intersects(&t.range) {
return Err(EngineError::Other(
+16 -1
View File
@@ -58,6 +58,13 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
let (mut wb, warnings) = crate::io::open_bytes_with(&name, &bytes, str_param(p, "password"), bool_param(p, "csvAsText").unwrap_or(false))?;
wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir);
// What `CELL("filename")` reports (#1489). A workbook opened from bytes knows its name but
// never a folder, so it reports `[name]Sheet` with none; an import reports the file it came
// from, as Excel does, even though Save must still ask where to put the .xlsx.
wb.source_path = match str_param(p, "path") {
Some(path) => crate::io::source_path(path),
None => Some(std::path::PathBuf::from(&name)),
};
let imported =
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
@@ -124,6 +131,13 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
crate::io::write_file(&path, &bytes)?;
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
if matches!(kind, Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) {
// The new file is the workbook's own now, so `CELL("filename")` reports it (#1489). Set
// before the `saved` snapshot, or the workbook would read as dirty the moment it was
// saved; and only when it changes, so an ordinary Save does not clone the workbook.
let file = crate::io::source_path(&path);
if d.wb.source_path != file {
std::sync::Arc::make_mut(&mut d.wb).source_path = file;
}
d.saved = d.wb.clone();
d.path = Some(path.clone());
}
@@ -141,8 +155,9 @@ fn save_as(s: &mut Session, p: &Json) -> Result<Json> {
fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
let fmt = str_param(p, "format").unwrap_or("xlsx");
let d = s.doc()?;
let bytes = crate::io::save_bytes(&d.wb, &format!("x.{fmt}"))?;
let name = format!("{}.{fmt}", std::path::Path::new(&d.display_title()).file_stem().and_then(|x| x.to_str()).unwrap_or("Book"));
// The file name goes into a PivotChart's link to its PivotTable.
let bytes = crate::io::save_bytes(&d.wb, &name)?;
Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()}))
}
+1
View File
@@ -411,6 +411,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
color: None,
secondary: false,
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
})
+175 -50
View File
@@ -133,9 +133,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
number_format
),
cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, |s, p| set_fmt(
s, p, ACCOUNTING
)),
cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, accounting),
cmd!("home.percent", "Percent Style", ["Home", "Number"], Some("Ctrl+Shift+%"), "{range?}", has_doc, |s, p| set_fmt(s, p, "0%")),
cmd!("home.comma", "Comma Style", ["Home", "Number"], None, "{range?}", has_doc, |s, p| set_fmt(s, p, COMMA)),
cmd!("home.increaseDecimal", "Increase Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, 1)),
@@ -156,7 +154,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Row Height…",
["Home", "Cells", "Format"],
None,
"{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}",
"{sheet?, rows?: \"2:5\" | \"2:2,4:5\" | \"Sheet2!2:5\", height: 20 (pixels) | points: 15}",
has_doc,
row_height
),
@@ -165,28 +163,34 @@ pub fn specs() -> Vec<CommandSpec> {
"Column Width…",
["Home", "Cells", "Format"],
None,
"{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}",
"{sheet?, cols?: \"B:D\" | \"B:B,D:F\" | \"Sheet2!B:D\", width: 64 (pixels) | chars: 8.43}",
has_doc,
col_width
),
cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{rows?}", has_doc, autofit_rows),
cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{cols?}", has_doc, autofit_cols),
cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{width: 64}", has_doc, default_width),
cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{rows?}", has_doc, |s, p| hide(
cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{sheet?, rows?}", has_doc, autofit_rows),
cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{sheet?, cols?}", has_doc, autofit_cols),
cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{sheet?, width: 64 (pixels)}", has_doc, default_width),
cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{sheet?, rows?}", has_doc, |s, p| hide(
s, p, true, true
)),
cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{cols?}", has_doc, |s, p| hide(
s, p, false, true
)),
cmd!("home.unhideRows", "Unhide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+9"), "{rows?}", has_doc, |s, p| hide(
s, p, true, false
)),
cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{sheet?, cols?}", has_doc, |s, p| {
hide(s, p, false, true)
}),
cmd!(
"home.unhideRows",
"Unhide Rows",
["Home", "Cells", "Format", "Hide & Unhide"],
Some("Cmd+Shift+9"),
"{sheet?, rows?}",
has_doc,
|s, p| hide(s, p, true, false)
),
cmd!(
"home.unhideColumns",
"Unhide Columns",
["Home", "Cells", "Format", "Hide & Unhide"],
Some("Cmd+Shift+0"),
"{cols?}",
"{sheet?, cols?}",
has_doc,
|s, p| hide(s, p, false, false)
),
@@ -202,6 +206,23 @@ pub fn specs() -> Vec<CommandSpec> {
const ACCOUNTING: &str = "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)";
const COMMA: &str = "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)";
/// Accounting Number Format with the currency symbol to use (Excel's own button takes the system
/// currency; `symbol` is how the control channel picks another one, e.g. `"L"` for lempiras).
fn accounting(s: &mut Session, p: &Json) -> Result<Json> {
let symbol = str_param(p, "symbol").unwrap_or("$");
// The symbol goes into a quoted literal: a quote or a backslash would end it, and a control
// character would reach the format's text as-is.
if symbol.contains(['"', '\\']) || symbol.chars().any(char::is_control) {
return Err(bad("home.accounting", "the symbol can't contain a quote, a backslash or a control character"));
}
set_fmt(s, p, &accounting_format(symbol))
}
fn accounting_format(symbol: &str) -> String {
let symbol = format!("\"{symbol}\"");
format!("_({symbol}* #,##0.00_);_({symbol}* \\(#,##0.00\\);_({symbol}* \"-\"??_);_(@_)")
}
fn with(p: &Json, key: &str, v: Json) -> Json {
let mut m = p.as_object().cloned().unwrap_or_default();
m.insert(key.into(), v);
@@ -296,7 +317,11 @@ fn underline(s: &mut Session, p: &Json) -> Result<Json> {
fn font_name(s: &mut Session, p: &Json) -> Result<Json> {
let Some(name) = str_param(p, "name").map(str::to_string) else { return Err(bad("home.fontName", "missing `name`")) };
apply_style(s, p, |st| st.font.name = name.clone())
apply_style(s, p, |st| {
st.font.name = name.clone();
st.font.charset = None;
st.font.family = None;
})
}
fn font_size(s: &mut Session, p: &Json) -> Result<Json> {
@@ -488,7 +513,7 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
}
fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
let Some(patch) = p.get("style").cloned() else {
let Some(patch) = p.get("style").map(snake_case_keys) else {
// Menu invocation without params: the UI opens the dialog.
s.ui_requests.push(crate::UiRequest::Dialog("formatCells".into(), json!({})));
return ok();
@@ -506,6 +531,32 @@ fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// The style patch with camelCase keys (`numFmt`, `diagDown`, as documented) renamed to the
/// style's own snake_case (`num_fmt`, `diag_down`). Keys starting with a capital (`Rgb`) stay.
fn snake_case_keys(v: &Json) -> Json {
match v {
Json::Object(m) => Json::Object(
m.iter()
.map(|(k, v)| {
let key = if k.starts_with(|c: char| c.is_ascii_lowercase()) {
k.chars().fold(String::new(), |mut out, c| {
if c.is_ascii_uppercase() {
out.push('_');
}
out.push(c.to_ascii_lowercase());
out
})
} else {
k.clone()
};
(key, snake_case_keys(v))
})
.collect(),
),
other => other.clone(),
}
}
/// Deep-merges `patch` into `base` (objects merge, everything else replaces).
pub(crate) fn merge_json(mut base: Json, patch: &Json) -> Json {
match (&mut base, patch) {
@@ -933,29 +984,60 @@ fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
// ---------------------------------------------------------------- sizes
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<Vec<(u32, u32)>> {
/// The sheet and the row (or column) spans a size or visibility command works on. The lines are
/// `rows`/`cols` (`"2:5"`, `"B:B,D:F"`, optionally `Sheet!`-prefixed), else the selection's; the
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
/// sheet, so another sheet needs explicit lines.
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
let key = if rows { "rows" } else { "cols" };
let mut sheet = target_sheet(s, p)?;
if let Some(t) = str_param(p, key) {
let (prefix, body) = split_sheet(t);
if let Some(name) = prefix {
let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?;
if p.get("sheet").is_some_and(|v| !v.is_null()) && named != sheet {
return Err(bad(key, format!("`{t}` names a sheet other than `sheet`")));
}
sheet = named;
}
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
return t
let lines = body
.split(',')
.map(|part| {
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect();
.collect::<Result<Vec<_>>>()?;
return Ok((sheet, lines));
}
Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())
let d = s.doc()?;
if sheet != d.wb.active_sheet {
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
}
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
}
/// Sizes are pixels at 96 dpi, the model's unit (header drags send pixels). `points` (row height)
/// and `chars` (column width) take Excel's units, those of its Row Height and Column Width dialogs
/// and of the file (`ht` is in points).
fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
let h = f64_param(p, "height").ok_or_else(|| bad("home.rowHeight", "missing `height`"))?;
if !(0.0..=546.0).contains(&h) {
return Err(EngineError::Other("Row height must be between 0 and 409 points.".into()));
}
let lines = line_targets(s, p, true)?;
let h = match (f64_param(p, "height"), f64_param(p, "points")) {
(Some(h), _) => {
if !(0.0..=546.0).contains(&h) {
return Err(EngineError::Other("Row height must be between 0 and 546 pixels (409.5 points).".into()));
}
h
}
(None, Some(pt)) => {
if !(0.0..=409.5).contains(&pt) {
return Err(EngineError::Other("Row height must be between 0 and 409.5 points.".into()));
}
pt * 4.0 / 3.0
}
_ => return Err(bad("home.rowHeight", "missing `height` (pixels) or `points`")),
};
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for r in a..=b.min(a.saturating_add(1_048_575)) {
@@ -972,16 +1054,22 @@ fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = match (f64_param(p, "width"), f64_param(p, "chars")) {
(Some(w), _) => w,
(None, Some(c)) => chars_to_points(c),
_ => return Err(bad("home.columnWidth", "missing `width`")),
(Some(w), _) => {
if !(0.0..=1800.0).contains(&w) {
return Err(EngineError::Other("Column width must be between 0 and 1800 pixels.".into()));
}
w
}
(None, Some(c)) => {
if !(0.0..=255.0).contains(&c) {
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
}
chars_to_points(c)
}
_ => return Err(bad("home.columnWidth", "missing `width` (pixels) or `chars`")),
};
if !(0.0..=1800.0).contains(&w) {
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
}
let lines = line_targets(s, p, false)?;
let (si, lines) = line_targets(s, p, false)?;
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for c in a..=b {
@@ -994,7 +1082,7 @@ fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Excel column width in characters (of the default font's digit width, 7 px) → points.
/// Excel column width in characters (of the default font's digit width, 7 px) → pixels.
pub fn chars_to_points(c: f64) -> f64 {
if c <= 0.0 {
0.0
@@ -1008,7 +1096,7 @@ pub fn chars_to_points(c: f64) -> f64 {
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).
/// widths for the default font; a front end with fonts measures instead, see `Session::text_width`).
pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
let mut w = 0.0;
for c in text.chars() {
@@ -1025,12 +1113,18 @@ pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
}
/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
/// quick measure so a huge column doesn't lay out every value.
const EXACT_TEXT_WIDTHS: usize = 2_000;
fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
let lines = line_targets(s, p, false)?;
let (si, lines) = line_targets(s, p, false)?;
let measure = s.text_width.clone();
edit(s, |cx| {
let si = cx.sheet_index();
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let mut widths = Vec::new();
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
let mut distinct = 0usize;
let used = sh.used_range();
for (a, b) in lines {
for c in a..=b.min(a + 16383) {
@@ -1041,12 +1135,26 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
continue;
}
let st = cx.wb.styles.get(sh.style_id(cell));
let id = sh.style_id(cell);
let st = cx.wb.styles.get(id);
let text = crate::display::cell_text(&cx.wb, sh, cell);
for line in text.lines() {
max = max.max(
approx_text_width(line, st.font.size, st.font.bold) + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell),
);
let w = match &measure {
Some(m) => {
let known = measured.entry(id).or_default();
match known.get(line) {
Some(w) => *w,
None => {
let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
known.insert(line.to_owned(), w);
distinct += 1;
w
}
}
}
None => approx_text_width(line, st.font.size, st.font.bold),
};
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
}
}
if max > 0.0 {
@@ -1065,9 +1173,8 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
}
fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
let lines = line_targets(s, p, true)?;
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
let si = cx.sheet_index();
for (a, b) in lines {
let b = b.min(a + 100_000);
if let Some(sh) = cx.wb.sheet_mut(si) {
@@ -1087,17 +1194,16 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
fn default_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = f64_param(p, "width").ok_or_else(|| bad("home.defaultWidth", "missing `width`"))?;
let si = target_sheet(s, p)?;
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.default_col_width = w.clamp(0.0, 1800.0) as f32;
Ok(Json::Null)
})
}
fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
let lines = line_targets(s, p, rows)?;
let (si, lines) = line_targets(s, p, rows)?;
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
// Unhiding a single selected line also reveals hidden neighbours (Excel needs the
@@ -1137,6 +1243,11 @@ mod tests {
assert_eq!(v(&mut s, "D1"), json!(4.0));
}
#[test]
fn the_default_symbol_reproduces_the_accounting_format() {
assert_eq!(accounting_format("$"), ACCOUNTING);
}
#[test]
fn decimal_steps() {
let v = gridcraft_core::Value::Number(1.5);
@@ -1149,6 +1260,20 @@ mod tests {
assert_eq!(adjust_decimals("0%", &v, 1), "0.0%");
}
#[test]
fn format_cells_takes_the_documented_keys() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
s.execute("home.formatCells", json!({"range": "A1", "style": {"numFmt": "0.0", "borders": {"diagUp": {"style": "Thin"}}}})).unwrap();
let st = &s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"];
assert_eq!(st["num_fmt"], "0.0");
assert_eq!(st["borders"]["diag_up"]["style"], "Thin");
// The style's own snake_case keys keep working.
s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "0.00"}})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00");
}
#[test]
fn width_units() {
assert_eq!(chars_to_points(8.43), 64.0);
+177 -11
View File
@@ -1,6 +1,6 @@
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{CalcMode, DefinedName, Formula};
use serde_json::{Value as Json, json};
@@ -49,11 +49,22 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents),
cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()),
cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas),
cmd!(
"formulas.convertToNumber",
"Convert to Number",
[],
None,
"{range?} → {converted} (numbers stored as text in the range or selection become numbers)",
has_doc,
convert_to_number
),
cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking),
cmd!(query "formulas.evaluateFormula", "Evaluate Formula", ["Formulas", "Formula Auditing"], None, "{cell?, formula?} → steps", has_doc, evaluate_formula),
cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet", has_doc, evaluate),
cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet; a formula that couldn't be entered in a cell is an error", has_doc, evaluate),
cmd!(noundo "formulas.calculateNow", "Calculate Now", ["Formulas", "Calculation"], Some("F9"), "{}", has_doc, calc_now),
cmd!(noundo "formulas.calculateSheet", "Calculate Sheet", ["Formulas", "Calculation"], Some("Shift+F9"), "{}", has_doc, calc_now),
cmd!(
@@ -237,7 +248,7 @@ fn valid_name(n: &str) -> bool {
/// Looks like an R1C1 reference (`R`, `C`, `RC`, `R2`, `C3`, `R1C1`), which Excel doesn't
/// accept as a name.
fn is_r1c1(n: &str) -> bool {
pub(crate) fn is_r1c1(n: &str) -> bool {
let digits = |s: &str| s.bytes().take_while(u8::is_ascii_digit).count();
let mut rest = n;
if let Some(r) = rest.strip_prefix(['R', 'r']) {
@@ -265,7 +276,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
let refers = match str_param(p, "refersTo") {
// As in Excel, relative references are relative to the active cell; names keep them
// relative to A1, as files do.
Some(r) => match gridcraft_formula::parse(r.trim_start_matches('=')) {
Some(r) => match gridcraft_formula::parse_name(r.trim_start_matches('=')) {
Ok(e) if gridcraft_formula::adjust::has_relative_refs(&e) => {
let at = d.selection.active;
gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative_wrapping(e, -(at.row as i64), -(at.col as i64)))
@@ -280,7 +291,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), body)
}
};
if gridcraft_formula::parse(&refers).is_err() {
if gridcraft_formula::parse_name(&refers).is_err() {
return Err(EngineError::Other("There's a problem with this formula.".into()));
}
let comment = str_param(p, "comment").unwrap_or("").to_string();
@@ -348,6 +359,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
let Some(e) = f.expr().and_then(rename) else { continue };
let mut nf = Formula::from_expr(e);
nf.array = f.array;
nf.dynamic = f.dynamic;
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = Some(std::sync::Arc::new(nf));
}
@@ -355,7 +367,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
}
for n in wb.names.iter_mut() {
if n.scope.is_none_or(applies)
&& let Some(e) = gridcraft_formula::parse(&n.formula).ok().and_then(rename)
&& let Some(e) = gridcraft_formula::parse_name(&n.formula).ok().and_then(rename)
{
n.formula = gridcraft_formula::print(&e);
}
@@ -389,7 +401,9 @@ fn name_manager(s: &mut Session, _: &Json) -> Result<Json> {
.filter(|n| !n.hidden)
.map(|n| {
let sheet = n.scope.unwrap_or(d.wb.active_sheet);
let v = gridcraft_calc::evaluate(&d.wb, sheet, CellRef::default(), &n.formula);
// parse_name, not evaluate's parse: a name's top-level commas are unions.
let v = gridcraft_formula::parse_name(&n.formula)
.map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e));
json!({"name": n.name, "refersTo": format!("={}", n.formula), "scope": n.scope.and_then(|i| d.wb.sheet(i)).map(|s| s.name.clone()).unwrap_or_else(|| "Workbook".into()), "value": crate::cmd::inspect::value_json(&v), "comment": n.comment})
})
.collect();
@@ -479,6 +493,82 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
Ok(Json::Array(v))
}
/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on
/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]).
fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Json> {
use std::collections::BTreeSet;
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let all = bool_param(p, "all").unwrap_or(false);
// (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells.
let mut found: BTreeSet<(usize, String)> = BTreeSet::new();
let mut seen: BTreeSet<(usize, CellRef)> = BTreeSet::new();
let mut frontier = vec![(sheet, at)];
seen.insert((sheet, at));
while let Some((ks, kc)) = frontier.pop() {
if precedents {
for a in d.calc.graph.precedents_of((ks, kc)) {
if all {
// Walk only the stored cells inside the precedent area (sparse
// range iterator, not the whole sheet): blanks are never selected
// and only formula cells extend the frontier, so a formula late in
// a large range is not skipped.
let Some(sh) = d.wb.sheet(a.sheet) else { continue };
for (c, cell) in sh.cells.iter_range(a.range) {
if matches!(cell.value, Value::Empty) {
continue;
}
if seen.insert((a.sheet, c)) {
found.insert((a.sheet, c.a1()));
if cell.formula.is_some() {
frontier.push((a.sheet, c));
}
}
}
} else {
found.insert((a.sheet, a.range.a1()));
}
}
} else {
let mut deps = Vec::new();
d.calc.graph.dependents(ks, kc, &mut deps);
for k2 in deps {
if all {
if seen.insert(k2) {
found.insert((k2.0, k2.1.a1()));
frontier.push(k2);
}
} else {
found.insert((k2.0, k2.1.a1()));
}
}
}
if !all {
break;
}
}
found.remove(&(sheet, at.a1()));
let name = if precedents { "precedent" } else { "dependent" };
let Some(si) = found.iter().find(|(si, _)| *si == sheet).or_else(|| found.first()).map(|(si, _)| *si) else {
return Err(bad("cell", format!("No {name} cells.")));
};
let list: Vec<String> = found.iter().filter(|(s, _)| *s == si).map(|(_, a)| a.clone()).collect();
let mut params = json!({ "range": list.join(",") });
if si != sheet {
params["sheet"] = json!(si);
}
s.execute("selection.set", params)
}
fn select_precedents(s: &mut Session, p: &Json) -> Result<Json> {
select_trace_cells(s, p, true)
}
fn select_dependents(s: &mut Session, p: &Json) -> Result<Json> {
select_trace_cells(s, p, false)
}
fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas);
@@ -489,6 +579,40 @@ fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// The number a cell's text stands for when Excel's background check flags it as a number stored
/// as text: a constant (not a formula's result) that would be a number if typed.
pub fn number_stored_as_text(cell: &gridcraft_model::Cell) -> Option<f64> {
match &cell.value {
Value::Text(t) if cell.formula.is_none() => gridcraft_core::parse::number_stored_as_text(t),
_ => None,
}
}
/// Convert to Number: each number stored as text in the range becomes that number, keeping the
/// cell's format.
fn convert_to_number(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
edit(s, |cx| {
let mut converted = 0;
for r in &ranges {
let Some(r) = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) else { continue };
let found: Vec<(CellRef, f64)> = cx
.wb
.sheet(sheet)
.map(|sh| sh.cells.iter_range(r).filter_map(|(c, x)| number_stored_as_text(x).map(|n| (c, n))).collect())
.unwrap_or_default();
let sh = cx.sheet_mut(sheet)?;
for (c, n) in &found {
sh.set_value(*c, Value::number(*n));
}
cx.changed.extend(found.iter().map(|(c, _)| (sheet, *c)));
converted += found.len();
}
Ok(json!({"converted": converted}))
})
}
fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let mut out = Vec::new();
@@ -528,23 +652,30 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
let mut steps = Vec::new();
let mut nodes = Vec::new();
expr.walk(&mut |e| {
// Borrowed, not cloned: copying every operator of a long chain A1+A2+… copies the
// rest of the chain each time (quadratic memory).
if matches!(e, gridcraft_formula::Expr::Call(..) | gridcraft_formula::Expr::Binary(..) | gridcraft_formula::Expr::Ref(_)) {
nodes.push(e.clone());
nodes.push(e);
}
});
for e in nodes.into_iter().rev().take(200) {
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &e);
steps.push(json!({"expression": gridcraft_formula::print(&e), "value": crate::cmd::inspect::value_json(&v)}));
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, e);
steps.push(json!({"expression": gridcraft_formula::print(e), "value": crate::cmd::inspect::value_json(&v)}));
}
let result = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &expr);
Ok(json!({"formula": format!("={text}"), "steps": steps, "result": crate::cmd::inspect::value_json(&result)}))
}
/// Evaluates what typing `formula` into a cell would show. A formula `cell.set` would refuse (it
/// doesn't parse, or calls a function with the wrong number of arguments) is an error that says
/// why, not `#NAME?`; an unknown function name still evaluates to `#NAME?`, as in Excel.
fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let f = str_param(p, "formula").ok_or_else(|| bad("formulas.evaluate", "missing `formula`"))?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let v = gridcraft_calc::evaluate(&d.wb, d.wb.active_sheet, at, f);
let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?;
crate::cmd::edit::check_arg_counts(&expr)?;
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr);
Ok(crate::cmd::inspect::value_json(&v))
}
@@ -624,4 +755,39 @@ mod tests {
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:A2", "top": true})).unwrap();
assert_eq!(r["created"], 1);
}
#[test]
fn names_referring_to_several_ranges() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap();
// Excel writes and accepts a union in a name without parentheses.
let r = s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap();
assert_eq!(r["refersTo"], "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2");
for (cell, input) in [("E1", "=SUM(u)"), ("E2", "=AREAS(u)"), ("E3", "=MIN(u)"), ("E4", "=INDEX(u,2,1,2)")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "E1"), json!(15.0));
assert_eq!(v(&mut s, "E2"), json!(2.0));
assert_eq!(v(&mut s, "E3"), json!(1.0));
assert_eq!(v(&mut s, "E4"), json!(7.0));
// Renaming the sheet follows every area, and the definition keeps Excel's form.
s.execute("sheet.rename", json!({"name": "Data"})).unwrap();
let name = s.doc().unwrap().wb.names.iter().find(|n| n.name == "u").map(|n| n.formula.clone());
assert_eq!(name.as_deref(), Some("Data!$A$1:$A$2,Data!$C$1:$C$2"));
assert_eq!(v(&mut s, "E1"), json!(15.0));
}
#[test]
fn name_manager_previews_a_name_over_several_ranges() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap();
s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap();
let list = s.execute("formulas.nameManager", json!({})).unwrap();
let u = list.as_array().and_then(|l| l.iter().find(|n| n["name"] == "u")).cloned().unwrap();
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
}
}
+247 -27
View File
@@ -2,9 +2,11 @@
//! conditional formatting (Home › Styles) and symbols.
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::sheet::{rewrite, shift_cell_features};
use super::*;
use crate::selection::current_region;
@@ -90,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Chart Properties",
["Chart Design"],
None,
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
has_doc,
chart_set
),
@@ -188,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Conditional Formatting",
["Home", "Styles"],
None,
"{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font..fill..}|preset: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}}",
"{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font?: {bold?, italic?, color?}, fill?: \"#FFC7CE\" | {pattern?, fg?, bg?}, borders?, num_fmt?} (colours \"#RRGGBB\") | preset?: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}; a text `value` is matched as text, or kept when already a quoted literal}",
has_doc,
add_cf
),
@@ -224,6 +226,17 @@ fn block(s: &Session, p: &Json) -> Result<RangeRef> {
Ok(current_region(sh, d.selection.active))
}
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
if p.get("range").is_some() {
return target_ranges(s, p);
}
let d = s.doc()?;
if d.selection.ranges.len() > 1 {
return Ok(d.selection.ranges.clone());
}
Ok(vec![block(s, p)?])
}
// ---------------------------------------------------------------- tables
fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
@@ -276,13 +289,13 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
}
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
sh.cell_pictures = std::mem::take(&mut sh.cell_pictures)
.into_iter()
.filter_map(|(c, v)| {
let dest = if c.col >= r.start.col && c.col <= r.end.col && c.row >= r.start.row { c.offset(1, 0) } else { Some(c) };
dest.map(|c| (c, v))
})
.collect();
// Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula
// reference (on any sheet) at the cells that moved.
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
shift_cell_features(sh, &e);
let name = sh.name.clone();
rewrite(&mut cx.wb, &name, &e);
let sh = cx.sheet_mut(sheet)?;
for (i, c) in (r.start.col..=r.end.col).enumerate() {
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
}
@@ -472,6 +485,7 @@ fn valid_table_name(name: &str) -> bool {
name.chars().count() <= 255
&& name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
&& CellRef::parse(name).is_none()
&& !super::formulas::is_r1c1(name)
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
}
@@ -481,7 +495,8 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
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)) {
let wb = &s.doc()?.wb;
if wb.table(&name).is_some_and(|x| x != (si, ti)) || wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
edit(s, |cx| {
@@ -604,6 +619,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
});
@@ -619,6 +635,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
});
@@ -627,6 +644,36 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
out
}
fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec<Series>)> {
let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) };
let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area));
if areas.len() == 1 {
let by_rows = first.width() > first.height() + 1;
return Ok((bounds, by_rows, series_from_range(sh, first, by_rows)));
}
let valid_shape = first.width() == 1
&& first.height() > 1
&& areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height())
&& first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty());
if !valid_shape {
return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns"));
}
let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values);
let mut series = Vec::with_capacity(areas.len() - 1);
for area in &areas[1..] {
let mut area_series = series_from_range(sh, *area, false);
let Some(mut item) = area_series.pop() else {
return Err(EngineError::Other("Select data for the chart first.".into()));
};
if item.name.is_none() {
return Err(bad("insert.chart", "multi-area series columns require headers"));
}
item.categories = category.clone();
series.push(item);
}
Ok((bounds, false, series))
}
/// A scatter plot of a block without category labels takes its first column (or row) as the X
/// values of the others, as Excel does: two plain columns make one series of points, not two (#428).
pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec<Series>) {
@@ -647,15 +694,14 @@ const MAX_CHART_CELLS: u64 = 1_000_000;
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
/// series → clustered and stacked columns and bars, line.
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
let r = block(s, p)?;
if r.count() > MAX_CHART_CELLS {
let areas = chart_areas(s, p)?;
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let by_rows = r.width() > r.height() + 1;
let series = series_from_range(sh, r, by_rows);
let (_, by_rows, series) = series_from_areas(sh, &areas)?;
let ranges: Vec<RangeRef> = series
.iter()
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
@@ -707,8 +753,8 @@ pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'st
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
/// goes on and the chart (previews in the Recommended Charts dialog).
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
let r = block(s, p)?;
if r.count() > MAX_CHART_CELLS {
let areas = chart_areas(s, p)?;
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let mut t = str_param(p, "type").unwrap_or("column").to_string();
@@ -721,18 +767,14 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// Excel plots series by the longer dimension (more rows → series in columns).
let data_h = r.height();
let data_w = r.width();
let by_rows = data_w > data_h + 1;
let kind = chart_kind(&t, &sub);
let mut series = series_from_range(sh, r, by_rows);
let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?;
if series.is_empty() {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
scatter_x_from_first(kind, &mut series);
let id = d.wb.next_object_id();
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2));
let title = str_param(p, "title").map(str::to_string).or_else(|| {
if series.len() == 1 {
series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display())
@@ -759,7 +801,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
style: 1,
x_title: None,
y_title: None,
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
by_rows,
pivot: None,
};
@@ -789,12 +831,25 @@ fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
let at = cell_param_on(s, p, "at", si)?;
let quartiles = p
.get("quartiles")
.map(|v| match v.as_str() {
Some("exclusive") => Ok(gridcraft_model::QuartileMethod::Exclusive),
Some("inclusive") => Ok(gridcraft_model::QuartileMethod::Inclusive),
_ => Err(bad("quartiles", "expected exclusive or inclusive")),
})
.transpose()?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
if let Some(t) = str_param(p, "type") {
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
}
if let Some(method) = quartiles {
for series in &mut c.series {
series.quartiles = method;
}
}
if let Some(t) = p.get("title") {
c.title = t.as_str().map(str::to_string);
}
@@ -978,7 +1033,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
// Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`).
mode: anchor_mode_param(p).unwrap_or(gridcraft_model::AnchorMode::MoveOnly),
},
data,
data: data.into(),
mime,
alt: str_param(p, "alt").unwrap_or("").into(),
};
@@ -1250,7 +1305,16 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
edit(s, |cx| {
cx.sheet_mut(sheet)?.comments.insert(
at,
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
Comment {
author: author.clone(),
text: text.clone(),
replies: vec![],
threaded,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
Ok(json!({"cell": at.a1()}))
})
@@ -1335,6 +1399,110 @@ fn preset_style(name: &str) -> Style {
s
}
/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled.
fn is_string_literal(v: &str) -> bool {
v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"'))
}
/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`,
/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be
/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a
/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than
/// dropped: dropped, it left a rule with an empty format that matches and shows nothing.
fn cf_style_json(st: &Json) -> Result<Json> {
const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"];
let err = |m: String| bad("home.conditionalFormat", m);
let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) };
let mut st = obj.clone();
if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) {
let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" };
return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", "))));
}
// A colour written the way the other formatting commands take it, as the model stores it.
let color = |v: &mut Json, what: &str| -> Result<()> {
if v.is_string() || v.get("theme").is_some() {
let c = super::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?;
*v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?;
}
Ok(())
};
if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut)
&& let Some(c) = f.get_mut("color")
{
color(c, "font.color")?;
}
if let Some(fill) = st.get_mut("fill") {
// `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one.
if fill.is_string() && super::format::color_param(Some(fill)) == Some(Color::Auto) {
*fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?;
} else if fill.is_string() || fill.get("theme").is_some() {
*fill = json!({"pattern": "Solid", "fg": fill.clone()});
}
if let Some(f) = fill.as_object_mut() {
for k in ["fg", "bg"] {
if let Some(c) = f.get_mut(k) {
color(c, &format!("fill.{k}"))?;
}
}
// A colour with no pattern is a solid fill, not an invisible one.
if f.contains_key("fg") && !f.contains_key("pattern") {
f.insert("pattern".into(), json!("Solid"));
}
}
}
if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) {
for (side, line) in b.iter_mut() {
if let Some(c) = line.get_mut("color") {
color(c, &format!("borders.{side}.color"))?;
}
}
}
Ok(Json::Object(st))
}
/// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open
/// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog
/// (which dispatches this command) share one gate, the way Excel's object model refuses.
fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> {
// Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1'
// only parses after several strips and would still be saved unparseable. (#1999 review)
if gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() {
return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file")));
}
Ok(())
}
fn check_cf_value(what: &str, v: &gridcraft_model::features::CfValueKind) -> Result<()> {
if let gridcraft_model::features::CfValueKind::Formula(f) = v {
check_cf_formula("home.conditionalFormat", what, f)?;
}
Ok(())
}
fn check_cf_rule(rule: &CfRule) -> Result<()> {
const CMD: &str = "home.conditionalFormat";
match rule {
CfRule::CellIs { a, b, .. } => {
check_cf_formula(CMD, "comparison value", a)?;
if let Some(b) = b {
check_cf_formula(CMD, "second comparison value", b)?;
}
}
CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?,
CfRule::DataBar { min, max, .. } => {
check_cf_value("data bar minimum", min)?;
check_cf_value("data bar maximum", max)?;
}
CfRule::ColorScale { stops } => {
for (kind, _) in stops {
check_cf_value("color scale threshold", kind)?;
}
}
_ => {}
}
Ok(())
}
fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let sheet = target_sheet(s, p)?;
@@ -1344,9 +1512,10 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
};
let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string);
let style = Box::new(match rule.get("style") {
Some(st) if st.is_object() => {
Some(st) if !st.is_null() => {
let st = cf_style_json(st)?;
let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?;
serde_json::from_value(super::format::merge_json(base, st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
serde_json::from_value(super::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
}
_ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")),
});
@@ -1385,8 +1554,12 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
let b = rule.get("value2").map(super::edit::json_to_input);
let quote = |v: String| {
// A number, a formula, or text already written as a string literal (`"Closed"`, as
// in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted.
if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') {
v.trim_start_matches('=').to_string()
} else if is_string_literal(&v) {
v
} else {
format!("\"{}\"", v.replace('"', "\"\""))
}
@@ -1443,6 +1616,7 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
},
other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))),
};
check_cf_rule(&rule)?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1;
@@ -1554,4 +1728,50 @@ mod tests {
let rules = s.execute("home.manageRules", json!({})).unwrap();
assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0));
}
#[test]
fn table_without_header_keeps_outside_references() {
// #1030: the data moved down for the new header row, but formulas outside kept the old addresses.
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "20"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A2)"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=A2"})).unwrap();
s.execute("cell.set", json!({"sheet": "Two", "cell": "A1", "input": "=Sheet1!A2"})).unwrap();
s.execute("insert.table", json!({"range": "A1:A2", "header": false})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], "Column1");
assert_eq!(get(&mut s, "A2")["value"], json!(10.0));
assert_eq!(get(&mut s, "B1")["formula"], "=SUM(A2:A3)");
assert_eq!(get(&mut s, "B1")["value"], json!(30.0));
assert_eq!(get(&mut s, "C1")["formula"], "=A3");
assert_eq!(get(&mut s, "C1")["value"], json!(20.0));
let other = s.execute("cell.get", json!({"sheet": "Two", "cell": "A1"})).unwrap();
assert_eq!(other["formula"], "=Sheet1!A3");
assert_eq!(other["value"], json!(20.0));
}
#[test]
fn table_names_refuse_defined_names_and_r1c1() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:A2", "values": [["h"], ["1"]]})).unwrap();
s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.2"})).unwrap();
s.execute("insert.table", json!({"range": "A1:A2"})).unwrap();
let e = s.execute("table.rename", json!({"table": "Table1", "name": "Rate"})).unwrap_err();
assert!(e.to_string().contains("already exists"), "{e}");
let e = s.execute("table.rename", json!({"table": "Table1", "name": "rate"})).unwrap_err();
assert!(e.to_string().contains("already exists"), "{e}");
for n in ["R2C3", "c", "r", "RC", "C5"] {
let e = s.execute("table.rename", json!({"table": "Table1", "name": n})).unwrap_err();
assert!(e.to_string().contains("not valid"), "{n}: {e}");
let e = s.execute("insert.table", json!({"range": "C1:C2", "name": n})).unwrap_err();
assert!(e.to_string().contains("not valid"), "{n}: {e}");
}
s.execute("table.rename", json!({"table": "Table1", "name": "Table1"})).unwrap();
s.execute("table.rename", json!({"table": "Table1", "name": "Rates"})).unwrap();
}
}
+1 -1
View File
@@ -131,7 +131,7 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
"text": crate::display::cell_text(&d.wb, sh, c),
"input": sh.input_text(c),
"picture": sh.cell_pictures.get(&c).map(|picture| json!({"mime": picture.mime, "alt": picture.alt, "bytes": picture.data.len()})),
"spilledFrom": sh.spill_ranges.iter().find(|(a, r)| **a != c && r.contains(c)).map(|(a, _)| a.a1()),
"spilledFrom": sh.spill_anchor(c).map(|a| a.a1()),
"spill": sh.spill_ranges.get(&c).map(|r| r.a1()),
"style": style,
"merge": sh.merge_at(c).map(|m| m.a1()),
+70 -22
View File
@@ -273,6 +273,11 @@ pub(crate) fn target_ranges(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
Ok(s.doc()?.selection.ranges.clone())
}
/// A `rows`/`cols` parameter: `3:5` and `B:C`, or a single `3` or `B`.
pub(crate) fn parse_lines(t: &str) -> Option<RangeRef> {
RangeRef::parse(t).or_else(|| if t.contains(':') { None } else { RangeRef::parse(&format!("{t}:{t}")) })
}
pub(crate) fn target_range(s: &Session, p: &Json) -> Result<RangeRef> {
Ok(target_ranges(s, p)?.first().copied().unwrap_or_default())
}
@@ -296,13 +301,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
Ok(d.wb.active_sheet)
}
/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`).
pub(crate) fn split_sheet(r: &str) -> (Option<String>, &str) {
match r.rsplit_once('!') {
Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body),
None => (None, r),
}
}
pub(crate) use gridcraft_core::split_sheet;
/// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
@@ -431,6 +430,18 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)
Ok(())
}
/// Refuses an action a protected sheet does not allow, by the tick box for it in Review ▸ Protect
/// Sheet. `what` names the action in the message. An unprotected sheet allows everything.
///
/// Cell edits are guarded separately, by whether the cell is locked (`locked_cell_error`): these
/// permissions are the ones that are not about one cell's contents.
pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick: fn(&gridcraft_model::SheetProtection) -> bool) -> Result<()> {
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pick(pr))) {
return Err(EngineError::Other(format!("{what} isn't allowed on this protected sheet (Review › Unprotect Sheet).")));
}
Ok(())
}
/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
/// Range without a password or one unlocked in this session, else the message to show.
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
@@ -490,18 +501,53 @@ fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::C
}
}
/// Recomputes an automatic row height from its content: the largest font, and wrapped or
/// multi-line text. Rows with a user-set height keep it.
/// Lines a single text line needs when word-wrapped into `width` points. The grid wraps whole
/// words (egui's LayoutJob), so dividing the total width by the column width undercounts: each
/// drawn line ends early at the last word that fits. A word wider than the column breaks
/// mid-word, the way egui does. (#1969)
fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 {
let word_w = |w: &str| crate::cmd::format::approx_text_width(w, size, bold);
let space_w = word_w(" ");
let mut lines = 1.0f32;
let mut used = 0.0f32;
for word in line.split_whitespace() {
let w = word_w(word);
if w > width {
// wider than the column: egui fills the current line, then breaks it across lines
if used > 0.0 {
lines += 1.0;
}
let chunks = (w / width).ceil();
lines += chunks - 1.0;
used = w - (chunks - 1.0) * width;
continue;
}
let gap = if used > 0.0 { space_w } else { 0.0 };
if used + gap + w > width {
lines += 1.0;
used = w;
} else {
used += gap + w;
}
}
lines
}
/// Recomputes an automatic row height from its content: the largest font, and wrapped text.
/// Rows with a user-set height keep it.
///
/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off
/// a multi-line value shows on one line and AutoFit gives a one-line row.
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
let Some(sh) = wb.sheet(si) else { return };
if sh.rows.get(&row).is_some_and(|i| i.custom) {
return;
}
let default = sh.default_row_height;
// Fast path: rows of default-size, unwrapped, single-line content keep the default height.
// Fast path: rows of default-size, unwrapped content keep the default height.
let plain = sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1).all(|(_, cell)| {
let st = wb.styles.get(cell.style);
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap && !cell.value.as_text().is_some_and(|t| t.contains('\n'))
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap
});
if plain {
if sh.rows.get(&row).is_some_and(|i| i.size.is_some())
@@ -519,22 +565,24 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
}
let mut need: f32 = default;
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
continue;
}
let st = wb.styles.get(cell.style);
let size = st.font.size;
let line = (size * 1.8).round().max(default * size / gridcraft_model::DEFAULT_FONT_SIZE);
// Scaled from an 11pt row's height, not the sheet's default: a sheet whose default
// already fits a bigger font (21pt rows for 16pt text) would otherwise scale it twice.
let line = (size * 1.8).round().max(gridcraft_model::DEFAULT_ROW_HEIGHT * size / gridcraft_model::DEFAULT_FONT_SIZE);
let mut lines = 1.0f32;
if !cell.value.is_empty() || cell.formula.is_some() {
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
let explicit = text.lines().count().max(1) as f32;
lines = if st.align.wrap {
// Only a wrapping cell is laid out over several lines: each explicit line break
// starts one, and each line folds at the column width.
if st.align.wrap {
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
let w = sh.col_width(c).max(1.0) - 6.0;
text.lines()
.map(|l| (crate::cmd::format::approx_text_width(l, size, st.font.bold) / w.max(1.0)).ceil().max(1.0))
.sum::<f32>()
.max(1.0)
} else {
explicit
};
lines = text.lines().map(|l| wrapped_line_count(l, w.max(1.0), size, st.font.bold)).sum::<f32>().max(1.0);
}
} else if st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE {
continue;
}
+50 -1
View File
@@ -1392,7 +1392,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
let (sheet, addr) = match t.rsplit_once('!') {
Some((sh, a)) => {
let sh = sh.trim();
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
let sh = gridcraft_core::unquote_sheet(sh);
(d.wb.sheet_index(&sh), a)
}
None => (Some(d.wb.active_sheet), t),
@@ -2314,4 +2314,53 @@ mod tests {
// 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();
}
#[test]
fn inserting_or_deleting_lines_moves_the_pivot_with_its_cells() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H10", "rows": ["Region"], "values": ["Sales"]}))
.unwrap();
assert_eq!(pt(&s).last_range.unwrap().a1(), "H10:I14");
// Rows above the report (and below the source) move the report only.
s.execute("home.insertRows", json!({"rows": "16:17"})).unwrap();
s.execute("home.insertRows", json!({"rows": "6:7"})).unwrap();
assert_eq!(text(&s, "H12"), "Row Labels");
let l = s.execute("pivot.list", json!({})).unwrap();
assert_eq!(l[0]["anchor"], "H12");
assert_eq!(l[0]["range"], "H12:I16");
assert_eq!(l[0]["source"], "Sheet1!A1:E15");
// A row above the source moves both.
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
let p = pt(&s);
assert_eq!(p.source, "Sheet1!A2:E16");
assert_eq!(p.anchor.a1(), "H13");
s.execute("pivot.refresh", json!({})).unwrap();
assert_eq!(pt(&s).last_range.unwrap().a1(), "H13:I17");
assert_eq!(text(&s, "H13"), "Row Labels");
assert_eq!(val(&s, "I17"), Value::Number(TOTAL));
// Deleting a column left of the report moves it left.
s.execute("home.deleteColumns", json!({"cols": "F:F"})).unwrap();
let p = pt(&s);
assert_eq!(p.anchor.a1(), "G13");
assert_eq!(p.last_range.unwrap().a1(), "G13:H17");
// Deleting rows above moves it up.
s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap();
assert_eq!(pt(&s).source, "Sheet1!A1:E15");
assert_eq!(pt(&s).anchor.a1(), "G12");
s.execute("pivot.refresh", json!({})).unwrap();
assert_eq!(text(&s, "G12"), "Row Labels");
assert_eq!(val(&s, "H16"), Value::Number(TOTAL));
}
#[test]
fn inserting_rows_on_the_source_sheet_moves_another_sheets_pivot_source() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap();
let l = s.execute("pivot.list", json!({})).unwrap();
assert_eq!(l[0]["source"], "Sheet1!A3:E15");
assert_eq!(l[0]["anchor"], "A3");
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
}
}
+210 -20
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Page Setup…",
["Page Layout", "Page Setup"],
None,
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|Tabloid|Executive|Ledger|B5, margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings",
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings",
has_doc,
page_setup
),
@@ -71,19 +71,36 @@ pub fn paper_size(name: &str) -> (f32, f32) {
known_paper(name).unwrap_or((612.0, 792.0))
}
/// Paper sizes in points, portrait. `B4`/`B5`/`B6` are the **JIS** sizes Japanese Excel and its
/// e-mail attachments use (257×364, 182×257 and 128×182 mm); the ISO B series stays reachable as
/// `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. The Japanese postcards and envelopes are the JIS ones too.
fn known_paper(name: &str) -> Option<(f32, f32)> {
let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_'], "");
// Spaces, hyphens, underscores and parentheses don't matter: `B5 (JIS)`, `b5-jis` and `b5`
// are one name.
let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], "");
Some(match n.as_str() {
"letter" | "usletter" => (612.0, 792.0),
"legal" | "uslegal" => (612.0, 1008.0),
"a4" => (595.28, 841.89),
"a3" => (841.89, 1190.55),
"a5" => (419.53, 595.28),
"a6" => (297.64, 419.53),
"tabloid" | "11x17" => (792.0, 1224.0),
"ledger" => (1224.0, 792.0),
"executive" => (522.0, 756.0),
"b5" => (498.9, 708.66),
"b4" => (708.66, 1000.63),
"b4" | "b4jis" => (728.50, 1031.81),
"b5" | "b5jis" => (515.91, 728.50),
"b6" | "b6jis" => (362.83, 515.91),
"b4iso" => (708.66, 1000.63),
"b5iso" => (498.90, 708.66),
"b6iso" => (354.33, 498.90),
"japanesepostcard" | "postcard" => (283.46, 419.53),
"japanesedoublepostcard" | "doublepostcard" => (419.53, 566.93),
"japaneseenvelope#3" | "chou#3" => (340.16, 666.14),
// 長形4号 is 90×205 mm; 90×225 mm is 長形40号, a different envelope, so it stays out.
"japaneseenvelope#4" | "chou#4" => (255.12, 581.10),
"japaneseenvelopekaku#2" | "kaku#2" => (680.31, 941.10),
"japaneseenvelopekaku#3" | "kaku#3" => (612.28, 785.20),
_ => return None,
})
}
@@ -604,20 +621,6 @@ fn rgb_of(c: Color, wb: &Workbook) -> Option<Rgb> {
c.resolve(&wb.theme)
}
fn numfmt_rgb(c: gridcraft_numfmt::FormatColor) -> Rgb {
use gridcraft_numfmt::FormatColor as F;
match c {
F::Black | F::Indexed(_) => [0, 0, 0],
F::Blue => [0, 0, 255],
F::Cyan => [0, 255, 255],
F::Green => [0, 128, 0],
F::Magenta => [255, 0, 255],
F::Red => [255, 0, 0],
F::White => [255, 255, 255],
F::Yellow => [255, 255, 0],
}
}
fn blend(fg: [u8; 4]) -> Rgb {
let a = fg[3] as u16;
let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8;
@@ -1000,6 +1003,62 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::
pg.restore();
}
struct PendingPdfLink {
page: usize,
sheet: usize,
rect: (f32, f32, f32, f32),
target: String,
}
struct PrintedBlock {
page: usize,
sheet: usize,
rows: (u32, u32),
cols: (u32, u32),
x: f32,
y: f32,
k: f32,
}
/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page
/// block before adding a clickable rectangle. A repeated print-title cell may appear on several
/// pages and receives one annotation on each of them.
fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec<PendingPdfLink>) {
let range = b.range();
let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return };
for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) {
if !range.contains(*cell) {
continue;
}
let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell));
let (x, y, w, h) = b.rect(cell_range);
let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom));
if x1 <= x0 || y1 <= y0 {
continue;
}
let rect = (x0, y0, x1 - x0, y1 - y0);
match crate::links::classify(&link.target) {
crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri),
crate::links::LinkKind::Internal(target) if !link.external => {
pending.push(PendingPdfLink { page, sheet: b.si, rect, target });
}
_ => {}
}
}
}
fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> {
let target = target.trim().trim_start_matches('#').trim_start_matches('=');
let (sheet_name, range) = split_sheet(target);
let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?;
let cell = RangeRef::parse(&range.replace('$', ""))?.start;
let sh = wb.sheet(sheet)?;
let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?;
let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k;
let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k;
Some((block.page, x, y))
}
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
let (x, y, w, h) = rect;
@@ -1065,7 +1124,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
let avail = (w - 2.0 * pad - indent).max(0.0);
let code = st.num_fmt.as_str();
let formatted = crate::display::format(&v, code, wb);
let mut color = formatted.color.map(numfmt_rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]);
let mut color = formatted.color.and_then(gridcraft_numfmt::FormatColor::rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]);
if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) {
color = rgb_of(st.font.color, wb).unwrap_or(color);
}
@@ -1467,6 +1526,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let total: usize = layouts.iter().map(|l| l.pages.len()).sum();
let (date, time) = hf_date_time(wb);
let mut page_no = 0usize;
let mut pending_links = Vec::new();
let mut printed_blocks = Vec::new();
for lay in layouts {
let Some(sh) = wb.sheet(lay.sheet) else { continue };
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
@@ -1481,6 +1542,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let content_h = (lay.heading_h + rsegs.iter().map(|s| seg_size(sh, *s, true)).sum::<f32>()) * k;
let ox = lay.margins[0] + if lay.settings.center_h { ((aw - content_w) / 2.0).max(0.0) } else { 0.0 };
let oy = lay.margins[2] + if lay.settings.center_v { ((ah - content_h) / 2.0).max(0.0) } else { 0.0 };
let page_index = doc.page_count();
let pg = doc.add_page(lay.page_w, lay.page_h);
let mut y = oy + lay.heading_h * k;
let mut body: Option<Block> = None;
@@ -1489,6 +1551,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
for (csi, cs) in csegs.iter().enumerate() {
let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);
draw_block(pg, &b, lay.settings.gridlines, &mut cf);
draw_block_links(pg, &b, page_index, &mut pending_links);
printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });
if lay.settings.headings {
draw_headings(pg, &b, rsi == 0, csi == 0, (ox, oy), (lay.heading_w * k, lay.heading_h * k));
}
@@ -1519,6 +1583,14 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx);
}
}
for link in pending_links {
if let Some((target_page, x, y)) = printed_target(wb, &printed_blocks, link.sheet, &link.target)
&& let Some(page) = doc.page_mut(link.page)
{
let (lx, ly, w, h) = link.rect;
page.link_page(lx, ly, w, h, target_page, x, y);
}
}
let n = doc.page_count();
(doc.finish(), n)
}
@@ -1764,6 +1836,29 @@ mod tests {
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
}
#[test]
fn pdf_export_keeps_safe_links_on_their_printed_pages() {
let mut s = s();
s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap();
s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!A60"})).unwrap();
s.execute("insert.link", json!({"cell": "A3", "target": "file:///private/report"})).unwrap();
s.execute("cell.set", json!({"cell": "A60", "input": "Destination"})).unwrap();
let (pdf, pages) = pdf_of(&mut s, json!({}));
assert_eq!(pages, 2);
let text = String::from_utf8_lossy(&pdf);
assert_eq!(text.matches("/Subtype /Link").count(), 2);
assert!(text.contains("/URI (https://example.org/report)"));
assert!(text.contains("/Dest [") && text.contains("/XYZ "));
assert!(!text.contains("/URI (file:"));
// A link to a cell omitted by the selected print range must not point to a nonexistent
// destination. The external link in the selected range remains clickable.
let (cropped, _) = pdf_of(&mut s, json!({"range": "A1:A3"}));
let cropped = String::from_utf8_lossy(&cropped);
assert_eq!(cropped.matches("/Subtype /Link").count(), 1);
assert!(!cropped.contains("/Dest ["));
}
#[test]
fn header_sections_split_join_and_field_names() {
let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D";
@@ -1899,6 +1994,16 @@ mod tests {
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
}
#[test]
fn pdf_prints_cjk_text_instead_of_question_marks() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["签字清单", 2]]})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(!contains(&pdf, "(????) Tj"), "CJK cell printed as (????) Tj");
assert!(contains(&pdf, "<7B7E5B576E055355> Tj"));
assert!(contains(&pdf, "(2) Tj"));
}
#[test]
fn pdf_contains_text_and_header_codes_expand() {
let mut s = s();
@@ -1952,6 +2057,16 @@ mod tests {
assert!(contains(&pdf, "(4.00) Tj") && !contains(&pdf, "(4.0) Tj"), "formatted cell under the rule");
}
#[test]
fn pdf_uses_indexed_number_format_palette_color() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
s.execute("home.numberFormat", json!({"range": "A1", "format": "[Color4]0"})).unwrap();
let (pdf, pages) = pdf_of(&mut s, json!({}));
assert_eq!(pages, 1);
assert!(contains(&pdf, "0 1 0 rg"), "expected indexed Color4 green in PDF text stream");
}
#[test]
fn export_writes_file() {
let mut s = s();
@@ -2020,7 +2135,7 @@ mod tests {
wb.sheet_mut(0).unwrap().images.push(Image {
id,
anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize },
data: png,
data: png.into(),
mime: "image/png".into(),
alt: String::new(),
});
@@ -2061,6 +2176,81 @@ mod tests {
assert!(s.doc().unwrap().wb.active().unwrap().print.print_area.is_some());
}
/// The Japanese paper sizes are the JIS ones: `B4`/`B5`/`B6` changed meaning from ISO, and the
/// ISO B series stays reachable by name. The postcards and envelopes are the JIS sizes too.
#[test]
fn japanese_paper_sizes_are_jis() {
for (name, w, h) in [
("A4", 595.28, 841.89), // unchanged
("B4", 728.50, 1031.81),
("B5", 515.91, 728.50),
("B6", 362.83, 515.91),
("B4 (JIS)", 728.50, 1031.81),
("b5-jis", 515.91, 728.50),
("B4 (ISO)", 708.66, 1000.63),
("B5 (ISO)", 498.90, 708.66),
("B6 (ISO)", 354.33, 498.90),
("A6", 297.64, 419.53),
("Japanese Postcard", 283.46, 419.53),
("japanese double postcard", 419.53, 566.93),
("Japanese Envelope #3", 340.16, 666.14),
("chou #4", 255.12, 581.10),
("Japanese Envelope Kaku #2", 680.31, 941.10),
("kaku #3", 612.28, 785.20),
] {
let got = paper_size(name);
assert!((got.0 - w).abs() < 0.01 && (got.1 - h).abs() < 0.01, "{name}: {got:?}");
}
// 長形4号 is 90×205 mm (90×225 mm is 長形40号), so its height is 205 mm = 581.10 pt — not the
// 225 mm it once had.
let chou4 = paper_size("Japanese Envelope #4");
assert_eq!((chou4.0, chou4.1), (255.12, 581.10));
assert_eq!(chou4, paper_size("chou #4"));
// Unknown names keep the old Letter fallback.
assert_eq!(paper_size("Napkin"), (612.0, 792.0));
}
/// Page Setup's paper reaches the PDF page: the MediaBox is the paper's, A4 is unchanged, B5 is
/// JIS B5, and the Japanese postcard prints at 283.46×419.53 pt. Landscape swaps the sides and
/// fit-to-page keeps the paper size.
#[test]
fn page_setup_paper_sizes_reach_the_pdf_page() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["x"]]})).unwrap();
let close = |v: &Json, want: f64| (v.as_f64().unwrap() - want).abs() < 0.01;
for (paper, w, h) in [
("A4", 595.28f64, 841.89f64),
("B5", 515.91, 728.50),
("Japanese Postcard", 283.46, 419.53),
("Japanese Double Postcard", 419.53, 566.93),
("Japanese Envelope Kaku #2", 680.31, 941.10),
] {
s.execute("pageLayout.pageSetup", json!({"paper": paper, "orientation": "portrait"})).unwrap();
let pv = s.execute("file.printPreview", json!({})).unwrap();
let page = &pv["pageList"][0];
assert_eq!(page["paper"], paper);
assert!(close(&page["width"], w) && close(&page["height"], h), "{paper}: {page}");
let (pdf, pages) = pdf_of(&mut s, json!({}));
assert_eq!(pages, 1);
let want = format!("/MediaBox [0 0 {w} {h}]");
assert!(contains(&pdf, &want), "{paper}: wanted {want}");
}
// Landscape swaps the sides, and fit-to-page does not change the paper.
s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "landscape"})).unwrap();
let (pdf, pages) = pdf_of(&mut s, json!({}));
assert_eq!(pages, 1);
assert!(contains(&pdf, "/MediaBox [0 0 728.5 515.91]"));
s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "portrait"})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({"fitToPage": true}));
assert!(contains(&pdf, "/MediaBox [0 0 515.91 728.5]"));
// `pageLayout.size` still takes any name, and an unknown one prints on Letter as before.
s.execute("pageLayout.size", json!({"paper": "Napkin"})).unwrap();
let pv = s.execute("file.printPreview", json!({})).unwrap();
assert!(close(&pv["pageList"][0]["width"], 612.0) && close(&pv["pageList"][0]["height"], 792.0));
// Page Setup refuses an unknown name.
assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err());
}
#[test]
fn hostile_settings_never_panic_and_cap_pages() {
let mut s = s();
+52 -2
View File
@@ -139,11 +139,15 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into()));
}
let b = |k: &str| bool_param(p, k).unwrap_or(false);
// Selection rights default to what the sheet has now (a file may forbid selecting locked cells).
let current = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.clone());
let select_locked = bool_param(p, "selectLockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_locked));
let select_unlocked = bool_param(p, "selectUnlockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_unlocked));
let prot = SheetProtection {
password: None,
legacy_password_hash: None,
select_locked: true,
select_unlocked: true,
select_locked,
select_unlocked,
format_cells: b("formatCells"),
format_columns: b("formatColumns"),
format_rows: b("formatRows"),
@@ -399,6 +403,26 @@ mod tests {
assert_eq!(at(&s), (0, "A1".to_string()));
}
#[test]
fn protection_that_forbids_selecting_locked_cells_is_enforced_and_kept() {
let mut s = book();
s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
// Locked cells can't be selected; unlocked ones can.
assert!(s.execute("selection.set", json!({"cell": "A1"})).is_err());
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
// Arrow keys skip locked cells, and stay put when nothing selectable is left.
s.execute("selection.move", json!({"dr": 0, "dc": 1})).unwrap();
assert_eq!(s.doc().unwrap().selection.active.a1(), "B2");
// Re-protecting without the flag keeps the sheet's selection rights.
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
s.execute("review.protectSheet", json!({"sort": true})).unwrap();
assert!(!s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap().select_locked);
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
}
#[test]
fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() {
let mut s = book();
@@ -426,6 +450,32 @@ mod tests {
s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap();
}
#[test]
fn protected_structure_blocks_rename_move_copy_hide_and_unhide_sheet() {
let mut s = book();
let names = |s: &Session| s.doc().unwrap().wb.sheets.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
let before = names(&s);
s.execute("review.protectWorkbook", json!({})).unwrap();
assert!(s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).is_err());
assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1})).is_err());
assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).is_err());
assert!(s.execute("sheet.hide", json!({"sheet": 1})).is_err());
assert_eq!(names(&s), before);
assert!(s.doc().unwrap().wb.sheets.iter().all(|x| x.visibility == Visibility::Visible));
s.execute("review.protectWorkbook", json!({})).unwrap();
s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap();
s.execute("sheet.move", json!({"sheet": 0, "to": 1})).unwrap();
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), 3);
s.execute("sheet.hide", json!({"sheet": 1})).unwrap();
s.execute("review.protectWorkbook", json!({})).unwrap();
assert!(s.execute("sheet.unhide", json!({"sheet": 1})).is_err());
assert!(s.execute("sheet.unhide", json!({})).is_err());
assert_eq!(s.doc().unwrap().wb.sheets[1].visibility, Visibility::Hidden);
s.execute("review.protectWorkbook", json!({})).unwrap();
s.execute("sheet.unhide", json!({"sheet": 1})).unwrap();
}
#[test]
fn protect_workbook_with_a_password_really_verifies() {
let mut s = book();
+60 -15
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn lines(s: &Session, p: &Json, axis: Axis) -> Result<(u32, u32)> {
let key = if axis == Axis::Rows { "rows" } else { "cols" };
let r = match str_param(p, key).or_else(|| str_param(p, "range")) {
Some(t) => RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?,
Some(t) => parse_lines(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?,
None => s.doc()?.selection.current(),
};
let (a, b) = if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) };
@@ -211,10 +211,19 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
if let Some(pa) = sh.print.print_area {
sh.print.print_area = map_range(pa);
}
// PivotTables follow their report cells. A deleted anchor line collapses onto the line that
// takes its place; a report deleted entirely has no last area until the next refresh.
for pt in sh.pivots.iter_mut() {
pt.anchor = map_cell(pt.anchor).unwrap_or_else(|| match axis {
Axis::Rows => CellRef::new(at, pt.anchor.col),
Axis::Cols => CellRef::new(pt.anchor.row, at),
});
pt.last_range = pt.last_range.and_then(map_range);
}
}
/// Rewrites every formula reference (and chart series) for a structural edit on `target`.
fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
super::edit::rewrite_all_formulas(wb, target, e);
for si in 0..wb.sheets.len() {
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
@@ -226,6 +235,8 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
}
};
for ch in sh.charts.iter_mut() {
// The source block moves with its cells; one that was deleted is dropped.
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
for se in ch.series.iter_mut() {
se.values = fix(&se.values);
se.categories = se.categories.as_deref().map(fix);
@@ -235,6 +246,18 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
for sp in sh.sparklines.iter_mut() {
sp.source = fix(&sp.source);
}
// PivotTable sources (a range, table or defined name) are only rewritten when the edit
// moves them, so table and name sources keep their text.
for pt in sh.pivots.iter_mut() {
let body = pt.source.trim().trim_start_matches('=');
if let Ok(expr) = gridcraft_formula::parse(body) {
let before = gridcraft_formula::print(&expr);
let after = gridcraft_formula::print(&gridcraft_formula::adjust::adjust(expr, &host, target, e));
if after != before {
pt.source = after;
}
}
}
}
}
@@ -306,7 +329,7 @@ fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result<Vec<(u32, u32)>> {
} else if let Some(t) = str_param(p, key).or_else(|| str_param(p, "range")) {
t.split(',')
.map(|part| {
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect::<Result<_>>()?
@@ -469,6 +492,15 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
if let Some(sh) = s.doc()?.wb.sheet(sheet) {
check_table_shift(sh, &e)?;
// Refuse to push data off the sheet: cells in the last `width`/`height` positions would be dropped.
let doomed = if shift == "right" {
RangeRef::new(CellRef::new(r.start.row, r.start.col.max(MAX_COLS.saturating_sub(r.width()))), CellRef::new(r.end.row, MAX_COLS - 1))
} else {
RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
};
if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
}
}
let right = shift == "right";
let out = edit(s, |cx| {
@@ -750,7 +782,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
*sc -= 1;
}
// Names referring to the sheet become #REF!.
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order);
if ne != e {
n.formula = gridcraft_formula::print(&ne);
@@ -771,6 +803,9 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
let i = target_sheet(s, p)?;
let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else { return Err(bad("sheet.rename", "missing `name`")) };
s.doc()?.wb.check_sheet_name(&name, Some(i)).map_err(EngineError::Other)?;
@@ -812,7 +847,7 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
for n in cx.wb.names.iter_mut() {
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name));
}
}
@@ -822,6 +857,9 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
let i = target_sheet(s, p)?;
let copy = bool_param(p, "copy").unwrap_or(false);
let to = p.get("to").and_then(Json::as_u64).map(|v| v as usize).ok_or_else(|| bad("sheet.move", "missing `to`"))?;
@@ -851,7 +889,7 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
.iter()
.filter(|nm| nm.scope == Some(i))
.map(|nm| {
let formula = gridcraft_formula::parse(&nm.formula)
let formula = gridcraft_formula::parse_name(&nm.formula)
.map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &base, &sh.name)))
.unwrap_or_else(|_| nm.formula.clone());
gridcraft_model::DefinedName { scope: Some(at), formula, ..nm.clone() }
@@ -899,6 +937,9 @@ fn tab_color(s: &mut Session, p: &Json) -> Result<Json> {
}
fn hide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
let i = target_sheet(s, p)?;
if s.doc()?.wb.sheets.iter().filter(|s| s.visibility == Visibility::Visible).count() <= 1 {
return Err(EngineError::Other("A workbook must contain at least one visible worksheet.".into()));
@@ -913,6 +954,9 @@ fn hide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
fn unhide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
if p.get("sheet").is_none() {
let hidden: Vec<String> = s.doc()?.wb.sheets.iter().filter(|s| s.visibility != Visibility::Visible).map(|s| s.name.clone()).collect();
if hidden.is_empty() {
@@ -936,17 +980,18 @@ fn activate(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"sheet": i, "name": d.wb.active().map(|s| s.name.clone())}))
}
fn step_sheet(s: &mut Session, dir: i64) -> Result<Json> {
fn step_sheet(s: &mut Session, dir: isize) -> Result<Json> {
let d = s.doc()?;
let n = d.wb.sheets.len() as i64;
let mut i = d.wb.active_sheet as i64;
for _ in 0..n {
i = (i + dir).clamp(0, n - 1);
if d.wb.sheet(i as usize).is_some_and(|s| s.visibility == Visibility::Visible) {
break;
let mut i = d.wb.active_sheet;
while let Some(next) = i.checked_add_signed(dir) {
let Some(sheet) = d.wb.sheet(next) else { break };
if sheet.visibility == Visibility::Visible {
s.doc_mut()?.wb_switch_sheet(next);
return ok();
}
i = next;
}
s.doc_mut()?.wb_switch_sheet(i as usize);
// No visible sheet in this direction: keep the current sheet and its selection.
ok()
}
@@ -967,7 +1012,7 @@ pub(crate) fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
/// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional
/// format, validation, table, filter and print-area ranges, notes, links and sparklines.
fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
pub(super) fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start);
sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect();
for cf in sh.cond_formats.iter_mut() {
+14 -4
View File
@@ -97,11 +97,21 @@ fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) {
}
fn insert_slicer(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
// A table named by `table` is found on any sheet, as the other table commands find it, and the slicer goes on that
// table's sheet. Looked up on the active sheet only, a named table on another sheet gave "Select a cell inside a
// table first, or pass `table`" although `table` was passed.
let named = match str_param(p, "table") {
Some(name) => Some(s.doc()?.wb.table(name).ok_or_else(|| bad("insert.slicer", format!("no table named `{name}`")))?),
None => None,
};
let sheet = match named {
Some((si, _)) => si,
None => target_sheet(s, p)?,
};
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let t = match str_param(p, "table") {
Some(name) => sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name)),
let t = match named {
Some((_, ti)) => sh.tables.get(ti),
None => sh.table_at(d.selection.active),
}
.ok_or_else(|| EngineError::Other("Select a cell inside a table first, or pass `table`.".into()))?;
@@ -119,7 +129,7 @@ fn insert_slicer(s: &mut Session, p: &Json) -> Result<Json> {
}
};
let table = t.name.clone();
let at = cell_param(s, p, "at")?
let at = cell_param_on(s, p, "at", sheet)?
.unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
let columns = u32_param(p, "columns").unwrap_or(1).clamp(1, 10);
let width = f64_param(p, "width").unwrap_or(168.0).clamp(60.0, 10000.0) as f32;
+201 -56
View File
@@ -1863,13 +1863,11 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
Ok(out)
}
/// Writes the reports on new sheets ("Answer Report 1"…) before the model's sheet, in one step;
/// the model's sheet stays active. Returns the new sheets' names.
fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<String>> {
/// Builds every report before the solution or saved model can change.
fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<(String, Lines)>> {
if kinds.is_empty() {
return Ok(Vec::new());
}
let d = s.doc()?;
if d.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
@@ -1886,41 +1884,48 @@ fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Re
.ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?;
built.push((name, lines));
}
Ok(built)
}
/// Inserts prepared reports before the model's sheet, which stays active.
fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec<String> {
let names: Vec<String> = built.iter().map(|(n, _)| n.clone()).collect();
let at = inp.model.sheet;
let count = built.len();
edit(s, |cx| {
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
for (i, (name, lines)) in built.into_iter().enumerate() {
let mut sheet = Sheet::new(name);
for (r, c, v, b) in lines {
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
}
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
}
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
if count == 0 {
return names;
}
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
for (i, (name, lines)) in built.into_iter().enumerate() {
let mut sheet = Sheet::new(name);
for (r, c, v, b) in lines {
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
}
for nm in cx.wb.names.iter_mut() {
if let Some(sc) = nm.scope.as_mut()
&& *sc >= at
{
*sc += count;
}
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
}
if cx.wb.active_sheet >= at {
cx.wb.active_sheet += count;
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
}
for nm in cx.wb.names.iter_mut() {
if let Some(sc) = nm.scope.as_mut()
&& *sc >= at
{
*sc += count;
}
cx.structural = true;
Ok(())
})?;
}
if cx.wb.active_sheet >= at {
cx.wb.active_sheet += count;
}
cx.structural = true;
names
}
fn insert_report_selections(s: &mut Session, at: usize, count: usize) {
if let Ok(d) = s.doc_mut() {
for i in 0..count {
d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default());
}
}
Ok(names)
}
/// `data.solverReport`: reports for a solution of the sheet's saved model.
@@ -1973,7 +1978,9 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
.map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
let message = str_param(p, "message").unwrap_or("").chars().take(500).collect();
let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget };
let sheets = write_reports(s, &kinds, &inp)?;
let built = prepare_reports(s.doc()?, &kinds, &inp)?;
let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?;
insert_report_selections(s, model.sheet, sheets.len());
Ok(json!({"sheets": sheets}))
}
@@ -2068,41 +2075,53 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
}
let original: serde_json::Map<String, Json> = model.vars.iter().zip(&x0).map(|(c, v)| (c.a1(), json!(v))).collect();
let changed = keep && solution.is_some();
if changed || save {
let sheet = model.sheet;
let vars = model.vars.clone();
let m = model.clone();
edit(s, |cx| {
if changed && let Some(x) = &solution {
let sh = cx.sheet_mut(sheet)?;
for (c, v) in vars.iter().zip(x) {
let style = sh.style_id(*c);
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
}
for c in &vars {
cx.touch(sheet, *c);
}
}
if save {
save_model(&mut cx.wb, &m);
}
Ok(())
})?;
}
let built = match (&reports, &solution) {
(Some(kinds), Some(x)) => {
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x.clone(), message: message.to_string(), budget };
prepare_reports(s.doc()?, kinds, &inp)?
}
_ => Vec::new(),
};
let count = built.len();
let before = s.doc()?.wb.clone();
let mut out = json!({
"status": status,
"message": message,
"objective": objective,
"values": values,
"original": original,
"model": model_params(&s.doc()?.wb, &model),
"model": model_params(&before, &model),
"evaluations": evaluations,
"changed": changed,
});
// Reports describe a solution; they are part of the solve's undo step.
if let (Some(kinds), Some(x)) = (reports, solution) {
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x, message: message.to_string(), budget };
out["reports"] = json!(write_reports(s, &kinds, &inp)?);
let names = if changed || save || count > 0 {
let sheet = model.sheet;
edit(s, |cx| {
if changed && let Some(x) = &solution {
let sh = cx.sheet_mut(sheet)?;
for (c, v) in model.vars.iter().zip(x) {
let style = sh.style_id(*c);
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
}
for c in &model.vars {
cx.touch(sheet + count, *c);
}
}
if save {
save_model(&mut cx.wb, &model);
}
if count > 0 {
// Inserting sheets makes this structural, so retain the variable edit's array check.
check_arrays(&before, &cx.wb)?;
}
Ok(write_reports(cx, sheet, built))
})?
} else {
Vec::new()
};
insert_report_selections(s, model.sheet, count);
if reports.is_some() && solution.is_some() {
out["reports"] = json!(names);
}
Ok(out)
}
@@ -2390,6 +2409,132 @@ mod tests {
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(2.0));
}
#[test]
fn refused_solver_reports_preserve_the_document_and_history() {
for (protected, method, constraints, reports, error) in [
(true, "simplex", json!(["A1 <= 4"]), json!(["answer"]), "workbook is protected"),
(false, "simplex", json!(["A1 <= 4", "A1 int"]), json!(["answer", "limits"]), "integer or binary"),
(false, "evolutionary", json!(["A1 <= 4"]), json!(["answer", "limits"]), "Simplex LP and GRG"),
] {
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
s.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 2"], "method": "simplex", "keep": false}),
)
.unwrap();
set(&mut s, &[("C1", "redo")]);
s.execute("edit.undo", json!({})).unwrap();
let d = s.doc_mut().unwrap();
std::sync::Arc::make_mut(&mut d.wb).protected_structure = protected;
d.saved = d.wb.clone();
let before = d.clone();
let journal = s.journal.clone();
let err = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1", "constraints": constraints, "method": method,
"reports": reports, "keep": keep, "saveModel": save}),
)
.unwrap_err();
assert!(err.to_string().contains(error), "{err}");
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb), "{method}: keep={keep}, save={save}: {err}");
assert!(!d.is_dirty());
assert_eq!(d.revision, before.revision);
assert_eq!(d.selection, before.selection);
assert_eq!(d.sheet_selections, before.sheet_selections);
for (after, before) in [(&d.undo, &before.undo), (&d.redo, &before.redo)] {
assert_eq!(after.len(), before.len());
for (a, b) in after.iter().zip(before) {
assert!(std::sync::Arc::ptr_eq(&a.wb, &b.wb));
assert_eq!((&a.label, &a.selection), (&b.label, &b.selection));
}
}
assert_eq!(s.journal, journal);
}
}
}
#[test]
fn solver_reports_and_optional_solution_and_model_share_one_undo() {
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
let before = s.doc().unwrap().clone();
let out = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex",
"reports": ["answer"], "keep": keep, "saveModel": save}),
)
.unwrap();
assert_eq!(out["status"], "optimal");
assert_eq!(out["reports"], json!(["Answer Report 1"]));
assert_eq!(out["model"]["objective"], "Sheet1!B1");
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
assert_eq!(saved(&s), if save { "solver_opt" } else { "" });
assert_eq!(s.doc().unwrap().undo.len(), before.undo.len() + 1);
assert_eq!(s.doc().unwrap().wb.active().unwrap().name, "Sheet1");
let r = row_of(&s, "Answer Report 1", "$A$1") + 1;
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(4.0));
s.execute("edit.undo", json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
}
}
#[test]
fn solver_reports_keep_row_autofit_on_the_model_sheet() {
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
s.execute("home.columnWidth", json!({"cols": "A:A", "width": 30})).unwrap();
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
let before = s.doc().unwrap().wb.active().unwrap().row_height(0);
s.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 1000000"], "method": "simplex", "reports": ["answer"]}),
)
.unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.name, "Sheet1");
assert_eq!(sh.value(CellRef::new(0, 0)), Value::number(1_000_000.0));
assert!(sh.row_height(0) > before, "the solved wrapped number must re-fit its row after the report shifts its sheet");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), before);
}
#[test]
fn solver_report_insertion_keeps_variable_edit_guards() {
for array in [false, true] {
let mut s = s();
set(&mut s, &[("A2", "1"), ("B1", "=2*A2")]);
let error = if array {
s.execute("selection.set", json!({"range": "A1:A2"})).unwrap();
s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap();
"part of an array"
} else {
s.execute("review.protectSheet", json!({})).unwrap();
"protected sheet"
};
let before = s.doc().unwrap().clone();
let err = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A2", "constraints": ["A2 <= 4"], "method": "simplex", "reports": ["answer"]}),
)
.unwrap_err();
assert!(err.to_string().contains(error), "{err}");
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
assert_eq!(d.undo.len(), before.undo.len());
assert_eq!(d.sheet_selections, before.sheet_selections);
}
}
#[test]
fn limits_report_of_a_nonlinear_model() {
// The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the
+66 -15
View File
@@ -13,19 +13,29 @@ pub fn specs() -> Vec<CommandSpec> {
"Freeze Panes",
["View", "Window", "Freeze Panes"],
None,
"{cell?: \"B2\" (rows above and columns left of it freeze)}",
"{sheet?, cell?: \"B2\" (rows above and columns left of it freeze); without `cell`, unfreezes a frozen sheet}",
has_doc,
freeze_panes
),
cmd!("view.freezeTopRow", "Freeze Top Row", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, Some((1, 0)))),
cmd!("view.freezeFirstColumn", "Freeze First Column", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(
cmd!("view.freezeTopRow", "Freeze Top Row", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
s,
target_sheet(s, p)?,
Some((1, 0))
)),
cmd!("view.freezeFirstColumn", "Freeze First Column", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
s,
target_sheet(s, p)?,
Some((0, 1))
)),
cmd!("view.unfreezePanes", "Unfreeze Panes", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, None)),
cmd!("view.unfreezePanes", "Unfreeze Panes", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
s,
target_sheet(s, p)?,
None
)),
cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")),
cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")),
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
@@ -35,7 +45,15 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
cmd!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper),
cmd!(
"pageLayout.size",
"Size",
["Page Layout", "Page Setup"],
None,
"{paper: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes)}",
has_doc,
paper
),
cmd!(
"pageLayout.margins",
"Margins",
@@ -113,23 +131,28 @@ pub fn specs() -> Vec<CommandSpec> {
}
fn freeze_panes(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
if sh.freeze.is_some() && p.get("cell").is_none() {
return set_freeze(s, None);
return set_freeze(s, si, None);
}
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let at = match cell_param_on(s, p, "cell", si)? {
Some(c) => c,
None if si == d.wb.active_sheet => d.selection.active,
// The selection belongs to the active sheet.
None => return Err(bad("view.freezePanes", "missing `cell`: the selection is on the active sheet")),
};
let top = sh.view_top_left;
let rows = at.row.saturating_sub(top.row);
let cols = at.col.saturating_sub(top.col);
let _ = (rows, cols);
let (rows, cols) = (at.row, at.col);
set_freeze(s, if rows == 0 && cols == 0 { None } else { Some((rows, cols)) })
set_freeze(s, si, if rows == 0 && cols == 0 { None } else { Some((rows, cols)) })
}
fn set_freeze(s: &mut Session, f: Option<(u32, u32)>) -> Result<Json> {
fn set_freeze(s: &mut Session, si: usize, f: Option<(u32, u32)>) -> Result<Json> {
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.freeze = f;
Ok(json!({"freeze": f}))
})
@@ -152,6 +175,16 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
})
}
/// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown.
fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
use gridcraft_model::ShowObjects;
edit(s, |cx| {
let on = bool_param(p, "on").unwrap_or(cx.wb.show_objects == ShowObjects::None);
cx.wb.show_objects = if on { ShowObjects::All } else { ShowObjects::None };
Ok(json!({"on": on}))
})
}
fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16;
let d = s.doc_mut()?;
@@ -232,13 +265,31 @@ fn print_area(s: &mut Session, p: &Json) -> Result<Json> {
}
fn print_titles(s: &mut Session, p: &Json) -> Result<Json> {
let rows = str_param(p, "rows").and_then(RangeRef::parse).map(|r| (r.start.row, r.end.row));
let cols = str_param(p, "cols").and_then(RangeRef::parse).map(|r| (r.start.col, r.end.col));
// An absent key leaves that axis alone, and an empty or null value clears it — the same
// reading `pageLayout.pageSetup` gives `titleRows`/`titleCols`. Assigning `None` for a
// key that was never sent used to wipe the other axis.
let axis = |key: &str, is_cols: bool| -> Result<Option<Option<(u32, u32)>>> {
match p.get(key) {
None => Ok(None),
Some(Json::Null) => Ok(Some(None)),
Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)),
Some(Json::String(t)) => match RangeRef::parse(t) {
Some(r) => Ok(Some(Some(if is_cols { (r.start.col, r.end.col) } else { (r.start.row, r.end.row) }))),
None => Err(bad("pageLayout.printTitles", format!("`{key}` is not a range: `{t}`"))),
},
Some(_) => Err(bad("pageLayout.printTitles", format!("`{key}` must be text like \"1:2\" or \"A:A\""))),
}
};
let (rows, cols) = (axis("rows", false)?, axis("cols", true)?);
edit(s, |cx| {
let si = cx.sheet_index();
let pr = &mut cx.sheet_mut(si)?.print;
pr.title_rows = rows;
pr.title_cols = cols;
if let Some(t) = rows {
pr.title_rows = t;
}
if let Some(t) = cols {
pr.title_cols = t;
}
Ok(Json::Null)
})
}
+104 -34
View File
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Advanced…",
["Data", "Sort & Filter"],
None,
"{range? (list with headers; default current region), criteria: \"H1:I3\" (header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
"{range? (list with headers; default current region), criteria?: \"H1:I3\" (omitted/blank matches all rows; header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
has_doc,
advanced_filter
),
@@ -488,8 +488,15 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
edit(s, |cx| {
for (c, v) in cells.iter().zip(sc.values.iter()) {
let input = super::edit::json_to_input(v);
let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?;
// Stored numbers are values, not typed text: entering them through the parser would
// apply percent entry (9 -> 9%) to a Percentage-formatted cell.
let cell = match v.as_f64() {
Some(n) => {
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
Some(Cell { value: Value::Number(n), formula: None, style })
}
None => super::edit::input_to_cell(&super::edit::json_to_input(v), si, *c, &mut cx.wb)?,
};
let sh = cx.sheet_mut(si)?;
match cell {
Some(cell) => sh.set_cell(*c, cell),
@@ -795,41 +802,45 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
(d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active))
}
};
let (csi, crit) = range_arg(s, p, "criteria", CMD)?;
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
if list.height() < 1 || list.count() > 50_000_000 {
return Err(bad(CMD, "the list range is too large"));
}
if crit.width() > 1024 || crit.height() > 10_000 {
if let Some((_, crit)) = criteria
&& (crit.width() > 1024 || crit.height() > 10_000)
{
return Err(bad(CMD, "the criteria range is too large"));
}
let wb = &d.wb;
let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?;
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
let headers: Vec<String> =
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect();
let first_data = list.start.row + 1;
// Criteria rows → conditions.
let mut rows: Vec<Vec<Cond>> = Vec::new();
for cr in crit.start.row + 1..=crit.end.row {
let mut conds = Vec::new();
for cc in crit.start.col..=crit.end.col {
let at = CellRef::new(cr, cc);
let cell = csh.cell(at);
let v = csh.value(at);
let formula = cell.and_then(|c| c.formula.as_ref());
if v.is_empty() && formula.is_none() {
continue;
}
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
match headers.iter().position(|h| !head.is_empty() && *h == head) {
Some(off) => conds.push(Cond::Compare(off as u32, v)),
None => match formula.and_then(|f| f.expr()) {
Some(e) => conds.push(Cond::Computed(e, at)),
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
},
if let Some((csi, crit)) = criteria {
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
for cr in crit.start.row + 1..=crit.end.row {
let mut conds = Vec::new();
for cc in crit.start.col..=crit.end.col {
let at = CellRef::new(cr, cc);
let cell = csh.cell(at);
let v = csh.value(at);
let formula = cell.and_then(|c| c.formula.as_ref());
if v.is_empty() && formula.is_none() {
continue;
}
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
match headers.iter().position(|h| !head.is_empty() && *h == head) {
Some(off) => conds.push(Cond::Compare(off as u32, v)),
None => match formula.and_then(|f| f.expr()) {
Some(e) => conds.push(Cond::Computed(e, at)),
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
},
}
}
rows.push(conds);
}
rows.push(conds);
}
let matches_row = |row: u32| -> bool {
if rows.is_empty() {
@@ -845,7 +856,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
}
Cond::Computed(e, at) => {
let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0);
let v = gridcraft_calc::recalc::evaluate_expr(wb, csi, *at, &shifted);
let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted);
matches!(v.scalar().to_bool(), Ok(true))
}
})
@@ -867,28 +878,30 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(t) = str_param(p, "copyTo") {
let (dsi, dest) = parse_range(wb, t, CMD)?;
let dest = dest.start;
let extract = RangeRef::new(dest, dest.offset_clamped(list.height() as i64 - 1, list.width() as i64 - 1));
if dsi == si && extract.intersects(&list) {
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
}
let mut rows_out: Vec<u32> = vec![list.start.row];
rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r));
let end =
dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?;
let out_range = RangeRef::new(dest, end);
if dsi == si && out_range.intersects(&list) {
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
}
let cells: Vec<(CellRef, Cell)> = rows_out
.iter()
.enumerate()
.flat_map(|(i, row)| {
(list.start.col..=list.end.col).filter_map(move |c| {
(list.start.col..=list.end.col).map(move |c| {
let src = CellRef::new(*row, c);
let to = dest.offset(i as i64, (c - list.start.col) as i64)?;
// The full output footprint was checked above.
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
let style = sh.style_id(src);
Some((to, Cell { value: sh.value(src), formula: None, style }))
(to, Cell { value: sh.value(src), formula: None, style })
})
})
.collect();
let out_range = RangeRef::new(dest, dest.offset_clamped(rows_out.len() as i64 - 1, list.width() as i64 - 1));
edit(s, |cx| {
let shm = cx.sheet_mut(dsi)?;
let old: Vec<CellRef> = shm.cells.iter_range(extract).map(|(c, _)| c).collect();
let old: Vec<CellRef> = shm.cells.iter_range(out_range).map(|(c, _)| c).collect();
for c in &old {
shm.remove_cell(*c);
}
@@ -1199,6 +1212,17 @@ mod tests {
assert!(!r.converged);
}
#[test]
fn scenario_show_restores_a_percentage_cells_stored_number() {
let mut s = s();
s.execute("home.numberFormat", json!({"range": "B2", "format": "Percentage"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "5"})).unwrap();
s.execute("data.scenarioAdd", json!({"name": "Low", "changing": "B2"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "9"})).unwrap();
s.execute("data.scenarioShow", json!({"name": "Low"})).unwrap();
assert_eq!(n(&s, "B2"), 0.05);
}
#[test]
fn scenarios() {
let mut s = s();
@@ -1317,6 +1341,52 @@ mod tests {
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3", "copyTo": "B2"})).is_err());
}
#[test]
fn advanced_filter_copy_preserves_cells_beyond_result() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap();
s.execute("cell.set", json!({"cell": "K4", "input": "Keep me"})).unwrap();
let r = s.execute("data.advancedFilter", json!({"range": "A1:B4", "criteria": "H1:H2", "copyTo": "K1"})).unwrap();
assert_eq!(r["range"], "K1:L2");
assert_eq!(v(&s, "K2"), Value::from("B"));
assert_eq!(v(&s, "K4"), Value::from("Keep me"));
}
#[test]
fn advanced_filter_copy_rejects_overflow_without_mutation() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1]]})).unwrap();
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
for dest in ["XFD1", "K1048576"] {
s.execute("cell.set", json!({"cell": dest, "input": "Keep me"})).unwrap();
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B2", "criteria": "H1:H2", "copyTo": dest})).is_err());
assert_eq!(v(&s, dest), Value::from("Keep me"));
}
}
#[test]
fn advanced_filter_copy_allows_output_above_source_when_only_old_clear_area_overlaps() {
let mut s = s();
s.execute("range.setValues", json!({"range": "C4", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
let r = s.execute("data.advancedFilter", json!({"range": "C4:D7", "criteria": "H1:H2", "copyTo": "B1"})).unwrap();
assert_eq!(r["range"], "B1:C2");
assert_eq!(v(&s, "C4"), Value::from("Name"));
assert_eq!(v(&s, "C2"), Value::Number(1.0));
}
#[test]
fn advanced_filter_unique_without_criteria() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap();
let r = s.execute("data.advancedFilter", json!({"range": "A1:A4", "copyTo": "C1", "unique": true})).unwrap();
assert_eq!(r["matched"], 2);
assert_eq!(r["range"], "C1:C3");
assert_eq!(v(&s, "C2"), Value::from("A"));
assert_eq!(v(&s, "C3"), Value::from("B"));
}
#[test]
fn criteria_semantics() {
use super::criterion_matches as m;