Files
gridcraft/crates/model/src/workbook.rs
T
aiongg 857bdae023 Merge PR #1692: XLSX: keep Japanese phonetic runs on open and save
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
2026-10-22 02:36:58 +00:00

407 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}