Skip to main content

ravenrs_sheet/model/
features.rs

1//! Sheet features beyond cells: tables, conditional formatting, data validation, comments,
2//! hyperlinks, charts, images, autofilter, print settings.
3
4use crate::base::{CellRef, RangeRef};
5use serde::{Deserialize, Serialize};
6
7use crate::model::style::{Color, Style};
8
9mod pivot;
10mod print;
11
12pub use pivot::{PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue};
13pub use print::{EditRange, HfPage, LineInfo, Orientation, PageOrder, PrintSettings, SheetProtection, password_hash};
14
15// ---------------------------------------------------------------- tables
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
18pub enum TotalsFn {
19    #[default]
20    None,
21    Average,
22    Count,
23    CountNums,
24    Max,
25    Min,
26    Sum,
27    StdDev,
28    Var,
29    Custom,
30}
31
32impl TotalsFn {
33    /// SUBTOTAL function number.
34    pub fn subtotal_code(&self) -> Option<u32> {
35        Some(match self {
36            TotalsFn::Average => 101,
37            TotalsFn::Count => 103,
38            TotalsFn::CountNums => 102,
39            TotalsFn::Max => 104,
40            TotalsFn::Min => 105,
41            TotalsFn::Sum => 109,
42            TotalsFn::StdDev => 107,
43            TotalsFn::Var => 110,
44            _ => return None,
45        })
46    }
47}
48
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
50pub struct TableColumn {
51    pub name: String,
52    pub totals: TotalsFn,
53    /// Totals row label (first column) or custom formula.
54    pub totals_label: Option<String>,
55    /// Calculated column formula.
56    pub formula: Option<String>,
57    /// `uniqueName`: the name XML maps know the column by.
58    #[serde(default)]
59    pub unique_name: Option<String>,
60    /// Binding to an element of an XML map. It moves with the column and goes when the column is
61    /// deleted; inserted columns are unmapped.
62    #[serde(default)]
63    pub xml: Option<XmlColumnPr>,
64}
65
66/// A table column's binding to an XML map (`<xmlColumnPr>`).
67#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
68pub struct XmlColumnPr {
69    pub map_id: u32,
70    pub xpath: String,
71    /// `xmlDataType` as written in the file (`string`, `boolean`, `integer`…).
72    pub data_type: String,
73    pub denormalized: bool,
74}
75
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77pub struct Table {
78    pub id: u32,
79    pub name: String,
80    /// Whole table including header and totals rows.
81    pub range: RangeRef,
82    pub header_row: bool,
83    pub totals_row: bool,
84    pub columns: Vec<TableColumn>,
85    pub style: String,
86    pub banded_rows: bool,
87    pub banded_cols: bool,
88    pub first_col: bool,
89    pub last_col: bool,
90    pub filter_button: bool,
91    /// Criteria of the table's AutoFilter (column offset within the table → criterion). With
92    /// criteria present, hidden data rows are filtered out rather than hidden by hand, which
93    /// SUBTOTAL distinguishes.
94    #[serde(default)]
95    pub filter: Vec<(u32, FilterCriterion)>,
96    /// `tableType` when it isn't a plain worksheet table: `xml` for a table bound to an XML map.
97    #[serde(default)]
98    pub table_type: Option<String>,
99    /// The connection (`xl/connections.xml`) an XML table is bound through.
100    #[serde(default)]
101    pub connection_id: Option<u32>,
102}
103
104impl Table {
105    pub fn data_range(&self) -> Option<RangeRef> {
106        let r0 = self.range.start.row + self.header_row as u32;
107        let r1 = self.range.end.row.checked_sub(self.totals_row as u32)?;
108        if r1 < r0 {
109            return None;
110        }
111        Some(RangeRef::new(CellRef::new(r0, self.range.start.col), CellRef::new(r1, self.range.end.col)))
112    }
113    pub fn header_range(&self) -> Option<RangeRef> {
114        self.header_row.then(|| RangeRef::new(self.range.start, CellRef::new(self.range.start.row, self.range.end.col)))
115    }
116    pub fn totals_range(&self) -> Option<RangeRef> {
117        self.totals_row.then(|| RangeRef::new(CellRef::new(self.range.end.row, self.range.start.col), self.range.end))
118    }
119    pub fn column_index(&self, name: &str) -> Option<usize> {
120        self.columns.iter().position(|c| c.name.eq_ignore_ascii_case(name))
121    }
122}
123
124// ---------------------------------------------------------------- conditional formatting
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
127pub enum CfOperator {
128    Between,
129    NotBetween,
130    Equal,
131    NotEqual,
132    Greater,
133    Less,
134    GreaterOrEqual,
135    LessOrEqual,
136}
137
138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
139pub enum CfValueKind {
140    Min,
141    Max,
142    Number(f64),
143    Percent(f64),
144    Percentile(f64),
145    Formula(String),
146}
147
148#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
149pub enum CfRule {
150    /// Cell value compared with one or two formulas.
151    CellIs {
152        op: CfOperator,
153        a: String,
154        b: Option<String>,
155        style: Box<Style>,
156    },
157    /// Custom formula (relative to the top-left cell of the range).
158    Expression {
159        formula: String,
160        style: Box<Style>,
161    },
162    ContainsText {
163        text: String,
164        style: Box<Style>,
165    },
166    NotContainsText {
167        text: String,
168        style: Box<Style>,
169    },
170    BeginsWith {
171        text: String,
172        style: Box<Style>,
173    },
174    EndsWith {
175        text: String,
176        style: Box<Style>,
177    },
178    Blanks {
179        style: Box<Style>,
180    },
181    NoBlanks {
182        style: Box<Style>,
183    },
184    Errors {
185        style: Box<Style>,
186    },
187    NoErrors {
188        style: Box<Style>,
189    },
190    Duplicate {
191        style: Box<Style>,
192    },
193    Unique {
194        style: Box<Style>,
195    },
196    /// Top/bottom N or N percent.
197    Top10 {
198        bottom: bool,
199        percent: bool,
200        rank: u32,
201        style: Box<Style>,
202    },
203    AboveAverage {
204        below: bool,
205        equal: bool,
206        std_dev: u8,
207        style: Box<Style>,
208    },
209    /// `yesterday`, `today`, `tomorrow`, `last7Days`, `lastWeek`, `thisWeek`, `nextWeek`, `lastMonth`, `thisMonth`, `nextMonth`.
210    TimePeriod {
211        period: String,
212        style: Box<Style>,
213    },
214    ColorScale {
215        stops: Vec<(CfValueKind, Color)>,
216    },
217    DataBar {
218        min: CfValueKind,
219        max: CfValueKind,
220        color: Color,
221        gradient: bool,
222        show_value: bool,
223    },
224    /// Icon set name (`3Arrows`, `3TrafficLights1`, `4Rating`, `5Quarters`…).
225    IconSet {
226        set: String,
227        thresholds: Vec<CfValueKind>,
228        reverse: bool,
229        show_value: bool,
230    },
231}
232
233/// Number of icons in an icon set (`3Arrows` → 3, `5Quarters` → 5), always 3 to 5.
234pub fn icon_count(set: &str) -> usize {
235    set.chars().next().and_then(|c| c.to_digit(10)).unwrap_or(3).clamp(3, 5) as usize
236}
237
238/// Excel's default icon-set thresholds: one per icon, the first being the lower bound,
239/// then even percent steps (`0, 33, 67` for three icons).
240pub fn default_icon_thresholds(set: &str) -> Vec<CfValueKind> {
241    let k = icon_count(set);
242    (0..k).map(|i| CfValueKind::Percent((i as f64 * 100.0 / k as f64).round())).collect()
243}
244
245#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
246pub struct CondFormat {
247    pub ranges: Vec<RangeRef>,
248    pub rule: CfRule,
249    pub priority: u32,
250    pub stop_if_true: bool,
251}
252
253// ---------------------------------------------------------------- data validation
254
255#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
256pub enum ValidationKind {
257    #[default]
258    Any,
259    Whole,
260    Decimal,
261    List,
262    Date,
263    Time,
264    TextLength,
265    Custom,
266}
267
268#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
269pub enum ErrorStyle {
270    #[default]
271    Stop,
272    Warning,
273    Information,
274}
275
276#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
277pub struct Validation {
278    pub ranges: Vec<RangeRef>,
279    pub kind: ValidationKind,
280    pub op: CfOperator,
281    /// Formula 1 (list source: `"a,b,c"` literal list or `=$A$1:$A$5`).
282    pub f1: String,
283    pub f2: Option<String>,
284    pub allow_blank: bool,
285    pub in_cell_dropdown: bool,
286    pub input_title: String,
287    pub input_message: String,
288    pub show_input: bool,
289    pub error_title: String,
290    pub error_message: String,
291    pub error_style: ErrorStyle,
292    pub show_error: bool,
293}
294
295impl Default for Validation {
296    fn default() -> Self {
297        Validation {
298            ranges: vec![],
299            kind: ValidationKind::Any,
300            op: CfOperator::Between,
301            f1: String::new(),
302            f2: None,
303            allow_blank: true,
304            in_cell_dropdown: true,
305            input_title: String::new(),
306            input_message: String::new(),
307            show_input: true,
308            error_title: String::new(),
309            error_message: String::new(),
310            error_style: ErrorStyle::Stop,
311            show_error: true,
312        }
313    }
314}
315
316// ---------------------------------------------------------------- comments, links
317
318/// A note (classic comment) or a threaded comment.
319#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
320pub struct Comment {
321    pub author: String,
322    pub text: String,
323    /// Replies of a threaded comment (author, text).
324    pub replies: Vec<(String, String)>,
325    pub threaded: bool,
326    pub resolved: bool,
327    pub visible: bool,
328    /// Threaded comments: when the thread (first entry) and each reply were written, ISO 8601 as
329    /// read from the file (`dT`); empty when unknown, e.g. for comments made here.
330    #[serde(default, skip_serializing_if = "Vec::is_empty")]
331    pub dates: Vec<String>,
332    /// Where the note's box sits and how big it is, as read from the file; `None` (a note made
333    /// here, or a file without the shape) writes the default box next to the cell.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub note_box: Option<NoteBox>,
336}
337
338/// A note box's place and size, from the note's VML shape: Excel's `<x:Anchor>` with its corners
339/// stored relative to the note's cell, so the box moves with the note when rows or columns are
340/// inserted or deleted, and the shape's width and height.
341#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
342pub struct NoteBox {
343    /// Top-left corner: columns and rows from the note's cell (negative = left of or above it),
344    /// then the pixel offset into that column and row.
345    pub from: NoteCorner,
346    /// Bottom-right corner, the same way.
347    pub to: NoteCorner,
348    /// The shape's width and height in points (the VML style's `width`/`height`), when given.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub size_pt: Option<(f64, f64)>,
351}
352
353/// One corner of a [`NoteBox`].
354#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
355pub struct NoteCorner {
356    pub col: i64,
357    pub col_px: u32,
358    pub row: i64,
359    pub row_px: u32,
360}
361
362#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
363pub struct Hyperlink {
364    /// URL, `mailto:`, a file path (`Book.xlsx`, `Book.xlsx#Sheet1!A2`), or an in-workbook location
365    /// like `Sheet2!A1`.
366    pub target: String,
367    pub tooltip: Option<String>,
368    /// The target is outside this workbook (Excel's "Existing File or Web Page"): a relationship in
369    /// the file, not a `location`. A bare file name can't be told from a defined name by its text.
370    #[serde(default)]
371    pub external: bool,
372}
373
374// ---------------------------------------------------------------- drawing objects
375
376/// How a floating object is pinned to the grid when its cells move or resize. Mirrors Excel's
377/// Format ▸ Size and Properties ▸ Properties, stored in the file as the anchor's `editAs`:
378/// `twoCell` (absent `editAs`), `oneCell` and `absolute`.
379#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
380pub enum AnchorMode {
381    /// Move and size with cells (Excel's default; `<xdr:twoCellAnchor>`).
382    #[default]
383    MoveAndSize,
384    /// Move but don't size with cells (`<xdr:twoCellAnchor editAs="oneCell">` / `<xdr:oneCellAnchor>`).
385    MoveOnly,
386    /// Don't move or size with cells (`editAs="absolute"` / `<xdr:absoluteAnchor>`).
387    Absolute,
388}
389
390/// Position of a floating object: top-left cell plus offset, size in points.
391#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
392pub struct Anchor {
393    pub cell: CellRef,
394    pub dx: f32,
395    pub dy: f32,
396    pub width: f32,
397    pub height: f32,
398    /// How the object follows its anchor cell when rows/columns move (sort, insert, delete).
399    #[serde(default)]
400    pub mode: AnchorMode,
401}
402
403#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
404pub enum ChartKind {
405    #[default]
406    ColumnClustered,
407    ColumnStacked,
408    ColumnStacked100,
409    BarClustered,
410    BarStacked,
411    BarStacked100,
412    Line,
413    LineMarkers,
414    LineStacked,
415    Pie,
416    Doughnut,
417    Area,
418    AreaStacked,
419    AreaStacked100,
420    Scatter,
421    ScatterLines,
422    Bubble,
423    Radar,
424    Histogram,
425    Waterfall,
426    Funnel,
427    Treemap,
428    Sunburst,
429    BoxWhisker,
430    Stock,
431    Combo,
432}
433
434#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
435pub enum LegendPos {
436    None,
437    #[default]
438    Bottom,
439    Top,
440    Left,
441    Right,
442}
443
444/// Quartile calculation for a box & whisker series.
445#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
446pub enum QuartileMethod {
447    #[default]
448    Exclusive,
449    Inclusive,
450}
451
452#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
453pub struct Series {
454    /// Formula text for the series name (`Sheet1!$B$1`) or a literal.
455    pub name: Option<String>,
456    pub categories: Option<String>,
457    pub values: String,
458    /// X values for scatter / bubble sizes.
459    pub bubble_sizes: Option<String>,
460    pub color: Option<Color>,
461    /// Combo charts: this series drawn as a line on the secondary axis.
462    pub secondary: bool,
463    pub kind: Option<ChartKind>,
464    /// Draw line segments as a smoothed (spline) curve.
465    #[serde(default)]
466    pub smooth: bool,
467    /// Box & whisker quartiles; Excel defaults to exclusive.
468    #[serde(default)]
469    pub quartiles: QuartileMethod,
470    /// Trendlines fitted to this series (Excel allows several per series).
471    #[serde(default)]
472    pub trendlines: Vec<Trendline>,
473}
474
475/// Regression a trendline fits (`c:trendlineType`).
476#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
477pub enum TrendlineKind {
478    #[default]
479    Linear,
480    /// `y = c·e^(b·x)`.
481    Exponential,
482    /// `y = c·ln(x) + b`.
483    Logarithmic,
484    /// Polynomial of this order (2..=6).
485    Polynomial(u8),
486    /// `y = c·x^b`.
487    Power,
488    /// Average of this many consecutive points (2..=255).
489    MovingAverage(u32),
490}
491
492/// DrawingML preset line dash (`a:prstDash`).
493#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
494pub enum LineDash {
495    #[default]
496    Solid,
497    Dot,
498    Dash,
499    LgDash,
500    DashDot,
501    LgDashDot,
502    LgDashDotDot,
503    SysDash,
504    SysDot,
505    SysDashDot,
506    SysDashDotDot,
507}
508
509/// A chart trendline (Add Chart Element ▸ Trendline, Format Trendline pane).
510#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
511pub struct Trendline {
512    pub kind: TrendlineKind,
513    /// Custom name shown in the legend; `None` = automatic ("Linear (Sales)").
514    pub name: Option<String>,
515    /// Forecast past the last point, in x units (categories on a category axis).
516    pub forward: f64,
517    /// Forecast before the first point, in x units.
518    pub backward: f64,
519    /// Set Intercept: fixed y at x = 0 (linear, exponential, polynomial).
520    pub intercept: Option<f64>,
521    pub display_equation: bool,
522    pub display_r_squared: bool,
523    /// Line colour; `None` = the series colour.
524    pub color: Option<Color>,
525    /// Line width in points; `None` = default.
526    pub width: Option<f32>,
527    /// Line dash; `None` = default.
528    pub dash: Option<LineDash>,
529}
530
531impl Trendline {
532    /// Longest forward / backward forecast kept from a file or a command, in x units. Excel's own
533    /// limit is unconfirmed; the renderer additionally bounds the forecast by the plot's slot budget.
534    pub const MAX_FORECAST: f64 = 100_000.0;
535
536    /// A forecast distance as stored: finite, not negative (NaN and infinity become 0) and at most
537    /// [`Self::MAX_FORECAST`].
538    pub fn clamp_forecast(v: f64) -> f64 {
539        if v.is_finite() && v > 0.0 { v.min(Self::MAX_FORECAST) } else { 0.0 }
540    }
541}
542
543/// Charts saved before `hole_size` existed were drawn with a 50% hole. New charts and charts read
544/// from XLSX set 75 explicitly (Excel's default), so only a missing JSON field keeps the old look.
545fn default_hole_size() -> u8 {
546    50
547}
548
549/// A chart's data table (`c:dTable`), independent of the chart legend.
550#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(default)]
552pub struct ChartDataTable {
553    pub show_keys: bool,
554    pub horizontal_border: bool,
555    pub vertical_border: bool,
556    pub outline: bool,
557}
558
559impl Default for ChartDataTable {
560    fn default() -> Self {
561        Self { show_keys: true, horizontal_border: true, vertical_border: true, outline: true }
562    }
563}
564
565#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
566pub struct Chart {
567    pub id: u32,
568    pub kind: ChartKind,
569    pub anchor: Anchor,
570    pub title: Option<String>,
571    pub series: Vec<Series>,
572    pub legend: LegendPos,
573    pub data_labels: bool,
574    #[serde(default)]
575    pub data_table: Option<ChartDataTable>,
576    pub gridlines: bool,
577    pub style: u32,
578    /// Doughnut hole size as a percentage of the plot radius (Excel's 10–90 range). New charts and
579    /// XLSX imports use Excel's 75; a document saved before this field existed reads as 50.
580    #[serde(default = "default_hole_size")]
581    pub hole_size: u8,
582    /// Pie/doughnut first slice angle in degrees clockwise from 12 o'clock (0–359).
583    #[serde(default)]
584    pub first_slice_angle: u16,
585    pub x_title: Option<String>,
586    pub y_title: Option<String>,
587    /// Source range when created from a selection (for Switch Row/Column).
588    pub source: Option<String>,
589    pub by_rows: bool,
590    /// A PivotChart's PivotTable (`Sheet2!PivotTable1`): its series are rebuilt from the report
591    /// whenever the PivotTable is refreshed or its layout changes.
592    #[serde(default)]
593    pub pivot: Option<String>,
594}
595
596impl Chart {
597    /// Data tables belong to category-axis column, bar, line and area charts.
598    pub fn supports_data_table(&self) -> bool {
599        let eligible = |kind| {
600            matches!(
601                kind,
602                ChartKind::ColumnClustered
603                    | ChartKind::ColumnStacked
604                    | ChartKind::ColumnStacked100
605                    | ChartKind::BarClustered
606                    | ChartKind::BarStacked
607                    | ChartKind::BarStacked100
608                    | ChartKind::Line
609                    | ChartKind::LineMarkers
610                    | ChartKind::LineStacked
611                    | ChartKind::Area
612                    | ChartKind::AreaStacked
613                    | ChartKind::AreaStacked100
614            )
615        };
616        eligible(self.kind) || (self.kind == ChartKind::Combo && self.series.iter().all(|s| eligible(s.kind.unwrap_or(ChartKind::Line))))
617    }
618}
619
620#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
621pub struct Image {
622    pub id: u32,
623    pub anchor: Anchor,
624    /// Encoded bytes (PNG/JPEG), base64 in JSON. Shared, so a copy of the sheet (an undo step)
625    /// doesn't copy the picture.
626    #[serde(with = "crate::model::b64::shared")]
627    pub data: std::sync::Arc<Vec<u8>>,
628    pub mime: String,
629    pub alt: String,
630}
631
632#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
633pub enum ShapeKind {
634    #[default]
635    Rectangle,
636    RoundedRectangle,
637    Ellipse,
638    Triangle,
639    Line,
640    Arrow,
641    TextBox,
642    /// An icon from GridCraft's own icon set; `Shape::text` holds the icon name.
643    Icon,
644    /// A freehand ink stroke; `Shape::text` holds the points as `x,y;x,y;…` relative to the
645    /// anchor (sheet points), `Shape::line` the ink colour and `fill` unused.
646    Ink,
647    /// An equation: `Shape::text` holds its LaTeX source, `Shape::line` its ink colour
648    /// (`Color::Auto` for the sheet's text colour) and `Shape::math` its cached layout.
649    Equation,
650}
651
652#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
653pub struct Shape {
654    pub id: u32,
655    pub kind: ShapeKind,
656    pub anchor: Anchor,
657    pub fill: Color,
658    pub line: Color,
659    pub text: String,
660    /// Outline visibility and text formatting (DrawingML `a:ln`, `a:bodyPr`, `a:pPr`, `a:rPr`).
661    #[serde(default)]
662    pub format: ShapeFormat,
663    /// An equation's layout, rebuilt from `text` and never saved.
664    #[serde(skip)]
665    pub math: EquationCache,
666}
667
668/// The laid-out form of an equation's LaTeX source. It is derived data: rebuilt by
669/// [`EquationCache::refresh`] whenever the source changes, never saved, and ignored when shapes
670/// are compared. Painters read it and never lay equations out themselves, so nothing in a paint
671/// path can reach the layout engine.
672///
673/// An entry answers only for the source it was built from, compared in full: a hash match alone
674/// is not enough, so two sources whose hashes collide never share a layout. A source over
675/// [`crate::math::MAX_SOURCE_BYTES`] never lays out, so [`EquationCache::get`] answers for it
676/// by its length alone, with [`crate::math::SOURCE_TOO_LONG`], and an entry built from one holds
677/// neither the source nor its hash: a painter asking every frame about a source of megabytes read
678/// from a file does no work on it.
679#[derive(Clone, Debug, Default)]
680pub struct EquationCache {
681    /// A hash of the source the entry was built from (a quick first check).
682    key: u64,
683    /// The source's length in bytes.
684    len: usize,
685    /// The source itself, when it is within the length cap.
686    source: Option<std::sync::Arc<str>>,
687    layout: Option<Result<std::sync::Arc<crate::math::MathList>, crate::math::MathError>>,
688}
689
690impl PartialEq for EquationCache {
691    fn eq(&self, _: &Self) -> bool {
692        true
693    }
694}
695
696fn source_key(src: &str) -> u64 {
697    use std::hash::{Hash, Hasher};
698    #[cfg(test)]
699    equation_cache_tests::HASHES.with(|h| h.set(h.get() + 1));
700    let mut h = std::collections::hash_map::DefaultHasher::new();
701    src.hash(&mut h);
702    h.finish()
703}
704
705impl EquationCache {
706    /// Whether this entry was built from `src`. Over the cap the length is the match (an entry for
707    /// such a source is always the same error); within it, the hash and then the source itself.
708    fn holds(&self, src: &str) -> bool {
709        if self.layout.is_none() || self.len != src.len() {
710            return false;
711        }
712        if src.len() > crate::math::MAX_SOURCE_BYTES {
713            return self.source.is_none();
714        }
715        self.key == source_key(src) && self.source.as_deref() == Some(src)
716    }
717
718    /// Makes this the entry for `src`, with `layout`. A source over the cap is neither copied nor
719    /// hashed.
720    fn store(&mut self, src: &str, layout: Result<std::sync::Arc<crate::math::MathList>, crate::math::MathError>) {
721        let within = src.len() <= crate::math::MAX_SOURCE_BYTES;
722        self.key = if within { source_key(src) } else { 0 };
723        self.len = src.len();
724        self.source = within.then(|| std::sync::Arc::from(src));
725        self.layout = Some(layout);
726    }
727
728    /// The layout of `src`, or why it has none; `None` until [`EquationCache::refresh`] has run
729    /// for this source. A source over [`crate::math::MAX_SOURCE_BYTES`] is answered at once,
730    /// whatever the entry holds, by its length alone.
731    pub fn get(&self, src: &str) -> Option<Result<&crate::math::MathList, &crate::math::MathError>> {
732        if src.len() > crate::math::MAX_SOURCE_BYTES {
733            return Some(Err(&*crate::math::SOURCE_TOO_LONG));
734        }
735        if !self.holds(src) {
736            return None;
737        }
738        self.layout.as_ref().map(|r| r.as_ref().map(|l| l.as_ref()))
739    }
740
741    /// Lays `src` out again unless the cache already holds its layout. True when it did.
742    pub fn refresh(&mut self, src: &str) -> bool {
743        if self.holds(src) {
744            return false;
745        }
746        self.store(src, crate::math::layout(src).map(std::sync::Arc::new));
747        true
748    }
749
750    /// Records that `src` was not laid out, and why, without running the layout engine.
751    pub fn refuse(&mut self, src: &str, message: &str) {
752        self.store(src, Err(crate::math::MathError { span: None, message: message.to_string() }));
753    }
754}
755
756/// What a form control on the sheet is.
757#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
758#[serde(rename_all = "camelCase")]
759pub enum ControlKind {
760    #[default]
761    Button,
762    Slider,
763    Spin,
764}
765
766impl ControlKind {
767    /// The kind's name in the window: Button, Slider, Spin Button.
768    pub fn label(self) -> &'static str {
769        match self {
770            ControlKind::Button => "Button",
771            ControlKind::Slider => "Slider",
772            ControlKind::Spin => "Spin Button",
773        }
774    }
775}
776
777/// The largest value a slider or spin button takes (Excel's scroll bar limit). Values are whole
778/// numbers from 0 to this.
779pub const CONTROL_MAX: u32 = 30_000;
780
781/// What a button runs when it is pressed.
782#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
783#[serde(tag = "type", rename_all = "camelCase")]
784pub enum ControlAction {
785    /// Runs a macro of the workbook on `input`, optionally sending its output to `output`.
786    Macro {
787        name: String,
788        #[serde(default)]
789        input: MacroInput,
790        #[serde(default, skip_serializing_if = "Option::is_none")]
791        output: Option<String>,
792    },
793    /// Runs one GridCraft command with fixed parameters.
794    Command {
795        id: String,
796        #[serde(default)]
797        params: serde_json::Value,
798    },
799}
800
801/// The range a button's macro runs on.
802#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
803#[serde(rename_all = "camelCase")]
804pub enum MacroInput {
805    /// The selection when the button is pressed.
806    #[default]
807    Selection,
808    /// Always this range (`B2:D9` or `Data!B2:D9`).
809    Fixed(String),
810}
811
812/// A form control (button, slider, spin button) floating over the sheet.
813#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
814#[serde(default)]
815pub struct FormControl {
816    pub id: u32,
817    pub kind: ControlKind,
818    pub anchor: Anchor,
819    /// The name shown in the Selection Pane (`Button 3`); empty means the kind and id.
820    pub name: String,
821    /// The caption of a button.
822    pub text: String,
823    /// The linked cell as reference text: `$B$2` on the control's own sheet, or `Sheet2!$B$2`.
824    /// Structural edits rewrite it like a formula reference.
825    pub link: Option<String>,
826    /// The current value of a slider or spin button (the linked cell holds it too).
827    pub value: u32,
828    pub min: u32,
829    pub max: u32,
830    /// Incremental change: one arrow click or arrow key.
831    pub step: u32,
832    /// Page change (sliders): a click on the track, or Page Up / Page Down.
833    pub page: u32,
834    /// A slider lies horizontally (false: vertically).
835    pub horizontal: bool,
836    /// What a button runs.
837    pub action: Option<ControlAction>,
838    /// Printed with the sheet.
839    pub print: bool,
840    /// Locked when the sheet is protected.
841    pub locked: bool,
842}
843
844impl Default for FormControl {
845    fn default() -> Self {
846        FormControl {
847            id: 0,
848            kind: ControlKind::default(),
849            anchor: Anchor::default(),
850            name: String::new(),
851            text: String::new(),
852            link: None,
853            value: 0,
854            min: 0,
855            max: 100,
856            step: 1,
857            page: 10,
858            horizontal: true,
859            action: None,
860            print: true,
861            locked: true,
862        }
863    }
864}
865
866impl FormControl {
867    /// The name shown for the control: its own, else `Button 3`, `Slider 4`, `Spin Button 5`.
868    pub fn display_name(&self) -> String {
869        if self.name.trim().is_empty() { format!("{} {}", self.kind.label(), self.id) } else { self.name.clone() }
870    }
871
872    /// Checks the numbers of a slider or spin button: 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min,
873    /// and (sliders) page ≥ step. Each rule has its own message.
874    pub fn check_numbers(&self) -> Result<(), String> {
875        if self.kind == ControlKind::Button {
876            return Ok(());
877        }
878        if self.max > CONTROL_MAX {
879            return Err(format!("Maximum value can be at most {CONTROL_MAX}."));
880        }
881        if self.min > CONTROL_MAX {
882            return Err(format!("Minimum value must be a whole number from 0 to {CONTROL_MAX}."));
883        }
884        if self.min >= self.max {
885            return Err(format!("Maximum value ({}) must be greater than Minimum value ({}).", self.max, self.min));
886        }
887        if self.step < 1 {
888            return Err("Incremental change must be at least 1.".into());
889        }
890        let span = self.max - self.min;
891        if self.step > span {
892            return Err(format!("Incremental change ({}) can be at most Maximum minus Minimum ({span}).", self.step));
893        }
894        if self.kind == ControlKind::Slider {
895            if self.page < self.step {
896                return Err(format!("Page change ({}) must be at least the incremental change ({}).", self.page, self.step));
897            }
898            if self.page > CONTROL_MAX {
899                return Err(format!("Page change can be at most {CONTROL_MAX}."));
900            }
901        }
902        Ok(())
903    }
904
905    /// `v` clamped to min..max and snapped to the nearest step from min (max stays reachable).
906    pub fn snap(&self, v: f64) -> u32 {
907        let (min, max) = (self.min.min(CONTROL_MAX), self.max.min(CONTROL_MAX).max(self.min.min(CONTROL_MAX)));
908        if !v.is_finite() || v <= min as f64 {
909            return min;
910        }
911        if v >= max as f64 {
912            return max;
913        }
914        let step = self.step.max(1) as f64;
915        let snapped = (min as f64 + ((v - min as f64) / step).round() * step).clamp(min as f64, max as f64);
916        // When the step doesn't divide the range, the maximum is a stop of its own.
917        if (max as f64 - v).abs() < (snapped - v).abs() { max } else { snapped as u32 }
918    }
919}
920
921/// The kinds of floating object a sheet holds. Commands name an object by kind and
922/// workbook-unique id (`{kind: "chart", id}`); [`ObjKind::name`] is that parameter's spelling.
923/// Equations are stored as shapes ([`ShapeKind::Equation`]) but select, open and delete as their
924/// own kind.
925#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
926#[serde(rename_all = "camelCase")]
927pub enum ObjKind {
928    Chart,
929    Image,
930    Shape,
931    Slicer,
932    Equation,
933    Control,
934}
935
936impl ObjKind {
937    pub const ALL: [ObjKind; 6] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Equation, ObjKind::Control];
938
939    /// The name used in command parameters.
940    pub fn name(self) -> &'static str {
941        match self {
942            ObjKind::Chart => "chart",
943            ObjKind::Image => "image",
944            ObjKind::Shape => "shape",
945            ObjKind::Slicer => "slicer",
946            ObjKind::Equation => "equation",
947            ObjKind::Control => "control",
948        }
949    }
950
951    /// The kind named `s` (any letter case).
952    pub fn parse(s: &str) -> Option<ObjKind> {
953        ObjKind::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s))
954    }
955}
956
957/// One floating object on a sheet: its kind and its workbook-unique id.
958#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
959pub struct ObjRef {
960    pub kind: ObjKind,
961    pub id: u32,
962}
963
964impl ObjRef {
965    pub fn new(kind: ObjKind, id: u32) -> ObjRef {
966        ObjRef { kind, id }
967    }
968}
969
970/// Horizontal alignment of a shape's text (`a:pPr algn`).
971#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
972pub enum ShapeTextAlign {
973    #[default]
974    Left,
975    Center,
976    Right,
977}
978
979/// Vertical anchor of a shape's text in its box (`a:bodyPr anchor`).
980#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
981pub enum ShapeTextAnchor {
982    #[default]
983    Top,
984    Middle,
985    Bottom,
986}
987
988/// How a shape's outline and text look. The text properties apply to the whole text (the first
989/// paragraph's and first run's properties when a file has several).
990#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
991#[serde(default)]
992pub struct ShapeFormat {
993    /// No outline (`a:ln/a:noFill`), whatever `Shape::line` says.
994    pub no_line: bool,
995    /// Text size in points (`a:rPr sz` / 100); `None` is the default size.
996    pub font_size: Option<f32>,
997    pub bold: bool,
998    pub italic: bool,
999    /// Typeface (`a:latin typeface`); `None` is the default font.
1000    pub font: Option<String>,
1001    /// Text fill colour; `None` is the default (black in text boxes).
1002    pub text_color: Option<Color>,
1003    /// Text outline (`a:rPr/a:ln`): colour and width in points.
1004    pub text_outline: Option<(Color, f32)>,
1005    /// `None`: the shape's default (left for text boxes, centred for other shapes).
1006    pub align: Option<ShapeTextAlign>,
1007    /// `None`: the shape's default (top for text boxes, middle for other shapes).
1008    pub anchor: Option<ShapeTextAnchor>,
1009    /// Paragraphs run right to left (`a:pPr rtl`).
1010    pub rtl: bool,
1011}
1012
1013#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1014pub enum SparklineKind {
1015    #[default]
1016    Line,
1017    Column,
1018    WinLoss,
1019}
1020
1021#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1022pub struct Sparkline {
1023    pub cell: CellRef,
1024    pub source: String,
1025    pub kind: SparklineKind,
1026    pub color: Color,
1027    pub markers: bool,
1028}
1029
1030// ---------------------------------------------------------------- filter & sort
1031
1032#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1033pub enum FilterCriterion {
1034    /// Show rows whose display text is one of these (case-insensitive); `blanks` includes empty.
1035    Values {
1036        values: Vec<String>,
1037        blanks: bool,
1038    },
1039    /// Custom: one or two comparisons (`op` like `>=`, `=*abc*`), joined by and/or.
1040    Custom {
1041        a: (String, String),
1042        b: Option<(String, String)>,
1043        and: bool,
1044    },
1045    Top10 {
1046        bottom: bool,
1047        percent: bool,
1048        count: u32,
1049    },
1050    AboveAverage(bool),
1051    FillColor(Color),
1052    FontColor(Color),
1053    /// Rows whose cell shows icon `id` (0-based; `None` = no icon) of conditional-format icon set
1054    /// `set` (`iconFilter`). Kept from files; re-applying the filter leaves those rows as they are.
1055    Icon {
1056        set: String,
1057        id: Option<u32>,
1058    },
1059}
1060
1061#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1062pub struct AutoFilter {
1063    pub range: RangeRef,
1064    /// Column offset within `range` → criterion.
1065    pub criteria: Vec<(u32, FilterCriterion)>,
1066}
1067
1068/// A slicer: an on-sheet filter widget bound to one column of a table. Clicking its value tiles
1069/// sets that column's `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applies it,
1070/// so a slicer is just a visual front-end over the existing filter machinery.
1071#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1072pub struct Slicer {
1073    pub id: u32,
1074    /// Name of the table this slicer filters.
1075    pub table: String,
1076    /// Header name of the filtered column within the table.
1077    pub column: String,
1078    /// Caption shown in the slicer header (defaults to the column name).
1079    pub caption: String,
1080    pub anchor: Anchor,
1081    /// Selected display values. `None` means every value (no filter from this slicer); an empty
1082    /// list means nothing is selected (all rows hidden), matching Excel's "clear all buttons".
1083    pub selected: Option<Vec<String>>,
1084    /// Number of button columns in the layout (Excel default 1).
1085    pub columns: u32,
1086}
1087
1088#[cfg(test)]
1089mod trendline_forecast_tests {
1090    use super::Trendline;
1091
1092    #[test]
1093    fn clamp_forecast_bounds_hostile_values() {
1094        assert_eq!(Trendline::clamp_forecast(2.5), 2.5);
1095        assert_eq!(Trendline::clamp_forecast(0.0), 0.0);
1096        assert_eq!(Trendline::clamp_forecast(-4.0), 0.0);
1097        assert_eq!(Trendline::clamp_forecast(f64::NAN), 0.0);
1098        assert_eq!(Trendline::clamp_forecast(f64::INFINITY), 0.0);
1099        assert_eq!(Trendline::clamp_forecast(f64::NEG_INFINITY), 0.0);
1100        assert_eq!(Trendline::clamp_forecast(1e300), Trendline::MAX_FORECAST);
1101    }
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107
1108    #[test]
1109    fn chart_without_hole_size_keeps_the_legacy_hole() {
1110        let chart = Chart {
1111            id: 1,
1112            kind: ChartKind::Doughnut,
1113            anchor: Anchor::default(),
1114            title: None,
1115            series: vec![],
1116            legend: LegendPos::Right,
1117            data_labels: false,
1118            data_table: None,
1119            gridlines: false,
1120            style: 1,
1121            hole_size: 75,
1122            first_slice_angle: 0,
1123            x_title: None,
1124            y_title: None,
1125            source: None,
1126            by_rows: false,
1127            pivot: None,
1128        };
1129        let mut json = serde_json::to_value(&chart).expect("serialize chart");
1130        json.as_object_mut().expect("chart object").remove("hole_size");
1131        let legacy: Chart = serde_json::from_value(json).expect("deserialize legacy chart");
1132        assert_eq!(legacy.hole_size, 50, "documents saved before hole_size existed keep the old 50% hole");
1133        assert_eq!(chart.hole_size, 75, "new charts keep Excel's 75% default");
1134    }
1135}
1136
1137#[cfg(test)]
1138mod equation_cache_tests {
1139    use super::{EquationCache, source_key};
1140
1141    thread_local! {
1142        /// How many sources `source_key` has hashed on this thread.
1143        pub(super) static HASHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1144    }
1145
1146    fn glyphs(c: &EquationCache, src: &str) -> Option<String> {
1147        let list = c.get(src)?.ok()?;
1148        Some(list.items.iter().filter_map(|i| if let crate::math::Item::Glyph(g) = i { Some(g.ch) } else { None }).collect())
1149    }
1150
1151    #[test]
1152    fn a_hash_collision_never_returns_another_sources_layout() {
1153        let (a, b) = ("x^2", "y^3");
1154        let mut c = EquationCache::default();
1155        assert!(c.refresh(a));
1156        assert_eq!(glyphs(&c, a).as_deref(), Some("x2"));
1157        // Forge a collision: the entry claims `b`'s hash; `b` has `a`'s length, so only the
1158        // full comparison tells them apart.
1159        c.key = source_key(b);
1160        assert!(c.get(b).is_none(), "`b` is a miss, not `a`'s layout");
1161        assert!(c.refresh(b), "`b` is laid out afresh");
1162        assert_eq!(glyphs(&c, b).as_deref(), Some("y3"));
1163        assert!(c.get(a).is_none(), "the entry is `b`'s now");
1164        // A refusal does not answer for a colliding source either.
1165        c.refuse(a, "not laid out");
1166        c.key = source_key(b);
1167        assert!(c.get(b).is_none());
1168    }
1169
1170    #[test]
1171    fn sources_over_the_cap_are_not_copied_hashed_or_laid_out() {
1172        let long = "x".repeat(crate::math::MAX_SOURCE_BYTES + 1);
1173        let mut c = EquationCache::default();
1174        HASHES.with(|h| h.set(0));
1175        assert!(matches!(c.get(&long), Some(Err(e)) if e.message.contains("4096")), "answered before any refresh");
1176        assert!(c.refresh(&long));
1177        assert!(c.source.is_none(), "an over-cap source is not kept");
1178        assert_eq!(c.key, 0, "nor hashed");
1179        assert!(matches!(c.get(&long), Some(Err(_))));
1180        assert!(!c.refresh(&long), "still cached");
1181        let other = "y".repeat(crate::math::MAX_SOURCE_BYTES + 1);
1182        assert!(matches!(c.get(&other), Some(Err(_))), "another over-cap source gets the same answer");
1183        assert_eq!(HASHES.with(|h| h.get()), 0, "nothing over the cap is hashed");
1184        // Within the cap the lookup is unchanged: a hash, then the source compared in full.
1185        assert!(c.refresh("x"));
1186        assert!(c.get("x").is_some_and(|r| r.is_ok()));
1187        assert!(HASHES.with(|h| h.get()) >= 2);
1188    }
1189
1190    #[test]
1191    #[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists
1192    fn a_source_of_a_megabyte_is_looked_up_without_being_read() {
1193        // The sheet painter asks the cache every frame and the PDF export every draw; a source
1194        // read from a file can be megabytes, and the lookup must not scan it each time.
1195        let big = "l".repeat(1 << 20);
1196        let mut c = EquationCache::default();
1197        c.refuse(&big, "budget");
1198        HASHES.with(|h| h.set(0));
1199        let start = std::time::Instant::now();
1200        for _ in 0..1000 {
1201            assert!(matches!(c.get(&big), Some(Err(_))));
1202        }
1203        let took = start.elapsed();
1204        assert_eq!(HASHES.with(|h| h.get()), 0, "a thousand lookups read nothing");
1205        assert!(took < std::time::Duration::from_millis(500), "{took:?}");
1206    }
1207}