Skip to main content

ironcalc_base/
conditional_formatting.rs

1use std::collections::HashMap;
2
3use crate::expressions::utils::parse_reference_a1;
4use crate::formatter::dates::{date_to_serial_number, from_excel_date};
5use crate::number_format::parse_finite_number;
6use crate::{
7    calc_result::CalcResult,
8    cell::CellValue,
9    cf_types::{
10        CfCellResult, CfDataBar, CfIcon, CfRating, CfRule, CfRuleInput, Cfvo, ColorScaleThreshold,
11        ConditionalFormatting, ConditionalFormattingView, ExtendedStyle, Icon, IconThreshold,
12        PeriodType, TextOperator, ValueOperator,
13    },
14    expressions::types::{CellReferenceIndex, CellReferenceRC},
15    types::{Color, Dxf},
16    Model,
17};
18
19use chrono::{Datelike, Duration, Months, NaiveDate};
20
21// ---------------------------------------------------------------------------
22// Free helper functions for CF evaluation
23// ---------------------------------------------------------------------------
24
25/// Parses a space-separated sqref like "A1:C3 E5" into a list of (row1,col1,row2,col2) tuples.
26fn parse_sqref(sqref: &str) -> Vec<(i32, i32, i32, i32)> {
27    sqref
28        .split_whitespace()
29        .filter_map(parse_range_part)
30        .collect()
31}
32
33fn parse_range_part(s: &str) -> Option<(i32, i32, i32, i32)> {
34    let upper = s.to_uppercase();
35    let parts: Vec<&str> = upper.splitn(2, ':').collect();
36    match parts.len() {
37        1 => {
38            let r = parse_reference_a1(parts[0])?;
39            Some((r.row, r.column, r.row, r.column))
40        }
41        2 => {
42            let r1 = parse_reference_a1(parts[0])?;
43            let r2 = parse_reference_a1(parts[1])?;
44            let (row_min, row_max) = (r1.row.min(r2.row), r1.row.max(r2.row));
45            let (col_min, col_max) = (r1.column.min(r2.column), r1.column.max(r2.column));
46            Some((row_min, col_min, row_max, col_max))
47        }
48        _ => None,
49    }
50}
51
52/// Interpolates a color along the color scale for a given value.
53fn interpolate_color(v: f64, thresholds: &[f64], colors: &[String]) -> String {
54    let n = thresholds.len();
55    if n == 0 || colors.len() != n {
56        return "#000000".to_string();
57    }
58    if v <= thresholds[0] {
59        return colors[0].clone();
60    }
61    if v >= thresholds[n - 1] {
62        return colors[n - 1].clone();
63    }
64    for i in 0..n - 1 {
65        if v >= thresholds[i] && v <= thresholds[i + 1] {
66            let span = thresholds[i + 1] - thresholds[i];
67            let t = if span.abs() < f64::EPSILON {
68                0.0
69            } else {
70                (v - thresholds[i]) / span
71            };
72            return lerp_color(&colors[i], &colors[i + 1], t);
73        }
74    }
75    colors[0].clone()
76}
77
78fn lerp_color(c1: &str, c2: &str, t: f64) -> String {
79    let (r1, g1, b1) = parse_hex_color(c1);
80    let (r2, g2, b2) = parse_hex_color(c2);
81    let r = (r1 as f64 + (r2 as f64 - r1 as f64) * t).round() as u8;
82    let g = (g1 as f64 + (g2 as f64 - g1 as f64) * t).round() as u8;
83    let b = (b1 as f64 + (b2 as f64 - b1 as f64) * t).round() as u8;
84    format!("#{r:02X}{g:02X}{b:02X}")
85}
86
87fn parse_hex_color(s: &str) -> (u8, u8, u8) {
88    let s = s.trim_start_matches('#');
89    if s.len() == 6 {
90        let r = u8::from_str_radix(&s[0..2], 16).unwrap_or(0);
91        let g = u8::from_str_radix(&s[2..4], 16).unwrap_or(0);
92        let b = u8::from_str_radix(&s[4..6], 16).unwrap_or(0);
93        (r, g, b)
94    } else {
95        (0, 0, 0)
96    }
97}
98
99/// Returns the 0-indexed icon position for `v` given the ordered thresholds.
100/// The icon with index `i` applies when `v >= thresholds[i]`.
101fn compute_icon_index(v: f64, thresholds: &[(f64, bool)]) -> u32 {
102    let mut idx = 0u32;
103    for (i, &(value, is_strict)) in thresholds.iter().enumerate() {
104        if v > value || (v == value && is_strict) {
105            idx = i as u32;
106        }
107    }
108    idx
109}
110
111/// Parses a date bound of a Between/NotBetween time-period rule: either a
112/// date string in any format DATEVALUE accepts (e.g. "2025-04-25",
113/// "4/25/2025") or a plain Excel serial number.
114fn parse_cf_date_bound(value: &str) -> Option<f64> {
115    let trimmed = value.trim();
116    if let Ok(serial) = crate::functions::date_and_time::parse_datevalue_text(trimmed) {
117        return Some(serial as f64);
118    }
119    parse_finite_number(trimmed).map(f64::floor)
120}
121
122/// Stable string key for a CellValue, used for duplicate detection.
123fn cell_value_key(v: &crate::cell::CellValue) -> Option<String> {
124    use crate::cell::CellValue;
125    match v {
126        CellValue::Number(n) => Some(format!("{n}")),
127        CellValue::String(s) => Some(s.to_lowercase()),
128        CellValue::Boolean(b) => Some(b.to_string()),
129        CellValue::None => None,
130    }
131}
132
133impl<'a> Model<'a> {
134    /// Evaluates all conditional formatting rules for the workbook.
135    ///
136    /// Iterates every worksheet's CF rules in priority order (lowest priority number,
137    /// processed last). The result for each cell is stored in
138    /// `cf_cache` and consumed by `get_extended_style_for_cell`.
139    pub fn evaluate_conditional_formatting(&mut self) {
140        self.cf_cache.clear();
141        let sheet_count = self.workbook.worksheets.len();
142        for sheet_idx in 0..sheet_count {
143            let mut cfs = self.workbook.worksheets[sheet_idx]
144                .conditional_formatting
145                .clone();
146            // Lower priority number = higher priority; process high-priority first so that
147            // the first writer into cf_cache wins.
148            cfs.sort_by_key(|cf| cf.priority);
149            for cf in cfs {
150                let ranges = parse_sqref(&cf.range);
151                if ranges.is_empty() {
152                    continue;
153                }
154                self.apply_cf_rule(sheet_idx as u32, &cf.cf_rule, &ranges);
155            }
156        }
157    }
158
159    // -----------------------------------------------------------------------
160    // CF evaluation helpers
161    // -----------------------------------------------------------------------
162
163    fn apply_cf_rule(&mut self, sheet: u32, rule: &CfRule, ranges: &[(i32, i32, i32, i32)]) {
164        match rule {
165            CfRule::ColorScale { thresholds } => {
166                self.apply_cf_color_scale(sheet, thresholds, ranges);
167            }
168            CfRule::CellIs {
169                operator,
170                formula,
171                formula2,
172                dxf_id,
173                stop_if_true,
174            } => {
175                self.apply_cf_cell_is(
176                    sheet,
177                    operator,
178                    formula,
179                    formula2.as_deref(),
180                    *dxf_id,
181                    *stop_if_true,
182                    ranges,
183                );
184            }
185            CfRule::Formula {
186                formula,
187                dxf_id,
188                stop_if_true,
189            } => {
190                self.apply_cf_formula(sheet, formula, *dxf_id, *stop_if_true, ranges);
191            }
192            CfRule::DuplicateValues {
193                dxf_id,
194                stop_if_true,
195            } => {
196                self.apply_cf_duplicate_values(sheet, *dxf_id, *stop_if_true, ranges);
197            }
198            CfRule::AboveAverage {
199                dxf_id,
200                stop_if_true,
201            } => {
202                self.apply_cf_average(sheet, *dxf_id, true, *stop_if_true, ranges);
203            }
204            CfRule::BelowAverage {
205                dxf_id,
206                stop_if_true,
207            } => {
208                self.apply_cf_average(sheet, *dxf_id, false, *stop_if_true, ranges);
209            }
210            CfRule::Top10 {
211                rank,
212                percent,
213                dxf_id,
214                stop_if_true,
215            } => {
216                self.apply_cf_top_n(
217                    sheet,
218                    *rank,
219                    *percent,
220                    false,
221                    *dxf_id,
222                    *stop_if_true,
223                    ranges,
224                );
225            }
226            CfRule::Bottom10 {
227                rank,
228                percent,
229                dxf_id,
230                stop_if_true,
231            } => {
232                self.apply_cf_top_n(sheet, *rank, *percent, true, *dxf_id, *stop_if_true, ranges);
233            }
234            CfRule::DataBar {
235                min,
236                max,
237                positive_color,
238                negative_color,
239                is_gradient,
240                show_value,
241            } => {
242                self.apply_cf_data_bar(
243                    sheet,
244                    min.as_ref(),
245                    max.as_ref(),
246                    positive_color,
247                    negative_color,
248                    *is_gradient,
249                    *show_value,
250                    ranges,
251                );
252            }
253            CfRule::IconSet {
254                thresholds,
255                show_value,
256            } => {
257                self.apply_cf_icon_set(sheet, thresholds, *show_value, ranges);
258            }
259            CfRule::Text {
260                operator,
261                value,
262                dxf_id,
263                stop_if_true,
264            } => {
265                self.apply_cf_text(sheet, operator, value, *dxf_id, *stop_if_true, ranges);
266            }
267            CfRule::UniqueValues {
268                dxf_id,
269                stop_if_true,
270            } => {
271                self.apply_cf_unique_values(sheet, *dxf_id, *stop_if_true, ranges);
272            }
273            CfRule::TimePeriod {
274                time_period,
275                date1,
276                date2,
277                dxf_id,
278                stop_if_true,
279            } => {
280                self.apply_cf_time_period(
281                    sheet,
282                    time_period,
283                    date1.as_deref(),
284                    date2.as_deref(),
285                    *dxf_id,
286                    *stop_if_true,
287                    ranges,
288                );
289            }
290            CfRule::IconRating {
291                icon,
292                color,
293                show_value,
294                thresholds,
295            } => {
296                self.apply_cf_icon_rating(sheet, icon, color, thresholds, *show_value, ranges);
297            }
298            CfRule::Blanks {
299                dxf_id,
300                stop_if_true,
301            } => {
302                self.apply_cf_blanks(sheet, *dxf_id, false, *stop_if_true, ranges);
303            }
304            CfRule::NotBlanks {
305                dxf_id,
306                stop_if_true,
307            } => {
308                self.apply_cf_blanks(sheet, *dxf_id, true, *stop_if_true, ranges);
309            }
310            CfRule::Errors {
311                dxf_id,
312                stop_if_true,
313            } => {
314                self.apply_cf_errors(sheet, *dxf_id, false, *stop_if_true, ranges);
315            }
316            CfRule::NoErrors {
317                dxf_id,
318                stop_if_true,
319            } => {
320                self.apply_cf_errors(sheet, *dxf_id, true, *stop_if_true, ranges);
321            }
322        }
323    }
324
325    fn update_cf_cache(
326        &mut self,
327        sheet: u32,
328        row: i32,
329        col: i32,
330        result: CfCellResult,
331        stop_if_true: bool,
332    ) {
333        if stop_if_true {
334            // Replace any lower-priority results already in the cache for this cell.
335            self.cf_cache.insert((sheet, row, col), vec![result]);
336        } else {
337            self.cf_cache
338                .entry((sheet, row, col))
339                .or_default()
340                .push(result);
341        }
342    }
343
344    /// Collects all numeric values from the given set of ranges on `sheet`.
345    fn collect_numeric_values(&self, sheet: u32, ranges: &[(i32, i32, i32, i32)]) -> Vec<f64> {
346        let mut values = Vec::new();
347        for &(r1, c1, r2, c2) in ranges {
348            for row in r1..=r2 {
349                for col in c1..=c2 {
350                    if let Ok(CellValue::Number(n)) = self.get_cell_value_by_index(sheet, row, col)
351                    {
352                        values.push(n);
353                    }
354                }
355            }
356        }
357        values
358    }
359
360    /// Resolves a Cfvo threshold to a concrete f64 value.
361    fn resolve_cfvo(&mut self, cfvo: &Cfvo, values: &[f64], sheet: u32) -> f64 {
362        match cfvo {
363            Cfvo::Min => values.iter().cloned().fold(f64::INFINITY, f64::min),
364            Cfvo::Max => values.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
365            Cfvo::Number(n) => *n,
366            Cfvo::Percent(p) => {
367                if values.is_empty() {
368                    return 0.0;
369                }
370                let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
371                let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
372                let p = *p;
373                min + (max - min) * p / 100.0
374            }
375            Cfvo::Percentile(p) => {
376                if values.is_empty() {
377                    return 0.0;
378                }
379                let mut sorted = values.to_vec();
380                sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
381                let idx = ((p / 100.0) * (sorted.len() - 1) as f64).floor() as usize;
382                sorted[idx.min(sorted.len() - 1)]
383            }
384            Cfvo::Formula(f) => self.evaluate_formula(f, sheet).unwrap_or(0.0),
385        }
386    }
387
388    fn apply_cf_color_scale(
389        &mut self,
390        sheet: u32,
391        thresholds: &[ColorScaleThreshold],
392        ranges: &[(i32, i32, i32, i32)],
393    ) {
394        if thresholds.len() < 2 {
395            return;
396        }
397        let values = self.collect_numeric_values(sheet, ranges);
398        if values.is_empty() {
399            return;
400        }
401        let colors: Vec<String> = thresholds
402            .iter()
403            .map(|t| t.color.to_rgb(&self.workbook.theme))
404            .collect();
405        let mut stops: Vec<f64> = Vec::with_capacity(thresholds.len());
406        for t in thresholds {
407            stops.push(self.resolve_cfvo(&t.cfvo, &values, sheet));
408        }
409        // Excel sorts only the threshold values, keeping colors at their original
410        // positional indices. A formula cfvo that resolves out-of-order (e.g. $G$13=0
411        // appearing after cfvo num=20) must be sorted into place while color[i] still
412        // maps to the i-th stop in sorted order.
413        stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
414
415        for &(r1, c1, r2, c2) in ranges {
416            for row in r1..=r2 {
417                for col in c1..=c2 {
418                    if let Ok(CellValue::Number(v)) = self.get_cell_value_by_index(sheet, row, col)
419                    {
420                        let color = interpolate_color(v, &stops, &colors);
421                        self.update_cf_cache(
422                            sheet,
423                            row,
424                            col,
425                            CfCellResult::ColorScale(Color::Rgb(color)),
426                            false,
427                        );
428                    }
429                }
430            }
431        }
432    }
433
434    #[allow(clippy::too_many_arguments)]
435    fn apply_cf_data_bar(
436        &mut self,
437        sheet: u32,
438        min: Option<&Cfvo>,
439        max: Option<&Cfvo>,
440        positive_color: &Color,
441        negative_color: &Color,
442        is_gradient: bool,
443        show_value: bool,
444        ranges: &[(i32, i32, i32, i32)],
445    ) {
446        let values = self.collect_numeric_values(sheet, ranges);
447        if values.is_empty() {
448            return;
449        }
450        let min_val = match min {
451            Some(cfvo) => self.resolve_cfvo(cfvo, &values, sheet),
452            None => values.iter().cloned().fold(0.0_f64, f64::min),
453        };
454        let max_val = match max {
455            Some(cfvo) => self.resolve_cfvo(cfvo, &values, sheet),
456            None => values.iter().cloned().fold(0.0_f64, f64::max),
457        };
458        let span = max_val - min_val;
459        if span.abs() < f64::EPSILON {
460            return;
461        }
462        let axis_position = (-min_val / span).clamp(0.0, 1.0);
463
464        for &(r1, c1, r2, c2) in ranges {
465            for row in r1..=r2 {
466                for col in c1..=c2 {
467                    if let Ok(CellValue::Number(v)) = self.get_cell_value_by_index(sheet, row, col)
468                    {
469                        let proportion = ((v - min_val) / span).clamp(0.0, 1.0);
470                        self.update_cf_cache(
471                            sheet,
472                            row,
473                            col,
474                            CfCellResult::DataBar {
475                                positive_color: positive_color.clone(),
476                                negative_color: negative_color.clone(),
477                                is_gradient,
478                                value: proportion,
479                                axis_position,
480                                show_value,
481                            },
482                            false,
483                        );
484                    }
485                }
486            }
487        }
488    }
489
490    fn apply_cf_icon_set(
491        &mut self,
492        sheet: u32,
493        thresholds: &[IconThreshold],
494        show_value: bool,
495        ranges: &[(i32, i32, i32, i32)],
496    ) {
497        if thresholds.is_empty() {
498            return;
499        }
500        let values = self.collect_numeric_values(sheet, ranges);
501        let stops: Vec<(f64, bool)> = thresholds
502            .iter()
503            .map(|t| (self.resolve_cfvo(&t.cfvo, &values, sheet), t.is_strict))
504            .collect();
505        let n = thresholds.len();
506
507        for &(r1, c1, r2, c2) in ranges {
508            for row in r1..=r2 {
509                for col in c1..=c2 {
510                    if let Ok(CellValue::Number(v)) = self.get_cell_value_by_index(sheet, row, col)
511                    {
512                        let idx = (compute_icon_index(v, &stops) as usize).min(n - 1);
513                        self.update_cf_cache(
514                            sheet,
515                            row,
516                            col,
517                            CfCellResult::Icon {
518                                icon: thresholds[idx].icon.clone(),
519                                color: thresholds[idx].color.clone(),
520                                show_value,
521                            },
522                            false,
523                        );
524                    }
525                }
526            }
527        }
528    }
529
530    fn apply_cf_icon_rating(
531        &mut self,
532        sheet: u32,
533        icon: &Icon,
534        color: &Color,
535        thresholds: &[(Cfvo, bool)],
536        show_value: bool,
537        ranges: &[(i32, i32, i32, i32)],
538    ) {
539        if thresholds.is_empty() {
540            return;
541        }
542        // Thresholds are stored lowest-value-first. count = how many thresholds the
543        // cell value exceeds; count ∈ [0, max] where max = thresholds.len().
544        // is_strict=true → threshold applies when v >= value (gte).
545        // is_strict=false → threshold applies when v > value (gt).
546        let max = thresholds.len() as u32;
547
548        let values = self.collect_numeric_values(sheet, ranges);
549        // Pre-resolve all Cfvo entries before iterating cells.
550        let resolved: Vec<(f64, bool)> = thresholds
551            .iter()
552            .map(|(cfvo, is_strict)| (self.resolve_cfvo(cfvo, &values, sheet), *is_strict))
553            .collect();
554
555        for &(r1, c1, r2, c2) in ranges {
556            for row in r1..=r2 {
557                for col in c1..=c2 {
558                    if let Ok(CellValue::Number(v)) = self.get_cell_value_by_index(sheet, row, col)
559                    {
560                        let mut count = 0u32;
561                        for &(threshold_val, is_strict) in resolved.iter().rev() {
562                            let exceeds = if is_strict {
563                                v >= threshold_val
564                            } else {
565                                v > threshold_val
566                            };
567                            if exceeds {
568                                count += 1;
569                            }
570                        }
571                        self.update_cf_cache(
572                            sheet,
573                            row,
574                            col,
575                            CfCellResult::Rating {
576                                icon: icon.clone(),
577                                count: count.min(max),
578                                max,
579                                color: color.clone(),
580                                show_value,
581                            },
582                            false,
583                        );
584                    }
585                }
586            }
587        }
588    }
589
590    #[allow(clippy::too_many_arguments)]
591    fn apply_cf_cell_is(
592        &mut self,
593        sheet: u32,
594        operator: &ValueOperator,
595        formula: &str,
596        formula2: Option<&str>,
597        dxf_id: u32,
598        stop_if_true: bool,
599        ranges: &[(i32, i32, i32, i32)],
600    ) {
601        let threshold = match self.evaluate_formula(formula, sheet) {
602            Some(v) => v,
603            None => return,
604        };
605        let threshold2 = formula2.and_then(|f| self.evaluate_formula(f, sheet));
606
607        for &(r1, c1, r2, c2) in ranges {
608            for row in r1..=r2 {
609                for col in c1..=c2 {
610                    if let Ok(CellValue::Number(v)) = self.get_cell_value_by_index(sheet, row, col)
611                    {
612                        let matches = match operator {
613                            ValueOperator::Equal => (v - threshold).abs() < f64::EPSILON,
614                            ValueOperator::GreaterThan => v > threshold,
615                            ValueOperator::GreaterThanOrEqual => v >= threshold,
616                            ValueOperator::LessThan => v < threshold,
617                            ValueOperator::LessThanOrEqual => v <= threshold,
618                            ValueOperator::NotEqual => (v - threshold).abs() >= f64::EPSILON,
619                            ValueOperator::Between => {
620                                let t2 = threshold2.unwrap_or(threshold);
621                                v >= threshold.min(t2) && v <= threshold.max(t2)
622                            }
623                            ValueOperator::NotBetween => {
624                                let t2 = threshold2.unwrap_or(threshold);
625                                v < threshold.min(t2) || v > threshold.max(t2)
626                            }
627                        };
628                        if matches {
629                            self.update_cf_cache(
630                                sheet,
631                                row,
632                                col,
633                                CfCellResult::Dxf(dxf_id),
634                                stop_if_true,
635                            );
636                        }
637                    }
638                }
639            }
640        }
641    }
642
643    fn apply_cf_average(
644        &mut self,
645        sheet: u32,
646        dxf_id: u32,
647        above: bool,
648        stop_if_true: bool,
649        ranges: &[(i32, i32, i32, i32)],
650    ) {
651        let values = self.collect_numeric_values(sheet, ranges);
652        if values.is_empty() {
653            return;
654        }
655        let avg = values.iter().sum::<f64>() / values.len() as f64;
656
657        for &(r1, c1, r2, c2) in ranges {
658            for row in r1..=r2 {
659                for col in c1..=c2 {
660                    if let Ok(CellValue::Number(v)) = self.get_cell_value_by_index(sheet, row, col)
661                    {
662                        if (above && v > avg) || (!above && v < avg) {
663                            self.update_cf_cache(
664                                sheet,
665                                row,
666                                col,
667                                CfCellResult::Dxf(dxf_id),
668                                stop_if_true,
669                            );
670                        }
671                    }
672                }
673            }
674        }
675    }
676
677    #[allow(clippy::too_many_arguments)]
678    fn apply_cf_top_n(
679        &mut self,
680        sheet: u32,
681        rank: u32,
682        percent: bool,
683        bottom: bool,
684        dxf_id: u32,
685        stop_if_true: bool,
686        ranges: &[(i32, i32, i32, i32)],
687    ) {
688        let values = self.collect_numeric_values(sheet, ranges);
689        if values.is_empty() {
690            return;
691        }
692        let n = if percent {
693            ((rank as f64 / 100.0) * values.len() as f64).ceil() as usize
694        } else {
695            rank as usize
696        }
697        .max(1);
698
699        let mut sorted = values.clone();
700        if bottom {
701            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
702        } else {
703            sorted.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
704        }
705        let threshold = sorted[n.min(sorted.len()) - 1];
706
707        for &(r1, c1, r2, c2) in ranges {
708            for row in r1..=r2 {
709                for col in c1..=c2 {
710                    if let Ok(CellValue::Number(v)) = self.get_cell_value_by_index(sheet, row, col)
711                    {
712                        let matches = if bottom {
713                            v <= threshold
714                        } else {
715                            v >= threshold
716                        };
717                        if matches {
718                            self.update_cf_cache(
719                                sheet,
720                                row,
721                                col,
722                                CfCellResult::Dxf(dxf_id),
723                                stop_if_true,
724                            );
725                        }
726                    }
727                }
728            }
729        }
730    }
731
732    fn apply_cf_text(
733        &mut self,
734        sheet: u32,
735        operator: &TextOperator,
736        value: &str,
737        dxf_id: u32,
738        stop_if_true: bool,
739        ranges: &[(i32, i32, i32, i32)],
740    ) {
741        let search = value.to_lowercase();
742        for &(r1, c1, r2, c2) in ranges {
743            for row in r1..=r2 {
744                for col in c1..=c2 {
745                    if let Ok(CellValue::String(s)) = self.get_cell_value_by_index(sheet, row, col)
746                    {
747                        let cell_lower = s.to_lowercase();
748                        let matches = match operator {
749                            TextOperator::Contains => cell_lower.contains(search.as_str()),
750                            TextOperator::DoesNotContain => !cell_lower.contains(search.as_str()),
751                            TextOperator::BeginsWith => cell_lower.starts_with(search.as_str()),
752                            TextOperator::EndsWith => cell_lower.ends_with(search.as_str()),
753                            TextOperator::Equals => cell_lower == search.as_str(),
754                        };
755                        if matches {
756                            self.update_cf_cache(
757                                sheet,
758                                row,
759                                col,
760                                CfCellResult::Dxf(dxf_id),
761                                stop_if_true,
762                            );
763                        }
764                    }
765                }
766            }
767        }
768    }
769
770    /// Evaluates a boolean CF formula rule across the given ranges.
771    ///
772    /// The formula is parsed once using the top-left cell of the first range as the parse
773    /// anchor.
774    fn apply_cf_formula(
775        &mut self,
776        sheet: u32,
777        formula: &str,
778        dxf_id: u32,
779        stop_if_true: bool,
780        ranges: &[(i32, i32, i32, i32)],
781    ) {
782        // The parse anchor is the top-left of the bounding box of *all* areas:
783        // the minimum row and minimum column across every range. For a
784        // single-area rule this is just its top-left, but for a multiple-areas
785        // rule like "D4:D8 B10:D10" the anchor is B4 (min row 4, min col 2),
786        // not the first area's top-left (D4).
787        let Some((anchor_row, anchor_col)) = ranges
788            .iter()
789            .map(|&(r1, c1, _, _)| (r1, c1))
790            .reduce(|(r, c), (r1, c1)| (r.min(r1), c.min(c1)))
791        else {
792            return;
793        };
794        let body = formula.trim().strip_prefix('=').unwrap_or(formula.trim());
795        if body.is_empty() {
796            return;
797        }
798        let sheet_name = match self.workbook.worksheets.get(sheet as usize) {
799            Some(ws) => ws.get_name(),
800            None => return,
801        };
802        let context_rc = CellReferenceRC {
803            sheet: sheet_name,
804            row: anchor_row,
805            column: anchor_col,
806        };
807        // CF formulas are stored internally in English.
808        let node = self.parse_internal_formula(body, &context_rc);
809
810        for &(r1, c1, r2, c2) in ranges {
811            for row in r1..=r2 {
812                for column in c1..=c2 {
813                    let ctx = CellReferenceIndex { sheet, row, column };
814                    let matches = match self.evaluate_node_in_context(&node, ctx) {
815                        CalcResult::Number(n) => n != 0.0,
816                        CalcResult::Boolean(b) => b,
817                        _ => false,
818                    };
819                    if matches {
820                        self.update_cf_cache(
821                            sheet,
822                            row,
823                            column,
824                            CfCellResult::Dxf(dxf_id),
825                            stop_if_true,
826                        );
827                    }
828                }
829            }
830        }
831    }
832
833    fn apply_cf_unique_values(
834        &mut self,
835        sheet: u32,
836        dxf_id: u32,
837        stop_if_true: bool,
838        ranges: &[(i32, i32, i32, i32)],
839    ) {
840        let mut counts: HashMap<String, u32> = HashMap::new();
841        for &(r1, c1, r2, c2) in ranges {
842            for row in r1..=r2 {
843                for col in c1..=c2 {
844                    if let Ok(v) = self.get_cell_value_by_index(sheet, row, col) {
845                        if let Some(k) = cell_value_key(&v) {
846                            *counts.entry(k).or_insert(0) += 1;
847                        }
848                    }
849                }
850            }
851        }
852        for &(r1, c1, r2, c2) in ranges {
853            for row in r1..=r2 {
854                for col in c1..=c2 {
855                    if let Ok(v) = self.get_cell_value_by_index(sheet, row, col) {
856                        if let Some(k) = cell_value_key(&v) {
857                            if counts.get(&k).copied().unwrap_or(0) == 1 {
858                                self.update_cf_cache(
859                                    sheet,
860                                    row,
861                                    col,
862                                    CfCellResult::Dxf(dxf_id),
863                                    stop_if_true,
864                                );
865                            }
866                        }
867                    }
868                }
869            }
870        }
871    }
872
873    #[allow(clippy::too_many_arguments)]
874    fn apply_cf_time_period(
875        &mut self,
876        sheet: u32,
877        period: &PeriodType,
878        date1: Option<&str>,
879        date2: Option<&str>,
880        dxf_id: u32,
881        stop_if_true: bool,
882        ranges: &[(i32, i32, i32, i32)],
883    ) {
884        let today_serial = match crate::tz::excel_serial_for_now(&self.tz) {
885            Some(s) => s.floor() as i64,
886            None => return,
887        };
888        let today = match from_excel_date(today_serial) {
889            Ok(d) => d,
890            Err(_) => return,
891        };
892
893        let serial_of = |d: NaiveDate| -> f64 {
894            date_to_serial_number(d.day(), d.month(), d.year()).unwrap_or(0) as f64
895        };
896
897        let range: (f64, f64) = match period {
898            PeriodType::Yesterday => {
899                let s = today_serial as f64 - 1.0;
900                (s, s)
901            }
902            PeriodType::Today => (today_serial as f64, today_serial as f64),
903            PeriodType::Tomorrow => {
904                let s = today_serial as f64 + 1.0;
905                (s, s)
906            }
907            PeriodType::Last7Days => (today_serial as f64 - 6.0, today_serial as f64),
908            PeriodType::Next7Days => (today_serial as f64, today_serial as f64 + 6.0),
909            PeriodType::LastWeek => {
910                let dow = today.weekday().num_days_from_monday() as i64;
911                let this_mon = today - Duration::days(dow);
912                let last_mon = this_mon - Duration::days(7);
913                let last_sun = this_mon - Duration::days(1);
914                (serial_of(last_mon), serial_of(last_sun))
915            }
916            PeriodType::ThisWeek => {
917                let dow = today.weekday().num_days_from_monday() as i64;
918                let this_mon = today - Duration::days(dow);
919                let this_sun = this_mon + Duration::days(6);
920                (serial_of(this_mon), serial_of(this_sun))
921            }
922            PeriodType::NextWeek => {
923                let dow = today.weekday().num_days_from_monday() as i64;
924                let next_mon = today - Duration::days(dow) + Duration::days(7);
925                let next_sun = next_mon + Duration::days(6);
926                (serial_of(next_mon), serial_of(next_sun))
927            }
928            PeriodType::LastMonth => {
929                let Some(this_month_start) =
930                    NaiveDate::from_ymd_opt(today.year(), today.month(), 1)
931                else {
932                    return;
933                };
934                let last_month_end = this_month_start - Duration::days(1);
935                let Some(last_month_start) =
936                    NaiveDate::from_ymd_opt(last_month_end.year(), last_month_end.month(), 1)
937                else {
938                    return;
939                };
940                (serial_of(last_month_start), serial_of(last_month_end))
941            }
942            PeriodType::ThisMonth => {
943                let Some(start) = NaiveDate::from_ymd_opt(today.year(), today.month(), 1) else {
944                    return;
945                };
946                let end = start + Months::new(1) - Duration::days(1);
947                (serial_of(start), serial_of(end))
948            }
949            PeriodType::NextMonth => {
950                let Some(this_start) = NaiveDate::from_ymd_opt(today.year(), today.month(), 1)
951                else {
952                    return;
953                };
954                let next_start = this_start + Months::new(1);
955                let next_end = next_start + Months::new(1) - Duration::days(1);
956                (serial_of(next_start), serial_of(next_end))
957            }
958            PeriodType::LastYear => {
959                let y = today.year() - 1;
960                let Some(start) = NaiveDate::from_ymd_opt(y, 1, 1) else {
961                    return;
962                };
963                let Some(end) = NaiveDate::from_ymd_opt(y, 12, 31) else {
964                    return;
965                };
966                (serial_of(start), serial_of(end))
967            }
968            PeriodType::ThisYear => {
969                let y = today.year();
970                let Some(start) = NaiveDate::from_ymd_opt(y, 1, 1) else {
971                    return;
972                };
973                let Some(end) = NaiveDate::from_ymd_opt(y, 12, 31) else {
974                    return;
975                };
976                (serial_of(start), serial_of(end))
977            }
978            PeriodType::NextYear => {
979                let y = today.year() + 1;
980                let Some(start) = NaiveDate::from_ymd_opt(y, 1, 1) else {
981                    return;
982                };
983                let Some(end) = NaiveDate::from_ymd_opt(y, 12, 31) else {
984                    return;
985                };
986                (serial_of(start), serial_of(end))
987            }
988            PeriodType::Between | PeriodType::NotBetween => {
989                let (Some(d1), Some(d2)) = (date1, date2) else {
990                    return;
991                };
992                let (Some(s1), Some(s2)) = (parse_cf_date_bound(d1), parse_cf_date_bound(d2))
993                else {
994                    return;
995                };
996                (s1.min(s2), s1.max(s2))
997            }
998        };
999        let negate = matches!(period, PeriodType::NotBetween);
1000
1001        for &(r1, c1, r2, c2) in ranges {
1002            for row in r1..=r2 {
1003                for col in c1..=c2 {
1004                    if let Ok(CellValue::Number(v)) = self.get_cell_value_by_index(sheet, row, col)
1005                    {
1006                        let day = v.floor();
1007                        let inside = day >= range.0 && day <= range.1;
1008                        if inside != negate {
1009                            self.update_cf_cache(
1010                                sheet,
1011                                row,
1012                                col,
1013                                CfCellResult::Dxf(dxf_id),
1014                                stop_if_true,
1015                            );
1016                        }
1017                    }
1018                }
1019            }
1020        }
1021    }
1022
1023    fn apply_cf_duplicate_values(
1024        &mut self,
1025        sheet: u32,
1026        dxf_id: u32,
1027        stop_if_true: bool,
1028        ranges: &[(i32, i32, i32, i32)],
1029    ) {
1030        let mut counts: HashMap<String, u32> = HashMap::new();
1031        for &(r1, c1, r2, c2) in ranges {
1032            for row in r1..=r2 {
1033                for col in c1..=c2 {
1034                    if let Ok(v) = self.get_cell_value_by_index(sheet, row, col) {
1035                        let key = cell_value_key(&v);
1036                        if let Some(k) = key {
1037                            *counts.entry(k).or_insert(0) += 1;
1038                        }
1039                    }
1040                }
1041            }
1042        }
1043        for &(r1, c1, r2, c2) in ranges {
1044            for row in r1..=r2 {
1045                for col in c1..=c2 {
1046                    if let Ok(v) = self.get_cell_value_by_index(sheet, row, col) {
1047                        if let Some(k) = cell_value_key(&v) {
1048                            if counts.get(&k).copied().unwrap_or(0) > 1 {
1049                                self.update_cf_cache(
1050                                    sheet,
1051                                    row,
1052                                    col,
1053                                    CfCellResult::Dxf(dxf_id),
1054                                    stop_if_true,
1055                                );
1056                            }
1057                        }
1058                    }
1059                }
1060            }
1061        }
1062    }
1063
1064    fn apply_cf_blanks(
1065        &mut self,
1066        sheet: u32,
1067        dxf_id: u32,
1068        invert: bool,
1069        stop_if_true: bool,
1070        ranges: &[(i32, i32, i32, i32)],
1071    ) {
1072        for &(r1, c1, r2, c2) in ranges {
1073            for row in r1..=r2 {
1074                for col in c1..=c2 {
1075                    let is_blank = matches!(
1076                        self.get_cell_value_by_index(sheet, row, col),
1077                        Ok(CellValue::None)
1078                    );
1079                    if is_blank != invert {
1080                        self.update_cf_cache(
1081                            sheet,
1082                            row,
1083                            col,
1084                            CfCellResult::Dxf(dxf_id),
1085                            stop_if_true,
1086                        );
1087                    }
1088                }
1089            }
1090        }
1091    }
1092
1093    fn apply_cf_errors(
1094        &mut self,
1095        sheet: u32,
1096        dxf_id: u32,
1097        invert: bool,
1098        stop_if_true: bool,
1099        ranges: &[(i32, i32, i32, i32)],
1100    ) {
1101        use crate::types::CellType;
1102        for &(r1, c1, r2, c2) in ranges {
1103            for row in r1..=r2 {
1104                for col in c1..=c2 {
1105                    let is_error = self
1106                        .workbook
1107                        .worksheet(sheet)
1108                        .ok()
1109                        .and_then(|ws| ws.cell(row, col))
1110                        .is_some_and(|c| c.get_type() == CellType::ErrorValue);
1111                    if is_error != invert {
1112                        self.update_cf_cache(
1113                            sheet,
1114                            row,
1115                            col,
1116                            CfCellResult::Dxf(dxf_id),
1117                            stop_if_true,
1118                        );
1119                    }
1120                }
1121            }
1122        }
1123    }
1124
1125    // -----------------------------------------------------------------------
1126
1127    /// Returns the extended cell style for (`sheet`, `row`, `column`), including
1128    /// any conditional-formatting overlay computed by the last evaluate() call.
1129    pub fn get_extended_style_for_cell(
1130        &self,
1131        sheet: u32,
1132        row: i32,
1133        column: i32,
1134    ) -> Result<ExtendedStyle, String> {
1135        let base = self.get_style_for_cell(sheet, row, column)?;
1136        let extended = match self.cf_cache.get(&(sheet, row, column)) {
1137            Some(c) => c,
1138            None => {
1139                return Ok(ExtendedStyle {
1140                    style: base,
1141                    icon: None,
1142                    data_bar: None,
1143                    rating: None,
1144                })
1145            }
1146        };
1147        let mut style = base;
1148        let mut icon = None;
1149        let mut data_bar = None;
1150        let mut rating = None;
1151        // `extended` contains the cached conditional-formatting results for this cell in the
1152        // order produced by the evaluator. Dxf results accumulate as we iterate; for exclusive
1153        // types (ColorScale, DataBar, Icon, Rating), the last assignment in this evaluated
1154        // result order wins.
1155        for cf_result in extended {
1156            match cf_result {
1157                CfCellResult::Dxf(dxf_id) => {
1158                    if let Some(dxf) = self.workbook.styles.dxfs.get(*dxf_id as usize) {
1159                        style = dxf.apply_to(&style);
1160                    }
1161                }
1162                CfCellResult::ColorScale(color) => {
1163                    style.fill.color = color.clone();
1164                }
1165                CfCellResult::DataBar {
1166                    positive_color,
1167                    negative_color,
1168                    is_gradient,
1169                    value,
1170                    axis_position,
1171                    show_value,
1172                } => {
1173                    data_bar = Some(CfDataBar {
1174                        positive_color: positive_color.clone(),
1175                        negative_color: negative_color.clone(),
1176                        is_gradient: *is_gradient,
1177                        value: *value,
1178                        axis_position: *axis_position,
1179                        show_value: *show_value,
1180                    });
1181                }
1182                CfCellResult::Icon {
1183                    icon: cf_icon,
1184                    color,
1185                    show_value,
1186                } => {
1187                    icon = Some(CfIcon {
1188                        icon: cf_icon.clone(),
1189                        color: color.clone(),
1190                        show_value: *show_value,
1191                    });
1192                }
1193                CfCellResult::Rating {
1194                    icon: cf_icon,
1195                    count,
1196                    max,
1197                    color,
1198                    show_value,
1199                } => {
1200                    rating = Some(CfRating {
1201                        icon: cf_icon.clone(),
1202                        count: *count,
1203                        max: *max,
1204                        color: color.clone(),
1205                        show_value: *show_value,
1206                    });
1207                }
1208            }
1209        }
1210        Ok(ExtendedStyle {
1211            style,
1212            icon,
1213            data_bar,
1214            rating,
1215        })
1216    }
1217
1218    // -----------------------------------------------------------------------
1219    // CRUD API for conditional formatting rules
1220    // -----------------------------------------------------------------------
1221
1222    /// Appends `dxf` to the workbook's dxf table and returns its new index.
1223    fn create_dxf(&mut self, dxf: Dxf) -> u32 {
1224        let id = self.workbook.styles.dxfs.len() as u32;
1225        self.workbook.styles.dxfs.push(dxf);
1226        id
1227    }
1228
1229    /// Converts a `CfRuleInput` into a stored `CfRule`, creating a dxf entry when a format is provided.
1230    fn cf_rule_from_input(&mut self, rule: CfRuleInput) -> CfRule {
1231        match rule {
1232            CfRuleInput::ColorScale { thresholds } => CfRule::ColorScale { thresholds },
1233            CfRuleInput::CellIs {
1234                operator,
1235                formula,
1236                formula2,
1237                format,
1238                stop_if_true,
1239            } => CfRule::CellIs {
1240                operator,
1241                formula,
1242                formula2,
1243                dxf_id: self.create_dxf(format),
1244                stop_if_true,
1245            },
1246            CfRuleInput::Text {
1247                operator,
1248                value,
1249                format,
1250                stop_if_true,
1251            } => CfRule::Text {
1252                operator,
1253                value,
1254                dxf_id: self.create_dxf(format),
1255                stop_if_true,
1256            },
1257            CfRuleInput::Formula {
1258                formula,
1259                format,
1260                stop_if_true,
1261            } => CfRule::Formula {
1262                formula,
1263                dxf_id: self.create_dxf(format),
1264                stop_if_true,
1265            },
1266            CfRuleInput::TimePeriod {
1267                time_period,
1268                date1,
1269                date2,
1270                format,
1271                stop_if_true,
1272            } => CfRule::TimePeriod {
1273                time_period,
1274                date1,
1275                date2,
1276                dxf_id: self.create_dxf(format),
1277                stop_if_true,
1278            },
1279            CfRuleInput::DuplicateValues {
1280                format,
1281                stop_if_true,
1282            } => CfRule::DuplicateValues {
1283                dxf_id: self.create_dxf(format),
1284                stop_if_true,
1285            },
1286            CfRuleInput::UniqueValues {
1287                format,
1288                stop_if_true,
1289            } => CfRule::UniqueValues {
1290                dxf_id: self.create_dxf(format),
1291                stop_if_true,
1292            },
1293            CfRuleInput::Blanks {
1294                format,
1295                stop_if_true,
1296            } => CfRule::Blanks {
1297                dxf_id: self.create_dxf(format),
1298                stop_if_true,
1299            },
1300            CfRuleInput::NotBlanks {
1301                format,
1302                stop_if_true,
1303            } => CfRule::NotBlanks {
1304                dxf_id: self.create_dxf(format),
1305                stop_if_true,
1306            },
1307            CfRuleInput::Errors {
1308                format,
1309                stop_if_true,
1310            } => CfRule::Errors {
1311                dxf_id: self.create_dxf(format),
1312                stop_if_true,
1313            },
1314            CfRuleInput::NoErrors {
1315                format,
1316                stop_if_true,
1317            } => CfRule::NoErrors {
1318                dxf_id: self.create_dxf(format),
1319                stop_if_true,
1320            },
1321            CfRuleInput::AboveAverage {
1322                format,
1323                stop_if_true,
1324            } => CfRule::AboveAverage {
1325                dxf_id: self.create_dxf(format),
1326                stop_if_true,
1327            },
1328            CfRuleInput::BelowAverage {
1329                format,
1330                stop_if_true,
1331            } => CfRule::BelowAverage {
1332                dxf_id: self.create_dxf(format),
1333                stop_if_true,
1334            },
1335            CfRuleInput::Top10 {
1336                rank,
1337                percent,
1338                format,
1339                stop_if_true,
1340            } => CfRule::Top10 {
1341                rank,
1342                percent,
1343                dxf_id: self.create_dxf(format),
1344                stop_if_true,
1345            },
1346            CfRuleInput::Bottom10 {
1347                rank,
1348                percent,
1349                format,
1350                stop_if_true,
1351            } => CfRule::Bottom10 {
1352                rank,
1353                percent,
1354                dxf_id: self.create_dxf(format),
1355                stop_if_true,
1356            },
1357            CfRuleInput::DataBar {
1358                min,
1359                max,
1360                positive_color,
1361                negative_color,
1362                is_gradient,
1363                show_value,
1364            } => CfRule::DataBar {
1365                min,
1366                max,
1367                positive_color,
1368                negative_color,
1369                is_gradient,
1370                show_value,
1371            },
1372            CfRuleInput::IconSet {
1373                thresholds,
1374                show_value,
1375            } => CfRule::IconSet {
1376                thresholds,
1377                show_value,
1378            },
1379            CfRuleInput::IconRating {
1380                icon,
1381                color,
1382                thresholds,
1383                show_value,
1384            } => CfRule::IconRating {
1385                icon,
1386                color,
1387                thresholds,
1388                show_value,
1389            },
1390        }
1391    }
1392
1393    /// The anchor used to parse/stringify a conditional-formatting formula on
1394    /// `sheet`. References round trip identically regardless of the anchor, so a
1395    /// fixed A1 anchor is enough.
1396    fn cf_formula_context(&self, sheet: u32) -> CellReferenceRC {
1397        let sheet_name = self
1398            .workbook
1399            .worksheets
1400            .get(sheet as usize)
1401            .map(|w| w.get_name())
1402            .unwrap_or_default();
1403        CellReferenceRC {
1404            sheet: sheet_name,
1405            row: 1,
1406            column: 1,
1407        }
1408    }
1409
1410    /// Translates every formula in a CF rule input from the active
1411    /// language/locale into the internal English representation that is stored.
1412    ///
1413    /// This runs before the rule is materialized (and before any dxf entry is
1414    /// created), so an invalid formula fails the whole operation without leaving
1415    /// behind an orphan dxf or storing a non-canonical formula that would later
1416    /// fail to parse as English.
1417    fn cf_rule_input_to_internal(
1418        &mut self,
1419        rule: &mut CfRuleInput,
1420        sheet: u32,
1421    ) -> Result<(), String> {
1422        let ctx = self.cf_formula_context(sheet);
1423        match rule {
1424            CfRuleInput::CellIs {
1425                formula, formula2, ..
1426            } => {
1427                *formula = self.user_formula_to_internal(formula, &ctx)?;
1428                if let Some(f2) = formula2 {
1429                    *f2 = self.user_formula_to_internal(f2, &ctx)?;
1430                }
1431            }
1432            CfRuleInput::Formula { formula, .. } => {
1433                *formula = self.user_formula_to_internal(formula, &ctx)?;
1434            }
1435            CfRuleInput::ColorScale { thresholds } => {
1436                for t in thresholds {
1437                    self.cfvo_to_internal(&mut t.cfvo, &ctx)?;
1438                }
1439            }
1440            CfRuleInput::DataBar { min, max, .. } => {
1441                if let Some(min) = min {
1442                    self.cfvo_to_internal(min, &ctx)?;
1443                }
1444                if let Some(max) = max {
1445                    self.cfvo_to_internal(max, &ctx)?;
1446                }
1447            }
1448            CfRuleInput::IconSet { thresholds, .. } => {
1449                for t in thresholds {
1450                    self.cfvo_to_internal(&mut t.cfvo, &ctx)?;
1451                }
1452            }
1453            CfRuleInput::IconRating { thresholds, .. } => {
1454                for (cfvo, _) in thresholds {
1455                    self.cfvo_to_internal(cfvo, &ctx)?;
1456                }
1457            }
1458            // Remaining rule kinds carry no formula strings.
1459            _ => {}
1460        }
1461        Ok(())
1462    }
1463
1464    fn cfvo_to_internal(&mut self, cfvo: &mut Cfvo, ctx: &CellReferenceRC) -> Result<(), String> {
1465        if let Cfvo::Formula(f) = cfvo {
1466            *f = self.user_formula_to_internal(f, ctx)?;
1467        }
1468        Ok(())
1469    }
1470
1471    /// Translates every formula in a CF rule from the internal English
1472    /// representation into the active language/locale for display.
1473    fn cf_rule_to_display(&self, rule: &mut CfRule, sheet: u32) {
1474        let ctx = self.cf_formula_context(sheet);
1475        match rule {
1476            CfRule::CellIs {
1477                formula, formula2, ..
1478            } => {
1479                *formula = self.internal_formula_to_display(formula, &ctx);
1480                if let Some(f2) = formula2 {
1481                    *f2 = self.internal_formula_to_display(f2, &ctx);
1482                }
1483            }
1484            CfRule::Formula { formula, .. } => {
1485                *formula = self.internal_formula_to_display(formula, &ctx);
1486            }
1487            CfRule::ColorScale { thresholds } => {
1488                for t in thresholds {
1489                    self.cfvo_to_display(&mut t.cfvo, &ctx);
1490                }
1491            }
1492            CfRule::DataBar { min, max, .. } => {
1493                if let Some(min) = min {
1494                    self.cfvo_to_display(min, &ctx);
1495                }
1496                if let Some(max) = max {
1497                    self.cfvo_to_display(max, &ctx);
1498                }
1499            }
1500            CfRule::IconSet { thresholds, .. } => {
1501                for t in thresholds {
1502                    self.cfvo_to_display(&mut t.cfvo, &ctx);
1503                }
1504            }
1505            CfRule::IconRating { thresholds, .. } => {
1506                for (cfvo, _) in thresholds {
1507                    self.cfvo_to_display(cfvo, &ctx);
1508                }
1509            }
1510            _ => {}
1511        }
1512    }
1513
1514    fn cfvo_to_display(&self, cfvo: &mut Cfvo, ctx: &CellReferenceRC) {
1515        if let Cfvo::Formula(f) = cfvo {
1516            *f = self.internal_formula_to_display(f, ctx);
1517        }
1518    }
1519
1520    /// Returns all CF rules for the given sheet, sorted by priority descending
1521    /// (highest priority first).
1522    ///
1523    /// Each entry carries its `index` into the worksheet's stored
1524    /// `conditional_formatting` vector so callers can pass it back to the
1525    /// index-based mutators ([`Self::get_dxf_for_conditional_formatting`],
1526    /// [`Self::update_conditional_formatting`], [`Self::delete_conditional_formatting`],
1527    /// [`Self::raise_conditional_formatting_priority`],
1528    /// [`Self::lower_conditional_formatting_priority`]) without having to infer the
1529    /// index from the display position.
1530    ///
1531    /// Formulas are stored internally in English; they are translated into the
1532    /// active language/locale for display.
1533    pub fn get_conditional_formatting_list(
1534        &self,
1535        sheet: u32,
1536    ) -> Result<Vec<ConditionalFormattingView>, String> {
1537        let mut list: Vec<(usize, ConditionalFormatting)> = self
1538            .workbook
1539            .worksheet(sheet)?
1540            .conditional_formatting
1541            .iter()
1542            .cloned()
1543            .enumerate()
1544            .collect();
1545        // sort by priority descending (highest first)
1546        list.sort_by_key(|(_, cf)| std::cmp::Reverse(cf.priority));
1547        let mut result = Vec::with_capacity(list.len());
1548        for (index, mut cf) in list {
1549            self.cf_rule_to_display(&mut cf.cf_rule, sheet);
1550            result.push(ConditionalFormattingView {
1551                index,
1552                range: cf.range,
1553                cf_rule: cf.cf_rule,
1554                priority: cf.priority,
1555            });
1556        }
1557        Ok(result)
1558    }
1559
1560    /// Returns the differential format (Dxf) for the CF rule at `index` on `sheet`,
1561    /// or `None` if the rule type has no dxf (e.g. ColorScale, DataBar, IconSet).
1562    pub fn get_dxf_for_conditional_formatting(
1563        &self,
1564        sheet: u32,
1565        index: usize,
1566    ) -> Result<Option<Dxf>, String> {
1567        let ws = self.workbook.worksheet(sheet)?;
1568        let cf = ws
1569            .conditional_formatting
1570            .get(index)
1571            .ok_or_else(|| format!("Conditional formatting index {index} out of bounds"))?;
1572        let dxf_id = match &cf.cf_rule {
1573            CfRule::CellIs { dxf_id, .. }
1574            | CfRule::Text { dxf_id, .. }
1575            | CfRule::TimePeriod { dxf_id, .. }
1576            | CfRule::DuplicateValues { dxf_id, .. }
1577            | CfRule::UniqueValues { dxf_id, .. }
1578            | CfRule::Blanks { dxf_id, .. }
1579            | CfRule::NotBlanks { dxf_id, .. }
1580            | CfRule::Errors { dxf_id, .. }
1581            | CfRule::NoErrors { dxf_id, .. }
1582            | CfRule::AboveAverage { dxf_id, .. }
1583            | CfRule::BelowAverage { dxf_id, .. }
1584            | CfRule::Top10 { dxf_id, .. }
1585            | CfRule::Bottom10 { dxf_id, .. }
1586            | CfRule::Formula { dxf_id, .. } => *dxf_id,
1587            _ => return Ok(None),
1588        };
1589        Ok(self.workbook.styles.dxfs.get(dxf_id as usize).cloned())
1590    }
1591
1592    /// Adds a new CF rule to `sheet`, appended with priority = 1 + current max.
1593    /// Returns the assigned priority.
1594    pub fn add_conditional_formatting(
1595        &mut self,
1596        sheet: u32,
1597        range: &str,
1598        rule: CfRuleInput,
1599    ) -> Result<u32, String> {
1600        if parse_sqref(range).is_empty() {
1601            return Err(format!("Invalid conditional formatting range: '{range}'"));
1602        }
1603        // Formulas are stored internally in English regardless of the user's
1604        // active language. Translate before materializing the rule so an invalid
1605        // formula fails without creating an orphan dxf.
1606        let mut rule = rule;
1607        self.cf_rule_input_to_internal(&mut rule, sheet)?;
1608        let final_rule = self.cf_rule_from_input(rule);
1609        let ws = self.workbook.worksheet_mut(sheet)?;
1610        let priority = ws
1611            .conditional_formatting
1612            .iter()
1613            .map(|cf| cf.priority)
1614            .max()
1615            .map(|m| m + 1)
1616            .unwrap_or(1);
1617        ws.conditional_formatting.push(ConditionalFormatting {
1618            range: range.to_string(),
1619            cf_rule: final_rule,
1620            priority,
1621        });
1622        Ok(priority)
1623    }
1624
1625    /// Removes the CF rule at `index` from `sheet`.  Returns the removed rule.
1626    pub fn delete_conditional_formatting(
1627        &mut self,
1628        sheet: u32,
1629        index: usize,
1630    ) -> Result<crate::cf_types::ConditionalFormatting, String> {
1631        let ws = self.workbook.worksheet_mut(sheet)?;
1632        if index >= ws.conditional_formatting.len() {
1633            return Err(format!(
1634                "Conditional formatting index {index} out of bounds"
1635            ));
1636        }
1637        Ok(ws.conditional_formatting.remove(index))
1638    }
1639
1640    /// Replaces the range and rule of the CF entry at `index` on `sheet`.
1641    /// The priority is preserved.  Returns the previous entry.
1642    pub fn update_conditional_formatting(
1643        &mut self,
1644        sheet: u32,
1645        index: usize,
1646        new_range: &str,
1647        new_rule: CfRuleInput,
1648    ) -> Result<ConditionalFormatting, String> {
1649        if parse_sqref(new_range).is_empty() {
1650            return Err(format!(
1651                "Invalid conditional formatting range: '{new_range}'"
1652            ));
1653        }
1654        // Formulas are stored internally in English regardless of the user's
1655        // active language. Translate before materializing the rule so an invalid
1656        // formula fails without creating an orphan dxf.
1657        let mut new_rule = new_rule;
1658        self.cf_rule_input_to_internal(&mut new_rule, sheet)?;
1659        let final_rule = self.cf_rule_from_input(new_rule);
1660        let ws = self.workbook.worksheet_mut(sheet)?;
1661        if index >= ws.conditional_formatting.len() {
1662            return Err(format!(
1663                "Conditional formatting index {index} out of bounds"
1664            ));
1665        }
1666        let old = ws.conditional_formatting[index].clone();
1667        ws.conditional_formatting[index].range = new_range.to_string();
1668        ws.conditional_formatting[index].cf_rule = final_rule;
1669        Ok(old)
1670    }
1671
1672    /// Raises the priority of the CF rule at `index` on `sheet`.
1673    ///
1674    /// In IronCalc a higher priority *number* means higher priority. Raising a
1675    /// rule swaps its priority with the rule that has the next-higher priority
1676    /// number (the closest rule above it). If the rule is already the
1677    /// highest-priority one, this is a no-op.
1678    pub fn raise_conditional_formatting_priority(
1679        &mut self,
1680        sheet: u32,
1681        index: usize,
1682    ) -> Result<(), String> {
1683        let ws = self.workbook.worksheet_mut(sheet)?;
1684        if index >= ws.conditional_formatting.len() {
1685            return Err(format!(
1686                "Conditional formatting index {index} out of bounds"
1687            ));
1688        }
1689        let current = ws.conditional_formatting[index].priority;
1690        // The neighbour is the rule with the smallest priority strictly greater
1691        // than `current`.
1692        let neighbour = ws
1693            .conditional_formatting
1694            .iter()
1695            .enumerate()
1696            .filter(|(_, cf)| cf.priority > current)
1697            .min_by_key(|(_, cf)| cf.priority)
1698            .map(|(i, _)| i);
1699        if let Some(j) = neighbour {
1700            let other = ws.conditional_formatting[j].priority;
1701            ws.conditional_formatting[j].priority = current;
1702            ws.conditional_formatting[index].priority = other;
1703        }
1704        Ok(())
1705    }
1706
1707    /// Lowers the priority of the CF rule at `index` on `sheet`.
1708    ///
1709    /// In IronCalc a higher priority *number* means higher priority. Lowering a
1710    /// rule swaps its priority with the rule that has the next-lower priority
1711    /// number (the closest rule below it). If the rule is already the
1712    /// lowest-priority one, this is a no-op.
1713    pub fn lower_conditional_formatting_priority(
1714        &mut self,
1715        sheet: u32,
1716        index: usize,
1717    ) -> Result<(), String> {
1718        let ws = self.workbook.worksheet_mut(sheet)?;
1719        if index >= ws.conditional_formatting.len() {
1720            return Err(format!(
1721                "Conditional formatting index {index} out of bounds"
1722            ));
1723        }
1724        let current = ws.conditional_formatting[index].priority;
1725        // The neighbour is the rule with the largest priority strictly less than
1726        // `current`.
1727        let neighbour = ws
1728            .conditional_formatting
1729            .iter()
1730            .enumerate()
1731            .filter(|(_, cf)| cf.priority < current)
1732            .max_by_key(|(_, cf)| cf.priority)
1733            .map(|(i, _)| i);
1734        if let Some(j) = neighbour {
1735            let other = ws.conditional_formatting[j].priority;
1736            ws.conditional_formatting[j].priority = current;
1737            ws.conditional_formatting[index].priority = other;
1738        }
1739        Ok(())
1740    }
1741
1742    /// Inserts a CF entry at `index` without modifying priority (used for undo/redo).
1743    pub(crate) fn insert_conditional_formatting_at(
1744        &mut self,
1745        sheet: u32,
1746        index: usize,
1747        cf: crate::cf_types::ConditionalFormatting,
1748    ) -> Result<(), String> {
1749        let ws = self.workbook.worksheet_mut(sheet)?;
1750        if index > ws.conditional_formatting.len() {
1751            return Err(format!(
1752                "Conditional formatting index {index} out of bounds"
1753            ));
1754        }
1755        ws.conditional_formatting.insert(index, cf);
1756        Ok(())
1757    }
1758}