Skip to main content

ravenrs_sheet/model/
workbook.rs

1//! Workbooks.
2
3use std::sync::Arc;
4use std::sync::atomic::{AtomicUsize, Ordering};
5
6use crate::base::DateSystem;
7use crate::formula::Expr;
8use serde::{Deserialize, Serialize};
9
10use crate::model::cell::Formula;
11use crate::model::sheet::Sheet;
12use crate::model::style::{StyleTable, Theme};
13
14/// Most equation source, in bytes, one [`Workbook::refresh_equations`] lays out: about 2,500
15/// typical equations, or a few seconds of the slowest 4 KB sources.
16pub const MAX_EQUATION_LAYOUT_BYTES: usize = 128 * 1024;
17
18/// Why an equation past [`MAX_EQUATION_LAYOUT_BYTES`] has no layout.
19pub const EQUATION_BUDGET_MESSAGE: &str = "This workbook has more equations than are laid out at once; edit this equation to lay it out.";
20
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
22pub enum CalcMode {
23    #[default]
24    Automatic,
25    AutomaticExceptTables,
26    Manual,
27}
28
29/// Options › Advanced › "For objects, show" (`<workbookPr showObjects>`, ECMA-376 `ST_Objects`):
30/// whether charts, pictures, shapes and slicers are drawn. Ctrl+6 switches between `All` and
31/// `None`; `Placeholders` only comes from files and is kept as it is.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
33pub enum ShowObjects {
34    #[default]
35    All,
36    Placeholders,
37    None,
38}
39
40/// The workbook window's display settings from `<workbookView>` (Excel: File › Options ›
41/// Advanced, "Display options for this workbook"): whether the window shows the sheet tabs
42/// and the two scroll bars, and how much of the horizontal bar the tabs take (`tabRatio`,
43/// thousandths of the bar; the format's default is 600). XLSX I/O normalizes this
44/// share to 0–1000, including values supplied directly through the model.
45///
46/// The values are modelled so an open + save keeps them — a workbook Excel saved with the
47/// tabs hidden must not come back with them shown and the settings gone. The app does not
48/// act on them yet and there is no Options control for them; that is the other half of
49/// storytold/gridcraft#3057.
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
51pub struct WorkbookView {
52    #[serde(default = "yes")]
53    pub show_sheet_tabs: bool,
54    #[serde(default = "yes")]
55    pub show_horizontal_scroll: bool,
56    #[serde(default = "yes")]
57    pub show_vertical_scroll: bool,
58    #[serde(default = "default_tab_ratio")]
59    pub tab_ratio: u32,
60}
61
62fn yes() -> bool {
63    true
64}
65
66fn default_tab_ratio() -> u32 {
67    600
68}
69
70impl Default for WorkbookView {
71    fn default() -> Self {
72        WorkbookView { show_sheet_tabs: true, show_horizontal_scroll: true, show_vertical_scroll: true, tab_ratio: default_tab_ratio() }
73    }
74}
75
76impl WorkbookView {
77    /// The entire bar, in thousandths: the upper bound used by XLSX I/O.
78    pub const MAX_TAB_RATIO: u32 = 1000;
79
80    fn is_default(&self) -> bool {
81        *self == WorkbookView::default()
82    }
83}
84
85/// File › Options › Formulas › Calculation options, stored per workbook (`<calcPr>`).
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87pub struct CalcSettings {
88    pub mode: CalcMode,
89    /// Enable iterative calculation: circular references are calculated repeatedly instead of
90    /// reporting an error.
91    pub iterative: bool,
92    /// Maximum Iterations, 1 to [`CalcSettings::MAX_ITERATIONS`].
93    pub max_iterations: u32,
94    /// Maximum Change: iteration stops once no value in a circular reference changes by more.
95    pub max_change: f64,
96    /// Use precision as displayed.
97    pub precision_as_displayed: bool,
98    /// R1C1 reference style (`<calcPr refMode="R1C1"/>`): kept with the file and written back;
99    /// GridCraft itself shows and takes A1 references.
100    #[serde(default)]
101    pub r1c1: bool,
102}
103
104impl Default for CalcSettings {
105    fn default() -> Self {
106        CalcSettings {
107            mode: CalcMode::Automatic,
108            iterative: false,
109            max_iterations: Self::DEFAULT_ITERATIONS,
110            max_change: Self::DEFAULT_CHANGE,
111            precision_as_displayed: false,
112            r1c1: false,
113        }
114    }
115}
116
117impl CalcSettings {
118    pub const DEFAULT_ITERATIONS: u32 = 100;
119    pub const DEFAULT_CHANGE: f64 = 0.001;
120    /// The largest Maximum Iterations Excel accepts.
121    pub const MAX_ITERATIONS: u32 = 32_767;
122
123    /// Sets Maximum Iterations, kept within 1 to [`CalcSettings::MAX_ITERATIONS`].
124    pub fn set_max_iterations(&mut self, n: u32) {
125        self.max_iterations = n.clamp(1, Self::MAX_ITERATIONS);
126    }
127
128    /// Sets Maximum Change (its magnitude); a value that isn't finite is ignored.
129    pub fn set_max_change(&mut self, d: f64) {
130        if d.is_finite() {
131            self.max_change = d.abs();
132        }
133    }
134}
135
136#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
137pub struct DefinedName {
138    pub name: String,
139    /// `None` = workbook scope; otherwise the sheet index.
140    pub scope: Option<usize>,
141    /// Formula text without `=` (`Sheet1!$A$1:$A$10`, `0.07`, `LAMBDA(x,x*2)`).
142    pub formula: String,
143    pub comment: String,
144    pub hidden: bool,
145}
146
147#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
148pub struct DocProps {
149    pub title: String,
150    pub subject: String,
151    pub author: String,
152    pub company: String,
153    pub keywords: String,
154    pub description: String,
155}
156
157/// One phonetic (furigana) run of a string item (`<rPh>`): the reading `text` and the range of the
158/// base text it annotates.
159///
160/// `start` and `end` are zero-based offsets into the base text (`sb` and `eb` in the file) with
161/// `start < end`; reading keeps them within the text's length in UTF-16 code units. Behaviour
162/// reference: ISO/IEC 29500-1 §18.4.6 `rPh` and the Microsoft Learn `PhoneticRun` class.
163#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
164pub struct PhoneticRun {
165    pub start: u32,
166    pub end: u32,
167    pub text: String,
168}
169
170/// The `<phoneticPr>` of a string item: how its phonetic runs are displayed. `kind` (`type`) and
171/// `alignment` only ever hold values ISO/IEC 29500-1 §18.18.57 `ST_PhoneticType` / §18.18.56
172/// `ST_PhoneticAlignment` define, so damaged files cannot be written back invalid.
173#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
174pub struct PhoneticProps {
175    /// `fontId`: a required attribute, an index into the style sheet's fonts. A file without one
176    /// gets 0 — the Normal style's font, which an out-of-range index falls back to anyway.
177    #[serde(default)]
178    pub font_id: u32,
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub kind: Option<String>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub alignment: Option<String>,
183}
184
185/// The phonetic (furigana) data of one shared string: its `<rPh>` runs, in document order, and its
186/// `<phoneticPr>` when the file had one.
187///
188/// `text` is the string's base text — the only handle a cell keeps on its shared string — and is
189/// the key the writer uses to re-emit the runs next to the right `<si>`.
190#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
191pub struct PhoneticString {
192    pub text: String,
193    pub runs: Vec<PhoneticRun>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub props: Option<PhoneticProps>,
196}
197
198#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
199pub struct Workbook {
200    pub sheets: Vec<Arc<Sheet>>,
201    pub names: Vec<DefinedName>,
202    pub styles: StyleTable,
203    pub theme: Theme,
204    pub date_system: DateSystem,
205    pub calc: CalcSettings,
206    #[serde(default, skip_serializing_if = "is_all")]
207    pub show_objects: ShowObjects,
208    /// Trust Center › Privacy Options › "Remove personal information from file properties on save"
209    /// (`<workbookPr filterPrivacy="1"/>`): kept with the file, and while it is on the author and
210    /// company are left out of the XLSX files GridCraft writes, as Excel leaves them empty. Other
211    /// formats aren't filtered: the workbook's own serialization still holds them.
212    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
213    pub remove_personal_info: bool,
214    /// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`].
215    pub active_sheet: ActiveSheet,
216    /// The workbook window's display settings (`<workbookView>`), kept across an open + save.
217    #[serde(default, skip_serializing_if = "WorkbookView::is_default")]
218    pub view: WorkbookView,
219    pub props: DocProps,
220    /// Workbook structure protection.
221    pub protected_structure: bool,
222    /// Password of the structure protection (XLSX `workbookProtection`), never the password.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub structure_password: Option<crate::model::PasswordHash>,
225    /// Custom fill lists (Options › Custom Lists) beyond the built-in ones.
226    pub custom_lists: Vec<Vec<String>>,
227    /// Named cell styles (Cell Styles gallery): name → style.
228    pub cell_styles: Vec<(String, crate::model::style::Style)>,
229    /// Phonetic (furigana) data of shared strings (`xl/sharedStrings.xml`), one entry per string
230    /// text, so that an open + save keeps a Japanese workbook's readings. Written back next to the
231    /// `<si>` holding the same text.
232    ///
233    /// Keyed by text because that is all a cell keeps of its shared string: the saved table holds
234    /// each text once (the format's `uniqueCount`), so two `<si>` items that share a base text can
235    /// only keep one reading — the reader keeps the first and warns. Empty for workbooks without
236    /// phonetic data; not present in older JSON. Modelled data (unlike [`Workbook::preserved`],
237    /// which holds unmodelled parts), so it is saved to JSON as well as to XLSX.
238    #[serde(default, skip_serializing_if = "Vec::is_empty")]
239    pub phonetics: Vec<PhoneticString>,
240    /// VBA project (`xl/vbaProject.bin`) carried through unchanged. GridCraft does not run
241    /// macros; the blob is preserved so that saving a macro-enabled workbook never destroys the
242    /// macros it arrived with. base64 in JSON. `None` = the workbook has no macros. Shared, so a
243    /// copy of the workbook (every edit's undo step) doesn't copy it.
244    #[serde(default, with = "crate::model::b64::opt")]
245    pub vba_project: Option<Arc<Vec<u8>>>,
246    /// Field separator of the CSV this workbook was read from, so saving it as CSV writes the same
247    /// separator back (a `;` file stays a `;` file). `None` = not read from CSV: CSV saves use `,`.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub csv_delimiter: Option<u8>,
250    /// GridCraft macros (typed command lists, not VBA), in the order the macro list shows them.
251    /// The JSON format keeps them as this field; xlsx keeps them in hidden defined names.
252    #[serde(default, skip_serializing_if = "crate::model::macros::Macros::is_empty", skip_deserializing)]
253    pub macros: crate::model::macros::Macros,
254    /// The `macros` entries of a JSON file as read, before the engine checks each one on its own
255    /// and moves it into `macros`, so one damaged or newer macro doesn't stop the file opening.
256    /// Never written.
257    #[serde(rename = "macros", default, skip_serializing, deserialize_with = "crate::model::macros::entries")]
258    pub macros_read: Vec<serde_json::Value>,
259    /// Macros in a newer format than this build reads, as the file held them. They are never read,
260    /// listed or run; a save writes them back unchanged, so the newer build gets them back whole.
261    /// Shared between snapshots, as nothing changes them.
262    #[serde(skip)]
263    pub macros_unread: Arc<Vec<serde_json::Value>>,
264    /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
265    #[serde(skip)]
266    pub preserved: Option<Arc<crate::model::preserved::Preserved>>,
267    /// The folder the workbook was read from, which relative paths to linked workbooks in
268    /// `preserved` are relative to. Saving into another folder rewrites them from there. Not in JSON.
269    #[serde(skip)]
270    pub source_dir: Option<std::path::PathBuf>,
271    /// The file this workbook reports as its own, which is what `CELL("filename")` returns.
272    /// `None` = no file yet, which Excel reports as an empty string until the workbook has been
273    /// saved once. Deliberately separate from `source_dir`: that one names the folder relative
274    /// links are relative to and must keep naming it after a Save As into another folder, since
275    /// the links in memory are still written for the old one. Not in JSON.
276    #[serde(skip)]
277    pub source_path: Option<std::path::PathBuf>,
278}
279
280fn is_all(s: &ShowObjects) -> bool {
281    *s == ShowObjects::All
282}
283
284impl Default for Workbook {
285    fn default() -> Self {
286        Workbook::new()
287    }
288}
289
290/// The active sheet index of a [`Workbook`].
291///
292/// Every other part of a workbook value is immutable once shared (the engine keeps versions as
293/// `Arc<Workbook>` and tells unsaved changes by pointer). The active sheet is the exception: it
294/// is the view state of the window showing the workbook, and several windows on one workbook
295/// swap their own sheet into it whenever one of them becomes the one commands act on. Keeping it
296/// in an atomic lets that swap happen through the shared `Arc` without cloning the workbook.
297/// Copies (undo versions, a cloned workbook) take the value at the time of the copy.
298#[derive(Default, Serialize, Deserialize)]
299#[serde(from = "usize", into = "usize")]
300pub struct ActiveSheet(AtomicUsize);
301
302impl ActiveSheet {
303    pub fn get(&self) -> usize {
304        self.0.load(Ordering::Relaxed)
305    }
306    pub fn set(&self, i: usize) {
307        self.0.store(i, Ordering::Relaxed);
308    }
309}
310
311impl From<usize> for ActiveSheet {
312    fn from(i: usize) -> ActiveSheet {
313        ActiveSheet(AtomicUsize::new(i))
314    }
315}
316
317impl From<ActiveSheet> for usize {
318    fn from(a: ActiveSheet) -> usize {
319        a.get()
320    }
321}
322
323impl Clone for ActiveSheet {
324    fn clone(&self) -> ActiveSheet {
325        ActiveSheet::from(self.get())
326    }
327}
328
329impl PartialEq for ActiveSheet {
330    fn eq(&self, other: &ActiveSheet) -> bool {
331        self.get() == other.get()
332    }
333}
334
335impl std::fmt::Debug for ActiveSheet {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        self.get().fmt(f)
338    }
339}
340
341impl Workbook {
342    /// A new workbook with one sheet, "Sheet1".
343    pub fn new() -> Workbook {
344        Workbook {
345            sheets: vec![Arc::new(Sheet::new("Sheet1"))],
346            names: vec![],
347            styles: StyleTable::default(),
348            theme: Theme::default(),
349            date_system: DateSystem::D1900,
350            calc: CalcSettings::default(),
351            show_objects: ShowObjects::All,
352            remove_personal_info: false,
353            active_sheet: ActiveSheet::default(),
354            view: WorkbookView::default(),
355            props: DocProps::default(),
356            protected_structure: false,
357            structure_password: None,
358            custom_lists: vec![],
359            cell_styles: vec![],
360            phonetics: vec![],
361            vba_project: None,
362            csv_delimiter: None,
363            macros: crate::model::macros::Macros::default(),
364            macros_read: vec![],
365            macros_unread: Arc::default(),
366            preserved: None,
367            source_dir: None,
368            source_path: None,
369        }
370    }
371    pub fn sheet(&self, i: usize) -> Option<&Sheet> {
372        self.sheets.get(i).map(|s| s.as_ref())
373    }
374    pub fn sheet_mut(&mut self, i: usize) -> Option<&mut Sheet> {
375        self.sheets.get_mut(i).map(Arc::make_mut)
376    }
377    pub fn sheet_index(&self, name: &str) -> Option<usize> {
378        self.sheets.iter().position(|s| s.name.eq_ignore_ascii_case(name))
379    }
380    /// The index of the sheet the workbook's window shows (the file's active tab).
381    pub fn active_sheet(&self) -> usize {
382        self.active_sheet.get()
383    }
384    /// Changes the active sheet in place, also through a shared `Arc<Workbook>`: it is view state,
385    /// so switching sheets or windows neither copies the workbook nor makes it a new version.
386    pub fn set_active_sheet(&self, i: usize) {
387        self.active_sheet.set(i);
388    }
389    pub fn active(&self) -> Option<&Sheet> {
390        self.sheet(self.active_sheet())
391    }
392    /// A fresh sheet name `SheetN` not in use.
393    pub fn next_sheet_name(&self) -> String {
394        (1..).map(|n| format!("Sheet{n}")).find(|n| self.sheet_index(n).is_none()).unwrap_or_else(|| "Sheet".into())
395    }
396    /// Validates a sheet name like Excel: 1–31 chars, none of `: \ / ? * [ ]`, not starting or
397    /// ending with `'`, unique (case-insensitive) unless it is `except`'s own name.
398    pub fn check_sheet_name(&self, name: &str, except: Option<usize>) -> Result<(), String> {
399        let n = name.chars().count();
400        if n == 0 {
401            return Err("A sheet name can't be blank.".into());
402        }
403        if n > 31 {
404            return Err("A sheet name can't be longer than 31 characters.".into());
405        }
406        if name.chars().any(|c| matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')) {
407            return Err("A sheet name can't contain any of these characters: : \\ / ? * [ ]".into());
408        }
409        if name.starts_with('\'') || name.ends_with('\'') {
410            return Err("A sheet name can't begin or end with an apostrophe.".into());
411        }
412        if name.eq_ignore_ascii_case("History") {
413            return Err("\"History\" is a reserved name.".into());
414        }
415        if let Some(i) = self.sheet_index(name)
416            && Some(i) != except
417        {
418            return Err("That name is already taken. Try a different one.".into());
419        }
420        Ok(())
421    }
422    /// Finds a defined name visible from `sheet`: sheet scope first, then workbook scope.
423    pub fn name(&self, name: &str, sheet: usize) -> Option<&DefinedName> {
424        self.names
425            .iter()
426            .find(|n| n.scope == Some(sheet) && n.name.eq_ignore_ascii_case(name))
427            .or_else(|| self.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(name)))
428    }
429    /// Spells calls of LAMBDA names the way the names are defined (`Double(4)`, where the
430    /// parser gives `DOUBLE(4)`: it upper-cases function names, as built-in ones are shown).
431    pub fn name_call_case(&self, e: Expr) -> Expr {
432        spell_calls(e, &self.lambda_names())
433    }
434    /// [`Workbook::name_call_case`] for every cell formula and defined name (after reading a
435    /// file).
436    pub fn apply_name_call_case(&mut self) {
437        let lambdas = self.lambda_names();
438        if lambdas.is_empty() {
439            return;
440        }
441        for sheet in self.sheets.iter_mut() {
442            let fixed: Vec<_> = sheet
443                .cells
444                .iter()
445                .filter_map(|(c, cell)| {
446                    let f = cell.formula.as_ref()?;
447                    let e = f.expr()?;
448                    let ne = spell_calls(e.clone(), &lambdas);
449                    (ne != e).then(|| (c, Formula { array: f.array, dynamic: f.dynamic, ..Formula::from_expr(ne) }))
450                })
451                .collect();
452            if fixed.is_empty() {
453                continue;
454            }
455            let sheet = Arc::make_mut(sheet);
456            for (c, f) in fixed {
457                if let Some(cell) = sheet.cells.get_mut(c) {
458                    cell.formula = Some(Arc::new(f));
459                }
460            }
461        }
462        for n in self.names.iter_mut() {
463            if let Ok(e) = crate::formula::parse_name(&n.formula) {
464                let ne = spell_calls(e.clone(), &lambdas);
465                if ne != e {
466                    n.formula = crate::formula::print(&ne);
467                }
468            }
469        }
470    }
471    /// Names of the defined names holding a LAMBDA (callable like functions).
472    fn lambda_names(&self) -> Vec<String> {
473        let is_lambda = |f: &str| f.trim_start_matches('=').trim_start().get(..7).is_some_and(|p| p.eq_ignore_ascii_case("LAMBDA("));
474        self.names.iter().filter(|n| is_lambda(&n.formula)).map(|n| n.name.clone()).collect()
475    }
476    /// Table by name across sheets: (sheet index, table index).
477    pub fn table(&self, name: &str) -> Option<(usize, usize)> {
478        for (si, s) in self.sheets.iter().enumerate() {
479            if let Some(ti) = s.tables.iter().position(|t| t.name.eq_ignore_ascii_case(name)) {
480                return Some((si, ti));
481            }
482        }
483        None
484    }
485    /// Rebuilds the cached layout of every equation whose source changed (or was never laid
486    /// out, as after loading a file). One call lays out at most [`MAX_EQUATION_LAYOUT_BYTES`] of
487    /// source: one equation can take a tenth of a second to lay out, so a file with thousands of
488    /// them would otherwise hold the app for minutes. Equations past the budget get an error
489    /// instead (they show their source; editing one lays it out).
490    pub fn refresh_equations(&mut self) {
491        let stale = |s: &crate::model::Shape| s.kind == crate::model::ShapeKind::Equation && s.math.get(&s.text).is_none();
492        let mut budget = MAX_EQUATION_LAYOUT_BYTES;
493        // Only sheets with a stale equation are copied (sheets are shared with undo snapshots).
494        for sh in self.sheets.iter_mut().filter(|sh| sh.shapes.iter().any(stale)) {
495            for s in Arc::make_mut(sh).shapes.iter_mut().filter(|s| stale(s)) {
496                // A source over the length cap never gets here: the cache answers for it without
497                // a lookup. An empty source still costs a call.
498                let cost = s.text.len().max(1);
499                if cost > budget {
500                    s.math.refuse(&s.text, EQUATION_BUDGET_MESSAGE);
501                    continue;
502                }
503                budget -= cost;
504                s.math.refresh(&s.text);
505            }
506        }
507    }
508
509    /// A free id for a new chart, picture, shape, table, slicer or form control: one more than the largest in
510    /// use, or, when that is `u32::MAX` (a file can carry any id), the lowest free one.
511    pub fn next_object_id(&self) -> u32 {
512        self.free_object_ids(1).first().copied().unwrap_or(u32::MAX)
513    }
514
515    /// `count` distinct ids no object in the workbook uses, sorted: the ones above the largest id
516    /// in use while they last, then the lowest free ones. Fewer come back only when the id space
517    /// is exhausted.
518    pub fn free_object_ids(&self, count: usize) -> Vec<u32> {
519        let mut used: Vec<u32> = self
520            .sheets
521            .iter()
522            .flat_map(|s| {
523                s.charts
524                    .iter()
525                    .map(|o| o.id)
526                    .chain(s.images.iter().map(|o| o.id))
527                    .chain(s.shapes.iter().map(|o| o.id))
528                    .chain(s.tables.iter().map(|o| o.id))
529                    .chain(s.slicers.iter().map(|o| o.id))
530                    .chain(s.controls.iter().map(|o| o.id))
531            })
532            .collect();
533        used.sort_unstable();
534        used.dedup();
535        let max = used.last().copied().unwrap_or(0);
536        let mut out = Vec::with_capacity(count.min(1 << 16));
537        let mut next = max.checked_add(1);
538        while out.len() < count
539            && let Some(id) = next
540        {
541            out.push(id);
542            next = id.checked_add(1);
543        }
544        // Past the top: the gaps below the largest id, from 1 (0 is never an object's id).
545        let mut taken = used.iter().copied().peekable();
546        let mut id = 1u32;
547        while out.len() < count && id < max {
548            while taken.next_if(|&t| t < id).is_some() {}
549            if taken.peek() == Some(&id) {
550                taken.next();
551            } else {
552                out.push(id);
553            }
554            id += 1;
555        }
556        out.sort_unstable();
557        out
558    }
559}
560
561/// Calls of the names in `lambdas` spelled as the names are.
562fn spell_calls(e: Expr, lambdas: &[String]) -> Expr {
563    if lambdas.is_empty() {
564        return e;
565    }
566    e.map(&mut |x| match x {
567        Expr::Call(n, args) => match lambdas.iter().find(|l| l.eq_ignore_ascii_case(&n)) {
568            Some(l) => Expr::Call(l.clone(), args),
569            None => Expr::Call(n, args),
570        },
571        other => other,
572    })
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn phonetics_are_additive_in_json() -> Result<(), Box<dyn std::error::Error>> {
581        let mut wb = Workbook::new();
582        assert!(wb.phonetics.is_empty(), "new workbooks have no phonetic data");
583        wb.phonetics.push(PhoneticString {
584            text: "東".into(),
585            runs: vec![PhoneticRun { start: 0, end: 1, text: "ヒガシ".into() }],
586            props: Some(PhoneticProps { font_id: 3, kind: Some("Hiragana".into()), alignment: None }),
587        });
588        let json = serde_json::to_string(&wb)?;
589        assert_eq!(serde_json::from_str::<Workbook>(&json)?.phonetics, wb.phonetics);
590        // JSON written before the field existed still loads.
591        let mut value: serde_json::Value = serde_json::from_str(&json)?;
592        value.as_object_mut().ok_or("none")?.remove("phonetics");
593        assert!(serde_json::from_value::<Workbook>(value)?.phonetics.is_empty());
594        Ok(())
595    }
596
597    #[test]
598    fn sheet_names() {
599        let wb = Workbook::new();
600        assert_eq!(wb.next_sheet_name(), "Sheet2");
601        assert!(wb.check_sheet_name("sheet1", None).is_err());
602        assert!(wb.check_sheet_name("sheet1", Some(0)).is_ok());
603        assert!(wb.check_sheet_name("a/b", None).is_err());
604        assert!(wb.check_sheet_name(&"x".repeat(32), None).is_err());
605        assert!(wb.check_sheet_name("Budget 2026", None).is_ok());
606    }
607
608    #[test]
609    fn object_ids_never_overflow_or_repeat() {
610        let mut wb = Workbook::new();
611        let image = |id| crate::model::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() };
612        assert_eq!(wb.next_object_id(), 1);
613        if let Some(sh) = wb.sheet_mut(0) {
614            sh.images.push(image(7));
615        }
616        assert_eq!(wb.next_object_id(), 8);
617        if let Some(sh) = wb.sheet_mut(0) {
618            sh.controls.push(crate::model::FormControl { id: 12, ..Default::default() });
619        }
620        assert_eq!(wb.next_object_id(), 13, "form controls take ids from the same sequence");
621        if let Some(sh) = wb.sheet_mut(0) {
622            sh.images.push(image(u32::MAX));
623        }
624        // An id read from a file at the maximum: the lowest free id, not the maximum again.
625        assert_eq!(wb.next_object_id(), 1);
626        if let Some(sh) = wb.sheet_mut(0) {
627            sh.images.push(image(1));
628            sh.images.push(image(3));
629        }
630        assert_eq!(wb.next_object_id(), 2);
631        assert_eq!(wb.free_object_ids(4), vec![2, 4, 5, 6], "the gaps, skipping 1, 3 and 7");
632        let mut wb = Workbook::new();
633        if let Some(sh) = wb.sheet_mut(0) {
634            sh.images.push(image(u32::MAX - 1));
635        }
636        assert_eq!(wb.free_object_ids(3), vec![1, 2, u32::MAX], "the top first, then from 1");
637    }
638
639    /// A form control's id is in use like any object's: the free ids skip it, also when it is the
640    /// largest id a file could carry.
641    #[test]
642    fn free_object_ids_skip_a_form_controls_id() {
643        let mut wb = Workbook::new();
644        if let Some(sh) = wb.sheet_mut(0) {
645            sh.controls.push(crate::model::FormControl { id: 3, ..Default::default() });
646            sh.controls.push(crate::model::FormControl { id: u32::MAX, ..Default::default() });
647        }
648        assert_eq!(wb.free_object_ids(4), vec![1, 2, 4, 5], "3 and the largest id are taken");
649        assert_eq!(wb.next_object_id(), 1);
650    }
651
652    /// A file can use the largest id and the one below it: nothing is free above them, so the free
653    /// ids are the lowest unused ones from 1 (0 is never an object's id), and a large request is
654    /// served in time linear in the count and the ids in use.
655    #[test]
656    fn free_object_ids_come_from_below_when_nothing_is_free_above() {
657        let mut wb = Workbook::new();
658        let image = |id| crate::model::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() };
659        if let Some(sh) = wb.sheet_mut(0) {
660            sh.images.push(image(u32::MAX));
661            sh.images.push(image(u32::MAX - 1));
662            sh.images.push(image(0));
663        }
664        assert_eq!(wb.free_object_ids(3), vec![1, 2, 3]);
665        assert_eq!(wb.next_object_id(), 1);
666        // The 100,000 lowest free ids: distinct, ascending, none of them in use.
667        let many = wb.free_object_ids(100_000);
668        assert_eq!(many.len(), 100_000);
669        assert!(many.windows(2).all(|w| w[0] < w[1]), "ascending without repeats");
670        assert!(!many.contains(&0) && !many.contains(&(u32::MAX - 1)) && !many.contains(&u32::MAX));
671        assert_eq!(wb.free_object_ids(0), Vec::<u32>::new());
672    }
673}