mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 11:46:49 -06:00
943 lines
28 KiB
Rust
943 lines
28 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,
|
||
},
|
||
}
|
||
|
||
/// Number of icons in an icon set (`3Arrows` → 3, `5Quarters` → 5), always 3 to 5.
|
||
pub fn icon_count(set: &str) -> usize {
|
||
set.chars().next().and_then(|c| c.to_digit(10)).unwrap_or(3).clamp(3, 5) as usize
|
||
}
|
||
|
||
/// Excel's default icon-set thresholds: one per icon, the first being the lower bound,
|
||
/// then even percent steps (`0, 33, 67` for three icons).
|
||
pub fn default_icon_thresholds(set: &str) -> Vec<CfValueKind> {
|
||
let k = icon_count(set);
|
||
(0..k).map(|i| CfValueKind::Percent((i as f64 * 100.0 / k as f64).round())).collect()
|
||
}
|
||
|
||
#[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,
|
||
/// Threaded comments: when the thread (first entry) and each reply were written, ISO 8601 as
|
||
/// read from the file (`dT`); empty when unknown, e.g. for comments made here.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub dates: Vec<String>,
|
||
}
|
||
|
||
#[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
|
||
|
||
/// How a floating object is pinned to the grid when its cells move or resize. Mirrors Excel's
|
||
/// Format ▸ Size and Properties ▸ Properties, stored in the file as the anchor's `editAs`:
|
||
/// `twoCell` (absent `editAs`), `oneCell` and `absolute`.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
pub enum AnchorMode {
|
||
/// Move and size with cells (Excel's default; `<xdr:twoCellAnchor>`).
|
||
#[default]
|
||
MoveAndSize,
|
||
/// Move but don't size with cells (`<xdr:twoCellAnchor editAs="oneCell">` / `<xdr:oneCellAnchor>`).
|
||
MoveOnly,
|
||
/// Don't move or size with cells (`editAs="absolute"` / `<xdr:absoluteAnchor>`).
|
||
Absolute,
|
||
}
|
||
|
||
/// 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,
|
||
/// How the object follows its anchor cell when rows/columns move (sort, insert, delete).
|
||
#[serde(default)]
|
||
pub mode: AnchorMode,
|
||
}
|
||
|
||
#[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, Default, 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>,
|
||
/// Draw line segments as a smoothed (spline) curve.
|
||
#[serde(default)]
|
||
pub smooth: bool,
|
||
}
|
||
|
||
#[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,
|
||
/// A PivotChart's PivotTable (`Sheet2!PivotTable1`): its series are rebuilt from the report
|
||
/// whenever the PivotTable is refreshed or its layout changes.
|
||
#[serde(default)]
|
||
pub pivot: Option<String>,
|
||
}
|
||
|
||
#[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,
|
||
/// Outline visibility and text formatting (DrawingML `a:ln`, `a:bodyPr`, `a:pPr`, `a:rPr`).
|
||
#[serde(default)]
|
||
pub format: ShapeFormat,
|
||
}
|
||
|
||
/// Horizontal alignment of a shape's text (`a:pPr algn`).
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum ShapeTextAlign {
|
||
#[default]
|
||
Left,
|
||
Center,
|
||
Right,
|
||
}
|
||
|
||
/// Vertical anchor of a shape's text in its box (`a:bodyPr anchor`).
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum ShapeTextAnchor {
|
||
#[default]
|
||
Top,
|
||
Middle,
|
||
Bottom,
|
||
}
|
||
|
||
/// How a shape's outline and text look. The text properties apply to the whole text (the first
|
||
/// run's properties when a file has several).
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct ShapeFormat {
|
||
/// No outline (`a:ln/a:noFill`), whatever `Shape::line` says.
|
||
pub no_line: bool,
|
||
/// Text size in points (`a:rPr sz` / 100); `None` is the default size.
|
||
pub font_size: Option<f32>,
|
||
pub bold: bool,
|
||
pub italic: bool,
|
||
/// Typeface (`a:latin typeface`); `None` is the default font.
|
||
pub font: Option<String>,
|
||
/// Text fill colour; `None` is the default (black in text boxes).
|
||
pub text_color: Option<Color>,
|
||
/// Text outline (`a:rPr/a:ln`): colour and width in points.
|
||
pub text_outline: Option<(Color, f32)>,
|
||
/// `None`: the shape's default (left for text boxes, centred for other shapes).
|
||
pub align: Option<ShapeTextAlign>,
|
||
/// `None`: the shape's default (top for text boxes, middle for other shapes).
|
||
pub anchor: Option<ShapeTextAnchor>,
|
||
}
|
||
|
||
#[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)>,
|
||
}
|
||
|
||
/// A slicer: an on-sheet filter widget bound to one column of a table. Clicking its value tiles
|
||
/// sets that column's `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applies it,
|
||
/// so a slicer is just a visual front-end over the existing filter machinery.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct Slicer {
|
||
pub id: u32,
|
||
/// Name of the table this slicer filters.
|
||
pub table: String,
|
||
/// Header name of the filtered column within the table.
|
||
pub column: String,
|
||
/// Caption shown in the slicer header (defaults to the column name).
|
||
pub caption: String,
|
||
pub anchor: Anchor,
|
||
/// Selected display values. `None` means every value (no filter from this slicer); an empty
|
||
/// list means nothing is selected (all rows hidden), matching Excel's "clear all buttons".
|
||
pub selected: Option<Vec<String>>,
|
||
/// Number of button columns in the layout (Excel default 1).
|
||
pub columns: u32,
|
||
}
|
||
|
||
#[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,
|
||
/// The header and footer of odd pages, and of every page unless the variants below apply.
|
||
pub header: String,
|
||
pub footer: String,
|
||
pub row_breaks: Vec<u32>,
|
||
pub col_breaks: Vec<u32>,
|
||
/// Different First Page: the sheet's first printed page uses `first_header`/`first_footer`.
|
||
#[serde(default)]
|
||
pub different_first: bool,
|
||
/// Different Odd & Even Pages: even-numbered pages use `even_header`/`even_footer`.
|
||
#[serde(default)]
|
||
pub different_odd_even: bool,
|
||
#[serde(default)]
|
||
pub first_header: String,
|
||
#[serde(default)]
|
||
pub first_footer: String,
|
||
#[serde(default)]
|
||
pub even_header: String,
|
||
#[serde(default)]
|
||
pub even_footer: String,
|
||
/// Scale with Document / Align with Page Margins (both on unless a file turned them off).
|
||
#[serde(default = "yes")]
|
||
pub hf_scale_with_doc: bool,
|
||
#[serde(default = "yes")]
|
||
pub hf_align_with_margins: bool,
|
||
}
|
||
|
||
fn yes() -> bool {
|
||
true
|
||
}
|
||
|
||
/// Which header/footer pair a printed page uses.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum HfPage {
|
||
Odd,
|
||
Even,
|
||
First,
|
||
}
|
||
|
||
impl HfPage {
|
||
pub fn id(self) -> &'static str {
|
||
match self {
|
||
HfPage::Odd => "odd",
|
||
HfPage::Even => "even",
|
||
HfPage::First => "first",
|
||
}
|
||
}
|
||
pub fn parse(id: &str) -> Option<HfPage> {
|
||
match id {
|
||
"odd" | "all" => Some(HfPage::Odd),
|
||
"even" => Some(HfPage::Even),
|
||
"first" => Some(HfPage::First),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl PrintSettings {
|
||
/// The pair used by page `number` (1-based, as `&P` prints it); `first` is the sheet's first page.
|
||
pub fn hf_page(&self, first: bool, number: usize) -> HfPage {
|
||
if self.different_first && first {
|
||
HfPage::First
|
||
} else if self.different_odd_even && number.is_multiple_of(2) {
|
||
HfPage::Even
|
||
} else {
|
||
HfPage::Odd
|
||
}
|
||
}
|
||
/// The header (`footer` false) or footer of a page kind.
|
||
pub fn hf(&self, page: HfPage, footer: bool) -> &String {
|
||
match (page, footer) {
|
||
(HfPage::Odd, false) => &self.header,
|
||
(HfPage::Odd, true) => &self.footer,
|
||
(HfPage::Even, false) => &self.even_header,
|
||
(HfPage::Even, true) => &self.even_footer,
|
||
(HfPage::First, false) => &self.first_header,
|
||
(HfPage::First, true) => &self.first_footer,
|
||
}
|
||
}
|
||
pub fn hf_mut(&mut self, page: HfPage, footer: bool) -> &mut String {
|
||
match (page, footer) {
|
||
(HfPage::Odd, false) => &mut self.header,
|
||
(HfPage::Odd, true) => &mut self.footer,
|
||
(HfPage::Even, false) => &mut self.even_header,
|
||
(HfPage::Even, true) => &mut self.even_footer,
|
||
(HfPage::First, false) => &mut self.first_header,
|
||
(HfPage::First, true) => &mut self.first_footer,
|
||
}
|
||
}
|
||
/// Any header or footer text at all (on any kind of page).
|
||
pub fn has_header_footer(&self) -> bool {
|
||
[&self.header, &self.footer, &self.first_header, &self.first_footer, &self.even_header, &self.even_footer].iter().any(|t| !t.is_empty())
|
||
}
|
||
}
|
||
|
||
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![],
|
||
different_first: false,
|
||
different_odd_even: false,
|
||
first_header: String::new(),
|
||
first_footer: String::new(),
|
||
even_header: String::new(),
|
||
even_footer: String::new(),
|
||
hf_scale_with_doc: true,
|
||
hf_align_with_margins: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
pub struct SheetProtection {
|
||
/// Hash of the password (Excel-compatible, written to XLSX `sheetProtection`; never the
|
||
/// password itself).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub password: Option<crate::PasswordHash>,
|
||
/// The internal hash older GridCraft JSON workbooks stored ([`password_hash`]); still
|
||
/// verified when unprotecting, never written for new passwords.
|
||
#[serde(rename = "password_hash", default, skip_serializing_if = "Option::is_none")]
|
||
pub legacy_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,
|
||
}
|
||
|
||
/// Review › Allow Edit Ranges: cells that stay editable on a protected sheet, with or without a
|
||
/// password (XLSX `<protectedRange>`).
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct EditRange {
|
||
pub title: String,
|
||
pub ranges: Vec<RangeRef>,
|
||
pub password: Option<crate::PasswordHash>,
|
||
/// Windows permissions (`securityDescriptor`), kept as read from the file.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub security_descriptor: Option<String>,
|
||
}
|
||
|
||
impl EditRange {
|
||
pub fn contains(&self, c: CellRef) -> bool {
|
||
self.ranges.iter().any(|r| r.contains(c))
|
||
}
|
||
}
|
||
|
||
/// The non-cryptographic password hash older GridCraft versions stored for sheet protection
|
||
/// (only verified now, see [`SheetProtection::legacy_password_hash`]).
|
||
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>,
|
||
/// Calculated fields, usable in the Values area by name.
|
||
pub calculated_fields: Vec<PivotCalcField>,
|
||
}
|
||
|
||
/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in
|
||
/// Excel, it is evaluated on the sums of the fields it names for each report cell, not per record.
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct PivotCalcField {
|
||
pub name: String,
|
||
/// Formula text, starting with `=`.
|
||
pub formula: String,
|
||
}
|
||
|
||
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,
|
||
calculated_fields: vec![],
|
||
}
|
||
}
|
||
}
|