Skip to main content

visi_core/core/
workbook.rs

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