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