Skip to main content

ravenrs_sheet/engine/cmd/
format.rs

1//! Formatting: Home › Font, Alignment, Number, Styles, Cells › Format.
2
3use 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        // Font
15        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        // Alignment
93        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        // Number
136        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        // Styles
151        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        // Cells › Format
161        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
218/// Accounting Number Format with the currency symbol to use (Excel's own button takes the system
219/// currency; `symbol` is how the control channel picks another one, e.g. `"L"` for lempiras).
220fn accounting(s: &mut Session, p: &Json) -> Result<Json> {
221    let symbol = str_param(p, "symbol").unwrap_or("$");
222    // The symbol goes into a quoted literal: a quote or a backslash would end it, and a control
223    // character would reach the format's text as-is.
224    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
241/// Applies `f` to the style of every targeted cell (whole rows/columns set the line format too).
242pub(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        // Formatting on a protected sheet keeps the cell's permission flags: a format call
251        // that flips `locked` from the format permission would authorize content edits that
252        // the password should still gate, so those flags may not change (Excel keeps that
253        // checkbox disabled while the sheet is protected).
254        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                // Line formats.
268                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                // Existing cells in those lines.
282                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                // Formatting touched these cells: re-fit their rows (Excel auto-fits).
290                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
312/// The style of the active cell (what toggles read).
313pub(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                    // A shared line can be stored on either cell. Selected cells are handled
513                    // by this loop; remove only facing edges outside this range.
514                    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        // Menu invocation without params: the UI opens the dialog.
543        s.ui_requests.push(crate::engine::UiRequest::Dialog("formatCells".into(), json!({})));
544        return ok();
545    };
546    // Validate by merging into the active style once.
547    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
559/// The style patch with camelCase keys (`numFmt`, `diagDown`, as documented) renamed to the
560/// style's own snake_case (`num_fmt`, `diag_down`). Keys starting with a capital (`Rgb`) stay.
561fn 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
585/// Deep-merges `patch` into `base` (objects merge, everything else replaces).
586pub(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    // Clicking the active alignment again returns to General.
601    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        // Areas whose merging changes: a merged cell blocks a dynamic array's spill.
643        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                            // As below: only the first value of each row survives.
656                            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                    // Only the upper-left value survives (Excel warns about this).
672                    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        // Spills over the areas, and blocked ones that could reach them, are evaluated again.
686        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                // A Python cell's result is blocked and unblocked like a formula's.
694                .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
706/// Ribbon format names → codes.
707pub 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
738/// Adds or removes one decimal place in the number format (Increase/Decrease Decimal).
739pub fn adjust_decimals(code: &str, value: &crate::base::Value, delta: i32) -> String {
740    if code == "General" {
741        // Start from the displayed number of decimals.
742        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            // Find the last digit placeholder run before any exponent / percent etc.
751            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        // The style's own snake_case keys keep working.
867        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}