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