Skip to main content

ravenrs_sheet/chart/
resolve.rs

1//! Pulls chart data (names, categories, values, formats, colours) out of a workbook.
2
3use crate::base::{CellRef, RangeRef, Value, category_levels, number_to_text};
4use crate::calc::evaluate;
5use crate::model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};
6use crate::numfmt::{NumberFormat, format_value};
7
8use crate::chart::{ChartData, Rgba, SeriesData, TrendlineData};
9
10/// Cap on points per series (keeps whole-column references from exploding).
11pub(crate) const MAX_POINTS: usize = 100_000;
12/// Cap on series per chart.
13pub(crate) const MAX_SERIES: usize = 255;
14/// Cap on trendlines drawn per series.
15pub(crate) const MAX_TRENDLINES: usize = 16;
16
17/// Tints used for series beyond the six theme accents: lighter, darker, lighter still…
18const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2];
19/// `series_color` repeats after this many colours (six accents × the tints).
20pub(crate) const PALETTE_CYCLE: usize = 6 * CYCLE_TINTS.len();
21
22/// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them.
23pub fn series_color(theme: &Theme, i: usize) -> Rgba {
24    let accent = i % 6;
25    let cycle = (i / 6) % CYCLE_TINTS.len();
26    let base = theme.colors.get(4 + accent).copied().unwrap_or(0x156082);
27    let rgb = [(base >> 16) as u8, (base >> 8) as u8, base as u8];
28    let tint = CYCLE_TINTS.get(cycle).copied().unwrap_or(0.0);
29    let [r, g, b] = apply_tint(rgb, tint);
30    [r, g, b, 0xFF]
31}
32
33/// The first `n` series colours for `theme`.
34pub fn default_palette(theme: &Theme, n: usize) -> Vec<Rgba> {
35    (0..n.min(4096)).map(|i| series_color(theme, i)).collect()
36}
37
38/// Splits `Sheet!A1:B2` / `'My Sheet'!$A$1` / `A1:B2` into a sheet index and range.
39fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, RangeRef)> {
40    let f = formula.trim().trim_start_matches('=').trim();
41    let (sh, addr) = match f.rfind('!') {
42        Some(i) => {
43            let name = f.get(..i)?.trim();
44            let name = crate::base::unquote_sheet(name);
45            (wb.sheet_index(&name)?, f.get(i + 1..)?)
46        }
47        None => (sheet, f),
48    };
49    let r = RangeRef::parse(addr)?;
50    Some((sh, r))
51}
52
53/// The parts of a union reference `(Sheet1!$A$1:$A$3,Sheet1!$A$5)`, or `None`.
54fn union_parts(f: &str) -> Option<Vec<&str>> {
55    let inner = f.strip_prefix('(')?.strip_suffix(')')?;
56    let mut parts = Vec::new();
57    let (mut depth, mut quoted, mut start) = (0i32, false, 0usize);
58    for (i, c) in inner.char_indices() {
59        match c {
60            '\'' => quoted = !quoted,
61            '(' if !quoted => depth += 1,
62            ')' if !quoted => depth -= 1,
63            ',' if !quoted && depth == 0 => {
64                parts.push(inner.get(start..i)?);
65                start = i + 1;
66            }
67            _ => {}
68        }
69        if depth < 0 {
70            return None;
71        }
72    }
73    parts.push(inner.get(start..)?);
74    (parts.len() > 1).then_some(parts)
75}
76
77/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
78/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
79fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
80    eval_list_in(wb, sheet, formula, 0)
81}
82
83fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
84    let f = formula.trim();
85    if f.is_empty() {
86        return vec![];
87    }
88    if depth < 4
89        && let Some(parts) = union_parts(f)
90    {
91        let mut out = Vec::new();
92        for p in parts {
93            if out.len() >= MAX_POINTS {
94                break;
95            }
96            let rest = MAX_POINTS - out.len();
97            out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
98        }
99        return out;
100    }
101    match evaluate(wb, sheet, CellRef::default(), f) {
102        Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
103        v => vec![v],
104    }
105}
106
107fn to_num(v: &Value) -> Option<f64> {
108    match v {
109        Value::Number(n) if n.is_finite() => Some(*n),
110        _ => None,
111    }
112}
113
114fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Option<f64>> {
115    eval_list(wb, sheet, formula).iter().map(to_num).collect()
116}
117
118/// Number format code of the first cell of a reference ("General" when not a reference).
119fn ref_format(wb: &Workbook, sheet: usize, formula: &str) -> String {
120    parse_ref(wb, sheet, formula)
121        .and_then(|(sh, r)| wb.sheet(sh).map(|s| wb.styles.get(s.style_id(r.start)).num_fmt.0.to_string()))
122        .unwrap_or_else(|| "General".into())
123}
124
125fn value_text(v: &Value) -> String {
126    match v {
127        Value::Empty => String::new(),
128        Value::Number(n) => number_to_text(*n),
129        Value::Text(t) => t.to_string(),
130        Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.into(),
131        Value::Error(e) => e.as_str().into(),
132        Value::Array(a) => a.get(0, 0).map(value_text).unwrap_or_default(),
133    }
134}
135
136/// Category labels, formatted with each source cell's number format (dates, currency…), and
137/// the outer levels of a multi-level axis: a category range wider than a column of values (or
138/// taller than a row of values) groups its innermost labels, year beside month. Each outer level
139/// has one entry per category, non-empty where a group starts, as the cells are.
140fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec<String>, Vec<Vec<String>>) {
141    let vals = eval_list(wb, sheet, formula);
142    let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);
143    let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r)));
144    let texts: Vec<String> = vals
145        .iter()
146        .enumerate()
147        .map(|(i, v)| {
148            if let (Some((s, r)), Value::Number(_)) = (src, v) {
149                let w = r.width().max(1) as usize;
150                let at = r.start.offset((i / w) as i64, (i % w) as i64);
151                if let Some(c) = at {
152                    let code = &wb.styles.get(s.style_id(c)).num_fmt.0;
153                    if !code.eq_ignore_ascii_case("general") {
154                        return format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text;
155                    }
156                }
157            }
158            value_text(v)
159        })
160        .collect();
161    let Some((_, r)) = src else { return (texts, vec![]) };
162    let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
163    let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) };
164    let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize);
165    if texts.len() != w.saturating_mul(h) {
166        return (texts, vec![]); // truncated or not a plain block: a flat list
167    }
168    let at = |row: usize, col: usize| texts.get(row * w + col).cloned().unwrap_or_default();
169    let column = |c: usize| (0..h).map(|row| at(row, c)).collect::<Vec<String>>();
170    let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::<Vec<String>>();
171    let outer = levels.outer as usize;
172    if levels.by_columns {
173        (column(w - 1), (1..=outer).filter_map(|k| (w - 1).checked_sub(k)).map(column).collect())
174    } else {
175        (row(h - 1), (1..=outer).filter_map(|k| (h - 1).checked_sub(k)).map(row).collect())
176    }
177}
178
179/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on
180/// that sheet), the way the evaluator looks names up.
181fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool {
182    // `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012).
183    match crate::base::split_sheet(t) {
184        (Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()),
185        (None, _) => wb.name(t, sheet).is_some(),
186    }
187}
188
189/// A series name: a reference/formula or a defined name (`[0]!Title` in a file) is evaluated,
190/// a quoted or other bare literal is used as is.
191fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String {
192    let t = name.trim();
193    if let Some(q) = t.strip_prefix('"').and_then(|q| q.strip_suffix('"')) {
194        return q.replace("\"\"", "\"");
195    }
196    if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') || is_defined_name(wb, sheet, t) {
197        let vals = eval_list(wb, sheet, t);
198        let parts: Vec<String> = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect();
199        return parts.join(" ");
200    }
201    t.to_string()
202}
203
204pub(crate) fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
205    match (chart_kind, series_kind) {
206        (ChartKind::Combo, Some(k)) if k != ChartKind::Combo => k,
207        (ChartKind::Combo, _) => {
208            if i == 0 {
209                ChartKind::ColumnClustered
210            } else {
211                ChartKind::Line
212            }
213        }
214        (_, Some(k)) if k != ChartKind::Combo => k,
215        (k, _) => k,
216    }
217}
218
219/// The kind series `i` is drawn as: its own kind in a combo chart (first series a column, the
220/// rest lines when it has none), else the chart's. The one rule shared by resolving and drawing.
221pub(crate) fn plot_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
222    if chart_kind == ChartKind::Combo { effective_kind(chart_kind, series_kind, i) } else { chart_kind }
223}
224
225/// The kind series `i` of `chart` is drawn as: its own kind in a combo chart, else the chart's.
226pub fn series_plot_kind(chart: &Chart, i: usize) -> ChartKind {
227    plot_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i)
228}
229
230/// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X
231/// values (scatter/bubble), bubble sizes, number formats and colours (explicit, else the
232/// default palette derived from the workbook theme accents).
233pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
234    let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);
235    let mut series = Vec::new();
236    let mut categories: Option<Vec<String>> = None;
237    let mut category_levels = Vec::new();
238    for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() {
239        let values = nums(wb, sheet, &s.values);
240        let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1));
241        let color =
242            s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i));
243        let kind = effective_kind(chart.kind, s.kind, i);
244        let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) {
245            // X values with text in them (labels) are ignored: the points are numbered 1..n.
246            (Some(f), true) => Some(eval_list(wb, sheet, f)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect()),
247            _ => None,
248        };
249        if categories.is_none()
250            && let Some(f) = &s.categories
251        {
252            let (c, outer) = category_table(wb, sheet, f, &s.values);
253            if !c.is_empty() {
254                categories = Some(c);
255                category_levels = outer;
256            }
257        }
258        let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
259        let trendlines = s
260            .trendlines
261            .iter()
262            .take(MAX_TRENDLINES)
263            .map(|t| TrendlineData {
264                spec: t.clone(),
265                color: t.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or(color),
266            })
267            .collect();
268        series.push(SeriesData {
269            name,
270            values,
271            x,
272            sizes,
273            color,
274            kind,
275            secondary: s.secondary,
276            smooth: s.smooth,
277            trendlines,
278            number_format: ref_format(wb, sheet, &s.values),
279        });
280    }
281    let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
282    let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect());
283    let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
284    ChartData { categories, category_levels, series, palette }
285}