Files
gridcraft/crates/model/src/features.rs
T

706 lines
18 KiB
Rust

//! Sheet features beyond cells: tables, conditional formatting, data validation, comments,
//! hyperlinks, charts, images, autofilter, print settings.
use gridcraft_core::{CellRef, RangeRef};
use serde::{Deserialize, Serialize};
use crate::style::{Color, Style};
// ---------------------------------------------------------------- tables
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TotalsFn {
#[default]
None,
Average,
Count,
CountNums,
Max,
Min,
Sum,
StdDev,
Var,
Custom,
}
impl TotalsFn {
/// SUBTOTAL function number.
pub fn subtotal_code(&self) -> Option<u32> {
Some(match self {
TotalsFn::Average => 101,
TotalsFn::Count => 103,
TotalsFn::CountNums => 102,
TotalsFn::Max => 104,
TotalsFn::Min => 105,
TotalsFn::Sum => 109,
TotalsFn::StdDev => 107,
TotalsFn::Var => 110,
_ => return None,
})
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TableColumn {
pub name: String,
pub totals: TotalsFn,
/// Totals row label (first column) or custom formula.
pub totals_label: Option<String>,
/// Calculated column formula.
pub formula: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Table {
pub id: u32,
pub name: String,
/// Whole table including header and totals rows.
pub range: RangeRef,
pub header_row: bool,
pub totals_row: bool,
pub columns: Vec<TableColumn>,
pub style: String,
pub banded_rows: bool,
pub banded_cols: bool,
pub first_col: bool,
pub last_col: bool,
pub filter_button: bool,
}
impl Table {
pub fn data_range(&self) -> Option<RangeRef> {
let r0 = self.range.start.row + self.header_row as u32;
let r1 = self.range.end.row.checked_sub(self.totals_row as u32)?;
if r1 < r0 {
return None;
}
Some(RangeRef::new(CellRef::new(r0, self.range.start.col), CellRef::new(r1, self.range.end.col)))
}
pub fn header_range(&self) -> Option<RangeRef> {
self.header_row.then(|| RangeRef::new(self.range.start, CellRef::new(self.range.start.row, self.range.end.col)))
}
pub fn totals_range(&self) -> Option<RangeRef> {
self.totals_row.then(|| RangeRef::new(CellRef::new(self.range.end.row, self.range.start.col), self.range.end))
}
pub fn column_index(&self, name: &str) -> Option<usize> {
self.columns.iter().position(|c| c.name.eq_ignore_ascii_case(name))
}
}
// ---------------------------------------------------------------- conditional formatting
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CfOperator {
Between,
NotBetween,
Equal,
NotEqual,
Greater,
Less,
GreaterOrEqual,
LessOrEqual,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum CfValueKind {
Min,
Max,
Number(f64),
Percent(f64),
Percentile(f64),
Formula(String),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum CfRule {
/// Cell value compared with one or two formulas.
CellIs {
op: CfOperator,
a: String,
b: Option<String>,
style: Box<Style>,
},
/// Custom formula (relative to the top-left cell of the range).
Expression {
formula: String,
style: Box<Style>,
},
ContainsText {
text: String,
style: Box<Style>,
},
NotContainsText {
text: String,
style: Box<Style>,
},
BeginsWith {
text: String,
style: Box<Style>,
},
EndsWith {
text: String,
style: Box<Style>,
},
Blanks {
style: Box<Style>,
},
NoBlanks {
style: Box<Style>,
},
Errors {
style: Box<Style>,
},
NoErrors {
style: Box<Style>,
},
Duplicate {
style: Box<Style>,
},
Unique {
style: Box<Style>,
},
/// Top/bottom N or N percent.
Top10 {
bottom: bool,
percent: bool,
rank: u32,
style: Box<Style>,
},
AboveAverage {
below: bool,
equal: bool,
std_dev: u8,
style: Box<Style>,
},
/// `yesterday`, `today`, `tomorrow`, `last7Days`, `lastWeek`, `thisWeek`, `nextWeek`, `lastMonth`, `thisMonth`, `nextMonth`.
TimePeriod {
period: String,
style: Box<Style>,
},
ColorScale {
stops: Vec<(CfValueKind, Color)>,
},
DataBar {
min: CfValueKind,
max: CfValueKind,
color: Color,
gradient: bool,
show_value: bool,
},
/// Icon set name (`3Arrows`, `3TrafficLights1`, `4Rating`, `5Quarters`…).
IconSet {
set: String,
thresholds: Vec<CfValueKind>,
reverse: bool,
show_value: bool,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CondFormat {
pub ranges: Vec<RangeRef>,
pub rule: CfRule,
pub priority: u32,
pub stop_if_true: bool,
}
// ---------------------------------------------------------------- data validation
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ValidationKind {
#[default]
Any,
Whole,
Decimal,
List,
Date,
Time,
TextLength,
Custom,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ErrorStyle {
#[default]
Stop,
Warning,
Information,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Validation {
pub ranges: Vec<RangeRef>,
pub kind: ValidationKind,
pub op: CfOperator,
/// Formula 1 (list source: `"a,b,c"` literal list or `=$A$1:$A$5`).
pub f1: String,
pub f2: Option<String>,
pub allow_blank: bool,
pub in_cell_dropdown: bool,
pub input_title: String,
pub input_message: String,
pub show_input: bool,
pub error_title: String,
pub error_message: String,
pub error_style: ErrorStyle,
pub show_error: bool,
}
impl Default for Validation {
fn default() -> Self {
Validation {
ranges: vec![],
kind: ValidationKind::Any,
op: CfOperator::Between,
f1: String::new(),
f2: None,
allow_blank: true,
in_cell_dropdown: true,
input_title: String::new(),
input_message: String::new(),
show_input: true,
error_title: String::new(),
error_message: String::new(),
error_style: ErrorStyle::Stop,
show_error: true,
}
}
}
// ---------------------------------------------------------------- comments, links
/// A note (classic comment) or a threaded comment.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Comment {
pub author: String,
pub text: String,
/// Replies of a threaded comment (author, text).
pub replies: Vec<(String, String)>,
pub threaded: bool,
pub resolved: bool,
pub visible: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Hyperlink {
/// URL, `mailto:`, or an in-workbook location like `Sheet2!A1`.
pub target: String,
pub tooltip: Option<String>,
}
// ---------------------------------------------------------------- drawing objects
/// Position of a floating object: top-left cell plus offset, size in points.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Anchor {
pub cell: CellRef,
pub dx: f32,
pub dy: f32,
pub width: f32,
pub height: f32,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ChartKind {
#[default]
ColumnClustered,
ColumnStacked,
ColumnStacked100,
BarClustered,
BarStacked,
BarStacked100,
Line,
LineMarkers,
LineStacked,
Pie,
Doughnut,
Area,
AreaStacked,
Scatter,
ScatterLines,
Bubble,
Radar,
Histogram,
Waterfall,
Funnel,
Treemap,
Sunburst,
BoxWhisker,
Stock,
Combo,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum LegendPos {
None,
#[default]
Bottom,
Top,
Left,
Right,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Series {
/// Formula text for the series name (`Sheet1!$B$1`) or a literal.
pub name: Option<String>,
pub categories: Option<String>,
pub values: String,
/// X values for scatter / bubble sizes.
pub bubble_sizes: Option<String>,
pub color: Option<Color>,
/// Combo charts: this series drawn as a line on the secondary axis.
pub secondary: bool,
pub kind: Option<ChartKind>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Chart {
pub id: u32,
pub kind: ChartKind,
pub anchor: Anchor,
pub title: Option<String>,
pub series: Vec<Series>,
pub legend: LegendPos,
pub data_labels: bool,
pub gridlines: bool,
pub style: u32,
pub x_title: Option<String>,
pub y_title: Option<String>,
/// Source range when created from a selection (for Switch Row/Column).
pub source: Option<String>,
pub by_rows: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Image {
pub id: u32,
pub anchor: Anchor,
/// Encoded bytes (PNG/JPEG), base64 in JSON.
#[serde(with = "crate::b64")]
pub data: Vec<u8>,
pub mime: String,
pub alt: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ShapeKind {
#[default]
Rectangle,
RoundedRectangle,
Ellipse,
Triangle,
Line,
Arrow,
TextBox,
/// An icon from GridCraft's own icon set; `Shape::text` holds the icon name.
Icon,
/// A freehand ink stroke; `Shape::text` holds the points as `x,y;x,y;…` relative to the
/// anchor (sheet points), `Shape::line` the ink colour and `fill` unused.
Ink,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Shape {
pub id: u32,
pub kind: ShapeKind,
pub anchor: Anchor,
pub fill: Color,
pub line: Color,
pub text: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SparklineKind {
#[default]
Line,
Column,
WinLoss,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Sparkline {
pub cell: CellRef,
pub source: String,
pub kind: SparklineKind,
pub color: Color,
pub markers: bool,
}
// ---------------------------------------------------------------- filter & sort
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum FilterCriterion {
/// Show rows whose display text is one of these (case-insensitive); `blanks` includes empty.
Values {
values: Vec<String>,
blanks: bool,
},
/// Custom: one or two comparisons (`op` like `>=`, `=*abc*`), joined by and/or.
Custom {
a: (String, String),
b: Option<(String, String)>,
and: bool,
},
Top10 {
bottom: bool,
percent: bool,
count: u32,
},
AboveAverage(bool),
FillColor(Color),
FontColor(Color),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AutoFilter {
pub range: RangeRef,
/// Column offset within `range` → criterion.
pub criteria: Vec<(u32, FilterCriterion)>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Orientation {
#[default]
Portrait,
Landscape,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PrintSettings {
pub orientation: Orientation,
/// Paper size name (`Letter`, `A4`, `Legal`…).
pub paper: String,
/// Margins in inches: left, right, top, bottom, header, footer.
pub margins: [f32; 6],
pub print_area: Option<RangeRef>,
pub title_rows: Option<(u32, u32)>,
pub title_cols: Option<(u32, u32)>,
/// Percent scale, or fit to pages (wide, tall).
pub scale: u16,
pub fit_to: Option<(u16, u16)>,
pub gridlines: bool,
pub headings: bool,
pub center_h: bool,
pub center_v: bool,
pub header: String,
pub footer: String,
pub row_breaks: Vec<u32>,
pub col_breaks: Vec<u32>,
}
impl Default for PrintSettings {
fn default() -> Self {
PrintSettings {
orientation: Orientation::Portrait,
paper: "Letter".into(),
margins: [0.7, 0.7, 0.75, 0.75, 0.3, 0.3],
print_area: None,
title_rows: None,
title_cols: None,
scale: 100,
fit_to: None,
gridlines: false,
headings: false,
center_h: false,
center_v: false,
header: String::new(),
footer: String::new(),
row_breaks: vec![],
col_breaks: vec![],
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SheetProtection {
/// Hash of the password (our own salted FNV; never the password itself).
pub password_hash: Option<u64>,
pub select_locked: bool,
pub select_unlocked: bool,
pub format_cells: bool,
pub format_columns: bool,
pub format_rows: bool,
pub insert_columns: bool,
pub insert_rows: bool,
pub delete_columns: bool,
pub delete_rows: bool,
pub sort: bool,
pub autofilter: bool,
}
/// Simple non-cryptographic password hash for sheet protection (like Excel's, protection is a
/// convenience, not security).
pub fn password_hash(pw: &str) -> u64 {
let mut h: u64 = 0xcbf29ce484222325 ^ 0x5348_4545_5443;
for b in pw.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
/// Outline grouping info for a row or column.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct LineInfo {
/// Size in points; `None` = default (rows auto-fit to content).
pub size: Option<f32>,
pub hidden: bool,
pub outline: u8,
pub collapsed: bool,
pub style: Option<crate::style::StyleId>,
/// The size was set by the user (rows): automatic heights follow the content.
#[serde(default)]
pub custom: bool,
}
// ---------------------------------------------------------------- pivot tables
/// How a PivotTable lays out its row fields.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum PivotLayout {
/// All row fields in one column, inner items indented ("Row Labels" header).
#[default]
Compact,
/// One column per row field; each item on its own row.
Outline,
/// One column per row field; inner items start on their parent's row, totals below.
Tabular,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum PivotSort {
#[default]
Asc,
Desc,
/// Source order (first appearance).
None,
}
/// Grouping of a date field (values are date serials).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum PivotDateGroup {
#[default]
None,
Years,
Quarters,
Months,
Days,
}
/// A field placed in the Rows or Columns area.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PivotField {
/// Source column header name.
pub source_col: String,
pub sort: PivotSort,
pub date_group: PivotDateGroup,
/// Item labels whose detail is hidden (collapsed).
pub collapsed_items: Vec<String>,
}
/// Summarize-values-by function.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum PivotFunc {
#[default]
Sum,
Count,
Average,
Max,
Min,
Product,
CountNumbers,
StdDev,
StdDevP,
Var,
VarP,
}
/// Show-values-as transform.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum PivotShowAs {
#[default]
Normal,
PercentOfGrandTotal,
PercentOfColumnTotal,
PercentOfRowTotal,
/// Running total in the first row field.
RunningTotal,
/// Rank largest to smallest among sibling row items.
Rank,
}
/// A field in the Values area.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PivotValue {
pub source_col: String,
pub func: PivotFunc,
/// Caption, e.g. "Sum of Sales".
pub name: String,
pub show_as: PivotShowAs,
pub number_format: Option<String>,
}
/// A report filter (or, for a field also in Rows/Columns, its hidden-items filter).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PivotFilter {
pub source_col: String,
/// Item labels shown; `None` = all.
pub selected: Option<Vec<String>>,
}
/// A PivotTable: its definition plus where its output was last written. The output itself is
/// static cell values and styles on the sheet, rewritten on every refresh.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PivotTable {
pub id: u32,
pub name: String,
/// Source: range formula text (`Sheet1!$A$1:$E$200`), a table name or a defined name.
pub source: String,
/// Top-left of the output body on the pivot's sheet.
pub anchor: CellRef,
pub rows: Vec<PivotField>,
pub columns: Vec<PivotField>,
pub values: Vec<PivotValue>,
pub filters: Vec<PivotFilter>,
pub layout: PivotLayout,
/// Grand total for each row (the "Grand Total" column), like Excel's "On for Rows".
pub grand_totals_rows: bool,
/// Grand total for each column (the "Grand Total" row), like Excel's "On for Columns".
pub grand_totals_cols: bool,
pub subtotals: bool,
/// Subtotals at the top of each group (Compact/Outline); otherwise below it.
pub subtotals_top: bool,
pub style: String,
pub show_headers: bool,
/// Area written by the last refresh.
pub last_range: Option<RangeRef>,
}
impl Default for PivotTable {
fn default() -> Self {
PivotTable {
id: 0,
name: "PivotTable1".into(),
source: String::new(),
anchor: CellRef::default(),
rows: vec![],
columns: vec![],
values: vec![],
filters: vec![],
layout: PivotLayout::Compact,
grand_totals_rows: true,
grand_totals_cols: true,
subtotals: true,
subtotals_top: true,
style: "PivotStyleLight16".into(),
show_headers: true,
last_range: None,
}
}
}