mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-31 14:17:12 -06:00
Merge origin/main into codex/xlsb-import
This commit is contained in:
@@ -662,8 +662,7 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
for (c, t) in out {
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned();
|
||||
let cell = super::edit::input_to_cell(&t, old.as_ref(), &mut cx.wb).unwrap_or(None);
|
||||
let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None);
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(x) => shm.cells.set(c, x),
|
||||
@@ -683,9 +682,15 @@ fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char)
|
||||
let mut cur = String::new();
|
||||
let mut in_q = false;
|
||||
let mut last_delim = false;
|
||||
for c in text.chars() {
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == quote {
|
||||
in_q = !in_q;
|
||||
if in_q && chars.peek() == Some("e) {
|
||||
cur.push(quote);
|
||||
chars.next();
|
||||
} else {
|
||||
in_q = !in_q;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !in_q && delims.contains(&c) {
|
||||
|
||||
@@ -119,9 +119,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// ---------------------------------------------------------------- entry
|
||||
|
||||
/// What typing `input` into a cell produces (constant or formula, plus an automatic number
|
||||
/// format). Errors when the formula can't be parsed.
|
||||
pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
|
||||
let style = old.map(|c| c.style).unwrap_or_default();
|
||||
/// format). Inherits the destination's cell, row or column style, including its number
|
||||
/// format, before parsing. Errors when the formula can't be parsed.
|
||||
pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
|
||||
let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
||||
if input.is_empty() {
|
||||
let c = Cell { value: Value::Empty, formula: None, style };
|
||||
return Ok(if c.is_blank() { None } else { Some(c) });
|
||||
@@ -148,6 +149,7 @@ pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_
|
||||
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 mut cell = Cell::formula(Formula::from_expr(expr));
|
||||
cell.style = style;
|
||||
// Formulas whose result is a date/time get a format from functions like TODAY().
|
||||
@@ -206,8 +208,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if array {
|
||||
// Legacy Ctrl+Shift+Enter array formula over the selection.
|
||||
let range = cx.sel.current();
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(range.start)).cloned();
|
||||
let mut cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?.unwrap_or_default();
|
||||
let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default();
|
||||
if let Some(f) = cell.formula.as_mut() {
|
||||
std::sync::Arc::make_mut(f).array = Some(range);
|
||||
}
|
||||
@@ -216,8 +217,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
cx.touch(sheet, range.start);
|
||||
return Ok(Json::Null);
|
||||
}
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned();
|
||||
let cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?;
|
||||
let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?;
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(c) => sh.cells.set(at, c),
|
||||
@@ -265,15 +265,16 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for (ci, v) in cols.iter().enumerate() {
|
||||
let Some(at) = start.offset(ri as i64, ci as i64) else { continue };
|
||||
let input = json_to_input(v);
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned();
|
||||
let cell = match v {
|
||||
Json::Number(n) => Some(Cell {
|
||||
value: Value::number(n.as_f64().unwrap_or(0.0)),
|
||||
formula: None,
|
||||
style: old.as_ref().map(|c| c.style).unwrap_or_default(),
|
||||
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
|
||||
}),
|
||||
Json::Bool(b) => Some(Cell { value: Value::Bool(*b), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default() }),
|
||||
_ => input_to_cell(&input, old.as_ref(), &mut cx.wb)?,
|
||||
Json::Bool(b) => {
|
||||
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
|
||||
}
|
||||
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
|
||||
};
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
@@ -297,8 +298,7 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let input = str_param(p, "input").unwrap_or("").to_string();
|
||||
let origin = s.doc()?.selection.active;
|
||||
edit(s, |cx| {
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(origin)).cloned();
|
||||
let base = input_to_cell(&input, old.as_ref(), &mut cx.wb)?;
|
||||
let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?;
|
||||
for r in &ranges {
|
||||
if r.count() > 2_000_000 {
|
||||
return Err(bad("range.fill", "range too large"));
|
||||
@@ -665,8 +665,7 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
for (ci, v) in row.iter().enumerate() {
|
||||
let Some(c) = at.offset(ri as i64, ci as i64) else { continue };
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned();
|
||||
let cell = input_to_cell(v, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
|
||||
let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(cell) => sh.cells.set(c, cell),
|
||||
@@ -1175,7 +1174,11 @@ fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.cells
|
||||
.iter()
|
||||
.filter_map(|(c, cell)| {
|
||||
let text = if values || cell.formula.is_none() { crate::display::cell_text(&d.wb, sh, c) } else { cell.input_text() };
|
||||
let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) {
|
||||
crate::display::cell_text(&d.wb, sh, c)
|
||||
} else {
|
||||
cell.input_text()
|
||||
};
|
||||
matches(&text, &what, case, whole).then_some((c, text))
|
||||
})
|
||||
.collect();
|
||||
@@ -1234,8 +1237,7 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
replace_ci(&text, &what, &with)
|
||||
};
|
||||
let old = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).cloned();
|
||||
let cell = input_to_cell(&new, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
|
||||
let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
Some(cell) => sh.cells.set(c, cell),
|
||||
|
||||
@@ -365,6 +365,9 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let base = border_style(str_param(p, "style").unwrap_or("thin"));
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
|
||||
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
for r in &ranges {
|
||||
if r.count() > 2_000_000 {
|
||||
@@ -453,7 +456,29 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
});
|
||||
cx.sheet_mut(sheet)?.set_style(c, new);
|
||||
// Clear the neighbour's facing edge when removing borders, so it really goes.
|
||||
if preset == "none" {
|
||||
// A shared line can be stored on either cell. Selected cells are handled
|
||||
// by this loop; remove only facing edges outside this range.
|
||||
for (boundary, dr, dc) in [(top, -1, 0), (bottom, 1, 0), (left, 0, -1), (right, 0, 1)] {
|
||||
if !boundary {
|
||||
continue;
|
||||
}
|
||||
let Some(neighbor) = c.offset(dr, dc) else { continue };
|
||||
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(neighbor)).unwrap_or_default();
|
||||
let new = cx.wb.styles.derive(st, |s| {
|
||||
let edge = match (dr, dc) {
|
||||
(-1, _) => &mut s.borders.bottom,
|
||||
(1, _) => &mut s.borders.top,
|
||||
(_, -1) => &mut s.borders.right,
|
||||
_ => &mut s.borders.left,
|
||||
};
|
||||
*edge = BorderLine::default();
|
||||
});
|
||||
if new != st {
|
||||
cx.sheet_mut(sheet)?.set_style(neighbor, new);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json::Null)
|
||||
|
||||
@@ -36,14 +36,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
define_name
|
||||
),
|
||||
cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?}", has_doc, delete_name),
|
||||
cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name),
|
||||
cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager),
|
||||
cmd!(
|
||||
"formulas.createFromSelection",
|
||||
"Create from Selection",
|
||||
["Formulas", "Defined Names"],
|
||||
Some("Cmd+Shift+F3"),
|
||||
"{range?, top?: true, left?: false, bottom?, right?}",
|
||||
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}",
|
||||
has_doc,
|
||||
create_from_selection
|
||||
),
|
||||
@@ -218,15 +218,26 @@ fn valid_name(n: &str) -> bool {
|
||||
if !(first.is_alphabetic() || first == '_' || first == '\\') {
|
||||
return false;
|
||||
}
|
||||
if n.len() > 255 || n.eq_ignore_ascii_case("R") || n.eq_ignore_ascii_case("C") {
|
||||
return false;
|
||||
}
|
||||
if CellRef::parse(n).is_some() {
|
||||
if n.len() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) {
|
||||
return false;
|
||||
}
|
||||
n.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?'))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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']) {
|
||||
rest = r.get(digits(r)..).unwrap_or("");
|
||||
}
|
||||
if let Some(c) = rest.strip_prefix(['C', 'c']) {
|
||||
rest = c.get(digits(c)..).unwrap_or("");
|
||||
}
|
||||
rest.is_empty() && !n.is_empty()
|
||||
}
|
||||
|
||||
fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(name) = str_param(p, "name").map(str::to_string) else {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("defineName".into(), json!({})));
|
||||
@@ -264,9 +275,14 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
|
||||
let scope = match str_param(p, "scope") {
|
||||
None => None,
|
||||
Some("Workbook") => Some(None),
|
||||
Some(sh) => Some(Some(s.doc()?.wb.sheet_index(sh).ok_or_else(|| bad("formulas.deleteName", "no such sheet"))?)),
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let before = cx.wb.names.len();
|
||||
cx.wb.names.retain(|n| !n.name.eq_ignore_ascii_case(&name));
|
||||
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(&name) && scope.is_none_or(|sc| n.scope == sc)));
|
||||
if cx.wb.names.len() == before {
|
||||
return Err(bad("formulas.deleteName", "no such name"));
|
||||
}
|
||||
@@ -295,6 +311,9 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = target_range(s, p)?;
|
||||
let top = bool_param(p, "top").unwrap_or(true);
|
||||
let left = bool_param(p, "left").unwrap_or(false);
|
||||
let bottom = bool_param(p, "bottom").unwrap_or(false);
|
||||
let right = bool_param(p, "right").unwrap_or(false);
|
||||
let replace = bool_param(p, "replace").unwrap_or(false);
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let q = gridcraft_formula::quote_sheet(&sh.name);
|
||||
@@ -309,28 +328,38 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let clean = |t: String| -> String {
|
||||
let mut n: String = t.trim().chars().map(|c| if c.is_alphanumeric() || c == '.' { c } else { '_' }).collect();
|
||||
if n.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&n).is_some() {
|
||||
if !n.is_empty() && !valid_name(&n) {
|
||||
n.insert(0, '_');
|
||||
}
|
||||
n
|
||||
if valid_name(&n) { n } else { String::new() }
|
||||
};
|
||||
// The labels in the chosen edge rows/columns name the cells between them.
|
||||
let (r0, r1) = (r.start.row + top as u32, r.end.row.saturating_sub(bottom as u32));
|
||||
let (c0, c1) = (r.start.col + left as u32, r.end.col.saturating_sub(right as u32));
|
||||
let mut made = Vec::new();
|
||||
if top && r.height() > 1 {
|
||||
for c in r.start.col..=r.end.col {
|
||||
let name = clean(sh.value(CellRef::new(r.start.row, c)).display());
|
||||
if !name.is_empty() {
|
||||
made.push((name, abs(RangeRef::new(CellRef::new(r.start.row + 1, c), CellRef::new(r.end.row, c)))));
|
||||
if r0 <= r1 && c0 <= c1 {
|
||||
for (on, row) in [(top, r.start.row), (bottom, r.end.row)] {
|
||||
for c in (c0..=c1).filter(|_| on) {
|
||||
let name = clean(sh.value(CellRef::new(row, c)).display());
|
||||
if !name.is_empty() {
|
||||
made.push((name, abs(RangeRef::new(CellRef::new(r0, c), CellRef::new(r1, c)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if left && r.width() > 1 {
|
||||
for row in r.start.row + top as u32..=r.end.row {
|
||||
let name = clean(sh.value(CellRef::new(row, r.start.col)).display());
|
||||
if !name.is_empty() {
|
||||
made.push((name, abs(RangeRef::new(CellRef::new(row, r.start.col + 1), CellRef::new(row, r.end.col)))));
|
||||
for (on, col) in [(left, r.start.col), (right, r.end.col)] {
|
||||
for row in (r0..=r1).filter(|_| on) {
|
||||
let name = clean(sh.value(CellRef::new(row, col)).display());
|
||||
if !name.is_empty() {
|
||||
made.push((name, abs(RangeRef::new(CellRef::new(row, c0), CellRef::new(row, c1)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Excel asks before replacing an existing name; here it's kept unless `replace` is set, and
|
||||
// reported.
|
||||
let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none());
|
||||
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n));
|
||||
let skipped: Vec<String> = skipped.into_iter().map(|(n, _)| n).collect();
|
||||
let n = made.len();
|
||||
edit(s, |cx| {
|
||||
for (name, f) in made {
|
||||
@@ -338,7 +367,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
cx.wb.names.push(DefinedName { name, scope: None, formula: f, comment: String::new(), hidden: false });
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(json!({"created": n}))
|
||||
Ok(json!({"created": n, "skipped": skipped}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,9 @@ pub struct Ctx<'a> {
|
||||
pub sel: &'a mut Selection,
|
||||
/// Rows whose automatic height should be recomputed (sheet, row).
|
||||
pub fit_rows: Vec<(usize, u32)>,
|
||||
/// The command applied sheet protection itself (row/column inserts and deletes the
|
||||
/// protection allows), so cells it shifts aren't refused as edits of locked cells.
|
||||
pub protection_checked: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
|
||||
@@ -220,9 +223,12 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
let prof = std::env::var_os("GRIDCRAFT_PROFILE").is_some();
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut sel = d.selection.clone();
|
||||
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new() };
|
||||
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
|
||||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, .. } = ctx;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
|
||||
if !protection_checked {
|
||||
check_protection(&d.wb, &wb)?;
|
||||
}
|
||||
if changed.len() <= 200_000 {
|
||||
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
|
||||
}
|
||||
@@ -250,6 +256,43 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Excel's message for an edit of a locked cell on a protected sheet.
|
||||
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
|
||||
|
||||
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
|
||||
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
|
||||
/// deleting a sheet isn't an edit of its cells.
|
||||
fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
for sh in &old.sheets {
|
||||
if !sh.is_protected() {
|
||||
continue;
|
||||
}
|
||||
let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
|
||||
if std::sync::Arc::ptr_eq(sh, after) || !after.is_protected() {
|
||||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
if !same_content(sh.cell(c), after.cell(c)) && old.styles.get(sh.style_id(c)).protection.locked {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two cells hold the same value or formula (formats aside).
|
||||
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => match (&a.formula, &b.formula) {
|
||||
(Some(x), Some(y)) => x.text == y.text && x.array == y.array,
|
||||
(None, None) => a.value == b.value,
|
||||
_ => false,
|
||||
},
|
||||
_ => empty(a) && empty(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
|
||||
@@ -923,7 +923,15 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
HAlign::Justify | HAlign::Fill => HAlign::Left,
|
||||
other => other,
|
||||
};
|
||||
let lines: Vec<String> = if wrap { wrap_lines(&text, font, size, avail.max(size)) } else { vec![text] };
|
||||
let rot = st.align.rotation;
|
||||
let rotated = rot != 0 && rot != 255;
|
||||
// Rotated text wraps along its own direction: upright (±90°) text to the row height.
|
||||
let lines: Vec<String> = match (wrap, rotated) {
|
||||
(true, false) => wrap_lines(&text, font, size, avail.max(size)),
|
||||
(true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)),
|
||||
(true, true) => vec![text.lines().next().unwrap_or_default().to_string()],
|
||||
(false, _) => vec![text],
|
||||
};
|
||||
let line_h = size * 1.2;
|
||||
let n = lines.len().max(1) as f32;
|
||||
let first_baseline = match st.align.v {
|
||||
@@ -933,7 +941,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
};
|
||||
// Overflow into empty neighbours (single-line text, not merged).
|
||||
let (mut clip_l, mut clip_r) = (x, x + w);
|
||||
if !wrap && !merged && !is_num && tw + 2.0 * pad + indent > w {
|
||||
if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w {
|
||||
let need = tw + 2.0 * pad + indent - w;
|
||||
let grow = |dir: i64, mut need: f32| -> f32 {
|
||||
let mut ext = 0.0;
|
||||
@@ -966,13 +974,20 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
let clip_r = clip_r.min(limits.1);
|
||||
pg.save();
|
||||
pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h);
|
||||
let rot = st.align.rotation;
|
||||
if rot != 0 && rot != 255 && !wrap {
|
||||
if rotated {
|
||||
// Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell.
|
||||
// Wrapped lines stack rightward for upward text and leftward for downward text, so
|
||||
// downward text starts its first line far enough in for the last to end at the left pad.
|
||||
let deg = (rot as f32).clamp(-90.0, 90.0);
|
||||
let angle = -deg.to_radians();
|
||||
let (ox, oy) = if deg > 0.0 { (x + pad + size * 0.8 * deg.to_radians().sin(), y + h - pad) } else { (x + pad, y + pad + size * 0.8) };
|
||||
if let Some(t) = lines.first() {
|
||||
let last = lines.len().saturating_sub(1) as f32;
|
||||
for (i, t) in lines.iter().enumerate() {
|
||||
let i = i as f32;
|
||||
let (ox, oy) = if deg > 0.0 {
|
||||
(x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad)
|
||||
} else {
|
||||
(x + pad + (last - i) * line_h, y + pad + size * 0.8)
|
||||
};
|
||||
pg.text_rotated(ox, oy, size, font, color, t, angle);
|
||||
}
|
||||
pg.restore();
|
||||
@@ -1762,4 +1777,26 @@ mod tests {
|
||||
let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0);
|
||||
assert!(w.len() > 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_wrapped_text_prints_rotated() {
|
||||
// Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text.
|
||||
let mut s = s();
|
||||
let header = "Term 1 : Drama : Continuous Assessment";
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap();
|
||||
s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap();
|
||||
s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap();
|
||||
s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap();
|
||||
s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
let pdf = String::from_utf8_lossy(&pdf);
|
||||
// The page holds only these two cells, so every text block on it is a piece of a header.
|
||||
let blocks: Vec<&str> = pdf.split("BT\n").skip(1).filter(|b| b.contains(" Tj")).collect();
|
||||
let up = blocks.iter().filter(|b| b.contains("\n0 1 -1 0 ")).count();
|
||||
let down = blocks.iter().filter(|b| b.contains("\n0 -1 1 0 ")).count();
|
||||
// Every piece of both headers is rotated, each wrapped along the row height into a few long lines.
|
||||
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
|
||||
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+161
-40
@@ -74,20 +74,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
None
|
||||
}
|
||||
};
|
||||
let map_range = |r: RangeRef| -> Option<RangeRef> {
|
||||
let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
|
||||
let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference {
|
||||
sheet: gridcraft_formula::SheetSel::Current,
|
||||
kind: gridcraft_formula::RefKind::Range(
|
||||
gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false },
|
||||
gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false },
|
||||
),
|
||||
});
|
||||
match gridcraft_formula::adjust::adjust(expr, "S", "S", &e) {
|
||||
gridcraft_formula::Expr::Ref(r) => Some(r.range()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
|
||||
let map_range = |r: RangeRef| map_range(r, &e);
|
||||
let map_cell = |c: CellRef| -> Option<CellRef> {
|
||||
match axis {
|
||||
Axis::Rows => map_line(c.row).map(|r| CellRef::new(r, c.col)),
|
||||
@@ -196,8 +184,18 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserting or deleting cells moves the copied block away from the stored source range, so, like
|
||||
/// Excel, it cancels copy/cut mode for this workbook (a later paste would take the wrong cells).
|
||||
fn cancel_copy_mode(s: &mut Session) {
|
||||
let uid = s.doc().map(|d| d.uid).ok();
|
||||
if s.clipboard.as_ref().is_some_and(|c| Some(c.doc_uid) == uid) {
|
||||
s.clipboard = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
check_lines_protection(s, p, axis, at, count, true)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
// Refuse to push data off the sheet.
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
@@ -207,8 +205,9 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
cx.protection_checked = true;
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match axis {
|
||||
@@ -230,14 +229,18 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
})?;
|
||||
cancel_copy_mode(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
check_lines_protection(s, p, axis, at, count, false)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
cx.protection_checked = true;
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let block = match axis {
|
||||
@@ -254,7 +257,9 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
})?;
|
||||
cancel_copy_mode(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -265,27 +270,24 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"column" | "entireColumn" => return insert_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols),
|
||||
_ => {}
|
||||
}
|
||||
let e = Edit::InsertCells { axis: if shift == "right" { Axis::Cols } else { Axis::Rows }, range: r };
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let right = shift == "right";
|
||||
edit(s, |cx| {
|
||||
let out = edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if right {
|
||||
sh.cells.shift_cols_in_rows(r.start.row, r.end.row, r.start.col, r.width() as i64);
|
||||
} else {
|
||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, r.height() as i64);
|
||||
}
|
||||
// Formulas referring to cells that moved: approximate with a whole-sheet move edit.
|
||||
let moved = if right {
|
||||
RangeRef::new(r.start, CellRef::new(r.end.row, MAX_COLS - 1 - r.width()))
|
||||
} else {
|
||||
RangeRef::new(r.start, CellRef::new(MAX_ROWS - 1 - r.height(), r.end.col))
|
||||
};
|
||||
shift_cell_features(sh, &e);
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
let to = if right { (r.start.row, r.start.col + r.width()) } else { (r.start.row + r.height(), r.start.col) };
|
||||
super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved, to_row: to.0, to_col: to.1 });
|
||||
rewrite(&mut cx.wb, &name, &e);
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
})?;
|
||||
cancel_copy_mode(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -296,9 +298,10 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"column" | "entireColumn" => return delete_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols),
|
||||
_ => {}
|
||||
}
|
||||
let e = Edit::DeleteCells { axis: if shift == "left" { Axis::Cols } else { Axis::Rows }, range: r };
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let left = shift == "left";
|
||||
edit(s, |cx| {
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
@@ -308,19 +311,15 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.end.row + 1, -(r.height() as i64));
|
||||
}
|
||||
shift_cell_features(sh, &e);
|
||||
}
|
||||
// References into the deleted block become #REF!; the rest move.
|
||||
let deleted = Edit::Move { from: r, to_row: r.start.row, to_col: r.start.col };
|
||||
let _ = deleted;
|
||||
let moved_from = if left {
|
||||
RangeRef::new(CellRef::new(r.start.row, r.end.col + 1), CellRef::new(r.end.row, MAX_COLS - 1))
|
||||
} else {
|
||||
RangeRef::new(CellRef::new(r.end.row + 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
|
||||
};
|
||||
super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved_from, to_row: r.start.row, to_col: r.start.col });
|
||||
// References into the deleted cells become #REF!; the rest move.
|
||||
rewrite(&mut cx.wb, &name, &e);
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
})?;
|
||||
cancel_copy_mode(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -359,6 +358,39 @@ fn edit_doc<R>(d: &mut crate::DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -
|
||||
super::commit(d, f)
|
||||
}
|
||||
|
||||
/// Sheet protection for inserting or deleting rows/columns: the protection must allow it, and
|
||||
/// deleted lines must not hold locked cells.
|
||||
fn check_lines_protection(s: &Session, p: &Json, axis: Axis, at: u32, count: u32, insert: bool) -> Result<()> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let wb = &s.doc()?.wb;
|
||||
let Some(sh) = wb.sheet(sheet) else { return Ok(()) };
|
||||
let Some(pr) = &sh.protection else { return Ok(()) };
|
||||
let allowed = match (axis, insert) {
|
||||
(Axis::Rows, true) => pr.insert_rows,
|
||||
(Axis::Rows, false) => pr.delete_rows,
|
||||
(Axis::Cols, true) => pr.insert_columns,
|
||||
(Axis::Cols, false) => pr.delete_columns,
|
||||
};
|
||||
let locked = |st: gridcraft_model::StyleId| wb.styles.get(st).protection.locked;
|
||||
let refuse = || EngineError::Other(PROTECTED.into());
|
||||
if !allowed {
|
||||
return Err(refuse());
|
||||
}
|
||||
if insert {
|
||||
return Ok(());
|
||||
}
|
||||
let end = at.saturating_add(count - 1).min(if axis == Axis::Rows { MAX_ROWS - 1 } else { MAX_COLS - 1 });
|
||||
let infos = if axis == Axis::Rows { &sh.rows } else { &sh.cols };
|
||||
if (at..=end).any(|i| locked(infos.get(&i).and_then(|x| x.style).unwrap_or_default())) {
|
||||
return Err(refuse());
|
||||
}
|
||||
let block = if axis == Axis::Rows { RangeRef::rows(at, end) } else { RangeRef::cols(at, end) };
|
||||
if sh.cells.iter_range(block).any(|(_, cell)| locked(cell.style)) {
|
||||
return Err(refuse());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let i = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
@@ -392,6 +424,13 @@ 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) {
|
||||
let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name);
|
||||
if ne != e {
|
||||
n.formula = gridcraft_formula::print(&ne);
|
||||
}
|
||||
}
|
||||
}
|
||||
if cx.wb.active_sheet >= cx.wb.sheets.len() || cx.wb.active_sheet > i {
|
||||
cx.wb.active_sheet = cx.wb.active_sheet.saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1));
|
||||
@@ -461,10 +500,31 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if copy {
|
||||
let mut sh = (*src).clone();
|
||||
let base = sh.name.clone();
|
||||
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base);
|
||||
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone());
|
||||
sh.name = name.chars().take(31).collect();
|
||||
sh.tables.clear(); // table names must be unique
|
||||
let at = to.min(n);
|
||||
// The copy gets its own copies of the sheet's names, referring to the copy.
|
||||
let copies: Vec<gridcraft_model::DefinedName> = cx
|
||||
.wb
|
||||
.names
|
||||
.iter()
|
||||
.filter(|nm| nm.scope == Some(i))
|
||||
.map(|nm| {
|
||||
let formula = gridcraft_formula::parse(&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() }
|
||||
})
|
||||
.collect();
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += 1;
|
||||
}
|
||||
}
|
||||
cx.wb.names.extend(copies);
|
||||
cx.wb.sheets.insert(at, Arc::new(sh));
|
||||
cx.wb.active_sheet = at;
|
||||
} else {
|
||||
@@ -472,6 +532,17 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let at = to.min(n - 1);
|
||||
cx.wb.sheets.insert(at, sh);
|
||||
cx.wb.active_sheet = at;
|
||||
// Sheet-level names follow their sheet.
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut() {
|
||||
*sc = match *sc {
|
||||
x if x == i => at,
|
||||
x if i < x && x <= at => x - 1,
|
||||
x if at <= x && x < i => x + 1,
|
||||
x => x,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
@@ -538,3 +609,53 @@ fn step_sheet(s: &mut Session, dir: i64) -> Result<Json> {
|
||||
s.doc_mut()?.wb_switch_sheet(i as usize);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Where a range of the edited sheet goes after a structural edit (`None` = deleted).
|
||||
fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
|
||||
let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference {
|
||||
sheet: gridcraft_formula::SheetSel::Current,
|
||||
kind: gridcraft_formula::RefKind::Range(
|
||||
gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false },
|
||||
gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false },
|
||||
),
|
||||
});
|
||||
match gridcraft_formula::adjust::adjust(expr, "S", "S", e) {
|
||||
gridcraft_formula::Expr::Ref(r) => Some(r.range()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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() {
|
||||
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
|
||||
}
|
||||
sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
|
||||
for dv in sh.validations.iter_mut() {
|
||||
dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
|
||||
}
|
||||
sh.validations.retain(|dv| !dv.ranges.is_empty());
|
||||
for t in sh.tables.iter_mut() {
|
||||
t.range = map_range(t.range, e).unwrap_or_default();
|
||||
}
|
||||
sh.tables.retain(|t| t.range != RangeRef::default());
|
||||
if let Some(af) = sh.autofilter.as_mut() {
|
||||
match map_range(af.range, e) {
|
||||
Some(r) => af.range = r,
|
||||
None => sh.autofilter = None,
|
||||
}
|
||||
}
|
||||
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
for sp in sh.sparklines.iter_mut() {
|
||||
if let Some(c) = map_cell(sp.cell) {
|
||||
sp.cell = c;
|
||||
}
|
||||
}
|
||||
if let Some(pa) = sh.print.print_area {
|
||||
sh.print.print_area = map_range(pa, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,8 +489,7 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
for (c, v) in cells.iter().zip(sc.values.iter()) {
|
||||
let input = super::edit::json_to_input(v);
|
||||
let old = cx.wb.sheet(si).and_then(|sh| sh.cell(*c)).cloned();
|
||||
let cell = super::edit::input_to_cell(&input, old.as_ref(), &mut cx.wb)?;
|
||||
let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?;
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
Some(cell) => sh.cells.set(*c, cell),
|
||||
|
||||
@@ -31,11 +31,28 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
|
||||
format_value(v, &parsed(code), wb.date_system)
|
||||
}
|
||||
|
||||
/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a
|
||||
/// protected sheet. A spilled cell follows its anchor.
|
||||
pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
|
||||
if !sheet.is_protected() {
|
||||
return false;
|
||||
}
|
||||
let at = match sheet.cell(c) {
|
||||
Some(x) if x.formula.is_some() => c,
|
||||
_ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) {
|
||||
Some((anchor, _)) => *anchor,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
wb.styles.get(sheet.style_id(at)).protection.hidden
|
||||
}
|
||||
|
||||
/// The text a cell shows (full precision General; the grid narrows General to fit the column).
|
||||
pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
let v = sheet.value(c);
|
||||
if sheet.show_formulas
|
||||
&& let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref())
|
||||
&& !formula_hidden(wb, sheet, c)
|
||||
{
|
||||
return format!("={}", f.text);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,77 @@ fn v(s: &Session, a: &str) -> Value {
|
||||
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_empty_cells_preserves_whole_sheet_formatting() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "existing"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1:XFD1048576"})).unwrap();
|
||||
s.execute("home.alignCenter", json!({})).unwrap();
|
||||
let border = gridcraft_model::BorderLine { style: gridcraft_model::BorderStyle::Thin, color: gridcraft_model::Color::Auto };
|
||||
s.execute("home.formatCells", json!({"style": {"borders": {"top": border, "bottom": border, "left": border, "right": border}}})).unwrap();
|
||||
let expected = {
|
||||
let d = s.doc().unwrap();
|
||||
d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("B1").unwrap())).clone()
|
||||
};
|
||||
assert_eq!(expected.align.h, gridcraft_model::HAlign::Center);
|
||||
for (cell, input) in [("A1", "edited"), ("B1", "new"), ("C1", "=1+2"), ("XFD1048576", "edge")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(cell).unwrap())), &expected, "{cell}");
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "D1", "values": [["text", 7, true]]})).unwrap();
|
||||
for cell in ["D1", "E1", "F1"] {
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(cell).unwrap())), &expected, "{cell}");
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(v(&s, "D1").is_empty());
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("D1").unwrap())), &expected);
|
||||
assert_eq!(d.wb.active().unwrap().cells.len(), 7);
|
||||
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&d.wb).unwrap()).unwrap();
|
||||
for cell in ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "XFD1048576"] {
|
||||
assert_eq!(
|
||||
reopened.styles.get(reopened.active().unwrap().style_id(CellRef::parse(cell).unwrap())),
|
||||
&expected,
|
||||
"{cell} after XLSX round trip"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() {
|
||||
let mut s = s();
|
||||
s.execute("home.bold", json!({"range": "B:B", "on": true})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "3:3", "style": {"num_fmt": "@", "font": {"italic": true}}})).unwrap();
|
||||
s.execute("home.fontColor", json!({"range": "B3", "color": "#FF0000"})).unwrap();
|
||||
let expected = {
|
||||
let d = s.doc().unwrap();
|
||||
d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("B3").unwrap())).clone()
|
||||
};
|
||||
s.execute("cell.set", json!({"cell": "B3", "input": "=1+2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C3", "input": "00123"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "D3", "text": "=2+3\t00456"})).unwrap();
|
||||
assert_eq!(v(&s, "B3"), Value::text("=1+2"));
|
||||
assert_eq!(v(&s, "C3"), Value::text("00123"));
|
||||
assert_eq!(v(&s, "D3"), Value::text("=2+3"));
|
||||
assert_eq!(v(&s, "E3"), Value::text("00456"));
|
||||
s.execute("cell.set", json!({"cell": "B4", "input": "12%"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("B3").unwrap())), &expected);
|
||||
for cell in ["C3", "D3", "E3"] {
|
||||
let st = d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap()));
|
||||
assert!(st.font.italic);
|
||||
assert!(!st.font.bold, "row style takes precedence over column style");
|
||||
assert_eq!(st.num_fmt.as_str(), "@");
|
||||
}
|
||||
let percent = d.wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap()));
|
||||
assert!(percent.font.bold);
|
||||
assert_ne!(percent.num_fmt.as_str(), "General");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_values_and_formulas() {
|
||||
let mut s = s();
|
||||
@@ -39,6 +110,81 @@ fn undo_redo() {
|
||||
assert!(s.doc().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_border_clears_only_shared_edges_and_undo_restores_them() {
|
||||
use gridcraft_core::RangeRef;
|
||||
use gridcraft_model::{BorderLine, Borders};
|
||||
|
||||
for (range, neighbors) in [
|
||||
("B2", &[("B1", "bottom"), ("B3", "top"), ("A2", "right"), ("C2", "left")][..]),
|
||||
(
|
||||
"B2:C3",
|
||||
&[("B1", "bottom"), ("C1", "bottom"), ("B4", "top"), ("C4", "top"), ("A2", "right"), ("A3", "right"), ("D2", "left"), ("D3", "left")][..],
|
||||
),
|
||||
] {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D4", "input": "=SUM(A1:C1)"})).unwrap();
|
||||
s.execute("home.bold", json!({"range": "A1:D4", "on": true})).unwrap();
|
||||
for preset in ["all", "diagonalDown"] {
|
||||
s.execute("home.borders", json!({"range": "A1:D4", "preset": preset, "style": "double", "color": "#CC3344"})).unwrap();
|
||||
}
|
||||
let before: Vec<_> = RangeRef::parse("A1:D4").unwrap().iter().map(|c| s.execute("cell.get", json!({"cell": c.a1()})).unwrap()).collect();
|
||||
s.execute("home.borders", json!({"range": range, "preset": "none"})).unwrap();
|
||||
let selected = RangeRef::parse(range).unwrap();
|
||||
for original in &before {
|
||||
let address = original["cell"].as_str().unwrap();
|
||||
let mut expected = original.clone();
|
||||
if selected.contains(CellRef::parse(address).unwrap()) {
|
||||
expected["style"]["borders"] = json!(Borders::default());
|
||||
} else if let Some((_, edge)) = neighbors.iter().find(|(cell, _)| *cell == address) {
|
||||
expected["style"]["borders"][*edge] = json!(BorderLine::default());
|
||||
}
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": address})).unwrap(), expected, "clearing {range}, cell {address}");
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
for original in before {
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": original["cell"]})).unwrap(), original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_border_handles_sheet_corners_without_creating_out_of_bounds_cells() {
|
||||
for (range, corner, neighbors) in [
|
||||
("A1:B2", "A1", [("B1", "left"), ("A2", "top")]),
|
||||
("XFC1048575:XFD1048576", "XFD1048576", [("XFC1048576", "right"), ("XFD1048575", "bottom")]),
|
||||
] {
|
||||
let mut s = s();
|
||||
s.execute("home.borders", json!({"range": range, "preset": "all"})).unwrap();
|
||||
s.execute("home.borders", json!({"range": corner, "preset": "none"})).unwrap();
|
||||
for (neighbor, edge) in neighbors {
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": neighbor})).unwrap()["style"]["borders"][edge]["style"], "None");
|
||||
}
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(sh.cells.iter().all(|(c, _)| c.is_valid()));
|
||||
assert!(sh.cells.iter().count() <= 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borders_respect_sheet_formatting_protection() {
|
||||
let mut s = s();
|
||||
s.execute("home.borders", json!({"range": "A1:B2", "preset": "all"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
let undo_count = s.doc().unwrap().undo.len();
|
||||
for preset in ["none", "left"] {
|
||||
assert!(s.execute("home.borders", json!({"range": "A1", "preset": preset})).is_err());
|
||||
assert_eq!(s.doc().unwrap().wb.styles, before.styles);
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().cells, before.active().unwrap().cells);
|
||||
assert_eq!(s.doc().unwrap().undo.len(), undo_count);
|
||||
}
|
||||
s.execute("review.protectSheet", json!({"formatCells": true})).unwrap();
|
||||
s.execute("home.borders", json!({"range": "A1", "preset": "none"})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "B1"})).unwrap()["style"]["borders"]["left"]["style"], "None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_shifts_formulas() {
|
||||
let mut s = s();
|
||||
@@ -70,6 +216,27 @@ fn insert_delete_rows_adjust() {
|
||||
assert_eq!(v(&s, "A1"), Value::Number(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_or_deleting_cells_cancels_copy_mode() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
|
||||
s.execute("edit.cut", json!({"range": "3:3"})).unwrap();
|
||||
s.execute("home.insertRows", json!({"rows": "3:3"})).unwrap();
|
||||
assert!(s.clipboard.is_none());
|
||||
// The cut data is now in row 4; a paste must not move the new blank row 3 instead.
|
||||
assert!(s.execute("edit.paste", json!({"at": "A10"})).is_err());
|
||||
assert_eq!(v(&s, "A4"), Value::Number(3.0));
|
||||
s.execute("edit.copy", json!({"range": "A:A"})).unwrap();
|
||||
s.execute("home.deleteColumns", json!({"cols": "B:B"})).unwrap();
|
||||
assert!(s.clipboard.is_none());
|
||||
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
|
||||
s.execute("home.insertCells", json!({"range": "A1", "shift": "down"})).unwrap();
|
||||
assert!(s.clipboard.is_none());
|
||||
s.execute("edit.copy", json!({"range": "A2"})).unwrap();
|
||||
s.execute("home.deleteCells", json!({"range": "A1", "shift": "up"})).unwrap();
|
||||
assert!(s.clipboard.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_series() {
|
||||
let mut s = s();
|
||||
@@ -84,6 +251,53 @@ fn fill_series() {
|
||||
assert_eq!(v(&s, "C2"), Value::from("Item 10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_delete_cells_adjust() {
|
||||
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=SUM(A1:A5)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=A4*10"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C3", "input": "=SUM(A1:B5)"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Fourth", "refersTo": "=Sheet1!$A$4"})).unwrap();
|
||||
s.execute(
|
||||
"home.conditionalFormat",
|
||||
json!({"range": "A4:A5", "rule": {"type": "cellIs", "operator": "greater", "value": "3", "preset": "redText"}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("data.validation", json!({"range": "A5", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:A5", "type": "line"})).unwrap();
|
||||
// Insert A3 shifting down: ranges across the insertion grow, references below move.
|
||||
s.execute("home.insertCells", json!({"range": "A3", "shift": "down"})).unwrap();
|
||||
assert_eq!(formula(&mut s, "C1"), "=SUM(A1:A6)");
|
||||
assert_eq!(formula(&mut s, "C2"), "=A5*10");
|
||||
assert_eq!(formula(&mut s, "C3"), "=SUM(A1:B5)");
|
||||
assert_eq!(v(&s, "C2"), Value::Number(40.0));
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert_eq!(s.doc().unwrap().wb.names[0].formula, "Sheet1!$A$5");
|
||||
assert_eq!(sh.cond_formats[0].ranges[0].a1(), "A5:A6");
|
||||
assert_eq!(sh.validations[0].ranges[0].a1(), "A6");
|
||||
assert_eq!(sh.charts[0].series[0].values, "Sheet1!$A$1:$A$6");
|
||||
// Delete A5 (the 4) shifting up: references to it become #REF!, those below move up.
|
||||
s.execute("home.deleteCells", json!({"range": "A5", "shift": "up"})).unwrap();
|
||||
assert_eq!(formula(&mut s, "C1"), "=SUM(A1:A5)");
|
||||
assert_eq!(formula(&mut s, "C2"), "=#REF!*10");
|
||||
assert_eq!(s.doc().unwrap().wb.names[0].formula, "#REF!");
|
||||
assert_eq!(v(&s, "C1"), Value::Number(11.0));
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert_eq!(sh.cond_formats[0].ranges[0].a1(), "A5");
|
||||
assert_eq!(sh.validations[0].ranges[0].a1(), "A5");
|
||||
assert_eq!(sh.charts[0].series[0].values, "Sheet1!$A$1:$A$5");
|
||||
// Shifting right and left.
|
||||
s.execute("range.setValues", json!({"range": "E1", "values": [[1, 2, 3]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "E3", "input": "=G1+SUM(E1:G1)"})).unwrap();
|
||||
s.execute("home.insertCells", json!({"range": "F1", "shift": "right"})).unwrap();
|
||||
assert_eq!(formula(&mut s, "E3"), "=H1+SUM(E1:H1)");
|
||||
s.execute("home.deleteCells", json!({"range": "E1:F1", "shift": "left"})).unwrap();
|
||||
assert_eq!(formula(&mut s, "E3"), "=F1+SUM(E1:F1)");
|
||||
assert_eq!(v(&s, "E3"), Value::Number(8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_and_filter() {
|
||||
let mut s = s();
|
||||
@@ -97,6 +311,100 @@ fn sort_and_filter() {
|
||||
assert_eq!(r["hiddenRows"], 1);
|
||||
}
|
||||
|
||||
/// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected.
|
||||
fn protected(extra: serde_json::Value) -> Session {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1+D1"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "D1", "values": [[10], [20], [30]]})).unwrap();
|
||||
s.execute("home.lockCell", json!({"range": "D1:D3", "on": false})).unwrap();
|
||||
s.execute("home.lockCell", json!({"range": "10:10", "on": false})).unwrap();
|
||||
s.execute("review.protectSheet", extra).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_refuses_edits_of_locked_cells() {
|
||||
let refused = |s: &mut Session, id: &str, p: serde_json::Value| {
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
let e = s.execute(id, p).expect_err(id).to_string();
|
||||
assert!(e.contains("protected sheet"), "{id}: {e}");
|
||||
assert_eq!(*s.doc().unwrap().wb, *before, "{id} changed the workbook");
|
||||
};
|
||||
let mut s = protected(json!({}));
|
||||
refused(&mut s, "cell.set", json!({"cell": "A1", "input": "9"}));
|
||||
refused(&mut s, "edit.clearContents", json!({"range": "A1:A3"}));
|
||||
refused(&mut s, "edit.clearAll", json!({"range": "A1"}));
|
||||
refused(&mut s, "range.setValues", json!({"range": "A2", "values": [[9]]}));
|
||||
refused(&mut s, "range.fill", json!({"range": "A5:A6", "input": "x"}));
|
||||
refused(&mut s, "edit.fillDown", json!({"range": "A1:A3"}));
|
||||
refused(&mut s, "edit.fillRight", json!({"range": "A1:B1"}));
|
||||
refused(&mut s, "edit.fillUp", json!({"range": "A1:A3"}));
|
||||
refused(&mut s, "edit.fillLeft", json!({"range": "A1:B1"}));
|
||||
refused(&mut s, "edit.autoFill", json!({"source": "A1", "target": "A1:A5"}));
|
||||
s.execute("edit.copy", json!({"range": "D1"})).unwrap();
|
||||
refused(&mut s, "edit.paste", json!({"at": "A2"}));
|
||||
refused(&mut s, "edit.paste", json!({"at": "A2", "text": "x\ty"}));
|
||||
refused(&mut s, "home.deleteRows", json!({"rows": "2:2"}));
|
||||
refused(&mut s, "home.deleteColumns", json!({"cols": "A:A"}));
|
||||
refused(&mut s, "home.insertRows", json!({"rows": "1:1"}));
|
||||
refused(&mut s, "home.deleteCells", json!({"range": "A1", "shift": "up"}));
|
||||
refused(&mut s, "home.insertCells", json!({"range": "A1", "shift": "down"}));
|
||||
refused(&mut s, "edit.replace", json!({"what": "2", "with": "5"}));
|
||||
refused(&mut s, "data.goalSeek", json!({"set": "B1", "to": 50, "changing": "A1"}));
|
||||
// Unlocked cells stay editable.
|
||||
s.execute("edit.paste", json!({"at": "D2"})).unwrap();
|
||||
assert_eq!(v(&s, "D2"), Value::Number(10.0));
|
||||
s.execute("range.setValues", json!({"range": "D2", "values": [[5]]})).unwrap();
|
||||
s.execute("edit.fillDown", json!({"range": "D1:D3"})).unwrap();
|
||||
assert_eq!(v(&s, "D3"), Value::Number(10.0));
|
||||
s.execute("edit.clearContents", json!({"range": "D3"})).unwrap();
|
||||
assert_eq!(v(&s, "D3"), Value::Empty);
|
||||
s.execute("data.goalSeek", json!({"set": "B1", "to": 50, "changing": "D1"})).unwrap();
|
||||
assert!((v(&s, "B1").as_f64().unwrap() - 50.0).abs() < 0.001);
|
||||
s.execute("edit.replace", json!({"what": "49", "with": "48", "wholeCell": true})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_allows_permitted_row_and_column_edits() {
|
||||
let mut s = protected(json!({"insertRows": true, "deleteRows": true, "insertColumns": true}));
|
||||
// Inserting shifts locked cells, which the protection allows.
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
assert_eq!(v(&s, "A2"), Value::Number(1.0));
|
||||
s.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap();
|
||||
// Deleting a row with locked cells isn't; deleting an unlocked row is.
|
||||
assert!(s.execute("home.deleteRows", json!({"rows": "2:2"})).is_err());
|
||||
s.execute("home.deleteRows", json!({"rows": "11:11"})).unwrap();
|
||||
assert!(s.execute("home.deleteColumns", json!({"cols": "Z:Z"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_formulas_stay_hidden_on_protected_sheets() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)*7"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=1+1"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap();
|
||||
s.execute("formulas.showFormulas", json!({"on": true})).unwrap();
|
||||
let text = |s: &Session, a: &str| {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
crate::display::cell_text(&d.wb, sh, CellRef::parse(a).unwrap())
|
||||
};
|
||||
assert_eq!(text(&s, "A1"), "=SEQUENCE(3)*7");
|
||||
assert_eq!(s.execute("edit.find", json!({"what": "SEQUENCE", "all": true})).unwrap()["count"], 1);
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
assert_eq!(text(&s, "A1"), "7");
|
||||
assert_eq!(text(&s, "B1"), "=1+1");
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert!(crate::display::formula_hidden(&d.wb, sh, CellRef::parse("A2").unwrap()));
|
||||
assert!(!crate::display::formula_hidden(&d.wb, sh, CellRef::parse("B1").unwrap()));
|
||||
assert!(s.execute("edit.find", json!({"what": "SEQUENCE", "all": true})).is_err());
|
||||
let r = s.execute("edit.find", json!({"what": "7", "all": true})).unwrap();
|
||||
assert_eq!(r["results"][0]["value"], "7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting_and_styles() {
|
||||
let mut s = s();
|
||||
@@ -110,6 +418,23 @@ fn formatting_and_styles() {
|
||||
s.execute("home.cellStyle", json!({"range": "D1", "name": "Good"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_references_roundtrip_through_xlsx() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=SEQUENCE(4)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=sum(b1#)"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Seq", "refersTo": "=Sheet1!$B$1#"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=MAX(Seq)"})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "C1"})).unwrap()["formula"], "=SUM(B1#)");
|
||||
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0)));
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "C1"})).unwrap()["formula"], "=SUM(B1#)");
|
||||
assert_eq!(s.doc().unwrap().wb.names[0].formula, "Sheet1!$B$1#");
|
||||
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_and_structured_refs() {
|
||||
let mut s = s();
|
||||
@@ -121,6 +446,65 @@ fn tables_and_structured_refs() {
|
||||
assert_eq!(v(&s, "B4"), Value::Number(7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_names_from_selection() {
|
||||
let names = |s: &Session| -> Vec<(String, String)> {
|
||||
let mut v: Vec<_> = s.doc().unwrap().wb.names.iter().map(|n| (n.name.clone(), n.formula.clone())).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let named = |n: &str, f: &str| (n.to_string(), f.to_string());
|
||||
let mut s = s();
|
||||
s.execute(
|
||||
"range.setValues",
|
||||
json!({"range": "A1", "values": [["", "Jan", "Feb", ""], ["North", 1, 2, "N"], ["South", 3, 4, "S"], ["", "First", "Second", ""]]}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("formulas.createFromSelection", json!({"range": "A1:D4", "top": false, "bottom": true, "right": true})).unwrap();
|
||||
assert_eq!(
|
||||
names(&s),
|
||||
[named("First", "Sheet1!$B$1:$B$3"), named("N", "Sheet1!$A$2:$C$2"), named("S", "Sheet1!$A$3:$C$3"), named("Second", "Sheet1!$C$1:$C$3")]
|
||||
);
|
||||
// Existing names are kept unless replacing is asked for.
|
||||
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:C3", "top": true, "left": true})).unwrap();
|
||||
assert_eq!(r["created"], 4);
|
||||
let r = s.execute("formulas.createFromSelection", json!({"range": "B1:C3", "top": true})).unwrap();
|
||||
assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(0), json!(["Jan", "Feb"])));
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "N"})).unwrap();
|
||||
let r = s.execute("formulas.createFromSelection", json!({"range": "B1:B3", "replace": true})).unwrap();
|
||||
assert_eq!(r["created"], 1);
|
||||
assert!(names(&s).contains(&named("N", "Sheet1!$B$2:$B$3")));
|
||||
assert!(names(&s).contains(&named("North", "Sheet1!$B$2:$C$2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_that_look_like_references_are_refused() {
|
||||
let mut s = s();
|
||||
for bad in ["R1C1", "r2", "C3", "RC", "R", "c", "rc12", "A1", "XFD1048576", "1st"] {
|
||||
assert!(s.execute("formulas.defineName", json!({"name": bad, "refersTo": "=1"})).is_err(), "{bad}");
|
||||
}
|
||||
for good in ["R1C1X", "Rate", "RCx", "Rx", "_R1", "Cost"] {
|
||||
s.execute("formulas.defineName", json!({"name": good, "refersTo": "=1"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_names_keep_their_case_in_calls() {
|
||||
let mut s = s();
|
||||
s.execute("formulas.defineName", json!({"name": "Double", "refersTo": "=LAMBDA(x,x*2)"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Quad", "refersTo": "=LAMBDA(x,Double(Double(x)))"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=double(4)+Quad(1)+sum(1)"})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["formula"], "=Double(4)+Quad(1)+SUM(1)");
|
||||
assert_eq!(v(&s, "A1"), Value::Number(13.0));
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["formula"], "=Double(4)+Quad(1)+SUM(1)");
|
||||
let quad = s.doc().unwrap().wb.names.iter().find(|n| n.name == "Quad").unwrap().formula.clone();
|
||||
assert_eq!(quad, "LAMBDA(x,Double(Double(x)))");
|
||||
assert_eq!(v(&s, "A1"), Value::Number(13.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheets() {
|
||||
let mut s = s();
|
||||
@@ -134,6 +518,51 @@ fn sheets() {
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["formula"], "='My Data'!A1*2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_operations_keep_names_on_their_sheets() {
|
||||
let names = |s: &Session| -> Vec<(String, Option<String>, String)> {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let mut v: Vec<_> =
|
||||
wb.names.iter().map(|n| (n.name.clone(), n.scope.and_then(|i| wb.sheet(i)).map(|s| s.name.clone()), n.formula.clone())).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let named = |n: &str, scope: Option<&str>, f: &str| (n.to_string(), scope.map(str::to_string), f.to_string());
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
|
||||
s.execute("home.insertSheet", json!({"name": "Notes"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=Data!$A$1", "scope": "Data"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=Notes!$B$1", "scope": "Notes"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Total", "refersTo": "=Data!$C$1"})).unwrap();
|
||||
// Moving Data (index 1) to the end: its names go with it.
|
||||
s.execute("sheet.move", json!({"sheet": "Data", "to": 2})).unwrap();
|
||||
assert_eq!(names(&s), [named("Rate", Some("Data"), "Data!$A$1"), named("Rate", Some("Notes"), "Notes!$B$1"), named("Total", None, "Data!$C$1")]);
|
||||
// Copying Data to the front copies its sheet-level names, referring to the copy.
|
||||
s.execute("sheet.move", json!({"sheet": "Data", "to": 0, "copy": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.sheets[0].name, "Data (2)");
|
||||
assert_eq!(
|
||||
names(&s),
|
||||
[
|
||||
named("Rate", Some("Data"), "Data!$A$1"),
|
||||
named("Rate", Some("Data (2)"), "'Data (2)'!$A$1"),
|
||||
named("Rate", Some("Notes"), "Notes!$B$1"),
|
||||
named("Total", None, "Data!$C$1")
|
||||
]
|
||||
);
|
||||
// Deleting Data removes its names; names referring to it become #REF!.
|
||||
s.execute("home.deleteSheet", json!({"sheet": "Data"})).unwrap();
|
||||
assert_eq!(
|
||||
names(&s),
|
||||
[named("Rate", Some("Data (2)"), "'Data (2)'!$A$1"), named("Rate", Some("Notes"), "Notes!$B$1"), named("Total", None, "#REF!")]
|
||||
);
|
||||
// Deleting a name in one scope keeps the others.
|
||||
s.execute("formulas.deleteName", json!({"name": "rate", "scope": "Notes"})).unwrap();
|
||||
assert_eq!(names(&s), [named("Rate", Some("Data (2)"), "'Data (2)'!$A$1"), named("Total", None, "#REF!")]);
|
||||
assert!(s.execute("formulas.deleteName", json!({"name": "Total", "scope": "Notes"})).is_err());
|
||||
s.execute("formulas.deleteName", json!({"name": "Total", "scope": "Workbook"})).unwrap();
|
||||
assert_eq!(names(&s).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xlsx_roundtrip_through_engine() {
|
||||
let mut s = s();
|
||||
|
||||
Reference in New Issue
Block a user