Files
gridcraft/crates/engine/src/cmd/format.rs
T
aiongg c7fd66d807 Integration: track t12
# Conflicts:
#	ROADMAP.md
#	crates/calc/src/tests.rs
#	crates/chart/src/tests.rs
#	crates/engine/src/tests.rs
#	crates/xlsx/src/tests/mod.rs
#	docs/chart-parity.md
2026-10-22 03:05:03 +00:00

1259 lines
52 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.
//! Formatting: Home › Font, Alignment, Number, Styles, Cells › Format.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
// Font
cmd!("home.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{range?, on?: bool}", has_doc, |s, p| toggle(
s,
p,
|st| st.font.bold,
|st, v| st.font.bold = v
)),
cmd!("home.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{range?, on?}", has_doc, |s, p| toggle(
s,
p,
|st| st.font.italic,
|st, v| st.font.italic = v
)),
cmd!(
"home.underline",
"Underline",
["Home", "Font"],
Some("Cmd+U"),
"{range?, on?, style?: single|double|singleAccounting|doubleAccounting}",
has_doc,
underline
),
cmd!("home.doubleUnderline", "Double Underline", ["Home", "Font"], None, "{range?}", has_doc, |s, p| underline(
s,
&with(p, "style", json!("double"))
)),
cmd!("home.strikethrough", "Strikethrough", ["Home", "Font"], Some("Cmd+Shift+X"), "{range?, on?}", has_doc, |s, p| toggle(
s,
p,
|st| st.font.strike,
|st, v| st.font.strike = v
)),
cmd!("home.superscript", "Superscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle(
s,
p,
|st| st.font.vert == VertAlign::Superscript,
|st, v| st.font.vert = if v { VertAlign::Superscript } else { VertAlign::Baseline }
)),
cmd!("home.subscript", "Subscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle(
s,
p,
|st| st.font.vert == VertAlign::Subscript,
|st, v| st.font.vert = if v { VertAlign::Subscript } else { VertAlign::Baseline }
)),
cmd!("home.fontName", "Font", ["Home", "Font"], None, "{range?, name: \"Calibri\"}", has_doc, font_name),
cmd!("home.fontSize", "Font Size", ["Home", "Font"], None, "{range?, size: 11}", has_doc, font_size),
cmd!("home.increaseFontSize", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+>"), "{range?}", has_doc, |s, p| step_font(s, p, true)),
cmd!("home.decreaseFontSize", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+<"), "{range?}", has_doc, |s, p| step_font(
s, p, false
)),
cmd!(
"home.fontColor",
"Font Color",
["Home", "Font"],
None,
"{range?, color: \"#C00000\" | \"auto\" | {theme: 4, tint: -0.25}}",
has_doc,
font_color
),
cmd!("home.fillColor", "Fill Color", ["Home", "Font"], None, "{range?, color: \"#FFFF00\" | \"none\" | {theme, tint}}", has_doc, fill_color),
cmd!(
"home.borders",
"Borders",
["Home", "Font"],
None,
"{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?}",
has_doc,
borders
),
cmd!(
"home.formatCells",
"Format Cells…",
["Home", "Cells", "Format"],
Some("Cmd+1"),
"{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)",
has_doc,
format_cells
),
// Alignment
cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)),
cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)),
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)),
cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)),
cmd!("home.alignMiddle", "Middle Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Center)),
cmd!("home.alignBottom", "Bottom Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Bottom)),
cmd!("home.wrapText", "Wrap Text", ["Home", "Alignment"], None, "{range?, on?}", has_doc, |s, p| toggle(
s,
p,
|st| st.align.wrap,
|st, v| st.align.wrap = v
)),
cmd!("home.shrinkToFit", "Shrink to Fit", [], None, "{range?, on?}", has_doc, |s, p| toggle(
s,
p,
|st| st.align.shrink,
|st, v| st.align.shrink = v
)),
cmd!("home.increaseIndent", "Increase Indent", ["Home", "Alignment"], Some("Ctrl+Alt+Tab"), "{range?}", has_doc, |s, p| indent(s, p, 1)),
cmd!("home.decreaseIndent", "Decrease Indent", ["Home", "Alignment"], Some("Ctrl+Alt+Shift+Tab"), "{range?}", has_doc, |s, p| indent(
s, p, -1
)),
cmd!(
"home.orientation",
"Orientation",
["Home", "Alignment"],
None,
"{range?, angle: -90..90 | \"vertical\" | \"counterclockwise\" | \"clockwise\" | \"up\" | \"down\" | 0}",
has_doc,
orientation
),
cmd!("home.mergeCenter", "Merge & Center", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "center")),
cmd!("home.mergeAcross", "Merge Across", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "across")),
cmd!("home.mergeCells", "Merge Cells", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "cells")),
cmd!("home.unmergeCells", "Unmerge Cells", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "unmerge")),
// Number
cmd!(
"home.numberFormat",
"Number Format",
["Home", "Number"],
None,
"{range?, format: \"General\"|\"Number\"|\"Currency\"|\"Accounting\"|\"Short Date\"|\"Long Date\"|\"Time\"|\"Percentage\"|\"Fraction\"|\"Scientific\"|\"Text\" | code: \"0.00\"}",
has_doc,
number_format
),
cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, |s, p| set_fmt(
s, p, ACCOUNTING
)),
cmd!("home.percent", "Percent Style", ["Home", "Number"], Some("Ctrl+Shift+%"), "{range?}", has_doc, |s, p| set_fmt(s, p, "0%")),
cmd!("home.comma", "Comma Style", ["Home", "Number"], None, "{range?}", has_doc, |s, p| set_fmt(s, p, COMMA)),
cmd!("home.increaseDecimal", "Increase Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, 1)),
cmd!("home.decreaseDecimal", "Decrease Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, -1)),
// Styles
cmd!(
"home.cellStyle",
"Cell Styles",
["Home", "Styles"],
None,
"{range?, name: \"Good\"|\"Bad\"|\"Neutral\"|\"Heading 1\"|\"Title\"|\"Total\"|\"Accent1\"…|\"Normal\"}",
has_doc,
cell_style
),
// Cells › Format
cmd!(
"home.rowHeight",
"Row Height…",
["Home", "Cells", "Format"],
None,
"{sheet?, rows?: \"2:5\" | \"2:2,4:5\" | \"Sheet2!2:5\", height: 20 (pixels) | points: 15}",
has_doc,
row_height
),
cmd!(
"home.columnWidth",
"Column Width…",
["Home", "Cells", "Format"],
None,
"{sheet?, cols?: \"B:D\" | \"B:B,D:F\" | \"Sheet2!B:D\", width: 64 (pixels) | chars: 8.43}",
has_doc,
col_width
),
cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{sheet?, rows?}", has_doc, autofit_rows),
cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{sheet?, cols?}", has_doc, autofit_cols),
cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{sheet?, width: 64 (pixels)}", has_doc, default_width),
cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{sheet?, rows?}", has_doc, |s, p| hide(
s, p, true, true
)),
cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{sheet?, cols?}", has_doc, |s, p| {
hide(s, p, false, true)
}),
cmd!(
"home.unhideRows",
"Unhide Rows",
["Home", "Cells", "Format", "Hide & Unhide"],
Some("Cmd+Shift+9"),
"{sheet?, rows?}",
has_doc,
|s, p| hide(s, p, true, false)
),
cmd!(
"home.unhideColumns",
"Unhide Columns",
["Home", "Cells", "Format", "Hide & Unhide"],
Some("Cmd+Shift+0"),
"{sheet?, cols?}",
has_doc,
|s, p| hide(s, p, false, false)
),
cmd!("home.lockCell", "Lock Cell", ["Home", "Cells", "Format"], None, "{range?, on?}", has_doc, |s, p| toggle(
s,
p,
|st| st.protection.locked,
|st, v| st.protection.locked = v
)),
]
}
const ACCOUNTING: &str = "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)";
const COMMA: &str = "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)";
fn with(p: &Json, key: &str, v: Json) -> Json {
let mut m = p.as_object().cloned().unwrap_or_default();
m.insert(key.into(), v);
Json::Object(m)
}
/// Applies `f` to the style of every targeted cell (whole rows/columns set the line format too).
pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
}
edit(s, |cx| {
let mut cache: std::collections::HashMap<StyleId, StyleId> = std::collections::HashMap::new();
for r in &ranges {
let full_rows = r.is_full_rows();
let full_cols = r.is_full_cols();
if full_rows || full_cols {
// Line formats.
let lines: Vec<u32> = if full_rows && !full_cols { (r.start.row..=r.end.row).collect() } else { (r.start.col..=r.end.col).collect() };
for line in lines {
let cur = {
let sh = cx.wb.sheet(sheet);
let info = if full_rows && !full_cols { sh.and_then(|s| s.rows.get(&line)) } else { sh.and_then(|s| s.cols.get(&line)) };
info.and_then(|i| i.style).unwrap_or_default()
};
let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f));
let sh = cx.sheet_mut(sheet)?;
let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols };
map.entry(line).or_default().style = Some(new);
}
// Existing cells in those lines.
let cells: Vec<(CellRef, StyleId)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
for (c, st) in &cells {
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
cx.sheet_mut(sheet)?.set_style(*c, new);
}
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
cx.fit_rows.extend(cells.iter().map(|(c, _)| (sheet, c.row)));
continue;
}
if r.count() > 4_000_000 {
return Err(bad("format", "selection too large to format cell by cell"));
}
if r.height() <= 100_000 {
cx.fit_rows.extend((r.start.row..=r.end.row).map(|row| (sheet, row)));
}
for c in r.iter() {
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
if new != st {
cx.sheet_mut(sheet)?.set_style(c, new);
}
}
}
Ok(Json::Null)
})
}
/// The style of the active cell (what toggles read).
pub(crate) fn active_style(s: &Session) -> Result<Style> {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
Ok(d.wb.styles.get(sh.style_id(d.selection.active)).clone())
}
fn toggle(s: &mut Session, p: &Json, get: fn(&Style) -> bool, set: fn(&mut Style, bool)) -> Result<Json> {
let on = match bool_param(p, "on") {
Some(v) => v,
None => !get(&active_style(s)?),
};
apply_style(s, p, |st| set(st, on))?;
Ok(json!({"on": on}))
}
fn underline(s: &mut Session, p: &Json) -> Result<Json> {
let style = match str_param(p, "style").unwrap_or("single") {
"double" => Underline::Double,
"singleAccounting" => Underline::SingleAccounting,
"doubleAccounting" => Underline::DoubleAccounting,
"none" => Underline::None,
_ => Underline::Single,
};
let cur = active_style(s)?.font.underline;
let on = bool_param(p, "on").unwrap_or(cur != style);
let u = if on { style } else { Underline::None };
apply_style(s, p, |st| st.font.underline = u)?;
Ok(json!({"on": on}))
}
fn font_name(s: &mut Session, p: &Json) -> Result<Json> {
let Some(name) = str_param(p, "name").map(str::to_string) else { return Err(bad("home.fontName", "missing `name`")) };
apply_style(s, p, |st| st.font.name = name.clone())
}
fn font_size(s: &mut Session, p: &Json) -> Result<Json> {
let size = f64_param(p, "size").ok_or_else(|| bad("home.fontSize", "missing `size`"))?;
if !(1.0..=409.0).contains(&size) {
return Err(EngineError::Other("The number must be between 1 and 409.".into()));
}
let size = ((size * 2.0).round() / 2.0) as f32;
apply_style(s, p, |st| st.font.size = size)
}
const 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];
fn step_font(s: &mut Session, p: &Json, up: bool) -> Result<Json> {
let cur = active_style(s)?.font.size;
let next =
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) };
apply_style(s, p, |st| st.font.size = next)?;
Ok(json!({"size": next}))
}
pub(crate) fn color_param(v: Option<&Json>) -> Option<Color> {
match v? {
Json::String(s) if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") || s.eq_ignore_ascii_case("none") => {
Some(Color::Auto)
}
Json::String(s) => Color::from_hex(s),
Json::Object(o) => {
let theme = o.get("theme")?.as_u64()? as u8;
let tint = o.get("tint").and_then(Json::as_f64).unwrap_or(0.0);
Some(Color::Theme(theme.min(11), (tint.clamp(-1.0, 1.0) * 1000.0) as i16))
}
_ => None,
}
}
fn font_color(s: &mut Session, p: &Json) -> Result<Json> {
let c = color_param(p.get("color")).ok_or_else(|| bad("home.fontColor", "missing or invalid `color`"))?;
apply_style(s, p, |st| st.font.color = c)
}
fn fill_color(s: &mut Session, p: &Json) -> Result<Json> {
let c = color_param(p.get("color")).ok_or_else(|| bad("home.fillColor", "missing or invalid `color`"))?;
apply_style(s, p, |st| st.fill = if c == Color::Auto { Fill::default() } else { Fill::solid(c) })
}
fn border_style(name: &str) -> BorderStyle {
match name {
"medium" => BorderStyle::Medium,
"thick" => BorderStyle::Thick,
"dashed" => BorderStyle::Dashed,
"dotted" => BorderStyle::Dotted,
"double" => BorderStyle::Double,
"hair" => BorderStyle::Hair,
"mediumDashed" => BorderStyle::MediumDashed,
"dashDot" => BorderStyle::DashDot,
"mediumDashDot" => BorderStyle::MediumDashDot,
"dashDotDot" => BorderStyle::DashDotDot,
"slantDashDot" => BorderStyle::SlantDashDot,
"none" => BorderStyle::None,
_ => BorderStyle::Thin,
}
}
fn borders(s: &mut Session, p: &Json) -> Result<Json> {
let preset = str_param(p, "preset").unwrap_or("bottom").to_string();
let color = color_param(p.get("color")).unwrap_or(Color::Auto);
let base = border_style(str_param(p, "style").unwrap_or("thin"));
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
}
edit(s, |cx| {
for r in &ranges {
if r.count() > 2_000_000 {
return Err(bad("home.borders", "selection too large"));
}
for c in r.iter() {
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);
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
let line = |style: BorderStyle| BorderLine { style, color };
let new = cx.wb.styles.derive(st, |s| {
let b = &mut s.borders;
match preset.as_str() {
"none" => *b = Borders::default(),
"all" => {
b.top = line(base);
b.bottom = line(base);
b.left = line(base);
b.right = line(base);
}
"outside" | "thickOutside" => {
let st = if preset == "thickOutside" { BorderStyle::Thick } else { base };
if top {
b.top = line(st);
}
if bottom {
b.bottom = line(st);
}
if left {
b.left = line(st);
}
if right {
b.right = line(st);
}
}
"inside" | "insideHorizontal" | "insideVertical" => {
if preset != "insideVertical" {
if !top {
b.top = line(base);
}
if !bottom {
b.bottom = line(base);
}
}
if preset != "insideHorizontal" {
if !left {
b.left = line(base);
}
if !right {
b.right = line(base);
}
}
}
"top" if top => b.top = line(base),
"bottom" if bottom => b.bottom = line(base),
"left" if left => b.left = line(base),
"right" if right => b.right = line(base),
"thickBottom" if bottom => b.bottom = line(BorderStyle::Thick),
"doubleBottom" if bottom => b.bottom = line(BorderStyle::Double),
"topBottom" => {
if top {
b.top = line(base);
}
if bottom {
b.bottom = line(base);
}
}
"topThickBottom" => {
if top {
b.top = line(base);
}
if bottom {
b.bottom = line(BorderStyle::Thick);
}
}
"topDoubleBottom" => {
if top {
b.top = line(base);
}
if bottom {
b.bottom = line(BorderStyle::Double);
}
}
"diagonalDown" => b.diag_down = line(base),
"diagonalUp" => b.diag_up = line(base),
_ => {}
}
});
cx.sheet_mut(sheet)?.set_style(c, new);
if preset == "none" {
// A shared line can be stored on either cell. Selected cells are handled
// by this loop; remove only facing edges outside this range.
for (boundary, dr, dc) in [(top, -1, 0), (bottom, 1, 0), (left, 0, -1), (right, 0, 1)] {
if !boundary {
continue;
}
let Some(neighbor) = c.offset(dr, dc) else { continue };
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(neighbor)).unwrap_or_default();
let new = cx.wb.styles.derive(st, |s| {
let edge = match (dr, dc) {
(-1, _) => &mut s.borders.bottom,
(1, _) => &mut s.borders.top,
(_, -1) => &mut s.borders.right,
_ => &mut s.borders.left,
};
*edge = BorderLine::default();
});
if new != st {
cx.sheet_mut(sheet)?.set_style(neighbor, new);
}
}
}
}
}
Ok(Json::Null)
})
}
fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
let Some(patch) = p.get("style").map(snake_case_keys) else {
// Menu invocation without params: the UI opens the dialog.
s.ui_requests.push(crate::UiRequest::Dialog("formatCells".into(), json!({})));
return ok();
};
// Validate by merging into the active style once.
let base = serde_json::to_value(active_style(s)?).map_err(|e| EngineError::Other(e.to_string()))?;
let merged = merge_json(base, &patch);
let _: Style = serde_json::from_value(merged).map_err(|e| bad("home.formatCells", e.to_string()))?;
apply_style(s, p, |st| {
if let Ok(cur) = serde_json::to_value(&*st)
&& let Ok(new) = serde_json::from_value::<Style>(merge_json(cur, &patch))
{
*st = new;
}
})
}
/// The style patch with camelCase keys (`numFmt`, `diagDown`, as documented) renamed to the
/// style's own snake_case (`num_fmt`, `diag_down`). Keys starting with a capital (`Rgb`) stay.
fn snake_case_keys(v: &Json) -> Json {
match v {
Json::Object(m) => Json::Object(
m.iter()
.map(|(k, v)| {
let key = if k.starts_with(|c: char| c.is_ascii_lowercase()) {
k.chars().fold(String::new(), |mut out, c| {
if c.is_ascii_uppercase() {
out.push('_');
}
out.push(c.to_ascii_lowercase());
out
})
} else {
k.clone()
};
(key, snake_case_keys(v))
})
.collect(),
),
other => other.clone(),
}
}
/// Deep-merges `patch` into `base` (objects merge, everything else replaces).
pub(crate) fn merge_json(mut base: Json, patch: &Json) -> Json {
match (&mut base, patch) {
(Json::Object(b), Json::Object(p)) => {
for (k, v) in p {
let cur = b.remove(k).unwrap_or(Json::Null);
b.insert(k.clone(), merge_json(cur, v));
}
base
}
(_, p) => p.clone(),
}
}
fn halign(s: &mut Session, p: &Json, a: HAlign) -> Result<Json> {
// Clicking the active alignment again returns to General.
let cur = active_style(s)?.align.h;
let a = if cur == a && p.get("on").is_none() { HAlign::General } else { a };
apply_style(s, p, |st| st.align.h = a)
}
fn valign(s: &mut Session, p: &Json, a: VAlign) -> Result<Json> {
apply_style(s, p, |st| st.align.v = a)
}
fn indent(s: &mut Session, p: &Json, d: i32) -> Result<Json> {
apply_style(s, p, |st| {
st.align.indent = (st.align.indent as i32 + d).clamp(0, 250) as u8;
if st.align.indent > 0 && matches!(st.align.h, HAlign::General | HAlign::Center | HAlign::Fill | HAlign::Justify | HAlign::CenterAcross) {
st.align.h = HAlign::Left;
}
})
}
fn orientation(s: &mut Session, p: &Json) -> Result<Json> {
let angle: i16 = match p.get("angle") {
Some(Json::Number(n)) => n.as_f64().unwrap_or(0.0).clamp(-90.0, 90.0) as i16,
Some(Json::String(t)) => match t.as_str() {
"vertical" => 255,
"counterclockwise" => 45,
"clockwise" => -45,
"up" => 90,
"down" => -90,
_ => 0,
},
_ => 0,
};
apply_style(s, p, |st| st.align.rotation = angle)
}
fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
let how =
if how == "center" && s.doc()?.wb.sheet(sheet).is_some_and(|sh| ranges.iter().all(|r| sh.merges.contains(r))) { "unmerge" } else { how };
edit(s, |cx| {
let mut touched = Vec::new();
// Areas whose merging changes: a merged cell blocks a dynamic array's spill.
let mut areas: Vec<RangeRef> = ranges.clone();
for r in &ranges {
let sh = cx.sheet_mut(sheet)?;
areas.extend(sh.merges.iter().filter(|m| m.intersects(r)));
sh.merges.retain(|m| !m.intersects(r));
match how {
"unmerge" => {}
"across" => {
for row in r.start.row..=r.end.row {
let m = RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col));
if !m.is_single() {
sh.merges.push(m);
// As below: only the first value of each row survives.
let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
for c in others {
if let Some(mut cell) = sh.cells.get(c).cloned() {
cell.value = gridcraft_core::Value::Empty;
cell.formula = None;
sh.set_cell(c, cell);
}
touched.push((sheet, c));
}
}
}
}
_ => {
if !r.is_single() {
sh.merges.push(*r);
}
// Only the upper-left value survives (Excel warns about this).
if r.count() <= 1_000_000 {
let others: Vec<CellRef> = sh.cells.iter_range(*r).map(|(c, _)| c).filter(|c| *c != r.start).collect();
for c in others {
if let Some(mut cell) = sh.cells.get(c).cloned() {
cell.value = gridcraft_core::Value::Empty;
cell.formula = None;
sh.set_cell(c, cell);
}
touched.push((sheet, c));
}
}
}
}
}
// Spills over the areas, and blocked ones that could reach them, are evaluated again.
let sh = cx.sheet_mut(sheet)?;
for a in &areas {
touched.extend(sh.spill_ranges.iter().filter(|(_, r)| r.intersects(a)).map(|(anchor, _)| (sheet, *anchor)));
let reach = RangeRef::new(CellRef::new(0, 0), a.end);
let blocked = sh
.cells
.iter_range(reach)
.filter(|(_, x)| x.formula.is_some() && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill));
touched.extend(blocked.map(|(c, _)| (sheet, c)));
}
cx.changed.extend(touched);
Ok(())
})?;
if how == "center" {
apply_style(s, p, |st| st.align.h = HAlign::Center)?;
}
ok()
}
/// Ribbon format names → codes.
pub fn format_code_for(name: &str) -> &str {
match name {
"General" => "General",
"Number" => "0.00",
"Currency" => "\"$\"#,##0.00",
"Accounting" => ACCOUNTING,
"Short Date" => "m/d/yyyy",
"Long Date" => "[$-F800]dddd, mmmm dd, yyyy",
"Time" => "[$-F400]h:mm:ss AM/PM",
"Percentage" => "0.00%",
"Fraction" => "# ?/?",
"Scientific" => "0.00E+00",
"Text" => "@",
"Comma" => COMMA,
other => other,
}
}
fn number_format(s: &mut Session, p: &Json) -> Result<Json> {
let code = str_param(p, "code")
.map(str::to_string)
.or_else(|| str_param(p, "format").map(|f| format_code_for(f).to_string()))
.ok_or_else(|| bad("home.numberFormat", "missing `format` or `code`"))?;
set_fmt(s, p, &code)
}
fn set_fmt(s: &mut Session, p: &Json, code: &str) -> Result<Json> {
let nf = NumFmt::new(code);
apply_style(s, p, |st| st.num_fmt = nf.clone())
}
/// Adds or removes one decimal place in the number format (Increase/Decrease Decimal).
pub fn adjust_decimals(code: &str, value: &gridcraft_core::Value, delta: i32) -> String {
if code == "General" {
// Start from the displayed number of decimals.
let text = value.as_f64().map(gridcraft_core::number_to_text).unwrap_or_default();
let dec = text.split_once('.').map_or(0, |(_, d)| d.len()) as i32;
let n = (dec + delta).max(0) as usize;
return if n == 0 { "0".into() } else { format!("0.{}", "0".repeat(n)) };
}
let sections: Vec<String> = code
.split(';')
.map(|sec| {
// Find the last digit placeholder run before any exponent / percent etc.
let chars: Vec<char> = sec.chars().collect();
let mut in_quote = false;
let mut in_bracket = false;
let mut last_digit = None;
let mut point = None;
for (i, c) in chars.iter().enumerate() {
if *c == '"' {
in_quote = !in_quote;
}
if *c == '[' && !in_quote {
in_bracket = true;
}
if *c == ']' && !in_quote {
in_bracket = false;
continue;
}
if in_quote || in_bracket {
continue;
}
if matches!(c, '0' | '#' | '?') {
last_digit = Some(i);
}
if *c == '.' && point.is_none() {
point = Some(i);
}
if matches!(c, 'E' | 'e') {
break;
}
}
let Some(ld) = last_digit else { return sec.to_string() };
let mut out: Vec<char> = chars.clone();
if delta > 0 {
match point {
Some(_) => out.insert(ld + 1, '0'),
None => {
out.insert(ld + 1, '0');
out.insert(ld + 1, '.');
}
}
} else if let Some(pt) = point
&& ld > pt
{
out.remove(ld);
if ld == pt + 1 {
out.remove(pt);
}
}
out.into_iter().collect()
})
.collect();
sections.join(";")
}
fn decimals(s: &mut Session, p: &Json, delta: i32) -> Result<Json> {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let a = d.selection.active;
let code = d.wb.styles.get(sh.style_id(a)).num_fmt.as_str().to_string();
let new = adjust_decimals(&code, &sh.value(a), delta);
set_fmt(s, p, &new)
}
/// Built-in cell styles (our own palette).
pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option<Style> {
let _ = theme;
let mut s = Style::default();
let accent = |i: u8, tint: i16| Color::Theme(i, tint);
match name {
"Normal" => {}
"Good" => {
s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE));
s.font.color = Color::rgb(0x00, 0x61, 0x00);
}
"Bad" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE));
s.font.color = Color::rgb(0x9C, 0x00, 0x06);
}
"Neutral" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C));
s.font.color = Color::rgb(0x9C, 0x57, 0x00);
}
"Calculation" => {
s.fill = Fill::solid(Color::rgb(0xF2, 0xF2, 0xF2));
s.font.color = Color::rgb(0xFA, 0x7D, 0x00);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x7F, 0x7F, 0x7F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Check Cell" => {
s.fill = Fill::solid(Color::rgb(0xA5, 0xA5, 0xA5));
s.font.color = Color::rgb(0xFF, 0xFF, 0xFF);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Double, color: Color::rgb(0x3F, 0x3F, 0x3F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Explanatory Text" => {
s.font.italic = true;
s.font.color = Color::rgb(0x7F, 0x7F, 0x7F);
}
"Input" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xCC, 0x99));
s.font.color = Color::rgb(0x3F, 0x3F, 0x76);
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x7F, 0x7F, 0x7F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Linked Cell" => {
s.font.color = Color::rgb(0xFA, 0x7D, 0x00);
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: Color::rgb(0xFF, 0x80, 0x01) };
}
"Note" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xFF, 0xCC));
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xB2, 0xB2, 0xB2) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Output" => {
s.fill = Fill::solid(Color::rgb(0xF2, 0xF2, 0xF2));
s.font.color = Color::rgb(0x3F, 0x3F, 0x3F);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x3F, 0x3F, 0x3F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Warning Text" => s.font.color = Color::rgb(0xFF, 0x00, 0x00),
"Title" => {
s.font.size = 18.0;
s.font.color = Color::Theme(3, 0);
s.font.name = theme.major_font.clone();
}
"Heading 1" => {
s.font.size = 15.0;
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Thick, color: accent(4, 0) };
}
"Heading 2" => {
s.font.size = 13.0;
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Thick, color: accent(4, 500) };
}
"Heading 3" => {
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Medium, color: accent(4, 400) };
}
"Heading 4" => {
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
}
"Total" => {
s.font.bold = true;
s.borders.top = BorderLine { style: BorderStyle::Thin, color: accent(4, 0) };
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: accent(4, 0) };
}
"Currency" => s.num_fmt = NumFmt::new(ACCOUNTING),
"Comma" => s.num_fmt = NumFmt::new(COMMA),
"Percent" => s.num_fmt = NumFmt::new("0%"),
"Currency [0]" => s.num_fmt = NumFmt::new("_(\"$\"* #,##0_);_(\"$\"* \\(#,##0\\);_(\"$\"* \"-\"_);_(@_)"),
"Comma [0]" => s.num_fmt = NumFmt::new("_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)"),
n => {
// AccentN, 20%/40%/60% - AccentN
let (pct, rest) = match n.split_once("% - ") {
Some((p, r)) => (p.parse::<i16>().ok(), r),
None => (None, n),
};
let i: u8 = rest.strip_prefix("Accent")?.parse().ok()?;
if !(1..=6).contains(&i) {
return None;
}
match pct {
None => {
s.fill = Fill::solid(accent(3 + i, 0));
s.font.color = Color::Theme(0, 0);
}
Some(p) => {
s.fill = Fill::solid(accent(3 + i, (1000 - p * 10).clamp(0, 1000)));
s.font.color = if p >= 60 { Color::Theme(0, 0) } else { Color::Theme(1, 0) };
}
}
}
}
Some(s)
}
pub const CELL_STYLE_NAMES: &[&str] = &[
"Normal",
"Bad",
"Good",
"Neutral",
"Calculation",
"Check Cell",
"Explanatory Text",
"Input",
"Linked Cell",
"Note",
"Output",
"Warning Text",
"Heading 1",
"Heading 2",
"Heading 3",
"Heading 4",
"Title",
"Total",
"20% - Accent1",
"20% - Accent2",
"20% - Accent3",
"20% - Accent4",
"20% - Accent5",
"20% - Accent6",
"40% - Accent1",
"40% - Accent2",
"40% - Accent3",
"40% - Accent4",
"40% - Accent5",
"40% - Accent6",
"60% - Accent1",
"60% - Accent2",
"60% - Accent3",
"60% - Accent4",
"60% - Accent5",
"60% - Accent6",
"Accent1",
"Accent2",
"Accent3",
"Accent4",
"Accent5",
"Accent6",
"Comma",
"Comma [0]",
"Currency",
"Currency [0]",
"Percent",
];
fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").unwrap_or("Normal").to_string();
let d = s.doc()?;
let style =
d.wb.cell_styles
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(&name))
.map(|(_, s)| s.clone())
.or_else(|| builtin_cell_style(&name, &d.wb.theme));
let Some(style) = style else { return Err(bad("home.cellStyle", format!("unknown cell style `{name}`"))) };
// Number-format styles only change the number format.
let only_fmt = matches!(name.as_str(), "Comma" | "Comma [0]" | "Currency" | "Currency [0]" | "Percent");
apply_style(s, p, move |st| {
if only_fmt {
st.num_fmt = style.num_fmt.clone();
} else {
let keep_fmt = st.num_fmt.clone();
*st = style.clone();
if style.num_fmt.as_str() == "General" {
st.num_fmt = keep_fmt;
}
}
})
}
// ---------------------------------------------------------------- sizes
/// The sheet and the row (or column) spans a size or visibility command works on. The lines are
/// `rows`/`cols` (`"2:5"`, `"B:B,D:F"`, optionally `Sheet!`-prefixed), else the selection's; the
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
/// sheet, so another sheet needs explicit lines.
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
let key = if rows { "rows" } else { "cols" };
let mut sheet = target_sheet(s, p)?;
if let Some(t) = str_param(p, key) {
let (prefix, body) = split_sheet(t);
if let Some(name) = prefix {
let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?;
if p.get("sheet").is_some_and(|v| !v.is_null()) && named != sheet {
return Err(bad(key, format!("`{t}` names a sheet other than `sheet`")));
}
sheet = named;
}
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
let lines = body
.split(',')
.map(|part| {
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect::<Result<Vec<_>>>()?;
return Ok((sheet, lines));
}
let d = s.doc()?;
if sheet != d.wb.active_sheet {
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
}
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
}
/// Sizes are pixels at 96 dpi, the model's unit (header drags send pixels). `points` (row height)
/// and `chars` (column width) take Excel's units, those of its Row Height and Column Width dialogs
/// and of the file (`ht` is in points).
fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
let h = match (f64_param(p, "height"), f64_param(p, "points")) {
(Some(h), _) => {
if !(0.0..=546.0).contains(&h) {
return Err(EngineError::Other("Row height must be between 0 and 546 pixels (409.5 points).".into()));
}
h
}
(None, Some(pt)) => {
if !(0.0..=409.5).contains(&pt) {
return Err(EngineError::Other("Row height must be between 0 and 409.5 points.".into()));
}
pt * 4.0 / 3.0
}
_ => return Err(bad("home.rowHeight", "missing `height` (pixels) or `points`")),
};
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for r in a..=b.min(a.saturating_add(1_048_575)) {
let e = sh.rows.entry(r).or_default();
e.size = Some(h as f32);
e.hidden = h == 0.0;
e.custom = true;
}
}
cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = match (f64_param(p, "width"), f64_param(p, "chars")) {
(Some(w), _) => {
if !(0.0..=1800.0).contains(&w) {
return Err(EngineError::Other("Column width must be between 0 and 1800 pixels.".into()));
}
w
}
(None, Some(c)) => {
if !(0.0..=255.0).contains(&c) {
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
}
chars_to_points(c)
}
_ => return Err(bad("home.columnWidth", "missing `width` (pixels) or `chars`")),
};
let (si, lines) = line_targets(s, p, false)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for c in a..=b {
let e = sh.cols.entry(c).or_default();
e.size = Some(w as f32);
e.hidden = w == 0.0;
}
}
Ok(Json::Null)
})
}
/// Excel column width in characters (of the default font's digit width, 7 px) → pixels.
pub fn chars_to_points(c: f64) -> f64 {
if c <= 0.0 {
0.0
} else if c < 1.0 {
(c * 12.0).round()
} else {
(c * 7.0 + 5.0).round()
}
}
pub use gridcraft_model::points_to_chars;
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
/// widths for the default font; a front end with fonts measures instead, see `Session::text_width`).
pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
let mut w = 0.0;
for c in text.chars() {
w += match c {
'i' | 'l' | 'j' | '\'' | '.' | ',' | ':' | ';' | '!' | '|' => 0.28,
'f' | 't' | 'r' | 'I' | ' ' | '(' | ')' | '-' => 0.38,
'm' | 'w' | 'M' | 'W' => 0.85,
c if c.is_ascii_uppercase() => 0.65,
c if c.is_ascii_digit() => 0.56,
c if c.is_ascii() => 0.52,
_ => 1.0,
};
}
w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
}
/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
/// quick measure so a huge column doesn't lay out every value.
const EXACT_TEXT_WIDTHS: usize = 2_000;
fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
let (si, lines) = line_targets(s, p, false)?;
let measure = s.text_width.clone();
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let mut widths = Vec::new();
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
let mut distinct = 0usize;
let used = sh.used_range();
for (a, b) in lines {
for c in a..=b.min(a + 16383) {
let Some(u) = used else { continue };
let col = RangeRef::new(CellRef::new(u.start.row, c), CellRef::new(u.end.row, c));
let mut max: f32 = 0.0;
for (cell, _) in sh.cells.iter_range(col) {
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
continue;
}
let id = sh.style_id(cell);
let st = cx.wb.styles.get(id);
let text = crate::display::cell_text(&cx.wb, sh, cell);
for line in text.lines() {
let w = match &measure {
Some(m) => {
let known = measured.entry(id).or_default();
match known.get(line) {
Some(w) => *w,
None => {
let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
known.insert(line.to_owned(), w);
distinct += 1;
w
}
}
}
None => approx_text_width(line, st.font.size, st.font.bold),
};
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
}
}
if max > 0.0 {
widths.push((c, max.clamp(8.0, 1785.0)));
}
}
}
let sh = cx.sheet_mut(si)?;
for (c, w) in widths {
let e = sh.cols.entry(c).or_default();
e.size = Some(w.ceil());
e.hidden = false;
}
Ok(Json::Null)
})
}
fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
for (a, b) in lines {
let b = b.min(a + 100_000);
if let Some(sh) = cx.wb.sheet_mut(si) {
for r in a..=b {
if let Some(e) = sh.rows.get_mut(&r) {
e.custom = false;
e.hidden = false;
}
}
}
cx.fit_rows.extend((a..=b).map(|r| (si, r)));
}
cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
fn default_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = f64_param(p, "width").ok_or_else(|| bad("home.defaultWidth", "missing `width`"))?;
let si = target_sheet(s, p)?;
edit(s, |cx| {
cx.sheet_mut(si)?.default_col_width = w.clamp(0.0, 1800.0) as f32;
Ok(Json::Null)
})
}
fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
let (si, lines) = line_targets(s, p, rows)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
// Unhiding a single selected line also reveals hidden neighbours (Excel needs the
// lines on both sides selected; we're forgiving).
let (a, b) = if !on && a == b { (a.saturating_sub(1), b + 1) } else { (a, b) };
for i in a..=b.min(a.saturating_add(1_048_575)) {
let map = if rows { &mut sh.rows } else { &mut sh.cols };
if on {
map.entry(i).or_default().hidden = true;
} else if let Some(e) = map.get_mut(&i) {
e.hidden = false;
if e.size == Some(0.0) {
e.size = None;
}
}
}
}
cx.structural = true; // SUBTOTAL(10x) depends on hidden rows
Ok(Json::Null)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_across_keeps_the_first_value_of_each_row() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("cell.set", json!({"cell": "D1", "input": "=SUM(A1:B2)"})).unwrap();
s.execute("home.mergeAcross", json!({"range": "A1:B2"})).unwrap();
let v = |s: &mut crate::Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "B1"), Json::Null);
assert_eq!(v(&mut s, "B2"), Json::Null);
assert_eq!(v(&mut s, "D1"), json!(4.0));
}
#[test]
fn decimal_steps() {
let v = gridcraft_core::Value::Number(1.5);
assert_eq!(adjust_decimals("General", &v, 1), "0.00");
assert_eq!(adjust_decimals("General", &v, -1), "0");
assert_eq!(adjust_decimals("0", &v, 1), "0.0");
assert_eq!(adjust_decimals("0.00", &v, -1), "0.0");
assert_eq!(adjust_decimals("0.0", &v, -1), "0");
assert_eq!(adjust_decimals("#,##0.00;[Red]-#,##0.00", &v, 1), "#,##0.000;[Red]-#,##0.000");
assert_eq!(adjust_decimals("0%", &v, 1), "0.0%");
}
#[test]
fn format_cells_takes_the_documented_keys() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
s.execute("home.formatCells", json!({"range": "A1", "style": {"numFmt": "0.0", "borders": {"diagUp": {"style": "Thin"}}}})).unwrap();
let st = &s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"];
assert_eq!(st["num_fmt"], "0.0");
assert_eq!(st["borders"]["diag_up"]["style"], "Thin");
// The style's own snake_case keys keep working.
s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "0.00"}})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00");
}
#[test]
fn width_units() {
assert_eq!(chars_to_points(8.43), 64.0);
assert!((points_to_chars(64.0) - 8.43).abs() < 0.01);
}
}