Skip to main content

ravenrs_sheet/engine/cmd/
insert.rs

1//! Insert tab: tables, charts, sparklines, pictures, shapes, hyperlinks, comments and notes,
2//! conditional formatting (Home › Styles) and symbols.
3
4use crate::base::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
5use crate::formula::adjust::{Axis, Edit};
6use crate::model::{
7    Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill,
8    Formula, Hyperlink, Image, LegendPos, ObjKind, ObjRef, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn,
9    TotalsFn, Underline, Workbook,
10};
11use serde_json::{Value as Json, json};
12
13use super::sheet::{rewrite, shift_cell_features};
14use super::{
15    CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, cell_param_on, cmd, edit,
16    f64_param, has_doc, locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
17};
18use crate::engine::selection::current_region;
19
20pub fn specs() -> Vec<CommandSpec> {
21    vec![
22        cmd!(
23            "insert.table",
24            "Table",
25            ["Insert", "Tables"],
26            Some("Cmd+T"),
27            "{range?, header?: true, style?: \"TableStyleMedium2\", name?}",
28            has_doc,
29            insert_table
30        ),
31        cmd!(
32            "home.formatAsTable",
33            "Format as Table",
34            ["Home", "Styles"],
35            None,
36            "{range?, style: \"TableStyleMedium2\", header?} (inside a table: restyles it)",
37            has_doc,
38            format_as_table
39        ),
40        cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag(
41            s, p, "totals"
42        )),
43        cmd!("table.bandedRows", "Banded Rows", ["Table", "Table Style Options"], None, "{table?, on?}", has_doc, |s, p| table_flag(
44            s,
45            p,
46            "bandedRows"
47        )),
48        cmd!("table.bandedColumns", "Banded Columns", ["Table", "Table Style Options"], None, "{table?, on?}", has_doc, |s, p| table_flag(
49            s,
50            p,
51            "bandedCols"
52        )),
53        cmd!("table.headerRow", "Header Row", ["Table", "Table Style Options"], None, "{table?, on?}", has_doc, |s, p| table_flag(s, p, "header")),
54        cmd!("table.firstColumn", "First Column", ["Table", "Table Style Options"], None, "{table?, on?}", has_doc, |s, p| table_flag(
55            s, p, "firstCol"
56        )),
57        cmd!("table.lastColumn", "Last Column", ["Table", "Table Style Options"], None, "{table?, on?}", has_doc, |s, p| table_flag(s, p, "lastCol")),
58        cmd!("table.style", "Table Styles", ["Table", "Table Styles"], None, "{table?, style}", has_doc, table_style),
59        cmd!("table.rename", "Table Name", ["Table", "Properties"], None, "{table?, name}", has_doc, table_rename),
60        cmd!("table.convertToRange", "Convert to Range", ["Table", "Tools"], None, "{table?}", has_doc, table_convert),
61        cmd!(
62            "table.totalFunction",
63            "Totals Function",
64            [],
65            None,
66            "{table?, column: \"Sales\", function: sum|average|count|countNums|max|min|stdDev|var|none}",
67            has_doc,
68            table_total_fn
69        ),
70        cmd!(
71            "insert.chart",
72            "Insert Chart",
73            ["Insert", "Charts"],
74            None,
75            "{range?, type?: column|bar|line|pie|doughnut|area|scatter|bubble|radar|combo|histogram|waterfall|funnel|treemap|sunburst|boxWhisker|stock, subtype?: clustered|stacked|stacked100|markers, title?, at?: \"H2\", width?, height?}",
76            has_doc,
77            insert_chart
78        ),
79        cmd!(
80            "insert.recommendedCharts",
81            "Recommended Charts",
82            ["Insert", "Charts"],
83            None,
84            "{range?, type?, subtype?} (no type: the first of insert.chartRecommendations; the ribbon button opens the dialog)",
85            has_doc,
86            |s, p| insert_chart(s, &if p.get("type").is_some() { p.clone() } else { with(p, "type", json!("auto")) })
87        ),
88        cmd!(
89            query "insert.chartRecommendations",
90            "Chart Recommendations",
91            [],
92            None,
93            "{range?} → [{type, subtype, label}] chart types that suit the data's shape, best first",
94            has_doc,
95            |s, p| Ok(Json::Array(recommend_charts(s, p)?.into_iter().map(|(t, sub, label)| json!({"type": t, "subtype": sub, "label": label})).collect()))
96        ),
97        cmd!(
98            "chart.set",
99            "Chart Properties",
100            ["Chart Design"],
101            None,
102            "{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, holeSize?, firstSliceAngle?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
103            has_doc,
104            chart_set
105        ),
106        cmd!("chart.switchRowColumn", "Switch Row/Column", ["Chart Design", "Data"], None, "{chart?}", has_doc, chart_switch),
107        cmd!("chart.delete", "Delete Chart", [], None, "{chart?}", has_doc, chart_delete),
108        cmd!(
109            "insert.sparkline",
110            "Sparklines",
111            ["Insert", "Sparklines"],
112            None,
113            "{range: \"B2:M2\", location: \"N2\", type?: line|column|winLoss, markers?}",
114            has_doc,
115            insert_sparkline
116        ),
117        cmd!(
118            "insert.picture",
119            "Picture",
120            ["Insert", "Illustrations"],
121            None,
122            "{path? | base64?: \"...\", mime?, at?: \"B2\", width?, height?, alt?, mode?: moveOnly|moveAndSize|absolute, placement?: cell|overCells}",
123            has_doc,
124            insert_picture
125        ),
126        cmd!(
127            "insert.shape",
128            "Shapes",
129            ["Insert", "Illustrations"],
130            None,
131            "{kind: rectangle|roundedRectangle|ellipse|triangle|line|arrow|textBox, at?, width?, height?, text?, fill?, line?}",
132            has_doc,
133            insert_shape
134        ),
135        cmd!(
136            "insert.icons",
137            "Icons",
138            ["Insert", "Illustrations"],
139            None,
140            "{name: chart|table|sum|filter|lock|comment|picture|book|folder|search|calc|check|… , at?, size?: 48, color?: hex}",
141            has_doc,
142            insert_icon
143        ),
144        cmd!("insert.textBox", "Text Box", ["Insert", "Text"], None, "{at?, width?, height?, text?}", has_doc, |s, p| insert_shape(
145            s,
146            &with(p, "kind", json!("textBox"))
147        )),
148        cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
149        cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation|control, id}", has_doc, delete_object),
150        cmd!(
151            "object.move",
152            "Move Object",
153            [],
154            None,
155            "{kind: chart|image|shape|slicer|equation|control, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
156            has_doc,
157            move_object
158        ),
159        cmd!(
160            "object.setAnchorMode",
161            "Object Properties",
162            [],
163            None,
164            "{kind: chart|image|shape|slicer|equation|control, id, mode: moveAndSize|moveOnly|absolute}",
165            has_doc,
166            set_anchor_mode
167        ),
168        cmd!(
169            "insert.link",
170            "Link",
171            ["Insert", "Links"],
172            Some("Cmd+K"),
173            "{cell?, target: \"https://…\" | \"Book.xlsx\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}",
174            has_doc,
175            insert_link
176        ),
177        cmd!(
178            "link.open",
179            "Open Hyperlink",
180            [],
181            None,
182            "{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); a reference selects; a web or e-mail address is an error unless the host allows it: the user opens those by clicking the link and confirming; any scheme but http, https and mailto is always an error",
183            has_doc,
184            open_link
185        ),
186        cmd!("review.newNote", "New Note", ["Review", "Notes"], Some("Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
187        cmd!("review.newComment", "New Comment", ["Review", "Comments"], Some("Cmd+Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(
188            s, p, true
189        )),
190        cmd!("review.replyComment", "Reply", [], None, "{cell?, text, author?}", has_doc, reply_comment),
191        cmd!("review.resolveComment", "Resolve Thread", [], None, "{cell?, resolved?: true}", has_doc, resolve_comment),
192        cmd!("review.deleteComment", "Delete Comment", ["Review", "Comments"], None, "{cell?}", has_doc, delete_comment),
193        cmd!("review.showNote", "Show/Hide Note", ["Review", "Notes"], None, "{cell?}", has_doc, show_note),
194        cmd!(
195            "insert.symbol",
196            "Symbol",
197            ["Insert", "Symbols"],
198            None,
199            "{char: \"€\"} (inserted at the end of the active cell's text)",
200            has_doc,
201            insert_symbol
202        ),
203        cmd!(
204            "home.conditionalFormat",
205            "Conditional Formatting",
206            ["Home", "Styles"],
207            None,
208            "{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font?: {bold?, italic?, color?}, fill?: \"#FFC7CE\" | {pattern?, fg?, bg?}, borders?, num_fmt?} (colours \"#RRGGBB\") | preset?: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}; a text `value` is matched as text, or kept when already a quoted literal}",
209            has_doc,
210            add_cf
211        ),
212        cmd!("home.clearRules", "Clear Rules", ["Home", "Styles", "Conditional Formatting"], None, "{range?|sheet: true}", has_doc, clear_cf),
213        cmd!(
214            "home.manageRules",
215            "Manage Rules…",
216            ["Home", "Styles", "Conditional Formatting"],
217            None,
218            "{delete?: index, moveUp?: index, moveDown?: index}",
219            has_doc,
220            manage_cf
221        ),
222    ]
223}
224
225fn with(p: &Json, k: &str, v: Json) -> Json {
226    let mut m = p.as_object().cloned().unwrap_or_default();
227    m.insert(k.into(), v);
228    Json::Object(m)
229}
230
231fn block(s: &Session, p: &Json) -> Result<RangeRef> {
232    if p.get("range").is_some() {
233        return target_range(s, p);
234    }
235    let d = s.doc()?;
236    let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
237    let sel = d.selection.current();
238    if !sel.is_single() {
239        return Ok(sel);
240    }
241    Ok(current_region(sh, d.selection.active))
242}
243
244fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
245    let mut areas = if p.get("range").is_some() {
246        target_ranges(s, p)?
247    } else if s.doc()?.selection.ranges.len() > 1 {
248        s.doc()?.selection.ranges.clone()
249    } else {
250        vec![block(s, p)?]
251    };
252    // Whole columns/rows span a million cells: clip their far edges to the cells in use, so only
253    // a block that really is too large gets refused. Bounded areas keep their extent as given.
254    let sheet = target_sheet(s, p)?;
255    if let Some(used) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
256        // Areas that start outside the used range keep their extent as given.
257        for r in &mut areas {
258            let whole_cols = r.start.row == 0 && r.end.row >= MAX_ROWS - 1;
259            let whole_rows = r.start.col == 0 && r.end.col >= MAX_COLS - 1;
260            if whole_cols && r.start.row <= used.end.row {
261                r.end.row = r.end.row.min(used.end.row);
262            }
263            if whole_rows && r.start.col <= used.end.col {
264                r.end.col = r.end.col.min(used.end.col);
265            }
266        }
267    }
268    Ok(areas)
269}
270
271// ---------------------------------------------------------------- tables
272
273fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
274    let r = block(s, p)?;
275    let sheet = target_sheet(s, p)?;
276    let d = s.doc()?;
277    if d.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&r))) {
278        return Err(EngineError::Other("A table can't overlap another table.".into()));
279    }
280    let arrays = |sh: &crate::model::Sheet| {
281        sh.cells.iter().any(|(_, x)| x.formula.as_ref().and_then(|f| f.array).is_some_and(|a| !a.is_single() && a.intersects(&r)))
282    };
283    if d.wb.sheet(sheet).is_some_and(arrays) {
284        return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into()));
285    }
286    if r.count() > 50_000_000 {
287        return Err(bad("insert.table", "range too large"));
288    }
289    let header = bool_param(p, "header").unwrap_or_else(|| {
290        let sh = d.wb.sheet(sheet);
291        sh.is_some_and(|sh| (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(r.start.row, c)).is_text()) && r.height() > 1)
292    });
293    let name = str_param(p, "name")
294        .map(str::to_string)
295        .unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
296    if !valid_table_name(&name) {
297        return Err(EngineError::Other("The name that you entered is not valid.".into()));
298    }
299    if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
300        return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
301    }
302    // Without a header row, the data moves down a row to make room for one: never a table with it.
303    let moves_a_table = |sh: &crate::model::Sheet| {
304        sh.tables.iter().any(|t| t.range.end.row >= r.start.row && t.range.start.col <= r.end.col && t.range.end.col >= r.start.col)
305    };
306    if !header && d.wb.sheet(sheet).is_some_and(moves_a_table) {
307        return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into()));
308    }
309    let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
310    let id = d.wb.next_object_id();
311    edit(s, |cx| {
312        // Without a header row, insert one with generic names.
313        let r = if header {
314            r
315        } else {
316            let sh = cx.sheet_mut(sheet)?;
317            // The header row pushes the data down one row: never off the end of the sheet.
318            let last = RangeRef::new(CellRef::new(MAX_ROWS - 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col));
319            if sh.cells.iter_range(last).any(|(_, x)| x.has_content()) {
320                return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
321            }
322            sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
323            // Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula
324            // reference (on any sheet) at the cells that moved.
325            let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
326            shift_cell_features(sh, &e);
327            let name = sh.name.clone();
328            rewrite(cx, &name, &e)?;
329            let sh = cx.sheet_mut(sheet)?;
330            for (i, c) in (r.start.col..=r.end.col).enumerate() {
331                sh.set_value(CellRef::new(r.start.row, c), crate::base::Value::text(format!("Column{}", i + 1)));
332            }
333            cx.structural = true;
334            RangeRef::new(r.start, CellRef::new((r.end.row + 1).min(MAX_ROWS - 1), r.end.col))
335        };
336        let sh = cx.sheet_mut(sheet)?;
337        let mut names: Vec<String> = Vec::new();
338        // Lower-case names taken, and the next suffix for each header, so that thousands of equal
339        // headers don't take cubic time.
340        let mut taken = std::collections::HashSet::new();
341        let mut next_suffix = std::collections::HashMap::new();
342        for c in r.start.col..=r.end.col {
343            let mut n = sh.value(CellRef::new(r.start.row, c)).display();
344            if n.is_empty() {
345                n = format!("Column{}", c - r.start.col + 1);
346            }
347            let base = n.clone();
348            let key = base.to_ascii_lowercase();
349            let mut k = next_suffix.get(&key).copied().unwrap_or(2);
350            while taken.contains(&n.to_ascii_lowercase()) {
351                n = format!("{base}{k}");
352                k += 1;
353            }
354            next_suffix.insert(key, k);
355            taken.insert(n.to_ascii_lowercase());
356            // Headers are text.
357            sh.set_value(CellRef::new(r.start.row, c), crate::base::Value::text(n.as_str()));
358            names.push(n);
359        }
360        sh.tables.push(Table {
361            id,
362            name: name.clone(),
363            range: r,
364            header_row: true,
365            totals_row: false,
366            columns: names
367                .into_iter()
368                .map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None })
369                .collect(),
370            style,
371            banded_rows: true,
372            banded_cols: false,
373            first_col: false,
374            last_col: false,
375            filter_button: true,
376            filter: vec![],
377            table_type: None,
378            connection_id: None,
379        });
380        cx.structural = true;
381        Ok(json!({"table": name, "range": r.a1()}))
382    })
383}
384
385/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one.
386fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
387    // Without a range, the active area decides (as in insert_table), not the first one.
388    let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() };
389    let sheet = target_sheet(s, p)?;
390    let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range));
391    match inside {
392        Some((table, range)) => {
393            table_style(s, &with(p, "table", json!(table)))?;
394            Ok(json!({"table": table, "range": range.a1()}))
395        }
396        None => insert_table(s, p),
397    }
398}
399
400fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
401    let d = s.doc()?;
402    if let Some(n) = str_param(p, "table") {
403        return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`")));
404    }
405    let sheet = d.wb.active_sheet();
406    let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
407    let ti = sh
408        .tables
409        .iter()
410        .position(|t| t.range.contains(d.selection.active))
411        .ok_or_else(|| EngineError::Disabled("table".into(), "select a cell in a table".into()))?;
412    Ok((sheet, ti))
413}
414
415fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
416    let (si, ti) = find_table(s, p)?;
417    let on = bool_param(p, "on");
418    edit(s, |cx| {
419        let sh = cx.sheet_mut(si)?;
420        let Some(t) = sh.tables.get_mut(ti) else { return Ok(Json::Null) };
421        match which {
422            "totals" => {
423                let v = on.unwrap_or(!t.totals_row);
424                if v != t.totals_row {
425                    if v && t.range.end.row.saturating_add(1) >= MAX_ROWS {
426                        return Err(EngineError::Other("There's no room below the table for the total row.".into()));
427                    }
428                    t.totals_row = v;
429                    if v {
430                        t.range.end.row += 1;
431                        if let Some(first) = t.columns.first_mut() {
432                            first.totals_label = Some("Total".into());
433                        }
434                        if t.columns.len() > 1
435                            && let Some(last) = t.columns.last_mut()
436                            && last.totals == TotalsFn::None
437                        {
438                            last.totals = TotalsFn::Sum;
439                        }
440                    } else {
441                        let row = t.range.end.row;
442                        t.range.end.row = t.range.end.row.saturating_sub(1);
443                        let (c0, c1) = (t.range.start.col, t.range.end.col);
444                        for c in c0..=c1 {
445                            sh.remove_cell(CellRef::new(row, c));
446                        }
447                    }
448                    write_totals(sh, ti);
449                    cx.structural = true;
450                }
451            }
452            "bandedRows" => t.banded_rows = on.unwrap_or(!t.banded_rows),
453            "bandedCols" => t.banded_cols = on.unwrap_or(!t.banded_cols),
454            "firstCol" => t.first_col = on.unwrap_or(!t.first_col),
455            "lastCol" => t.last_col = on.unwrap_or(!t.last_col),
456            "header" => {
457                t.filter_button = on.unwrap_or(!t.filter_button);
458                if !t.filter_button {
459                    t.filter.clear();
460                }
461            }
462            _ => {}
463        }
464        Ok(Json::Null)
465    })
466}
467
468/// Writes the totals row cells (labels and SUBTOTAL formulas).
469fn write_totals(sh: &mut Sheet, ti: usize) {
470    let Some(t) = sh.tables.get(ti).cloned() else { return };
471    if !t.totals_row {
472        return;
473    }
474    let row = t.range.end.row;
475    for (i, col) in t.columns.iter().enumerate() {
476        let c = CellRef::new(row, t.range.start.col + i as u32);
477        if let Some(code) = col.totals.subtotal_code() {
478            let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name);
479            sh.set_cell(
480                c,
481                Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: crate::base::Value::Empty, style: sh.style_id(c), py: None },
482            );
483        } else if let Some(l) = &col.totals_label {
484            sh.set_value(c, crate::base::Value::text(l.as_str()));
485        } else {
486            sh.remove_cell(c);
487        }
488    }
489}
490
491fn table_total_fn(s: &mut Session, p: &Json) -> Result<Json> {
492    let (si, ti) = find_table(s, p)?;
493    let col = str_param(p, "column").ok_or_else(|| bad("table.totalFunction", "missing `column`"))?.to_string();
494    let f = match str_param(p, "function").unwrap_or("sum") {
495        "average" => TotalsFn::Average,
496        "count" => TotalsFn::Count,
497        "countNums" => TotalsFn::CountNums,
498        "max" => TotalsFn::Max,
499        "min" => TotalsFn::Min,
500        "stdDev" => TotalsFn::StdDev,
501        "var" => TotalsFn::Var,
502        "none" => TotalsFn::None,
503        _ => TotalsFn::Sum,
504    };
505    edit(s, |cx| {
506        let sh = cx.sheet_mut(si)?;
507        if let Some(t) = sh.tables.get_mut(ti)
508            && let Some(i) = t.column_index(&col)
509            && let Some(c) = t.columns.get_mut(i)
510        {
511            c.totals = f;
512        }
513        write_totals(sh, ti);
514        cx.structural = true;
515        Ok(Json::Null)
516    })
517}
518
519fn table_style(s: &mut Session, p: &Json) -> Result<Json> {
520    let (si, ti) = find_table(s, p)?;
521    let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
522    edit(s, |cx| {
523        if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) {
524            t.style = style.clone();
525        }
526        Ok(Json::Null)
527    })
528}
529
530/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`,
531/// starting with a letter, `_` or `\`, and at most 255 characters. Anything else (a hyphen, say) would
532/// read as an operator inside a structured reference, and the writer would rename the table on save.
533fn valid_table_name(name: &str) -> bool {
534    name.chars().count() <= 255
535        && name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
536        && CellRef::parse(name).is_none()
537        && !super::formulas::is_r1c1(name)
538        && name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
539}
540
541fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
542    let (si, ti) = find_table(s, p)?;
543    let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string();
544    if !valid_table_name(&name) {
545        return Err(EngineError::Other("The name that you entered is not valid.".into()));
546    }
547    let wb = &s.doc()?.wb;
548    if wb.table(&name).is_some_and(|x| x != (si, ti)) || wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
549        return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
550    }
551    edit(s, |cx| {
552        let old = cx.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.name.clone()).unwrap_or_default();
553        if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) {
554            t.name = name.clone();
555        }
556        // Structured references follow the rename.
557        for i in 0..cx.wb.sheets.len() {
558            let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
559                cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
560            // A bare name that is also a defined name resolves to the defined name, so it is not the table.
561            let shadowed = cx.wb.name(&old, i).is_some();
562            let Some(sh) = cx.wb.sheet_mut(i) else { continue };
563            for (c, f) in keys {
564                let Some(e) = f.expr() else { continue };
565                let ne = e.map(&mut |x| match x {
566                    crate::formula::Expr::Struct(mut st) if st.table.eq_ignore_ascii_case(&old) => {
567                        st.table = name.clone();
568                        crate::formula::Expr::Struct(st)
569                    }
570                    crate::formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => crate::formula::Expr::Name(name.clone()),
571                    o => o,
572                });
573                if let Some(cell) = sh.cells.get_mut(c) {
574                    cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne)));
575                }
576            }
577        }
578        cx.structural = true;
579        Ok(Json::Null)
580    })
581}
582
583fn table_convert(s: &mut Session, p: &Json) -> Result<Json> {
584    let (si, ti) = find_table(s, p)?;
585    edit(s, |cx| {
586        // Apply the table look as plain formatting, then drop the table.
587        let wb = &mut cx.wb;
588        let Some(t) = wb.sheet(si).and_then(|sh| sh.tables.get(ti)).cloned() else { return Ok(Json::Null) };
589        let looks: Vec<(CellRef, Style)> = t.range.iter().map(|c| (c, crate::engine::tables::table_cell_style(wb, si, &t, c))).collect();
590        for (c, st) in looks {
591            let id = wb.styles.intern(st);
592            if let Some(sh) = wb.sheet_mut(si) {
593                sh.set_style(c, id);
594            }
595        }
596        if let Some(sh) = wb.sheet_mut(si) {
597            sh.tables.remove(ti);
598        }
599        cx.structural = true;
600        Ok(Json::Null)
601    })
602}
603
604// ---------------------------------------------------------------- charts
605
606pub fn chart_kind(t: &str, sub: &str) -> ChartKind {
607    match (t, sub) {
608        ("column", "stacked") => ChartKind::ColumnStacked,
609        ("column", "stacked100") => ChartKind::ColumnStacked100,
610        ("bar", "stacked") => ChartKind::BarStacked,
611        ("bar", "stacked100") => ChartKind::BarStacked100,
612        ("bar", _) => ChartKind::BarClustered,
613        ("line", "markers") => ChartKind::LineMarkers,
614        ("line", "stacked") => ChartKind::LineStacked,
615        ("line", _) => ChartKind::Line,
616        ("pie", _) => ChartKind::Pie,
617        ("doughnut", _) => ChartKind::Doughnut,
618        ("area", "stacked") => ChartKind::AreaStacked,
619        ("area", "stacked100") => ChartKind::AreaStacked100,
620        ("area", _) => ChartKind::Area,
621        ("scatter", "lines") => ChartKind::ScatterLines,
622        ("scatter", _) => ChartKind::Scatter,
623        ("bubble", _) => ChartKind::Bubble,
624        ("radar", _) => ChartKind::Radar,
625        ("histogram", _) => ChartKind::Histogram,
626        ("waterfall", _) => ChartKind::Waterfall,
627        ("funnel", _) => ChartKind::Funnel,
628        ("treemap", _) => ChartKind::Treemap,
629        ("sunburst", _) => ChartKind::Sunburst,
630        ("boxWhisker", _) => ChartKind::BoxWhisker,
631        ("stock", _) => ChartKind::Stock,
632        ("combo", _) => ChartKind::Combo,
633        _ => ChartKind::ColumnClustered,
634    }
635}
636
637/// Builds series from a block: first row/column as names/categories when they're text.
638pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series> {
639    let q = crate::formula::quote_sheet(&sh.name);
640    let abs = |r: RangeRef| {
641        format!("{q}!${}${}:${}${}", crate::base::col_to_letters(r.start.col), r.start.row + 1, crate::base::col_to_letters(r.end.col), r.end.row + 1)
642    };
643    let abs1 = |c: CellRef| format!("{q}!${}${}", crate::base::col_to_letters(c.col), c.row + 1);
644    let is_text = |c: CellRef| sh.value(c).is_text() || sh.value(c).is_empty();
645    let header_row = r.height() > 1
646        && (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text())
647        && (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c)));
648    // A blank top-left cell above a header row marks the first column as the category labels even
649    // when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't
650    // plotted as a series of its own (#287).
651    let blank_corner = header_row && sh.value(r.start).is_empty();
652    let label_col =
653        r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col))));
654    let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end);
655    let mut out = Vec::new();
656    if !by_rows {
657        for c in data.start.col..=data.end.col {
658            out.push(Series {
659                name: header_row.then(|| abs1(CellRef::new(r.start.row, c))),
660                categories: label_col.then(|| abs(RangeRef::new(CellRef::new(data.start.row, r.start.col), CellRef::new(data.end.row, r.start.col)))),
661                values: abs(RangeRef::new(CellRef::new(data.start.row, c), CellRef::new(data.end.row, c))),
662                bubble_sizes: None,
663                color: None,
664                secondary: false,
665                kind: None,
666                quartiles: Default::default(),
667                smooth: false,
668                trendlines: Vec::new(),
669            });
670        }
671    } else {
672        for row in data.start.row..=data.end.row {
673            out.push(Series {
674                name: label_col.then(|| abs1(CellRef::new(row, r.start.col))),
675                categories: header_row
676                    .then(|| abs(RangeRef::new(CellRef::new(r.start.row, data.start.col), CellRef::new(r.start.row, data.end.col)))),
677                values: abs(RangeRef::new(CellRef::new(row, data.start.col), CellRef::new(row, data.end.col))),
678                bubble_sizes: None,
679                color: None,
680                secondary: false,
681                kind: None,
682                quartiles: Default::default(),
683                smooth: false,
684                trendlines: Vec::new(),
685            });
686        }
687    }
688    out
689}
690
691fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec<Series>)> {
692    let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) };
693    let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area));
694    if areas.len() == 1 {
695        let by_rows = first.width() > first.height() + 1;
696        return Ok((bounds, by_rows, series_from_range(sh, first, by_rows)));
697    }
698    let valid_shape = first.width() == 1
699        && first.height() > 1
700        && areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height())
701        && first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty());
702    if !valid_shape {
703        return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns"));
704    }
705    let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values);
706    let mut series = Vec::with_capacity(areas.len() - 1);
707    for area in &areas[1..] {
708        let mut area_series = series_from_range(sh, *area, false);
709        let Some(mut item) = area_series.pop() else {
710            return Err(EngineError::Other("Select data for the chart first.".into()));
711        };
712        if item.name.is_none() {
713            return Err(bad("insert.chart", "multi-area series columns require headers"));
714        }
715        item.categories = category.clone();
716        series.push(item);
717    }
718    Ok((bounds, false, series))
719}
720
721/// A scatter plot of a block without category labels takes its first column (or row) as the X
722/// values of the others, as Excel does: two plain columns make one series of points, not two (#428).
723pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec<Series>) {
724    if matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines) && series.len() > 1 && series[0].categories.is_none() {
725        let x = series.remove(0).values;
726        for se in series.iter_mut() {
727            se.categories = Some(x.clone());
728        }
729    }
730}
731
732/// Most cells a chart's source block may span (bounds the series it builds).
733const MAX_CHART_CELLS: u64 = 1_000_000;
734
735/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for
736/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per
737/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns
738/// without labels → scatter; one short series → column, pie, bar; long series → line; several
739/// series → clustered and stacked columns and bars, line.
740pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
741    let areas = chart_areas(s, p)?;
742    if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
743        return Err(bad("insert.chart", "range too large for a chart"));
744    }
745    let sheet = target_sheet(s, p)?;
746    let d = s.doc()?;
747    let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
748    let (_, by_rows, series) = series_from_areas(sh, &areas)?;
749    let ranges: Vec<RangeRef> = series
750        .iter()
751        .filter_map(|x| match crate::formula::parse(&x.values) {
752            Ok(crate::formula::Expr::Ref(rf)) => Some(rf.range()),
753            _ => None,
754        })
755        .collect();
756    let points = ranges.first().map_or(0, RangeRef::count);
757    let (mut any_number, mut non_negative) = (false, true);
758    for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) {
759        any_number = true;
760        non_negative &= n >= 0.0;
761    }
762    if series.is_empty() || !any_number {
763        return Err(EngineError::Other("Select data for the chart first.".into()));
764    }
765    let labelled = series.first().is_some_and(|x| x.categories.is_some());
766    const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column");
767    const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar");
768    const LINE: (&str, &str, &str) = ("line", "", "Line");
769    const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers");
770    const PIE: (&str, &str, &str) = ("pie", "", "Pie");
771    const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut");
772    const AREA: (&str, &str, &str) = ("area", "", "Area");
773    const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter");
774    const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column");
775    const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar");
776    let mut out = Vec::new();
777    if series.len() == 2 && !labelled && !by_rows {
778        out.extend([SCATTER, LINE_MARKERS, COLUMN]);
779    } else if series.len() == 1 {
780        if points > 12 {
781            out.extend([LINE, COLUMN, AREA]);
782        } else {
783            out.extend([COLUMN, BAR]);
784            if non_negative && points >= 2 {
785                out.extend([PIE, DOUGHNUT]);
786            }
787            out.push(LINE_MARKERS);
788        }
789    } else if points > 12 {
790        out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]);
791    } else {
792        out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]);
793    }
794    Ok(out)
795}
796
797/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
798/// goes on and the chart (previews in the Recommended Charts dialog).
799pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
800    let areas = chart_areas(s, p)?;
801    if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
802        return Err(bad("insert.chart", "range too large for a chart"));
803    }
804    let mut t = str_param(p, "type").unwrap_or("column").to_string();
805    let mut sub = str_param(p, "subtype").unwrap_or("").to_string();
806    if t == "auto" {
807        let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", ""));
808        t = first.0.into();
809        sub = first.1.into();
810    }
811    let sheet = target_sheet(s, p)?;
812    let d = s.doc()?;
813    let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
814    let kind = chart_kind(&t, &sub);
815    let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?;
816    if series.is_empty() {
817        return Err(EngineError::Other("Select data for the chart first.".into()));
818    }
819    scatter_x_from_first(kind, &mut series);
820    let id = d.wb.next_object_id();
821    let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2));
822    let title = str_param(p, "title").map(str::to_string).or_else(|| {
823        if series.len() == 1 {
824            series[0].name.as_ref().map(|n| crate::calc::evaluate(&d.wb, sheet, at, n).display())
825        } else {
826            Some("Chart Title".into())
827        }
828    });
829    let chart = Chart {
830        id,
831        kind,
832        anchor: Anchor {
833            cell: at,
834            dx: 0.0,
835            dy: 0.0,
836            width: f64_param(p, "width").unwrap_or(480.0) as f32,
837            height: f64_param(p, "height").unwrap_or(288.0) as f32,
838            mode: anchor_mode_param(p).unwrap_or_default(),
839        },
840        title,
841        series,
842        legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
843        data_labels: false,
844        data_table: None,
845        gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
846        style: 1,
847        hole_size: 75,
848        first_slice_angle: 0,
849        x_title: None,
850        y_title: None,
851        source: (areas.len() == 1).then(|| format!("{}!{}", crate::formula::quote_sheet(&sh.name), bounds.a1())),
852        by_rows,
853        pivot: None,
854    };
855    Ok((sheet, chart))
856}
857
858fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
859    let (sheet, chart) = build_chart(s, p)?;
860    let id = chart.id;
861    edit(s, |cx| {
862        cx.sheet_mut(sheet)?.charts.push(chart);
863        Ok(json!({"chart": id}))
864    })
865}
866
867fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
868    let d = s.doc()?;
869    let sheet = d.wb.active_sheet();
870    let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
871    let idx = match u32_param(p, "chart") {
872        Some(id) => sh.charts.iter().position(|c| c.id == id),
873        None => sh.charts.len().checked_sub(1),
874    };
875    idx.map(|i| (sheet, i)).ok_or_else(|| bad("chart", "no such chart"))
876}
877
878fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
879    let (si, ci) = find_chart(s, p)?;
880    let at = cell_param_on(s, p, "at", si)?;
881    let quartiles = p
882        .get("quartiles")
883        .map(|v| match v.as_str() {
884            Some("exclusive") => Ok(crate::model::QuartileMethod::Exclusive),
885            Some("inclusive") => Ok(crate::model::QuartileMethod::Inclusive),
886            _ => Err(bad("quartiles", "expected exclusive or inclusive")),
887        })
888        .transpose()?;
889    edit(s, |cx| {
890        let sh = cx.sheet_mut(si)?;
891        let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
892        if let Some(t) = str_param(p, "type") {
893            c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
894            if !c.supports_data_table() {
895                c.data_table = None;
896            }
897        }
898        update_data_table(c, p, bool_param(p, "dataTable"))?;
899        if let Some(method) = quartiles {
900            for series in &mut c.series {
901                series.quartiles = method;
902            }
903        }
904        if let Some(t) = p.get("title") {
905            c.title = t.as_str().map(str::to_string);
906        }
907        if let Some(l) = str_param(p, "legend") {
908            c.legend = match l {
909                "none" => LegendPos::None,
910                "top" => LegendPos::Top,
911                "left" => LegendPos::Left,
912                "right" => LegendPos::Right,
913                _ => LegendPos::Bottom,
914            };
915        }
916        if let Some(v) = bool_param(p, "dataLabels") {
917            c.data_labels = v;
918        }
919        if let Some(v) = bool_param(p, "gridlines") {
920            c.gridlines = v;
921        }
922        if let Some(v) = u32_param(p, "style") {
923            c.style = v.clamp(1, 16);
924        }
925        if let Some(v) = u32_param(p, "holeSize") {
926            c.hole_size = v.clamp(10, 90) as u8;
927        }
928        if let Some(v) = u32_param(p, "firstSliceAngle") {
929            c.first_slice_angle = (v % 360) as u16;
930        }
931        if let Some(t) = p.get("xTitle") {
932            c.x_title = t.as_str().map(str::to_string);
933        }
934        if let Some(t) = p.get("yTitle") {
935            c.y_title = t.as_str().map(str::to_string);
936        }
937        if let Some(at) = at {
938            c.anchor.cell = at;
939        }
940        if let Some(w) = f64_param(p, "width") {
941            c.anchor.width = w.clamp(20.0, size_cap(c.anchor.width)) as f32;
942        }
943        if let Some(h) = f64_param(p, "height") {
944            c.anchor.height = h.clamp(20.0, size_cap(c.anchor.height)) as f32;
945        }
946        if let Some(v) = f64_param(p, "dx") {
947            c.anchor.dx = v as f32;
948        }
949        if let Some(v) = f64_param(p, "dy") {
950            c.anchor.dy = v as f32;
951        }
952        Ok(Json::Null)
953    })
954}
955
956/// Shared by Chart Properties and Add Chart Element; no separate UI state.
957pub(super) fn update_data_table(c: &mut Chart, p: &Json, on: Option<bool>) -> Result<()> {
958    if p.get("dataTable").is_some_and(|v| !v.is_boolean()) {
959        return Err(bad("chart", "`dataTable` must be a boolean"));
960    }
961    if on == Some(false) {
962        c.data_table = None;
963        return Ok(());
964    }
965    let fields = ["dataTableKeys", "dataTableHorizontalBorder", "dataTableVerticalBorder", "dataTableOutline"];
966    if on == Some(true) || fields.iter().any(|k| p.get(k).is_some()) {
967        if !c.supports_data_table() {
968            return Err(bad("chart", "data tables need a column, bar, line, area or compatible combo chart"));
969        }
970        let table = c.data_table.get_or_insert_with(Default::default);
971        for (key, value) in
972            fields.into_iter().zip([&mut table.show_keys, &mut table.horizontal_border, &mut table.vertical_border, &mut table.outline])
973        {
974            if let Some(v) = p.get(key) {
975                *value = v.as_bool().ok_or_else(|| bad("chart", format!("`{key}` must be a boolean")))?;
976            }
977        }
978    }
979    Ok(())
980}
981
982fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
983    let (si, ci) = find_chart(s, p)?;
984    edit(s, |cx| {
985        let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
986        let Some(c) = sh.charts.get(ci) else { return Ok(Json::Null) };
987        // Charts read from a file have no source range: take the block their series span.
988        let Some((src, by_rows)) = c.source.clone().map(|s| (s, c.by_rows)).or_else(|| series_block(&c.series)) else { return Ok(Json::Null) };
989        let (sheet_name, body) = split_sheet(&src);
990        let src_sheet = sheet_name.and_then(|n| cx.wb.sheet_index(&n)).unwrap_or(si);
991        let Some(mut r) = RangeRef::parse(body) else { return Ok(Json::Null) };
992        let Some(ssh) = cx.wb.sheet(src_sheet) else { return Ok(Json::Null) };
993        // Whole-column/row references (common in files) span a million cells: clip the block's
994        // far edges to the cells in use, then refuse anything still too large to chart.
995        if let Some(used) = ssh.used_range() {
996            r.end.row = r.end.row.min(used.end.row).max(r.start.row);
997            r.end.col = r.end.col.min(used.end.col).max(r.start.col);
998        }
999        if r.count() > MAX_CHART_CELLS {
1000            return Err(bad("chart.switchRowColumn", "range too large for a chart"));
1001        }
1002        let by_rows = !by_rows;
1003        let series = series_from_range(ssh, r, by_rows);
1004        if let Some(c) = cx.sheet_mut(si)?.charts.get_mut(ci) {
1005            c.source.get_or_insert(src);
1006            c.by_rows = by_rows;
1007            c.series = series;
1008            scatter_x_from_first(c.kind, &mut c.series);
1009        }
1010        Ok(Json::Null)
1011    })
1012}
1013
1014/// The block a chart's series come from, and whether each series is a row of it: the bounding
1015/// range of the series names, categories and values, when they're all references to one sheet.
1016fn series_block(series: &[Series]) -> Option<(String, bool)> {
1017    let mut sheet: Option<String> = None;
1018    let mut bounds: Option<RangeRef> = None;
1019    let mut rows = 0;
1020    for s in series {
1021        for (i, f) in [Some(&s.values), s.categories.as_ref(), s.name.as_ref()].into_iter().flatten().enumerate() {
1022            let Ok(crate::formula::Expr::Ref(r)) = crate::formula::parse(f) else { return None };
1023            let crate::formula::SheetSel::Named(n) = &r.sheet else { return None };
1024            if !sheet.get_or_insert_with(|| n.clone()).eq_ignore_ascii_case(n) {
1025                return None;
1026            }
1027            let range = r.range();
1028            if i == 0 && range.height() == 1 && range.width() > 1 {
1029                rows += 1;
1030            }
1031            bounds = Some(bounds.map_or(range, |b| b.union(&range)));
1032        }
1033    }
1034    Some((format!("{}!{}", crate::formula::quote_sheet(&sheet?), bounds?.a1()), rows > 0 && rows == series.len()))
1035}
1036
1037fn chart_delete(s: &mut Session, p: &Json) -> Result<Json> {
1038    let (si, ci) = find_chart(s, p)?;
1039    edit(s, |cx| {
1040        cx.sheet_mut(si)?.charts.remove(ci);
1041        Ok(Json::Null)
1042    })
1043}
1044
1045fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
1046    let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
1047    let loc = cell_param(s, p, "location")?.ok_or_else(|| bad("insert.sparkline", "missing `location`"))?;
1048    let kind = match str_param(p, "type") {
1049        Some("column") => SparklineKind::Column,
1050        Some("winLoss") => SparklineKind::WinLoss,
1051        _ => SparklineKind::Line,
1052    };
1053    let markers = bool_param(p, "markers").unwrap_or(false);
1054    let sheet = s.doc()?.wb.active_sheet();
1055    edit(s, |cx| {
1056        let sh = cx.sheet_mut(sheet)?;
1057        sh.sparklines.retain(|x| x.cell != loc);
1058        sh.sparklines.push(Sparkline { cell: loc, source: src.clone(), kind, color: Color::Theme(4, 0), markers });
1059        Ok(Json::Null)
1060    })
1061}
1062
1063fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
1064    let placement = str_param(p, "placement").unwrap_or("overCells");
1065    if !matches!(placement, "cell" | "overCells") {
1066        return Err(bad("insert.picture", "placement must be cell or overCells"));
1067    }
1068    let data = if let Some(b) = str_param(p, "base64") {
1069        crate::engine::io::base64_decode(b).ok_or_else(|| bad("insert.picture", "invalid base64"))?
1070    } else if let Some(path) = str_param(p, "path") {
1071        crate::engine::io::read_file(path)?
1072    } else {
1073        s.ui_requests.push(crate::engine::UiRequest::Dialog("insertPicture".into(), json!({"placement": placement})));
1074        return ok();
1075    };
1076    if placement == "cell" {
1077        let mime = validate_cell_picture(&data)?;
1078        let d = s.doc()?;
1079        let sheet = d.wb.active_sheet();
1080        let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
1081        let at = match str_param(p, "at") {
1082            Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?,
1083            None => d.selection.active,
1084        };
1085        let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at);
1086        let style = sh.style_id(at);
1087        if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() {
1088            return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into()));
1089        }
1090        let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() };
1091        return edit(s, |cx| {
1092            cx.sheet_mut(sheet)?.set_picture(at, std::sync::Arc::new(picture));
1093            cx.touch(sheet, at);
1094            Ok(json!({"cell": at.a1(), "placement": "cell"}))
1095        });
1096    }
1097    if data.len() > 64 * 1024 * 1024 {
1098        return Err(bad("insert.picture", "image larger than 64 MB"));
1099    }
1100    let mime = str_param(p, "mime")
1101        .map(str::to_string)
1102        .unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() });
1103    let (w, h) = crate::engine::io::image_size(&data).unwrap_or((320, 240));
1104    let d = s.doc()?;
1105    // Sheet protection locks drawing objects, as for a text box's text.
1106    if d.wb.active().is_some_and(|sh| sh.is_protected()) {
1107        return Err(EngineError::Other(PROTECTED.into()));
1108    }
1109    let id = d.wb.next_object_id();
1110    let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
1111    let scale = (640.0 / w.max(1) as f32).min(1.0);
1112    let img = Image {
1113        id,
1114        anchor: Anchor {
1115            cell: at,
1116            dx: 0.0,
1117            dy: 0.0,
1118            width: f64_param(p, "width").map(|v| v as f32).unwrap_or(w as f32 * scale),
1119            height: f64_param(p, "height").map(|v| v as f32).unwrap_or(h as f32 * scale),
1120            // Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`).
1121            mode: anchor_mode_param(p).unwrap_or(crate::model::AnchorMode::MoveOnly),
1122        },
1123        data: data.into(),
1124        mime,
1125        alt: str_param(p, "alt").unwrap_or("").into(),
1126    };
1127    let sheet = d.wb.active_sheet();
1128    edit(s, |cx| {
1129        cx.sheet_mut(sheet)?.images.push(img);
1130        Ok(json!({"image": id}))
1131    })
1132}
1133
1134/// Validate before mutating the workbook; header dimensions alone do not prove the pixels decode.
1135fn validate_cell_picture(data: &[u8]) -> Result<&'static str> {
1136    if data.len() > 16 * 1024 * 1024 {
1137        return Err(bad("insert.picture", "cell pictures must be at most 16 MiB"));
1138    }
1139    let format = image::guess_format(data).map_err(|_| bad("insert.picture", "choose a valid PNG or JPEG picture"))?;
1140    let mime = match format {
1141        image::ImageFormat::Png => "image/png",
1142        image::ImageFormat::Jpeg => "image/jpeg",
1143        _ => return Err(bad("insert.picture", "cell pictures support PNG and JPEG")),
1144    };
1145    let (width, height) = image::ImageReader::with_format(std::io::Cursor::new(data), format)
1146        .into_dimensions()
1147        .map_err(|_| bad("insert.picture", "invalid image dimensions"))?;
1148    if width == 0 || height == 0 || width > 8192 || height > 8192 || u64::from(width) * u64::from(height) > 16_000_000 {
1149        return Err(bad("insert.picture", "cell pictures are limited to 8192 pixels per side and 16 million pixels"));
1150    }
1151    let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
1152    let mut limits = image::Limits::default();
1153    limits.max_image_width = Some(8192);
1154    limits.max_image_height = Some(8192);
1155    limits.max_alloc = Some(64 * 1024 * 1024);
1156    reader.limits(limits);
1157    reader.decode().map_err(|_| bad("insert.picture", "picture cannot be decoded within the 64 MiB limit"))?;
1158    Ok(mime)
1159}
1160
1161fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
1162    let kind = match str_param(p, "kind").unwrap_or("rectangle") {
1163        "roundedRectangle" => ShapeKind::RoundedRectangle,
1164        "ellipse" | "oval" => ShapeKind::Ellipse,
1165        "triangle" => ShapeKind::Triangle,
1166        "line" => ShapeKind::Line,
1167        "arrow" => ShapeKind::Arrow,
1168        "textBox" => ShapeKind::TextBox,
1169        _ => ShapeKind::Rectangle,
1170    };
1171    let d = s.doc()?;
1172    let id = d.wb.next_object_id();
1173    let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
1174    let fill =
1175        super::format::color_param(p.get("fill")).unwrap_or(if kind == ShapeKind::TextBox { Color::rgb(255, 255, 255) } else { Color::Theme(4, 0) });
1176    let line = super::format::color_param(p.get("line")).unwrap_or(if kind == ShapeKind::TextBox {
1177        Color::rgb(0x80, 0x80, 0x80)
1178    } else {
1179        Color::Theme(4, -250)
1180    });
1181    let shape = Shape {
1182        math: Default::default(),
1183        id,
1184        kind,
1185        anchor: Anchor {
1186            cell: at,
1187            dx: 0.0,
1188            dy: 0.0,
1189            width: f64_param(p, "width").unwrap_or(144.0) as f32,
1190            height: f64_param(p, "height").unwrap_or(if kind == ShapeKind::Line { 0.0 } else { 96.0 }) as f32,
1191            mode: anchor_mode_param(p).unwrap_or_default(),
1192        },
1193        fill,
1194        line,
1195        text: str_param(p, "text").unwrap_or("").into(),
1196        format: Default::default(),
1197    };
1198    let sheet = d.wb.active_sheet();
1199    edit(s, |cx| {
1200        cx.sheet_mut(sheet)?.shapes.push(shape);
1201        Ok(json!({"shape": id}))
1202    })
1203}
1204
1205/// A text box holds at most as many characters as a cell.
1206const SHAPE_TEXT_MAX_CHARS: usize = 32_767;
1207
1208fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
1209    let id =
1210        p.get("id").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()).ok_or_else(|| bad("shape.setText", "`id` must be a text box id"))?;
1211    let text = str_param(p, "text").ok_or_else(|| bad("shape.setText", "`text` must be a string"))?;
1212    if text.chars().nth(SHAPE_TEXT_MAX_CHARS).is_some() {
1213        return Err(bad("shape.setText", "`text` is longer than 32,767 characters"));
1214    }
1215    let d = s.doc()?;
1216    let sheet = d.wb.active_sheet();
1217    let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
1218    let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
1219    if shape.kind != ShapeKind::TextBox {
1220        return Err(bad("shape.setText", "the shape is not a text box"));
1221    }
1222    // Sheet protection locks drawing objects (there is no "edit objects" allowance yet).
1223    if sh.is_protected() {
1224        return Err(EngineError::Other(PROTECTED.into()));
1225    }
1226    if shape.text == text {
1227        return ok();
1228    }
1229    edit(s, |cx| {
1230        let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
1231        shape.text = text.into();
1232        Ok(Json::Null)
1233    })
1234}
1235
1236/// The floating object a `{kind, id}` command names on the active sheet. Unknown kinds and
1237/// objects are errors, so a stale id never succeeds silently.
1238fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> {
1239    let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
1240    let kind =
1241        str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer, equation or control"))?;
1242    let d = s.doc()?;
1243    let sheet = d.wb.active_sheet();
1244    let o = ObjRef::new(kind, id);
1245    if !d.wb.sheet(sheet).is_some_and(|sh| sh.has_object(o)) {
1246        return Err(bad(cmd, "no such object"));
1247    }
1248    // A locked form control on a protected sheet stays as it is.
1249    super::controls::check_objects_unlocked(s, sheet, kind, &[id])?;
1250    Ok((sheet, o))
1251}
1252
1253fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
1254    let (sheet, o) = object_param(s, p, "object.delete")?;
1255    edit(s, |cx| {
1256        cx.sheet_mut(sheet)?.remove_object(o);
1257        Ok(json!({"deleted": o}))
1258    })
1259}
1260
1261/// Largest offset (points) `object.move` accepts from the anchor cell.
1262const MAX_OBJECT_OFFSET: f64 = 100_000.0;
1263
1264/// Largest width or height (points) `object.move` and `chart.set` give an object that is not
1265/// already larger: see [`size_cap`].
1266const MAX_OBJECT_SIZE: f64 = 10_000.0;
1267
1268/// The most a command may make a dimension that is `current` now: [`MAX_OBJECT_SIZE`], or the
1269/// current size when a file made the object larger (the XLSX reader accepts anchors far past the
1270/// limit). Such an object keeps its size when it is moved and shrinks by what a resize asks, but
1271/// it never grows further, so hostile input is still capped. Never NaN: `f64::max` drops one.
1272fn size_cap(current: f32) -> f64 {
1273    MAX_OBJECT_SIZE.max(f64::from(current))
1274}
1275
1276fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
1277    let (sheet, o) = object_param(s, p, "object.move")?;
1278    let at = cell_param(s, p, "at")?;
1279    edit(s, |cx| {
1280        let Some(a) = cx.sheet_mut(sheet)?.object_anchor_mut(o) else { return Err(bad("object.move", "no such object")) };
1281        if let Some(at) = at {
1282            a.cell = at;
1283        }
1284        if let Some(v) = f64_param(p, "dx") {
1285            a.dx = v.clamp(-MAX_OBJECT_OFFSET, MAX_OBJECT_OFFSET) as f32;
1286        }
1287        if let Some(v) = f64_param(p, "dy") {
1288            a.dy = v.clamp(-MAX_OBJECT_OFFSET, MAX_OBJECT_OFFSET) as f32;
1289        }
1290        if let Some(v) = f64_param(p, "width") {
1291            a.width = v.clamp(1.0, size_cap(a.width)) as f32;
1292        }
1293        if let Some(v) = f64_param(p, "height") {
1294            a.height = v.clamp(0.0, size_cap(a.height)) as f32;
1295        }
1296        Ok(Json::Null)
1297    })
1298}
1299
1300/// Sets an object's anchor mode (Excel's "Move and size with cells" / "Move but don't size with
1301/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns
1302/// when the sheet is sorted or edited structurally.
1303fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
1304    let (sheet, o) = object_param(s, p, "object.setAnchorMode")?;
1305    let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
1306    edit(s, |cx| {
1307        let Some(a) = cx.sheet_mut(sheet)?.object_anchor_mut(o) else { return Err(bad("object.setAnchorMode", "no such object")) };
1308        a.mode = mode;
1309        Ok(json!({"mode": anchor_mode_name(mode)}))
1310    })
1311}
1312
1313// ---------------------------------------------------------------- links & comments
1314
1315fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
1316    let sheet = target_sheet(s, p)?;
1317    let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
1318    if bool_param(p, "remove").unwrap_or(false) {
1319        return edit(s, |cx| {
1320            cx.sheet_mut(sheet)?.hyperlinks.remove(&at);
1321            Ok(Json::Null)
1322        });
1323    }
1324    let Some(target) = str_param(p, "target").map(str::to_string) else {
1325        s.ui_requests.push(crate::engine::UiRequest::Dialog("insertLink".into(), json!({})));
1326        return ok();
1327    };
1328    let text = str_param(p, "text").map(str::to_string);
1329    let tooltip = str_param(p, "tooltip").map(str::to_string);
1330    let external = links_outside(&s.doc()?.wb, sheet, &target);
1331    edit(s, |cx| {
1332        let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| {
1333            st.font.color = Color::Theme(10, 0);
1334            st.font.underline = Underline::Single;
1335        });
1336        let sh = cx.sheet_mut(sheet)?;
1337        if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) {
1338            sh.set_cell(at, Cell { value: crate::base::Value::text(t.as_str()), formula: None, style: link_style, py: None });
1339        } else {
1340            sh.set_style(at, link_style);
1341        }
1342        sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external });
1343        cx.touch(sheet, at);
1344        Ok(Json::Null)
1345    })
1346}
1347
1348/// Follows a link: a reference selects it; anything else is refused ([`crate::engine::links::classify`]).
1349/// An `http`/`https`/`mailto` address is refused too unless the host set
1350/// [`crate::engine::links::LinkPolicy::Allow`]: every caller of a command may be a script or an agent, so
1351/// the user opens such links by clicking and confirming, and the UI then calls
1352/// [`Session::open_confirmed_link`] (#2181). Opens no dialog.
1353fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
1354    let target = match str_param(p, "url").or_else(|| str_param(p, "target")) {
1355        Some(t) => t.to_string(),
1356        None => {
1357            let sheet = target_sheet(s, p)?;
1358            let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
1359            let Some(h) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.hyperlinks.get(&at)) else {
1360                return Err(EngineError::Other("There's no hyperlink in this cell.".into()));
1361            };
1362            h.target.clone()
1363        }
1364    };
1365    match crate::engine::links::classify(&target) {
1366        crate::engine::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})),
1367        crate::engine::links::LinkKind::External(u) => {
1368            if s.link_policy != crate::engine::links::LinkPolicy::Allow {
1369                return Err(EngineError::Other(crate::engine::links::NEEDS_CONFIRMATION.into()));
1370            }
1371            s.ui_requests.push(crate::engine::UiRequest::OpenUrl(u.clone()));
1372            Ok(json!({"opened": u}))
1373        }
1374        crate::engine::links::LinkKind::Refused(why) => Err(EngineError::Other(why)),
1375    }
1376}
1377
1378/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This
1379/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web
1380/// Page") is outside the workbook and saved as a relationship.
1381fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool {
1382    let t = target.trim();
1383    if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() {
1384        return false;
1385    }
1386    let (on, body) = split_sheet(t);
1387    // Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference,
1388    // and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name.
1389    let on_sheet = on.is_none_or(|name| {
1390        wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name))
1391            || (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#']))
1392    });
1393    !(on_sheet && RangeRef::parse(body).is_some())
1394}
1395
1396fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
1397    let sheet = target_sheet(s, p)?;
1398    let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
1399    let text = str_param(p, "text").unwrap_or("").to_string();
1400    let named = str_param(p, "author").map(str::to_string);
1401    let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone());
1402    edit(s, |cx| {
1403        let comments = &mut cx.sheet_mut(sheet)?.comments;
1404        // Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the
1405        // replies, the dates and who wrote it stay. A note replaces a comment and vice versa.
1406        if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) {
1407            c.text = text.clone();
1408            if let Some(author) = named.clone() {
1409                c.author = author;
1410            }
1411            return Ok(json!({"cell": at.a1()}));
1412        }
1413        comments.insert(
1414            at,
1415            Comment {
1416                author: author.clone(),
1417                text: text.clone(),
1418                replies: vec![],
1419                threaded,
1420                resolved: false,
1421                visible: false,
1422                dates: vec![],
1423                note_box: None,
1424            },
1425        );
1426        Ok(json!({"cell": at.a1()}))
1427    })
1428}
1429
1430fn reply_comment(s: &mut Session, p: &Json) -> Result<Json> {
1431    let sheet = target_sheet(s, p)?;
1432    let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
1433    let text = str_param(p, "text").unwrap_or("").to_string();
1434    let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
1435    edit(s, |cx| {
1436        let c = cx.sheet_mut(sheet)?.comments.get_mut(&at).ok_or_else(|| EngineError::Other("There's no comment in this cell.".into()))?;
1437        c.replies.push((author.clone(), text.clone()));
1438        Ok(Json::Null)
1439    })
1440}
1441
1442fn resolve_comment(s: &mut Session, p: &Json) -> Result<Json> {
1443    let sheet = target_sheet(s, p)?;
1444    let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
1445    let v = bool_param(p, "resolved").unwrap_or(true);
1446    edit(s, |cx| {
1447        if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
1448            c.resolved = v;
1449        }
1450        Ok(Json::Null)
1451    })
1452}
1453
1454fn delete_comment(s: &mut Session, p: &Json) -> Result<Json> {
1455    let sheet = target_sheet(s, p)?;
1456    let ranges = target_ranges(s, p)?;
1457    edit(s, |cx| {
1458        cx.sheet_mut(sheet)?.comments.retain(|c, _| !ranges.iter().any(|r| r.contains(*c)));
1459        Ok(Json::Null)
1460    })
1461}
1462
1463fn show_note(s: &mut Session, p: &Json) -> Result<Json> {
1464    let sheet = target_sheet(s, p)?;
1465    let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
1466    edit(s, |cx| {
1467        if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
1468            c.visible = !c.visible;
1469        }
1470        Ok(Json::Null)
1471    })
1472}
1473
1474fn insert_symbol(s: &mut Session, p: &Json) -> Result<Json> {
1475    let ch = str_param(p, "char").unwrap_or("").to_string();
1476    let d = s.doc()?;
1477    let at = d.selection.active;
1478    let cur = d.wb.active().and_then(|sh| sh.cell(at)).map(|c| c.input_text()).unwrap_or_default();
1479    s.execute("cell.set", json!({"cell": at.a1(), "input": format!("{cur}{ch}")}))
1480}
1481
1482// ---------------------------------------------------------------- conditional formatting
1483
1484fn preset_style(name: &str) -> Style {
1485    let mut s = Style::default();
1486    match name {
1487        "redText" => s.font.color = Color::rgb(0x9C, 0x00, 0x06),
1488        "yellowFill" | "yellowFillDarkYellowText" => {
1489            s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C));
1490            s.font.color = Color::rgb(0x9C, 0x57, 0x00);
1491        }
1492        "greenFill" | "greenFillDarkGreenText" => {
1493            s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE));
1494            s.font.color = Color::rgb(0x00, 0x61, 0x00);
1495        }
1496        "lightRedFill" => s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE)),
1497        "redBorder" => {
1498            let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xC0, 0x00, 0x00) };
1499            s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
1500        }
1501        _ => {
1502            s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE));
1503            s.font.color = Color::rgb(0x9C, 0x00, 0x06);
1504        }
1505    }
1506    s
1507}
1508
1509/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled.
1510fn is_string_literal(v: &str) -> bool {
1511    v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"'))
1512}
1513
1514/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`,
1515/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be
1516/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a
1517/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than
1518/// dropped: dropped, it left a rule with an empty format that matches and shows nothing.
1519fn cf_style_json(st: &Json) -> Result<Json> {
1520    const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"];
1521    let err = |m: String| bad("home.conditionalFormat", m);
1522    let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) };
1523    let mut st = obj.clone();
1524    if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) {
1525        let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" };
1526        return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", "))));
1527    }
1528    // A colour written the way the other formatting commands take it, as the model stores it.
1529    let color = |v: &mut Json, what: &str| -> Result<()> {
1530        if v.is_string() || v.get("theme").is_some() {
1531            let c = super::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?;
1532            *v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?;
1533        }
1534        Ok(())
1535    };
1536    if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut)
1537        && let Some(c) = f.get_mut("color")
1538    {
1539        color(c, "font.color")?;
1540    }
1541    if let Some(fill) = st.get_mut("fill") {
1542        // `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one.
1543        if fill.is_string() && super::format::color_param(Some(fill)) == Some(Color::Auto) {
1544            *fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?;
1545        } else if fill.is_string() || fill.get("theme").is_some() {
1546            *fill = json!({"pattern": "Solid", "fg": fill.clone()});
1547        }
1548        if let Some(f) = fill.as_object_mut() {
1549            for k in ["fg", "bg"] {
1550                if let Some(c) = f.get_mut(k) {
1551                    color(c, &format!("fill.{k}"))?;
1552                }
1553            }
1554            // A colour with no pattern is a solid fill, not an invisible one.
1555            if f.contains_key("fg") && !f.contains_key("pattern") {
1556                f.insert("pattern".into(), json!("Solid"));
1557            }
1558        }
1559    }
1560    if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) {
1561        for (side, line) in b.iter_mut() {
1562            if let Some(c) = line.get_mut("color") {
1563                color(c, &format!("borders.{side}.color"))?;
1564            }
1565        }
1566    }
1567    Ok(Json::Object(st))
1568}
1569
1570/// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open
1571/// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog
1572/// (which dispatches this command) share one gate, the way Excel's object model refuses.
1573fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> {
1574    // Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1'
1575    // only parses after several strips and would still be saved unparseable. (#1999 review)
1576    if crate::formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() {
1577        return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file")));
1578    }
1579    Ok(())
1580}
1581
1582fn check_cf_value(what: &str, v: &crate::model::features::CfValueKind) -> Result<()> {
1583    if let crate::model::features::CfValueKind::Formula(f) = v {
1584        check_cf_formula("home.conditionalFormat", what, f)?;
1585    }
1586    Ok(())
1587}
1588
1589fn check_cf_rule(rule: &CfRule) -> Result<()> {
1590    const CMD: &str = "home.conditionalFormat";
1591    match rule {
1592        CfRule::CellIs { a, b, .. } => {
1593            check_cf_formula(CMD, "comparison value", a)?;
1594            if let Some(b) = b {
1595                check_cf_formula(CMD, "second comparison value", b)?;
1596            }
1597        }
1598        CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?,
1599        CfRule::DataBar { min, max, .. } => {
1600            check_cf_value("data bar minimum", min)?;
1601            check_cf_value("data bar maximum", max)?;
1602        }
1603        CfRule::ColorScale { stops } => {
1604            for (kind, _) in stops {
1605                check_cf_value("color scale threshold", kind)?;
1606            }
1607        }
1608        _ => {}
1609    }
1610    Ok(())
1611}
1612
1613fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
1614    let ranges = target_ranges(s, p)?;
1615    let sheet = target_sheet(s, p)?;
1616    let Some(rule) = p.get("rule") else {
1617        s.ui_requests.push(crate::engine::UiRequest::Dialog("newFormattingRule".into(), json!({})));
1618        return ok();
1619    };
1620    let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string);
1621    let style = Box::new(match rule.get("style") {
1622        Some(st) if !st.is_null() => {
1623            let st = cf_style_json(st)?;
1624            let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?;
1625            serde_json::from_value(super::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
1626        }
1627        _ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")),
1628    });
1629    let kind = g("type").unwrap_or_else(|| "cellIs".into());
1630    let value_kind = |v: Option<&Json>, default: CfValueKind| -> CfValueKind {
1631        match v {
1632            Some(Json::Object(o)) => {
1633                let t = o.get("type").and_then(Json::as_str).unwrap_or("num");
1634                let val = o.get("value").and_then(Json::as_f64).unwrap_or(0.0);
1635                match t {
1636                    "min" => CfValueKind::Min,
1637                    "max" => CfValueKind::Max,
1638                    "percent" => CfValueKind::Percent(val),
1639                    "percentile" => CfValueKind::Percentile(val),
1640                    "formula" => CfValueKind::Formula(o.get("value").and_then(Json::as_str).unwrap_or("").into()),
1641                    _ => CfValueKind::Number(val),
1642                }
1643            }
1644            _ => default,
1645        }
1646    };
1647    let rule = match kind.as_str() {
1648        "cellIs" => {
1649            // Excel's file and VBA names (`lessThan`, …) are accepted too.
1650            let op = match g("operator").as_deref().unwrap_or("greater") {
1651                "between" => CfOperator::Between,
1652                "notBetween" => CfOperator::NotBetween,
1653                "equal" => CfOperator::Equal,
1654                "notEqual" => CfOperator::NotEqual,
1655                "less" | "lessThan" => CfOperator::Less,
1656                "greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
1657                "lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
1658                "greater" | "greaterThan" => CfOperator::Greater,
1659                other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))),
1660            };
1661            let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
1662            let b = rule.get("value2").map(super::edit::json_to_input);
1663            let quote = |v: String| {
1664                // A number, a formula, or text already written as a string literal (`"Closed"`, as
1665                // in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted.
1666                if crate::base::parse::parse_number_text(&v).is_some() || v.starts_with('=') {
1667                    v.trim_start_matches('=').to_string()
1668                } else if is_string_literal(&v) {
1669                    v
1670                } else {
1671                    format!("\"{}\"", v.replace('"', "\"\""))
1672                }
1673            };
1674            CfRule::CellIs { op, a: quote(a), b: b.map(quote), style }
1675        }
1676        "expression" => CfRule::Expression { formula: g("formula").unwrap_or_default().trim_start_matches('=').to_string(), style },
1677        "containsText" => CfRule::ContainsText { text: g("text").unwrap_or_default(), style },
1678        "notContainsText" => CfRule::NotContainsText { text: g("text").unwrap_or_default(), style },
1679        "beginsWith" => CfRule::BeginsWith { text: g("text").unwrap_or_default(), style },
1680        "endsWith" => CfRule::EndsWith { text: g("text").unwrap_or_default(), style },
1681        "blanks" => CfRule::Blanks { style },
1682        "noBlanks" => CfRule::NoBlanks { style },
1683        "errors" => CfRule::Errors { style },
1684        "noErrors" => CfRule::NoErrors { style },
1685        "duplicate" => CfRule::Duplicate { style },
1686        "unique" => CfRule::Unique { style },
1687        "top10" => CfRule::Top10 {
1688            bottom: rule.get("bottom").and_then(Json::as_bool).unwrap_or(false),
1689            percent: rule.get("percent").and_then(Json::as_bool).unwrap_or(false),
1690            rank: rule.get("rank").and_then(Json::as_u64).unwrap_or(10) as u32,
1691            style,
1692        },
1693        "aboveAverage" => CfRule::AboveAverage { below: rule.get("below").and_then(Json::as_bool).unwrap_or(false), equal: false, std_dev: 0, style },
1694        "timePeriod" => CfRule::TimePeriod { period: g("period").unwrap_or_else(|| "today".into()), style },
1695        "colorScale" => {
1696            let cols: Vec<Color> = rule
1697                .get("colors")
1698                .and_then(Json::as_array)
1699                .map(|a| a.iter().filter_map(|c| super::format::color_param(Some(c))).collect())
1700                .unwrap_or_else(|| vec![Color::rgb(0xF8, 0x69, 0x6B), Color::rgb(0xFF, 0xEB, 0x84), Color::rgb(0x63, 0xBE, 0x7B)]);
1701            let stops = match cols.len() {
1702                2 => vec![(CfValueKind::Min, cols[0]), (CfValueKind::Max, cols[1])],
1703                _ => vec![
1704                    (CfValueKind::Min, cols.first().copied().unwrap_or_default()),
1705                    (CfValueKind::Percentile(50.0), cols.get(1).copied().unwrap_or_default()),
1706                    (CfValueKind::Max, cols.get(2).copied().unwrap_or_default()),
1707                ],
1708            };
1709            CfRule::ColorScale { stops }
1710        }
1711        "dataBar" => CfRule::DataBar {
1712            min: value_kind(rule.get("min"), CfValueKind::Min),
1713            max: value_kind(rule.get("max"), CfValueKind::Max),
1714            color: super::format::color_param(rule.get("color")).unwrap_or(Color::rgb(0x63, 0x8E, 0xC6)),
1715            gradient: rule.get("gradient").and_then(Json::as_bool).unwrap_or(true),
1716            show_value: true,
1717        },
1718        "iconSet" => CfRule::IconSet {
1719            set: g("set").unwrap_or_else(|| "3TrafficLights1".into()),
1720            thresholds: vec![],
1721            reverse: rule.get("reverse").and_then(Json::as_bool).unwrap_or(false),
1722            show_value: true,
1723        },
1724        other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))),
1725    };
1726    check_cf_rule(&rule)?;
1727    edit(s, |cx| {
1728        let sh = cx.sheet_mut(sheet)?;
1729        let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1;
1730        // New rules take precedence over older ones.
1731        for cf in sh.cond_formats.iter_mut() {
1732            cf.priority += 1;
1733        }
1734        sh.cond_formats.insert(0, CondFormat { ranges: ranges.clone(), rule, priority: 1, stop_if_true: false });
1735        let _ = priority;
1736        Ok(json!({"rules": sh.cond_formats.len()}))
1737    })
1738}
1739
1740fn clear_cf(s: &mut Session, p: &Json) -> Result<Json> {
1741    let sheet = target_sheet(s, p)?;
1742    let whole = bool_param(p, "sheet").unwrap_or(false);
1743    let ranges = target_ranges(s, p)?;
1744    edit(s, |cx| {
1745        let sh = cx.sheet_mut(sheet)?;
1746        if whole {
1747            sh.cond_formats.clear();
1748        } else {
1749            for cf in sh.cond_formats.iter_mut() {
1750                let before = RangeRef::top_left(&cf.ranges);
1751                cf.ranges.retain(|r| !ranges.iter().any(|x| x.intersects(r)));
1752                // The relative formulas are written for the top-left cell: when it goes, they're
1753                // rewritten for the new one, so the areas left keep reading the same cells.
1754                if let (Some(from), Some(to)) = (before, RangeRef::top_left(&cf.ranges)) {
1755                    rebase_cf_formulas(&mut cf.rule, from, to);
1756                }
1757            }
1758            sh.cond_formats.retain(|c| !c.ranges.is_empty());
1759        }
1760        Ok(Json::Null)
1761    })
1762}
1763
1764/// Moves the relative references of a rule's per-cell formulas written for `from` to `to`.
1765fn rebase_cf_formulas(rule: &mut CfRule, from: CellRef, to: CellRef) {
1766    let mv = |f: &str| crate::engine::cmd::data::move_rule_formula(f, from, to);
1767    match rule {
1768        CfRule::CellIs { a, b, .. } => {
1769            *a = mv(a);
1770            *b = b.as_deref().map(mv);
1771        }
1772        CfRule::Expression { formula, .. } => *formula = mv(formula),
1773        _ => {}
1774    }
1775}
1776
1777fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
1778    let sheet = target_sheet(s, p)?;
1779    if p.get("delete").is_none() && p.get("moveUp").is_none() && p.get("moveDown").is_none() {
1780        let list: Vec<Json> = s
1781            .doc()?
1782            .wb
1783            .sheet(sheet)
1784            .map(|sh| {
1785                sh.cond_formats.iter().map(|c| json!({"ranges": c.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), "rule": c.rule})).collect()
1786            })
1787            .unwrap_or_default();
1788        return Ok(json!({"rules": list}));
1789    }
1790    edit(s, |cx| {
1791        let sh = cx.sheet_mut(sheet)?;
1792        // The count is taken again after each step: a delete shortens the list.
1793        if let Some(i) = u32_param(p, "delete").map(|v| v as usize)
1794            && i < sh.cond_formats.len()
1795        {
1796            sh.cond_formats.remove(i);
1797        }
1798        if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize)
1799            && i > 0
1800            && i < sh.cond_formats.len()
1801        {
1802            sh.cond_formats.swap(i, i - 1);
1803        }
1804        if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize)
1805            && i + 1 < sh.cond_formats.len()
1806        {
1807            sh.cond_formats.swap(i, i + 1);
1808        }
1809        for (k, cf) in sh.cond_formats.iter_mut().enumerate() {
1810            cf.priority = k as u32 + 1;
1811        }
1812        Ok(Json::Null)
1813    })
1814}
1815
1816fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
1817    let name = str_param(p, "name").unwrap_or("check").to_string();
1818    let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32;
1819    let d = s.doc()?;
1820    let id = d.wb.next_object_id();
1821    let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
1822    let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
1823    let shape = Shape {
1824        math: Default::default(),
1825        id,
1826        kind: ShapeKind::Icon,
1827        anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() },
1828        fill: color,
1829        line: Color::Auto,
1830        text: name,
1831        format: Default::default(),
1832    };
1833    let sheet = d.wb.active_sheet();
1834    edit(s, |cx| {
1835        cx.sheet_mut(sheet)?.shapes.push(shape);
1836        Ok(json!({"shape": id}))
1837    })
1838}
1839
1840#[cfg(test)]
1841mod tests {
1842    use super::*;
1843
1844    #[test]
1845    fn chart_from_whole_columns_uses_the_rows_in_use() {
1846        // #3114: selecting two whole columns of data refused the chart as "range too large".
1847        let mut s = Session::new();
1848        s.new_workbook();
1849        let values: Vec<Vec<String>> = (1..=5).map(|i| vec![i.to_string(), (i * 10).to_string()]).collect();
1850        s.execute("range.setValues", json!({"range": "A1:B5", "values": values})).unwrap();
1851        s.execute("selection.set", json!({"range": "A:B"})).unwrap();
1852        s.execute("insert.chart", json!({"type": "line"})).unwrap();
1853        let d = s.doc().unwrap();
1854        let chart = d.wb.active().unwrap().charts.last().unwrap();
1855        let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect();
1856        assert_eq!(values, ["Sheet1!$A$1:$A$5", "Sheet1!$B$1:$B$5"]);
1857        // A block that really is too large still is.
1858        s.execute("cell.set", json!({"cell": "B600000", "input": "1"})).unwrap();
1859        assert!(s.execute("insert.chart", json!({"range": "A:B", "type": "line"})).is_err());
1860        // A bounded block keeps its extent, even where it runs past the data.
1861        s.execute("insert.chart", json!({"range": "A1:A20", "type": "line"})).unwrap();
1862        let d = s.doc().unwrap();
1863        let chart = d.wb.active().unwrap().charts.last().unwrap();
1864        let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect();
1865        assert_eq!(values, ["Sheet1!$A$1:$A$20"]);
1866        // A bounded block entirely below the data keeps its extent.
1867        s.execute("insert.chart", json!({"range": "C700000:C700010", "type": "line"})).unwrap();
1868        let d = s.doc().unwrap();
1869        let chart = d.wb.active().unwrap().charts.last().unwrap();
1870        let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect();
1871        assert_eq!(values, ["Sheet1!$C$700000:$C$700010"]);
1872    }
1873
1874    #[test]
1875    fn chart_data_table_commands_undo_and_follow_live_values() {
1876        let mut s = Session::new();
1877        s.new_workbook();
1878        s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap();
1879        s.execute("insert.chart", json!({"range": "A1:B3", "type": "column"})).unwrap();
1880        s.execute("chart.addElement", json!({"element": "dataTable", "dataTableKeys": false})).unwrap();
1881        let get = |s: &Session| s.active().unwrap().wb.active().unwrap().charts[0].clone();
1882        let table = get(&s).data_table.unwrap();
1883        assert!(!table.show_keys && table.horizontal_border && table.vertical_border && table.outline);
1884        s.execute("edit.undo", json!({})).unwrap();
1885        assert_eq!(get(&s).data_table, None);
1886        s.execute("edit.redo", json!({})).unwrap();
1887        assert_eq!(get(&s).data_table, Some(table));
1888        s.execute("chart.set", json!({"dataTableKeys": true, "dataTableHorizontalBorder": false})).unwrap();
1889        assert!(get(&s).data_table.as_ref().unwrap().show_keys);
1890        assert!(!get(&s).data_table.as_ref().unwrap().horizontal_border);
1891        s.execute("cell.set", json!({"cell": "B2", "input": "=12*3"})).unwrap();
1892        let d = s.active().unwrap();
1893        assert_eq!(crate::chart::resolve(&d.wb, 0, &get(&s)).series[0].values[0], Some(36.0));
1894        s.execute("chart.addElement", json!({"element": "dataTable", "on": false})).unwrap();
1895        assert_eq!(get(&s).data_table, None);
1896        // Old JSON documents have no table field.
1897        let mut json = serde_json::to_value(get(&s)).unwrap();
1898        json.as_object_mut().unwrap().remove("data_table");
1899        assert_eq!(serde_json::from_value::<Chart>(json).unwrap().data_table, None);
1900    }
1901
1902    #[test]
1903    fn chart_data_table_refuses_unsupported_types_without_changing_history() {
1904        let mut s = Session::new();
1905        s.new_workbook();
1906        s.execute("range.setValues", json!({"range": "A1", "values": [["Q", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap();
1907        s.execute("insert.chart", json!({"range": "A1:B3", "type": "pie"})).unwrap();
1908        let before = s.active().unwrap().wb.clone();
1909        let history = s.execute("history.list", json!({})).unwrap();
1910        for params in [json!({"dataTable": true}), json!({"dataTableKeys": true}), json!({"dataTable": "yes"})] {
1911            assert!(s.execute("chart.set", params).is_err());
1912            assert_eq!(s.active().unwrap().wb, before);
1913            assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
1914        }
1915        assert!(s.execute("chart.addElement", json!({"element": "dataTable"})).is_err());
1916        s.execute("chart.set", json!({"type": "line", "dataTable": true})).unwrap();
1917        assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some());
1918        s.execute("chart.set", json!({"type": "scatter"})).unwrap();
1919        assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_none());
1920        s.execute("edit.undo", json!({})).unwrap();
1921        assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some());
1922    }
1923
1924    #[test]
1925    fn manage_rules_delete_then_move() {
1926        let mut s = Session::new();
1927        s.new_workbook();
1928        for v in ["1", "2"] {
1929            let rule = json!({"type": "cellIs", "operator": "greaterThan", "value": v, "preset": "lightRedFill"});
1930            s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap();
1931        }
1932        // Deleting first left two rules' worth of indices for the move: it panicked.
1933        s.execute("home.manageRules", json!({"delete": 0, "moveUp": 1})).unwrap();
1934        s.execute("home.manageRules", json!({"delete": 0, "moveDown": 0})).unwrap();
1935        let rules = s.execute("home.manageRules", json!({})).unwrap();
1936        assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0));
1937    }
1938
1939    #[test]
1940    fn table_without_header_keeps_outside_references() {
1941        // #1030: the data moved down for the new header row, but formulas outside kept the old addresses.
1942        let mut s = Session::new();
1943        s.new_workbook();
1944        s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
1945        s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
1946        s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap();
1947        s.execute("cell.set", json!({"cell": "A2", "input": "20"})).unwrap();
1948        s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A2)"})).unwrap();
1949        s.execute("cell.set", json!({"cell": "C1", "input": "=A2"})).unwrap();
1950        s.execute("cell.set", json!({"sheet": "Two", "cell": "A1", "input": "=Sheet1!A2"})).unwrap();
1951        s.execute("insert.table", json!({"range": "A1:A2", "header": false})).unwrap();
1952        let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
1953        assert_eq!(get(&mut s, "A1")["value"], "Column1");
1954        assert_eq!(get(&mut s, "A2")["value"], json!(10.0));
1955        assert_eq!(get(&mut s, "B1")["formula"], "=SUM(A2:A3)");
1956        assert_eq!(get(&mut s, "B1")["value"], json!(30.0));
1957        assert_eq!(get(&mut s, "C1")["formula"], "=A3");
1958        assert_eq!(get(&mut s, "C1")["value"], json!(20.0));
1959        let other = s.execute("cell.get", json!({"sheet": "Two", "cell": "A1"})).unwrap();
1960        assert_eq!(other["formula"], "=Sheet1!A3");
1961        assert_eq!(other["value"], json!(20.0));
1962    }
1963
1964    #[test]
1965    fn table_names_refuse_defined_names_and_r1c1() {
1966        let mut s = Session::new();
1967        s.new_workbook();
1968        s.execute("range.setValues", json!({"range": "A1:A2", "values": [["h"], ["1"]]})).unwrap();
1969        s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.2"})).unwrap();
1970        s.execute("insert.table", json!({"range": "A1:A2"})).unwrap();
1971        let e = s.execute("table.rename", json!({"table": "Table1", "name": "Rate"})).unwrap_err();
1972        assert!(e.to_string().contains("already exists"), "{e}");
1973        let e = s.execute("table.rename", json!({"table": "Table1", "name": "rate"})).unwrap_err();
1974        assert!(e.to_string().contains("already exists"), "{e}");
1975        for n in ["R2C3", "c", "r", "RC", "C5"] {
1976            let e = s.execute("table.rename", json!({"table": "Table1", "name": n})).unwrap_err();
1977            assert!(e.to_string().contains("not valid"), "{n}: {e}");
1978            let e = s.execute("insert.table", json!({"range": "C1:C2", "name": n})).unwrap_err();
1979            assert!(e.to_string().contains("not valid"), "{n}: {e}");
1980        }
1981        s.execute("table.rename", json!({"table": "Table1", "name": "Table1"})).unwrap();
1982        s.execute("table.rename", json!({"table": "Table1", "name": "Rates"})).unwrap();
1983    }
1984}