mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 05:37:00 -06:00
Merge main into #2152
# Conflicts: # ROADMAP.md # docs/architecture.md # docs/target-app-parity.md # docs/ui-parity.md
This commit is contained in:
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user