Files
gridcraft/crates/engine/src/cmd/insert.rs
T
justinjohn0306 69709bd458 Line charts: smooth (spline) curves (issue #52)
Add support for Excel's "smoothed line" chart style. The smooth flag flows
model -> resolve -> chart render, persists through XLSX (<c:smooth val="1"/>),
and is toggled via the chart.addElement "smooth" element.

Rendering interpolates each gap-free run with a uniform Catmull-Rom spline
(0.5 tangent scale, 100 sub-segments per data segment), so the curve passes
through every data point and rounds the vertices — matching Excel's observed
behaviour. Long runs are still decimated before smoothing.

- model: Series.smooth (#[serde(default)], so old workbooks load unchanged)
- chart: SeriesData.smooth; smooth_run + catmull in render; wired through
  resolve and the sanitize() copy; draw_runs takes the flag (line + scatter)
- engine: chart.addElement element="smooth"
- xlsx: read/write <c:smooth val> instead of hard-coding val="0"
- tests: smooth_line_adds_interpolated_points_and_keeps_endpoints,
  smooth_line_splits_at_gaps, smooth_line_setting_round_trips
- examples/smooth_demo.rs: generates a straight+smooth side-by-side workbook

cargo xtask ci: all 7 steps pass.
2026-10-10 00:09:12 +05:30

1150 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Insert tab: tables, charts, sparklines, pictures, shapes, hyperlinks, comments and notes,
//! conditional formatting (Home › Styles) and symbols.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::*;
use crate::selection::current_region;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"insert.table",
"Table",
["Insert", "Tables"],
Some("Cmd+T"),
"{range?, header?: true, style?: \"TableStyleMedium2\", name?}",
has_doc,
insert_table
),
cmd!(
"home.formatAsTable",
"Format as Table",
["Home", "Styles"],
None,
"{range?, style: \"TableStyleMedium2\", header?}",
has_doc,
insert_table
),
cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag(
s, p, "totals"
)),
cmd!("table.bandedRows", "Banded Rows", ["Table", "Table Style Options"], None, "{table?, on?}", has_doc, |s, p| table_flag(
s,
p,
"bandedRows"
)),
cmd!("table.bandedColumns", "Banded Columns", ["Table", "Table Style Options"], None, "{table?, on?}", has_doc, |s, p| table_flag(
s,
p,
"bandedCols"
)),
cmd!("table.headerRow", "Header Row", ["Table", "Table Style Options"], None, "{table?, on?}", has_doc, |s, p| table_flag(s, p, "header")),
cmd!("table.firstColumn", "First Column", ["Table", "Table Style Options"], None, "{table?, on?}", has_doc, |s, p| table_flag(
s, p, "firstCol"
)),
cmd!("table.lastColumn", "Last Column", ["Table", "Table Style Options"], None, "{table?, on?}", has_doc, |s, p| table_flag(s, p, "lastCol")),
cmd!("table.style", "Table Styles", ["Table", "Table Styles"], None, "{table?, style}", has_doc, table_style),
cmd!("table.rename", "Table Name", ["Table", "Properties"], None, "{table?, name}", has_doc, table_rename),
cmd!("table.convertToRange", "Convert to Range", ["Table", "Tools"], None, "{table?}", has_doc, table_convert),
cmd!(
"table.totalFunction",
"Totals Function",
[],
None,
"{table?, column: \"Sales\", function: sum|average|count|countNums|max|min|stdDev|var|none}",
has_doc,
table_total_fn
),
cmd!(
"insert.chart",
"Insert Chart",
["Insert", "Charts"],
None,
"{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?}",
has_doc,
insert_chart
),
cmd!("insert.recommendedCharts", "Recommended Charts", ["Insert", "Charts"], None, "{range?}", has_doc, |s, p| insert_chart(
s,
&with(p, "type", json!("auto"))
)),
cmd!(
"chart.set",
"Chart Properties",
["Chart Design"],
None,
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
has_doc,
chart_set
),
cmd!("chart.switchRowColumn", "Switch Row/Column", ["Chart Design", "Data"], None, "{chart?}", has_doc, chart_switch),
cmd!("chart.delete", "Delete Chart", [], None, "{chart?}", has_doc, chart_delete),
cmd!(
"insert.sparkline",
"Sparklines",
["Insert", "Sparklines"],
None,
"{range: \"B2:M2\", location: \"N2\", type?: line|column|winLoss, markers?}",
has_doc,
insert_sparkline
),
cmd!(
"insert.picture",
"Picture",
["Insert", "Illustrations"],
None,
"{path? | base64?: \"...\", mime?, at?: \"B2\", width?, height?, alt?}",
has_doc,
insert_picture
),
cmd!(
"insert.shape",
"Shapes",
["Insert", "Illustrations"],
None,
"{kind: rectangle|roundedRectangle|ellipse|triangle|line|arrow|textBox, at?, width?, height?, text?, fill?, line?}",
has_doc,
insert_shape
),
cmd!(
"insert.icons",
"Icons",
["Insert", "Illustrations"],
None,
"{name: chart|table|sum|filter|lock|comment|picture|book|folder|search|calc|check|… , at?, size?: 48, color?: hex}",
has_doc,
insert_icon
),
cmd!("insert.textBox", "Text Box", ["Insert", "Text"], None, "{at?, width?, height?, text?}", has_doc, |s, p| insert_shape(
s,
&with(p, "kind", json!("textBox"))
)),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape, id}", has_doc, delete_object),
cmd!("object.move", "Move Object", [], None, "{kind: chart|image|shape, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object),
cmd!(
"insert.link",
"Link",
["Insert", "Links"],
Some("Cmd+K"),
"{cell?, target: \"https://…\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}",
has_doc,
insert_link
),
cmd!("review.newNote", "New Note", ["Review", "Notes"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
cmd!("review.newComment", "New Comment", ["Review", "Comments"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, true)),
cmd!("review.replyComment", "Reply", [], None, "{cell?, text, author?}", has_doc, reply_comment),
cmd!("review.resolveComment", "Resolve Thread", [], None, "{cell?, resolved?: true}", has_doc, resolve_comment),
cmd!("review.deleteComment", "Delete Comment", ["Review", "Comments"], None, "{cell?}", has_doc, delete_comment),
cmd!("review.showNote", "Show/Hide Note", ["Review", "Notes"], None, "{cell?}", has_doc, show_note),
cmd!(
"insert.symbol",
"Symbol",
["Insert", "Symbols"],
None,
"{char: \"€\"} (inserted at the end of the active cell's text)",
has_doc,
insert_symbol
),
cmd!(
"home.conditionalFormat",
"Conditional Formatting",
["Home", "Styles"],
None,
"{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..fill..}|preset: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}}",
has_doc,
add_cf
),
cmd!("home.clearRules", "Clear Rules", ["Home", "Styles", "Conditional Formatting"], None, "{range?|sheet: true}", has_doc, clear_cf),
cmd!(
"home.manageRules",
"Manage Rules…",
["Home", "Styles", "Conditional Formatting"],
None,
"{delete?: index, moveUp?: index, moveDown?: index}",
has_doc,
manage_cf
),
]
}
fn with(p: &Json, k: &str, v: Json) -> Json {
let mut m = p.as_object().cloned().unwrap_or_default();
m.insert(k.into(), v);
Json::Object(m)
}
fn block(s: &Session, p: &Json) -> Result<RangeRef> {
if p.get("range").is_some() {
return target_range(s, p);
}
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = d.selection.current();
if !sel.is_single() {
return Ok(sel);
}
Ok(current_region(sh, d.selection.active))
}
// ---------------------------------------------------------------- tables
fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
let r = block(s, p)?;
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
if d.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&r))) {
return Err(EngineError::Other("A table can't overlap another table.".into()));
}
if r.count() > 50_000_000 {
return Err(bad("insert.table", "range too large"));
}
let header = bool_param(p, "header").unwrap_or_else(|| {
let sh = d.wb.sheet(sheet);
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)
});
let name = str_param(p, "name")
.map(str::to_string)
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
let id = d.wb.next_object_id();
edit(s, |cx| {
// Without a header row, insert one with generic names.
let r = if header {
r
} else {
let sh = cx.sheet_mut(sheet)?;
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
for (i, c) in (r.start.col..=r.end.col).enumerate() {
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
}
cx.structural = true;
RangeRef::new(r.start, CellRef::new(r.end.row + 1, r.end.col))
};
let sh = cx.sheet_mut(sheet)?;
let mut names: Vec<String> = Vec::new();
for c in r.start.col..=r.end.col {
let mut n = sh.value(CellRef::new(r.start.row, c)).display();
if n.is_empty() {
n = format!("Column{}", c - r.start.col + 1);
}
let base = n.clone();
let mut k = 2;
while names.iter().any(|x| x.eq_ignore_ascii_case(&n)) {
n = format!("{base}{k}");
k += 1;
}
// Headers are text.
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(n.as_str()));
names.push(n);
}
sh.tables.push(Table {
id,
name: name.clone(),
range: r,
header_row: true,
totals_row: false,
columns: names.into_iter().map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None }).collect(),
style,
banded_rows: true,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: true,
});
cx.structural = true;
Ok(json!({"table": name, "range": r.a1()}))
})
}
fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
if let Some(n) = str_param(p, "table") {
return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`")));
}
let sheet = d.wb.active_sheet;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let ti = sh
.tables
.iter()
.position(|t| t.range.contains(d.selection.active))
.ok_or_else(|| EngineError::Disabled("table".into(), "select a cell in a table".into()))?;
Ok((sheet, ti))
}
fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let on = bool_param(p, "on");
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let Some(t) = sh.tables.get_mut(ti) else { return Ok(Json::Null) };
match which {
"totals" => {
let v = on.unwrap_or(!t.totals_row);
if v != t.totals_row {
t.totals_row = v;
if v {
t.range.end.row += 1;
if let Some(first) = t.columns.first_mut() {
first.totals_label = Some("Total".into());
}
if t.columns.len() > 1
&& let Some(last) = t.columns.last_mut()
&& last.totals == TotalsFn::None
{
last.totals = TotalsFn::Sum;
}
} else {
let row = t.range.end.row;
t.range.end.row = t.range.end.row.saturating_sub(1);
let (c0, c1) = (t.range.start.col, t.range.end.col);
for c in c0..=c1 {
sh.cells.remove(CellRef::new(row, c));
}
}
write_totals(sh, ti);
cx.structural = true;
}
}
"bandedRows" => t.banded_rows = on.unwrap_or(!t.banded_rows),
"bandedCols" => t.banded_cols = on.unwrap_or(!t.banded_cols),
"firstCol" => t.first_col = on.unwrap_or(!t.first_col),
"lastCol" => t.last_col = on.unwrap_or(!t.last_col),
"header" => t.filter_button = on.unwrap_or(!t.filter_button),
_ => {}
}
Ok(Json::Null)
})
}
/// Writes the totals row cells (labels and SUBTOTAL formulas).
fn write_totals(sh: &mut Sheet, ti: usize) {
let Some(t) = sh.tables.get(ti).cloned() else { return };
if !t.totals_row {
return;
}
let row = t.range.end.row;
for (i, col) in t.columns.iter().enumerate() {
let c = CellRef::new(row, t.range.start.col + i as u32);
if let Some(code) = col.totals.subtotal_code() {
let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name);
sh.cells
.set(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
} else if let Some(l) = &col.totals_label {
sh.set_value(c, gridcraft_core::Value::text(l.as_str()));
} else {
sh.cells.remove(c);
}
}
}
fn table_total_fn(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let col = str_param(p, "column").ok_or_else(|| bad("table.totalFunction", "missing `column`"))?.to_string();
let f = match str_param(p, "function").unwrap_or("sum") {
"average" => TotalsFn::Average,
"count" => TotalsFn::Count,
"countNums" => TotalsFn::CountNums,
"max" => TotalsFn::Max,
"min" => TotalsFn::Min,
"stdDev" => TotalsFn::StdDev,
"var" => TotalsFn::Var,
"none" => TotalsFn::None,
_ => TotalsFn::Sum,
};
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
if let Some(t) = sh.tables.get_mut(ti)
&& let Some(i) = t.column_index(&col)
&& let Some(c) = t.columns.get_mut(i)
{
c.totals = f;
}
write_totals(sh, ti);
cx.structural = true;
Ok(Json::Null)
})
}
fn table_style(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
edit(s, |cx| {
if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) {
t.style = style.clone();
}
Ok(Json::Null)
})
}
fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string();
if name.is_empty()
|| name.contains(' ')
|| name.chars().next().is_some_and(|c| c.is_ascii_digit())
|| gridcraft_core::CellRef::parse(&name).is_some()
{
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
if s.doc()?.wb.table(&name).is_some_and(|x| x != (si, ti)) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
edit(s, |cx| {
let old = cx.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.name.clone()).unwrap_or_default();
if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) {
t.name = name.clone();
}
// Structured references follow the rename.
for i in 0..cx.wb.sheets.len() {
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
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();
let Some(sh) = cx.wb.sheet_mut(i) else { continue };
for (c, f) in keys {
let Some(e) = f.expr() else { continue };
let ne = e.map(&mut |x| match x {
gridcraft_formula::Expr::Struct(mut st) if st.table.eq_ignore_ascii_case(&old) => {
st.table = name.clone();
gridcraft_formula::Expr::Struct(st)
}
gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
o => o,
});
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne)));
}
}
}
cx.structural = true;
Ok(Json::Null)
})
}
fn table_convert(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
edit(s, |cx| {
// Apply the table look as plain formatting, then drop the table.
let wb = &mut cx.wb;
let Some(t) = wb.sheet(si).and_then(|sh| sh.tables.get(ti)).cloned() else { return Ok(Json::Null) };
let looks: Vec<(CellRef, Style)> = t.range.iter().map(|c| (c, crate::tables::table_cell_style(wb, si, &t, c))).collect();
for (c, st) in looks {
let id = wb.styles.intern(st);
if let Some(sh) = wb.sheet_mut(si) {
sh.set_style(c, id);
}
}
if let Some(sh) = wb.sheet_mut(si) {
sh.tables.remove(ti);
}
cx.structural = true;
Ok(Json::Null)
})
}
// ---------------------------------------------------------------- charts
pub fn chart_kind(t: &str, sub: &str) -> ChartKind {
match (t, sub) {
("column", "stacked") => ChartKind::ColumnStacked,
("column", "stacked100") => ChartKind::ColumnStacked100,
("bar", "stacked") => ChartKind::BarStacked,
("bar", "stacked100") => ChartKind::BarStacked100,
("bar", _) => ChartKind::BarClustered,
("line", "markers") => ChartKind::LineMarkers,
("line", "stacked") => ChartKind::LineStacked,
("line", _) => ChartKind::Line,
("pie", _) => ChartKind::Pie,
("doughnut", _) => ChartKind::Doughnut,
("area", "stacked") => ChartKind::AreaStacked,
("area", _) => ChartKind::Area,
("scatter", "lines") => ChartKind::ScatterLines,
("scatter", _) => ChartKind::Scatter,
("bubble", _) => ChartKind::Bubble,
("radar", _) => ChartKind::Radar,
("histogram", _) => ChartKind::Histogram,
("waterfall", _) => ChartKind::Waterfall,
("funnel", _) => ChartKind::Funnel,
("treemap", _) => ChartKind::Treemap,
("sunburst", _) => ChartKind::Sunburst,
("boxWhisker", _) => ChartKind::BoxWhisker,
("stock", _) => ChartKind::Stock,
("combo", _) => ChartKind::Combo,
_ => ChartKind::ColumnClustered,
}
}
/// Builds series from a block: first row/column as names/categories when they're text.
pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series> {
let q = gridcraft_formula::quote_sheet(&sh.name);
let abs = |r: RangeRef| {
format!(
"{q}!${}${}:${}${}",
gridcraft_core::col_to_letters(r.start.col),
r.start.row + 1,
gridcraft_core::col_to_letters(r.end.col),
r.end.row + 1
)
};
let abs1 = |c: CellRef| format!("{q}!${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
let is_text = |c: CellRef| sh.value(c).is_text() || sh.value(c).is_empty();
let header_row = r.height() > 1
&& (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text())
&& (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c)));
let label_col = r.width() > 1 && (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col)));
let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end);
let mut out = Vec::new();
if !by_rows {
for c in data.start.col..=data.end.col {
out.push(Series {
name: header_row.then(|| abs1(CellRef::new(r.start.row, c))),
categories: label_col.then(|| abs(RangeRef::new(CellRef::new(data.start.row, r.start.col), CellRef::new(data.end.row, r.start.col)))),
values: abs(RangeRef::new(CellRef::new(data.start.row, c), CellRef::new(data.end.row, c))),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
});
}
} else {
for row in data.start.row..=data.end.row {
out.push(Series {
name: label_col.then(|| abs1(CellRef::new(row, r.start.col))),
categories: header_row
.then(|| abs(RangeRef::new(CellRef::new(r.start.row, data.start.col), CellRef::new(r.start.row, data.end.col)))),
values: abs(RangeRef::new(CellRef::new(row, data.start.col), CellRef::new(row, data.end.col))),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
});
}
}
out
}
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
let r = block(s, p)?;
if r.count() > 1_000_000 {
return Err(bad("insert.chart", "range too large for a chart"));
}
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let mut t = str_param(p, "type").unwrap_or("column").to_string();
// Excel plots series by the longer dimension (more rows → series in columns).
let data_h = r.height();
let data_w = r.width();
let by_rows = data_w > data_h + 1;
if t == "auto" {
t = if data_h > 12 && data_w <= 3 { "line".into() } else { "column".into() };
}
let kind = chart_kind(&t, str_param(p, "subtype").unwrap_or(""));
let series = series_from_range(sh, r, by_rows);
if series.is_empty() {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
let title = str_param(p, "title").map(str::to_string).or_else(|| {
if series.len() == 1 {
series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display())
} else {
Some("Chart Title".into())
}
});
let chart = Chart {
id,
kind,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").unwrap_or(480.0) as f32,
height: f64_param(p, "height").unwrap_or(288.0) as f32,
},
title,
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
y_title: None,
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
by_rows,
};
edit(s, |cx| {
cx.sheet_mut(sheet)?.charts.push(chart);
Ok(json!({"chart": id}))
})
}
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
None => sh.charts.len().checked_sub(1),
};
idx.map(|i| (sheet, i)).ok_or_else(|| bad("chart", "no such chart"))
}
fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
if let Some(t) = str_param(p, "type") {
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
}
if let Some(t) = p.get("title") {
c.title = t.as_str().map(str::to_string);
}
if let Some(l) = str_param(p, "legend") {
c.legend = match l {
"none" => LegendPos::None,
"top" => LegendPos::Top,
"left" => LegendPos::Left,
"right" => LegendPos::Right,
_ => LegendPos::Bottom,
};
}
if let Some(v) = bool_param(p, "dataLabels") {
c.data_labels = v;
}
if let Some(v) = bool_param(p, "gridlines") {
c.gridlines = v;
}
if let Some(v) = u32_param(p, "style") {
c.style = v.clamp(1, 16);
}
if let Some(t) = p.get("xTitle") {
c.x_title = t.as_str().map(str::to_string);
}
if let Some(t) = p.get("yTitle") {
c.y_title = t.as_str().map(str::to_string);
}
if let Some(at) = cell_param(p, "at") {
c.anchor.cell = at;
}
if let Some(w) = f64_param(p, "width") {
c.anchor.width = w.clamp(20.0, 10000.0) as f32;
}
if let Some(h) = f64_param(p, "height") {
c.anchor.height = h.clamp(20.0, 10000.0) as f32;
}
if let Some(v) = f64_param(p, "dx") {
c.anchor.dx = v as f32;
}
if let Some(v) = f64_param(p, "dy") {
c.anchor.dy = v as f32;
}
Ok(Json::Null)
})
}
fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let Some(c) = sh.charts.get(ci) else { return Ok(Json::Null) };
let Some(src) = c.source.clone() else { return Ok(Json::Null) };
let (sheet_name, body) = split_sheet(&src);
let src_sheet = sheet_name.and_then(|n| cx.wb.sheet_index(&n)).unwrap_or(si);
let Some(r) = RangeRef::parse(body) else { return Ok(Json::Null) };
let Some(ssh) = cx.wb.sheet(src_sheet) else { return Ok(Json::Null) };
let by_rows = !c.by_rows;
let series = series_from_range(ssh, r, by_rows);
if let Some(c) = cx.sheet_mut(si)?.charts.get_mut(ci) {
c.by_rows = by_rows;
c.series = series;
}
Ok(Json::Null)
})
}
fn chart_delete(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
cx.sheet_mut(si)?.charts.remove(ci);
Ok(Json::Null)
})
}
fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
let loc = cell_param(p, "location").ok_or_else(|| bad("insert.sparkline", "missing `location`"))?;
let kind = match str_param(p, "type") {
Some("column") => SparklineKind::Column,
Some("winLoss") => SparklineKind::WinLoss,
_ => SparklineKind::Line,
};
let markers = bool_param(p, "markers").unwrap_or(false);
let sheet = s.doc()?.wb.active_sheet;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
sh.sparklines.retain(|x| x.cell != loc);
sh.sparklines.push(Sparkline { cell: loc, source: src.clone(), kind, color: Color::Theme(4, 0), markers });
Ok(Json::Null)
})
}
fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
let data = if let Some(b) = str_param(p, "base64") {
crate::io::base64_decode(b).ok_or_else(|| bad("insert.picture", "invalid base64"))?
} else if let Some(path) = str_param(p, "path") {
crate::io::read_file(path)?
} else {
s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({})));
return ok();
};
if data.len() > 64 * 1024 * 1024 {
return Err(bad("insert.picture", "image larger than 64 MB"));
}
let mime = str_param(p, "mime")
.map(str::to_string)
.unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() });
let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240));
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or(d.selection.active);
let scale = (640.0 / w.max(1) as f32).min(1.0);
let img = Image {
id,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").map(|v| v as f32).unwrap_or(w as f32 * scale),
height: f64_param(p, "height").map(|v| v as f32).unwrap_or(h as f32 * scale),
},
data,
mime,
alt: str_param(p, "alt").unwrap_or("").into(),
};
let sheet = d.wb.active_sheet;
edit(s, |cx| {
cx.sheet_mut(sheet)?.images.push(img);
Ok(json!({"image": id}))
})
}
fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
let kind = match str_param(p, "kind").unwrap_or("rectangle") {
"roundedRectangle" => ShapeKind::RoundedRectangle,
"ellipse" | "oval" => ShapeKind::Ellipse,
"triangle" => ShapeKind::Triangle,
"line" => ShapeKind::Line,
"arrow" => ShapeKind::Arrow,
"textBox" => ShapeKind::TextBox,
_ => ShapeKind::Rectangle,
};
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or(d.selection.active);
let fill =
super::format::color_param(p.get("fill")).unwrap_or(if kind == ShapeKind::TextBox { Color::rgb(255, 255, 255) } else { Color::Theme(4, 0) });
let line = super::format::color_param(p.get("line")).unwrap_or(if kind == ShapeKind::TextBox {
Color::rgb(0x80, 0x80, 0x80)
} else {
Color::Theme(4, -250)
});
let shape = Shape {
id,
kind,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").unwrap_or(144.0) as f32,
height: f64_param(p, "height").unwrap_or(if kind == ShapeKind::Line { 0.0 } else { 96.0 }) as f32,
},
fill,
line,
text: str_param(p, "text").unwrap_or("").into(),
};
let sheet = d.wb.active_sheet;
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
})
}
fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
match kind.as_str() {
"chart" => sh.charts.retain(|c| c.id != id),
"image" => sh.images.retain(|c| c.id != id),
_ => sh.shapes.retain(|c| c.id != id),
}
Ok(Json::Null)
})
}
fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
};
let Some(a) = anchor else { return Err(bad("object.move", "no such object")) };
if let Some(at) = cell_param(p, "at") {
a.cell = at;
}
if let Some(v) = f64_param(p, "dx") {
a.dx = v as f32;
}
if let Some(v) = f64_param(p, "dy") {
a.dy = v as f32;
}
if let Some(v) = f64_param(p, "width") {
a.width = v.clamp(1.0, 10000.0) as f32;
}
if let Some(v) = f64_param(p, "height") {
a.height = v.clamp(0.0, 10000.0) as f32;
}
Ok(Json::Null)
})
}
// ---------------------------------------------------------------- links & comments
fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
if bool_param(p, "remove").unwrap_or(false) {
return edit(s, |cx| {
cx.sheet_mut(sheet)?.hyperlinks.remove(&at);
Ok(Json::Null)
});
}
let Some(target) = str_param(p, "target").map(str::to_string) else {
s.ui_requests.push(crate::UiRequest::Dialog("insertLink".into(), json!({})));
return ok();
};
let text = str_param(p, "text").map(str::to_string);
let tooltip = str_param(p, "tooltip").map(str::to_string);
edit(s, |cx| {
let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| {
st.font.color = Color::Theme(10, 0);
st.font.underline = Underline::Single;
});
let sh = cx.sheet_mut(sheet)?;
if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) {
sh.cells.set(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style });
} else {
sh.set_style(at, link_style);
}
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone() });
cx.touch(sheet, at);
Ok(Json::Null)
})
}
fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
cx.sheet_mut(sheet)?
.comments
.insert(at, Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false });
Ok(json!({"cell": at.a1()}))
})
}
fn reply_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
let c = cx.sheet_mut(sheet)?.comments.get_mut(&at).ok_or_else(|| EngineError::Other("There's no comment in this cell.".into()))?;
c.replies.push((author.clone(), text.clone()));
Ok(Json::Null)
})
}
fn resolve_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
let v = bool_param(p, "resolved").unwrap_or(true);
edit(s, |cx| {
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
c.resolved = v;
}
Ok(Json::Null)
})
}
fn delete_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
edit(s, |cx| {
cx.sheet_mut(sheet)?.comments.retain(|c, _| !ranges.iter().any(|r| r.contains(*c)));
Ok(Json::Null)
})
}
fn show_note(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "cell").unwrap_or(s.doc()?.selection.active);
edit(s, |cx| {
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
c.visible = !c.visible;
}
Ok(Json::Null)
})
}
fn insert_symbol(s: &mut Session, p: &Json) -> Result<Json> {
let ch = str_param(p, "char").unwrap_or("").to_string();
let d = s.doc()?;
let at = d.selection.active;
let cur = d.wb.active().and_then(|sh| sh.cell(at)).map(|c| c.input_text()).unwrap_or_default();
s.execute("cell.set", json!({"cell": at.a1(), "input": format!("{cur}{ch}")}))
}
// ---------------------------------------------------------------- conditional formatting
fn preset_style(name: &str) -> Style {
let mut s = Style::default();
match name {
"redText" => s.font.color = Color::rgb(0x9C, 0x00, 0x06),
"yellowFill" | "yellowFillDarkYellowText" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C));
s.font.color = Color::rgb(0x9C, 0x57, 0x00);
}
"greenFill" | "greenFillDarkGreenText" => {
s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE));
s.font.color = Color::rgb(0x00, 0x61, 0x00);
}
"lightRedFill" => s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE)),
"redBorder" => {
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xC0, 0x00, 0x00) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
_ => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE));
s.font.color = Color::rgb(0x9C, 0x00, 0x06);
}
}
s
}
fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let sheet = target_sheet(s, p)?;
let Some(rule) = p.get("rule") else {
s.ui_requests.push(crate::UiRequest::Dialog("newFormattingRule".into(), json!({})));
return ok();
};
let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string);
let style = Box::new(match rule.get("style") {
Some(st) if st.is_object() => {
let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?;
serde_json::from_value(super::format::merge_json(base, st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
}
_ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")),
});
let kind = g("type").unwrap_or_else(|| "cellIs".into());
let value_kind = |v: Option<&Json>, default: CfValueKind| -> CfValueKind {
match v {
Some(Json::Object(o)) => {
let t = o.get("type").and_then(Json::as_str).unwrap_or("num");
let val = o.get("value").and_then(Json::as_f64).unwrap_or(0.0);
match t {
"min" => CfValueKind::Min,
"max" => CfValueKind::Max,
"percent" => CfValueKind::Percent(val),
"percentile" => CfValueKind::Percentile(val),
"formula" => CfValueKind::Formula(o.get("value").and_then(Json::as_str).unwrap_or("").into()),
_ => CfValueKind::Number(val),
}
}
_ => default,
}
};
let rule = match kind.as_str() {
"cellIs" => {
let op = match g("operator").as_deref().unwrap_or("greater") {
"between" => CfOperator::Between,
"notBetween" => CfOperator::NotBetween,
"equal" => CfOperator::Equal,
"notEqual" => CfOperator::NotEqual,
"less" => CfOperator::Less,
"greaterOrEqual" => CfOperator::GreaterOrEqual,
"lessOrEqual" => CfOperator::LessOrEqual,
_ => CfOperator::Greater,
};
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
let b = rule.get("value2").map(super::edit::json_to_input);
let quote = |v: String| {
if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') {
v.trim_start_matches('=').to_string()
} else {
format!("\"{}\"", v.replace('"', "\"\""))
}
};
CfRule::CellIs { op, a: quote(a), b: b.map(quote), style }
}
"expression" => CfRule::Expression { formula: g("formula").unwrap_or_default().trim_start_matches('=').to_string(), style },
"containsText" => CfRule::ContainsText { text: g("text").unwrap_or_default(), style },
"notContainsText" => CfRule::NotContainsText { text: g("text").unwrap_or_default(), style },
"beginsWith" => CfRule::BeginsWith { text: g("text").unwrap_or_default(), style },
"endsWith" => CfRule::EndsWith { text: g("text").unwrap_or_default(), style },
"blanks" => CfRule::Blanks { style },
"noBlanks" => CfRule::NoBlanks { style },
"errors" => CfRule::Errors { style },
"noErrors" => CfRule::NoErrors { style },
"duplicate" => CfRule::Duplicate { style },
"unique" => CfRule::Unique { style },
"top10" => CfRule::Top10 {
bottom: rule.get("bottom").and_then(Json::as_bool).unwrap_or(false),
percent: rule.get("percent").and_then(Json::as_bool).unwrap_or(false),
rank: rule.get("rank").and_then(Json::as_u64).unwrap_or(10) as u32,
style,
},
"aboveAverage" => CfRule::AboveAverage { below: rule.get("below").and_then(Json::as_bool).unwrap_or(false), equal: false, std_dev: 0, style },
"timePeriod" => CfRule::TimePeriod { period: g("period").unwrap_or_else(|| "today".into()), style },
"colorScale" => {
let cols: Vec<Color> = rule
.get("colors")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| super::format::color_param(Some(c))).collect())
.unwrap_or_else(|| vec![Color::rgb(0xF8, 0x69, 0x6B), Color::rgb(0xFF, 0xEB, 0x84), Color::rgb(0x63, 0xBE, 0x7B)]);
let stops = match cols.len() {
2 => vec![(CfValueKind::Min, cols[0]), (CfValueKind::Max, cols[1])],
_ => vec![
(CfValueKind::Min, cols.first().copied().unwrap_or_default()),
(CfValueKind::Percentile(50.0), cols.get(1).copied().unwrap_or_default()),
(CfValueKind::Max, cols.get(2).copied().unwrap_or_default()),
],
};
CfRule::ColorScale { stops }
}
"dataBar" => CfRule::DataBar {
min: value_kind(rule.get("min"), CfValueKind::Min),
max: value_kind(rule.get("max"), CfValueKind::Max),
color: super::format::color_param(rule.get("color")).unwrap_or(Color::rgb(0x63, 0x8E, 0xC6)),
gradient: rule.get("gradient").and_then(Json::as_bool).unwrap_or(true),
show_value: true,
},
"iconSet" => CfRule::IconSet {
set: g("set").unwrap_or_else(|| "3TrafficLights1".into()),
thresholds: vec![],
reverse: rule.get("reverse").and_then(Json::as_bool).unwrap_or(false),
show_value: true,
},
other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))),
};
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1;
// New rules take precedence over older ones.
for cf in sh.cond_formats.iter_mut() {
cf.priority += 1;
}
sh.cond_formats.insert(0, CondFormat { ranges: ranges.clone(), rule, priority: 1, stop_if_true: false });
let _ = priority;
Ok(json!({"rules": sh.cond_formats.len()}))
})
}
fn clear_cf(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let whole = bool_param(p, "sheet").unwrap_or(false);
let ranges = target_ranges(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if whole {
sh.cond_formats.clear();
} else {
for cf in sh.cond_formats.iter_mut() {
cf.ranges.retain(|r| !ranges.iter().any(|x| x.intersects(r)));
}
sh.cond_formats.retain(|c| !c.ranges.is_empty());
}
Ok(Json::Null)
})
}
fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
if p.get("delete").is_none() && p.get("moveUp").is_none() && p.get("moveDown").is_none() {
let list: Vec<Json> = s
.doc()?
.wb
.sheet(sheet)
.map(|sh| {
sh.cond_formats.iter().map(|c| json!({"ranges": c.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), "rule": c.rule})).collect()
})
.unwrap_or_default();
return Ok(json!({"rules": list}));
}
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let n = sh.cond_formats.len();
if let Some(i) = u32_param(p, "delete").map(|v| v as usize)
&& i < n
{
sh.cond_formats.remove(i);
}
if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize)
&& i > 0
&& i < n
{
sh.cond_formats.swap(i, i - 1);
}
if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize)
&& i + 1 < n
{
sh.cond_formats.swap(i, i + 1);
}
for (k, cf) in sh.cond_formats.iter_mut().enumerate() {
cf.priority = k as u32 + 1;
}
Ok(Json::Null)
})
}
fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").unwrap_or("check").to_string();
let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32;
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(p, "at").unwrap_or(d.selection.active);
let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
let shape = Shape {
id,
kind: ShapeKind::Icon,
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size },
fill: color,
line: Color::Auto,
text: name,
};
let sheet = d.wb.active_sheet;
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
})
}