From 21b64df0fe8c5cee99aa13dee578954a8c03114b Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:24:59 -0600 Subject: [PATCH] Paste of a whole-column or whole-row copy no longer aborts the app (#2018) * Paste: whole-column paste and validation without per-cell blowup edit.paste of a whole-column or whole-row copy walked every cell of the copied block (up to the whole sheet) and, for each one, re-cut every validation rule and pushed a one-cell rule. Over a 2M-cell block that is quadratic and the fuzz soak aborted on 34-68 GB allocations. - Paste visits only cells that hold something in the source or the destination; blank onto blank changes nothing (and with an operation stays blank, as in Excel, instead of becoming 0). - Validation pastes as ranges: the pasted rectangle is cut once out of the destination's rules and each source rule comes along as one rule over its translated parts (Skip Blanks or not, as Excel does). - Whole columns and rows paste whole, so destination content further down the columns is replaced, as in Excel. - A paste that does not fit on the sheet is refused, as Excel does, instead of pasting part of the block (whole columns below row 1, whole rows right of column A, a whole column transposed, a block at XFD). - Paste Link, the one paste that writes every cell, is capped at 1,048,576 cells; tiled validation at 100,000 ranges. Co-Authored-By: Claude Sonnet 5.5 * Paste: visit destination cells once per paste, add link, transpose and tiling tests Co-Authored-By: Claude Sonnet 5.5 * Paste: visit link-only cells so a cut keeps their hyperlink * Paste validation: Skip Blanks keeps the destination rule under a blank source cell with no rule * Paste validation: Skip Blanks collects clearing cuts only when a rule is under the paste, merges them into runs, and limits only generated areas * Paste validation: annotate the clearing cut list * Qualify paste validation context type * Fix paste-special clippy warnings * engine: batch validation range cuts * Fix range type inference * Annotate validation range index type * Avoid shadowing test session helper * Keep validation rows after the final cut --------- Co-authored-by: Claude Sonnet 5.5 --- crates/engine/src/cmd/data/validation.rs | 80 ++- crates/engine/src/cmd/edit/paste_special.rs | 636 ++++++++++++-------- crates/engine/src/tests.rs | 230 +++++++ 3 files changed, 693 insertions(+), 253 deletions(-) diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs index 2217a372a..e02ef06b3 100644 --- a/crates/engine/src/cmd/data/validation.rs +++ b/crates/engine/src/cmd/data/validation.rs @@ -18,21 +18,71 @@ fn op_param(s: Option<&str>) -> CfOperator { } } -/// `r` minus `cut`, as up to four non-overlapping rectangles. -fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { - let Some(i) = r.intersection(cut) else { return vec![r] }; - let mut out = Vec::new(); - if i.start.row > r.start.row { - out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col))); +/// Removes a set of cuts from one validation range in a row sweep, updating the result once +/// instead of fragmenting it again for every cut. +fn subtract_ranges(r: RangeRef, cuts: &[RangeRef]) -> Vec { + let mut events = std::collections::BTreeMap::>::new(); + for cut in cuts { + let Some(i) = r.intersection(cut) else { continue }; + let columns = (i.start.col, i.end.col); + events.entry(u64::from(i.start.row)).or_default().push((columns, true)); + events.entry(u64::from(i.end.row) + 1).or_default().push((columns, false)); } - if i.end.row < r.end.row { - out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end)); + if events.is_empty() { + return vec![r]; } - if i.start.col > r.start.col { - out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1))); - } - if i.end.col < r.end.col { - out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col))); + events.entry(u64::from(r.end.row) + 1).or_default(); + + let mut active = std::collections::BTreeMap::<(u32, u32), usize>::new(); + let mut out: Vec = Vec::new(); + let mut last_by_columns = std::collections::BTreeMap::<(u32, u32), usize>::new(); + let mut previous_row = u64::from(r.start.row); + for (row, changes) in events { + if previous_row < row { + let start_row = previous_row as u32; + let end_row = (row - 1).min(u64::from(r.end.row)) as u32; + let mut covered = Vec::<(u32, u32)>::new(); + for &(start, end) in active.keys() { + match covered.last_mut() { + Some(last) if start <= last.1.saturating_add(1) => last.1 = last.1.max(end), + _ => covered.push((start, end)), + } + } + let mut next_col = u64::from(r.start.col); + let mut remaining = Vec::new(); + for (start, end) in covered { + if next_col < u64::from(start) { + remaining.push((next_col as u32, start - 1)); + } + next_col = u64::from(end) + 1; + } + if next_col <= u64::from(r.end.col) { + remaining.push((next_col as u32, r.end.col)); + } + for (start_col, end_col) in remaining { + let columns = (start_col, end_col); + if let Some(&index) = last_by_columns.get(&columns) + && out[index].end.row.saturating_add(1) == start_row + { + out[index].end.row = end_row; + } else { + let index = out.len(); + out.push(RangeRef::new(CellRef::new(start_row, start_col), CellRef::new(end_row, end_col))); + last_by_columns.insert(columns, index); + } + } + } + for (columns, add) in changes { + if add { + *active.entry(columns).or_default() += 1; + } else if let Some(count) = active.get_mut(&columns) { + *count -= 1; + if *count == 0 { + active.remove(&columns); + } + } + } + previous_row = row; } out } @@ -43,9 +93,7 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { pub(in super::super) fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { for dv in validations.iter_mut() { let before = RangeRef::top_left(&dv.ranges); - for cut in cuts { - dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect(); - } + dv.ranges = dv.ranges.iter().flat_map(|r| subtract_ranges(*r, cuts)).collect(); if let (Some(from), Some(to)) = (before, RangeRef::top_left(&dv.ranges)) { dv.f1 = move_rule_formula(&dv.f1, from, to); dv.f2 = dv.f2.take().map(|f| move_rule_formula(&f, from, to)); diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index 352c8e201..ab662642a 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -4,74 +4,7 @@ use super::{ target_sheet, }; -fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator + Clone { - (0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c))))) -} - -fn apply_operation(op: &str, old: &Cell, new: &mut Cell) { - let operand = new.value.clone(); - if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number()) - && (old.value.is_number() || old.value.is_empty() || old.value.is_text()) - && (operand.is_number() || operand.is_empty()) - { - new.value = match op { - "add" => Value::number(x + y), - "subtract" => Value::number(x - y), - "multiply" => Value::number(x * y), - "divide" if y == 0.0 => Value::Error(gridcraft_core::CellError::Div0), - "divide" => Value::number(x / y), - _ => operand.clone(), - }; - } else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) { - new.value = old.value.clone(); - } - if let Some(formula) = &old.formula - && let Ok(y) = operand.to_number() - && (operand.is_number() || operand.is_empty()) - && let Some(symbol) = operation_symbol(op) - { - let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y)); - new.formula = Some(std::sync::Arc::new(Formula::new(&text))); - new.value = Value::Empty; - } -} - -fn operation_symbol(op: &str) -> Option { - match op { - "add" => Some('+'), - "subtract" => Some('-'), - "multiply" => Some('*'), - "divide" => Some('/'), - _ => None, - } -} - -fn copy_merges( - cx: &mut crate::cmd::Ctx, - dest_sheet: usize, - src_sheet: &gridcraft_model::Sheet, - src: RangeRef, - at: CellRef, - what: What, - transpose: bool, -) -> Result<()> { - if !matches!(what, What::All | What::Formats) || transpose { - return Ok(()); - } - let merges: Vec = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect(); - for merge in merges { - let row_offset = at.row as i64 - src.start.row as i64; - let col_offset = at.col as i64 - src.start.col as i64; - if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) { - let moved = RangeRef::new(start, end); - let sheet = cx.sheet_mut(dest_sheet)?; - sheet.merges.retain(|existing| !existing.intersects(&moved)); - sheet.merges.push(moved); - } - } - Ok(()) -} - +#[allow(clippy::cognitive_complexity)] // The command applies the selected paste mode in one atomic edit. pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; let what = match str_param(p, "what").unwrap_or("all") { @@ -97,7 +30,11 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { let dest_sheet = target_sheet(s, p)?; let sel = s.doc()?.selection.current(); let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start); - let src = clip.range; + // Whole columns or rows paste whole (the copy keeps only their used part as its text), so + // what the destination columns hold further down is replaced too, as in Excel. The cells are + // visited sparsely below, so this costs what the columns hold, not 1,048,576 rows. + let whole = clip.areas.is_empty() && (clip.selected.is_full_cols() || clip.selected.is_full_rows()) && clip.selected.contains_range(&clip.range); + let src = if whole { clip.selected } else { clip.range }; let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; // Tile the copy over a larger destination that is a multiple of its size. let tile = sel.height() % h == 0 @@ -108,17 +45,25 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { && !sel.is_full_rows() && sel.count() <= 4_000_000; let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) }; + // Excel refuses a paste that would run off the sheet (whole columns pasted below row 1, whole + // rows right of column A, a whole column transposed) instead of pasting part of it. + if at.offset(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1).is_none() { + return Err(EngineError::Other("The copied cells don't fit here: paste them where the whole copied area fits on the sheet.".into())); + } + // Paste Link writes a formula into every cell, blank ones included, so it is the one paste + // whose work grows with the area rather than with what the source holds. + let link_src = clip.range; + if link && link_src.count().saturating_mul(u64::from(tiles_r) * u64::from(tiles_c)) > MAX_PASTE_LINK_CELLS { + return Err(EngineError::Other("Too many cells to paste as links at once.".into())); + } let same_doc = s.doc()?.uid == clip.doc_uid; let cut_move = clip.cut && same_doc && what == What::All; 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 dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default(); let mut style_map = std::collections::HashMap::new(); let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId { if same_doc { @@ -126,7 +71,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { } *style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone())) }; - let mut moved_formulas = Vec::new(); + let mut moved_formulas = std::collections::BTreeMap::new(); let mut moved_pictures = std::collections::BTreeMap::new(); if cut_move { // Remove the source first (contents and formats), and fix references to it. @@ -143,190 +88,281 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { for (c, _) in &taken { cx.touch(clip.sheet, *c); } - moved_formulas = taken; + moved_formulas = taken.into_iter().collect(); } - let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default(); - for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) { - let sc = CellRef::new(src.start.row + r, src.start.col + c); - let (dr, dc) = if transpose { (c, r) } else { (r, c) }; - let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; - let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() }; - let src_val = src_sheet.value(sc); - let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); - if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) { - continue; - } - let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); - let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); - let mut new_picture = old_picture.clone(); - let mut new = old.clone(); - let shift = |cell: &Cell| -> Option> { - let f = cell.formula.as_ref()?; - let e = f.expr()?; - if cut_move { - // A moved formula still reads cells on its original sheet. Qualify - // those references before the workbook-wide move adjustment below. - let e = if dest_sheet != clip.sheet { - e.map(&mut |x| match x { - gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { - r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); - gridcraft_formula::Expr::Ref(r) - } - other => other, - }) - } else { - e - }; - let mut moved = Formula::from_expr(e); - // 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 { dynamic: f.dynamic, ..Formula::from_expr(moved) })) - }; - // A Python cell's code travels with it. Cut to another sheet, it keeps reading the - // cells its `xl()` references named on its original sheet, as a moved formula does: - // they are qualified with that sheet before the workbook-wide move adjustment below. - let moved_py = |cell: &Cell| -> Result>> { - let Some(py) = cell.py.clone() else { return Ok(None) }; - if !cut_move || dest_sheet == clip.sheet { - return Ok(Some(py)); - } - let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| { - let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x { - gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { - r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); - gridcraft_formula::Expr::Ref(r) - } - other => other, - }); - Some(gridcraft_formula::print(&e)) + // The cells of the copied block that hold something (or that a link reads), as offsets + // inside it. Blank source cells matter only where the destination holds something. + let source_offsets: std::collections::BTreeSet<(u32, u32)> = if link { + (0..link_src.height()).flat_map(|r| (0..link_src.width()).map(move |c| (r, c))).collect() + } else { + let offset = |c: CellRef| (c.row - src.start.row, c.col - src.start.col); + let mut held: std::collections::BTreeSet<(u32, u32)> = src_sheet.cells.iter_range(src).map(|(c, _)| offset(c)).collect(); + let side = + src_sheet.spill.keys().chain(src_sheet.cell_pictures.keys()).chain(src_sheet.comments.keys()).chain(src_sheet.hyperlinks.keys()); + held.extend(side.chain(moved_formulas.keys()).chain(moved_pictures.keys()).filter(|c| src.contains(**c)).map(|c| offset(*c))); + held + }; + let skip_cells = matches!(what, What::Validation | What::ColumnWidths); + // A blank source cell clears (or reformats) a destination cell that holds something, so + // those cells join the visit. One scan of the whole pasted area sorts them by tile. + let mut dest_held: std::collections::BTreeMap<(u32, u32), Vec<(u32, u32)>> = Default::default(); + if !link && !skip_cells { + let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1)); + let held = + cx.wb.sheet(dest_sheet).into_iter().flat_map(|sh| { + sh.cells.iter_range(pasted).map(|(c, _)| c).chain(sh.cell_pictures.keys().copied().filter(|c| pasted.contains(*c))) }); - Ok(Some(match qualified { - Some(code) => { - let moved = gridcraft_model::PyCode { code, ..(*py).clone() }; - check_python_fits(&moved, &dest_name, dest)?; - std::sync::Arc::new(moved) + for d in held.collect::>() { + let (dr, dc) = (d.row - at.row, d.col - at.col); + let local = (dr % h, dc % w); + dest_held.entry((dr / h, dc / w)).or_default().push(if transpose { (local.1, local.0) } else { local }); + } + } + let no_cells = std::collections::BTreeSet::new(); + for tr in 0..tiles_r { + for tc in 0..tiles_c { + let merged; + let offsets = match dest_held.get(&(tr, tc)) { + Some(extra) => { + merged = source_offsets.iter().chain(extra).copied().collect::>(); + &merged } - None => py, - })) - }; - if link { - let sheet_prefix = if dest_sheet == clip.sheet && same_doc { - String::new() - } else { - format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) + None if skip_cells => &no_cells, + None => &source_offsets, }; - new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); - new.value = Value::Empty; - new.py = None; - new_picture = None; - } else { - let sc_cell = src_cell.clone().unwrap_or_default(); - match what { - What::All | What::AllExceptBorders => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - new.py = moved_py(&sc_cell)?; - let st = map_style(sc_cell.style, &mut cx.wb); - new.style = if what == What::AllExceptBorders { - let borders = cx.wb.styles.get(old.style).borders; - cx.wb.styles.derive(st, |s| s.borders = borders) + for &(r, c) in offsets { + let sc = CellRef::new(src.start.row + r, src.start.col + c); + let (dr, dc) = if transpose { (c, r) } else { (r, c) }; + let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; + let src_cell = if cut_move { moved_formulas.get(&sc).cloned() } else { src_sheet.cell(sc).cloned() }; + let src_val = src_sheet.value(sc); + let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); + if skip_blanks + && src_picture.is_none() + && src_val.is_empty() + && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) + { + continue; + } + let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); + let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); + let mut new_picture = old_picture.clone(); + let mut new = old.clone(); + let shift = |cell: &Cell| -> Option> { + let f = cell.formula.as_ref()?; + let e = f.expr()?; + if cut_move { + // A moved formula still reads cells on its original sheet. Qualify + // those references before the workbook-wide move adjustment below. + let e = if dest_sheet != clip.sheet { + e.map(&mut |x| match x { + gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { + r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); + gridcraft_formula::Expr::Ref(r) + } + other => other, + }) + } else { + e + }; + let mut moved = Formula::from_expr(e); + // 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 { dynamic: f.dynamic, ..Formula::from_expr(moved) })) + }; + let moved_py = |cell: &Cell| -> Result>> { + let Some(py) = cell.py.clone() else { return Ok(None) }; + if !cut_move || dest_sheet == clip.sheet { + return Ok(Some(py)); + } + let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| { + let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x { + gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { + r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); + gridcraft_formula::Expr::Ref(r) + } + other => other, + }); + Some(gridcraft_formula::print(&e)) + }); + Ok(Some(match qualified { + Some(code) => { + let moved = gridcraft_model::PyCode { code, ..(*py).clone() }; + check_python_fits(&moved, &dest_name, dest)?; + std::sync::Arc::new(moved) + } + None => py, + })) + }; + if link { + let sheet_prefix = if dest_sheet == clip.sheet && same_doc { + String::new() } else { - st + format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) }; - } - What::Formulas | What::FormulasAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - new.py = moved_py(&sc_cell)?; - if what == What::FormulasAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); - } - } - What::Values | What::ValuesAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = None; + new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); + new.value = Value::Empty; new.py = None; - new.value = src_val.clone(); - if what == What::ValuesAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + new_picture = None; + } else { + let sc_cell = src_cell.clone().unwrap_or_default(); + match what { + What::All | What::AllExceptBorders => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + new.py = moved_py(&sc_cell)?; + let st = map_style(sc_cell.style, &mut cx.wb); + new.style = if what == What::AllExceptBorders { + let borders = cx.wb.styles.get(old.style).borders; + cx.wb.styles.derive(st, |s| s.borders = borders) + } else { + st + }; + } + What::Formulas | What::FormulasAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + new.py = moved_py(&sc_cell)?; + if what == What::FormulasAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + } + } + What::Values | What::ValuesAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = None; + new.py = None; + new.value = src_val.clone(); + if what == What::ValuesAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + } + } + What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), + What::Comments | What::Validation | What::ColumnWidths => {} + } + // Operations combine numbers with what is already there. + if op != "none" + && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) + && new.formula.is_none() + && new.py.is_none() + { + if old_picture.is_some() || new_picture.is_some() { + new_picture = None; + new.value = Value::Error(gridcraft_core::CellError::Value); + } + let a = old.value.clone(); + let b = new.value.clone(); + // Numbers stored as text take part (multiplying by 1 turns them into + // numbers); other text and errors stay as they are. + if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) + && (a.is_number() || a.is_empty() || a.is_text()) + && (b.is_number() || b.is_empty()) + { + new.value = match op.as_str() { + "add" => Value::number(x + y), + "subtract" => Value::number(x - y), + "multiply" => Value::number(x * y), + "divide" => { + if y == 0.0 { + Value::Error(gridcraft_core::CellError::Div0) + } else { + Value::number(x / y) + } + } + _ => b.clone(), + }; + } else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) { + new.value = a; + } + // A formula already there is kept and combined with the pasted number. + if let Some(f) = &old.formula + && let Ok(y) = b.to_number() + && (b.is_number() || b.is_empty()) + && let Some(sym) = match op.as_str() { + "add" => Some('+'), + "subtract" => Some('-'), + "multiply" => Some('*'), + "divide" => Some('/'), + _ => None, + } + { + let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y)); + new.formula = Some(std::sync::Arc::new(Formula::new(&text))); + new.value = Value::Empty; + } } } - What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), - What::Comments | What::Validation | What::ColumnWidths => {} - } - // Operations combine numbers with what is already there. - if op != "none" - && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) - && new.formula.is_none() - && new.py.is_none() - { - if old_picture.is_some() || new_picture.is_some() { - new_picture = None; - new.value = Value::Error(gridcraft_core::CellError::Value); + match what { + What::Comments => { + if let Some(cm) = src_sheet.comments.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); + } + } + What::All => { + 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), } - // Numbers stored as text participate; other text and errors stay as they are. - apply_operation(&op, &old, &mut new); + if let Some(picture) = new_picture { + cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); + } + cx.touch(dest_sheet, dest); } } - // The copied cell's validation rule replaces whatever rule the destination had. - if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { - let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned(); - let sh = cx.sheet_mut(dest_sheet)?; - crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); - if let Some(mut dv) = rule { - // Relative references move with the rule, as pasted formulas do. - let first = RangeRef::top_left(&dv.ranges).unwrap_or(sc); - dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest); - dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest)); - dv.ranges = vec![RangeRef::new(dest, dest)]; - sh.validations.push(dv); + } + let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1)); + if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { + paste_validations(cx, src_sheet, src, dest_sheet, pasted, transpose, skip_blanks)?; + } + if what == What::ColumnWidths { + // Each pasted column takes the width of the column copied to it (transposed, the + // copied rows land in columns and all take the last copied column's width). + let cols = cx.sheet_mut(dest_sheet)?; + for dc in 0..pasted.width() { + let info = if transpose { + (0..src.width()).rev().find_map(|c| src_sheet.cols.get(&(src.start.col + c)).copied()) + } else { + src_sheet.cols.get(&(src.start.col + dc % w)).copied() + }; + if let Some(info) = info { + cols.cols.insert(pasted.start.col + dc, info); } } - match what { - What::Comments => { - if let Some(cm) = src_sheet.comments.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); - } - } - What::ColumnWidths => { - if let Some(info) = src_sheet.cols.get(&sc.col).copied() { - cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); - } - } - What::All => { - 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), - } - if let Some(picture) = new_picture { - cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); - } - cx.touch(dest_sheet, dest); } // Merged areas inside the copied block travel with it. - copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?; + if matches!(what, What::All | What::Formats) && !transpose { + let merges: Vec = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect(); + for m in merges { + let dr = at.row as i64 - src.start.row as i64; + let dc = at.col as i64 - src.start.col as i64; + if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { + let nm = RangeRef::new(a, b); + let sh = cx.sheet_mut(dest_sheet)?; + sh.merges.retain(|x| !x.intersects(&nm)); + sh.merges.push(nm); + } + } + } if cut_move { // References elsewhere to the moved block now point to its new place. - let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default(); let e = if dest_sheet == clip.sheet { gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col } } else { - gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col } + gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name.clone(), to_row: at.row, to_col: at.col } }; rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e)?; cx.structural = true; @@ -351,6 +387,132 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { Ok(json!({"pasted": s.doc()?.selection.a1()})) } +/// Most cells one Paste Link writes (it writes a formula into every cell of the copied area, +/// blank ones included). +const MAX_PASTE_LINK_CELLS: u64 = 1_048_576; +/// Most validation ranges one paste adds (a rule covering part of a small block tiled over a huge +/// selection makes one range per tile). +const MAX_PASTE_VALIDATION_RANGES: usize = 100_000; + +/// Pasting validation works on ranges, as Excel stores it: the pasted rectangle is cut out of the +/// destination's rules (cells whose source had no rule end up with none), then each source rule +/// that covers part of the copied block `src` comes along as one rule over the translated parts. +/// `size` is one tile's (height, width) at the destination; `pasted` is all the tiles together. +fn paste_validations( + cx: &mut crate::cmd::Ctx<'_>, + src_sheet: &gridcraft_model::Sheet, + src: RangeRef, + dest_sheet: usize, + pasted: RangeRef, + transpose: bool, + skip_blanks: bool, +) -> Result<()> { + let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; + let (tiles_r, tiles_c) = (pasted.height() / h.max(1), pasted.width() / w.max(1)); + let mut rules = Vec::new(); + // Under Skip Blanks the destination keeps its rule below a blank source cell that has none, so + // only the destination of cells that hold something (and of the rules) is cleared. Nothing is + // collected when no destination rule is under the paste. + let mut cleared: Vec = Vec::new(); + let dest_rules = + cx.wb.sheet(dest_sheet).is_some_and(|sh| sh.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.intersection(&pasted).is_some()))); + if skip_blanks && dest_rules { + let mut cells = Vec::new(); + for (sc, cell) in src_sheet.cells.iter_range(src) { + if cell.formula.is_none() && cell.py.is_none() && src_sheet.value(sc).is_empty() { + continue; + } + let (r, c) = (sc.row - src.start.row, sc.col - src.start.col); + let (r, c) = if transpose { (c, r) } else { (r, c) }; + for tr in 0..tiles_r { + for tc in 0..tiles_c { + if let Some(d) = pasted.start.offset(i64::from(tr * h + r), i64::from(tc * w + c)) { + cells.push((d.row, d.col)); + } + } + } + } + // Coalesce populated cells into horizontal runs, then join identical runs in adjacent + // rows. The range limit applies to the cuts, not the number of populated cells. + cells.sort_unstable(); + let mut runs: Vec = Vec::new(); + let mut i = 0; + while i < cells.len() { + let (row, start_col) = cells[i]; + let mut end_col = start_col; + i += 1; + while i < cells.len() && cells[i].0 == row && cells[i].1 <= end_col.saturating_add(1) { + end_col = end_col.max(cells[i].1); + i += 1; + } + runs.push(RangeRef::new(CellRef::new(row, start_col), CellRef::new(row, end_col))); + } + let mut last_run_by_columns = std::collections::BTreeMap::<(u32, u32), usize>::new(); + for run in runs { + let columns = (run.start.col, run.end.col); + if let Some(&index) = last_run_by_columns.get(&columns) + && cleared[index].end.row.saturating_add(1) == run.start.row + { + cleared[index].end.row = run.end.row; + } else { + let index = cleared.len(); + cleared.push(run); + last_run_by_columns.insert(columns, index); + } + } + } + let mut total = 0usize; + for dv in &src_sheet.validations { + let parts: Vec = dv.ranges.iter().filter_map(|r| r.intersection(&src)).collect(); + let Some(first) = dv.ranges.first().map(|r| r.start) else { continue }; + if parts.is_empty() { + continue; + } + let mut ranges = Vec::new(); + if parts.contains(&src) { + ranges.push(pasted); + } else { + total = total.saturating_add(parts.len().saturating_mul(tiles_r as usize).saturating_mul(tiles_c as usize)); + if total > MAX_PASTE_VALIDATION_RANGES { + return Err(EngineError::Other("Too many separate validation areas to paste at once.".into())); + } + for tr in 0..tiles_r { + for tc in 0..tiles_c { + let Some(origin) = pasted.start.offset(i64::from(tr * h), i64::from(tc * w)) else { continue }; + for p in &parts { + let (r0, c0) = (p.start.row - src.start.row, p.start.col - src.start.col); + let (r1, c1) = (p.end.row - src.start.row, p.end.col - src.start.col); + let ((r0, c0), (r1, c1)) = if transpose { ((c0, r0), (c1, r1)) } else { ((r0, c0), (r1, c1)) }; + let Some(a) = origin.offset(i64::from(r0), i64::from(c0)) else { continue }; + let b = origin.offset_clamped(i64::from(r1), i64::from(c1)); + ranges.push(RangeRef::new(a, b)); + } + } + } + } + let Some(anchor) = ranges.first().map(|r| r.start) else { continue }; + let mut rule = dv.clone(); + // Relative references move with the rule, as pasted formulas do: they are written for the + // rule's first cell. + rule.f1 = super::super::data::move_rule_formula(&dv.f1, first, anchor); + rule.f2 = dv.f2.as_ref().map(|f| super::super::data::move_rule_formula(f, first, anchor)); + rule.ranges = ranges; + rules.push(rule); + } + if cleared.len() > MAX_PASTE_VALIDATION_RANGES { + return Err(EngineError::Other("Too many separate validation areas to paste at once.".into())); + } + let sh = cx.sheet_mut(dest_sheet)?; + if skip_blanks { + cleared.extend(rules.iter().flat_map(|r| r.ranges.iter().copied())); + super::super::data::remove_validation_cells(&mut sh.validations, &cleared); + } else { + super::super::data::remove_validation_cells(&mut sh.validations, &[pasted]); + } + sh.validations.extend(rules); + Ok(()) +} + /// Applies a reference adjustment to every cell formula and defined name in the workbook. pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) -> Result<()> { use gridcraft_formula::adjust::Edit; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b83879c16..02c273e14 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4170,6 +4170,25 @@ fn a_cut_moves_the_sources_notes_hyperlinks_and_merges() { assert!(v(&s, "A1").is_empty() && v(&s, "B1").is_empty()); } +/// A cell that holds only a hyperlink (its content cleared) is still part of the block: a +/// cut must carry the link to the destination instead of dropping it with the source. +#[test] +fn a_cut_carries_a_link_only_cell() { + let mut s = s(); + s.execute("insert.link", json!({"cell": "A1", "target": "https://example.com"})).unwrap(); + let a1 = CellRef::parse("A1").unwrap(); + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + sh.remove_cell(a1); + assert!(sh.cell(a1).is_none(), "A1 holds only the link"); + s.execute("edit.cut", json!({"range": "A1:B1"})).unwrap(); + s.execute("edit.paste", json!({"at": "D1"})).unwrap(); + + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(!sh.hyperlinks.contains_key(&CellRef::parse("A1").unwrap()), "the link left the source"); + assert!(sh.hyperlinks.contains_key(&CellRef::parse("D1").unwrap()), "the link arrived at the destination"); +} + /// Page Layout ▸ Print Titles: naming one axis must not clear the other. `print_titles` read /// both `rows` and `cols` into `Option`s and assigned both unconditionally, so /// `{"rows":"2:2"}` silently dropped the repeated columns from every printed page. @@ -5014,6 +5033,217 @@ fn csv_as_text_keeps_identifier_columns_as_written() { assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes()); } +fn validation_areas(s: &Session) -> Vec { + let sh = s.doc().unwrap().wb.active().unwrap(); + let mut out: Vec = sh.validations.iter().map(|dv| dv.ranges.iter().map(|r| r.a1()).collect::>().join(" ")).collect(); + out.sort(); + out +} + +/// ft-3ir.10.16: pasting whole columns walked every one of their 1,048,576 rows and added one +/// single-cell validation rule per pasted cell, re-cutting every rule each time (the fuzz run +/// aborted on a 34 GB allocation). Validation now pastes as ranges and only cells that hold +/// something are visited. +#[test] +fn whole_column_paste_with_validation_works_on_ranges() { + let mut s = s(); + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + s.execute("cell.set", json!({"cell": "C1", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "D1048576", "input": "2"})).unwrap(); + s.execute("cell.set", json!({"cell": "F100", "input": "old"})).unwrap(); + s.execute("data.validation", json!({"range": "C:C", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("data.validation", json!({"range": "F:H", "type": "list", "formula1": "\"a,b\""})).unwrap(); + s.execute("edit.copy", json!({"range": "C:D"})).unwrap(); + s.execute("edit.paste", json!({"at": "F1"})).unwrap(); + assert_eq!(v(&s, "F1"), Value::Number(1.0)); + assert_eq!(v(&s, "G1048576"), Value::Number(2.0)); + assert_eq!(v(&s, "F100"), Value::Empty, "the whole column is replaced, below the copied text too"); + // One rule per copied rule, over whole columns; G (D had no rule) has none; H keeps the list. + assert_eq!(validation_areas(&s), ["C:C", "F:F", "H:H"]); + assert!(!ok(&mut s, "F500", "-1")); + assert!(ok(&mut s, "G500", "anything")); + assert!(!ok(&mut s, "H500", "c")); + assert_eq!(s.doc().unwrap().selection.a1(), "F:G"); + // Undo gives the list back over F:H. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(validation_areas(&s), ["C:C", "F:H"]); + assert_eq!(v(&s, "F100"), Value::Text("old".into())); +} + +/// Paste Link of whole columns links the cells the columns hold, wherever they start. +#[test] +fn paste_link_of_whole_columns_links_the_used_rows() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "C5", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "C6", "input": "2"})).unwrap(); + s.execute("edit.copy", json!({"range": "C:C"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "F1"})).unwrap(); + assert_eq!(v(&s, "F1"), Value::Number(0.0), "a link to a blank cell reads 0"); + assert_eq!(v(&s, "F5"), Value::Number(1.0)); + assert_eq!(v(&s, "F6"), Value::Number(2.0)); + assert_eq!(s.execute("cell.get", json!({"cell": "F5"})).unwrap()["input"], json!("=C5")); +} + +/// A transposed paste clears the destination cells that line up with blank source cells. +#[test] +fn transposed_paste_clears_destination_cells_under_blank_source_cells() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "3"})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "4"})).unwrap(); + s.execute("cell.set", json!({"cell": "E2", "input": "old"})).unwrap(); + s.execute("cell.set", json!({"cell": "F1", "input": "older"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:B2"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "E1"})).unwrap(); + assert_eq!(v(&s, "E1"), Value::Number(1.0)); + assert_eq!(v(&s, "E2"), Value::Empty, "B1 was blank"); + assert_eq!(v(&s, "F1"), Value::Number(3.0)); + assert_eq!(v(&s, "F2"), Value::Number(4.0)); +} + +/// A copy tiled over a larger selection clears the non-blank destination cells under its blanks, +/// in every tile. +#[test] +fn tiled_paste_clears_destination_cells_under_blank_source_cells() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "x"})).unwrap(); + s.execute("cell.set", json!({"cell": "C4", "input": "y"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A2"})).unwrap(); + s.execute("selection.set", json!({"range": "C1:C4"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(1.0)); + assert_eq!(v(&s, "C2"), Value::Empty); + assert_eq!(v(&s, "C3"), Value::Number(1.0)); + assert_eq!(v(&s, "C4"), Value::Empty); +} + +/// Excel refuses a paste that would run off the sheet rather than pasting part of it: whole +/// columns below row 1, whole rows right of column A, a whole column transposed, or a block at +/// the last row or column. +#[test] +fn paste_that_does_not_fit_on_the_sheet_is_refused() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "C1", "values": [[1, 2], [3, 4]]})).unwrap(); + s.execute("cell.set", json!({"cell": "A15", "input": "x"})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + s.execute("edit.copy", json!({"range": "C:D"})).unwrap(); + assert!(s.execute("edit.paste", json!({"at": "B8"})).is_err()); + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "A1"})).is_err()); + s.execute("edit.copy", json!({"range": "15:15"})).unwrap(); + assert!(s.execute("edit.paste", json!({"at": "AN17"})).is_err()); + s.execute("edit.copy", json!({"range": "C1:D2"})).unwrap(); + assert!(s.execute("edit.paste", json!({"at": "XFD1"})).is_err()); + assert!(s.execute("edit.paste", json!({"at": "A1048576"})).is_err()); + s.execute("edit.cut", json!({"range": "C1:D2"})).unwrap(); + assert!(s.execute("edit.paste", json!({"at": "XFD1"})).is_err()); + let after = s.doc().unwrap().wb.clone(); + assert_eq!(after.active().unwrap().cells.len(), before.active().unwrap().cells.len()); + assert_eq!(v(&s, "C1"), Value::Number(1.0)); + // Whole rows still paste at column A, whole columns at row 1. + s.execute("edit.copy", json!({"range": "15:15"})).unwrap(); + s.execute("edit.paste", json!({"at": "A20"})).unwrap(); + assert_eq!(v(&s, "A20"), Value::Text("x".into())); +} + +/// Paste visits only cells that hold something on either side: a blank copied cell still clears +/// (or reformats) a destination cell that holds something, and blank onto blank stays blank, even +/// with an operation (Excel leaves it empty rather than writing 0). +#[test] +fn paste_blank_cells_clear_destination_and_blank_onto_blank_stays_blank() { + let mut s = s(); + let has_cell = |s: &Session, a: &str| s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(a).unwrap()).is_some(); + s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); + s.execute("cell.set", json!({"cell": "D2", "input": "7"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A3"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "add", "at": "D1"})).unwrap(); + assert_eq!(v(&s, "D1"), Value::Number(5.0)); + assert_eq!(v(&s, "D2"), Value::Number(7.0)); + assert!(!has_cell(&s, "D3"), "blank + blank stays blank"); + s.execute("cell.set", json!({"cell": "F2", "input": "old"})).unwrap(); + s.execute("edit.paste", json!({"at": "F1"})).unwrap(); + assert_eq!(v(&s, "F1"), Value::Number(5.0)); + assert!(!has_cell(&s, "F2"), "a blank copied cell clears the destination"); + s.execute("home.bold", json!({"range": "H2"})).unwrap(); + assert!(has_cell(&s, "H2")); + s.execute("edit.pasteSpecial", json!({"what": "formats", "at": "H1"})).unwrap(); + assert!(!has_cell(&s, "H2"), "a blank copied cell's format replaces the bold"); +} + +/// Validation pastes over the whole pasted area (a blank copied cell's rule included, as Excel +/// does), and cuts only the pasted cells out of a rule the block lands inside. +#[test] +fn paste_validation_covers_the_pasted_area_as_ranges() { + let mut s = s(); + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "6"})).unwrap(); + s.execute("data.validation", json!({"range": "A1:A3", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("data.validation", json!({"range": "D1:D3", "type": "list", "formula1": "\"x,y\""})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A3"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "all", "skipBlanks": true, "at": "D1"})).unwrap(); + assert!(!ok(&mut s, "D2", "-1"), "the blank copied cell's rule is pasted too"); + assert!(ok(&mut s, "D2", "3")); + assert_eq!(validation_areas(&s), ["A1:A3", "D1:D3"]); + + // A block with a rule on its middle cells pasted inside a bigger list rule. + let mut s = self::s(); + s.execute("data.validation", json!({"range": "A2:A3", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("data.validation", json!({"range": "C1:E10", "type": "list", "formula1": "\"p,q\""})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A4"})).unwrap(); + s.execute("edit.paste", json!({"at": "D4"})).unwrap(); + assert_eq!(validation_areas(&s), ["A2:A3", "C1:E3 C4:C7 E4:E7 C8:E10", "D5:D6"]); + assert!(ok(&mut s, "D4", "anything")); + assert!(!ok(&mut s, "D5", "-1")); + assert!(!ok(&mut s, "D3", "z")); + + // One copied cell tiled over a selection is one rule over the selection. + s.execute("edit.copy", json!({"range": "A2"})).unwrap(); + s.execute("selection.set", json!({"range": "G1:G4"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert!(validation_areas(&s).contains(&"G1:G4".to_string())); +} + +/// Paste Special > Validation, checked against Excel 16.0: A1 has a list rule, A2 is blank with a +/// whole-number rule, A3 is blank with no rule, over C1:C3 that hold a decimal rule. With Skip +/// Blanks a blank source cell that has a rule still pastes it, and one without keeps the +/// destination's rule; without it the destination's rule is cleared. +#[test] +fn paste_validation_skip_blanks_keeps_rules_under_blank_cells_without_one() { + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + for skip in [true, false] { + let mut s = s(); + s.execute("data.validation", json!({"range": "A1", "type": "list", "formula1": "\"x,y\""})).unwrap(); + s.execute("data.validation", json!({"range": "A2", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("data.validation", json!({"range": "C1:C3", "type": "decimal", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A3"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "validation", "skipBlanks": skip, "at": "C1"})).unwrap(); + assert!(!ok(&mut s, "C1", "z"), "C1 gets A1's list rule (skip {skip})"); + assert!(!ok(&mut s, "C2", "1.5"), "C2 gets A2's whole-number rule (skip {skip})"); + assert_eq!(ok(&mut s, "C3", "-1"), !skip, "C3 keeps its decimal rule only when blanks are skipped"); + } +} + +/// A big populated Skip Blanks validation paste is not refused for its cell count: only +/// validation areas are limited, and with no rule under the paste nothing is cleared. +#[test] +fn paste_validation_skip_blanks_large_populated_paste() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1:A100002", "values": (0..100_002).map(|i| vec![i]).collect::>()})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A100002"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "validation", "skipBlanks": true, "at": "C1"})).unwrap(); + assert!(validation_areas(&s).is_empty()); + + // A destination rule is cleared under every populated source cell. Count the resulting + // coalesced ranges, not the 100,002 cells that were considered for clearing. + let mut s = self::s(); + s.execute("range.setValues", json!({"range": "A1:A100002", "values": (0..100_002).map(|i| vec![i]).collect::>()})).unwrap(); + s.execute("data.validation", json!({"range": "C1:C100002", "type": "list", "formula1": "\"x,y\""})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A100002"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "validation", "skipBlanks": true, "at": "C1"})).unwrap(); + assert!(validation_areas(&s).is_empty()); +} + /// Open a workbook by path, and `CELL("filename")` names the file it came from, as Excel does; /// Save As makes the new file the one it names. The sheet-name formula of #1489 depends on it. #[test]