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