mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Conflicts: - crates/model/src/lib.rs, crates/xlsx/src/read.rs, crates/xlsx/src/write.rs: use lists unioned (ShowObjects and the phonetic types) - docs/file-format-parity.md: main's header and rows kept (#1692 collapsed the three duplicate .xlsx rows; they stay), phonetic-runs text added to each; Last updated 2026-10-20
407 lines
16 KiB
Rust
407 lines
16 KiB
Rust
//! Workbooks.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use gridcraft_core::DateSystem;
|
||
use gridcraft_formula::Expr;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::cell::Formula;
|
||
use crate::sheet::Sheet;
|
||
use crate::style::{StyleTable, Theme};
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum CalcMode {
|
||
#[default]
|
||
Automatic,
|
||
AutomaticExceptTables,
|
||
Manual,
|
||
}
|
||
|
||
/// Options › Advanced › "For objects, show" (`<workbookPr showObjects>`, ECMA-376 `ST_Objects`):
|
||
/// whether charts, pictures, shapes and slicers are drawn. Ctrl+6 switches between `All` and
|
||
/// `None`; `Placeholders` only comes from files and is kept as it is.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum ShowObjects {
|
||
#[default]
|
||
All,
|
||
Placeholders,
|
||
None,
|
||
}
|
||
|
||
/// File › Options › Formulas › Calculation options, stored per workbook (`<calcPr>`).
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct CalcSettings {
|
||
pub mode: CalcMode,
|
||
/// Enable iterative calculation: circular references are calculated repeatedly instead of
|
||
/// reporting an error.
|
||
pub iterative: bool,
|
||
/// Maximum Iterations, 1 to [`CalcSettings::MAX_ITERATIONS`].
|
||
pub max_iterations: u32,
|
||
/// Maximum Change: iteration stops once no value in a circular reference changes by more.
|
||
pub max_change: f64,
|
||
/// Use precision as displayed.
|
||
pub precision_as_displayed: bool,
|
||
}
|
||
|
||
impl Default for CalcSettings {
|
||
fn default() -> Self {
|
||
CalcSettings {
|
||
mode: CalcMode::Automatic,
|
||
iterative: false,
|
||
max_iterations: Self::DEFAULT_ITERATIONS,
|
||
max_change: Self::DEFAULT_CHANGE,
|
||
precision_as_displayed: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl CalcSettings {
|
||
pub const DEFAULT_ITERATIONS: u32 = 100;
|
||
pub const DEFAULT_CHANGE: f64 = 0.001;
|
||
/// The largest Maximum Iterations Excel accepts.
|
||
pub const MAX_ITERATIONS: u32 = 32_767;
|
||
|
||
/// Sets Maximum Iterations, kept within 1 to [`CalcSettings::MAX_ITERATIONS`].
|
||
pub fn set_max_iterations(&mut self, n: u32) {
|
||
self.max_iterations = n.clamp(1, Self::MAX_ITERATIONS);
|
||
}
|
||
|
||
/// Sets Maximum Change (its magnitude); a value that isn't finite is ignored.
|
||
pub fn set_max_change(&mut self, d: f64) {
|
||
if d.is_finite() {
|
||
self.max_change = d.abs();
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct DefinedName {
|
||
pub name: String,
|
||
/// `None` = workbook scope; otherwise the sheet index.
|
||
pub scope: Option<usize>,
|
||
/// Formula text without `=` (`Sheet1!$A$1:$A$10`, `0.07`, `LAMBDA(x,x*2)`).
|
||
pub formula: String,
|
||
pub comment: String,
|
||
pub hidden: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
pub struct DocProps {
|
||
pub title: String,
|
||
pub subject: String,
|
||
pub author: String,
|
||
pub company: String,
|
||
pub keywords: String,
|
||
pub description: String,
|
||
}
|
||
|
||
/// One phonetic (furigana) run of a string item (`<rPh>`): the reading `text` and the range of the
|
||
/// base text it annotates.
|
||
///
|
||
/// `start` and `end` are zero-based offsets into the base text (`sb` and `eb` in the file) with
|
||
/// `start < end`; reading keeps them within the text's length in UTF-16 code units. Behaviour
|
||
/// reference: ISO/IEC 29500-1 §18.4.6 `rPh` and the Microsoft Learn `PhoneticRun` class.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PhoneticRun {
|
||
pub start: u32,
|
||
pub end: u32,
|
||
pub text: String,
|
||
}
|
||
|
||
/// The `<phoneticPr>` of a string item: how its phonetic runs are displayed. `kind` (`type`) and
|
||
/// `alignment` only ever hold values ISO/IEC 29500-1 §18.18.57 `ST_PhoneticType` / §18.18.56
|
||
/// `ST_PhoneticAlignment` define, so damaged files cannot be written back invalid.
|
||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PhoneticProps {
|
||
/// `fontId`: a required attribute, an index into the style sheet's fonts. A file without one
|
||
/// gets 0 — the Normal style's font, which an out-of-range index falls back to anyway.
|
||
#[serde(default)]
|
||
pub font_id: u32,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub kind: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub alignment: Option<String>,
|
||
}
|
||
|
||
/// The phonetic (furigana) data of one shared string: its `<rPh>` runs, in document order, and its
|
||
/// `<phoneticPr>` when the file had one.
|
||
///
|
||
/// `text` is the string's base text — the only handle a cell keeps on its shared string — and is
|
||
/// the key the writer uses to re-emit the runs next to the right `<si>`.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PhoneticString {
|
||
pub text: String,
|
||
pub runs: Vec<PhoneticRun>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub props: Option<PhoneticProps>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct Workbook {
|
||
pub sheets: Vec<Arc<Sheet>>,
|
||
pub names: Vec<DefinedName>,
|
||
pub styles: StyleTable,
|
||
pub theme: Theme,
|
||
pub date_system: DateSystem,
|
||
pub calc: CalcSettings,
|
||
#[serde(default, skip_serializing_if = "is_all")]
|
||
pub show_objects: ShowObjects,
|
||
pub active_sheet: usize,
|
||
pub props: DocProps,
|
||
/// Workbook structure protection.
|
||
pub protected_structure: bool,
|
||
/// Password of the structure protection (XLSX `workbookProtection`), never the password.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub structure_password: Option<crate::PasswordHash>,
|
||
/// Custom fill lists (Options › Custom Lists) beyond the built-in ones.
|
||
pub custom_lists: Vec<Vec<String>>,
|
||
/// Named cell styles (Cell Styles gallery): name → style.
|
||
pub cell_styles: Vec<(String, crate::style::Style)>,
|
||
/// Phonetic (furigana) data of shared strings (`xl/sharedStrings.xml`), one entry per string
|
||
/// text, so that an open + save keeps a Japanese workbook's readings. Written back next to the
|
||
/// `<si>` holding the same text.
|
||
///
|
||
/// Keyed by text because that is all a cell keeps of its shared string: the saved table holds
|
||
/// each text once (the format's `uniqueCount`), so two `<si>` items that share a base text can
|
||
/// only keep one reading — the reader keeps the first and warns. Empty for workbooks without
|
||
/// phonetic data; not present in older JSON. Modelled data (unlike [`Workbook::preserved`],
|
||
/// which holds unmodelled parts), so it is saved to JSON as well as to XLSX.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub phonetics: Vec<PhoneticString>,
|
||
/// VBA project (`xl/vbaProject.bin`) carried through unchanged. GridCraft does not run
|
||
/// macros; the blob is preserved so that saving a macro-enabled workbook never destroys the
|
||
/// macros it arrived with. base64 in JSON. `None` = the workbook has no macros. Shared, so a
|
||
/// copy of the workbook (every edit's undo step) doesn't copy it.
|
||
#[serde(default, with = "crate::b64::opt")]
|
||
pub vba_project: Option<Arc<Vec<u8>>>,
|
||
/// Field separator of the CSV this workbook was read from, so saving it as CSV writes the same
|
||
/// separator back (a `;` file stays a `;` file). `None` = not read from CSV: CSV saves use `,`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub csv_delimiter: Option<u8>,
|
||
/// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
|
||
#[serde(skip)]
|
||
pub preserved: Option<Arc<crate::preserved::Preserved>>,
|
||
/// The folder the workbook was read from, which relative paths to linked workbooks in
|
||
/// `preserved` are relative to. Saving into another folder rewrites them from there. Not in JSON.
|
||
#[serde(skip)]
|
||
pub source_dir: Option<std::path::PathBuf>,
|
||
/// The file this workbook reports as its own, which is what `CELL("filename")` returns.
|
||
/// `None` = no file yet, which Excel reports as an empty string until the workbook has been
|
||
/// saved once. Deliberately separate from `source_dir`: that one names the folder relative
|
||
/// links are relative to and must keep naming it after a Save As into another folder, since
|
||
/// the links in memory are still written for the old one. Not in JSON.
|
||
#[serde(skip)]
|
||
pub source_path: Option<std::path::PathBuf>,
|
||
}
|
||
|
||
fn is_all(s: &ShowObjects) -> bool {
|
||
*s == ShowObjects::All
|
||
}
|
||
|
||
impl Default for Workbook {
|
||
fn default() -> Self {
|
||
Workbook::new()
|
||
}
|
||
}
|
||
|
||
impl Workbook {
|
||
/// A new workbook with one sheet, "Sheet1".
|
||
pub fn new() -> Workbook {
|
||
Workbook {
|
||
sheets: vec![Arc::new(Sheet::new("Sheet1"))],
|
||
names: vec![],
|
||
styles: StyleTable::default(),
|
||
theme: Theme::default(),
|
||
date_system: DateSystem::D1900,
|
||
calc: CalcSettings::default(),
|
||
show_objects: ShowObjects::All,
|
||
active_sheet: 0,
|
||
props: DocProps::default(),
|
||
protected_structure: false,
|
||
structure_password: None,
|
||
custom_lists: vec![],
|
||
cell_styles: vec![],
|
||
phonetics: vec![],
|
||
vba_project: None,
|
||
csv_delimiter: None,
|
||
preserved: None,
|
||
source_dir: None,
|
||
source_path: None,
|
||
}
|
||
}
|
||
pub fn sheet(&self, i: usize) -> Option<&Sheet> {
|
||
self.sheets.get(i).map(|s| s.as_ref())
|
||
}
|
||
pub fn sheet_mut(&mut self, i: usize) -> Option<&mut Sheet> {
|
||
self.sheets.get_mut(i).map(Arc::make_mut)
|
||
}
|
||
pub fn sheet_index(&self, name: &str) -> Option<usize> {
|
||
self.sheets.iter().position(|s| s.name.eq_ignore_ascii_case(name))
|
||
}
|
||
pub fn active(&self) -> Option<&Sheet> {
|
||
self.sheet(self.active_sheet)
|
||
}
|
||
/// A fresh sheet name `SheetN` not in use.
|
||
pub fn next_sheet_name(&self) -> String {
|
||
(1..).map(|n| format!("Sheet{n}")).find(|n| self.sheet_index(n).is_none()).unwrap_or_else(|| "Sheet".into())
|
||
}
|
||
/// Validates a sheet name like Excel: 1–31 chars, none of `: \ / ? * [ ]`, not starting or
|
||
/// ending with `'`, unique (case-insensitive) unless it is `except`'s own name.
|
||
pub fn check_sheet_name(&self, name: &str, except: Option<usize>) -> Result<(), String> {
|
||
let n = name.chars().count();
|
||
if n == 0 {
|
||
return Err("A sheet name can't be blank.".into());
|
||
}
|
||
if n > 31 {
|
||
return Err("A sheet name can't be longer than 31 characters.".into());
|
||
}
|
||
if name.chars().any(|c| matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')) {
|
||
return Err("A sheet name can't contain any of these characters: : \\ / ? * [ ]".into());
|
||
}
|
||
if name.starts_with('\'') || name.ends_with('\'') {
|
||
return Err("A sheet name can't begin or end with an apostrophe.".into());
|
||
}
|
||
if name.eq_ignore_ascii_case("History") {
|
||
return Err("\"History\" is a reserved name.".into());
|
||
}
|
||
if let Some(i) = self.sheet_index(name)
|
||
&& Some(i) != except
|
||
{
|
||
return Err("That name is already taken. Try a different one.".into());
|
||
}
|
||
Ok(())
|
||
}
|
||
/// Finds a defined name visible from `sheet`: sheet scope first, then workbook scope.
|
||
pub fn name(&self, name: &str, sheet: usize) -> Option<&DefinedName> {
|
||
self.names
|
||
.iter()
|
||
.find(|n| n.scope == Some(sheet) && n.name.eq_ignore_ascii_case(name))
|
||
.or_else(|| self.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(name)))
|
||
}
|
||
/// Spells calls of LAMBDA names the way the names are defined (`Double(4)`, where the
|
||
/// parser gives `DOUBLE(4)`: it upper-cases function names, as built-in ones are shown).
|
||
pub fn name_call_case(&self, e: Expr) -> Expr {
|
||
spell_calls(e, &self.lambda_names())
|
||
}
|
||
/// [`Workbook::name_call_case`] for every cell formula and defined name (after reading a
|
||
/// file).
|
||
pub fn apply_name_call_case(&mut self) {
|
||
let lambdas = self.lambda_names();
|
||
if lambdas.is_empty() {
|
||
return;
|
||
}
|
||
for sheet in self.sheets.iter_mut() {
|
||
let fixed: Vec<_> = sheet
|
||
.cells
|
||
.iter()
|
||
.filter_map(|(c, cell)| {
|
||
let f = cell.formula.as_ref()?;
|
||
let e = f.expr()?;
|
||
let ne = spell_calls(e.clone(), &lambdas);
|
||
(ne != e).then(|| (c, Formula { array: f.array, ..Formula::from_expr(ne) }))
|
||
})
|
||
.collect();
|
||
if fixed.is_empty() {
|
||
continue;
|
||
}
|
||
let sheet = Arc::make_mut(sheet);
|
||
for (c, f) in fixed {
|
||
if let Some(cell) = sheet.cells.get_mut(c) {
|
||
cell.formula = Some(Arc::new(f));
|
||
}
|
||
}
|
||
}
|
||
for n in self.names.iter_mut() {
|
||
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
|
||
let ne = spell_calls(e.clone(), &lambdas);
|
||
if ne != e {
|
||
n.formula = gridcraft_formula::print(&ne);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
/// Names of the defined names holding a LAMBDA (callable like functions).
|
||
fn lambda_names(&self) -> Vec<String> {
|
||
let is_lambda = |f: &str| f.trim_start_matches('=').trim_start().get(..7).is_some_and(|p| p.eq_ignore_ascii_case("LAMBDA("));
|
||
self.names.iter().filter(|n| is_lambda(&n.formula)).map(|n| n.name.clone()).collect()
|
||
}
|
||
/// Table by name across sheets: (sheet index, table index).
|
||
pub fn table(&self, name: &str) -> Option<(usize, usize)> {
|
||
for (si, s) in self.sheets.iter().enumerate() {
|
||
if let Some(ti) = s.tables.iter().position(|t| t.name.eq_ignore_ascii_case(name)) {
|
||
return Some((si, ti));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
pub fn next_object_id(&self) -> u32 {
|
||
let mut m = 0;
|
||
for s in &self.sheets {
|
||
for c in &s.charts {
|
||
m = m.max(c.id);
|
||
}
|
||
for c in &s.images {
|
||
m = m.max(c.id);
|
||
}
|
||
for c in &s.shapes {
|
||
m = m.max(c.id);
|
||
}
|
||
for c in &s.tables {
|
||
m = m.max(c.id);
|
||
}
|
||
for c in &s.slicers {
|
||
m = m.max(c.id);
|
||
}
|
||
}
|
||
m + 1
|
||
}
|
||
}
|
||
|
||
/// Calls of the names in `lambdas` spelled as the names are.
|
||
fn spell_calls(e: Expr, lambdas: &[String]) -> Expr {
|
||
if lambdas.is_empty() {
|
||
return e;
|
||
}
|
||
e.map(&mut |x| match x {
|
||
Expr::Call(n, args) => match lambdas.iter().find(|l| l.eq_ignore_ascii_case(&n)) {
|
||
Some(l) => Expr::Call(l.clone(), args),
|
||
None => Expr::Call(n, args),
|
||
},
|
||
other => other,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn phonetics_are_additive_in_json() {
|
||
let mut wb = Workbook::new();
|
||
assert!(wb.phonetics.is_empty(), "new workbooks have no phonetic data");
|
||
wb.phonetics.push(PhoneticString {
|
||
text: "東".into(),
|
||
runs: vec![PhoneticRun { start: 0, end: 1, text: "ヒガシ".into() }],
|
||
props: Some(PhoneticProps { font_id: 3, kind: Some("Hiragana".into()), alignment: None }),
|
||
});
|
||
let json = serde_json::to_string(&wb).expect("serialize");
|
||
assert_eq!(serde_json::from_str::<Workbook>(&json).expect("deserialize").phonetics, wb.phonetics);
|
||
// JSON written before the field existed still loads.
|
||
let mut value: serde_json::Value = serde_json::from_str(&json).expect("value");
|
||
value.as_object_mut().expect("an object").remove("phonetics");
|
||
assert!(serde_json::from_value::<Workbook>(value).expect("older JSON").phonetics.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn sheet_names() {
|
||
let wb = Workbook::new();
|
||
assert_eq!(wb.next_sheet_name(), "Sheet2");
|
||
assert!(wb.check_sheet_name("sheet1", None).is_err());
|
||
assert!(wb.check_sheet_name("sheet1", Some(0)).is_ok());
|
||
assert!(wb.check_sheet_name("a/b", None).is_err());
|
||
assert!(wb.check_sheet_name(&"x".repeat(32), None).is_err());
|
||
assert!(wb.check_sheet_name("Budget 2026", None).is_ok());
|
||
}
|
||
}
|