1use 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 let sheet = target_sheet(s, p)?;
255 if let Some(used) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
256 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
271fn 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 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 let r = if header {
314 r
315 } else {
316 let sh = cx.sheet_mut(sheet)?;
317 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 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 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 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
385fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
387 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
468fn 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
530fn 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 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 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 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
604pub 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
637pub 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 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
721pub 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
732const MAX_CHART_CELLS: u64 = 1_000_000;
734
735pub 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
797pub 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
956pub(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 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 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
1014fn 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 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 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
1134fn 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
1205const 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 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
1236fn 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 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
1261const MAX_OBJECT_OFFSET: f64 = 100_000.0;
1263
1264const MAX_OBJECT_SIZE: f64 = 10_000.0;
1267
1268fn 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
1300fn 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
1313fn 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
1348fn 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
1378fn 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 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 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
1482fn 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
1509fn 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
1514fn 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 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 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 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
1570fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> {
1574 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 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 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 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 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
1764fn 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 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 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 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 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 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 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 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 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}