use super::clipboard::What; use super::{ Cell, CellRef, EngineError, Formula, Json, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, str_param, 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(()) } 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") { "all" => What::All, "formulas" => What::Formulas, "values" => What::Values, "formats" => What::Formats, "comments" | "notes" => What::Comments, "validation" => What::Validation, "allExceptBorders" => What::AllExceptBorders, "columnWidths" => What::ColumnWidths, "formulasAndNumberFormats" => What::FormulasAndNumberFormats, "valuesAndNumberFormats" => What::ValuesAndNumberFormats, other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))), }; let op = str_param(p, "operation").unwrap_or("none").to_string(); let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false); let transpose = bool_param(p, "transpose").unwrap_or(false); let link = bool_param(p, "link").unwrap_or(false); if link && !clip.areas.is_empty() { return Err(EngineError::Other("This action won't work on multiple selections.".into())); } 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; let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; // Tile the copy over a larger destination that is a multiple of its size. let tile = sel.height() % h == 0 && sel.width() % w == 0 && !sel.is_single() && cell_param_on(s, p, "at", dest_sheet)?.is_none() && !sel.is_full_cols() && !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) }; 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 mut style_map = std::collections::HashMap::new(); let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId { if same_doc { return id; } *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_pictures = std::collections::BTreeMap::new(); if cut_move { // Remove the source first (contents and formats), and fix references to it. 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); } moved_formulas = taken; } 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)) }); 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 { format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) }; 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) } 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); } // Numbers stored as text participate; other text and errors stay as they are. apply_operation(&op, &old, &mut new); } } // 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 = dv.ranges.first().map_or(sc, |r| r.start); 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); } } 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 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 } }; rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e)?; cx.structural = true; } let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1); *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; Ok(()) })?; // Formulas and validation from an untrusted file leave the destination untrusted; plain values, // formats (cell styles and merged areas, no conditional formats), column widths and notes // carry nothing active. A linked paste writes a formula into every cell whatever `what` says, // except for column widths and notes, which write no cells at all. let passive = matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats)); if !same_doc && !passive { let d = s.doc_mut()?; d.trust = d.trust.receive(clip.trust); } if cut_move { s.clipboard = None; } Ok(json!({"pasted": s.doc()?.selection.a1()})) } /// 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; // Rows and columns inserted or deleted on the target sheet move its legacy array ranges too // (a cut moves its arrays itself). let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. }); for si in 0..wb.sheets.len() { let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default(); let keys: Vec<(CellRef, std::sync::Arc)> = wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); if keys.is_empty() { continue; } let on_target = moves_arrays && host.eq_ignore_ascii_case(target); let Some(sheet) = wb.sheet_mut(si) else { continue }; for (c, f) in keys { let Some(expr) = f.expr() else { continue }; let new = gridcraft_formula::adjust::adjust(expr, &host, target, e); let text = gridcraft_formula::print(&new); let array = match f.array { Some(a) if on_target => crate::cmd::sheet::map_range(a, e), a => a, }; if (text != f.text || array != f.array) && let Some(cell) = sheet.cells.get_mut(c) { 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_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); } } rewrite_cell_links(wb, |host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e)); rewrite_python_refs(wb, &|host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e)) } /// Runs `f` over every reference a floating object holds (a chart's series values, categories, /// name and bubble sizes, and the source block it was made from). Every edit that moves /// references calls it: inserting and deleting lines and cells and cut-moves (through /// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an /// object on the sheet named `host` to its new form; text that does not parse is kept. A source /// block that no longer exists is dropped. pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) { for si in 0..wb.sheets.len() { let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue }; let Some(sh) = wb.sheet_mut(si) else { continue }; let fix = |text: &str| -> String { match gridcraft_formula::parse(text) { Ok(expr) => gridcraft_formula::print(&f(&host, expr)), Err(_) => text.to_string(), } }; for ch in sh.charts.iter_mut() { 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); se.name = se.name.as_deref().map(fix); se.bubble_sizes = se.bubble_sizes.as_deref().map(fix); } } } } /// Rewrites the `xl("…")` literals in every Python cell with `f`, which maps a reference held on /// sheet `host` to its new form. Every reference-moving edit (insert and delete of lines or cells, /// cut-move, sheet rename and delete) runs it, as it adjusts formulas. A literal is only replaced /// when the reference it names changed, and text that doesn't parse is kept. A cell's cached /// result is kept while its references still name the same data (a rename, a move); a reference /// the edit deleted (`#REF!`), or one that grew or shrank (rows or columns inserted or deleted /// inside it), leaves the result stale, so the cell is new code (`#BLOCKED!`) and the /// recalculation removes its spill. A rewrite that would /// make a cell's code longer than one cell holds (a longer sheet name, `Z1` becoming `AA1`) refuses /// the whole edit, which then changes nothing: see [`check_python_fits`]. pub(crate) fn rewrite_python_refs( wb: &mut gridcraft_model::Workbook, f: &impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr, ) -> Result<()> { for si in 0..wb.sheets.len() { let Some(sh) = wb.sheets.get(si) else { continue }; let host = sh.name.clone(); let mut new_code: Vec<(CellRef, String, bool)> = Vec::new(); for (c, cell) in sh.cells.iter() { let Some(py) = &cell.py else { continue }; let mut lost = false; let changed = gridcraft_model::python::rewrite_xl(&py.code, |reference| { let expr = gridcraft_formula::parse(reference).ok()?; let before = gridcraft_formula::print(&expr); let after = gridcraft_formula::print(&f(&host, expr)); lost |= (after.contains("#REF!") && !before.contains("#REF!")) || area_shape(&before) != area_shape(&after); (after != before).then_some(after) }); if let Some(code) = changed { check_python_fits(&gridcraft_model::PyCode { code: code.clone(), object: py.object, ..Default::default() }, &host, c)?; new_code.push((c, code, lost)); } } if new_code.is_empty() { continue; } let Some(sh) = wb.sheet_mut(si) else { continue }; for (c, code, lost) in new_code { if let Some(cell) = sh.cells.get_mut(c) && let Some(py) = cell.py.as_mut() { let py = std::sync::Arc::make_mut(py); py.code = code; if lost { py.output = None; cell.value = Value::Error(gridcraft_core::CellError::Blocked); } } } } Ok(()) } /// The rows and columns a printed reference covers (`A1:B3`, `Sheet2!$A$1`, `C:E`), or `None` /// for any other form (a name, an error). fn area_shape(reference: &str) -> Option<(u32, u32)> { let r = RangeRef::parse(reference.rsplit('!').next()?)?; Some((r.height(), r.width())) } /// Refuses Python code an edit has made longer than one cell holds: the limit `python.set` /// enforces and opening a file applies (code over it is dropped to its value on open). The edit /// is refused rather than made, so no code is lost and none keeps a reference the edit moved. pub(crate) fn check_python_fits(py: &gridcraft_model::PyCode, sheet: &str, at: CellRef) -> Result<()> { if py.fits() { return Ok(()); } Err(EngineError::Other(format!( "The edit would make the Python code in {}!{} longer than Excel's {}-character formula limit, so nothing was changed. Shorten the code first.", gridcraft_formula::quote_sheet(sheet), at.a1(), gridcraft_model::python::MAX_FORMULA_CHARS ))) }