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    /// Insert row at 0-based index.
437    ///
438    /// Formulas throughout the workbook are rewritten to follow the cells
439    /// that moved, as in Excel, and Excel Table and pivot ranges move with
440    /// the cells they cover. See `core::grid_edit` for the rules.
441    pub fn insert_row(&mut self, sheet_idx: usize, row_idx: usize) -> crate::Result<()> {
442        let sheet = &self.sheets[sheet_idx];
443        // `Sheet::insert_row` appends when the index is past the end, so the
444        // edit the rewrite is told about has to say the same thing.
445        let at = row_idx.min(sheet.row_count());
446        let edit = GridEdit::insert_row(sheet.id, at);
447        self.apply_grid_edit(edit, &[], |wb| wb.sheets[sheet_idx].insert_row(at));
448        self.evaluate()
449    }
450
451    /// Delete row at 0-based index.
452    ///
453    /// References to the deleted row become `#REF!` and references below it
454    /// move up, as in Excel.
455    pub fn delete_row(&mut self, sheet_idx: usize, row_idx: usize) -> crate::Result<()> {
456        let sheet = &self.sheets[sheet_idx];
457        if row_idx >= sheet.row_count() {
458            return Err(Error::OutOfBounds {
459                what: "row",
460                index: row_idx,
461                len: sheet.row_count(),
462            });
463        }
464        let edit = GridEdit::delete_row(sheet.id, row_idx);
465        self.apply_grid_edit(edit, &[], |wb| wb.sheets[sheet_idx].delete_row(row_idx));
466        self.evaluate()
467    }
468
469    /// Insert column at 0-based index.
470    pub fn insert_col(&mut self, sheet_idx: usize, col_idx: usize) -> crate::Result<()> {
471        let sheet = &self.sheets[sheet_idx];
472        let at = col_idx.min(sheet.col_count());
473        let edit = GridEdit::insert_col(sheet.id, at);
474        self.apply_grid_edit(edit, &[], |wb| wb.sheets[sheet_idx].insert_col(at));
475        self.evaluate()
476    }
477
478    /// Delete column at 0-based index.
479    pub fn delete_col(&mut self, sheet_idx: usize, col_idx: usize) -> crate::Result<()> {
480        let sheet = &self.sheets[sheet_idx];
481        if col_idx >= sheet.col_count() {
482            return Err(Error::OutOfBounds {
483                what: "column",
484                index: col_idx,
485                len: sheet.col_count(),
486            });
487        }
488        // A whole-column reference is held by column id, not position, so the
489        // only way to tell whether the edit broke one is to note the id
490        // before the column is gone.
491        let deleted_col_ids = vec![sheet.columns()[col_idx].id];
492        let edit = GridEdit::delete_col(sheet.id, col_idx);
493        self.apply_grid_edit(edit, &deleted_col_ids, |wb| {
494            wb.sheets[sheet_idx].delete_col(col_idx)
495        });
496        self.evaluate()
497    }
498
499    /// Excel's *Insert cells, shift down* over an inclusive column band,
500    /// with the workbook-wide formula rewrite that goes with it.
501    ///
502    /// This is what `ListRows.Add` is: only `first_col..=last_col` move, so a
503    /// formula beside the band stays put while one inside it shifts. See
504    /// `core::grid_edit`'s `band` field for the reference rules, which are
505    /// measured rather than assumed.
506    pub fn insert_cells_shift_down(
507        &mut self,
508        sheet_idx: usize,
509        row: usize,
510        first_col: usize,
511        last_col: usize,
512        count: usize,
513    ) -> crate::Result<()> {
514        let sheet = &self.sheets[sheet_idx];
515        let edit = GridEdit::band_rows(sheet.id, row, count, first_col, last_col, true);
516        self.apply_grid_edit(edit, &[], |wb| {
517            wb.sheets[sheet_idx].insert_cells_shift_down(row, first_col, last_col, count)
518        });
519        self.evaluate()
520    }
521
522    /// Excel's *Delete cells, shift up* over an inclusive column band; the
523    /// inverse of [`WorkbookManager::insert_cells_shift_down`].
524    pub fn delete_cells_shift_up(
525        &mut self,
526        sheet_idx: usize,
527        row: usize,
528        first_col: usize,
529        last_col: usize,
530        count: usize,
531    ) -> crate::Result<()> {
532        let sheet = &self.sheets[sheet_idx];
533        let edit = GridEdit::band_rows(sheet.id, row, count, first_col, last_col, false);
534        self.apply_grid_edit(edit, &[], |wb| {
535            wb.sheets[sheet_idx].delete_cells_shift_up(row, first_col, last_col, count)
536        });
537        self.evaluate()
538    }
539
540    /// Runs a structural edit, keeping everything that holds a coordinate
541    /// pointing at what it pointed at before.
542    ///
543    /// Three phases, and the order is the whole point:
544    ///
545    /// 1. **Before the edit**, compile every formula in the workbook and
546    ///    shift its references. Compiling needs the grid the formula text was
547    ///    written against.
548    /// 2. Apply the edit itself, via `apply`.
549    /// 3. **After the edit**, serialize the shifted formulas back to text and
550    ///    write each one at wherever its own cell moved to.
551    ///
552    /// Phase 3 cannot be folded into phase 1. A whole-column reference
553    /// renders as the column's *current* letter, so serializing `=SUM(B:B)`
554    /// before a column is inserted to its left would write `B:B` into a cell
555    /// where `B` now names a different column -- and `src` is what the next
556    /// recompile reads, so the wrong text wins.
557    fn apply_grid_edit(
558        &mut self,
559        edit: GridEdit,
560        deleted_col_ids: &[u64],
561        apply: impl FnOnce(&mut Self),
562    ) {
563        // Phase 1: compile against the pre-edit grid and shift. Formulas the
564        // edit does not touch are skipped outright rather than rewritten to
565        // an equivalent spelling.
566        let mut shifted: Vec<(usize, usize, usize, CompiledFormula)> = Vec::new();
567        for (sheet_idx, sheet) in self.sheets.iter().enumerate() {
568            for (col_idx, column) in sheet.columns().iter().enumerate() {
569                for row_idx in 0..column.len() {
570                    let Some(src) = column.src(row_idx).filter(|s| s.starts_with('=')) else {
571                        continue;
572                    };
573                    let compiled = crate::core::parser::compile_formula(src, &self.sheets);
574                    if let Some(next) =
575                        crate::core::grid_edit::shift_formula(&compiled, &edit, deleted_col_ids)
576                    {
577                        shifted.push((sheet_idx, col_idx, row_idx, next));
578                    }
579                }
580            }
581        }
582
583        // Phase 2.
584        apply(self);
585        self.shift_table_and_pivot_ranges(&edit);
586
587        // Phase 3.
588        for (sheet_idx, col_idx, row_idx, compiled) in shifted {
589            let Some((row, col)) = self.moved_cell(&edit, sheet_idx, row_idx, col_idx) else {
590                // The cell holding the formula was itself deleted.
591                continue;
592            };
593            let text = crate::core::parser::serialize_formula(&compiled, &self.sheets);
594            self.sheets[sheet_idx].set_cell_src(row, col, text);
595        }
596    }
597
598    /// Where the cell at `(row, col)` on `sheet_idx` ends up after `edit`, or
599    /// `None` if the edit deleted it.
600    fn moved_cell(
601        &self,
602        edit: &GridEdit,
603        sheet_idx: usize,
604        row: usize,
605        col: usize,
606    ) -> Option<(usize, usize)> {
607        // A band edit moves only its own columns, so a formula *beside* the
608        // band stays where it is -- including one below the insert row.
609        // Getting this wrong writes the rewritten formula into the cell below
610        // the right one and leaves the original in place.
611        if self.sheets[sheet_idx].id != edit.sheet_id || !edit.covers_columns(col, col) {
612            return Some((row, col));
613        }
614        let moved = |index: usize| {
615            crate::core::grid_edit::shift_point(index, edit.at, edit.count, edit.insert)
616        };
617        match edit.axis {
618            Axis::Row => Some((moved(row)?, col)),
619            Axis::Col => Some((row, moved(col)?)),
620        }
621    }
622
623    /// Moves the Excel Table and pivot rectangles the edit passed through.
624    ///
625    /// A table or a pivot source whose every row (or every column) was
626    /// deleted has nothing left to describe, so it is dropped -- the same
627    /// thing Excel does when you delete the last row of a one-row table.
628    fn shift_table_and_pivot_ranges(&mut self, edit: &GridEdit) {
629        use crate::core::grid_edit::{shift_point, shift_rect};
630
631        for sheet in &mut self.sheets {
632            if sheet.id != edit.sheet_id {
633                continue;
634            }
635            sheet.tables.retain_mut(|table| {
636                // A band edit moves a table only if the table's columns are
637                // wholly inside the band, exactly as for a reference.
638                if !edit.covers_columns(table.start_col, table.end_col) {
639                    return true;
640                }
641                match shift_rect(
642                    edit,
643                    table.start_row,
644                    table.start_col,
645                    table.end_row,
646                    table.end_col,
647                ) {
648                    Some((r0, c0, r1, c1)) => {
649                        // Column names are per sheet-column, so dropping a
650                        // column has to drop its name with it or every name
651                        // past it shifts onto the wrong column.
652                        if edit.axis == Axis::Col {
653                            resize_table_columns(table, c0, c1, edit);
654                        }
655                        table.start_row = r0;
656                        table.start_col = c0;
657                        table.end_row = r1;
658                        table.end_col = c1;
659                        true
660                    }
661                    None => false,
662                }
663            });
664        }
665
666        for pivot in &mut self.pivot_tables {
667            if let PivotSource::Range {
668                sheet_id,
669                start_row,
670                start_col,
671                end_row,
672                end_col,
673            } = &mut pivot.source
674                && *sheet_id == edit.sheet_id
675                && edit.covers_columns(*start_col, *end_col)
676                && let Some((r0, c0, r1, c1)) =
677                    shift_rect(edit, *start_row, *start_col, *end_row, *end_col)
678            {
679                *start_row = r0;
680                *start_col = c0;
681                *end_row = r1;
682                *end_col = c1;
683            }
684
685            if pivot.dest_sheet_id == edit.sheet_id
686                && edit.covers_columns(pivot.dest_col, pivot.dest_col)
687            {
688                // The destination is a corner, not a span. A deleted corner
689                // clamps to the edit rather than vanishing: the grid is
690                // rewritten wholesale on the next refresh anyway, so what
691                // matters is that it names a live cell.
692                match edit.axis {
693                    Axis::Row => {
694                        pivot.dest_row =
695                            shift_point(pivot.dest_row, edit.at, edit.count, edit.insert)
696                                .unwrap_or(edit.at);
697                    }
698                    Axis::Col => {
699                        pivot.dest_col =
700                            shift_point(pivot.dest_col, edit.at, edit.count, edit.insert)
701                                .unwrap_or(edit.at);
702                    }
703                }
704                // The last rendered extent is only used to clear stale cells,
705                // so a stale one over-clears rather than under-clears; drop it
706                // and let the next refresh re-record it.
707                pivot.last_output_end_row = None;
708                pivot.last_output_end_col = None;
709            }
710        }
711    }
712
713    /// Add new sheet with specified name
714    pub fn add_sheet(&mut self, name: &str) -> crate::Result<()> {
715        if self
716            .sheets
717            .iter()
718            .any(|s| s.name.eq_ignore_ascii_case(name))
719        {
720            return Err(Error::AlreadyExists {
721                kind: ObjectKind::Sheet,
722                name: name.to_string(),
723            });
724        }
725
726        let mut columns = Vec::new();
727        for col_idx in 0..5 {
728            let mut col = DataColumn::new(10);
729            col.id = generate_unique_id();
730            col.name = col_idx_to_letters(col_idx);
731            columns.push(col);
732        }
733
734        let new_sheet = Sheet {
735            id: generate_unique_id(),
736            name: name.to_string(),
737            columns,
738            tables: Vec::new(),
739            dependencies: std::collections::HashMap::new(),
740            dependencies_rev: std::collections::HashMap::new(),
741            uncommitted_actions: Vec::new(),
742        };
743
744        self.sheets.push(new_sheet);
745        Ok(())
746    }
747
748    /// Delete sheet by name
749    pub fn delete_sheet(&mut self, name: &str) -> crate::Result<()> {
750        let idx = self.find_sheet_index(Some(name))?;
751        if self.sheets.len() <= 1 {
752            return Err(Error::LastSheetInWorkbook);
753        }
754        self.sheets.remove(idx);
755        Ok(())
756    }
757
758    /// Rename sheet
759    pub fn rename_sheet(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
760        let idx = self.find_sheet_index(Some(old_name))?;
761        if self
762            .sheets
763            .iter()
764            .enumerate()
765            .any(|(i, s)| i != idx && s.name.eq_ignore_ascii_case(new_name))
766        {
767            return Err(Error::NameTaken {
768                kind: ObjectKind::Sheet,
769                name: new_name.to_string(),
770            });
771        }
772        self.sheets[idx].name = new_name.to_string();
773        Ok(())
774    }
775
776    /// Add chart to workbook
777    #[allow(clippy::too_many_arguments)]
778    pub fn add_chart(
779        &mut self,
780        sheet_name: &str,
781        chart_type: ChartType,
782        range: String,
783        title: Option<String>,
784        anchor: Option<(usize, usize)>,
785    ) -> crate::Result<u64> {
786        let _ = self.find_sheet_index(Some(sheet_name))?;
787        let id = generate_unique_id();
788        let name = format!("Chart {}", self.charts.len() + 1);
789        let (anchor_row, anchor_col) = anchor.unwrap_or((0, 0));
790
791        let chart = Chart {
792            id,
793            name,
794            chart_type,
795            data_range: range,
796            title,
797            xlabel: None,
798            ylabel: None,
799            show_legend: true,
800            anchor_row,
801            anchor_col,
802        };
803
804        self.charts.push(chart);
805        Ok(id)
806    }
807
808    /// Edit an existing chart's properties. Every parameter is optional;
809    /// `None` leaves that field unchanged. `title`/`xlabel`/`ylabel` are
810    /// tri-state (`Option<Option<String>>`): outer `None` leaves the field
811    /// unchanged, `Some(None)` clears it, `Some(Some(text))` sets it.
812    #[allow(clippy::too_many_arguments)]
813    pub fn edit_chart(
814        &mut self,
815        id: u64,
816        name: Option<String>,
817        chart_type: Option<ChartType>,
818        data_range: Option<String>,
819        title: Option<Option<String>>,
820        xlabel: Option<Option<String>>,
821        ylabel: Option<Option<String>>,
822        show_legend: Option<bool>,
823        anchor: Option<(usize, usize)>,
824    ) -> crate::Result<()> {
825        let chart = self
826            .charts
827            .iter_mut()
828            .find(|c| c.id == id)
829            .ok_or_else(|| Error::not_found(ObjectKind::Chart, id.to_string()))?;
830        if let Some(name) = name {
831            chart.name = name;
832        }
833        if let Some(chart_type) = chart_type {
834            chart.chart_type = chart_type;
835        }
836        if let Some(data_range) = data_range {
837            chart.data_range = data_range;
838        }
839        if let Some(title) = title {
840            chart.title = title;
841        }
842        if let Some(xlabel) = xlabel {
843            chart.xlabel = xlabel;
844        }
845        if let Some(ylabel) = ylabel {
846            chart.ylabel = ylabel;
847        }
848        if let Some(show_legend) = show_legend {
849            chart.show_legend = show_legend;
850        }
851        if let Some((anchor_row, anchor_col)) = anchor {
852            chart.anchor_row = anchor_row;
853            chart.anchor_col = anchor_col;
854        }
855        Ok(())
856    }
857
858    /// Whether the workbook carries a VBA project.
859    pub fn has_vba_project(&self) -> bool {
860        self.vba_project.is_some()
861    }
862
863    /// Lists every module in the workbook's VBA project, if it has one.
864    pub fn list_vba_modules(&self) -> Vec<&VbaModule> {
865        self.vba_project
866            .as_ref()
867            .map(|p| p.modules.iter().collect())
868            .unwrap_or_default()
869    }
870
871    /// Creates an empty, entirely synthetic VBA project (see
872    /// `VbaProject::new_empty`) if this workbook doesn't already have one.
873    /// Idempotent.
874    pub fn ensure_vba_project(&mut self) -> crate::Result<()> {
875        if self.vba_project.is_some() {
876            return Ok(());
877        }
878        self.vba_project = Some(VbaProject::new_empty());
879        Ok(())
880    }
881
882    /// Adds a new module to the workbook's VBA project (creating the
883    /// project from the bundled template first, if needed). `bound_sheet_id`
884    /// is required for `VbaModuleKind::Document` (except when `name` is
885    /// `"ThisWorkbook"`, which -- like real Excel's own always-present
886    /// ThisWorkbook module -- isn't tied to a specific sheet; any
887    /// `bound_sheet_id` passed alongside it is ignored rather than stored)
888    /// -- note this does NOT rename the sheet, or vice versa; Excel allows a
889    /// document module's own name and its sheet's display name to diverge,
890    /// and this codebase deliberately doesn't cascade one into the other.
891    pub fn add_vba_module(
892        &mut self,
893        name: String,
894        kind: VbaModuleKind,
895        source: String,
896        bound_sheet_id: Option<u64>,
897    ) -> crate::Result<()> {
898        validate_vba_module_name(&name).map_err(|reason| Error::InvalidName {
899            kind: ObjectKind::VbaModule,
900            name: name.clone(),
901            reason,
902        })?;
903        let is_this_workbook = kind == VbaModuleKind::Document && name == "ThisWorkbook";
904        if kind == VbaModuleKind::Document && !is_this_workbook {
905            let sheet_id = bound_sheet_id
906                .ok_or_else(|| Error::Vba("document modules require a bound sheet".to_string()))?;
907            if !self.sheets.iter().any(|s| s.id == sheet_id) {
908                return Err(Error::not_found(ObjectKind::Sheet, sheet_id.to_string()));
909            }
910        }
911        self.ensure_vba_project()?;
912        let project = self.vba_project.as_mut().unwrap();
913        if project.module_name_taken(&name) {
914            return Err(Error::AlreadyExists {
915                kind: ObjectKind::VbaModule,
916                name: name.to_string(),
917            });
918        }
919        if kind == VbaModuleKind::Document
920            && bound_sheet_id.is_some()
921            && project
922                .modules
923                .iter()
924                .any(|m| m.kind == VbaModuleKind::Document && m.bound_sheet_id == bound_sheet_id)
925        {
926            return Err(Error::DocumentModuleExists);
927        }
928        // Donate p-code prefix bytes and a module cookie from any existing
929        // module in this same project -- proven (via a scratchpad
930        // proof-of-concept against real Excel) that the prefix's content
931        // doesn't need to correspond to the module it precedes, only its
932        // shape matters. If this project has no modules yet (the common
933        // case for one freshly created by `ensure_vba_project`), fall back
934        // to the synthetic seed values instead.
935        let prefix_bytes = project
936            .modules
937            .first()
938            .map(|m| m.prefix_bytes.clone())
939            .unwrap_or_else(|| project.seed_prefix_bytes.clone());
940        let module_cookie = project
941            .modules
942            .first()
943            .map(|m| m.module_cookie)
944            .unwrap_or(project.seed_module_cookie);
945        let stored_bound_sheet_id = if kind == VbaModuleKind::Document && !is_this_workbook {
946            bound_sheet_id
947        } else {
948            None
949        };
950        project.modules.push(VbaModule {
951            name,
952            kind,
953            source,
954            bound_sheet_id: stored_bound_sheet_id,
955            prefix_bytes,
956            module_cookie,
957            // A brand-new module has no already-compressed form to reuse.
958            cached_compressed_source: None,
959        });
960        Ok(())
961    }
962
963    /// Removes a VBA module by name, matched case-insensitively.
964    ///
965    /// # Errors
966    ///
967    /// [`Error::Vba`] if the workbook has no VBA project, or
968    /// [`Error::NotFound`] if it has no module by that name.
969    pub fn remove_vba_module(&mut self, name: &str) -> crate::Result<()> {
970        let project = self
971            .vba_project
972            .as_mut()
973            .ok_or_else(|| Error::Vba("workbook has no VBA project".to_string()))?;
974        let before = project.modules.len();
975        project
976            .modules
977            .retain(|m| !m.name.eq_ignore_ascii_case(name));
978        if project.modules.len() == before {
979            return Err(Error::not_found(ObjectKind::VbaModule, name.to_string()));
980        }
981        Ok(())
982    }
983
984    /// Renames a VBA module.
985    ///
986    /// Renames only the module; VBA source that calls into it is not
987    /// rewritten, so a module referenced by name elsewhere will no longer
988    /// resolve.
989    ///
990    /// # Errors
991    ///
992    /// [`Error::InvalidName`] if `new_name` is not a valid VBA identifier,
993    /// [`Error::AlreadyExists`] if another module already has it,
994    /// [`Error::Vba`] if the workbook has no VBA project, or
995    /// [`Error::NotFound`] if it has no module called `old_name`.
996    pub fn rename_vba_module(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
997        validate_vba_module_name(new_name).map_err(|reason| Error::InvalidName {
998            kind: ObjectKind::VbaModule,
999            name: new_name.to_string(),
1000            reason,
1001        })?;
1002        let project = self
1003            .vba_project
1004            .as_mut()
1005            .ok_or_else(|| Error::Vba("workbook has no VBA project".to_string()))?;
1006        if !old_name.eq_ignore_ascii_case(new_name) && project.module_name_taken(new_name) {
1007            return Err(Error::AlreadyExists {
1008                kind: ObjectKind::VbaModule,
1009                name: new_name.to_string(),
1010            });
1011        }
1012        let module = project
1013            .find_module_mut(old_name)
1014            .ok_or_else(|| Error::not_found(ObjectKind::VbaModule, old_name))?;
1015        module.name = new_name.to_string();
1016        Ok(())
1017    }
1018
1019    /// Replaces a VBA module's source text.
1020    ///
1021    /// The caller supplies the whole module body, including its
1022    /// `Attribute VB_Name = "..."` line, matching how real Excel-authored
1023    /// module streams are shaped.
1024    ///
1025    /// # Errors
1026    ///
1027    /// [`Error::Vba`] if the workbook has no VBA project, or
1028    /// [`Error::NotFound`] if it has no module by that name.
1029    pub fn set_vba_module_source(&mut self, name: &str, source: String) -> crate::Result<()> {
1030        let project = self
1031            .vba_project
1032            .as_mut()
1033            .ok_or_else(|| Error::Vba("workbook has no VBA project".to_string()))?;
1034        let module = project
1035            .find_module_mut(name)
1036            .ok_or_else(|| Error::not_found(ObjectKind::VbaModule, name))?;
1037        module.source = source;
1038        // Invalidate the cached compressed form -- see
1039        // VbaModule::cached_compressed_source -- since it no longer matches
1040        // the new `source`.
1041        module.cached_compressed_source = None;
1042        Ok(())
1043    }
1044
1045    /// Delete chart by u64 ID
1046    pub fn delete_chart(&mut self, id: u64) -> crate::Result<()> {
1047        if let Some(pos) = self.charts.iter().position(|c| c.id == id) {
1048            self.charts.remove(pos);
1049            Ok(())
1050        } else {
1051            Err(Error::not_found(ObjectKind::Chart, id.to_string()))
1052        }
1053    }
1054
1055    /// Find the sheet that owns the table with the given name, and the
1056    /// table itself. Table names are unique across the whole workbook.
1057    pub fn find_table(&self, name: &str) -> Option<(&Sheet, &ExcelTable)> {
1058        self.sheets
1059            .iter()
1060            .find_map(|s| s.find_table(name).map(|t| (s, t)))
1061    }
1062
1063    /// List every table in the workbook, alongside the name of the sheet it
1064    /// lives on.
1065    pub fn list_tables(&self) -> Vec<(&str, &ExcelTable)> {
1066        self.sheets
1067            .iter()
1068            .flat_map(|s| s.tables.iter().map(move |t| (s.name.as_str(), t)))
1069            .collect()
1070    }
1071
1072    fn find_table_sheet_index(&self, name: &str) -> crate::Result<usize> {
1073        self.sheets
1074            .iter()
1075            .position(|s| s.find_table(name).is_some())
1076            .ok_or_else(|| Error::not_found(ObjectKind::Table, name))
1077    }
1078
1079    fn table_name_taken(&self, name: &str) -> bool {
1080        self.sheets
1081            .iter()
1082            .any(|s| s.tables.iter().any(|t| t.name.eq_ignore_ascii_case(name)))
1083    }
1084
1085    /// Define a new Excel Table over an existing cell range on a sheet.
1086    /// Table names are unique across the entire workbook (not just the
1087    /// sheet), matching how Excel itself scopes structured-reference names.
1088    #[allow(clippy::too_many_arguments)]
1089    pub fn add_table(
1090        &mut self,
1091        sheet_name: Option<&str>,
1092        name: &str,
1093        start_row: usize,
1094        start_col: usize,
1095        end_row: usize,
1096        end_col: usize,
1097        has_header_row: bool,
1098        has_totals_row: bool,
1099    ) -> crate::Result<u64> {
1100        if self.table_name_taken(name) {
1101            return Err(Error::AlreadyExists {
1102                kind: ObjectKind::Table,
1103                name: name.to_string(),
1104            });
1105        }
1106        let idx = self.find_sheet_index(sheet_name)?;
1107        self.sheets[idx]
1108            .add_table(
1109                name.to_string(),
1110                start_row,
1111                start_col,
1112                end_row,
1113                end_col,
1114                has_header_row,
1115                has_totals_row,
1116            )
1117            .map_err(Error::InvalidArgument)
1118    }
1119
1120    /// Delete a table by name (leaves the underlying cell contents alone).
1121    pub fn delete_table(&mut self, name: &str) -> crate::Result<()> {
1122        let idx = self.find_table_sheet_index(name)?;
1123        self.sheets[idx]
1124            .delete_table_by_name(name)
1125            .map_err(Error::InvalidArgument)
1126    }
1127
1128    /// Rename a table.
1129    pub fn rename_table(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
1130        if !old_name.eq_ignore_ascii_case(new_name) && self.table_name_taken(new_name) {
1131            return Err(Error::NameTaken {
1132                kind: ObjectKind::Table,
1133                name: new_name.to_string(),
1134            });
1135        }
1136        let idx = self.find_table_sheet_index(old_name)?;
1137        self.sheets[idx]
1138            .rename_table(old_name, new_name)
1139            .map_err(Error::InvalidArgument)?;
1140        // Excel updates every structured reference to a renamed table, so a
1141        // formula like `=SUM(Sales[Amount])` keeps working after "Sales" is
1142        // renamed; match that instead of silently breaking those formulas.
1143        self.rewrite_table_references(old_name, Some(new_name), None);
1144        self.evaluate()
1145    }
1146
1147    /// Rewrites every formula in the workbook that structurally references
1148    /// `table_name` (optionally renaming the table and/or one column),
1149    /// mirroring how Excel keeps structured references in sync when a Table
1150    /// or one of its column headers is renamed.
1151    fn rewrite_table_references(
1152        &mut self,
1153        table_name: &str,
1154        new_table_name: Option<&str>,
1155        col_rename: Option<(&str, &str)>,
1156    ) {
1157        for sheet in &mut self.sheets {
1158            for col_idx in 0..sheet.columns.len() {
1159                let row_count = sheet.columns[col_idx].src.len();
1160                for row_idx in 0..row_count {
1161                    let src = sheet.columns[col_idx].src[row_idx].clone();
1162                    if let Some(new_src) = crate::core::parser::rewrite_structured_table_reference(
1163                        &src,
1164                        table_name,
1165                        new_table_name,
1166                        col_rename,
1167                    ) {
1168                        sheet.set_cell_src(row_idx, col_idx, new_src);
1169                    }
1170                }
1171            }
1172        }
1173    }
1174
1175    /// Resize a table by moving its bottom-right corner.
1176    pub fn resize_table(
1177        &mut self,
1178        name: &str,
1179        new_end_row: usize,
1180        new_end_col: usize,
1181    ) -> crate::Result<()> {
1182        let idx = self.find_table_sheet_index(name)?;
1183        self.sheets[idx]
1184            .resize_table(name, new_end_row, new_end_col)
1185            .map_err(Error::InvalidArgument)
1186    }
1187
1188    /// Rename one column (0-based, relative to the table) of a table.
1189    pub fn rename_table_column(
1190        &mut self,
1191        table_name: &str,
1192        col_index: usize,
1193        new_name: &str,
1194    ) -> crate::Result<()> {
1195        let idx = self.find_table_sheet_index(table_name)?;
1196        let old_col_name = self.sheets[idx]
1197            .find_table(table_name)
1198            .and_then(|t| t.columns.get(col_index).cloned())
1199            .ok_or_else(|| {
1200                Error::InvalidArgument(format!(
1201                    "column index {col_index} out of bounds for table '{table_name}'"
1202                ))
1203            })?;
1204        self.sheets[idx]
1205            .rename_table_column(table_name, col_index, new_name)
1206            .map_err(Error::InvalidArgument)?;
1207        // As with rename_table, keep dependent formulas working across the
1208        // rename instead of leaving them referencing the old column name.
1209        self.rewrite_table_references(table_name, None, Some((&old_col_name, new_name)));
1210        self.evaluate()
1211    }
1212
1213    /// Find a pivot table by name (case-insensitive).
1214    pub fn find_pivot_table(&self, name: &str) -> Option<&PivotTable> {
1215        self.pivot_tables
1216            .iter()
1217            .find(|p| p.name.eq_ignore_ascii_case(name))
1218    }
1219
1220    fn find_pivot_table_index(&self, name: &str) -> crate::Result<usize> {
1221        self.pivot_tables
1222            .iter()
1223            .position(|p| p.name.eq_ignore_ascii_case(name))
1224            .ok_or_else(|| Error::not_found(ObjectKind::PivotTable, name))
1225    }
1226
1227    /// List every pivot table in the workbook.
1228    pub fn list_pivot_tables(&self) -> &[PivotTable] {
1229        &self.pivot_tables
1230    }
1231
1232    fn pivot_table_name_taken(&self, name: &str) -> bool {
1233        self.pivot_tables
1234            .iter()
1235            .any(|p| p.name.eq_ignore_ascii_case(name))
1236    }
1237
1238    /// Defines a new pivot table sourced from an existing Excel Table, with
1239    /// no fields assigned yet -- mirroring Excel inserting an empty
1240    /// PivotTable shell that fills in as fields are added to it.
1241    #[allow(clippy::too_many_arguments)]
1242    pub fn add_pivot_table_from_table(
1243        &mut self,
1244        name: &str,
1245        source_table_name: &str,
1246        dest_sheet_name: Option<&str>,
1247        dest_row: usize,
1248        dest_col: usize,
1249        grand_totals_row: bool,
1250        grand_totals_col: bool,
1251    ) -> crate::Result<u64> {
1252        if self.pivot_table_name_taken(name) {
1253            return Err(Error::AlreadyExists {
1254                kind: ObjectKind::PivotTable,
1255                name: name.to_string(),
1256            });
1257        }
1258        self.find_table(source_table_name)
1259            .ok_or_else(|| Error::not_found(ObjectKind::Table, source_table_name))?;
1260        let dest_idx = self.find_sheet_index(dest_sheet_name)?;
1261        let id = generate_unique_id();
1262        self.pivot_tables.push(PivotTable {
1263            id,
1264            name: name.to_string(),
1265            source: PivotSource::Table {
1266                name: source_table_name.to_string(),
1267            },
1268            dest_sheet_id: self.sheets[dest_idx].id,
1269            dest_row,
1270            dest_col,
1271            row_fields: Vec::new(),
1272            col_fields: Vec::new(),
1273            value_fields: Vec::new(),
1274            filter_fields: Vec::new(),
1275            grand_totals_row,
1276            grand_totals_col,
1277            last_output_end_row: None,
1278            last_output_end_col: None,
1279        });
1280        self.refresh_pivot_table(name)?;
1281        Ok(id)
1282    }
1283
1284    /// Defines a new pivot table sourced from a plain cell range (its first
1285    /// row is treated as column headers), with no fields assigned yet.
1286    #[allow(clippy::too_many_arguments)]
1287    pub fn add_pivot_table_from_range(
1288        &mut self,
1289        name: &str,
1290        source_sheet_name: Option<&str>,
1291        start_row: usize,
1292        start_col: usize,
1293        end_row: usize,
1294        end_col: usize,
1295        dest_sheet_name: Option<&str>,
1296        dest_row: usize,
1297        dest_col: usize,
1298        grand_totals_row: bool,
1299        grand_totals_col: bool,
1300    ) -> crate::Result<u64> {
1301        if self.pivot_table_name_taken(name) {
1302            return Err(Error::AlreadyExists {
1303                kind: ObjectKind::PivotTable,
1304                name: name.to_string(),
1305            });
1306        }
1307        let src_idx = self.find_sheet_index(source_sheet_name)?;
1308        let dest_idx = self.find_sheet_index(dest_sheet_name)?;
1309        let id = generate_unique_id();
1310        self.pivot_tables.push(PivotTable {
1311            id,
1312            name: name.to_string(),
1313            source: PivotSource::Range {
1314                sheet_id: self.sheets[src_idx].id,
1315                start_row,
1316                start_col,
1317                end_row,
1318                end_col,
1319            },
1320            dest_sheet_id: self.sheets[dest_idx].id,
1321            dest_row,
1322            dest_col,
1323            row_fields: Vec::new(),
1324            col_fields: Vec::new(),
1325            value_fields: Vec::new(),
1326            filter_fields: Vec::new(),
1327            grand_totals_row,
1328            grand_totals_col,
1329            last_output_end_row: None,
1330            last_output_end_col: None,
1331        });
1332        self.refresh_pivot_table(name)?;
1333        Ok(id)
1334    }
1335
1336    /// Deletes a pivot table definition and clears its last rendered output
1337    /// range (leaves the source data untouched).
1338    pub fn delete_pivot_table(&mut self, name: &str) -> crate::Result<()> {
1339        let idx = self.find_pivot_table_index(name)?;
1340        let pivot = self.pivot_tables.remove(idx);
1341        if let (Some(end_row), Some(end_col)) =
1342            (pivot.last_output_end_row, pivot.last_output_end_col)
1343            && let Some(sheet_idx) = self.sheets.iter().position(|s| s.id == pivot.dest_sheet_id)
1344        {
1345            self.clear_range(sheet_idx, pivot.dest_row, pivot.dest_col, end_row, end_col);
1346        }
1347        Ok(())
1348    }
1349
1350    /// Renames a pivot table (names are unique workbook-wide, like tables).
1351    pub fn rename_pivot_table(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
1352        if !old_name.eq_ignore_ascii_case(new_name) && self.pivot_table_name_taken(new_name) {
1353            return Err(Error::NameTaken {
1354                kind: ObjectKind::PivotTable,
1355                name: new_name.to_string(),
1356            });
1357        }
1358        let idx = self.find_pivot_table_index(old_name)?;
1359        self.pivot_tables[idx].name = new_name.to_string();
1360        Ok(())
1361    }
1362
1363    /// Adds a field to one of a pivot table's four areas (Row/Column/
1364    /// Value/Filter) and immediately refreshes its output, mirroring
1365    /// Excel's live-updating field list.
1366    ///
1367    /// A field can only occupy one area at a time, exactly like dragging a
1368    /// field to a new area in Excel's field list moves it rather than
1369    /// duplicating it (confirmed via the win32com driver: setting
1370    /// `PivotField.Orientation` a second time relocates the field). Value
1371    /// fields are the one exception -- Excel allows the same source column
1372    /// to appear as multiple value fields simultaneously (e.g. both "Sum of
1373    /// Amount" and "Min of Amount"), so adding to `PivotArea::Value` does
1374    /// not evict the column from Row/Column/Filter, and vice versa a
1375    /// Row/Column/Filter add does not evict existing value fields.
1376    pub fn add_pivot_field(
1377        &mut self,
1378        pivot_name: &str,
1379        area: PivotArea,
1380        column: &str,
1381        aggregation: Option<PivotAggregation>,
1382    ) -> crate::Result<()> {
1383        let idx = self.find_pivot_table_index(pivot_name)?;
1384        if !matches!(area, PivotArea::Value) {
1385            let pivot = &mut self.pivot_tables[idx];
1386            remove_pivot_field(&mut pivot.row_fields, column);
1387            remove_pivot_field(&mut pivot.col_fields, column);
1388            pivot
1389                .filter_fields
1390                .retain(|f| !f.column.eq_ignore_ascii_case(column));
1391        }
1392        match area {
1393            PivotArea::Row => self.pivot_tables[idx]
1394                .row_fields
1395                .push(PivotField::new(column)),
1396            PivotArea::Column => self.pivot_tables[idx]
1397                .col_fields
1398                .push(PivotField::new(column)),
1399            PivotArea::Value => {
1400                let agg = aggregation.unwrap_or(PivotAggregation::Sum);
1401                self.pivot_tables[idx]
1402                    .value_fields
1403                    .push(PivotValueField::new(column, agg));
1404            }
1405            PivotArea::Filter => self.pivot_tables[idx]
1406                .filter_fields
1407                .push(PivotFilterField::new(column)),
1408        }
1409        self.refresh_pivot_table(pivot_name)
1410    }
1411
1412    /// Removes a field from one of a pivot table's four areas and
1413    /// refreshes its output.
1414    pub fn remove_pivot_field(
1415        &mut self,
1416        pivot_name: &str,
1417        area: PivotArea,
1418        column: &str,
1419    ) -> crate::Result<()> {
1420        let idx = self.find_pivot_table_index(pivot_name)?;
1421        let removed = match area {
1422            PivotArea::Row => remove_pivot_field(&mut self.pivot_tables[idx].row_fields, column),
1423            PivotArea::Column => remove_pivot_field(&mut self.pivot_tables[idx].col_fields, column),
1424            PivotArea::Value => {
1425                let before = self.pivot_tables[idx].value_fields.len();
1426                self.pivot_tables[idx]
1427                    .value_fields
1428                    .retain(|f| !f.column.eq_ignore_ascii_case(column));
1429                before != self.pivot_tables[idx].value_fields.len()
1430            }
1431            PivotArea::Filter => {
1432                let before = self.pivot_tables[idx].filter_fields.len();
1433                self.pivot_tables[idx]
1434                    .filter_fields
1435                    .retain(|f| !f.column.eq_ignore_ascii_case(column));
1436                before != self.pivot_tables[idx].filter_fields.len()
1437            }
1438        };
1439        if !removed {
1440            return Err(Error::not_found(
1441                ObjectKind::PivotField,
1442                format!("{column}' in pivot table '{pivot_name}"),
1443            ));
1444        }
1445        self.refresh_pivot_table(pivot_name)
1446    }
1447
1448    /// Restricts (or clears, with `values: None`) a filter field's allowed
1449    /// values and refreshes the pivot table's output.
1450    pub fn set_pivot_filter(
1451        &mut self,
1452        pivot_name: &str,
1453        column: &str,
1454        values: Option<Vec<String>>,
1455    ) -> crate::Result<()> {
1456        let idx = self.find_pivot_table_index(pivot_name)?;
1457        let field = self.pivot_tables[idx]
1458            .filter_fields
1459            .iter_mut()
1460            .find(|f| f.column.eq_ignore_ascii_case(column))
1461            .ok_or_else(|| {
1462                Error::not_found(
1463                    ObjectKind::PivotField,
1464                    format!("{column}' on pivot table '{pivot_name}"),
1465                )
1466            })?;
1467        field.selected_values = values;
1468        self.refresh_pivot_table(pivot_name)
1469    }
1470
1471    /// Recomputes a pivot table's aggregation and re-materializes it as
1472    /// plain values onto its destination sheet. Like Excel, a pivot table
1473    /// only updates on an explicit refresh, never automatically as its
1474    /// source data changes.
1475    pub fn refresh_pivot_table(&mut self, pivot_name: &str) -> crate::Result<()> {
1476        let idx = self.find_pivot_table_index(pivot_name)?;
1477        let pivot = self.pivot_tables[idx].clone();
1478        let dest_idx = self
1479            .sheets
1480            .iter()
1481            .position(|s| s.id == pivot.dest_sheet_id)
1482            .ok_or_else(|| {
1483                Error::InvalidArgument(
1484                    "pivot table's destination sheet no longer exists".to_string(),
1485                )
1486            })?;
1487
1488        let grid: Option<PivotGrid> = if pivot.value_fields.is_empty() {
1489            None
1490        } else {
1491            let sheet_refs: Vec<&Sheet> = self.sheets.iter().collect();
1492            Some(compute_pivot(&sheet_refs, &pivot).map_err(Error::InvalidArgument)?)
1493        };
1494
1495        // Clear the previously rendered area first, since a refresh can
1496        // shrink the grid (fewer groups, a narrower filter, etc).
1497        if let (Some(old_end_row), Some(old_end_col)) =
1498            (pivot.last_output_end_row, pivot.last_output_end_col)
1499        {
1500            self.clear_range(
1501                dest_idx,
1502                pivot.dest_row,
1503                pivot.dest_col,
1504                old_end_row,
1505                old_end_col,
1506            );
1507        }
1508
1509        let new_bounds = grid.as_ref().map(|grid| {
1510            let height = grid.height();
1511            let width = grid.width.max(1);
1512            self.ensure_capacity(
1513                dest_idx,
1514                pivot.dest_row + height.saturating_sub(1),
1515                pivot.dest_col + width.saturating_sub(1),
1516            );
1517
1518            let mut r = pivot.dest_row;
1519            for (name, state) in &grid.filter_rows {
1520                self.set_cell(dest_idx, r, pivot.dest_col, pivot_label_literal(name));
1521                self.set_cell(dest_idx, r, pivot.dest_col + 1, pivot_label_literal(state));
1522                r += 1;
1523            }
1524            if !grid.filter_rows.is_empty() {
1525                r += 1; // blank spacer row before the grid, matching Excel
1526            }
1527            for header in &grid.header_rows {
1528                for (c, text) in header.iter().enumerate() {
1529                    self.set_cell(dest_idx, r, pivot.dest_col + c, pivot_label_literal(text));
1530                }
1531                r += 1;
1532            }
1533            for body in &grid.body_rows {
1534                for (c, label) in body.row_labels.iter().enumerate() {
1535                    self.set_cell(dest_idx, r, pivot.dest_col + c, pivot_label_literal(label));
1536                }
1537                for (c, val) in body.values.iter().enumerate() {
1538                    self.set_cell(
1539                        dest_idx,
1540                        r,
1541                        pivot.dest_col + body.row_labels.len() + c,
1542                        pivot_value_literal(val),
1543                    );
1544                }
1545                r += 1;
1546            }
1547            (
1548                pivot.dest_row + height.saturating_sub(1),
1549                pivot.dest_col + width.saturating_sub(1),
1550            )
1551        });
1552
1553        self.pivot_tables[idx].last_output_end_row = new_bounds.map(|(r, _)| r);
1554        self.pivot_tables[idx].last_output_end_col = new_bounds.map(|(_, c)| c);
1555        self.evaluate()
1556    }
1557
1558    /// Blanks every cell in the given rectangular range (inclusive),
1559    /// clipped to the sheet's current bounds. Used to wipe a pivot table's
1560    /// previous output before re-rendering a possibly smaller grid.
1561    fn clear_range(
1562        &mut self,
1563        sheet_idx: usize,
1564        start_row: usize,
1565        start_col: usize,
1566        end_row: usize,
1567        end_col: usize,
1568    ) {
1569        if sheet_idx >= self.sheets.len() {
1570            return;
1571        }
1572        let (row_count, col_count) = {
1573            let s = &self.sheets[sheet_idx];
1574            (s.row_count(), s.col_count())
1575        };
1576        if row_count == 0 || col_count == 0 {
1577            return;
1578        }
1579        for r in start_row..=end_row.min(row_count - 1) {
1580            for c in start_col..=end_col.min(col_count - 1) {
1581                self.sheets[sheet_idx].set_cell_src(r, c, String::new());
1582            }
1583        }
1584    }
1585}