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