1use crate::base::{CellRef, RangeRef};
4use crate::model::{BorderLine, BorderStyle, Borders, Color, Fill, HAlign, NumFmt, Style, StyleId, Theme, Underline, VAlign, VertAlign};
5use serde_json::{Value as Json, json};
6
7use super::{
8 CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, parse_lines, split_sheet, str_param,
9 target_ranges, target_sheet,
10};
11
12pub fn specs() -> Vec<CommandSpec> {
13 vec![
14 cmd!("home.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{range?, on?: bool}", has_doc, |s, p| toggle(
16 s,
17 p,
18 |st| st.font.bold,
19 |st, v| st.font.bold = v
20 )),
21 cmd!("home.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{range?, on?}", has_doc, |s, p| toggle(
22 s,
23 p,
24 |st| st.font.italic,
25 |st, v| st.font.italic = v
26 )),
27 cmd!(
28 "home.underline",
29 "Underline",
30 ["Home", "Font"],
31 Some("Cmd+U"),
32 "{range?, on?, style?: single|double|singleAccounting|doubleAccounting}",
33 has_doc,
34 underline
35 ),
36 cmd!("home.doubleUnderline", "Double Underline", ["Home", "Font"], None, "{range?}", has_doc, |s, p| underline(
37 s,
38 &with(p, "style", json!("double"))
39 )),
40 cmd!("home.strikethrough", "Strikethrough", ["Home", "Font"], Some("Cmd+Shift+X"), "{range?, on?}", has_doc, |s, p| toggle(
41 s,
42 p,
43 |st| st.font.strike,
44 |st, v| st.font.strike = v
45 )),
46 cmd!("home.superscript", "Superscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle(
47 s,
48 p,
49 |st| st.font.vert == VertAlign::Superscript,
50 |st, v| st.font.vert = if v { VertAlign::Superscript } else { VertAlign::Baseline }
51 )),
52 cmd!("home.subscript", "Subscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle(
53 s,
54 p,
55 |st| st.font.vert == VertAlign::Subscript,
56 |st, v| st.font.vert = if v { VertAlign::Subscript } else { VertAlign::Baseline }
57 )),
58 cmd!("home.fontName", "Font", ["Home", "Font"], None, "{range?, name: \"Calibri\"}", has_doc, font_name),
59 cmd!("home.fontSize", "Font Size", ["Home", "Font"], None, "{range?, size: 11}", has_doc, font_size),
60 cmd!("home.increaseFontSize", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+>"), "{range?}", has_doc, |s, p| step_font(s, p, true)),
61 cmd!("home.decreaseFontSize", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+<"), "{range?}", has_doc, |s, p| step_font(
62 s, p, false
63 )),
64 cmd!(
65 "home.fontColor",
66 "Font Color",
67 ["Home", "Font"],
68 None,
69 "{range?, color: \"#C00000\" | \"auto\" | {theme: 4, tint: -0.25}}",
70 has_doc,
71 font_color
72 ),
73 cmd!("home.fillColor", "Fill Color", ["Home", "Font"], None, "{range?, color: \"#FFFF00\" | \"none\" | {theme, tint}}", has_doc, fill_color),
74 cmd!(
75 "home.borders",
76 "Borders",
77 ["Home", "Font"],
78 None,
79 "{range?, preset: bottom|top|left|right|none|all|outside|thickOutside|thickBottom|doubleBottom|topBottom|topThickBottom|topDoubleBottom|insideHorizontal|insideVertical|inside|diagonalDown|diagonalUp, style?: thin|medium|thick|dashed|dotted|double|hair…, color?}",
80 has_doc,
81 borders
82 ),
83 cmd!(
84 "home.formatCells",
85 "Format Cells…",
86 ["Home", "Cells", "Format"],
87 Some("Cmd+1"),
88 "{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)",
89 has_doc,
90 format_cells
91 ),
92 CommandSpec {
94 shortcut_mac: Some("Cmd+L"),
95 ..cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left))
96 },
97 CommandSpec {
98 shortcut_mac: Some("Cmd+E"),
99 ..cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center))
100 },
101 cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
102 cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)),
103 cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)),
104 cmd!("home.alignMiddle", "Middle Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Center)),
105 cmd!("home.alignBottom", "Bottom Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Bottom)),
106 cmd!("home.wrapText", "Wrap Text", ["Home", "Alignment"], None, "{range?, on?}", has_doc, |s, p| toggle(
107 s,
108 p,
109 |st| st.align.wrap,
110 |st, v| st.align.wrap = v
111 )),
112 cmd!("home.shrinkToFit", "Shrink to Fit", [], None, "{range?, on?}", has_doc, |s, p| toggle(
113 s,
114 p,
115 |st| st.align.shrink,
116 |st, v| st.align.shrink = v
117 )),
118 cmd!("home.increaseIndent", "Increase Indent", ["Home", "Alignment"], Some("Ctrl+Alt+Tab"), "{range?}", has_doc, |s, p| indent(s, p, 1)),
119 cmd!("home.decreaseIndent", "Decrease Indent", ["Home", "Alignment"], Some("Ctrl+Alt+Shift+Tab"), "{range?}", has_doc, |s, p| indent(
120 s, p, -1
121 )),
122 cmd!(
123 "home.orientation",
124 "Orientation",
125 ["Home", "Alignment"],
126 None,
127 "{range?, angle: -90..90 | \"vertical\" | \"counterclockwise\" | \"clockwise\" | \"up\" | \"down\" | 0}",
128 has_doc,
129 orientation
130 ),
131 cmd!("home.mergeCenter", "Merge & Center", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "center")),
132 cmd!("home.mergeAcross", "Merge Across", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "across")),
133 cmd!("home.mergeCells", "Merge Cells", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "cells")),
134 cmd!("home.unmergeCells", "Unmerge Cells", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "unmerge")),
135 cmd!(
137 "home.numberFormat",
138 "Number Format",
139 ["Home", "Number"],
140 None,
141 "{range?, format: \"General\"|\"Number\"|\"Currency\"|\"Accounting\"|\"Short Date\"|\"Long Date\"|\"Time\"|\"Percentage\"|\"Fraction\"|\"Scientific\"|\"Text\" | code: \"0.00\"}",
142 has_doc,
143 number_format
144 ),
145 cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, accounting),
146 cmd!("home.percent", "Percent Style", ["Home", "Number"], Some("Ctrl+Shift+%"), "{range?}", has_doc, |s, p| set_fmt(s, p, "0%")),
147 cmd!("home.comma", "Comma Style", ["Home", "Number"], None, "{range?}", has_doc, |s, p| set_fmt(s, p, COMMA)),
148 cmd!("home.increaseDecimal", "Increase Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, 1)),
149 cmd!("home.decreaseDecimal", "Decrease Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, -1)),
150 cmd!(
152 "home.cellStyle",
153 "Cell Styles",
154 ["Home", "Styles"],
155 None,
156 "{range?, name: \"Good\"|\"Bad\"|\"Neutral\"|\"Heading 1\"|\"Title\"|\"Total\"|\"Accent1\"…|\"Normal\"}",
157 has_doc,
158 cell_style
159 ),
160 cmd!(
162 "home.rowHeight",
163 "Row Height…",
164 ["Home", "Cells", "Format"],
165 None,
166 "{sheet?, rows?: \"2:5\" | \"2:2,4:5\" | \"Sheet2!2:5\", height: 20 (pixels) | points: 15}",
167 has_doc,
168 row_height
169 ),
170 cmd!(
171 "home.columnWidth",
172 "Column Width…",
173 ["Home", "Cells", "Format"],
174 None,
175 "{sheet?, cols?: \"B:D\" | \"B:B,D:F\" | \"Sheet2!B:D\", width: 64 (pixels) | chars: 8.43}",
176 has_doc,
177 col_width
178 ),
179 cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{sheet?, rows?}", has_doc, autofit_rows),
180 cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{sheet?, cols?}", has_doc, autofit_cols),
181 cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{sheet?, width: 64 (pixels)}", has_doc, default_width),
182 cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{sheet?, rows?}", has_doc, |s, p| hide(
183 s, p, true, true
184 )),
185 cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{sheet?, cols?}", has_doc, |s, p| {
186 hide(s, p, false, true)
187 }),
188 cmd!(
189 "home.unhideRows",
190 "Unhide Rows",
191 ["Home", "Cells", "Format", "Hide & Unhide"],
192 Some("Cmd+Shift+9"),
193 "{sheet?, rows?}",
194 has_doc,
195 |s, p| hide(s, p, true, false)
196 ),
197 cmd!(
198 "home.unhideColumns",
199 "Unhide Columns",
200 ["Home", "Cells", "Format", "Hide & Unhide"],
201 Some("Cmd+Shift+0"),
202 "{sheet?, cols?}",
203 has_doc,
204 |s, p| hide(s, p, false, false)
205 ),
206 cmd!("home.lockCell", "Lock Cell", ["Home", "Cells", "Format"], None, "{range?, on?}", has_doc, |s, p| toggle(
207 s,
208 p,
209 |st| st.protection.locked,
210 |st, v| st.protection.locked = v
211 )),
212 ]
213}
214
215const ACCOUNTING: &str = "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)";
216const COMMA: &str = "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)";
217
218fn accounting(s: &mut Session, p: &Json) -> Result<Json> {
221 let symbol = str_param(p, "symbol").unwrap_or("$");
222 if symbol.contains(['"', '\\']) || symbol.chars().any(char::is_control) {
225 return Err(bad("home.accounting", "the symbol can't contain a quote, a backslash or a control character"));
226 }
227 set_fmt(s, p, &accounting_format(symbol))
228}
229
230fn accounting_format(symbol: &str) -> String {
231 let symbol = format!("\"{symbol}\"");
232 format!("_({symbol}* #,##0.00_);_({symbol}* \\(#,##0.00\\);_({symbol}* \"-\"??_);_(@_)")
233}
234
235fn with(p: &Json, key: &str, v: Json) -> Json {
236 let mut m = p.as_object().cloned().unwrap_or_default();
237 m.insert(key.into(), v);
238 Json::Object(m)
239}
240
241pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result<Json> {
243 let sheet = target_sheet(s, p)?;
244 let ranges = target_ranges(s, p)?;
245 let guarded = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.is_some());
246 if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
247 return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
248 }
249 edit(s, |cx| {
250 const FLAG_GUARD: &str = "You can't change cell protection on a protected sheet. Unprotect it first.";
255 macro_rules! guard_flags {
256 ($old:expr, $new:expr) => {
257 if guarded && cx.wb.styles.get($old).protection != cx.wb.styles.get($new).protection {
258 return Err(EngineError::Other(FLAG_GUARD.into()));
259 }
260 };
261 }
262 let mut cache: std::collections::HashMap<StyleId, StyleId> = std::collections::HashMap::new();
263 for r in &ranges {
264 let full_rows = r.is_full_rows();
265 let full_cols = r.is_full_cols();
266 if full_rows || full_cols {
267 let lines: Vec<u32> = if full_rows && !full_cols { (r.start.row..=r.end.row).collect() } else { (r.start.col..=r.end.col).collect() };
269 for line in lines {
270 let cur = {
271 let sh = cx.wb.sheet(sheet);
272 let info = if full_rows && !full_cols { sh.and_then(|s| s.rows.get(&line)) } else { sh.and_then(|s| s.cols.get(&line)) };
273 info.and_then(|i| i.style).unwrap_or_default()
274 };
275 let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f));
276 guard_flags!(cur, new);
277 let sh = cx.sheet_mut(sheet)?;
278 let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols };
279 map.entry(line).or_default().style = Some(new);
280 }
281 let cells: Vec<(CellRef, StyleId)> =
283 cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
284 for (c, st) in &cells {
285 let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
286 guard_flags!(*st, new);
287 cx.sheet_mut(sheet)?.set_style(*c, new);
288 }
289 cx.fit_rows.extend(cells.iter().map(|(c, _)| (sheet, c.row)));
291 continue;
292 }
293 if r.count() > 4_000_000 {
294 return Err(bad("format", "selection too large to format cell by cell"));
295 }
296 if r.height() <= 100_000 {
297 cx.fit_rows.extend((r.start.row..=r.end.row).map(|row| (sheet, row)));
298 }
299 for c in r.iter() {
300 let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
301 let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
302 if new != st {
303 guard_flags!(st, new);
304 cx.sheet_mut(sheet)?.set_style(c, new);
305 }
306 }
307 }
308 Ok(Json::Null)
309 })
310}
311
312pub(crate) fn active_style(s: &Session) -> Result<Style> {
314 let d = s.doc()?;
315 let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
316 Ok(d.wb.styles.get(sh.style_id(d.selection.active)).clone())
317}
318
319fn toggle(s: &mut Session, p: &Json, get: fn(&Style) -> bool, set: fn(&mut Style, bool)) -> Result<Json> {
320 let on = match bool_param(p, "on") {
321 Some(v) => v,
322 None => !get(&active_style(s)?),
323 };
324 apply_style(s, p, |st| set(st, on))?;
325 Ok(json!({"on": on}))
326}
327
328fn underline(s: &mut Session, p: &Json) -> Result<Json> {
329 let style = match str_param(p, "style").unwrap_or("single") {
330 "double" => Underline::Double,
331 "singleAccounting" => Underline::SingleAccounting,
332 "doubleAccounting" => Underline::DoubleAccounting,
333 "none" => Underline::None,
334 _ => Underline::Single,
335 };
336 let cur = active_style(s)?.font.underline;
337 let on = bool_param(p, "on").unwrap_or(cur != style);
338 let u = if on { style } else { Underline::None };
339 apply_style(s, p, |st| st.font.underline = u)?;
340 Ok(json!({"on": on}))
341}
342
343fn font_name(s: &mut Session, p: &Json) -> Result<Json> {
344 let Some(name) = str_param(p, "name").map(str::to_string) else { return Err(bad("home.fontName", "missing `name`")) };
345 apply_style(s, p, |st| {
346 st.font.name = name.clone();
347 st.font.charset = None;
348 st.font.family = None;
349 })
350}
351
352fn font_size(s: &mut Session, p: &Json) -> Result<Json> {
353 let size = f64_param(p, "size").ok_or_else(|| bad("home.fontSize", "missing `size`"))?;
354 if !(1.0..=409.0).contains(&size) {
355 return Err(EngineError::Other("The number must be between 1 and 409.".into()));
356 }
357 let size = ((size * 2.0).round() / 2.0) as f32;
358 apply_style(s, p, |st| st.font.size = size)
359}
360
361const SIZES: [f32; 16] = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0, 96.0, 409.0];
362
363fn step_font(s: &mut Session, p: &Json, up: bool) -> Result<Json> {
364 let cur = active_style(s)?.font.size;
365 let next =
366 if up { SIZES.iter().copied().find(|x| *x > cur).unwrap_or(409.0) } else { SIZES.iter().rev().copied().find(|x| *x < cur).unwrap_or(1.0) };
367 apply_style(s, p, |st| st.font.size = next)?;
368 Ok(json!({"size": next}))
369}
370
371pub(crate) fn color_param(v: Option<&Json>) -> Option<Color> {
372 match v? {
373 Json::String(s) if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") || s.eq_ignore_ascii_case("none") => {
374 Some(Color::Auto)
375 }
376 Json::String(s) => Color::from_hex(s),
377 Json::Object(o) => {
378 let theme = o.get("theme")?.as_u64()? as u8;
379 let tint = o.get("tint").and_then(Json::as_f64).unwrap_or(0.0);
380 Some(Color::Theme(theme.min(11), (tint.clamp(-1.0, 1.0) * 1000.0) as i16))
381 }
382 _ => None,
383 }
384}
385
386fn font_color(s: &mut Session, p: &Json) -> Result<Json> {
387 let c = color_param(p.get("color")).ok_or_else(|| bad("home.fontColor", "missing or invalid `color`"))?;
388 apply_style(s, p, |st| st.font.color = c)
389}
390
391fn fill_color(s: &mut Session, p: &Json) -> Result<Json> {
392 let c = color_param(p.get("color")).ok_or_else(|| bad("home.fillColor", "missing or invalid `color`"))?;
393 apply_style(s, p, |st| st.fill = if c == Color::Auto { Fill::default() } else { Fill::solid(c) })
394}
395
396fn border_style(name: &str) -> BorderStyle {
397 match name {
398 "medium" => BorderStyle::Medium,
399 "thick" => BorderStyle::Thick,
400 "dashed" => BorderStyle::Dashed,
401 "dotted" => BorderStyle::Dotted,
402 "double" => BorderStyle::Double,
403 "hair" => BorderStyle::Hair,
404 "mediumDashed" => BorderStyle::MediumDashed,
405 "dashDot" => BorderStyle::DashDot,
406 "mediumDashDot" => BorderStyle::MediumDashDot,
407 "dashDotDot" => BorderStyle::DashDotDot,
408 "slantDashDot" => BorderStyle::SlantDashDot,
409 "none" => BorderStyle::None,
410 _ => BorderStyle::Thin,
411 }
412}
413
414fn borders(s: &mut Session, p: &Json) -> Result<Json> {
415 let preset = str_param(p, "preset").unwrap_or("bottom").to_string();
416 let color = color_param(p.get("color")).unwrap_or(Color::Auto);
417 let base = border_style(str_param(p, "style").unwrap_or("thin"));
418 let sheet = target_sheet(s, p)?;
419 let ranges = target_ranges(s, p)?;
420 if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
421 return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
422 }
423 edit(s, |cx| {
424 for r in &ranges {
425 if r.count() > 2_000_000 {
426 return Err(bad("home.borders", "selection too large"));
427 }
428 for c in r.iter() {
429 let (top, bottom, left, right) = (c.row == r.start.row, c.row == r.end.row, c.col == r.start.col, c.col == r.end.col);
430 let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
431 let line = |style: BorderStyle| BorderLine { style, color };
432 let new = cx.wb.styles.derive(st, |s| {
433 let b = &mut s.borders;
434 match preset.as_str() {
435 "none" => *b = Borders::default(),
436 "all" => {
437 b.top = line(base);
438 b.bottom = line(base);
439 b.left = line(base);
440 b.right = line(base);
441 }
442 "outside" | "thickOutside" => {
443 let st = if preset == "thickOutside" { BorderStyle::Thick } else { base };
444 if top {
445 b.top = line(st);
446 }
447 if bottom {
448 b.bottom = line(st);
449 }
450 if left {
451 b.left = line(st);
452 }
453 if right {
454 b.right = line(st);
455 }
456 }
457 "inside" | "insideHorizontal" | "insideVertical" => {
458 if preset != "insideVertical" {
459 if !top {
460 b.top = line(base);
461 }
462 if !bottom {
463 b.bottom = line(base);
464 }
465 }
466 if preset != "insideHorizontal" {
467 if !left {
468 b.left = line(base);
469 }
470 if !right {
471 b.right = line(base);
472 }
473 }
474 }
475 "top" if top => b.top = line(base),
476 "bottom" if bottom => b.bottom = line(base),
477 "left" if left => b.left = line(base),
478 "right" if right => b.right = line(base),
479 "thickBottom" if bottom => b.bottom = line(BorderStyle::Thick),
480 "doubleBottom" if bottom => b.bottom = line(BorderStyle::Double),
481 "topBottom" => {
482 if top {
483 b.top = line(base);
484 }
485 if bottom {
486 b.bottom = line(base);
487 }
488 }
489 "topThickBottom" => {
490 if top {
491 b.top = line(base);
492 }
493 if bottom {
494 b.bottom = line(BorderStyle::Thick);
495 }
496 }
497 "topDoubleBottom" => {
498 if top {
499 b.top = line(base);
500 }
501 if bottom {
502 b.bottom = line(BorderStyle::Double);
503 }
504 }
505 "diagonalDown" => b.diag_down = line(base),
506 "diagonalUp" => b.diag_up = line(base),
507 _ => {}
508 }
509 });
510 cx.sheet_mut(sheet)?.set_style(c, new);
511 if preset == "none" {
512 for (boundary, dr, dc) in [(top, -1, 0), (bottom, 1, 0), (left, 0, -1), (right, 0, 1)] {
515 if !boundary {
516 continue;
517 }
518 let Some(neighbor) = c.offset(dr, dc) else { continue };
519 let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(neighbor)).unwrap_or_default();
520 let new = cx.wb.styles.derive(st, |s| {
521 let edge = match (dr, dc) {
522 (-1, _) => &mut s.borders.bottom,
523 (1, _) => &mut s.borders.top,
524 (_, -1) => &mut s.borders.right,
525 _ => &mut s.borders.left,
526 };
527 *edge = BorderLine::default();
528 });
529 if new != st {
530 cx.sheet_mut(sheet)?.set_style(neighbor, new);
531 }
532 }
533 }
534 }
535 }
536 Ok(Json::Null)
537 })
538}
539
540fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
541 let Some(patch) = p.get("style").map(snake_case_keys) else {
542 s.ui_requests.push(crate::engine::UiRequest::Dialog("formatCells".into(), json!({})));
544 return ok();
545 };
546 let base = serde_json::to_value(active_style(s)?).map_err(|e| EngineError::Other(e.to_string()))?;
548 let merged = merge_json(base, &patch);
549 let _: Style = serde_json::from_value(merged).map_err(|e| bad("home.formatCells", e.to_string()))?;
550 apply_style(s, p, |st| {
551 if let Ok(cur) = serde_json::to_value(&*st)
552 && let Ok(new) = serde_json::from_value::<Style>(merge_json(cur, &patch))
553 {
554 *st = new;
555 }
556 })
557}
558
559fn snake_case_keys(v: &Json) -> Json {
562 match v {
563 Json::Object(m) => Json::Object(
564 m.iter()
565 .map(|(k, v)| {
566 let key = if k.starts_with(|c: char| c.is_ascii_lowercase()) {
567 k.chars().fold(String::new(), |mut out, c| {
568 if c.is_ascii_uppercase() {
569 out.push('_');
570 }
571 out.push(c.to_ascii_lowercase());
572 out
573 })
574 } else {
575 k.clone()
576 };
577 (key, snake_case_keys(v))
578 })
579 .collect(),
580 ),
581 other => other.clone(),
582 }
583}
584
585pub(crate) fn merge_json(mut base: Json, patch: &Json) -> Json {
587 match (&mut base, patch) {
588 (Json::Object(b), Json::Object(p)) => {
589 for (k, v) in p {
590 let cur = b.remove(k).unwrap_or(Json::Null);
591 b.insert(k.clone(), merge_json(cur, v));
592 }
593 base
594 }
595 (_, p) => p.clone(),
596 }
597}
598
599fn halign(s: &mut Session, p: &Json, a: HAlign) -> Result<Json> {
600 let cur = active_style(s)?.align.h;
602 let a = if cur == a && p.get("on").is_none() { HAlign::General } else { a };
603 apply_style(s, p, |st| st.align.h = a)
604}
605
606fn valign(s: &mut Session, p: &Json, a: VAlign) -> Result<Json> {
607 apply_style(s, p, |st| st.align.v = a)
608}
609
610fn indent(s: &mut Session, p: &Json, d: i32) -> Result<Json> {
611 apply_style(s, p, |st| {
612 st.align.indent = (st.align.indent as i32 + d).clamp(0, 250) as u8;
613 if st.align.indent > 0 && matches!(st.align.h, HAlign::General | HAlign::Center | HAlign::Fill | HAlign::Justify | HAlign::CenterAcross) {
614 st.align.h = HAlign::Left;
615 }
616 })
617}
618
619fn orientation(s: &mut Session, p: &Json) -> Result<Json> {
620 let angle: i16 = match p.get("angle") {
621 Some(Json::Number(n)) => n.as_f64().unwrap_or(0.0).clamp(-90.0, 90.0) as i16,
622 Some(Json::String(t)) => match t.as_str() {
623 "vertical" => 255,
624 "counterclockwise" => 45,
625 "clockwise" => -45,
626 "up" => 90,
627 "down" => -90,
628 _ => 0,
629 },
630 _ => 0,
631 };
632 apply_style(s, p, |st| st.align.rotation = angle)
633}
634
635fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
636 let sheet = target_sheet(s, p)?;
637 let ranges = target_ranges(s, p)?;
638 let how =
639 if how == "center" && s.doc()?.wb.sheet(sheet).is_some_and(|sh| ranges.iter().all(|r| sh.merges.contains(r))) { "unmerge" } else { how };
640 edit(s, |cx| {
641 let mut touched = Vec::new();
642 let mut areas: Vec<RangeRef> = ranges.clone();
644 for r in &ranges {
645 let sh = cx.sheet_mut(sheet)?;
646 areas.extend(sh.merges.iter().filter(|m| m.intersects(r)));
647 sh.merges.retain(|m| !m.intersects(r));
648 match how {
649 "unmerge" => {}
650 "across" => {
651 for row in r.start.row..=r.end.row {
652 let m = RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col));
653 if !m.is_single() {
654 sh.merges.push(m);
655 let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
657 for c in others {
658 if let Some(mut cell) = sh.cells.get(c).cloned() {
659 cell.clear_content();
660 sh.set_cell(c, cell);
661 }
662 touched.push((sheet, c));
663 }
664 }
665 }
666 }
667 _ => {
668 if !r.is_single() {
669 sh.merges.push(*r);
670 }
671 if r.count() <= 1_000_000 {
673 let others: Vec<CellRef> = sh.cells.iter_range(*r).map(|(c, _)| c).filter(|c| *c != r.start).collect();
674 for c in others {
675 if let Some(mut cell) = sh.cells.get(c).cloned() {
676 cell.clear_content();
677 sh.set_cell(c, cell);
678 }
679 touched.push((sheet, c));
680 }
681 }
682 }
683 }
684 }
685 let sh = cx.sheet_mut(sheet)?;
687 for a in &areas {
688 touched.extend(sh.spill_ranges.iter().filter(|(_, r)| r.intersects(a)).map(|(anchor, _)| (sheet, *anchor)));
689 let reach = RangeRef::new(CellRef::new(0, 0), a.end);
690 let blocked = sh
691 .cells
692 .iter_range(reach)
693 .filter(|(_, x)| (x.formula.is_some() || x.py.is_some()) && x.value == crate::base::Value::Error(crate::base::CellError::Spill));
695 touched.extend(blocked.map(|(c, _)| (sheet, c)));
696 }
697 cx.changed.extend(touched);
698 Ok(())
699 })?;
700 if how == "center" {
701 apply_style(s, p, |st| st.align.h = HAlign::Center)?;
702 }
703 ok()
704}
705
706pub fn format_code_for(name: &str) -> &str {
708 match name {
709 "General" => "General",
710 "Number" => "0.00",
711 "Currency" => "\"$\"#,##0.00",
712 "Accounting" => ACCOUNTING,
713 "Short Date" => "m/d/yyyy",
714 "Long Date" => "[$-F800]dddd, mmmm dd, yyyy",
715 "Time" => "[$-F400]h:mm:ss AM/PM",
716 "Percentage" => "0.00%",
717 "Fraction" => "# ?/?",
718 "Scientific" => "0.00E+00",
719 "Text" => "@",
720 "Comma" => COMMA,
721 other => other,
722 }
723}
724
725fn number_format(s: &mut Session, p: &Json) -> Result<Json> {
726 let code = str_param(p, "code")
727 .map(str::to_string)
728 .or_else(|| str_param(p, "format").map(|f| format_code_for(f).to_string()))
729 .ok_or_else(|| bad("home.numberFormat", "missing `format` or `code`"))?;
730 set_fmt(s, p, &code)
731}
732
733fn set_fmt(s: &mut Session, p: &Json, code: &str) -> Result<Json> {
734 let nf = NumFmt::new(code);
735 apply_style(s, p, |st| st.num_fmt = nf.clone())
736}
737
738pub fn adjust_decimals(code: &str, value: &crate::base::Value, delta: i32) -> String {
740 if code == "General" {
741 let text = value.as_f64().map(crate::base::number_to_text).unwrap_or_default();
743 let dec = text.split_once('.').map_or(0, |(_, d)| d.len()) as i32;
744 let n = (dec + delta).max(0) as usize;
745 return if n == 0 { "0".into() } else { format!("0.{}", "0".repeat(n)) };
746 }
747 let sections: Vec<String> = code
748 .split(';')
749 .map(|sec| {
750 let chars: Vec<char> = sec.chars().collect();
752 let mut in_quote = false;
753 let mut in_bracket = false;
754 let mut last_digit = None;
755 let mut point = None;
756 for (i, c) in chars.iter().enumerate() {
757 if *c == '"' {
758 in_quote = !in_quote;
759 }
760 if *c == '[' && !in_quote {
761 in_bracket = true;
762 }
763 if *c == ']' && !in_quote {
764 in_bracket = false;
765 continue;
766 }
767 if in_quote || in_bracket {
768 continue;
769 }
770 if matches!(c, '0' | '#' | '?') {
771 last_digit = Some(i);
772 }
773 if *c == '.' && point.is_none() {
774 point = Some(i);
775 }
776 if matches!(c, 'E' | 'e') {
777 break;
778 }
779 }
780 let Some(ld) = last_digit else { return sec.to_string() };
781 let mut out: Vec<char> = chars.clone();
782 if delta > 0 {
783 match point {
784 Some(_) => out.insert(ld + 1, '0'),
785 None => {
786 out.insert(ld + 1, '0');
787 out.insert(ld + 1, '.');
788 }
789 }
790 } else if let Some(pt) = point
791 && ld > pt
792 {
793 out.remove(ld);
794 if ld == pt + 1 {
795 out.remove(pt);
796 }
797 }
798 out.into_iter().collect()
799 })
800 .collect();
801 sections.join(";")
802}
803
804fn decimals(s: &mut Session, p: &Json, delta: i32) -> Result<Json> {
805 let d = s.doc()?;
806 let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
807 let a = d.selection.active;
808 let code = d.wb.styles.get(sh.style_id(a)).num_fmt.as_str().to_string();
809 let new = adjust_decimals(&code, &sh.value(a), delta);
810 set_fmt(s, p, &new)
811}
812
813pub use crate::model::points_to_chars;
814
815mod cell_styles;
816mod sizes;
817
818use cell_styles::cell_style;
819pub use cell_styles::{CELL_STYLE_NAMES, builtin_cell_style};
820pub use sizes::{approx_text_width, chars_to_points};
821use sizes::{autofit_cols, autofit_rows, col_width, default_width, hide, row_height};
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826
827 #[test]
828 fn merge_across_keeps_the_first_value_of_each_row() {
829 let mut s = crate::engine::Session::new();
830 s.new_workbook();
831 s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
832 s.execute("cell.set", json!({"cell": "D1", "input": "=SUM(A1:B2)"})).unwrap();
833 s.execute("home.mergeAcross", json!({"range": "A1:B2"})).unwrap();
834 let v = |s: &mut crate::engine::Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
835 assert_eq!(v(&mut s, "B1"), Json::Null);
836 assert_eq!(v(&mut s, "B2"), Json::Null);
837 assert_eq!(v(&mut s, "D1"), json!(4.0));
838 }
839
840 #[test]
841 fn the_default_symbol_reproduces_the_accounting_format() {
842 assert_eq!(accounting_format("$"), ACCOUNTING);
843 }
844
845 #[test]
846 fn decimal_steps() {
847 let v = crate::base::Value::Number(1.5);
848 assert_eq!(adjust_decimals("General", &v, 1), "0.00");
849 assert_eq!(adjust_decimals("General", &v, -1), "0");
850 assert_eq!(adjust_decimals("0", &v, 1), "0.0");
851 assert_eq!(adjust_decimals("0.00", &v, -1), "0.0");
852 assert_eq!(adjust_decimals("0.0", &v, -1), "0");
853 assert_eq!(adjust_decimals("#,##0.00;[Red]-#,##0.00", &v, 1), "#,##0.000;[Red]-#,##0.000");
854 assert_eq!(adjust_decimals("0%", &v, 1), "0.0%");
855 }
856
857 #[test]
858 fn format_cells_takes_the_documented_keys() {
859 let mut s = crate::engine::Session::new();
860 s.new_workbook();
861 s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
862 s.execute("home.formatCells", json!({"range": "A1", "style": {"numFmt": "0.0", "borders": {"diagUp": {"style": "Thin"}}}})).unwrap();
863 let st = &s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"];
864 assert_eq!(st["num_fmt"], "0.0");
865 assert_eq!(st["borders"]["diag_up"]["style"], "Thin");
866 s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "0.00"}})).unwrap();
868 assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00");
869 }
870
871 #[test]
872 fn width_units() {
873 assert_eq!(chars_to_points(8.43), 64.0);
874 assert!((points_to_chars(64.0) - 8.43).abs() < 0.01);
875 }
876}