mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 06:31:20 +00:00
A sheet holds form controls as floating objects of a new kind (ObjKind::Control): buttons that run a macro or one command, and sliders and spin buttons that hold a whole number from 0 to 30000 and write it to a linked cell. They take part in everything other objects do: ids from the workbook sequence, object.move, object.delete and object.setAnchorMode, the Selection Pane and Bring Forward / Send Backward, anchors that follow row and column edits, fresh ids on a copied sheet, and links that follow inserts, deletes, cut-moves, sheet renames and sheet deletes like chart series. Commands (cmd/controls.rs): insert.button, insert.slider, insert.spinButton, control.format, control.assign, control.setValue, control.press and the control.list query. Each numeric rule (0 <= min < max <= 30000, 1 <= step <= max - min, page >= step for sliders) is refused with its own message, and values are clamped and snapped to the step. control.setValue writes the linked cell through cell.set inside one undo group, so dependents recalculate and one Undo restores both; every commit moves linked controls to what their cells hold. control.press runs a button's macro through macro.run, or its command through the macro policy with the document's trust, so a button in a workbook opened from a file asks before it runs anything. Like a macro step, the command runs inside the scripts' file sandbox unless it is an approved file command. Macros never press controls, and macro.rename keeps buttons pointing at the renamed macro. macro.run, macro.check and the debugger take `output`, which replaces the macro's output anchor for that run (a button that sends the output elsewhere). The policy and the approval go by the stored macro; the footprint, the conflict rule and the per-step check work on the cells `output` names. A value that isn't reference text of at most 1,024 characters, or names no cell, is refused before anything runs.
509 lines
20 KiB
Rust
509 lines
20 KiB
Rust
//! Workbooks.
|
||
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||
|
||
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,
|
||
/// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`].
|
||
pub active_sheet: ActiveSheet,
|
||
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>,
|
||
/// GridCraft macros (typed command lists, not VBA), in the order the macro list shows them.
|
||
/// The JSON format keeps them as this field; xlsx keeps them in hidden defined names.
|
||
#[serde(default, skip_serializing_if = "crate::macros::Macros::is_empty", skip_deserializing)]
|
||
pub macros: crate::macros::Macros,
|
||
/// The `macros` entries of a JSON file as read, before the engine checks each one on its own
|
||
/// and moves it into `macros`, so one damaged or newer macro doesn't stop the file opening.
|
||
/// Never written.
|
||
#[serde(rename = "macros", default, skip_serializing, deserialize_with = "crate::macros::entries")]
|
||
pub macros_read: Vec<serde_json::Value>,
|
||
/// Macros in a newer format than this build reads, as the file held them. They are never read,
|
||
/// listed or run; a save writes them back unchanged, so the newer build gets them back whole.
|
||
/// Shared between snapshots, as nothing changes them.
|
||
#[serde(skip)]
|
||
pub macros_unread: Arc<Vec<serde_json::Value>>,
|
||
/// 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()
|
||
}
|
||
}
|
||
|
||
/// The active sheet index of a [`Workbook`].
|
||
///
|
||
/// Every other part of a workbook value is immutable once shared (the engine keeps versions as
|
||
/// `Arc<Workbook>` and tells unsaved changes by pointer). The active sheet is the exception: it
|
||
/// is the view state of the window showing the workbook, and several windows on one workbook
|
||
/// swap their own sheet into it whenever one of them becomes the one commands act on. Keeping it
|
||
/// in an atomic lets that swap happen through the shared `Arc` without cloning the workbook.
|
||
/// Copies (undo versions, a cloned workbook) take the value at the time of the copy.
|
||
#[derive(Default, Serialize, Deserialize)]
|
||
#[serde(from = "usize", into = "usize")]
|
||
pub struct ActiveSheet(AtomicUsize);
|
||
|
||
impl ActiveSheet {
|
||
pub fn get(&self) -> usize {
|
||
self.0.load(Ordering::Relaxed)
|
||
}
|
||
pub fn set(&self, i: usize) {
|
||
self.0.store(i, Ordering::Relaxed);
|
||
}
|
||
}
|
||
|
||
impl From<usize> for ActiveSheet {
|
||
fn from(i: usize) -> ActiveSheet {
|
||
ActiveSheet(AtomicUsize::new(i))
|
||
}
|
||
}
|
||
|
||
impl From<ActiveSheet> for usize {
|
||
fn from(a: ActiveSheet) -> usize {
|
||
a.get()
|
||
}
|
||
}
|
||
|
||
impl Clone for ActiveSheet {
|
||
fn clone(&self) -> ActiveSheet {
|
||
ActiveSheet::from(self.get())
|
||
}
|
||
}
|
||
|
||
impl PartialEq for ActiveSheet {
|
||
fn eq(&self, other: &ActiveSheet) -> bool {
|
||
self.get() == other.get()
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Debug for ActiveSheet {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
self.get().fmt(f)
|
||
}
|
||
}
|
||
|
||
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: ActiveSheet::default(),
|
||
props: DocProps::default(),
|
||
protected_structure: false,
|
||
structure_password: None,
|
||
custom_lists: vec![],
|
||
cell_styles: vec![],
|
||
phonetics: vec![],
|
||
vba_project: None,
|
||
csv_delimiter: None,
|
||
macros: crate::macros::Macros::default(),
|
||
macros_read: vec![],
|
||
macros_unread: Arc::default(),
|
||
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))
|
||
}
|
||
/// The index of the sheet the workbook's window shows (the file's active tab).
|
||
pub fn active_sheet(&self) -> usize {
|
||
self.active_sheet.get()
|
||
}
|
||
/// Changes the active sheet in place, also through a shared `Arc<Workbook>`: it is view state,
|
||
/// so switching sheets or windows neither copies the workbook nor makes it a new version.
|
||
pub fn set_active_sheet(&self, i: usize) {
|
||
self.active_sheet.set(i);
|
||
}
|
||
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, dynamic: f.dynamic, ..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_name(&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);
|
||
}
|
||
for c in &s.controls {
|
||
m = m.max(c.id);
|
||
}
|
||
}
|
||
m.saturating_add(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() -> Result<(), Box<dyn std::error::Error>> {
|
||
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)?;
|
||
assert_eq!(serde_json::from_str::<Workbook>(&json)?.phonetics, wb.phonetics);
|
||
// JSON written before the field existed still loads.
|
||
let mut value: serde_json::Value = serde_json::from_str(&json)?;
|
||
value.as_object_mut().ok_or("none")?.remove("phonetics");
|
||
assert!(serde_json::from_value::<Workbook>(value)?.phonetics.is_empty());
|
||
Ok(())
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
|
||
#[test]
|
||
fn object_ids_saturate_instead_of_overflowing() {
|
||
let mut wb = Workbook::new();
|
||
let image = |id| crate::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() };
|
||
assert_eq!(wb.next_object_id(), 1);
|
||
if let Some(sh) = wb.sheet_mut(0) {
|
||
sh.images.push(image(7));
|
||
}
|
||
assert_eq!(wb.next_object_id(), 8);
|
||
if let Some(sh) = wb.sheet_mut(0) {
|
||
sh.controls.push(crate::FormControl { id: 12, ..Default::default() });
|
||
}
|
||
assert_eq!(wb.next_object_id(), 13, "form controls take ids from the same sequence");
|
||
if let Some(sh) = wb.sheet_mut(0) {
|
||
sh.images.push(image(u32::MAX));
|
||
}
|
||
assert_eq!(wb.next_object_id(), u32::MAX, "an id read from a file at the maximum saturates");
|
||
}
|
||
}
|