Skip to main content

visi_core/core/
workbook.rs

1//! Workbook-level orchestration: the sheets, charts, pivot tables and VBA
2//! project of one `.xlsx` file, plus the CRUD and recalculation operations
3//! that span more than a single [`Sheet`].
4//!
5//! This layer is where cross-sheet behavior lives. [`Sheet::commit`] only
6//! propagates *local* dependencies; [`WorkbookManager::evaluate`] is what
7//! carries values across sheets. Likewise nothing recomputes a pivot table
8//! implicitly -- [`WorkbookManager::refresh_pivot_table`] is the only thing
9//! that writes a computed grid into cells. Embedders should drive the
10//! workbook through this type rather than manipulating [`Sheet`] directly,
11//! or cross-sheet formulas and pivot output will silently go stale.
12//!
13//! Loading and saving are byte-oriented ([`WorkbookManager::load_bytes`] /
14//! [`WorkbookManager::save_bytes`]) so this stays usable off the filesystem,
15//! including on wasm. The `visi` CLI layers path- and stdio-based helpers on
16//! top via its own `WorkbookFile` trait.
17
18use crate::core::formula::CompiledFormula;
19use crate::core::grid_edit::{Axis, GridEdit};
20use crate::core::parser::col_idx_to_letters;
21use crate::core::xlsx::{export_xlsx_data, import_xlsx_data};
22use crate::core::{
23    ExcelTable, PivotAggregation, PivotArea, PivotField, PivotFilterField, PivotGrid, PivotSource,
24    PivotTable, PivotValueField, VbaModule, VbaModuleKind, VbaProject,
25    chart::{Chart, ChartType},
26    compute_pivot,
27    engine::{Context, DataColumn, ResultData, Sheet, generate_unique_id},
28    validate_vba_module_name,
29};
30use crate::{Error, ObjectKind};
31
32/// Keeps a table's column names lined up with its sheet columns after a
33/// column insert or delete cut through it.
34///
35/// `ExcelTable::columns` has one entry per sheet column in
36/// `start_col..=end_col`, so a column added or removed inside that span has to
37/// add or remove a name at the matching offset -- otherwise every name past
38/// the edit describes the wrong column. Called with the table's *pre-edit*
39/// extent still in place, which is what `edit.at` is compared against.
40fn resize_table_columns(
41    table: &mut ExcelTable,
42    new_start_col: usize,
43    new_end_col: usize,
44    edit: &GridEdit,
45) {
46    if edit.insert {
47        // Inserting at or before the table's first column moves the table
48        // rather than widening it, so only a strictly-interior insert adds a
49        // column -- the same asymmetry `shift_span` encodes for references.
50        if edit.at > table.start_col && edit.at <= table.end_col {
51            let offset = (edit.at - table.start_col).min(table.columns.len());
52            for _ in 0..edit.count {
53                table.columns.insert(offset, String::new());
54            }
55        }
56    } else {
57        let first = edit.at.max(table.start_col);
58        let last = (edit.at + edit.count).min(table.end_col + 1);
59        if first < last {
60            let lo = (first - table.start_col).min(table.columns.len());
61            let hi = (last - table.start_col).min(table.columns.len());
62            table.columns.drain(lo..hi);
63        }
64    }
65    // A table loaded from elsewhere may already disagree with its own extent;
66    // the new width is the authority either way.
67    table
68        .columns
69        .resize(new_end_col - new_start_col + 1, String::new());
70}
71
72/// A single sheet's line in a [`WorkbookSummary`].
73pub struct SheetSummary {
74    /// The sheet's name.
75    pub name: String,
76    /// Allocated rows.
77    pub row_count: usize,
78    /// Allocated columns.
79    pub col_count: usize,
80    /// How many of its cells hold a formula rather than a literal.
81    pub formula_count: usize,
82}
83
84/// An overview of a workbook's shape, for reporting rather than editing.
85pub struct WorkbookSummary {
86    /// The file the workbook was loaded from, as the caller named it.
87    pub file_name: String,
88    /// How many sheets it has.
89    pub sheet_count: usize,
90    /// How many charts it has.
91    pub chart_count: usize,
92    /// One entry per sheet, in workbook order.
93    pub sheets: Vec<SheetSummary>,
94}
95
96/// A whole workbook: its sheets, charts, pivot tables and VBA project, and the
97/// operations that span more than one of them.
98///
99/// The entry point to this crate, and the layer an embedder should drive.
100/// Two behaviors are only correct at this level:
101///
102/// - **Cross-sheet formulas.** [`Sheet::commit`] propagates local dependencies
103///   only; [`WorkbookManager::evaluate`] is what carries values between
104///   sheets.
105/// - **Pivot tables.** Nothing recomputes one implicitly.
106///   [`WorkbookManager::refresh_pivot_table`] is the only thing that writes a
107///   computed grid into cells.
108///
109/// Editing the [`Sheet`]s directly is allowed -- the fields are public -- but
110/// skips both, so cross-sheet formulas and pivot output go stale silently.
111pub struct WorkbookManager {
112    /// The worksheets, in workbook order. Cell coordinates within them are
113    /// 0-based.
114    pub sheets: Vec<Sheet>,
115    /// The charts. Workbook-level rather than sheet-scoped; which sheet a
116    /// chart is drawn on comes from its `data_range`.
117    pub charts: Vec<Chart>,
118    /// The pivot table definitions. Workbook-level, since a pivot's source and
119    /// destination may be on different sheets.
120    pub pivot_tables: Vec<PivotTable>,
121    /// The VBA project, if the workbook has macros.
122    pub vba_project: Option<VbaProject>,
123}
124
125/// Quotes a materialized pivot label that would otherwise be re-parsed as a
126/// number, boolean, or formula by `Sheet::commit`'s literal-cell parsing
127/// (mirrors `xlsx::text_cell_src`'s treatment of imported text cells).
128fn pivot_label_literal(text: &str) -> String {
129    if text.is_empty() {
130        String::new()
131    } else if text.starts_with('=')
132        || text.parse::<f64>().is_ok()
133        || text.eq_ignore_ascii_case("true")
134        || text.eq_ignore_ascii_case("false")
135    {
136        format!("\"{}\"", text)
137    } else {
138        text.to_string()
139    }
140}
141
142/// Renders one aggregated pivot value as literal cell text; errors (e.g.
143/// `AVERAGE` over zero numeric records) are written as their Excel error
144/// string rather than `ResultData`'s human-readable `"Error: ..."` form.
145fn pivot_value_literal(v: &ResultData) -> String {
146    match v {
147        ResultData::Error(e) => e.clone(),
148        other => other.to_string(),
149    }
150}
151
152fn remove_pivot_field(fields: &mut Vec<PivotField>, column: &str) -> bool {
153    let before = fields.len();
154    fields.retain(|f| !f.column.eq_ignore_ascii_case(column));
155    before != fields.len()
156}
157
158impl WorkbookManager {
159    /// Load Excel workbook from bytes buffer
160    pub fn load_bytes(buffer: &[u8]) -> crate::Result<Self> {
161        let (imported_tables, charts, pivot_tables, vba_project) =
162            import_xlsx_data(buffer, &[], |_, _, _| {})?;
163
164        let sheets = imported_tables.into_iter().map(|it| it.sheet).collect();
165        Ok(Self {
166            sheets,
167            charts,
168            pivot_tables,
169            vba_project,
170        })
171    }
172
173    /// Serialize the workbook to `.xlsx` bytes.
174    ///
175    /// The byte-level counterpart to [`Self::load_bytes`]. Callers that want
176    /// to read or write an actual file supply their own IO -- the `visi` CLI
177    /// does so through its `WorkbookFile` trait.
178    pub fn save_bytes(&self) -> crate::Result<Vec<u8>> {
179        export_xlsx_data(
180            &self.sheets,
181            &self.charts,
182            &self.pivot_tables,
183            self.vba_project.as_ref(),
184        )
185    }
186
187    /// A new workbook containing a single empty sheet named `Sheet1`.
188    pub fn new_empty() -> crate::Result<Self> {
189        let mut wb = Self {
190            sheets: Vec::new(),
191            charts: Vec::new(),
192            pivot_tables: Vec::new(),
193            vba_project: None,
194        };
195        wb.add_sheet("Sheet1")?;
196        Ok(wb)
197    }
198
199    /// Recalculate all formulas in all sheets using visi-core engine
200    pub fn evaluate(&mut self) -> crate::Result<()> {
201        if self.sheets.is_empty() {
202            return Ok(());
203        }
204
205        // `self.sheets` is the one place true workbook order exists --
206        // `Context.sheets` is an unordered `HashMap` -- so `SHEET()` needs
207        // this collected once up front rather than derived from a context.
208        let sheet_order: Vec<String> = self.sheets.iter().map(|s| s.name.clone()).collect();
209
210        // Multi-pass evaluation to resolve cross-sheet formula dependencies.
211        // Every cell is re-marked dirty at the start of *each* pass, not
212        // just once before the loop -- `Sheet::commit` drains and clears a
213        // sheet's dirty queue as it processes it, so without re-marking,
214        // passes 2 and 3 had nothing left dirty and were silent no-ops.
215        // That meant a cross-sheet chain more than one hop deep (sheet A's
216        // formula depends on sheet B's formula depending on sheet A) kept
217        // whatever stale value pass 1 happened to compute before B had a
218        // chance to update -- found via the fuzzer's new cross-sheet
219        // generator block (#26).
220        for _pass in 0..3 {
221            for sheet in &mut self.sheets {
222                sheet.mark_all_dirty();
223            }
224            for i in 0..self.sheets.len() {
225                let (left, right) = self.sheets.split_at_mut(i);
226                let (target_sheet, right_tail) = right.split_first_mut().unwrap();
227
228                let mut context = Context::new();
229                for s in left.iter() {
230                    context.add_table(s.name.clone(), s);
231                }
232                for s in right_tail.iter() {
233                    context.add_table(s.name.clone(), s);
234                }
235                context.pivot_tables = &self.pivot_tables;
236                context.sheet_order = sheet_order.clone();
237
238                let _ = target_sheet.commit(Some(&context));
239            }
240        }
241
242        Ok(())
243    }
244
245    /// Evaluates one Excel function against this workbook, outside any cell.
246    ///
247    /// What `Application.WorksheetFunction.X` calls. Every sheet is in the
248    /// context, so an argument naming a range on any of them resolves; the
249    /// call itself is hosted on the first sheet, which only matters for the
250    /// handful of functions that read the calling cell's position -- and a
251    /// macro's call has no calling cell to read.
252    pub(crate) fn call_worksheet_function(
253        &self,
254        name: &str,
255        args: &[crate::core::parser::Expr],
256    ) -> Result<ResultData, crate::core::EngineError> {
257        let Some(host) = self.sheets.first() else {
258            return Err(crate::core::EngineError::EvalError(
259                crate::core::EvalError::UnknownFunction("no worksheets".to_string()),
260            ));
261        };
262        let mut context = Context::new();
263        for s in &self.sheets {
264            context.add_table(s.name.clone(), s);
265        }
266        context.pivot_tables = &self.pivot_tables;
267        context.sheet_order = self.sheets.iter().map(|s| s.name.clone()).collect();
268        host.call_worksheet_function(name, args, Some(&context))
269    }
270
271    /// Find index of sheet by name, or return default index 0 if name is None.
272    pub fn find_sheet_index(&self, name_opt: Option<&str>) -> crate::Result<usize> {
273        if self.sheets.is_empty() {
274            return Err(Error::EmptyWorkbook);
275        }
276
277        match name_opt {
278            Some(name) => {
279                if let Some(idx) = self
280                    .sheets
281                    .iter()
282                    .position(|s| s.name.eq_ignore_ascii_case(name))
283                {
284                    Ok(idx)
285                } else {
286                    let available: Vec<String> =
287                        self.sheets.iter().map(|s| s.name.clone()).collect();
288                    Err(Error::not_found_among(
289                        ObjectKind::Sheet,
290                        name.to_string(),
291                        available,
292                    ))
293                }
294            }
295            None => Ok(0),
296        }
297    }
298
299    /// Get structural summary of workbook
300    pub fn get_summary(&self, file_name: &str) -> WorkbookSummary {
301        let sheet_summaries = self
302            .sheets
303            .iter()
304            .map(|sheet| {
305                let row_count = sheet.row_count();
306                let col_count = sheet.col_count();
307                let mut formula_count = 0;
308
309                for col in &sheet.columns {
310                    for src in &col.src {
311                        if src.starts_with('=') {
312                            formula_count += 1;
313                        }
314                    }
315                }
316
317                SheetSummary {
318                    name: sheet.name.clone(),
319                    row_count,
320                    col_count,
321                    formula_count,
322                }
323            })
324            .collect();
325
326        WorkbookSummary {
327            file_name: file_name.to_string(),
328            sheet_count: self.sheets.len(),
329            chart_count: self.charts.len(),
330            sheets: sheet_summaries,
331        }
332    }
333
334    /// Ensure sheet bounds can accommodate specified target_row and target_col
335    pub fn ensure_capacity(&mut self, sheet_idx: usize, target_row: usize, target_col: usize) {
336        if sheet_idx >= self.sheets.len() {
337            return;
338        }
339        self.sheets[sheet_idx].ensure_capacity(target_row, target_col);
340    }
341
342    /// Merges `style` into one cell's existing style.
343    ///
344    /// Row and column are 0-based, like every other coordinate on this type.
345    /// A1 notation is a CLI/parser-boundary concern: callers holding a
346    /// string like `"Sheet2!B3"` parse it themselves and resolve the sheet
347    /// prefix against `sheet_name` before calling in.
348    pub fn set_cell_style(
349        &mut self,
350        sheet_name: Option<&str>,
351        row: usize,
352        col: usize,
353        style: crate::core::CellStyle,
354    ) -> crate::Result<()> {
355        let sheet_idx = self.find_sheet_index(sheet_name)?;
356        self.sheets[sheet_idx].update_cell_style(row, col, |s| s.merge(&style));
357        Ok(())
358    }
359
360    /// Merges `style` into every cell of an inclusive 0-based range.
361    pub fn set_range_style(
362        &mut self,
363        sheet_name: Option<&str>,
364        start_row: usize,
365        start_col: usize,
366        end_row: usize,
367        end_col: usize,
368        style: crate::core::CellStyle,
369    ) -> crate::Result<()> {
370        if end_row < start_row || end_col < start_col {
371            return Err(Error::InvalidRange(
372                "range end must not precede its start".to_string(),
373            ));
374        }
375        let sheet_idx = self.find_sheet_index(sheet_name)?;
376        for r in start_row..=end_row {
377            for c in start_col..=end_col {
378                self.sheets[sheet_idx].update_cell_style(r, c, |s| s.merge(&style));
379            }
380        }
381        Ok(())
382    }
383
384    /// The style applied to one 0-based cell, if it has one.
385    pub fn get_cell_style(
386        &self,
387        sheet_name: Option<&str>,
388        row: usize,
389        col: usize,
390    ) -> crate::Result<Option<crate::core::CellStyle>> {
391        let sheet_idx = self.find_sheet_index(sheet_name)?;
392        Ok(self.sheets[sheet_idx].get_cell_style(row, col).cloned())
393    }
394
395    /// Sets an Excel Table's visual style, looking the table up by name
396    /// across every sheet.
397    ///
398    /// # Errors
399    ///
400    /// [`Error::NotFound`] if no table in the workbook has that name.
401    pub fn set_table_style(&mut self, table_name: &str, style_name: &str) -> crate::Result<()> {
402        for sheet in &mut self.sheets {
403            for table in &mut sheet.tables {
404                if table.name.eq_ignore_ascii_case(table_name) {
405                    table.set_style_name(Some(style_name.to_string()));
406                    return Ok(());
407                }
408            }
409        }
410        Err(Error::not_found(ObjectKind::Table, table_name.to_string()))
411    }
412
413    /// An Excel Table's visual style, or `None` if it has none set.
414    ///
415    /// # Errors
416    ///
417    /// [`Error::NotFound`] if no table in the workbook has that name.
418    pub fn get_table_style(&self, table_name: &str) -> crate::Result<Option<String>> {
419        for sheet in &self.sheets {
420            for table in &sheet.tables {
421                if table.name.eq_ignore_ascii_case(table_name) {
422                    return Ok(table.style_name.clone());
423                }
424            }
425        }
426        Err(Error::not_found(ObjectKind::Table, table_name.to_string()))
427    }
428
429    /// Update cell source / value at (row, col)
430    pub fn set_cell(&mut self, sheet_idx: usize, row: usize, col: usize, value: String) {
431        self.ensure_capacity(sheet_idx, row, col);
432        let sheet = &mut self.sheets[sheet_idx];
433        sheet.set_cell_src(row, col, value);
434    }
435
436    /// Update cell source and explicit cell type at (row, col)
437    pub fn set_cell_with_type(
438        &mut self,
439        sheet_idx: usize,
440        row: usize,
441        col: usize,
442        value: String,
443        cell_type: crate::core::CellType,
444    ) {
445        self.ensure_capacity(sheet_idx, row, col);
446        let sheet = &mut self.sheets[sheet_idx];
447        sheet.set_cell_with_type(row, col, value, cell_type);
448    }
449
450    /// Returns the cell type at (row, col)
451    pub fn get_cell_type(&self, sheet_idx: usize, row: usize, col: usize) -> crate::core::CellType {
452        if let Some(sheet) = self.sheets.get(sheet_idx) {
453            sheet.get_cell_type(&crate::core::CellRef::new(row, col))
454        } else {
455            crate::core::CellType::Empty
456        }
457    }
458
459    /// Insert row at 0-based index.
460    ///
461    /// Formulas throughout the workbook are rewritten to follow the cells
462    /// that moved, as in Excel, and Excel Table and pivot ranges move with
463    /// the cells they cover. See `core::grid_edit` for the rules.
464    pub fn insert_row(&mut self, sheet_idx: usize, row_idx: usize) -> crate::Result<()> {
465        let sheet = &self.sheets[sheet_idx];
466        // `Sheet::insert_row` appends when the index is past the end, so the
467        // edit the rewrite is told about has to say the same thing.
468        let at = row_idx.min(sheet.row_count());
469        let edit = GridEdit::insert_row(sheet.id, at);
470        self.apply_grid_edit(edit, &[], |wb| wb.sheets[sheet_idx].insert_row(at));
471        self.evaluate()
472    }
473
474    /// Delete row at 0-based index.
475    ///
476    /// References to the deleted row become `#REF!` and references below it
477    /// move up, as in Excel.
478    pub fn delete_row(&mut self, sheet_idx: usize, row_idx: usize) -> crate::Result<()> {
479        let sheet = &self.sheets[sheet_idx];
480        if row_idx >= sheet.row_count() {
481            return Err(Error::OutOfBounds {
482                what: "row",
483                index: row_idx,
484                len: sheet.row_count(),
485            });
486        }
487        let edit = GridEdit::delete_row(sheet.id, row_idx);
488        self.apply_grid_edit(edit, &[], |wb| wb.sheets[sheet_idx].delete_row(row_idx));
489        self.evaluate()
490    }
491
492    /// Insert column at 0-based index.
493    pub fn insert_col(&mut self, sheet_idx: usize, col_idx: usize) -> crate::Result<()> {
494        let sheet = &self.sheets[sheet_idx];
495        let at = col_idx.min(sheet.col_count());
496        let edit = GridEdit::insert_col(sheet.id, at);
497        self.apply_grid_edit(edit, &[], |wb| wb.sheets[sheet_idx].insert_col(at));
498        self.evaluate()
499    }
500
501    /// Delete column at 0-based index.
502    pub fn delete_col(&mut self, sheet_idx: usize, col_idx: usize) -> crate::Result<()> {
503        let sheet = &self.sheets[sheet_idx];
504        if col_idx >= sheet.col_count() {
505            return Err(Error::OutOfBounds {
506                what: "column",
507                index: col_idx,
508                len: sheet.col_count(),
509            });
510        }
511        // A whole-column reference is held by column id, not position, so the
512        // only way to tell whether the edit broke one is to note the id
513        // before the column is gone.
514        let deleted_col_ids = vec![sheet.columns()[col_idx].id];
515        let edit = GridEdit::delete_col(sheet.id, col_idx);
516        self.apply_grid_edit(edit, &deleted_col_ids, |wb| {
517            wb.sheets[sheet_idx].delete_col(col_idx)
518        });
519        self.evaluate()
520    }
521
522    /// Excel's *Insert cells, shift down* over an inclusive column band,
523    /// with the workbook-wide formula rewrite that goes with it.
524    ///
525    /// This is what `ListRows.Add` is: only `first_col..=last_col` move, so a
526    /// formula beside the band stays put while one inside it shifts. See
527    /// `core::grid_edit`'s `band` field for the reference rules, which are
528    /// measured rather than assumed.
529    pub fn insert_cells_shift_down(
530        &mut self,
531        sheet_idx: usize,
532        row: usize,
533        first_col: usize,
534        last_col: usize,
535        count: usize,
536    ) -> crate::Result<()> {
537        let sheet = &self.sheets[sheet_idx];
538        let edit = GridEdit::band_rows(sheet.id, row, count, first_col, last_col, true);
539        self.apply_grid_edit(edit, &[], |wb| {
540            wb.sheets[sheet_idx].insert_cells_shift_down(row, first_col, last_col, count)
541        });
542        self.evaluate()
543    }
544
545    /// Excel's *Delete cells, shift up* over an inclusive column band; the
546    /// inverse of [`WorkbookManager::insert_cells_shift_down`].
547    pub fn delete_cells_shift_up(
548        &mut self,
549        sheet_idx: usize,
550        row: usize,
551        first_col: usize,
552        last_col: usize,
553        count: usize,
554    ) -> crate::Result<()> {
555        let sheet = &self.sheets[sheet_idx];
556        let edit = GridEdit::band_rows(sheet.id, row, count, first_col, last_col, false);
557        self.apply_grid_edit(edit, &[], |wb| {
558            wb.sheets[sheet_idx].delete_cells_shift_up(row, first_col, last_col, count)
559        });
560        self.evaluate()
561    }
562
563    /// Runs a structural edit, keeping everything that holds a coordinate
564    /// pointing at what it pointed at before.
565    ///
566    /// Three phases, and the order is the whole point:
567    ///
568    /// 1. **Before the edit**, compile every formula in the workbook and
569    ///    shift its references. Compiling needs the grid the formula text was
570    ///    written against.
571    /// 2. Apply the edit itself, via `apply`.
572    /// 3. **After the edit**, serialize the shifted formulas back to text and
573    ///    write each one at wherever its own cell moved to.
574    ///
575    /// Phase 3 cannot be folded into phase 1. A whole-column reference
576    /// renders as the column's *current* letter, so serializing `=SUM(B:B)`
577    /// before a column is inserted to its left would write `B:B` into a cell
578    /// where `B` now names a different column -- and `src` is what the next
579    /// recompile reads, so the wrong text wins.
580    fn apply_grid_edit(
581        &mut self,
582        edit: GridEdit,
583        deleted_col_ids: &[u64],
584        apply: impl FnOnce(&mut Self),
585    ) {
586        // Phase 1: compile against the pre-edit grid and shift. Formulas the
587        // edit does not touch are skipped outright rather than rewritten to
588        // an equivalent spelling.
589        let mut shifted: Vec<(usize, usize, usize, CompiledFormula)> = Vec::new();
590        for (sheet_idx, sheet) in self.sheets.iter().enumerate() {
591            for (col_idx, column) in sheet.columns().iter().enumerate() {
592                for row_idx in 0..column.len() {
593                    let Some(src) = column.src(row_idx).filter(|s| s.starts_with('=')) else {
594                        continue;
595                    };
596                    let compiled = crate::core::parser::compile_formula(src, &self.sheets);
597                    if let Some(next) =
598                        crate::core::grid_edit::shift_formula(&compiled, &edit, deleted_col_ids)
599                    {
600                        shifted.push((sheet_idx, col_idx, row_idx, next));
601                    }
602                }
603            }
604        }
605
606        // Phase 2.
607        apply(self);
608        self.shift_table_and_pivot_ranges(&edit);
609
610        // Phase 3.
611        for (sheet_idx, col_idx, row_idx, compiled) in shifted {
612            let Some((row, col)) = self.moved_cell(&edit, sheet_idx, row_idx, col_idx) else {
613                // The cell holding the formula was itself deleted.
614                continue;
615            };
616            let text = crate::core::parser::serialize_formula(&compiled, &self.sheets);
617            self.sheets[sheet_idx].set_cell_src(row, col, text);
618        }
619    }
620
621    /// Where the cell at `(row, col)` on `sheet_idx` ends up after `edit`, or
622    /// `None` if the edit deleted it.
623    fn moved_cell(
624        &self,
625        edit: &GridEdit,
626        sheet_idx: usize,
627        row: usize,
628        col: usize,
629    ) -> Option<(usize, usize)> {
630        // A band edit moves only its own columns, so a formula *beside* the
631        // band stays where it is -- including one below the insert row.
632        // Getting this wrong writes the rewritten formula into the cell below
633        // the right one and leaves the original in place.
634        if self.sheets[sheet_idx].id != edit.sheet_id || !edit.covers_columns(col, col) {
635            return Some((row, col));
636        }
637        let moved = |index: usize| {
638            crate::core::grid_edit::shift_point(index, edit.at, edit.count, edit.insert)
639        };
640        match edit.axis {
641            Axis::Row => Some((moved(row)?, col)),
642            Axis::Col => Some((row, moved(col)?)),
643        }
644    }
645
646    /// Moves the Excel Table and pivot rectangles the edit passed through.
647    ///
648    /// A table or a pivot source whose every row (or every column) was
649    /// deleted has nothing left to describe, so it is dropped -- the same
650    /// thing Excel does when you delete the last row of a one-row table.
651    fn shift_table_and_pivot_ranges(&mut self, edit: &GridEdit) {
652        use crate::core::grid_edit::{shift_point, shift_rect};
653
654        for sheet in &mut self.sheets {
655            if sheet.id != edit.sheet_id {
656                continue;
657            }
658            sheet.tables.retain_mut(|table| {
659                // A band edit moves a table only if the table's columns are
660                // wholly inside the band, exactly as for a reference.
661                if !edit.covers_columns(table.start_col, table.end_col) {
662                    return true;
663                }
664                match shift_rect(
665                    edit,
666                    table.start_row,
667                    table.start_col,
668                    table.end_row,
669                    table.end_col,
670                ) {
671                    Some((r0, c0, r1, c1)) => {
672                        // Column names are per sheet-column, so dropping a
673                        // column has to drop its name with it or every name
674                        // past it shifts onto the wrong column.
675                        if edit.axis == Axis::Col {
676                            resize_table_columns(table, c0, c1, edit);
677                        }
678                        table.start_row = r0;
679                        table.start_col = c0;
680                        table.end_row = r1;
681                        table.end_col = c1;
682                        true
683                    }
684                    None => false,
685                }
686            });
687        }
688
689        for pivot in &mut self.pivot_tables {
690            if let PivotSource::Range {
691                sheet_id,
692                start_row,
693                start_col,
694                end_row,
695                end_col,
696            } = &mut pivot.source
697                && *sheet_id == edit.sheet_id
698                && edit.covers_columns(*start_col, *end_col)
699                && let Some((r0, c0, r1, c1)) =
700                    shift_rect(edit, *start_row, *start_col, *end_row, *end_col)
701            {
702                *start_row = r0;
703                *start_col = c0;
704                *end_row = r1;
705                *end_col = c1;
706            }
707
708            if pivot.dest_sheet_id == edit.sheet_id
709                && edit.covers_columns(pivot.dest_col, pivot.dest_col)
710            {
711                // The destination is a corner, not a span. A deleted corner
712                // clamps to the edit rather than vanishing: the grid is
713                // rewritten wholesale on the next refresh anyway, so what
714                // matters is that it names a live cell.
715                match edit.axis {
716                    Axis::Row => {
717                        pivot.dest_row =
718                            shift_point(pivot.dest_row, edit.at, edit.count, edit.insert)
719                                .unwrap_or(edit.at);
720                    }
721                    Axis::Col => {
722                        pivot.dest_col =
723                            shift_point(pivot.dest_col, edit.at, edit.count, edit.insert)
724                                .unwrap_or(edit.at);
725                    }
726                }
727                // The last rendered extent is only used to clear stale cells,
728                // so a stale one over-clears rather than under-clears; drop it
729                // and let the next refresh re-record it.
730                pivot.last_output_end_row = None;
731                pivot.last_output_end_col = None;
732            }
733        }
734    }
735
736    /// Add new sheet with specified name
737    pub fn add_sheet(&mut self, name: &str) -> crate::Result<()> {
738        if self
739            .sheets
740            .iter()
741            .any(|s| s.name.eq_ignore_ascii_case(name))
742        {
743            return Err(Error::AlreadyExists {
744                kind: ObjectKind::Sheet,
745                name: name.to_string(),
746            });
747        }
748
749        let mut columns = Vec::new();
750        for col_idx in 0..5 {
751            let mut col = DataColumn::new(10);
752            col.id = generate_unique_id();
753            col.name = col_idx_to_letters(col_idx);
754            columns.push(col);
755        }
756
757        let new_sheet = Sheet {
758            id: generate_unique_id(),
759            name: name.to_string(),
760            columns,
761            tables: Vec::new(),
762            dependencies: std::collections::HashMap::new(),
763            dependencies_rev: std::collections::HashMap::new(),
764            uncommitted_actions: Vec::new(),
765        };
766
767        self.sheets.push(new_sheet);
768        Ok(())
769    }
770
771    /// Delete sheet by name
772    pub fn delete_sheet(&mut self, name: &str) -> crate::Result<()> {
773        let idx = self.find_sheet_index(Some(name))?;
774        if self.sheets.len() <= 1 {
775            return Err(Error::LastSheetInWorkbook);
776        }
777        self.sheets.remove(idx);
778        Ok(())
779    }
780
781    /// Rename sheet
782    pub fn rename_sheet(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
783        let idx = self.find_sheet_index(Some(old_name))?;
784        if self
785            .sheets
786            .iter()
787            .enumerate()
788            .any(|(i, s)| i != idx && s.name.eq_ignore_ascii_case(new_name))
789        {
790            return Err(Error::NameTaken {
791                kind: ObjectKind::Sheet,
792                name: new_name.to_string(),
793            });
794        }
795        self.sheets[idx].name = new_name.to_string();
796        Ok(())
797    }
798
799    /// Add chart to workbook
800    #[allow(clippy::too_many_arguments)]
801    pub fn add_chart(
802        &mut self,
803        sheet_name: &str,
804        chart_type: ChartType,
805        range: String,
806        title: Option<String>,
807        anchor: Option<(usize, usize)>,
808    ) -> crate::Result<u64> {
809        let _ = self.find_sheet_index(Some(sheet_name))?;
810        let id = generate_unique_id();
811        let name = format!("Chart {}", self.charts.len() + 1);
812        let (anchor_row, anchor_col) = anchor.unwrap_or((0, 0));
813
814        let chart = Chart {
815            id,
816            name,
817            chart_type,
818            data_range: range,
819            title,
820            xlabel: None,
821            ylabel: None,
822            show_legend: true,
823            anchor_row,
824            anchor_col,
825        };
826
827        self.charts.push(chart);
828        Ok(id)
829    }
830
831    /// Edit an existing chart's properties. Every parameter is optional;
832    /// `None` leaves that field unchanged. `title`/`xlabel`/`ylabel` are
833    /// tri-state (`Option<Option<String>>`): outer `None` leaves the field
834    /// unchanged, `Some(None)` clears it, `Some(Some(text))` sets it.
835    #[allow(clippy::too_many_arguments)]
836    pub fn edit_chart(
837        &mut self,
838        id: u64,
839        name: Option<String>,
840        chart_type: Option<ChartType>,
841        data_range: Option<String>,
842        title: Option<Option<String>>,
843        xlabel: Option<Option<String>>,
844        ylabel: Option<Option<String>>,
845        show_legend: Option<bool>,
846        anchor: Option<(usize, usize)>,
847    ) -> crate::Result<()> {
848        let chart = self
849            .charts
850            .iter_mut()
851            .find(|c| c.id == id)
852            .ok_or_else(|| Error::not_found(ObjectKind::Chart, id.to_string()))?;
853        if let Some(name) = name {
854            chart.name = name;
855        }
856        if let Some(chart_type) = chart_type {
857            chart.chart_type = chart_type;
858        }
859        if let Some(data_range) = data_range {
860            chart.data_range = data_range;
861        }
862        if let Some(title) = title {
863            chart.title = title;
864        }
865        if let Some(xlabel) = xlabel {
866            chart.xlabel = xlabel;
867        }
868        if let Some(ylabel) = ylabel {
869            chart.ylabel = ylabel;
870        }
871        if let Some(show_legend) = show_legend {
872            chart.show_legend = show_legend;
873        }
874        if let Some((anchor_row, anchor_col)) = anchor {
875            chart.anchor_row = anchor_row;
876            chart.anchor_col = anchor_col;
877        }
878        Ok(())
879    }
880
881    /// Whether the workbook carries a VBA project.
882    pub fn has_vba_project(&self) -> bool {
883        self.vba_project.is_some()
884    }
885
886    /// Lists every module in the workbook's VBA project, if it has one.
887    pub fn list_vba_modules(&self) -> Vec<&VbaModule> {
888        self.vba_project
889            .as_ref()
890            .map(|p| p.modules.iter().collect())
891            .unwrap_or_default()
892    }
893
894    /// Creates an empty, entirely synthetic VBA project (see
895    /// `VbaProject::new_empty`) if this workbook doesn't already have one.
896    /// Idempotent.
897    pub fn ensure_vba_project(&mut self) -> crate::Result<()> {
898        if self.vba_project.is_some() {
899            return Ok(());
900        }
901        self.vba_project = Some(VbaProject::new_empty());
902        Ok(())
903    }
904
905    /// Adds a new module to the workbook's VBA project (creating the
906    /// project from the bundled template first, if needed). `bound_sheet_id`
907    /// is required for `VbaModuleKind::Document` (except when `name` is
908    /// `"ThisWorkbook"`, which -- like real Excel's own always-present
909    /// ThisWorkbook module -- isn't tied to a specific sheet; any
910    /// `bound_sheet_id` passed alongside it is ignored rather than stored)
911    /// -- note this does NOT rename the sheet, or vice versa; Excel allows a
912    /// document module's own name and its sheet's display name to diverge,
913    /// and this codebase deliberately doesn't cascade one into the other.
914    pub fn add_vba_module(
915        &mut self,
916        name: String,
917        kind: VbaModuleKind,
918        source: String,
919        bound_sheet_id: Option<u64>,
920    ) -> crate::Result<()> {
921        validate_vba_module_name(&name).map_err(|reason| Error::InvalidName {
922            kind: ObjectKind::VbaModule,
923            name: name.clone(),
924            reason,
925        })?;
926        let is_this_workbook = kind == VbaModuleKind::Document && name == "ThisWorkbook";
927        if kind == VbaModuleKind::Document && !is_this_workbook {
928            let sheet_id = bound_sheet_id
929                .ok_or_else(|| Error::Vba("document modules require a bound sheet".to_string()))?;
930            if !self.sheets.iter().any(|s| s.id == sheet_id) {
931                return Err(Error::not_found(ObjectKind::Sheet, sheet_id.to_string()));
932            }
933        }
934        self.ensure_vba_project()?;
935        let project = self.vba_project.as_mut().unwrap();
936        if project.module_name_taken(&name) {
937            return Err(Error::AlreadyExists {
938                kind: ObjectKind::VbaModule,
939                name: name.to_string(),
940            });
941        }
942        if kind == VbaModuleKind::Document
943            && bound_sheet_id.is_some()
944            && project
945                .modules
946                .iter()
947                .any(|m| m.kind == VbaModuleKind::Document && m.bound_sheet_id == bound_sheet_id)
948        {
949            return Err(Error::DocumentModuleExists);
950        }
951        // Donate p-code prefix bytes and a module cookie from any existing
952        // module in this same project -- proven (via a scratchpad
953        // proof-of-concept against real Excel) that the prefix's content
954        // doesn't need to correspond to the module it precedes, only its
955        // shape matters. If this project has no modules yet (the common
956        // case for one freshly created by `ensure_vba_project`), fall back
957        // to the synthetic seed values instead.
958        let prefix_bytes = project
959            .modules
960            .first()
961            .map(|m| m.prefix_bytes.clone())
962            .unwrap_or_else(|| project.seed_prefix_bytes.clone());
963        let module_cookie = project
964            .modules
965            .first()
966            .map(|m| m.module_cookie)
967            .unwrap_or(project.seed_module_cookie);
968        let stored_bound_sheet_id = if kind == VbaModuleKind::Document && !is_this_workbook {
969            bound_sheet_id
970        } else {
971            None
972        };
973        project.modules.push(VbaModule {
974            name,
975            kind,
976            source,
977            bound_sheet_id: stored_bound_sheet_id,
978            prefix_bytes,
979            module_cookie,
980            // A brand-new module has no already-compressed form to reuse.
981            cached_compressed_source: None,
982        });
983        Ok(())
984    }
985
986    /// Removes a VBA module by name, matched case-insensitively.
987    ///
988    /// # Errors
989    ///
990    /// [`Error::Vba`] if the workbook has no VBA project, or
991    /// [`Error::NotFound`] if it has no module by that name.
992    pub fn remove_vba_module(&mut self, name: &str) -> crate::Result<()> {
993        let project = self
994            .vba_project
995            .as_mut()
996            .ok_or_else(|| Error::Vba("workbook has no VBA project".to_string()))?;
997        let before = project.modules.len();
998        project
999            .modules
1000            .retain(|m| !m.name.eq_ignore_ascii_case(name));
1001        if project.modules.len() == before {
1002            return Err(Error::not_found(ObjectKind::VbaModule, name.to_string()));
1003        }
1004        Ok(())
1005    }
1006
1007    /// Renames a VBA module.
1008    ///
1009    /// Renames only the module; VBA source that calls into it is not
1010    /// rewritten, so a module referenced by name elsewhere will no longer
1011    /// resolve.
1012    ///
1013    /// # Errors
1014    ///
1015    /// [`Error::InvalidName`] if `new_name` is not a valid VBA identifier,
1016    /// [`Error::AlreadyExists`] if another module already has it,
1017    /// [`Error::Vba`] if the workbook has no VBA project, or
1018    /// [`Error::NotFound`] if it has no module called `old_name`.
1019    pub fn rename_vba_module(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
1020        validate_vba_module_name(new_name).map_err(|reason| Error::InvalidName {
1021            kind: ObjectKind::VbaModule,
1022            name: new_name.to_string(),
1023            reason,
1024        })?;
1025        let project = self
1026            .vba_project
1027            .as_mut()
1028            .ok_or_else(|| Error::Vba("workbook has no VBA project".to_string()))?;
1029        if !old_name.eq_ignore_ascii_case(new_name) && project.module_name_taken(new_name) {
1030            return Err(Error::AlreadyExists {
1031                kind: ObjectKind::VbaModule,
1032                name: new_name.to_string(),
1033            });
1034        }
1035        let module = project
1036            .find_module_mut(old_name)
1037            .ok_or_else(|| Error::not_found(ObjectKind::VbaModule, old_name))?;
1038        module.name = new_name.to_string();
1039        Ok(())
1040    }
1041
1042    /// Replaces a VBA module's source text.
1043    ///
1044    /// The caller supplies the whole module body, including its
1045    /// `Attribute VB_Name = "..."` line, matching how real Excel-authored
1046    /// module streams are shaped.
1047    ///
1048    /// # Errors
1049    ///
1050    /// [`Error::Vba`] if the workbook has no VBA project, or
1051    /// [`Error::NotFound`] if it has no module by that name.
1052    pub fn set_vba_module_source(&mut self, name: &str, source: String) -> crate::Result<()> {
1053        let project = self
1054            .vba_project
1055            .as_mut()
1056            .ok_or_else(|| Error::Vba("workbook has no VBA project".to_string()))?;
1057        let module = project
1058            .find_module_mut(name)
1059            .ok_or_else(|| Error::not_found(ObjectKind::VbaModule, name))?;
1060        module.source = source;
1061        // Invalidate the cached compressed form -- see
1062        // VbaModule::cached_compressed_source -- since it no longer matches
1063        // the new `source`.
1064        module.cached_compressed_source = None;
1065        Ok(())
1066    }
1067
1068    /// Delete chart by u64 ID
1069    pub fn delete_chart(&mut self, id: u64) -> crate::Result<()> {
1070        if let Some(pos) = self.charts.iter().position(|c| c.id == id) {
1071            self.charts.remove(pos);
1072            Ok(())
1073        } else {
1074            Err(Error::not_found(ObjectKind::Chart, id.to_string()))
1075        }
1076    }
1077
1078    /// Find the sheet that owns the table with the given name, and the
1079    /// table itself. Table names are unique across the whole workbook.
1080    pub fn find_table(&self, name: &str) -> Option<(&Sheet, &ExcelTable)> {
1081        self.sheets
1082            .iter()
1083            .find_map(|s| s.find_table(name).map(|t| (s, t)))
1084    }
1085
1086    /// List every table in the workbook, alongside the name of the sheet it
1087    /// lives on.
1088    pub fn list_tables(&self) -> Vec<(&str, &ExcelTable)> {
1089        self.sheets
1090            .iter()
1091            .flat_map(|s| s.tables.iter().map(move |t| (s.name.as_str(), t)))
1092            .collect()
1093    }
1094
1095    fn find_table_sheet_index(&self, name: &str) -> crate::Result<usize> {
1096        self.sheets
1097            .iter()
1098            .position(|s| s.find_table(name).is_some())
1099            .ok_or_else(|| Error::not_found(ObjectKind::Table, name))
1100    }
1101
1102    fn table_name_taken(&self, name: &str) -> bool {
1103        self.sheets
1104            .iter()
1105            .any(|s| s.tables.iter().any(|t| t.name.eq_ignore_ascii_case(name)))
1106    }
1107
1108    /// Define a new Excel Table over an existing cell range on a sheet.
1109    /// Table names are unique across the entire workbook (not just the
1110    /// sheet), matching how Excel itself scopes structured-reference names.
1111    #[allow(clippy::too_many_arguments)]
1112    pub fn add_table(
1113        &mut self,
1114        sheet_name: Option<&str>,
1115        name: &str,
1116        start_row: usize,
1117        start_col: usize,
1118        end_row: usize,
1119        end_col: usize,
1120        has_header_row: bool,
1121        has_totals_row: bool,
1122    ) -> crate::Result<u64> {
1123        if self.table_name_taken(name) {
1124            return Err(Error::AlreadyExists {
1125                kind: ObjectKind::Table,
1126                name: name.to_string(),
1127            });
1128        }
1129        let idx = self.find_sheet_index(sheet_name)?;
1130        self.sheets[idx]
1131            .add_table(
1132                name.to_string(),
1133                start_row,
1134                start_col,
1135                end_row,
1136                end_col,
1137                has_header_row,
1138                has_totals_row,
1139            )
1140            .map_err(Error::InvalidArgument)
1141    }
1142
1143    /// Delete a table by name (leaves the underlying cell contents alone).
1144    pub fn delete_table(&mut self, name: &str) -> crate::Result<()> {
1145        let idx = self.find_table_sheet_index(name)?;
1146        self.sheets[idx]
1147            .delete_table_by_name(name)
1148            .map_err(Error::InvalidArgument)
1149    }
1150
1151    /// Rename a table.
1152    pub fn rename_table(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
1153        if !old_name.eq_ignore_ascii_case(new_name) && self.table_name_taken(new_name) {
1154            return Err(Error::NameTaken {
1155                kind: ObjectKind::Table,
1156                name: new_name.to_string(),
1157            });
1158        }
1159        let idx = self.find_table_sheet_index(old_name)?;
1160        self.sheets[idx]
1161            .rename_table(old_name, new_name)
1162            .map_err(Error::InvalidArgument)?;
1163        // Excel updates every structured reference to a renamed table, so a
1164        // formula like `=SUM(Sales[Amount])` keeps working after "Sales" is
1165        // renamed; match that instead of silently breaking those formulas.
1166        self.rewrite_table_references(old_name, Some(new_name), None);
1167        self.evaluate()
1168    }
1169
1170    /// Rewrites every formula in the workbook that structurally references
1171    /// `table_name` (optionally renaming the table and/or one column),
1172    /// mirroring how Excel keeps structured references in sync when a Table
1173    /// or one of its column headers is renamed.
1174    fn rewrite_table_references(
1175        &mut self,
1176        table_name: &str,
1177        new_table_name: Option<&str>,
1178        col_rename: Option<(&str, &str)>,
1179    ) {
1180        for sheet in &mut self.sheets {
1181            for col_idx in 0..sheet.columns.len() {
1182                let row_count = sheet.columns[col_idx].src.len();
1183                for row_idx in 0..row_count {
1184                    let src = sheet.columns[col_idx].src[row_idx].clone();
1185                    if let Some(new_src) = crate::core::parser::rewrite_structured_table_reference(
1186                        &src,
1187                        table_name,
1188                        new_table_name,
1189                        col_rename,
1190                    ) {
1191                        sheet.set_cell_src(row_idx, col_idx, new_src);
1192                    }
1193                }
1194            }
1195        }
1196    }
1197
1198    /// Resize a table by moving its bottom-right corner.
1199    pub fn resize_table(
1200        &mut self,
1201        name: &str,
1202        new_end_row: usize,
1203        new_end_col: usize,
1204    ) -> crate::Result<()> {
1205        let idx = self.find_table_sheet_index(name)?;
1206        self.sheets[idx]
1207            .resize_table(name, new_end_row, new_end_col)
1208            .map_err(Error::InvalidArgument)
1209    }
1210
1211    /// Rename one column (0-based, relative to the table) of a table.
1212    pub fn rename_table_column(
1213        &mut self,
1214        table_name: &str,
1215        col_index: usize,
1216        new_name: &str,
1217    ) -> crate::Result<()> {
1218        let idx = self.find_table_sheet_index(table_name)?;
1219        let old_col_name = self.sheets[idx]
1220            .find_table(table_name)
1221            .and_then(|t| t.columns.get(col_index).cloned())
1222            .ok_or_else(|| {
1223                Error::InvalidArgument(format!(
1224                    "column index {col_index} out of bounds for table '{table_name}'"
1225                ))
1226            })?;
1227        self.sheets[idx]
1228            .rename_table_column(table_name, col_index, new_name)
1229            .map_err(Error::InvalidArgument)?;
1230        // As with rename_table, keep dependent formulas working across the
1231        // rename instead of leaving them referencing the old column name.
1232        self.rewrite_table_references(table_name, None, Some((&old_col_name, new_name)));
1233        self.evaluate()
1234    }
1235
1236    /// Find a pivot table by name (case-insensitive).
1237    pub fn find_pivot_table(&self, name: &str) -> Option<&PivotTable> {
1238        self.pivot_tables
1239            .iter()
1240            .find(|p| p.name.eq_ignore_ascii_case(name))
1241    }
1242
1243    fn find_pivot_table_index(&self, name: &str) -> crate::Result<usize> {
1244        self.pivot_tables
1245            .iter()
1246            .position(|p| p.name.eq_ignore_ascii_case(name))
1247            .ok_or_else(|| Error::not_found(ObjectKind::PivotTable, name))
1248    }
1249
1250    /// List every pivot table in the workbook.
1251    pub fn list_pivot_tables(&self) -> &[PivotTable] {
1252        &self.pivot_tables
1253    }
1254
1255    fn pivot_table_name_taken(&self, name: &str) -> bool {
1256        self.pivot_tables
1257            .iter()
1258            .any(|p| p.name.eq_ignore_ascii_case(name))
1259    }
1260
1261    /// Defines a new pivot table sourced from an existing Excel Table, with
1262    /// no fields assigned yet -- mirroring Excel inserting an empty
1263    /// PivotTable shell that fills in as fields are added to it.
1264    #[allow(clippy::too_many_arguments)]
1265    pub fn add_pivot_table_from_table(
1266        &mut self,
1267        name: &str,
1268        source_table_name: &str,
1269        dest_sheet_name: Option<&str>,
1270        dest_row: usize,
1271        dest_col: usize,
1272        grand_totals_row: bool,
1273        grand_totals_col: bool,
1274    ) -> crate::Result<u64> {
1275        if self.pivot_table_name_taken(name) {
1276            return Err(Error::AlreadyExists {
1277                kind: ObjectKind::PivotTable,
1278                name: name.to_string(),
1279            });
1280        }
1281        self.find_table(source_table_name)
1282            .ok_or_else(|| Error::not_found(ObjectKind::Table, source_table_name))?;
1283        let dest_idx = self.find_sheet_index(dest_sheet_name)?;
1284        let id = generate_unique_id();
1285        self.pivot_tables.push(PivotTable {
1286            id,
1287            name: name.to_string(),
1288            source: PivotSource::Table {
1289                name: source_table_name.to_string(),
1290            },
1291            dest_sheet_id: self.sheets[dest_idx].id,
1292            dest_row,
1293            dest_col,
1294            row_fields: Vec::new(),
1295            col_fields: Vec::new(),
1296            value_fields: Vec::new(),
1297            filter_fields: Vec::new(),
1298            grand_totals_row,
1299            grand_totals_col,
1300            last_output_end_row: None,
1301            last_output_end_col: None,
1302        });
1303        self.refresh_pivot_table(name)?;
1304        Ok(id)
1305    }
1306
1307    /// Defines a new pivot table sourced from a plain cell range (its first
1308    /// row is treated as column headers), with no fields assigned yet.
1309    #[allow(clippy::too_many_arguments)]
1310    pub fn add_pivot_table_from_range(
1311        &mut self,
1312        name: &str,
1313        source_sheet_name: Option<&str>,
1314        start_row: usize,
1315        start_col: usize,
1316        end_row: usize,
1317        end_col: usize,
1318        dest_sheet_name: Option<&str>,
1319        dest_row: usize,
1320        dest_col: usize,
1321        grand_totals_row: bool,
1322        grand_totals_col: bool,
1323    ) -> crate::Result<u64> {
1324        if self.pivot_table_name_taken(name) {
1325            return Err(Error::AlreadyExists {
1326                kind: ObjectKind::PivotTable,
1327                name: name.to_string(),
1328            });
1329        }
1330        let src_idx = self.find_sheet_index(source_sheet_name)?;
1331        let dest_idx = self.find_sheet_index(dest_sheet_name)?;
1332        let id = generate_unique_id();
1333        self.pivot_tables.push(PivotTable {
1334            id,
1335            name: name.to_string(),
1336            source: PivotSource::Range {
1337                sheet_id: self.sheets[src_idx].id,
1338                start_row,
1339                start_col,
1340                end_row,
1341                end_col,
1342            },
1343            dest_sheet_id: self.sheets[dest_idx].id,
1344            dest_row,
1345            dest_col,
1346            row_fields: Vec::new(),
1347            col_fields: Vec::new(),
1348            value_fields: Vec::new(),
1349            filter_fields: Vec::new(),
1350            grand_totals_row,
1351            grand_totals_col,
1352            last_output_end_row: None,
1353            last_output_end_col: None,
1354        });
1355        self.refresh_pivot_table(name)?;
1356        Ok(id)
1357    }
1358
1359    /// Deletes a pivot table definition and clears its last rendered output
1360    /// range (leaves the source data untouched).
1361    pub fn delete_pivot_table(&mut self, name: &str) -> crate::Result<()> {
1362        let idx = self.find_pivot_table_index(name)?;
1363        let pivot = self.pivot_tables.remove(idx);
1364        if let (Some(end_row), Some(end_col)) =
1365            (pivot.last_output_end_row, pivot.last_output_end_col)
1366            && let Some(sheet_idx) = self.sheets.iter().position(|s| s.id == pivot.dest_sheet_id)
1367        {
1368            self.clear_range(sheet_idx, pivot.dest_row, pivot.dest_col, end_row, end_col);
1369        }
1370        Ok(())
1371    }
1372
1373    /// Renames a pivot table (names are unique workbook-wide, like tables).
1374    pub fn rename_pivot_table(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
1375        if !old_name.eq_ignore_ascii_case(new_name) && self.pivot_table_name_taken(new_name) {
1376            return Err(Error::NameTaken {
1377                kind: ObjectKind::PivotTable,
1378                name: new_name.to_string(),
1379            });
1380        }
1381        let idx = self.find_pivot_table_index(old_name)?;
1382        self.pivot_tables[idx].name = new_name.to_string();
1383        Ok(())
1384    }
1385
1386    /// Adds a field to one of a pivot table's four areas (Row/Column/
1387    /// Value/Filter) and immediately refreshes its output, mirroring
1388    /// Excel's live-updating field list.
1389    ///
1390    /// A field can only occupy one area at a time, exactly like dragging a
1391    /// field to a new area in Excel's field list moves it rather than
1392    /// duplicating it (confirmed via the win32com driver: setting
1393    /// `PivotField.Orientation` a second time relocates the field). Value
1394    /// fields are the one exception -- Excel allows the same source column
1395    /// to appear as multiple value fields simultaneously (e.g. both "Sum of
1396    /// Amount" and "Min of Amount"), so adding to `PivotArea::Value` does
1397    /// not evict the column from Row/Column/Filter, and vice versa a
1398    /// Row/Column/Filter add does not evict existing value fields.
1399    pub fn add_pivot_field(
1400        &mut self,
1401        pivot_name: &str,
1402        area: PivotArea,
1403        column: &str,
1404        aggregation: Option<PivotAggregation>,
1405    ) -> crate::Result<()> {
1406        let idx = self.find_pivot_table_index(pivot_name)?;
1407        if !matches!(area, PivotArea::Value) {
1408            let pivot = &mut self.pivot_tables[idx];
1409            remove_pivot_field(&mut pivot.row_fields, column);
1410            remove_pivot_field(&mut pivot.col_fields, column);
1411            pivot
1412                .filter_fields
1413                .retain(|f| !f.column.eq_ignore_ascii_case(column));
1414        }
1415        match area {
1416            PivotArea::Row => self.pivot_tables[idx]
1417                .row_fields
1418                .push(PivotField::new(column)),
1419            PivotArea::Column => self.pivot_tables[idx]
1420                .col_fields
1421                .push(PivotField::new(column)),
1422            PivotArea::Value => {
1423                let agg = aggregation.unwrap_or(PivotAggregation::Sum);
1424                self.pivot_tables[idx]
1425                    .value_fields
1426                    .push(PivotValueField::new(column, agg));
1427            }
1428            PivotArea::Filter => self.pivot_tables[idx]
1429                .filter_fields
1430                .push(PivotFilterField::new(column)),
1431        }
1432        self.refresh_pivot_table(pivot_name)
1433    }
1434
1435    /// Removes a field from one of a pivot table's four areas and
1436    /// refreshes its output.
1437    pub fn remove_pivot_field(
1438        &mut self,
1439        pivot_name: &str,
1440        area: PivotArea,
1441        column: &str,
1442    ) -> crate::Result<()> {
1443        let idx = self.find_pivot_table_index(pivot_name)?;
1444        let removed = match area {
1445            PivotArea::Row => remove_pivot_field(&mut self.pivot_tables[idx].row_fields, column),
1446            PivotArea::Column => remove_pivot_field(&mut self.pivot_tables[idx].col_fields, column),
1447            PivotArea::Value => {
1448                let before = self.pivot_tables[idx].value_fields.len();
1449                self.pivot_tables[idx]
1450                    .value_fields
1451                    .retain(|f| !f.column.eq_ignore_ascii_case(column));
1452                before != self.pivot_tables[idx].value_fields.len()
1453            }
1454            PivotArea::Filter => {
1455                let before = self.pivot_tables[idx].filter_fields.len();
1456                self.pivot_tables[idx]
1457                    .filter_fields
1458                    .retain(|f| !f.column.eq_ignore_ascii_case(column));
1459                before != self.pivot_tables[idx].filter_fields.len()
1460            }
1461        };
1462        if !removed {
1463            return Err(Error::not_found(
1464                ObjectKind::PivotField,
1465                format!("{column}' in pivot table '{pivot_name}"),
1466            ));
1467        }
1468        self.refresh_pivot_table(pivot_name)
1469    }
1470
1471    /// Restricts (or clears, with `values: None`) a filter field's allowed
1472    /// values and refreshes the pivot table's output.
1473    pub fn set_pivot_filter(
1474        &mut self,
1475        pivot_name: &str,
1476        column: &str,
1477        values: Option<Vec<String>>,
1478    ) -> crate::Result<()> {
1479        let idx = self.find_pivot_table_index(pivot_name)?;
1480        let field = self.pivot_tables[idx]
1481            .filter_fields
1482            .iter_mut()
1483            .find(|f| f.column.eq_ignore_ascii_case(column))
1484            .ok_or_else(|| {
1485                Error::not_found(
1486                    ObjectKind::PivotField,
1487                    format!("{column}' on pivot table '{pivot_name}"),
1488                )
1489            })?;
1490        field.selected_values = values;
1491        self.refresh_pivot_table(pivot_name)
1492    }
1493
1494    /// Recomputes a pivot table's aggregation and re-materializes it as
1495    /// plain values onto its destination sheet. Like Excel, a pivot table
1496    /// only updates on an explicit refresh, never automatically as its
1497    /// source data changes.
1498    pub fn refresh_pivot_table(&mut self, pivot_name: &str) -> crate::Result<()> {
1499        let idx = self.find_pivot_table_index(pivot_name)?;
1500        let pivot = self.pivot_tables[idx].clone();
1501        let dest_idx = self
1502            .sheets
1503            .iter()
1504            .position(|s| s.id == pivot.dest_sheet_id)
1505            .ok_or_else(|| {
1506                Error::InvalidArgument(
1507                    "pivot table's destination sheet no longer exists".to_string(),
1508                )
1509            })?;
1510
1511        let grid: Option<PivotGrid> = if pivot.value_fields.is_empty() {
1512            None
1513        } else {
1514            let sheet_refs: Vec<&Sheet> = self.sheets.iter().collect();
1515            Some(compute_pivot(&sheet_refs, &pivot).map_err(Error::InvalidArgument)?)
1516        };
1517
1518        // Clear the previously rendered area first, since a refresh can
1519        // shrink the grid (fewer groups, a narrower filter, etc).
1520        if let (Some(old_end_row), Some(old_end_col)) =
1521            (pivot.last_output_end_row, pivot.last_output_end_col)
1522        {
1523            self.clear_range(
1524                dest_idx,
1525                pivot.dest_row,
1526                pivot.dest_col,
1527                old_end_row,
1528                old_end_col,
1529            );
1530        }
1531
1532        let new_bounds = grid.as_ref().map(|grid| {
1533            let height = grid.height();
1534            let width = grid.width.max(1);
1535            self.ensure_capacity(
1536                dest_idx,
1537                pivot.dest_row + height.saturating_sub(1),
1538                pivot.dest_col + width.saturating_sub(1),
1539            );
1540
1541            let mut r = pivot.dest_row;
1542            for (name, state) in &grid.filter_rows {
1543                self.set_cell(dest_idx, r, pivot.dest_col, pivot_label_literal(name));
1544                self.set_cell(dest_idx, r, pivot.dest_col + 1, pivot_label_literal(state));
1545                r += 1;
1546            }
1547            if !grid.filter_rows.is_empty() {
1548                r += 1; // blank spacer row before the grid, matching Excel
1549            }
1550            for header in &grid.header_rows {
1551                for (c, text) in header.iter().enumerate() {
1552                    self.set_cell(dest_idx, r, pivot.dest_col + c, pivot_label_literal(text));
1553                }
1554                r += 1;
1555            }
1556            for body in &grid.body_rows {
1557                for (c, label) in body.row_labels.iter().enumerate() {
1558                    self.set_cell(dest_idx, r, pivot.dest_col + c, pivot_label_literal(label));
1559                }
1560                for (c, val) in body.values.iter().enumerate() {
1561                    self.set_cell(
1562                        dest_idx,
1563                        r,
1564                        pivot.dest_col + body.row_labels.len() + c,
1565                        pivot_value_literal(val),
1566                    );
1567                }
1568                r += 1;
1569            }
1570            (
1571                pivot.dest_row + height.saturating_sub(1),
1572                pivot.dest_col + width.saturating_sub(1),
1573            )
1574        });
1575
1576        self.pivot_tables[idx].last_output_end_row = new_bounds.map(|(r, _)| r);
1577        self.pivot_tables[idx].last_output_end_col = new_bounds.map(|(_, c)| c);
1578        self.evaluate()
1579    }
1580
1581    /// Blanks every cell in the given rectangular range (inclusive),
1582    /// clipped to the sheet's current bounds. Used to wipe a pivot table's
1583    /// previous output before re-rendering a possibly smaller grid.
1584    fn clear_range(
1585        &mut self,
1586        sheet_idx: usize,
1587        start_row: usize,
1588        start_col: usize,
1589        end_row: usize,
1590        end_col: usize,
1591    ) {
1592        if sheet_idx >= self.sheets.len() {
1593            return;
1594        }
1595        let (row_count, col_count) = {
1596            let s = &self.sheets[sheet_idx];
1597            (s.row_count(), s.col_count())
1598        };
1599        if row_count == 0 || col_count == 0 {
1600            return;
1601        }
1602        for r in start_row..=end_row.min(row_count - 1) {
1603            for c in start_col..=end_col.min(col_count - 1) {
1604                self.sheets[sheet_idx].set_cell_src(r, c, String::new());
1605            }
1606        }
1607    }
1608}