use super::{ Anchor, BorderLine, BorderStyle, Borders, CfOperator, CfRule, CfValueKind, Color, CondFormat, EngineError, Fill, Json, Result, Session, Shape, ShapeKind, Style, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, ok, str_param, target_ranges, target_sheet, u32_param, }; // ---------------------------------------------------------------- conditional formatting fn preset_style(name: &str) -> Style { let mut s = Style::default(); match name { "redText" => s.font.color = Color::rgb(0x9C, 0x00, 0x06), "yellowFill" | "yellowFillDarkYellowText" => { s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C)); s.font.color = Color::rgb(0x9C, 0x57, 0x00); } "greenFill" | "greenFillDarkGreenText" => { s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE)); s.font.color = Color::rgb(0x00, 0x61, 0x00); } "lightRedFill" => s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE)), "redBorder" => { let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xC0, 0x00, 0x00) }; s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() }; } _ => { s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE)); s.font.color = Color::rgb(0x9C, 0x00, 0x06); } } s } /// Whether `v` is one formula string literal: `"…"` with every inner quote doubled. fn is_string_literal(v: &str) -> bool { v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"')) } /// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`, /// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be /// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a /// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than /// dropped: dropped, it left a rule with an empty format that matches and shows nothing. fn cf_style_json(st: &Json) -> Result { const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"]; let err = |m: String| bad("home.conditionalFormat", m); let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) }; let mut st = obj.clone(); if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) { let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" }; return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", ")))); } // A colour written the way the other formatting commands take it, as the model stores it. let color = |v: &mut Json, what: &str| -> Result<()> { if v.is_string() || v.get("theme").is_some() { let c = crate::cmd::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?; *v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?; } Ok(()) }; if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut) && let Some(c) = f.get_mut("color") { color(c, "font.color")?; } if let Some(fill) = st.get_mut("fill") { // `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one. if fill.is_string() && crate::cmd::format::color_param(Some(fill)) == Some(Color::Auto) { *fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?; } else if fill.is_string() || fill.get("theme").is_some() { *fill = json!({"pattern": "Solid", "fg": fill.clone()}); } if let Some(f) = fill.as_object_mut() { for k in ["fg", "bg"] { if let Some(c) = f.get_mut(k) { color(c, &format!("fill.{k}"))?; } } // A colour with no pattern is a solid fill, not an invisible one. if f.contains_key("fg") && !f.contains_key("pattern") { f.insert("pattern".into(), json!("Solid")); } } } if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) { for (side, line) in b.iter_mut() { if let Some(c) = line.get_mut("color") { color(c, &format!("borders.{side}.color"))?; } } } Ok(Json::Object(st)) } /// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open /// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog /// (which dispatches this command) share one gate, the way Excel's object model refuses. fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> { // Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1' // only parses after several strips and would still be saved unparseable. (#1999 review) if gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() { return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file"))); } Ok(()) } fn check_cf_value(what: &str, v: &gridcraft_model::features::CfValueKind) -> Result<()> { if let gridcraft_model::features::CfValueKind::Formula(f) = v { check_cf_formula("home.conditionalFormat", what, f)?; } Ok(()) } fn check_cf_rule(rule: &CfRule) -> Result<()> { const CMD: &str = "home.conditionalFormat"; match rule { CfRule::CellIs { a, b, .. } => { check_cf_formula(CMD, "comparison value", a)?; if let Some(b) = b { check_cf_formula(CMD, "second comparison value", b)?; } } CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?, CfRule::DataBar { min, max, .. } => { check_cf_value("data bar minimum", min)?; check_cf_value("data bar maximum", max)?; } CfRule::ColorScale { stops } => { for (kind, _) in stops { check_cf_value("color scale threshold", kind)?; } } _ => {} } Ok(()) } pub(super) fn add_cf(s: &mut Session, p: &Json) -> Result { let ranges = target_ranges(s, p)?; let sheet = target_sheet(s, p)?; let Some(rule) = p.get("rule") else { s.ui_requests.push(crate::UiRequest::Dialog("newFormattingRule".into(), json!({}))); return ok(); }; let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string); let style = Box::new(match rule.get("style") { Some(st) if !st.is_null() => { let st = cf_style_json(st)?; let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?; serde_json::from_value(crate::cmd::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))? } _ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")), }); let kind = g("type").unwrap_or_else(|| "cellIs".into()); let value_kind = |v: Option<&Json>, default: CfValueKind| -> CfValueKind { match v { Some(Json::Object(o)) => { let t = o.get("type").and_then(Json::as_str).unwrap_or("num"); let val = o.get("value").and_then(Json::as_f64).unwrap_or(0.0); match t { "min" => CfValueKind::Min, "max" => CfValueKind::Max, "percent" => CfValueKind::Percent(val), "percentile" => CfValueKind::Percentile(val), "formula" => CfValueKind::Formula(o.get("value").and_then(Json::as_str).unwrap_or("").into()), _ => CfValueKind::Number(val), } } _ => default, } }; let rule = match kind.as_str() { "cellIs" => { // Excel's file and VBA names (`lessThan`, …) are accepted too. let op = match g("operator").as_deref().unwrap_or("greater") { "between" => CfOperator::Between, "notBetween" => CfOperator::NotBetween, "equal" => CfOperator::Equal, "notEqual" => CfOperator::NotEqual, "less" | "lessThan" => CfOperator::Less, "greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual, "lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual, "greater" | "greaterThan" => CfOperator::Greater, other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))), }; let a = rule.get("value").map(crate::cmd::edit::json_to_input).unwrap_or_default(); let b = rule.get("value2").map(crate::cmd::edit::json_to_input); let quote = |v: String| { // A number, a formula, or text already written as a string literal (`"Closed"`, as // in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted. if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') { v.trim_start_matches('=').to_string() } else if is_string_literal(&v) { v } else { format!("\"{}\"", v.replace('"', "\"\"")) } }; CfRule::CellIs { op, a: quote(a), b: b.map(quote), style } } "expression" => CfRule::Expression { formula: g("formula").unwrap_or_default().trim_start_matches('=').to_string(), style }, "containsText" => CfRule::ContainsText { text: g("text").unwrap_or_default(), style }, "notContainsText" => CfRule::NotContainsText { text: g("text").unwrap_or_default(), style }, "beginsWith" => CfRule::BeginsWith { text: g("text").unwrap_or_default(), style }, "endsWith" => CfRule::EndsWith { text: g("text").unwrap_or_default(), style }, "blanks" => CfRule::Blanks { style }, "noBlanks" => CfRule::NoBlanks { style }, "errors" => CfRule::Errors { style }, "noErrors" => CfRule::NoErrors { style }, "duplicate" => CfRule::Duplicate { style }, "unique" => CfRule::Unique { style }, "top10" => CfRule::Top10 { bottom: rule.get("bottom").and_then(Json::as_bool).unwrap_or(false), percent: rule.get("percent").and_then(Json::as_bool).unwrap_or(false), rank: rule.get("rank").and_then(Json::as_u64).unwrap_or(10) as u32, style, }, "aboveAverage" => CfRule::AboveAverage { below: rule.get("below").and_then(Json::as_bool).unwrap_or(false), equal: false, std_dev: 0, style }, "timePeriod" => CfRule::TimePeriod { period: g("period").unwrap_or_else(|| "today".into()), style }, "colorScale" => { let cols: Vec = rule .get("colors") .and_then(Json::as_array) .map(|a| a.iter().filter_map(|c| crate::cmd::format::color_param(Some(c))).collect()) .unwrap_or_else(|| vec![Color::rgb(0xF8, 0x69, 0x6B), Color::rgb(0xFF, 0xEB, 0x84), Color::rgb(0x63, 0xBE, 0x7B)]); let stops = match cols.len() { 2 => vec![(CfValueKind::Min, cols[0]), (CfValueKind::Max, cols[1])], _ => vec![ (CfValueKind::Min, cols.first().copied().unwrap_or_default()), (CfValueKind::Percentile(50.0), cols.get(1).copied().unwrap_or_default()), (CfValueKind::Max, cols.get(2).copied().unwrap_or_default()), ], }; CfRule::ColorScale { stops } } "dataBar" => CfRule::DataBar { min: value_kind(rule.get("min"), CfValueKind::Min), max: value_kind(rule.get("max"), CfValueKind::Max), color: crate::cmd::format::color_param(rule.get("color")).unwrap_or(Color::rgb(0x63, 0x8E, 0xC6)), gradient: rule.get("gradient").and_then(Json::as_bool).unwrap_or(true), show_value: true, }, "iconSet" => CfRule::IconSet { set: g("set").unwrap_or_else(|| "3TrafficLights1".into()), thresholds: vec![], reverse: rule.get("reverse").and_then(Json::as_bool).unwrap_or(false), show_value: true, }, other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))), }; check_cf_rule(&rule)?; edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1; // New rules take precedence over older ones. for cf in sh.cond_formats.iter_mut() { cf.priority += 1; } sh.cond_formats.insert(0, CondFormat { ranges: ranges.clone(), rule, priority: 1, stop_if_true: false }); let _ = priority; Ok(json!({"rules": sh.cond_formats.len()})) }) } pub(super) fn clear_cf(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let whole = bool_param(p, "sheet").unwrap_or(false); let ranges = target_ranges(s, p)?; edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; if whole { sh.cond_formats.clear(); } else { for cf in sh.cond_formats.iter_mut() { cf.ranges.retain(|r| !ranges.iter().any(|x| x.intersects(r))); } sh.cond_formats.retain(|c| !c.ranges.is_empty()); } Ok(Json::Null) }) } pub(super) fn manage_cf(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; if p.get("delete").is_none() && p.get("moveUp").is_none() && p.get("moveDown").is_none() { let list: Vec = s .doc()? .wb .sheet(sheet) .map(|sh| { sh.cond_formats.iter().map(|c| json!({"ranges": c.ranges.iter().map(|r| r.a1()).collect::>(), "rule": c.rule})).collect() }) .unwrap_or_default(); return Ok(json!({"rules": list})); } edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; // The count is taken again after each step: a delete shortens the list. if let Some(i) = u32_param(p, "delete").map(|v| v as usize) && i < sh.cond_formats.len() { sh.cond_formats.remove(i); } if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize) && i > 0 && i < sh.cond_formats.len() { sh.cond_formats.swap(i, i - 1); } if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize) && i + 1 < sh.cond_formats.len() { sh.cond_formats.swap(i, i + 1); } for (k, cf) in sh.cond_formats.iter_mut().enumerate() { cf.priority = k as u32 + 1; } Ok(Json::Null) }) } pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").unwrap_or("check").to_string(); let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32; let d = s.doc()?; let id = d.wb.next_object_id(); let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let color = crate::cmd::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0)); let shape = Shape { id, kind: ShapeKind::Icon, anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() }, fill: color, line: Color::Auto, text: name, format: Default::default(), }; let sheet = d.wb.active_sheet(); edit(s, |cx| { cx.sheet_mut(sheet)?.shapes.push(shape); Ok(json!({"shape": id})) }) }