Skip to main content

sheetkit_core/workbook/
io.rs

1use super::*;
2use crate::workbook::open_options::OpenOptions;
3
4/// VBA project relationship type URI.
5const VBA_PROJECT_REL_TYPE: &str =
6    "http://schemas.microsoft.com/office/2006/relationships/vbaProject";
7
8/// VBA project content type.
9const VBA_PROJECT_CONTENT_TYPE: &str = "application/vnd.ms-office.vbaProject";
10
11impl Workbook {
12    /// Create a new empty workbook containing a single empty sheet named "Sheet1".
13    pub fn new() -> Self {
14        let workbook_xml = WorkbookXml {
15            // Excel-like workbook.xml defaults for interoperability.
16            file_version: Some(sheetkit_xml::workbook::FileVersion {
17                app_name: Some("xl".to_string()),
18                last_edited: Some("7".to_string()),
19                lowest_edited: Some("7".to_string()),
20                rup_build: Some("27425".to_string()),
21            }),
22            // Excel-like workbookPr default.
23            workbook_pr: Some(sheetkit_xml::workbook::WorkbookPr {
24                date1904: None,
25                filter_privacy: None,
26                default_theme_version: Some(166925),
27                show_objects: None,
28                backup_file: None,
29                code_name: None,
30                check_compatibility: None,
31                auto_compress_pictures: None,
32                save_external_link_values: None,
33                update_links: None,
34                hide_pivot_field_list: None,
35                show_pivot_chart_filter: None,
36                allow_refresh_query: None,
37                publish_items: None,
38                show_border_unselected_tables: None,
39                prompted_solutions: None,
40                show_ink_annotation: None,
41            }),
42            // Minimal book view default (active tab only).
43            book_views: Some(sheetkit_xml::workbook::BookViews {
44                workbook_views: vec![sheetkit_xml::workbook::WorkbookView {
45                    x_window: None,
46                    y_window: None,
47                    window_width: None,
48                    window_height: None,
49                    active_tab: Some(0),
50                }],
51            }),
52            ..WorkbookXml::default()
53        };
54
55        let sst_runtime = SharedStringTable::new();
56        let mut sheet_name_index = HashMap::new();
57        sheet_name_index.insert("Sheet1".to_string(), 0);
58        Self {
59            format: WorkbookFormat::default(),
60            content_types: ContentTypes::default(),
61            package_rels: relationships::package_rels(),
62            workbook_xml,
63            workbook_rels: relationships::workbook_rels(),
64            worksheets: vec![(
65                "Sheet1".to_string(),
66                initialized_lock(WorksheetXml::default()),
67            )],
68            stylesheet: StyleSheet::default(),
69            sst_runtime,
70            sheet_comments: vec![None],
71            charts: vec![],
72            raw_charts: vec![],
73            drawings: vec![],
74            images: vec![],
75            worksheet_drawings: HashMap::new(),
76            worksheet_rels: HashMap::new(),
77            drawing_rels: HashMap::new(),
78            core_properties: None,
79            app_properties: None,
80            custom_properties: None,
81            pivot_tables: vec![],
82            pivot_cache_defs: vec![],
83            pivot_cache_records: vec![],
84            theme_xml: None,
85            theme_colors: crate::theme::default_theme_colors(),
86            sheet_name_index,
87            sheet_sparklines: vec![vec![]],
88            sheet_vml: vec![None],
89            unknown_parts: vec![],
90            deferred_parts: crate::workbook::aux::DeferredAuxParts::new(),
91            vba_blob: None,
92            tables: vec![],
93            raw_sheet_xml: vec![None],
94            sheet_dirty: vec![true],
95            slicer_defs: vec![],
96            slicer_caches: vec![],
97            sheet_threaded_comments: vec![None],
98            person_list: sheetkit_xml::threaded_comment::PersonList::default(),
99            sheet_form_controls: vec![vec![]],
100            streamed_sheets: HashMap::new(),
101            package_source: None,
102            read_mode: ReadMode::default(),
103            sheet_rows_limit: None,
104            date_interpretation: super::DateInterpretation::default(),
105        }
106    }
107
108    /// Open an existing `.xlsx` file from disk.
109    ///
110    /// If the file is encrypted (CFB container), returns
111    /// [`Error::FileEncrypted`]. Use [`Workbook::open_with_password`] instead.
112    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
113        Self::open_with_options(path, &OpenOptions::default())
114    }
115
116    /// Open an existing `.xlsx` file with custom parsing options.
117    ///
118    /// See [`OpenOptions`] for available options including row limits,
119    /// sheet filtering, and ZIP safety limits.
120    ///
121    /// The file is opened directly via `std::fs::File` and the ZIP archive
122    /// is read from the file handle, avoiding a full `std::fs::read` copy.
123    pub fn open_with_options<P: AsRef<Path>>(path: P, options: &OpenOptions) -> Result<Self> {
124        let file_path = path.as_ref();
125
126        // Detect encrypted files (CFB container) by reading the magic bytes.
127        #[cfg(feature = "encryption")]
128        {
129            let mut header = [0u8; 8];
130            if let Ok(mut f) = std::fs::File::open(file_path) {
131                use std::io::Read as _;
132                if f.read_exact(&mut header).is_ok() {
133                    if let Ok(crate::crypt::ContainerFormat::Cfb) =
134                        crate::crypt::detect_container_format(&header)
135                    {
136                        return Err(Error::FileEncrypted);
137                    }
138                }
139            }
140        }
141
142        let file = std::fs::File::open(file_path)?;
143        let mut archive = zip::ZipArchive::new(file).map_err(|e| Error::Zip(e.to_string()))?;
144        let mut wb = Self::from_archive(&mut archive, options)?;
145        wb.package_source = Some(PackageSource::Path(file_path.to_path_buf()));
146        wb.read_mode = options.read_mode;
147        Ok(wb)
148    }
149
150    /// Build a Workbook from an already-opened ZIP archive.
151    fn from_archive<R: std::io::Read + std::io::Seek>(
152        archive: &mut zip::ZipArchive<R>,
153        options: &OpenOptions,
154    ) -> Result<Self> {
155        // ZIP safety checks: entry count and total decompressed size.
156        if let Some(max_entries) = options.max_zip_entries {
157            let count = archive.len();
158            if count > max_entries {
159                return Err(Error::ZipEntryCountExceeded {
160                    count,
161                    limit: max_entries,
162                });
163            }
164        }
165        if let Some(max_size) = options.max_unzip_size {
166            let mut total_size: u64 = 0;
167            for i in 0..archive.len() {
168                let entry = archive.by_index(i).map_err(|e| Error::Zip(e.to_string()))?;
169                total_size = total_size.saturating_add(entry.size());
170                if total_size > max_size {
171                    return Err(Error::ZipSizeExceeded {
172                        size: total_size,
173                        limit: max_size,
174                    });
175                }
176            }
177        }
178
179        // Track all ZIP entry paths that are explicitly handled so that the
180        // remaining entries can be preserved as unknown parts.
181        let mut known_paths: HashSet<String> = HashSet::new();
182
183        // Parse [Content_Types].xml
184        let content_types: ContentTypes = read_xml_part(archive, "[Content_Types].xml")?;
185        known_paths.insert("[Content_Types].xml".to_string());
186
187        // Infer the workbook format from the content type of xl/workbook.xml.
188        let format = content_types
189            .overrides
190            .iter()
191            .find(|o| o.part_name == "/xl/workbook.xml")
192            .and_then(|o| WorkbookFormat::from_content_type(&o.content_type))
193            .unwrap_or_default();
194
195        // Parse _rels/.rels
196        let package_rels: Relationships = read_xml_part(archive, "_rels/.rels")?;
197        known_paths.insert("_rels/.rels".to_string());
198
199        // Parse xl/workbook.xml
200        let workbook_xml: WorkbookXml = read_xml_part(archive, "xl/workbook.xml")?;
201        known_paths.insert("xl/workbook.xml".to_string());
202
203        // Parse xl/_rels/workbook.xml.rels
204        let workbook_rels: Relationships = read_xml_part(archive, "xl/_rels/workbook.xml.rels")?;
205        known_paths.insert("xl/_rels/workbook.xml.rels".to_string());
206
207        // Parse each worksheet referenced in the workbook.
208        let sheet_count = workbook_xml.sheets.sheets.len();
209        let mut worksheets: Vec<(String, OnceLock<WorksheetXml>)> = Vec::with_capacity(sheet_count);
210        let mut worksheet_paths = Vec::with_capacity(sheet_count);
211        let mut raw_sheet_xml: Vec<Option<Vec<u8>>> = Vec::with_capacity(sheet_count);
212
213        let defer_sheets = matches!(options.read_mode, ReadMode::Lazy | ReadMode::Stream);
214
215        for sheet_entry in &workbook_xml.sheets.sheets {
216            // Find the relationship target for this sheet's rId.
217            let rel = workbook_rels
218                .relationships
219                .iter()
220                .find(|r| r.id == sheet_entry.r_id && r.rel_type == rel_types::WORKSHEET);
221
222            let rel = rel.ok_or_else(|| {
223                Error::Internal(format!(
224                    "missing worksheet relationship for sheet '{}'",
225                    sheet_entry.name
226                ))
227            })?;
228
229            let sheet_path = resolve_relationship_target("xl/workbook.xml", &rel.target);
230
231            let should_parse = options.should_parse_sheet(&sheet_entry.name);
232
233            if should_parse && !defer_sheets {
234                // Eager mode + selected: parse immediately.
235                let mut ws: WorksheetXml = read_xml_part(archive, &sheet_path)?;
236                for row in &mut ws.sheet_data.rows {
237                    row.cells.shrink_to_fit();
238                }
239                ws.sheet_data.rows.shrink_to_fit();
240                worksheets.push((sheet_entry.name.clone(), initialized_lock(ws)));
241                raw_sheet_xml.push(None);
242            } else if !should_parse {
243                // Filtered out (any mode): store raw bytes for round-trip save
244                // but initialize the OnceLock with an empty worksheet so that
245                // cell queries return Empty instead of hydrating real data.
246                let raw_bytes = read_bytes_part(archive, &sheet_path)?;
247                worksheets.push((
248                    sheet_entry.name.clone(),
249                    initialized_lock(WorksheetXml::default()),
250                ));
251                raw_sheet_xml.push(Some(raw_bytes));
252            } else {
253                // Lazy/Stream mode + selected: store raw bytes for on-demand
254                // hydration. OnceLock is left empty; `worksheet_ref` will
255                // parse from `raw_sheet_xml` on first access.
256                let raw_bytes = read_bytes_part(archive, &sheet_path)?;
257                worksheets.push((sheet_entry.name.clone(), OnceLock::new()));
258                raw_sheet_xml.push(Some(raw_bytes));
259            }
260            known_paths.insert(sheet_path.clone());
261            worksheet_paths.push(sheet_path);
262        }
263
264        // Parse xl/styles.xml
265        let stylesheet: StyleSheet = read_xml_part(archive, "xl/styles.xml")?;
266        known_paths.insert("xl/styles.xml".to_string());
267
268        // Parse xl/sharedStrings.xml (optional -- may not exist for workbooks with no strings)
269        let shared_strings: Sst =
270            read_xml_part(archive, "xl/sharedStrings.xml").unwrap_or_default();
271        known_paths.insert("xl/sharedStrings.xml".to_string());
272
273        let sst_runtime = SharedStringTable::from_sst(shared_strings);
274
275        // Parse xl/theme/theme1.xml (optional -- preserved as raw bytes for round-trip).
276        let (theme_xml, theme_colors) = match read_bytes_part(archive, "xl/theme/theme1.xml") {
277            Ok(bytes) => {
278                let colors = sheetkit_xml::theme::parse_theme_colors(&bytes);
279                (Some(bytes), colors)
280            }
281            Err(_) => (None, crate::theme::default_theme_colors()),
282        };
283        known_paths.insert("xl/theme/theme1.xml".to_string());
284
285        // Parse per-sheet worksheet relationship files (optional).
286        // Always loaded: needed for hyperlinks, on-demand comment loading, etc.
287        let mut worksheet_rels: HashMap<usize, Relationships> = HashMap::with_capacity(sheet_count);
288        for (i, sheet_path) in worksheet_paths.iter().enumerate() {
289            let rels_path = relationship_part_path(sheet_path);
290            if let Ok(rels) = read_xml_part::<Relationships, _>(archive, &rels_path) {
291                worksheet_rels.insert(i, rels);
292                known_paths.insert(rels_path);
293            }
294        }
295
296        let skip_aux = options.skip_aux_parts();
297
298        // Auxiliary part parsing: skipped in Lazy/Stream mode.
299        let mut sheet_comments: Vec<Option<Comments>> = vec![None; worksheets.len()];
300        let mut sheet_vml: Vec<Option<Vec<u8>>> = vec![None; worksheets.len()];
301        let mut drawings: Vec<(String, WsDr)> = Vec::new();
302        let mut worksheet_drawings: HashMap<usize, usize> = HashMap::new();
303        let mut drawing_rels: HashMap<usize, Relationships> = HashMap::new();
304        let mut charts: Vec<(String, ChartSpace)> = Vec::new();
305        let mut raw_charts: Vec<(String, Vec<u8>)> = Vec::new();
306        let mut images: Vec<(String, Vec<u8>)> = Vec::new();
307        let mut core_properties: Option<sheetkit_xml::doc_props::CoreProperties> = None;
308        let mut app_properties: Option<sheetkit_xml::doc_props::ExtendedProperties> = None;
309        let mut custom_properties: Option<sheetkit_xml::doc_props::CustomProperties> = None;
310        let mut pivot_cache_defs = Vec::new();
311        let mut pivot_tables = Vec::new();
312        let mut pivot_cache_records = Vec::new();
313        let mut slicer_defs = Vec::new();
314        let mut slicer_caches = Vec::new();
315        let mut sheet_threaded_comments: Vec<
316            Option<sheetkit_xml::threaded_comment::ThreadedComments>,
317        > = vec![None; worksheets.len()];
318        let mut person_list = sheetkit_xml::threaded_comment::PersonList::default();
319        let mut sheet_sparklines: Vec<Vec<crate::sparkline::SparklineConfig>> =
320            vec![vec![]; worksheets.len()];
321        let mut vba_blob: Option<Vec<u8>> = None;
322        let mut tables: Vec<(String, sheetkit_xml::table::TableXml, usize)> = Vec::new();
323
324        if !skip_aux {
325            let mut drawing_path_to_idx: HashMap<String, usize> = HashMap::new();
326
327            for (sheet_idx, sheet_path) in worksheet_paths.iter().enumerate() {
328                let Some(rels) = worksheet_rels.get(&sheet_idx) else {
329                    continue;
330                };
331
332                if let Some(comment_rel) = rels
333                    .relationships
334                    .iter()
335                    .find(|r| r.rel_type == rel_types::COMMENTS)
336                {
337                    let comment_path = resolve_relationship_target(sheet_path, &comment_rel.target);
338                    if let Ok(comments) = read_xml_part::<Comments, _>(archive, &comment_path) {
339                        sheet_comments[sheet_idx] = Some(comments);
340                        known_paths.insert(comment_path);
341                    }
342                }
343
344                if let Some(vml_rel) = rels
345                    .relationships
346                    .iter()
347                    .find(|r| r.rel_type == rel_types::VML_DRAWING)
348                {
349                    let vml_path = resolve_relationship_target(sheet_path, &vml_rel.target);
350                    if let Ok(bytes) = read_bytes_part(archive, &vml_path) {
351                        sheet_vml[sheet_idx] = Some(bytes);
352                        known_paths.insert(vml_path);
353                    }
354                }
355
356                if let Some(drawing_rel) = rels
357                    .relationships
358                    .iter()
359                    .find(|r| r.rel_type == rel_types::DRAWING)
360                {
361                    let drawing_path = resolve_relationship_target(sheet_path, &drawing_rel.target);
362                    let drawing_idx = if let Some(idx) = drawing_path_to_idx.get(&drawing_path) {
363                        *idx
364                    } else if let Ok(drawing) = read_xml_part::<WsDr, _>(archive, &drawing_path) {
365                        let idx = drawings.len();
366                        drawings.push((drawing_path.clone(), drawing));
367                        drawing_path_to_idx.insert(drawing_path.clone(), idx);
368                        known_paths.insert(drawing_path);
369                        idx
370                    } else {
371                        continue;
372                    };
373                    worksheet_drawings.insert(sheet_idx, drawing_idx);
374                }
375            }
376
377            // Fallback: load drawing parts listed in content types even when they
378            // are not discoverable via worksheet rel parsing.
379            for ovr in &content_types.overrides {
380                if ovr.content_type != mime_types::DRAWING {
381                    continue;
382                }
383                let drawing_path = ovr.part_name.trim_start_matches('/').to_string();
384                if drawing_path_to_idx.contains_key(&drawing_path) {
385                    continue;
386                }
387                if let Ok(drawing) = read_xml_part::<WsDr, _>(archive, &drawing_path) {
388                    let idx = drawings.len();
389                    drawings.push((drawing_path.clone(), drawing));
390                    known_paths.insert(drawing_path.clone());
391                    drawing_path_to_idx.insert(drawing_path, idx);
392                }
393            }
394
395            let mut seen_chart_paths: HashSet<String> = HashSet::new();
396            let mut seen_image_paths: HashSet<String> = HashSet::new();
397
398            for (drawing_idx, (drawing_path, _)) in drawings.iter().enumerate() {
399                let drawing_rels_path = relationship_part_path(drawing_path);
400                let Ok(rels) = read_xml_part::<Relationships, _>(archive, &drawing_rels_path)
401                else {
402                    continue;
403                };
404                known_paths.insert(drawing_rels_path);
405
406                for rel in &rels.relationships {
407                    if rel.rel_type == rel_types::CHART {
408                        let chart_path = resolve_relationship_target(drawing_path, &rel.target);
409                        if seen_chart_paths.insert(chart_path.clone()) {
410                            match read_xml_part::<ChartSpace, _>(archive, &chart_path) {
411                                Ok(chart) => {
412                                    known_paths.insert(chart_path.clone());
413                                    charts.push((chart_path, chart));
414                                }
415                                Err(_) => {
416                                    if let Ok(bytes) = read_bytes_part(archive, &chart_path) {
417                                        known_paths.insert(chart_path.clone());
418                                        raw_charts.push((chart_path, bytes));
419                                    }
420                                }
421                            }
422                        }
423                    } else if rel.rel_type == rel_types::IMAGE {
424                        let image_path = resolve_relationship_target(drawing_path, &rel.target);
425                        if seen_image_paths.insert(image_path.clone()) {
426                            if let Ok(bytes) = read_bytes_part(archive, &image_path) {
427                                known_paths.insert(image_path.clone());
428                                images.push((image_path, bytes));
429                            }
430                        }
431                    }
432                }
433
434                drawing_rels.insert(drawing_idx, rels);
435            }
436
437            // Fallback: load chart parts listed in content types even when no
438            // drawing relationship was read.
439            for ovr in &content_types.overrides {
440                if ovr.content_type != mime_types::CHART {
441                    continue;
442                }
443                let chart_path = ovr.part_name.trim_start_matches('/').to_string();
444                if seen_chart_paths.insert(chart_path.clone()) {
445                    match read_xml_part::<ChartSpace, _>(archive, &chart_path) {
446                        Ok(chart) => {
447                            known_paths.insert(chart_path.clone());
448                            charts.push((chart_path, chart));
449                        }
450                        Err(_) => {
451                            if let Ok(bytes) = read_bytes_part(archive, &chart_path) {
452                                known_paths.insert(chart_path.clone());
453                                raw_charts.push((chart_path, bytes));
454                            }
455                        }
456                    }
457                }
458            }
459
460            // Parse docProps/core.xml (optional - uses manual XML parsing)
461            core_properties = read_string_part(archive, "docProps/core.xml")
462                .ok()
463                .and_then(|xml_str| {
464                    sheetkit_xml::doc_props::deserialize_core_properties(&xml_str).ok()
465                });
466            known_paths.insert("docProps/core.xml".to_string());
467
468            // Parse docProps/app.xml (optional - uses serde)
469            app_properties = read_xml_part(archive, "docProps/app.xml").ok();
470            known_paths.insert("docProps/app.xml".to_string());
471
472            // Parse docProps/custom.xml (optional - uses manual XML parsing)
473            custom_properties = read_string_part(archive, "docProps/custom.xml")
474                .ok()
475                .and_then(|xml_str| {
476                    sheetkit_xml::doc_props::deserialize_custom_properties(&xml_str).ok()
477                });
478            known_paths.insert("docProps/custom.xml".to_string());
479
480            // Parse pivot cache definitions, pivot tables, and pivot cache records.
481            for ovr in &content_types.overrides {
482                let path = ovr.part_name.trim_start_matches('/');
483                if ovr.content_type == mime_types::PIVOT_CACHE_DEFINITION {
484                    if let Ok(pcd) = read_xml_part::<
485                        sheetkit_xml::pivot_cache::PivotCacheDefinition,
486                        _,
487                    >(archive, path)
488                    {
489                        known_paths.insert(path.to_string());
490                        pivot_cache_defs.push((path.to_string(), pcd));
491                    }
492                } else if ovr.content_type == mime_types::PIVOT_TABLE {
493                    if let Ok(pt) = read_xml_part::<
494                        sheetkit_xml::pivot_table::PivotTableDefinition,
495                        _,
496                    >(archive, path)
497                    {
498                        known_paths.insert(path.to_string());
499                        pivot_tables.push((path.to_string(), pt));
500                    }
501                } else if ovr.content_type == mime_types::PIVOT_CACHE_RECORDS {
502                    if let Ok(pcr) = read_xml_part::<sheetkit_xml::pivot_cache::PivotCacheRecords, _>(
503                        archive, path,
504                    ) {
505                        known_paths.insert(path.to_string());
506                        pivot_cache_records.push((path.to_string(), pcr));
507                    }
508                }
509            }
510
511            // Parse slicer definitions and slicer cache definitions.
512            for ovr in &content_types.overrides {
513                let path = ovr.part_name.trim_start_matches('/');
514                if ovr.content_type == mime_types::SLICER {
515                    if let Ok(sd) =
516                        read_xml_part::<sheetkit_xml::slicer::SlicerDefinitions, _>(archive, path)
517                    {
518                        slicer_defs.push((path.to_string(), sd));
519                    }
520                } else if ovr.content_type == mime_types::SLICER_CACHE {
521                    if let Ok(raw) = read_string_part(archive, path) {
522                        if let Some(scd) = sheetkit_xml::slicer::parse_slicer_cache(&raw) {
523                            slicer_caches.push((path.to_string(), scd));
524                        }
525                    }
526                }
527            }
528
529            // Parse threaded comments per-sheet and the workbook-level person list.
530            for (sheet_idx, sheet_path) in worksheet_paths.iter().enumerate() {
531                let Some(rels) = worksheet_rels.get(&sheet_idx) else {
532                    continue;
533                };
534                if let Some(tc_rel) = rels.relationships.iter().find(|r| {
535                    r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_THREADED_COMMENT
536                }) {
537                    let tc_path = resolve_relationship_target(sheet_path, &tc_rel.target);
538                    if let Ok(tc) = read_xml_part::<
539                        sheetkit_xml::threaded_comment::ThreadedComments,
540                        _,
541                    >(archive, &tc_path)
542                    {
543                        sheet_threaded_comments[sheet_idx] = Some(tc);
544                        known_paths.insert(tc_path);
545                    }
546                }
547            }
548
549            // Parse person list (workbook-level).
550            person_list = {
551                let mut found = None;
552                if let Some(person_rel) = workbook_rels
553                    .relationships
554                    .iter()
555                    .find(|r| r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_PERSON)
556                {
557                    let person_path =
558                        resolve_relationship_target("xl/workbook.xml", &person_rel.target);
559                    if let Ok(pl) = read_xml_part::<sheetkit_xml::threaded_comment::PersonList, _>(
560                        archive,
561                        &person_path,
562                    ) {
563                        known_paths.insert(person_path);
564                        found = Some(pl);
565                    }
566                }
567                if found.is_none() {
568                    if let Ok(pl) = read_xml_part::<sheetkit_xml::threaded_comment::PersonList, _>(
569                        archive,
570                        "xl/persons/person.xml",
571                    ) {
572                        known_paths.insert("xl/persons/person.xml".to_string());
573                        found = Some(pl);
574                    }
575                }
576                found.unwrap_or_default()
577            };
578
579            // Parse sparklines from worksheet extension lists.
580            for (i, ws_path) in worksheet_paths.iter().enumerate() {
581                if let Ok(raw) = read_string_part(archive, ws_path) {
582                    let parsed = parse_sparklines_from_xml(&raw);
583                    if !parsed.is_empty() {
584                        sheet_sparklines[i] = parsed;
585                    }
586                }
587            }
588
589            // Load VBA project binary blob if present (macro-enabled files).
590            vba_blob = read_bytes_part(archive, "xl/vbaProject.bin").ok();
591            if vba_blob.is_some() {
592                known_paths.insert("xl/vbaProject.bin".to_string());
593            }
594
595            // Parse table parts referenced from worksheet relationships.
596            for (sheet_idx, sheet_path) in worksheet_paths.iter().enumerate() {
597                let Some(rels) = worksheet_rels.get(&sheet_idx) else {
598                    continue;
599                };
600                for rel in &rels.relationships {
601                    if rel.rel_type != rel_types::TABLE {
602                        continue;
603                    }
604                    let table_path = resolve_relationship_target(sheet_path, &rel.target);
605                    if let Ok(table_xml) =
606                        read_xml_part::<sheetkit_xml::table::TableXml, _>(archive, &table_path)
607                    {
608                        known_paths.insert(table_path.clone());
609                        tables.push((table_path, table_xml, sheet_idx));
610                    }
611                }
612            }
613            // Fallback: load table parts from content type overrides if not found via rels.
614            for ovr in &content_types.overrides {
615                if ovr.content_type != mime_types::TABLE {
616                    continue;
617                }
618                let table_path = ovr.part_name.trim_start_matches('/').to_string();
619                if tables.iter().any(|(p, _, _)| p == &table_path) {
620                    continue;
621                }
622                if let Ok(table_xml) =
623                    read_xml_part::<sheetkit_xml::table::TableXml, _>(archive, &table_path)
624                {
625                    known_paths.insert(table_path.clone());
626                    tables.push((table_path, table_xml, 0));
627                }
628            }
629        }
630
631        let sheet_form_controls: Vec<Vec<crate::control::FormControlConfig>> =
632            vec![vec![]; worksheets.len()];
633
634        // Build sheet name -> index lookup.
635        let mut sheet_name_index = HashMap::with_capacity(worksheets.len());
636        for (i, (name, _)) in worksheets.iter().enumerate() {
637            sheet_name_index.insert(name.clone(), i);
638        }
639
640        // Collect remaining ZIP entries. In Lazy/Stream mode, unhandled entries
641        // go into deferred_parts (typed index); in Eager mode, they go into
642        // unknown_parts for round-trip preservation.
643        let mut unknown_parts: Vec<(String, Vec<u8>)> = Vec::new();
644        let mut deferred_parts = crate::workbook::aux::DeferredAuxParts::new();
645        for i in 0..archive.len() {
646            let Ok(entry) = archive.by_index(i) else {
647                continue;
648            };
649            let name = entry.name().to_string();
650            drop(entry);
651            if !known_paths.contains(&name) {
652                if let Ok(bytes) = read_bytes_part(archive, &name) {
653                    if skip_aux && crate::workbook::aux::classify_deferred_path(&name).is_some() {
654                        deferred_parts.insert(name, bytes);
655                    } else {
656                        unknown_parts.push((name, bytes));
657                    }
658                }
659            }
660        }
661
662        // Populate cached column numbers on all eagerly-parsed cells, apply
663        // row limit, and ensure sorted order for binary search correctness.
664        // Deferred sheets (empty OnceLock) are skipped here; they are
665        // post-processed on demand in `deserialize_worksheet_xml`.
666        for (_name, ws_lock) in &mut worksheets {
667            let Some(ws) = ws_lock.get_mut() else {
668                continue;
669            };
670            // Ensure rows are sorted by row number (some writers output unsorted data).
671            ws.sheet_data.rows.sort_unstable_by_key(|r| r.r);
672
673            // Apply sheet_rows limit: keep only the first N rows.
674            if let Some(max_rows) = options.sheet_rows {
675                ws.sheet_data.rows.truncate(max_rows as usize);
676            }
677
678            for row in &mut ws.sheet_data.rows {
679                for cell in &mut row.cells {
680                    cell.col = fast_col_number(cell.r.as_str());
681                }
682                // Ensure cells within a row are sorted by column number.
683                row.cells.sort_unstable_by_key(|c| c.col);
684            }
685        }
686
687        Ok(Self {
688            format,
689            content_types,
690            package_rels,
691            workbook_xml,
692            workbook_rels,
693            worksheets,
694            stylesheet,
695            sst_runtime,
696            sheet_comments,
697            charts,
698            raw_charts,
699            drawings,
700            images,
701            worksheet_drawings,
702            worksheet_rels,
703            drawing_rels,
704            core_properties,
705            app_properties,
706            custom_properties,
707            pivot_tables,
708            pivot_cache_defs,
709            pivot_cache_records,
710            theme_xml,
711            theme_colors,
712            sheet_name_index,
713            sheet_sparklines,
714            sheet_vml,
715            unknown_parts,
716            deferred_parts,
717            vba_blob,
718            tables,
719            // Sheets with raw bytes (deferred or filtered) start clean;
720            // eagerly-parsed sheets (no raw bytes) start dirty so they
721            // always take the serialize path on save.
722            sheet_dirty: raw_sheet_xml.iter().map(|raw| raw.is_none()).collect(),
723            raw_sheet_xml,
724            slicer_defs,
725            slicer_caches,
726            sheet_threaded_comments,
727            person_list,
728            sheet_form_controls,
729            streamed_sheets: HashMap::new(),
730            package_source: None,
731            read_mode: options.read_mode,
732            sheet_rows_limit: options.sheet_rows,
733            date_interpretation: options.date_interpretation,
734        })
735    }
736
737    /// Save the workbook to a file at the given path.
738    ///
739    /// The target format is inferred from the file extension. Supported
740    /// extensions are `.xlsx`, `.xlsm`, `.xltx`, `.xltm`, and `.xlam`.
741    /// An unsupported extension returns [`Error::UnsupportedFileExtension`].
742    ///
743    /// The inferred format overrides the workbook's stored format so that
744    /// the content type in the output always matches the extension.
745    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
746        let path = path.as_ref();
747        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
748        let target_format = WorkbookFormat::from_extension(ext)
749            .ok_or_else(|| Error::UnsupportedFileExtension(ext.to_string()))?;
750
751        let file = std::fs::File::create(path)?;
752        let mut zip = zip::ZipWriter::new(file);
753        let options = SimpleFileOptions::default()
754            .compression_method(CompressionMethod::Deflated)
755            .compression_level(Some(1));
756        self.write_zip_contents(&mut zip, options, Some(target_format))?;
757        zip.finish().map_err(|e| Error::Zip(e.to_string()))?;
758        Ok(())
759    }
760
761    /// Serialize the workbook to an in-memory buffer using the stored format.
762    pub fn save_to_buffer(&self) -> Result<Vec<u8>> {
763        // Estimate compressed output size to reduce reallocations.
764        let estimated = self.worksheets.len() * 4000
765            + self.sst_runtime.len() * 60
766            + self.images.iter().map(|(_, d)| d.len()).sum::<usize>()
767            + 32_000;
768        let mut buf = Vec::with_capacity(estimated);
769        {
770            let cursor = std::io::Cursor::new(&mut buf);
771            let mut zip = zip::ZipWriter::new(cursor);
772            let options =
773                SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
774            self.write_zip_contents(&mut zip, options, None)?;
775            zip.finish().map_err(|e| Error::Zip(e.to_string()))?;
776        }
777        Ok(buf)
778    }
779
780    /// Open a workbook from an in-memory `.xlsx` buffer.
781    pub fn open_from_buffer(data: &[u8]) -> Result<Self> {
782        Self::open_from_buffer_with_options(data, &OpenOptions::default())
783    }
784
785    /// Open a workbook from an in-memory buffer with custom parsing options.
786    pub fn open_from_buffer_with_options(data: &[u8], options: &OpenOptions) -> Result<Self> {
787        // Detect encrypted files (CFB container)
788        #[cfg(feature = "encryption")]
789        if data.len() >= 8 {
790            if let Ok(crate::crypt::ContainerFormat::Cfb) =
791                crate::crypt::detect_container_format(data)
792            {
793                return Err(Error::FileEncrypted);
794            }
795        }
796
797        let cursor = std::io::Cursor::new(data);
798        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Zip(e.to_string()))?;
799        let mut wb = Self::from_archive(&mut archive, options)?;
800        wb.package_source = Some(PackageSource::Buffer(data.into()));
801        wb.read_mode = options.read_mode;
802        Ok(wb)
803    }
804
805    /// Open an encrypted `.xlsx` file using a password.
806    ///
807    /// The file must be in OLE/CFB container format. Supports both Standard
808    /// Encryption (Office 2007, AES-128-ECB) and Agile Encryption (Office
809    /// 2010+, AES-256-CBC).
810    #[cfg(feature = "encryption")]
811    pub fn open_with_password<P: AsRef<Path>>(path: P, password: &str) -> Result<Self> {
812        let data = std::fs::read(path.as_ref())?;
813        let decrypted_zip = crate::crypt::decrypt_xlsx(&data, password)?;
814        let cursor = std::io::Cursor::new(decrypted_zip);
815        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Zip(e.to_string()))?;
816        Self::from_archive(&mut archive, &OpenOptions::default())
817    }
818
819    /// Save the workbook as an encrypted `.xlsx` file using Agile Encryption
820    /// (AES-256-CBC + SHA-512, 100K iterations).
821    #[cfg(feature = "encryption")]
822    pub fn save_with_password<P: AsRef<Path>>(&self, path: P, password: &str) -> Result<()> {
823        // First, serialize to an in-memory ZIP buffer
824        let mut zip_buf = Vec::new();
825        {
826            let cursor = std::io::Cursor::new(&mut zip_buf);
827            let mut zip = zip::ZipWriter::new(cursor);
828            let options =
829                SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
830            self.write_zip_contents(&mut zip, options, None)?;
831            zip.finish().map_err(|e| Error::Zip(e.to_string()))?;
832        }
833
834        // Encrypt and write to CFB container
835        let cfb_data = crate::crypt::encrypt_xlsx(&zip_buf, password)?;
836        std::fs::write(path.as_ref(), &cfb_data)?;
837        Ok(())
838    }
839
840    /// Write all workbook parts into the given ZIP writer.
841    ///
842    /// When `format_override` is `Some`, that format is used for the workbook
843    /// content type instead of the stored `self.format`. This allows `save()`
844    /// to infer the format from the file extension without mutating `self`.
845    fn write_zip_contents<W: std::io::Write + std::io::Seek>(
846        &self,
847        zip: &mut zip::ZipWriter<W>,
848        options: SimpleFileOptions,
849        format_override: Option<WorkbookFormat>,
850    ) -> Result<()> {
851        let effective_format = format_override.unwrap_or(self.format);
852        let mut content_types = self.content_types.clone();
853
854        // Ensure the workbook override content type matches the effective format.
855        if let Some(wb_override) = content_types
856            .overrides
857            .iter_mut()
858            .find(|o| o.part_name == "/xl/workbook.xml")
859        {
860            wb_override.content_type = effective_format.content_type().to_string();
861        }
862
863        // Ensure VBA project content type override and workbook relationship are
864        // present when a VBA blob exists, and absent when it does not.
865        // Skip when deferred_parts is non-empty: relationships are already correct.
866        let has_deferred = self.deferred_parts.has_any();
867        let mut workbook_rels = self.workbook_rels.clone();
868        if self.vba_blob.is_some() {
869            let vba_part_name = "/xl/vbaProject.bin";
870            if !content_types
871                .overrides
872                .iter()
873                .any(|o| o.part_name == vba_part_name)
874            {
875                content_types.overrides.push(ContentTypeOverride {
876                    part_name: vba_part_name.to_string(),
877                    content_type: VBA_PROJECT_CONTENT_TYPE.to_string(),
878                });
879            }
880            if !content_types.defaults.iter().any(|d| d.extension == "bin") {
881                content_types.defaults.push(ContentTypeDefault {
882                    extension: "bin".to_string(),
883                    content_type: VBA_PROJECT_CONTENT_TYPE.to_string(),
884                });
885            }
886            if !workbook_rels
887                .relationships
888                .iter()
889                .any(|r| r.rel_type == VBA_PROJECT_REL_TYPE)
890            {
891                let rid = crate::sheet::next_rid(&workbook_rels.relationships);
892                workbook_rels.relationships.push(Relationship {
893                    id: rid,
894                    rel_type: VBA_PROJECT_REL_TYPE.to_string(),
895                    target: "vbaProject.bin".to_string(),
896                    target_mode: None,
897                });
898            }
899        } else if !has_deferred {
900            content_types
901                .overrides
902                .retain(|o| o.content_type != VBA_PROJECT_CONTENT_TYPE);
903            workbook_rels
904                .relationships
905                .retain(|r| r.rel_type != VBA_PROJECT_REL_TYPE);
906        }
907
908        let mut worksheet_rels = self.worksheet_rels.clone();
909
910        // Synchronize comment/form-control VML parts with worksheet relationships/content types.
911        // Per-sheet VML bytes to write: (sheet_idx, zip_path, bytes).
912        let mut vml_parts_to_write: Vec<(usize, String, Vec<u8>)> = Vec::new();
913        // Per-sheet legacy drawing relationship IDs for worksheet XML serialization.
914        let mut legacy_drawing_rids: HashMap<usize, String> = HashMap::new();
915
916        // Ensure the vml extension default content type is present if any VML exists.
917        let mut has_any_vml = false;
918
919        // When deferred_parts is non-empty (Lazy open), skip comment/VML
920        // synchronization. The original relationships and content types are already
921        // correct, and deferred_parts will supply the raw bytes on save.
922        for sheet_idx in 0..self.worksheets.len() {
923            let has_comments = self
924                .sheet_comments
925                .get(sheet_idx)
926                .and_then(|c| c.as_ref())
927                .is_some();
928            let has_form_controls = self
929                .sheet_form_controls
930                .get(sheet_idx)
931                .map(|v| !v.is_empty())
932                .unwrap_or(false);
933            let has_preserved_vml = self
934                .sheet_vml
935                .get(sheet_idx)
936                .and_then(|v| v.as_ref())
937                .is_some();
938
939            // When deferred_parts is non-empty (Lazy open), skip comment/VML
940            // synchronization only for sheets whose comment data is still deferred
941            // (not yet hydrated). Hydrated sheets need normal relationship sync.
942            if has_deferred && !has_comments && !has_form_controls && !has_preserved_vml {
943                continue;
944            }
945
946            if let Some(rels) = worksheet_rels.get_mut(&sheet_idx) {
947                rels.relationships
948                    .retain(|r| r.rel_type != rel_types::COMMENTS);
949                rels.relationships
950                    .retain(|r| r.rel_type != rel_types::VML_DRAWING);
951            }
952
953            let needs_vml = has_comments || has_form_controls || has_preserved_vml;
954            if !needs_vml && !has_comments {
955                continue;
956            }
957
958            if has_comments {
959                let comment_path = format!("xl/comments{}.xml", sheet_idx + 1);
960                let part_name = format!("/{}", comment_path);
961                if !content_types
962                    .overrides
963                    .iter()
964                    .any(|o| o.part_name == part_name && o.content_type == mime_types::COMMENTS)
965                {
966                    content_types.overrides.push(ContentTypeOverride {
967                        part_name,
968                        content_type: mime_types::COMMENTS.to_string(),
969                    });
970                }
971
972                let sheet_path = self.sheet_part_path(sheet_idx);
973                let target = relative_relationship_target(&sheet_path, &comment_path);
974                let rels = worksheet_rels
975                    .entry(sheet_idx)
976                    .or_insert_with(default_relationships);
977                let rid = crate::sheet::next_rid(&rels.relationships);
978                rels.relationships.push(Relationship {
979                    id: rid,
980                    rel_type: rel_types::COMMENTS.to_string(),
981                    target,
982                    target_mode: None,
983                });
984            }
985
986            if !needs_vml {
987                continue;
988            }
989
990            // Build VML bytes combining comments and form controls.
991            let vml_path = format!("xl/drawings/vmlDrawing{}.vml", sheet_idx + 1);
992            let vml_bytes = if has_comments && has_form_controls {
993                // Both comments and form controls: start with comment VML, then append controls.
994                let comment_vml =
995                    if let Some(bytes) = self.sheet_vml.get(sheet_idx).and_then(|v| v.as_ref()) {
996                        bytes.clone()
997                    } else if let Some(Some(comments)) = self.sheet_comments.get(sheet_idx) {
998                        let cells: Vec<&str> = comments
999                            .comment_list
1000                            .comments
1001                            .iter()
1002                            .map(|c| c.r#ref.as_str())
1003                            .collect();
1004                        crate::vml::build_vml_drawing(&cells).into_bytes()
1005                    } else {
1006                        continue;
1007                    };
1008                let shape_count = crate::control::count_vml_shapes(&comment_vml);
1009                let start_id = 1025 + shape_count;
1010                let form_controls = &self.sheet_form_controls[sheet_idx];
1011                crate::control::merge_vml_controls(&comment_vml, form_controls, start_id)
1012            } else if has_comments {
1013                if let Some(bytes) = self.sheet_vml.get(sheet_idx).and_then(|v| v.as_ref()) {
1014                    bytes.clone()
1015                } else if let Some(Some(comments)) = self.sheet_comments.get(sheet_idx) {
1016                    let cells: Vec<&str> = comments
1017                        .comment_list
1018                        .comments
1019                        .iter()
1020                        .map(|c| c.r#ref.as_str())
1021                        .collect();
1022                    crate::vml::build_vml_drawing(&cells).into_bytes()
1023                } else {
1024                    continue;
1025                }
1026            } else if has_form_controls {
1027                // Hydrated form controls only (no comments).
1028                let form_controls = &self.sheet_form_controls[sheet_idx];
1029                crate::control::build_form_control_vml(form_controls, 1025).into_bytes()
1030            } else if let Some(Some(vml)) = self.sheet_vml.get(sheet_idx) {
1031                // Preserved VML bytes only (controls not hydrated, no comments).
1032                vml.clone()
1033            } else {
1034                continue;
1035            };
1036
1037            let vml_part_name = format!("/{}", vml_path);
1038            if !content_types
1039                .overrides
1040                .iter()
1041                .any(|o| o.part_name == vml_part_name && o.content_type == mime_types::VML_DRAWING)
1042            {
1043                content_types.overrides.push(ContentTypeOverride {
1044                    part_name: vml_part_name,
1045                    content_type: mime_types::VML_DRAWING.to_string(),
1046                });
1047            }
1048
1049            let sheet_path = self.sheet_part_path(sheet_idx);
1050            let rels = worksheet_rels
1051                .entry(sheet_idx)
1052                .or_insert_with(default_relationships);
1053            let vml_target = relative_relationship_target(&sheet_path, &vml_path);
1054            let vml_rid = crate::sheet::next_rid(&rels.relationships);
1055            rels.relationships.push(Relationship {
1056                id: vml_rid.clone(),
1057                rel_type: rel_types::VML_DRAWING.to_string(),
1058                target: vml_target,
1059                target_mode: None,
1060            });
1061
1062            legacy_drawing_rids.insert(sheet_idx, vml_rid);
1063            vml_parts_to_write.push((sheet_idx, vml_path, vml_bytes));
1064            has_any_vml = true;
1065        }
1066
1067        // Add vml extension default content type if needed.
1068        if has_any_vml && !content_types.defaults.iter().any(|d| d.extension == "vml") {
1069            content_types.defaults.push(ContentTypeDefault {
1070                extension: "vml".to_string(),
1071                content_type: mime_types::VML_DRAWING.to_string(),
1072            });
1073        }
1074
1075        // Synchronize table parts with worksheet relationships and content types.
1076        // Also build tableParts references for each worksheet.
1077        // In Lazy mode, untouched deferred tables should remain pass-through.
1078        // Once table data is mutated (or new live tables exist), we fully
1079        // resynchronize worksheet rels/content types/tableParts.
1080        use crate::workbook::aux::AuxCategory;
1081        let mut table_parts_by_sheet: HashMap<usize, Vec<String>> = HashMap::new();
1082        let should_sync_tables = !has_deferred
1083            || self.deferred_parts.is_dirty(AuxCategory::Tables)
1084            || !self.tables.is_empty();
1085        if should_sync_tables {
1086            for (sheet_idx, _) in self.worksheets.iter().enumerate() {
1087                if let Some(rels) = worksheet_rels.get_mut(&sheet_idx) {
1088                    rels.relationships
1089                        .retain(|r| r.rel_type != rel_types::TABLE);
1090                }
1091            }
1092            content_types
1093                .overrides
1094                .retain(|o| o.content_type != mime_types::TABLE);
1095        }
1096        for (table_path, _table_xml, sheet_idx) in &self.tables {
1097            let part_name = format!("/{table_path}");
1098            content_types.overrides.push(ContentTypeOverride {
1099                part_name,
1100                content_type: mime_types::TABLE.to_string(),
1101            });
1102
1103            let sheet_path = self.sheet_part_path(*sheet_idx);
1104            let target = relative_relationship_target(&sheet_path, table_path);
1105            let rels = worksheet_rels
1106                .entry(*sheet_idx)
1107                .or_insert_with(default_relationships);
1108            let rid = crate::sheet::next_rid(&rels.relationships);
1109            rels.relationships.push(Relationship {
1110                id: rid.clone(),
1111                rel_type: rel_types::TABLE.to_string(),
1112                target,
1113                target_mode: None,
1114            });
1115            table_parts_by_sheet
1116                .entry(*sheet_idx)
1117                .or_default()
1118                .push(rid);
1119        }
1120
1121        // Register threaded comment content types and relationships before writing.
1122        let has_any_threaded = self.sheet_threaded_comments.iter().any(|tc| tc.is_some());
1123        if has_any_threaded {
1124            for (i, tc) in self.sheet_threaded_comments.iter().enumerate() {
1125                if tc.is_some() {
1126                    let tc_path = format!("xl/threadedComments/threadedComment{}.xml", i + 1);
1127                    let tc_part_name = format!("/{tc_path}");
1128                    if !content_types.overrides.iter().any(|o| {
1129                        o.part_name == tc_part_name
1130                            && o.content_type
1131                                == sheetkit_xml::threaded_comment::THREADED_COMMENTS_CONTENT_TYPE
1132                    }) {
1133                        content_types.overrides.push(ContentTypeOverride {
1134                            part_name: tc_part_name,
1135                            content_type:
1136                                sheetkit_xml::threaded_comment::THREADED_COMMENTS_CONTENT_TYPE
1137                                    .to_string(),
1138                        });
1139                    }
1140
1141                    let sheet_path = self.sheet_part_path(i);
1142                    let target = relative_relationship_target(&sheet_path, &tc_path);
1143                    let rels = worksheet_rels
1144                        .entry(i)
1145                        .or_insert_with(default_relationships);
1146                    if !rels.relationships.iter().any(|r| {
1147                        r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_THREADED_COMMENT
1148                    }) {
1149                        let rid = crate::sheet::next_rid(&rels.relationships);
1150                        rels.relationships.push(Relationship {
1151                            id: rid,
1152                            rel_type: sheetkit_xml::threaded_comment::REL_TYPE_THREADED_COMMENT
1153                                .to_string(),
1154                            target,
1155                            target_mode: None,
1156                        });
1157                    }
1158                }
1159            }
1160
1161            let person_part_name = "/xl/persons/person.xml";
1162            if !content_types.overrides.iter().any(|o| {
1163                o.part_name == person_part_name
1164                    && o.content_type == sheetkit_xml::threaded_comment::PERSON_LIST_CONTENT_TYPE
1165            }) {
1166                content_types.overrides.push(ContentTypeOverride {
1167                    part_name: person_part_name.to_string(),
1168                    content_type: sheetkit_xml::threaded_comment::PERSON_LIST_CONTENT_TYPE
1169                        .to_string(),
1170                });
1171            }
1172
1173            // Add person relationship to workbook_rels so Excel can discover the person list.
1174            if !workbook_rels
1175                .relationships
1176                .iter()
1177                .any(|r| r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_PERSON)
1178            {
1179                let rid = crate::sheet::next_rid(&workbook_rels.relationships);
1180                workbook_rels.relationships.push(Relationship {
1181                    id: rid,
1182                    rel_type: sheetkit_xml::threaded_comment::REL_TYPE_PERSON.to_string(),
1183                    target: "persons/person.xml".to_string(),
1184                    target_mode: None,
1185                });
1186            }
1187        }
1188
1189        // [Content_Types].xml
1190        write_xml_part(zip, "[Content_Types].xml", &content_types, options)?;
1191
1192        // _rels/.rels
1193        write_xml_part(zip, "_rels/.rels", &self.package_rels, options)?;
1194
1195        // xl/workbook.xml
1196        write_xml_part(zip, "xl/workbook.xml", &self.workbook_xml, options)?;
1197
1198        // xl/_rels/workbook.xml.rels
1199        write_xml_part(zip, "xl/_rels/workbook.xml.rels", &workbook_rels, options)?;
1200
1201        // xl/worksheets/sheet{N}.xml
1202        for (i, (_name, ws_lock)) in self.worksheets.iter().enumerate() {
1203            let entry_name = self.sheet_part_path(i);
1204            let dirty = self.sheet_dirty.get(i).copied().unwrap_or(true);
1205
1206            // If the sheet has streamed data, write it directly from the temp file.
1207            if let Some(streamed) = self.streamed_sheets.get(&i) {
1208                crate::stream::write_streamed_sheet(zip, &entry_name, streamed, options)?;
1209                continue;
1210            }
1211
1212            // Copy-on-write passthrough: if the sheet has not been modified
1213            // (not dirty) and raw XML bytes are available, write them directly.
1214            // This avoids deserialize-then-serialize overhead for untouched
1215            // sheets. Dirty sheets always take the serialization path even if
1216            // raw bytes happen to still be present.
1217            //
1218            // The passthrough is also disabled when auxiliary parts (comments,
1219            // tables, sparklines) require XML injection into the worksheet,
1220            // since the raw bytes would lack those references.
1221            let needs_aux_injection =
1222                legacy_drawing_rids.contains_key(&i) || table_parts_by_sheet.contains_key(&i);
1223            if !dirty && !needs_aux_injection {
1224                if let Some(Some(raw_bytes)) = self.raw_sheet_xml.get(i) {
1225                    zip.start_file(&entry_name, options)
1226                        .map_err(|e| Error::Zip(e.to_string()))?;
1227                    zip.write_all(raw_bytes)?;
1228                    continue;
1229                }
1230            }
1231
1232            // For non-dirty sheets that need aux injection (comments/tables),
1233            // or lazy/deferred sheets whose OnceLock is uninitialized, hydrate
1234            // from raw bytes. We intentionally avoid worksheet_ref_by_index
1235            // here because it applies sheet_rows truncation, which would cause
1236            // data loss on save for sheets that were never read by the user.
1237            //
1238            // Filtered-out sheets (via OpenOptions::sheets) have their OnceLock
1239            // initialized with an empty placeholder while raw_sheet_xml holds
1240            // the real data. We must prefer raw bytes over the placeholder.
1241            let hydrated_for_save: WorksheetXml;
1242            let ws = if !dirty {
1243                if let Some(Some(raw_bytes)) = self.raw_sheet_xml.get(i) {
1244                    hydrated_for_save = deserialize_worksheet_xml(raw_bytes)?;
1245                    &hydrated_for_save
1246                } else {
1247                    match ws_lock.get() {
1248                        Some(ws) => ws,
1249                        None => continue,
1250                    }
1251                }
1252            } else {
1253                match ws_lock.get() {
1254                    Some(ws) => ws,
1255                    None => {
1256                        if let Some(Some(raw_bytes)) = self.raw_sheet_xml.get(i) {
1257                            hydrated_for_save = deserialize_worksheet_xml(raw_bytes)?;
1258                            &hydrated_for_save
1259                        } else {
1260                            continue;
1261                        }
1262                    }
1263                }
1264            };
1265
1266            let empty_sparklines: Vec<crate::sparkline::SparklineConfig> = vec![];
1267            let sparklines = self.sheet_sparklines.get(i).unwrap_or(&empty_sparklines);
1268            let legacy_rid = legacy_drawing_rids.get(&i).map(|s| s.as_str());
1269            let sheet_table_rids = table_parts_by_sheet.get(&i);
1270            let stale_table_parts =
1271                should_sync_tables && sheet_table_rids.is_none() && ws.table_parts.is_some();
1272            let has_extras = legacy_rid.is_some()
1273                || !sparklines.is_empty()
1274                || sheet_table_rids.is_some()
1275                || stale_table_parts;
1276
1277            if !has_extras {
1278                write_xml_part(zip, &entry_name, ws, options)?;
1279            } else {
1280                let ws_to_serialize;
1281                let ws_ref = if let Some(rids) = sheet_table_rids {
1282                    ws_to_serialize = {
1283                        let mut cloned = ws.clone();
1284                        use sheetkit_xml::worksheet::{TablePart, TableParts};
1285                        cloned.table_parts = Some(TableParts {
1286                            count: Some(rids.len() as u32),
1287                            table_parts: rids
1288                                .iter()
1289                                .map(|rid| TablePart { r_id: rid.clone() })
1290                                .collect(),
1291                        });
1292                        cloned
1293                    };
1294                    &ws_to_serialize
1295                } else if stale_table_parts {
1296                    ws_to_serialize = {
1297                        let mut cloned = ws.clone();
1298                        cloned.table_parts = None;
1299                        cloned
1300                    };
1301                    &ws_to_serialize
1302                } else {
1303                    ws
1304                };
1305                let xml = serialize_worksheet_with_extras(ws_ref, sparklines, legacy_rid)?;
1306                zip.start_file(&entry_name, options)
1307                    .map_err(|e| Error::Zip(e.to_string()))?;
1308                zip.write_all(xml.as_bytes())?;
1309            }
1310        }
1311
1312        // xl/styles.xml
1313        write_xml_part(zip, "xl/styles.xml", &self.stylesheet, options)?;
1314
1315        // xl/sharedStrings.xml -- write from the runtime SST
1316        let sst_xml = self.sst_runtime.to_sst();
1317        write_xml_part(zip, "xl/sharedStrings.xml", &sst_xml, options)?;
1318
1319        // xl/comments{N}.xml -- write per-sheet comments
1320        for (i, comments) in self.sheet_comments.iter().enumerate() {
1321            if let Some(ref c) = comments {
1322                let entry_name = format!("xl/comments{}.xml", i + 1);
1323                write_xml_part(zip, &entry_name, c, options)?;
1324            }
1325        }
1326
1327        // xl/drawings/vmlDrawing{N}.vml -- write VML drawing parts
1328        for (_sheet_idx, vml_path, vml_bytes) in &vml_parts_to_write {
1329            zip.start_file(vml_path, options)
1330                .map_err(|e| Error::Zip(e.to_string()))?;
1331            zip.write_all(vml_bytes)?;
1332        }
1333
1334        // xl/drawings/drawing{N}.xml -- write drawing parts
1335        for (path, drawing) in &self.drawings {
1336            write_xml_part(zip, path, drawing, options)?;
1337        }
1338
1339        // xl/charts/chart{N}.xml -- write chart parts
1340        for (path, chart) in &self.charts {
1341            write_xml_part(zip, path, chart, options)?;
1342        }
1343        for (path, data) in &self.raw_charts {
1344            if self.charts.iter().any(|(p, _)| p == path) {
1345                continue;
1346            }
1347            zip.start_file(path, options)
1348                .map_err(|e| Error::Zip(e.to_string()))?;
1349            zip.write_all(data)?;
1350        }
1351
1352        // xl/media/image{N}.{ext} -- write image data
1353        for (path, data) in &self.images {
1354            zip.start_file(path, options)
1355                .map_err(|e| Error::Zip(e.to_string()))?;
1356            zip.write_all(data)?;
1357        }
1358
1359        // xl/worksheets/_rels/sheet{N}.xml.rels -- write worksheet relationships
1360        for (sheet_idx, rels) in &worksheet_rels {
1361            let sheet_path = self.sheet_part_path(*sheet_idx);
1362            let path = relationship_part_path(&sheet_path);
1363            write_xml_part(zip, &path, rels, options)?;
1364        }
1365
1366        // xl/drawings/_rels/drawing{N}.xml.rels -- write drawing relationships
1367        for (drawing_idx, rels) in &self.drawing_rels {
1368            if let Some((drawing_path, _)) = self.drawings.get(*drawing_idx) {
1369                let path = relationship_part_path(drawing_path);
1370                write_xml_part(zip, &path, rels, options)?;
1371            }
1372        }
1373
1374        // xl/pivotTables/pivotTable{N}.xml
1375        for (path, pt) in &self.pivot_tables {
1376            write_xml_part(zip, path, pt, options)?;
1377        }
1378
1379        // xl/pivotCache/pivotCacheDefinition{N}.xml
1380        for (path, pcd) in &self.pivot_cache_defs {
1381            write_xml_part(zip, path, pcd, options)?;
1382        }
1383
1384        // xl/pivotCache/pivotCacheRecords{N}.xml
1385        for (path, pcr) in &self.pivot_cache_records {
1386            write_xml_part(zip, path, pcr, options)?;
1387        }
1388
1389        // xl/tables/table{N}.xml
1390        for (path, table_xml, _sheet_idx) in &self.tables {
1391            write_xml_part(zip, path, table_xml, options)?;
1392        }
1393
1394        // xl/slicers/slicer{N}.xml
1395        for (path, sd) in &self.slicer_defs {
1396            write_xml_part(zip, path, sd, options)?;
1397        }
1398
1399        // xl/slicerCaches/slicerCache{N}.xml (manual serialization)
1400        for (path, scd) in &self.slicer_caches {
1401            let xml_str = format!(
1402                "{}\n{}",
1403                XML_DECLARATION,
1404                sheetkit_xml::slicer::serialize_slicer_cache(scd),
1405            );
1406            zip.start_file(path, options)
1407                .map_err(|e| Error::Zip(e.to_string()))?;
1408            zip.write_all(xml_str.as_bytes())?;
1409        }
1410
1411        // xl/theme/theme1.xml
1412        {
1413            let default_theme = crate::theme::default_theme_xml();
1414            let theme_bytes = self.theme_xml.as_deref().unwrap_or(&default_theme);
1415            zip.start_file("xl/theme/theme1.xml", options)
1416                .map_err(|e| Error::Zip(e.to_string()))?;
1417            zip.write_all(theme_bytes)?;
1418        }
1419
1420        // xl/vbaProject.bin -- write VBA blob if present
1421        if let Some(ref blob) = self.vba_blob {
1422            zip.start_file("xl/vbaProject.bin", options)
1423                .map_err(|e| Error::Zip(e.to_string()))?;
1424            zip.write_all(blob)?;
1425        }
1426
1427        // docProps/core.xml
1428        if let Some(ref props) = self.core_properties {
1429            let xml_str = sheetkit_xml::doc_props::serialize_core_properties(props);
1430            zip.start_file("docProps/core.xml", options)
1431                .map_err(|e| Error::Zip(e.to_string()))?;
1432            zip.write_all(xml_str.as_bytes())?;
1433        }
1434
1435        // docProps/app.xml
1436        if let Some(ref props) = self.app_properties {
1437            write_xml_part(zip, "docProps/app.xml", props, options)?;
1438        }
1439
1440        // docProps/custom.xml
1441        if let Some(ref props) = self.custom_properties {
1442            let xml_str = sheetkit_xml::doc_props::serialize_custom_properties(props);
1443            zip.start_file("docProps/custom.xml", options)
1444                .map_err(|e| Error::Zip(e.to_string()))?;
1445            zip.write_all(xml_str.as_bytes())?;
1446        }
1447
1448        // xl/threadedComments/threadedComment{N}.xml
1449        if has_any_threaded {
1450            for (i, tc) in self.sheet_threaded_comments.iter().enumerate() {
1451                if let Some(ref tc_data) = tc {
1452                    let tc_path = format!("xl/threadedComments/threadedComment{}.xml", i + 1);
1453                    write_xml_part(zip, &tc_path, tc_data, options)?;
1454                }
1455            }
1456            write_xml_part(zip, "xl/persons/person.xml", &self.person_list, options)?;
1457        }
1458
1459        // Write back unknown parts preserved from the original file.
1460        for (path, data) in &self.unknown_parts {
1461            zip.start_file(path, options)
1462                .map_err(|e| Error::Zip(e.to_string()))?;
1463            zip.write_all(data)?;
1464        }
1465
1466        // Write back deferred parts from Lazy open (raw bytes, unparsed).
1467        // Skip any path that was already written by the normal code above. This
1468        // prevents duplicate ZIP entries when an auxiliary part (comments, doc
1469        // properties, etc.) is mutated after a Lazy open.
1470        if self.deferred_parts.has_any() {
1471            let mut emitted_owned: HashSet<String> = HashSet::new();
1472            // Essential parts always written.
1473            emitted_owned.insert("[Content_Types].xml".to_string());
1474            emitted_owned.insert("_rels/.rels".to_string());
1475            emitted_owned.insert("xl/workbook.xml".to_string());
1476            emitted_owned.insert("xl/_rels/workbook.xml.rels".to_string());
1477            emitted_owned.insert("xl/styles.xml".to_string());
1478            emitted_owned.insert("xl/sharedStrings.xml".to_string());
1479            emitted_owned.insert("xl/theme/theme1.xml".to_string());
1480            // Per-sheet worksheet paths.
1481            for i in 0..self.worksheets.len() {
1482                emitted_owned.insert(self.sheet_part_path(i));
1483            }
1484            for (i, comments) in self.sheet_comments.iter().enumerate() {
1485                if comments.is_some() {
1486                    emitted_owned.insert(format!("xl/comments{}.xml", i + 1));
1487                }
1488            }
1489            for (_sheet_idx, vml_path, _) in &vml_parts_to_write {
1490                emitted_owned.insert(vml_path.clone());
1491            }
1492            for (path, _) in &self.drawings {
1493                emitted_owned.insert(path.clone());
1494            }
1495            for (path, _) in &self.charts {
1496                emitted_owned.insert(path.clone());
1497            }
1498            for (path, _) in &self.raw_charts {
1499                emitted_owned.insert(path.clone());
1500            }
1501            for (path, _) in &self.images {
1502                emitted_owned.insert(path.clone());
1503            }
1504            for sheet_idx in worksheet_rels.keys() {
1505                let sheet_path = self.sheet_part_path(*sheet_idx);
1506                emitted_owned.insert(relationship_part_path(&sheet_path));
1507            }
1508            for drawing_idx in self.drawing_rels.keys() {
1509                if let Some((drawing_path, _)) = self.drawings.get(*drawing_idx) {
1510                    emitted_owned.insert(relationship_part_path(drawing_path));
1511                }
1512            }
1513            for (path, _) in &self.pivot_tables {
1514                emitted_owned.insert(path.clone());
1515            }
1516            for (path, _) in &self.pivot_cache_defs {
1517                emitted_owned.insert(path.clone());
1518            }
1519            for (path, _) in &self.pivot_cache_records {
1520                emitted_owned.insert(path.clone());
1521            }
1522            for (path, _, _) in &self.tables {
1523                emitted_owned.insert(path.clone());
1524            }
1525            for (path, _) in &self.slicer_defs {
1526                emitted_owned.insert(path.clone());
1527            }
1528            for (path, _) in &self.slicer_caches {
1529                emitted_owned.insert(path.clone());
1530            }
1531            if self.vba_blob.is_some() {
1532                emitted_owned.insert("xl/vbaProject.bin".to_string());
1533            }
1534            if self.core_properties.is_some() {
1535                emitted_owned.insert("docProps/core.xml".to_string());
1536            }
1537            if self.app_properties.is_some() {
1538                emitted_owned.insert("docProps/app.xml".to_string());
1539            }
1540            if self.custom_properties.is_some() {
1541                emitted_owned.insert("docProps/custom.xml".to_string());
1542            }
1543            if has_any_threaded {
1544                for (i, tc) in self.sheet_threaded_comments.iter().enumerate() {
1545                    if tc.is_some() {
1546                        emitted_owned
1547                            .insert(format!("xl/threadedComments/threadedComment{}.xml", i + 1));
1548                    }
1549                }
1550                emitted_owned.insert("xl/persons/person.xml".to_string());
1551            }
1552            for (path, _) in &self.unknown_parts {
1553                emitted_owned.insert(path.clone());
1554            }
1555
1556            for (path, data) in self.deferred_parts.remaining_parts() {
1557                if emitted_owned.contains(path) {
1558                    continue;
1559                }
1560                zip.start_file(path, options)
1561                    .map_err(|e| Error::Zip(e.to_string()))?;
1562                zip.write_all(data)?;
1563            }
1564        }
1565
1566        Ok(())
1567    }
1568}
1569
1570impl Default for Workbook {
1571    fn default() -> Self {
1572        Self::new()
1573    }
1574}
1575
1576/// Serialize a value to XML with the standard XML declaration prepended.
1577pub(crate) fn serialize_xml<T: Serialize>(value: &T) -> Result<String> {
1578    let body = quick_xml::se::to_string(value).map_err(|e| Error::XmlParse(e.to_string()))?;
1579    let mut result = String::with_capacity(XML_DECLARATION.len() + 1 + body.len());
1580    result.push_str(XML_DECLARATION);
1581    result.push('\n');
1582    result.push_str(&body);
1583    Ok(result)
1584}
1585
1586/// Deserialize a `WorksheetXml` from raw XML bytes.
1587///
1588/// This is the on-demand counterpart of `read_xml_part` for worksheet data
1589/// that was stored as raw bytes during open (lazy mode or filtered-out sheets).
1590/// After deserialization, cell column numbers are populated and rows/cells
1591/// are sorted for binary-search correctness.
1592pub(super) fn deserialize_worksheet_xml(bytes: &[u8]) -> Result<WorksheetXml> {
1593    let buf_cap = bytes.len().clamp(8192, LARGE_BUF_CAPACITY);
1594    let reader = std::io::BufReader::with_capacity(buf_cap, bytes);
1595    let mut ws: WorksheetXml =
1596        quick_xml::de::from_reader(reader).map_err(|e| Error::XmlDeserialize(e.to_string()))?;
1597    // Post-process: populate cached column numbers, sort rows and cells.
1598    ws.sheet_data.rows.sort_unstable_by_key(|r| r.r);
1599    for row in &mut ws.sheet_data.rows {
1600        for cell in &mut row.cells {
1601            cell.col = fast_col_number(cell.r.as_str());
1602        }
1603        row.cells.sort_unstable_by_key(|c| c.col);
1604        row.cells.shrink_to_fit();
1605    }
1606    ws.sheet_data.rows.shrink_to_fit();
1607    Ok(ws)
1608}
1609
1610/// BufReader capacity for large XML parts (worksheets, sharedStrings).
1611/// 64 KB reduces read-syscall overhead compared to the 8 KB default.
1612const LARGE_BUF_CAPACITY: usize = 64 * 1024;
1613
1614/// Read a ZIP entry and deserialize it from XML.
1615///
1616/// Uses `quick_xml::de::from_reader` to deserialize directly from the
1617/// decompressed ZIP stream, avoiding the intermediate `String` allocation
1618/// that `read_to_string` + `from_str` would require. The BufReader
1619/// capacity is scaled based on the uncompressed entry size, up to 64 KB,
1620/// to reduce read-syscall overhead on large parts.
1621pub(crate) fn read_xml_part<T: serde::de::DeserializeOwned, R: std::io::Read + std::io::Seek>(
1622    archive: &mut zip::ZipArchive<R>,
1623    name: &str,
1624) -> Result<T> {
1625    let entry = archive
1626        .by_name(name)
1627        .map_err(|e| Error::Zip(e.to_string()))?;
1628    let size = entry.size() as usize;
1629    let buf_cap = size.clamp(8192, LARGE_BUF_CAPACITY);
1630    let reader = std::io::BufReader::with_capacity(buf_cap, entry);
1631    quick_xml::de::from_reader(reader).map_err(|e| Error::XmlDeserialize(e.to_string()))
1632}
1633
1634/// Read a ZIP entry as a raw string (no serde deserialization).
1635pub(crate) fn read_string_part<R: std::io::Read + std::io::Seek>(
1636    archive: &mut zip::ZipArchive<R>,
1637    name: &str,
1638) -> Result<String> {
1639    let mut entry = archive
1640        .by_name(name)
1641        .map_err(|e| Error::Zip(e.to_string()))?;
1642    let size_hint = entry.size() as usize;
1643    let mut content = String::with_capacity(size_hint);
1644    entry
1645        .read_to_string(&mut content)
1646        .map_err(|e| Error::Zip(e.to_string()))?;
1647    Ok(content)
1648}
1649
1650/// Read a ZIP entry as raw bytes.
1651pub(crate) fn read_bytes_part<R: std::io::Read + std::io::Seek>(
1652    archive: &mut zip::ZipArchive<R>,
1653    name: &str,
1654) -> Result<Vec<u8>> {
1655    let mut entry = archive
1656        .by_name(name)
1657        .map_err(|e| Error::Zip(e.to_string()))?;
1658    let size_hint = entry.size() as usize;
1659    let mut content = Vec::with_capacity(size_hint);
1660    entry
1661        .read_to_end(&mut content)
1662        .map_err(|e| Error::Zip(e.to_string()))?;
1663    Ok(content)
1664}
1665
1666/// Serialize a worksheet with optional sparklines and legacy drawing injected
1667/// via string manipulation, avoiding a full WorksheetXml clone.
1668pub(crate) fn serialize_worksheet_with_extras(
1669    ws: &WorksheetXml,
1670    sparklines: &[crate::sparkline::SparklineConfig],
1671    legacy_drawing_rid: Option<&str>,
1672) -> Result<String> {
1673    let body = quick_xml::se::to_string(ws).map_err(|e| Error::XmlParse(e.to_string()))?;
1674
1675    let closing = "</worksheet>";
1676    let ext_xml = if sparklines.is_empty() {
1677        String::new()
1678    } else {
1679        build_sparkline_ext_xml(sparklines)
1680    };
1681    let legacy_xml = if let Some(rid) = legacy_drawing_rid {
1682        format!("<legacyDrawing r:id=\"{rid}\"/>")
1683    } else {
1684        String::new()
1685    };
1686
1687    if let Some(pos) = body.rfind(closing) {
1688        // If injecting a legacy drawing, strip any existing one from the serde output
1689        // to avoid duplicates (the original ws.legacy_drawing may already be set).
1690        let body_prefix = &body[..pos];
1691        let stripped;
1692        let prefix = if !legacy_xml.is_empty() {
1693            if let Some(ld_start) = body_prefix.find("<legacyDrawing ") {
1694                // Find the end of the self-closing element.
1695                let ld_end = body_prefix[ld_start..]
1696                    .find("/>")
1697                    .map(|e| ld_start + e + 2)
1698                    .unwrap_or(ld_start);
1699                stripped = format!("{}{}", &body_prefix[..ld_start], &body_prefix[ld_end..]);
1700                stripped.as_str()
1701            } else {
1702                body_prefix
1703            }
1704        } else {
1705            body_prefix
1706        };
1707
1708        let extra_len = ext_xml.len() + legacy_xml.len();
1709        let mut result = String::with_capacity(XML_DECLARATION.len() + 1 + body.len() + extra_len);
1710        result.push_str(XML_DECLARATION);
1711        result.push('\n');
1712        result.push_str(prefix);
1713        result.push_str(&legacy_xml);
1714        result.push_str(&ext_xml);
1715        result.push_str(closing);
1716        Ok(result)
1717    } else {
1718        Ok(format!("{XML_DECLARATION}\n{body}"))
1719    }
1720}
1721
1722/// Build the extLst XML block for sparklines using manual string construction.
1723pub(crate) fn build_sparkline_ext_xml(sparklines: &[crate::sparkline::SparklineConfig]) -> String {
1724    use std::fmt::Write;
1725    let mut xml = String::new();
1726    let _ = write!(
1727        xml,
1728        "<extLst>\
1729         <ext xmlns:x14=\"http://schemas.microsoft.com/office/spreadsheetml/2009/9/main\" \
1730         uri=\"{{05C60535-1F16-4fd2-B633-F4F36F0B64E0}}\">\
1731         <x14:sparklineGroups \
1732         xmlns:xm=\"http://schemas.microsoft.com/office/excel/2006/main\">"
1733    );
1734    for config in sparklines {
1735        let group = crate::sparkline::config_to_xml_group(config);
1736        let _ = write!(xml, "<x14:sparklineGroup");
1737        if let Some(ref t) = group.sparkline_type {
1738            let _ = write!(xml, " type=\"{t}\"");
1739        }
1740        if group.markers == Some(true) {
1741            let _ = write!(xml, " markers=\"1\"");
1742        }
1743        if group.high == Some(true) {
1744            let _ = write!(xml, " high=\"1\"");
1745        }
1746        if group.low == Some(true) {
1747            let _ = write!(xml, " low=\"1\"");
1748        }
1749        if group.first == Some(true) {
1750            let _ = write!(xml, " first=\"1\"");
1751        }
1752        if group.last == Some(true) {
1753            let _ = write!(xml, " last=\"1\"");
1754        }
1755        if group.negative == Some(true) {
1756            let _ = write!(xml, " negative=\"1\"");
1757        }
1758        if group.display_x_axis == Some(true) {
1759            let _ = write!(xml, " displayXAxis=\"1\"");
1760        }
1761        if let Some(w) = group.line_weight {
1762            let _ = write!(xml, " lineWeight=\"{w}\"");
1763        }
1764        let _ = write!(xml, "><x14:sparklines>");
1765        for sp in &group.sparklines.items {
1766            let _ = write!(
1767                xml,
1768                "<x14:sparkline><xm:f>{}</xm:f><xm:sqref>{}</xm:sqref></x14:sparkline>",
1769                sp.formula, sp.sqref
1770            );
1771        }
1772        let _ = write!(xml, "</x14:sparklines></x14:sparklineGroup>");
1773    }
1774    let _ = write!(xml, "</x14:sparklineGroups></ext></extLst>");
1775    xml
1776}
1777
1778/// Parse sparkline configurations from raw worksheet XML content.
1779pub(crate) fn parse_sparklines_from_xml(xml: &str) -> Vec<crate::sparkline::SparklineConfig> {
1780    use crate::sparkline::{SparklineConfig, SparklineType};
1781
1782    let mut sparklines = Vec::new();
1783
1784    // Find all sparklineGroup elements and parse their attributes and children.
1785    let mut search_from = 0;
1786    while let Some(group_start) = xml[search_from..].find("<x14:sparklineGroup") {
1787        let abs_start = search_from + group_start;
1788        let group_end_tag = "</x14:sparklineGroup>";
1789        let abs_end = match xml[abs_start..].find(group_end_tag) {
1790            Some(pos) => abs_start + pos + group_end_tag.len(),
1791            None => break,
1792        };
1793        let group_xml = &xml[abs_start..abs_end];
1794
1795        // Parse group-level attributes.
1796        let sparkline_type = extract_xml_attr(group_xml, "type")
1797            .and_then(|s| SparklineType::parse(&s))
1798            .unwrap_or_default();
1799        let markers = extract_xml_bool_attr(group_xml, "markers");
1800        let high_point = extract_xml_bool_attr(group_xml, "high");
1801        let low_point = extract_xml_bool_attr(group_xml, "low");
1802        let first_point = extract_xml_bool_attr(group_xml, "first");
1803        let last_point = extract_xml_bool_attr(group_xml, "last");
1804        let negative_points = extract_xml_bool_attr(group_xml, "negative");
1805        let show_axis = extract_xml_bool_attr(group_xml, "displayXAxis");
1806        let line_weight =
1807            extract_xml_attr(group_xml, "lineWeight").and_then(|s| s.parse::<f64>().ok());
1808
1809        // Parse individual sparkline entries within this group.
1810        let mut sp_from = 0;
1811        while let Some(sp_start) = group_xml[sp_from..].find("<x14:sparkline>") {
1812            let sp_abs = sp_from + sp_start;
1813            let sp_end_tag = "</x14:sparkline>";
1814            let sp_abs_end = match group_xml[sp_abs..].find(sp_end_tag) {
1815                Some(pos) => sp_abs + pos + sp_end_tag.len(),
1816                None => break,
1817            };
1818            let sp_xml = &group_xml[sp_abs..sp_abs_end];
1819
1820            let formula = extract_xml_element(sp_xml, "xm:f").unwrap_or_default();
1821            let sqref = extract_xml_element(sp_xml, "xm:sqref").unwrap_or_default();
1822
1823            if !formula.is_empty() && !sqref.is_empty() {
1824                sparklines.push(SparklineConfig {
1825                    data_range: formula,
1826                    location: sqref,
1827                    sparkline_type: sparkline_type.clone(),
1828                    markers,
1829                    high_point,
1830                    low_point,
1831                    first_point,
1832                    last_point,
1833                    negative_points,
1834                    show_axis,
1835                    line_weight,
1836                    style: None,
1837                });
1838            }
1839            sp_from = sp_abs_end;
1840        }
1841        search_from = abs_end;
1842    }
1843    sparklines
1844}
1845
1846/// Extract an XML attribute value from an element's opening tag.
1847///
1848/// Uses manual search to avoid allocating format strings for patterns.
1849pub(crate) fn extract_xml_attr(xml: &str, attr: &str) -> Option<String> {
1850    // Search for ` attr="` or ` attr='` without allocating pattern strings.
1851    for quote in ['"', '\''] {
1852        // Build the search target: " attr=" (space + attr name + = + quote)
1853        let haystack = xml.as_bytes();
1854        let attr_bytes = attr.as_bytes();
1855        let mut pos = 0;
1856        while pos + 1 + attr_bytes.len() + 2 <= haystack.len() {
1857            if haystack[pos] == b' '
1858                && haystack[pos + 1..pos + 1 + attr_bytes.len()] == *attr_bytes
1859                && haystack[pos + 1 + attr_bytes.len()] == b'='
1860                && haystack[pos + 1 + attr_bytes.len() + 1] == quote as u8
1861            {
1862                let val_start = pos + 1 + attr_bytes.len() + 2;
1863                if let Some(end) = xml[val_start..].find(quote) {
1864                    return Some(xml[val_start..val_start + end].to_string());
1865                }
1866            }
1867            pos += 1;
1868        }
1869    }
1870    None
1871}
1872
1873/// Extract a boolean attribute from an XML element (true for "1" or "true").
1874pub(crate) fn extract_xml_bool_attr(xml: &str, attr: &str) -> bool {
1875    extract_xml_attr(xml, attr)
1876        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1877        .unwrap_or(false)
1878}
1879
1880/// Extract the text content of an XML element like `<tag>content</tag>`.
1881pub(crate) fn extract_xml_element(xml: &str, tag: &str) -> Option<String> {
1882    let open = format!("<{tag}>");
1883    let close = format!("</{tag}>");
1884    let start = xml.find(&open)?;
1885    let content_start = start + open.len();
1886    let end = xml[content_start..].find(&close)?;
1887    Some(xml[content_start..content_start + end].to_string())
1888}
1889
1890/// Serialize a value to XML and write it as a ZIP entry.
1891pub(crate) fn write_xml_part<T: Serialize, W: std::io::Write + std::io::Seek>(
1892    zip: &mut zip::ZipWriter<W>,
1893    name: &str,
1894    value: &T,
1895    options: SimpleFileOptions,
1896) -> Result<()> {
1897    let xml = serialize_xml(value)?;
1898    zip.start_file(name, options)
1899        .map_err(|e| Error::Zip(e.to_string()))?;
1900    zip.write_all(xml.as_bytes())?;
1901    Ok(())
1902}
1903
1904/// Fast column number extraction from a cell reference string like "A1", "BC42".
1905///
1906/// Parses only the alphabetic prefix (column letters) and converts to a
1907/// 1-based column number. Much faster than [`cell_name_to_coordinates`] because
1908/// it skips row parsing and avoids error handling overhead.
1909fn fast_col_number(cell_ref: &str) -> u32 {
1910    let mut col: u32 = 0;
1911    for b in cell_ref.bytes() {
1912        if b.is_ascii_alphabetic() {
1913            col = col * 26 + (b.to_ascii_uppercase() - b'A') as u32 + 1;
1914        } else {
1915            break;
1916        }
1917    }
1918    col
1919}
1920
1921#[cfg(test)]
1922#[allow(clippy::unnecessary_map_or)]
1923mod tests {
1924    use super::*;
1925    use tempfile::TempDir;
1926
1927    #[test]
1928    fn test_fast_col_number() {
1929        assert_eq!(fast_col_number("A1"), 1);
1930        assert_eq!(fast_col_number("B1"), 2);
1931        assert_eq!(fast_col_number("Z1"), 26);
1932        assert_eq!(fast_col_number("AA1"), 27);
1933        assert_eq!(fast_col_number("AZ1"), 52);
1934        assert_eq!(fast_col_number("BA1"), 53);
1935        assert_eq!(fast_col_number("XFD1"), 16384);
1936    }
1937
1938    #[test]
1939    fn test_extract_xml_attr() {
1940        let xml = r#"<tag type="column" markers="1" weight="2.5">"#;
1941        assert_eq!(extract_xml_attr(xml, "type"), Some("column".to_string()));
1942        assert_eq!(extract_xml_attr(xml, "markers"), Some("1".to_string()));
1943        assert_eq!(extract_xml_attr(xml, "weight"), Some("2.5".to_string()));
1944        assert_eq!(extract_xml_attr(xml, "missing"), None);
1945        // Single-quoted attributes
1946        let xml2 = "<tag name='hello'>";
1947        assert_eq!(extract_xml_attr(xml2, "name"), Some("hello".to_string()));
1948    }
1949
1950    #[test]
1951    fn test_extract_xml_bool_attr() {
1952        let xml = r#"<tag markers="1" hidden="0" visible="true">"#;
1953        assert!(extract_xml_bool_attr(xml, "markers"));
1954        assert!(!extract_xml_bool_attr(xml, "hidden"));
1955        assert!(extract_xml_bool_attr(xml, "visible"));
1956        assert!(!extract_xml_bool_attr(xml, "missing"));
1957    }
1958
1959    #[test]
1960    fn test_new_workbook_has_sheet1() {
1961        let wb = Workbook::new();
1962        assert_eq!(wb.sheet_names(), vec!["Sheet1"]);
1963    }
1964
1965    #[test]
1966    fn test_new_workbook_writes_interop_workbook_defaults() {
1967        let wb = Workbook::new();
1968        let buf = wb.save_to_buffer().unwrap();
1969
1970        let cursor = std::io::Cursor::new(buf);
1971        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1972        let mut workbook_xml = String::new();
1973        std::io::Read::read_to_string(
1974            &mut archive.by_name("xl/workbook.xml").unwrap(),
1975            &mut workbook_xml,
1976        )
1977        .unwrap();
1978
1979        assert!(workbook_xml.contains("<fileVersion"));
1980        assert!(workbook_xml.contains("appName=\"xl\""));
1981        assert!(workbook_xml.contains("lastEdited=\"7\""));
1982        assert!(workbook_xml.contains("lowestEdited=\"7\""));
1983        assert!(workbook_xml.contains("rupBuild=\"27425\""));
1984
1985        assert!(workbook_xml.contains("<workbookPr"));
1986        assert!(workbook_xml.contains("defaultThemeVersion=\"166925\""));
1987
1988        assert!(workbook_xml.contains("<bookViews>"));
1989        assert!(workbook_xml.contains("<workbookView"));
1990        assert!(workbook_xml.contains("activeTab=\"0\""));
1991        assert!(!workbook_xml.contains("xWindow="));
1992        assert!(!workbook_xml.contains("yWindow="));
1993        assert!(!workbook_xml.contains("windowWidth="));
1994        assert!(!workbook_xml.contains("windowHeight="));
1995    }
1996
1997    #[test]
1998    fn test_new_workbook_save_creates_file() {
1999        let dir = TempDir::new().unwrap();
2000        let path = dir.path().join("test.xlsx");
2001        let wb = Workbook::new();
2002        wb.save(&path).unwrap();
2003        assert!(path.exists());
2004    }
2005
2006    #[test]
2007    fn test_save_and_open_roundtrip() {
2008        let dir = TempDir::new().unwrap();
2009        let path = dir.path().join("roundtrip.xlsx");
2010
2011        let wb = Workbook::new();
2012        wb.save(&path).unwrap();
2013
2014        let wb2 = Workbook::open(&path).unwrap();
2015        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
2016    }
2017
2018    #[test]
2019    fn test_saved_file_is_valid_zip() {
2020        let dir = TempDir::new().unwrap();
2021        let path = dir.path().join("valid.xlsx");
2022        let wb = Workbook::new();
2023        wb.save(&path).unwrap();
2024
2025        // Verify it's a valid ZIP with expected entries
2026        let file = std::fs::File::open(&path).unwrap();
2027        let mut archive = zip::ZipArchive::new(file).unwrap();
2028
2029        let expected_files = [
2030            "[Content_Types].xml",
2031            "_rels/.rels",
2032            "xl/workbook.xml",
2033            "xl/_rels/workbook.xml.rels",
2034            "xl/worksheets/sheet1.xml",
2035            "xl/styles.xml",
2036            "xl/sharedStrings.xml",
2037        ];
2038
2039        for name in &expected_files {
2040            assert!(archive.by_name(name).is_ok(), "Missing ZIP entry: {}", name);
2041        }
2042    }
2043
2044    #[test]
2045    fn test_open_nonexistent_file_returns_error() {
2046        let result = Workbook::open("/nonexistent/path.xlsx");
2047        assert!(result.is_err());
2048    }
2049
2050    #[test]
2051    fn test_saved_xml_has_declarations() {
2052        let dir = TempDir::new().unwrap();
2053        let path = dir.path().join("decl.xlsx");
2054        let wb = Workbook::new();
2055        wb.save(&path).unwrap();
2056
2057        let file = std::fs::File::open(&path).unwrap();
2058        let mut archive = zip::ZipArchive::new(file).unwrap();
2059
2060        let mut content = String::new();
2061        std::io::Read::read_to_string(
2062            &mut archive.by_name("[Content_Types].xml").unwrap(),
2063            &mut content,
2064        )
2065        .unwrap();
2066        assert!(content.starts_with("<?xml"));
2067    }
2068
2069    #[test]
2070    fn test_default_trait() {
2071        let wb = Workbook::default();
2072        assert_eq!(wb.sheet_names(), vec!["Sheet1"]);
2073    }
2074
2075    #[test]
2076    fn test_serialize_xml_helper() {
2077        let ct = ContentTypes::default();
2078        let xml = serialize_xml(&ct).unwrap();
2079        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"));
2080        assert!(xml.contains("<Types"));
2081    }
2082
2083    #[test]
2084    fn test_save_to_buffer_and_open_from_buffer_roundtrip() {
2085        let mut wb = Workbook::new();
2086        wb.set_cell_value("Sheet1", "A1", CellValue::String("Hello".to_string()))
2087            .unwrap();
2088        wb.set_cell_value("Sheet1", "B2", CellValue::Number(42.0))
2089            .unwrap();
2090
2091        let buf = wb.save_to_buffer().unwrap();
2092        assert!(!buf.is_empty());
2093
2094        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2095        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
2096        assert_eq!(
2097            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2098            CellValue::String("Hello".to_string())
2099        );
2100        assert_eq!(
2101            wb2.get_cell_value("Sheet1", "B2").unwrap(),
2102            CellValue::Number(42.0)
2103        );
2104    }
2105
2106    #[test]
2107    fn test_save_to_buffer_produces_valid_zip() {
2108        let wb = Workbook::new();
2109        let buf = wb.save_to_buffer().unwrap();
2110
2111        let cursor = std::io::Cursor::new(buf);
2112        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2113
2114        let expected_files = [
2115            "[Content_Types].xml",
2116            "_rels/.rels",
2117            "xl/workbook.xml",
2118            "xl/_rels/workbook.xml.rels",
2119            "xl/worksheets/sheet1.xml",
2120            "xl/styles.xml",
2121            "xl/sharedStrings.xml",
2122        ];
2123
2124        for name in &expected_files {
2125            assert!(archive.by_name(name).is_ok(), "Missing ZIP entry: {}", name);
2126        }
2127    }
2128
2129    #[test]
2130    fn test_open_from_buffer_invalid_data() {
2131        let result = Workbook::open_from_buffer(b"not a zip file");
2132        assert!(result.is_err());
2133    }
2134
2135    #[cfg(feature = "encryption")]
2136    #[test]
2137    fn test_save_and_open_with_password_roundtrip() {
2138        let dir = TempDir::new().unwrap();
2139        let path = dir.path().join("encrypted.xlsx");
2140
2141        // Create a workbook with some data
2142        let mut wb = Workbook::new();
2143        wb.set_cell_value("Sheet1", "A1", CellValue::String("Hello".to_string()))
2144            .unwrap();
2145        wb.set_cell_value("Sheet1", "B2", CellValue::Number(42.0))
2146            .unwrap();
2147
2148        // Save with password
2149        wb.save_with_password(&path, "test123").unwrap();
2150
2151        // Verify it's a CFB file, not a ZIP
2152        let data = std::fs::read(&path).unwrap();
2153        assert_eq!(
2154            &data[..8],
2155            &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]
2156        );
2157
2158        // Open without password should fail
2159        let result = Workbook::open(&path);
2160        assert!(matches!(result, Err(Error::FileEncrypted)));
2161
2162        // Open with wrong password should fail
2163        let result = Workbook::open_with_password(&path, "wrong");
2164        assert!(matches!(result, Err(Error::IncorrectPassword)));
2165
2166        // Open with correct password should succeed
2167        let wb2 = Workbook::open_with_password(&path, "test123").unwrap();
2168        assert_eq!(
2169            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2170            CellValue::String("Hello".to_string())
2171        );
2172        assert_eq!(
2173            wb2.get_cell_value("Sheet1", "B2").unwrap(),
2174            CellValue::Number(42.0)
2175        );
2176    }
2177
2178    /// Create a test xlsx buffer with extra custom ZIP entries that sheetkit
2179    /// does not natively handle.
2180    fn create_xlsx_with_custom_entries() -> Vec<u8> {
2181        let mut wb = Workbook::new();
2182        wb.set_cell_value("Sheet1", "A1", CellValue::String("hello".to_string()))
2183            .unwrap();
2184        let base_buf = wb.save_to_buffer().unwrap();
2185
2186        // Re-open the ZIP and inject custom entries.
2187        let cursor = std::io::Cursor::new(&base_buf);
2188        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2189        let mut out = Vec::new();
2190        {
2191            let out_cursor = std::io::Cursor::new(&mut out);
2192            let mut zip_writer = zip::ZipWriter::new(out_cursor);
2193            let options =
2194                SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
2195
2196            // Copy all existing entries.
2197            for i in 0..archive.len() {
2198                let mut entry = archive.by_index(i).unwrap();
2199                let name = entry.name().to_string();
2200                let mut data = Vec::new();
2201                std::io::Read::read_to_end(&mut entry, &mut data).unwrap();
2202                zip_writer.start_file(&name, options).unwrap();
2203                std::io::Write::write_all(&mut zip_writer, &data).unwrap();
2204            }
2205
2206            // Add custom entries that sheetkit does not handle.
2207            zip_writer
2208                .start_file("customXml/item1.xml", options)
2209                .unwrap();
2210            std::io::Write::write_all(&mut zip_writer, b"<custom>data1</custom>").unwrap();
2211
2212            zip_writer
2213                .start_file("customXml/itemProps1.xml", options)
2214                .unwrap();
2215            std::io::Write::write_all(
2216                &mut zip_writer,
2217                b"<ds:datastoreItem xmlns:ds=\"http://schemas.openxmlformats.org/officeDocument/2006/customXml\"/>",
2218            )
2219            .unwrap();
2220
2221            zip_writer
2222                .start_file("xl/printerSettings/printerSettings1.bin", options)
2223                .unwrap();
2224            std::io::Write::write_all(&mut zip_writer, b"\x00\x01\x02\x03PRINTER").unwrap();
2225
2226            zip_writer.finish().unwrap();
2227        }
2228        out
2229    }
2230
2231    #[test]
2232    fn test_unknown_zip_entries_preserved_on_roundtrip() {
2233        let buf = create_xlsx_with_custom_entries();
2234
2235        // Open, verify the data is still accessible.
2236        let wb = Workbook::open_from_buffer(&buf).unwrap();
2237        assert_eq!(
2238            wb.get_cell_value("Sheet1", "A1").unwrap(),
2239            CellValue::String("hello".to_string())
2240        );
2241
2242        // Save and re-open.
2243        let saved = wb.save_to_buffer().unwrap();
2244        let cursor = std::io::Cursor::new(&saved);
2245        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2246
2247        // Verify custom entries are present in the output.
2248        let mut custom_xml = String::new();
2249        std::io::Read::read_to_string(
2250            &mut archive.by_name("customXml/item1.xml").unwrap(),
2251            &mut custom_xml,
2252        )
2253        .unwrap();
2254        assert_eq!(custom_xml, "<custom>data1</custom>");
2255
2256        let mut props_xml = String::new();
2257        std::io::Read::read_to_string(
2258            &mut archive.by_name("customXml/itemProps1.xml").unwrap(),
2259            &mut props_xml,
2260        )
2261        .unwrap();
2262        assert!(props_xml.contains("datastoreItem"));
2263
2264        let mut printer = Vec::new();
2265        std::io::Read::read_to_end(
2266            &mut archive
2267                .by_name("xl/printerSettings/printerSettings1.bin")
2268                .unwrap(),
2269            &mut printer,
2270        )
2271        .unwrap();
2272        assert_eq!(printer, b"\x00\x01\x02\x03PRINTER");
2273    }
2274
2275    #[test]
2276    fn test_unknown_entries_survive_multiple_roundtrips() {
2277        let buf = create_xlsx_with_custom_entries();
2278        let wb1 = Workbook::open_from_buffer(&buf).unwrap();
2279        let buf2 = wb1.save_to_buffer().unwrap();
2280        let wb2 = Workbook::open_from_buffer(&buf2).unwrap();
2281        let buf3 = wb2.save_to_buffer().unwrap();
2282
2283        let cursor = std::io::Cursor::new(&buf3);
2284        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2285
2286        let mut custom_xml = String::new();
2287        std::io::Read::read_to_string(
2288            &mut archive.by_name("customXml/item1.xml").unwrap(),
2289            &mut custom_xml,
2290        )
2291        .unwrap();
2292        assert_eq!(custom_xml, "<custom>data1</custom>");
2293
2294        let mut printer = Vec::new();
2295        std::io::Read::read_to_end(
2296            &mut archive
2297                .by_name("xl/printerSettings/printerSettings1.bin")
2298                .unwrap(),
2299            &mut printer,
2300        )
2301        .unwrap();
2302        assert_eq!(printer, b"\x00\x01\x02\x03PRINTER");
2303    }
2304
2305    #[test]
2306    fn test_new_workbook_has_no_unknown_parts() {
2307        let wb = Workbook::new();
2308        let buf = wb.save_to_buffer().unwrap();
2309        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2310        assert!(wb2.unknown_parts.is_empty());
2311    }
2312
2313    #[test]
2314    fn test_known_entries_not_duplicated_as_unknown() {
2315        let wb = Workbook::new();
2316        let buf = wb.save_to_buffer().unwrap();
2317        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2318
2319        // None of the standard entries should appear in unknown_parts.
2320        let unknown_paths: Vec<&str> = wb2.unknown_parts.iter().map(|(p, _)| p.as_str()).collect();
2321        assert!(
2322            !unknown_paths.contains(&"[Content_Types].xml"),
2323            "Content_Types should not be in unknown_parts"
2324        );
2325        assert!(
2326            !unknown_paths.contains(&"xl/workbook.xml"),
2327            "workbook.xml should not be in unknown_parts"
2328        );
2329        assert!(
2330            !unknown_paths.contains(&"xl/styles.xml"),
2331            "styles.xml should not be in unknown_parts"
2332        );
2333    }
2334
2335    #[test]
2336    fn test_modifications_preserved_alongside_unknown_parts() {
2337        let buf = create_xlsx_with_custom_entries();
2338        let mut wb = Workbook::open_from_buffer(&buf).unwrap();
2339
2340        // Modify data in the workbook.
2341        wb.set_cell_value("Sheet1", "B1", CellValue::Number(42.0))
2342            .unwrap();
2343
2344        let saved = wb.save_to_buffer().unwrap();
2345        let wb2 = Workbook::open_from_buffer(&saved).unwrap();
2346
2347        // Original data preserved.
2348        assert_eq!(
2349            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2350            CellValue::String("hello".to_string())
2351        );
2352        // New data present.
2353        assert_eq!(
2354            wb2.get_cell_value("Sheet1", "B1").unwrap(),
2355            CellValue::Number(42.0)
2356        );
2357        // Unknown parts still present.
2358        let cursor = std::io::Cursor::new(&saved);
2359        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2360        assert!(archive.by_name("customXml/item1.xml").is_ok());
2361    }
2362
2363    #[test]
2364    fn test_threaded_comment_person_rel_in_workbook_rels() {
2365        let mut wb = Workbook::new();
2366        wb.add_threaded_comment(
2367            "Sheet1",
2368            "A1",
2369            &crate::threaded_comment::ThreadedCommentInput {
2370                author: "Alice".to_string(),
2371                text: "Test comment".to_string(),
2372                parent_id: None,
2373            },
2374        )
2375        .unwrap();
2376
2377        let buf = wb.save_to_buffer().unwrap();
2378        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2379
2380        // Verify workbook_rels contains a REL_TYPE_PERSON relationship.
2381        let has_person_rel = wb2.workbook_rels.relationships.iter().any(|r| {
2382            r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_PERSON
2383                && r.target == "persons/person.xml"
2384        });
2385        assert!(
2386            has_person_rel,
2387            "workbook_rels must contain a person relationship for threaded comments"
2388        );
2389    }
2390
2391    #[test]
2392    fn test_no_person_rel_without_threaded_comments() {
2393        let wb = Workbook::new();
2394        let buf = wb.save_to_buffer().unwrap();
2395        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2396
2397        let has_person_rel = wb2
2398            .workbook_rels
2399            .relationships
2400            .iter()
2401            .any(|r| r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_PERSON);
2402        assert!(
2403            !has_person_rel,
2404            "workbook_rels must not contain a person relationship when there are no threaded comments"
2405        );
2406    }
2407
2408    #[cfg(feature = "encryption")]
2409    #[test]
2410    fn test_open_encrypted_file_without_password_returns_file_encrypted() {
2411        let dir = TempDir::new().unwrap();
2412        let path = dir.path().join("encrypted2.xlsx");
2413
2414        let wb = Workbook::new();
2415        wb.save_with_password(&path, "secret").unwrap();
2416
2417        let result = Workbook::open(&path);
2418        assert!(matches!(result, Err(Error::FileEncrypted)))
2419    }
2420
2421    #[test]
2422    fn test_workbook_format_from_content_type() {
2423        use sheetkit_xml::content_types::mime_types;
2424        assert_eq!(
2425            WorkbookFormat::from_content_type(mime_types::WORKBOOK),
2426            Some(WorkbookFormat::Xlsx)
2427        );
2428        assert_eq!(
2429            WorkbookFormat::from_content_type(mime_types::WORKBOOK_MACRO),
2430            Some(WorkbookFormat::Xlsm)
2431        );
2432        assert_eq!(
2433            WorkbookFormat::from_content_type(mime_types::WORKBOOK_TEMPLATE),
2434            Some(WorkbookFormat::Xltx)
2435        );
2436        assert_eq!(
2437            WorkbookFormat::from_content_type(mime_types::WORKBOOK_TEMPLATE_MACRO),
2438            Some(WorkbookFormat::Xltm)
2439        );
2440        assert_eq!(
2441            WorkbookFormat::from_content_type(mime_types::WORKBOOK_ADDIN_MACRO),
2442            Some(WorkbookFormat::Xlam)
2443        );
2444        assert_eq!(
2445            WorkbookFormat::from_content_type("application/unknown"),
2446            None
2447        );
2448    }
2449
2450    #[test]
2451    fn test_workbook_format_content_type_roundtrip() {
2452        for fmt in [
2453            WorkbookFormat::Xlsx,
2454            WorkbookFormat::Xlsm,
2455            WorkbookFormat::Xltx,
2456            WorkbookFormat::Xltm,
2457            WorkbookFormat::Xlam,
2458        ] {
2459            let ct = fmt.content_type();
2460            assert_eq!(WorkbookFormat::from_content_type(ct), Some(fmt));
2461        }
2462    }
2463
2464    #[test]
2465    fn test_new_workbook_defaults_to_xlsx_format() {
2466        let wb = Workbook::new();
2467        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
2468    }
2469
2470    #[test]
2471    fn test_xlsx_roundtrip_preserves_format() {
2472        let dir = TempDir::new().unwrap();
2473        let path = dir.path().join("roundtrip_format.xlsx");
2474
2475        let wb = Workbook::new();
2476        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
2477        wb.save(&path).unwrap();
2478
2479        let wb2 = Workbook::open(&path).unwrap();
2480        assert_eq!(wb2.format(), WorkbookFormat::Xlsx);
2481    }
2482
2483    #[test]
2484    fn test_save_writes_correct_content_type_for_each_extension() {
2485        let dir = TempDir::new().unwrap();
2486
2487        let cases = [
2488            (WorkbookFormat::Xlsx, "test.xlsx"),
2489            (WorkbookFormat::Xlsm, "test.xlsm"),
2490            (WorkbookFormat::Xltx, "test.xltx"),
2491            (WorkbookFormat::Xltm, "test.xltm"),
2492            (WorkbookFormat::Xlam, "test.xlam"),
2493        ];
2494
2495        for (expected_fmt, filename) in cases {
2496            let path = dir.path().join(filename);
2497            let wb = Workbook::new();
2498            wb.save(&path).unwrap();
2499
2500            let file = std::fs::File::open(&path).unwrap();
2501            let mut archive = zip::ZipArchive::new(file).unwrap();
2502
2503            let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2504            let wb_override = ct
2505                .overrides
2506                .iter()
2507                .find(|o| o.part_name == "/xl/workbook.xml")
2508                .expect("workbook override must exist");
2509            assert_eq!(
2510                wb_override.content_type,
2511                expected_fmt.content_type(),
2512                "content type mismatch for {}",
2513                filename
2514            );
2515        }
2516    }
2517
2518    #[test]
2519    fn test_set_format_changes_workbook_format() {
2520        let mut wb = Workbook::new();
2521        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
2522
2523        wb.set_format(WorkbookFormat::Xlsm);
2524        assert_eq!(wb.format(), WorkbookFormat::Xlsm);
2525    }
2526
2527    #[test]
2528    fn test_save_buffer_roundtrip_with_xlsm_format() {
2529        let mut wb = Workbook::new();
2530        wb.set_format(WorkbookFormat::Xlsm);
2531        wb.set_cell_value("Sheet1", "A1", CellValue::String("test".to_string()))
2532            .unwrap();
2533
2534        let buf = wb.save_to_buffer().unwrap();
2535        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2536        assert_eq!(wb2.format(), WorkbookFormat::Xlsm);
2537        assert_eq!(
2538            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2539            CellValue::String("test".to_string())
2540        );
2541    }
2542
2543    #[test]
2544    fn test_open_with_default_options_is_equivalent_to_open() {
2545        let dir = TempDir::new().unwrap();
2546        let path = dir.path().join("default_opts.xlsx");
2547        let mut wb = Workbook::new();
2548        wb.set_cell_value("Sheet1", "A1", CellValue::String("test".to_string()))
2549            .unwrap();
2550        wb.save(&path).unwrap();
2551
2552        let wb2 = Workbook::open_with_options(&path, &OpenOptions::default()).unwrap();
2553        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
2554        assert_eq!(
2555            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2556            CellValue::String("test".to_string())
2557        );
2558    }
2559
2560    #[test]
2561    fn test_format_inference_from_content_types_overrides() {
2562        use sheetkit_xml::content_types::mime_types;
2563
2564        // Simulate a content_types with xlsm workbook type.
2565        let ct = ContentTypes {
2566            xmlns: "http://schemas.openxmlformats.org/package/2006/content-types".to_string(),
2567            defaults: vec![],
2568            overrides: vec![ContentTypeOverride {
2569                part_name: "/xl/workbook.xml".to_string(),
2570                content_type: mime_types::WORKBOOK_MACRO.to_string(),
2571            }],
2572        };
2573
2574        let detected = ct
2575            .overrides
2576            .iter()
2577            .find(|o| o.part_name == "/xl/workbook.xml")
2578            .and_then(|o| WorkbookFormat::from_content_type(&o.content_type))
2579            .unwrap_or_default();
2580        assert_eq!(detected, WorkbookFormat::Xlsm);
2581    }
2582
2583    #[test]
2584    fn test_workbook_format_default_is_xlsx() {
2585        assert_eq!(WorkbookFormat::default(), WorkbookFormat::Xlsx);
2586    }
2587
2588    fn build_xlsm_with_vba(vba_bytes: &[u8]) -> Vec<u8> {
2589        use std::io::Write;
2590        let mut buf = Vec::new();
2591        {
2592            let cursor = std::io::Cursor::new(&mut buf);
2593            let mut zip = zip::ZipWriter::new(cursor);
2594            let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
2595
2596            let ct_xml = format!(
2597                r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2598<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
2599  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
2600  <Default Extension="xml" ContentType="application/xml"/>
2601  <Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>
2602  <Override PartName="/xl/workbook.xml" ContentType="{wb_ct}"/>
2603  <Override PartName="/xl/worksheets/sheet1.xml" ContentType="{ws_ct}"/>
2604  <Override PartName="/xl/styles.xml" ContentType="{st_ct}"/>
2605  <Override PartName="/xl/sharedStrings.xml" ContentType="{sst_ct}"/>
2606  <Override PartName="/xl/vbaProject.bin" ContentType="application/vnd.ms-office.vbaProject"/>
2607</Types>"#,
2608                wb_ct = mime_types::WORKBOOK_MACRO,
2609                ws_ct = mime_types::WORKSHEET,
2610                st_ct = mime_types::STYLES,
2611                sst_ct = mime_types::SHARED_STRINGS,
2612            );
2613            zip.start_file("[Content_Types].xml", opts).unwrap();
2614            zip.write_all(ct_xml.as_bytes()).unwrap();
2615
2616            let pkg_rels = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2617<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2618  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
2619</Relationships>"#;
2620            zip.start_file("_rels/.rels", opts).unwrap();
2621            zip.write_all(pkg_rels.as_bytes()).unwrap();
2622
2623            let wb_rels = format!(
2624                r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2625<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2626  <Relationship Id="rId1" Type="{ws_rel}" Target="worksheets/sheet1.xml"/>
2627  <Relationship Id="rId2" Type="{st_rel}" Target="styles.xml"/>
2628  <Relationship Id="rId3" Type="{sst_rel}" Target="sharedStrings.xml"/>
2629  <Relationship Id="rId4" Type="{vba_rel}" Target="vbaProject.bin"/>
2630</Relationships>"#,
2631                ws_rel = rel_types::WORKSHEET,
2632                st_rel = rel_types::STYLES,
2633                sst_rel = rel_types::SHARED_STRINGS,
2634                vba_rel = VBA_PROJECT_REL_TYPE,
2635            );
2636            zip.start_file("xl/_rels/workbook.xml.rels", opts).unwrap();
2637            zip.write_all(wb_rels.as_bytes()).unwrap();
2638
2639            let wb_xml = concat!(
2640                r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
2641                r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main""#,
2642                r#" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
2643                r#"<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>"#,
2644                r#"</workbook>"#,
2645            );
2646            zip.start_file("xl/workbook.xml", opts).unwrap();
2647            zip.write_all(wb_xml.as_bytes()).unwrap();
2648
2649            let ws_xml = concat!(
2650                r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
2651                r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main""#,
2652                r#" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
2653                r#"<sheetData/>"#,
2654                r#"</worksheet>"#,
2655            );
2656            zip.start_file("xl/worksheets/sheet1.xml", opts).unwrap();
2657            zip.write_all(ws_xml.as_bytes()).unwrap();
2658
2659            let styles_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2660<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
2661  <fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
2662  <fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
2663  <borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
2664  <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
2665  <cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
2666</styleSheet>"#;
2667            zip.start_file("xl/styles.xml", opts).unwrap();
2668            zip.write_all(styles_xml.as_bytes()).unwrap();
2669
2670            let sst_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2671<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="0" uniqueCount="0"/>"#;
2672            zip.start_file("xl/sharedStrings.xml", opts).unwrap();
2673            zip.write_all(sst_xml.as_bytes()).unwrap();
2674
2675            zip.start_file("xl/vbaProject.bin", opts).unwrap();
2676            zip.write_all(vba_bytes).unwrap();
2677
2678            zip.finish().unwrap();
2679        }
2680        buf
2681    }
2682
2683    #[test]
2684    fn test_vba_blob_loaded_when_present() {
2685        use crate::workbook::open_options::{AuxParts, OpenOptions, ReadMode};
2686
2687        let vba_data = b"FAKE_VBA_PROJECT_BINARY_DATA_1234567890";
2688        let xlsm = build_xlsm_with_vba(vba_data);
2689        let opts = OpenOptions::new()
2690            .read_mode(ReadMode::Eager)
2691            .aux_parts(AuxParts::EagerLoad);
2692        let wb = Workbook::open_from_buffer_with_options(&xlsm, &opts).unwrap();
2693        assert!(wb.vba_blob.is_some());
2694        assert_eq!(wb.vba_blob.as_deref().unwrap(), vba_data);
2695    }
2696
2697    #[test]
2698    fn test_vba_blob_none_for_plain_xlsx() {
2699        let wb = Workbook::new();
2700        assert!(wb.vba_blob.is_none());
2701
2702        let buf = wb.save_to_buffer().unwrap();
2703        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2704        assert!(wb2.vba_blob.is_none());
2705    }
2706
2707    #[test]
2708    fn test_vba_blob_survives_roundtrip_with_identical_bytes() {
2709        use crate::workbook::open_options::{AuxParts, OpenOptions, ReadMode};
2710
2711        let vba_data: Vec<u8> = (0..=255).cycle().take(1024).collect();
2712        let xlsm = build_xlsm_with_vba(&vba_data);
2713
2714        let opts = OpenOptions::new()
2715            .read_mode(ReadMode::Eager)
2716            .aux_parts(AuxParts::EagerLoad);
2717        let wb = Workbook::open_from_buffer_with_options(&xlsm, &opts).unwrap();
2718        assert_eq!(wb.vba_blob.as_deref().unwrap(), &vba_data[..]);
2719
2720        let saved = wb.save_to_buffer().unwrap();
2721        let cursor = std::io::Cursor::new(&saved);
2722        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2723
2724        let mut roundtripped = Vec::new();
2725        std::io::Read::read_to_end(
2726            &mut archive.by_name("xl/vbaProject.bin").unwrap(),
2727            &mut roundtripped,
2728        )
2729        .unwrap();
2730        assert_eq!(roundtripped, vba_data);
2731    }
2732
2733    #[test]
2734    fn test_vba_relationship_preserved_on_roundtrip() {
2735        let vba_data = b"VBA_BLOB";
2736        let xlsm = build_xlsm_with_vba(vba_data);
2737
2738        let wb = Workbook::open_from_buffer(&xlsm).unwrap();
2739        let saved = wb.save_to_buffer().unwrap();
2740
2741        let cursor = std::io::Cursor::new(&saved);
2742        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2743
2744        let rels: Relationships =
2745            read_xml_part(&mut archive, "xl/_rels/workbook.xml.rels").unwrap();
2746        let vba_rel = rels
2747            .relationships
2748            .iter()
2749            .find(|r| r.rel_type == VBA_PROJECT_REL_TYPE);
2750        assert!(vba_rel.is_some(), "VBA relationship must be preserved");
2751        assert_eq!(vba_rel.unwrap().target, "vbaProject.bin");
2752    }
2753
2754    #[test]
2755    fn test_vba_content_type_preserved_on_roundtrip() {
2756        let vba_data = b"VBA_BLOB";
2757        let xlsm = build_xlsm_with_vba(vba_data);
2758
2759        let wb = Workbook::open_from_buffer(&xlsm).unwrap();
2760        let saved = wb.save_to_buffer().unwrap();
2761
2762        let cursor = std::io::Cursor::new(&saved);
2763        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2764
2765        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2766        let vba_override = ct
2767            .overrides
2768            .iter()
2769            .find(|o| o.part_name == "/xl/vbaProject.bin");
2770        assert!(
2771            vba_override.is_some(),
2772            "VBA content type override must be preserved"
2773        );
2774        assert_eq!(vba_override.unwrap().content_type, VBA_PROJECT_CONTENT_TYPE);
2775    }
2776
2777    #[test]
2778    fn test_non_vba_save_has_no_vba_entries() {
2779        let wb = Workbook::new();
2780        let buf = wb.save_to_buffer().unwrap();
2781
2782        let cursor = std::io::Cursor::new(&buf);
2783        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2784
2785        assert!(
2786            archive.by_name("xl/vbaProject.bin").is_err(),
2787            "plain xlsx must not contain vbaProject.bin"
2788        );
2789
2790        let rels: Relationships =
2791            read_xml_part(&mut archive, "xl/_rels/workbook.xml.rels").unwrap();
2792        assert!(
2793            !rels
2794                .relationships
2795                .iter()
2796                .any(|r| r.rel_type == VBA_PROJECT_REL_TYPE),
2797            "plain xlsx must not have VBA relationship"
2798        );
2799
2800        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2801        assert!(
2802            !ct.overrides
2803                .iter()
2804                .any(|o| o.content_type == VBA_PROJECT_CONTENT_TYPE),
2805            "plain xlsx must not have VBA content type override"
2806        );
2807    }
2808
2809    #[test]
2810    fn test_xlsm_format_detected_with_vba() {
2811        let vba_data = b"VBA_BLOB";
2812        let xlsm = build_xlsm_with_vba(vba_data);
2813        let wb = Workbook::open_from_buffer(&xlsm).unwrap();
2814        assert_eq!(wb.format(), WorkbookFormat::Xlsm);
2815    }
2816
2817    #[test]
2818    fn test_from_extension_recognized() {
2819        assert_eq!(
2820            WorkbookFormat::from_extension("xlsx"),
2821            Some(WorkbookFormat::Xlsx)
2822        );
2823        assert_eq!(
2824            WorkbookFormat::from_extension("xlsm"),
2825            Some(WorkbookFormat::Xlsm)
2826        );
2827        assert_eq!(
2828            WorkbookFormat::from_extension("xltx"),
2829            Some(WorkbookFormat::Xltx)
2830        );
2831        assert_eq!(
2832            WorkbookFormat::from_extension("xltm"),
2833            Some(WorkbookFormat::Xltm)
2834        );
2835        assert_eq!(
2836            WorkbookFormat::from_extension("xlam"),
2837            Some(WorkbookFormat::Xlam)
2838        );
2839    }
2840
2841    #[test]
2842    fn test_from_extension_case_insensitive() {
2843        assert_eq!(
2844            WorkbookFormat::from_extension("XLSX"),
2845            Some(WorkbookFormat::Xlsx)
2846        );
2847        assert_eq!(
2848            WorkbookFormat::from_extension("Xlsm"),
2849            Some(WorkbookFormat::Xlsm)
2850        );
2851        assert_eq!(
2852            WorkbookFormat::from_extension("XLTX"),
2853            Some(WorkbookFormat::Xltx)
2854        );
2855    }
2856
2857    #[test]
2858    fn test_from_extension_unrecognized() {
2859        assert_eq!(WorkbookFormat::from_extension("csv"), None);
2860        assert_eq!(WorkbookFormat::from_extension("xls"), None);
2861        assert_eq!(WorkbookFormat::from_extension("txt"), None);
2862        assert_eq!(WorkbookFormat::from_extension("pdf"), None);
2863        assert_eq!(WorkbookFormat::from_extension(""), None);
2864    }
2865
2866    #[test]
2867    fn test_save_unsupported_extension_csv() {
2868        let dir = TempDir::new().unwrap();
2869        let path = dir.path().join("output.csv");
2870        let wb = Workbook::new();
2871        let result = wb.save(&path);
2872        assert!(matches!(result, Err(Error::UnsupportedFileExtension(ext)) if ext == "csv"));
2873    }
2874
2875    #[test]
2876    fn test_save_unsupported_extension_xls() {
2877        let dir = TempDir::new().unwrap();
2878        let path = dir.path().join("output.xls");
2879        let wb = Workbook::new();
2880        let result = wb.save(&path);
2881        assert!(matches!(result, Err(Error::UnsupportedFileExtension(ext)) if ext == "xls"));
2882    }
2883
2884    #[test]
2885    fn test_save_unsupported_extension_unknown() {
2886        let dir = TempDir::new().unwrap();
2887        let path = dir.path().join("output.foo");
2888        let wb = Workbook::new();
2889        let result = wb.save(&path);
2890        assert!(matches!(result, Err(Error::UnsupportedFileExtension(ext)) if ext == "foo"));
2891    }
2892
2893    #[test]
2894    fn test_save_no_extension_fails() {
2895        let dir = TempDir::new().unwrap();
2896        let path = dir.path().join("noext");
2897        let wb = Workbook::new();
2898        let result = wb.save(&path);
2899        assert!(matches!(
2900            result,
2901            Err(Error::UnsupportedFileExtension(ext)) if ext.is_empty()
2902        ));
2903    }
2904
2905    #[test]
2906    fn test_save_as_xlsm_writes_xlsm_content_type() {
2907        let dir = TempDir::new().unwrap();
2908        let path = dir.path().join("output.xlsm");
2909        let wb = Workbook::new();
2910        wb.save(&path).unwrap();
2911
2912        let file = std::fs::File::open(&path).unwrap();
2913        let mut archive = zip::ZipArchive::new(file).unwrap();
2914        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2915        let wb_ct = ct
2916            .overrides
2917            .iter()
2918            .find(|o| o.part_name == "/xl/workbook.xml")
2919            .expect("workbook override must exist");
2920        assert_eq!(wb_ct.content_type, WorkbookFormat::Xlsm.content_type());
2921    }
2922
2923    #[test]
2924    fn test_save_as_xltx_writes_template_content_type() {
2925        let dir = TempDir::new().unwrap();
2926        let path = dir.path().join("output.xltx");
2927        let wb = Workbook::new();
2928        wb.save(&path).unwrap();
2929
2930        let file = std::fs::File::open(&path).unwrap();
2931        let mut archive = zip::ZipArchive::new(file).unwrap();
2932        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2933        let wb_ct = ct
2934            .overrides
2935            .iter()
2936            .find(|o| o.part_name == "/xl/workbook.xml")
2937            .expect("workbook override must exist");
2938        assert_eq!(wb_ct.content_type, WorkbookFormat::Xltx.content_type());
2939    }
2940
2941    #[test]
2942    fn test_save_as_xltm_writes_template_macro_content_type() {
2943        let dir = TempDir::new().unwrap();
2944        let path = dir.path().join("output.xltm");
2945        let wb = Workbook::new();
2946        wb.save(&path).unwrap();
2947
2948        let file = std::fs::File::open(&path).unwrap();
2949        let mut archive = zip::ZipArchive::new(file).unwrap();
2950        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2951        let wb_ct = ct
2952            .overrides
2953            .iter()
2954            .find(|o| o.part_name == "/xl/workbook.xml")
2955            .expect("workbook override must exist");
2956        assert_eq!(wb_ct.content_type, WorkbookFormat::Xltm.content_type());
2957    }
2958
2959    #[test]
2960    fn test_save_as_xlam_writes_addin_content_type() {
2961        let dir = TempDir::new().unwrap();
2962        let path = dir.path().join("output.xlam");
2963        let wb = Workbook::new();
2964        wb.save(&path).unwrap();
2965
2966        let file = std::fs::File::open(&path).unwrap();
2967        let mut archive = zip::ZipArchive::new(file).unwrap();
2968        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2969        let wb_ct = ct
2970            .overrides
2971            .iter()
2972            .find(|o| o.part_name == "/xl/workbook.xml")
2973            .expect("workbook override must exist");
2974        assert_eq!(wb_ct.content_type, WorkbookFormat::Xlam.content_type());
2975    }
2976
2977    #[test]
2978    fn test_save_extension_overrides_stored_format() {
2979        let dir = TempDir::new().unwrap();
2980        let path = dir.path().join("output.xlsm");
2981
2982        // Workbook has Xlsx format stored, but saved as .xlsm
2983        let wb = Workbook::new();
2984        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
2985        wb.save(&path).unwrap();
2986
2987        let file = std::fs::File::open(&path).unwrap();
2988        let mut archive = zip::ZipArchive::new(file).unwrap();
2989        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2990        let wb_ct = ct
2991            .overrides
2992            .iter()
2993            .find(|o| o.part_name == "/xl/workbook.xml")
2994            .expect("workbook override must exist");
2995        assert_eq!(
2996            wb_ct.content_type,
2997            WorkbookFormat::Xlsm.content_type(),
2998            "extension .xlsm must override stored Xlsx format"
2999        );
3000    }
3001
3002    #[test]
3003    fn test_save_to_buffer_preserves_stored_format() {
3004        let mut wb = Workbook::new();
3005        wb.set_format(WorkbookFormat::Xltx);
3006
3007        let buf = wb.save_to_buffer().unwrap();
3008        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
3009        assert_eq!(
3010            wb2.format(),
3011            WorkbookFormat::Xltx,
3012            "save_to_buffer must use the stored format, not infer from extension"
3013        );
3014    }
3015
3016    #[test]
3017    fn test_sheet_rows_limits_rows_read() {
3018        let dir = TempDir::new().unwrap();
3019        let path = dir.path().join("sheet_rows.xlsx");
3020
3021        let mut wb = Workbook::new();
3022        for i in 1..=20 {
3023            let cell = format!("A{}", i);
3024            wb.set_cell_value("Sheet1", &cell, CellValue::Number(i as f64))
3025                .unwrap();
3026        }
3027        wb.save(&path).unwrap();
3028
3029        let opts = OpenOptions::new().sheet_rows(5);
3030        let wb2 = Workbook::open_with_options(&path, &opts).unwrap();
3031
3032        // First 5 rows should be present
3033        for i in 1..=5 {
3034            let cell = format!("A{}", i);
3035            assert_eq!(
3036                wb2.get_cell_value("Sheet1", &cell).unwrap(),
3037                CellValue::Number(i as f64)
3038            );
3039        }
3040
3041        // Rows 6+ should return Empty
3042        for i in 6..=20 {
3043            let cell = format!("A{}", i);
3044            assert_eq!(
3045                wb2.get_cell_value("Sheet1", &cell).unwrap(),
3046                CellValue::Empty
3047            );
3048        }
3049    }
3050
3051    #[test]
3052    fn test_sheet_rows_with_buffer() {
3053        let mut wb = Workbook::new();
3054        for i in 1..=10 {
3055            let cell = format!("A{}", i);
3056            wb.set_cell_value("Sheet1", &cell, CellValue::Number(i as f64))
3057                .unwrap();
3058        }
3059        let buf = wb.save_to_buffer().unwrap();
3060
3061        let opts = OpenOptions::new().sheet_rows(3);
3062        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3063
3064        assert_eq!(
3065            wb2.get_cell_value("Sheet1", "A3").unwrap(),
3066            CellValue::Number(3.0)
3067        );
3068        assert_eq!(
3069            wb2.get_cell_value("Sheet1", "A4").unwrap(),
3070            CellValue::Empty
3071        );
3072    }
3073
3074    #[test]
3075    fn test_save_xlsx_preserves_existing_behavior() {
3076        let dir = TempDir::new().unwrap();
3077        let path = dir.path().join("preserved.xlsx");
3078
3079        let mut wb = Workbook::new();
3080        wb.set_cell_value("Sheet1", "A1", CellValue::String("hello".to_string()))
3081            .unwrap();
3082        wb.save(&path).unwrap();
3083
3084        let wb2 = Workbook::open(&path).unwrap();
3085        assert_eq!(wb2.format(), WorkbookFormat::Xlsx);
3086        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
3087        assert_eq!(
3088            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3089            CellValue::String("hello".to_string())
3090        );
3091    }
3092
3093    #[test]
3094    fn test_selective_sheet_parsing() {
3095        let dir = TempDir::new().unwrap();
3096        let path = dir.path().join("selective.xlsx");
3097
3098        let mut wb = Workbook::new();
3099        wb.new_sheet("Sales").unwrap();
3100        wb.new_sheet("Data").unwrap();
3101        wb.set_cell_value("Sheet1", "A1", CellValue::String("Sheet1 data".to_string()))
3102            .unwrap();
3103        wb.set_cell_value("Sales", "A1", CellValue::String("Sales data".to_string()))
3104            .unwrap();
3105        wb.set_cell_value("Data", "A1", CellValue::String("Data data".to_string()))
3106            .unwrap();
3107        wb.save(&path).unwrap();
3108
3109        let opts = OpenOptions::new().sheets(vec!["Sales".to_string()]);
3110        let wb2 = Workbook::open_with_options(&path, &opts).unwrap();
3111
3112        // All sheets exist in the workbook
3113        assert_eq!(wb2.sheet_names(), vec!["Sheet1", "Sales", "Data"]);
3114
3115        // Only Sales should have data
3116        assert_eq!(
3117            wb2.get_cell_value("Sales", "A1").unwrap(),
3118            CellValue::String("Sales data".to_string())
3119        );
3120
3121        // Sheet1 and Data were not parsed, so they should be empty
3122        assert_eq!(
3123            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3124            CellValue::Empty
3125        );
3126        assert_eq!(wb2.get_cell_value("Data", "A1").unwrap(), CellValue::Empty);
3127    }
3128
3129    #[test]
3130    fn test_selective_sheets_multiple() {
3131        let mut wb = Workbook::new();
3132        wb.new_sheet("Alpha").unwrap();
3133        wb.new_sheet("Beta").unwrap();
3134        wb.set_cell_value("Sheet1", "A1", CellValue::Number(1.0))
3135            .unwrap();
3136        wb.set_cell_value("Alpha", "A1", CellValue::Number(2.0))
3137            .unwrap();
3138        wb.set_cell_value("Beta", "A1", CellValue::Number(3.0))
3139            .unwrap();
3140        let buf = wb.save_to_buffer().unwrap();
3141
3142        let opts = OpenOptions::new().sheets(vec!["Sheet1".to_string(), "Beta".to_string()]);
3143        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3144
3145        assert_eq!(
3146            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3147            CellValue::Number(1.0)
3148        );
3149        assert_eq!(wb2.get_cell_value("Alpha", "A1").unwrap(), CellValue::Empty);
3150        assert_eq!(
3151            wb2.get_cell_value("Beta", "A1").unwrap(),
3152            CellValue::Number(3.0)
3153        );
3154    }
3155
3156    #[test]
3157    fn test_save_does_not_mutate_stored_format() {
3158        let dir = TempDir::new().unwrap();
3159        let path = dir.path().join("test.xlsm");
3160        let wb = Workbook::new();
3161        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
3162        wb.save(&path).unwrap();
3163        // The save call takes &self, so the stored format is unchanged.
3164        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
3165    }
3166
3167    #[test]
3168    fn test_max_zip_entries_exceeded() {
3169        let wb = Workbook::new();
3170        let buf = wb.save_to_buffer().unwrap();
3171
3172        // A basic workbook has at least 8 ZIP entries -- set limit to 2
3173        let opts = OpenOptions::new().max_zip_entries(2);
3174        let result = Workbook::open_from_buffer_with_options(&buf, &opts);
3175        assert!(matches!(result, Err(Error::ZipEntryCountExceeded { .. })));
3176    }
3177
3178    #[test]
3179    fn test_max_zip_entries_within_limit() {
3180        let wb = Workbook::new();
3181        let buf = wb.save_to_buffer().unwrap();
3182
3183        let opts = OpenOptions::new().max_zip_entries(1000);
3184        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3185        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
3186    }
3187
3188    #[test]
3189    fn test_max_unzip_size_exceeded() {
3190        let mut wb = Workbook::new();
3191        // Write enough data so the decompressed size is non-trivial
3192        for i in 1..=100 {
3193            let cell = format!("A{}", i);
3194            wb.set_cell_value(
3195                "Sheet1",
3196                &cell,
3197                CellValue::String("long_value_for_size_check".repeat(10)),
3198            )
3199            .unwrap();
3200        }
3201        let buf = wb.save_to_buffer().unwrap();
3202
3203        // Set a very small decompressed size limit
3204        let opts = OpenOptions::new().max_unzip_size(100);
3205        let result = Workbook::open_from_buffer_with_options(&buf, &opts);
3206        assert!(matches!(result, Err(Error::ZipSizeExceeded { .. })));
3207    }
3208
3209    #[test]
3210    fn test_max_unzip_size_within_limit() {
3211        let wb = Workbook::new();
3212        let buf = wb.save_to_buffer().unwrap();
3213
3214        let opts = OpenOptions::new().max_unzip_size(1_000_000_000);
3215        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3216        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
3217    }
3218
3219    #[test]
3220    fn test_combined_options() {
3221        let mut wb = Workbook::new();
3222        wb.new_sheet("Parsed").unwrap();
3223        wb.new_sheet("Skipped").unwrap();
3224        for i in 1..=10 {
3225            let cell = format!("A{}", i);
3226            wb.set_cell_value("Parsed", &cell, CellValue::Number(i as f64))
3227                .unwrap();
3228            wb.set_cell_value("Skipped", &cell, CellValue::Number(i as f64))
3229                .unwrap();
3230        }
3231        let buf = wb.save_to_buffer().unwrap();
3232
3233        let opts = OpenOptions::new()
3234            .sheets(vec!["Parsed".to_string()])
3235            .sheet_rows(3);
3236        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3237
3238        // Parsed sheet has only 3 rows
3239        assert_eq!(
3240            wb2.get_cell_value("Parsed", "A3").unwrap(),
3241            CellValue::Number(3.0)
3242        );
3243        assert_eq!(
3244            wb2.get_cell_value("Parsed", "A4").unwrap(),
3245            CellValue::Empty
3246        );
3247
3248        // Skipped sheet is empty
3249        assert_eq!(
3250            wb2.get_cell_value("Skipped", "A1").unwrap(),
3251            CellValue::Empty
3252        );
3253    }
3254
3255    #[test]
3256    fn test_sheet_rows_zero_means_no_rows() {
3257        let mut wb = Workbook::new();
3258        wb.set_cell_value("Sheet1", "A1", CellValue::Number(1.0))
3259            .unwrap();
3260        let buf = wb.save_to_buffer().unwrap();
3261
3262        let opts = OpenOptions::new().sheet_rows(0);
3263        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3264        assert_eq!(
3265            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3266            CellValue::Empty
3267        );
3268    }
3269
3270    #[test]
3271    fn test_selective_sheet_parsing_preserves_unparsed_sheets_on_save() {
3272        let dir = TempDir::new().unwrap();
3273        let path1 = dir.path().join("original.xlsx");
3274        let path2 = dir.path().join("resaved.xlsx");
3275
3276        // Create a workbook with 3 sheets, each with distinct data.
3277        let mut wb = Workbook::new();
3278        wb.new_sheet("Sales").unwrap();
3279        wb.new_sheet("Data").unwrap();
3280        wb.set_cell_value(
3281            "Sheet1",
3282            "A1",
3283            CellValue::String("Sheet1 value".to_string()),
3284        )
3285        .unwrap();
3286        wb.set_cell_value("Sheet1", "B2", CellValue::Number(100.0))
3287            .unwrap();
3288        wb.set_cell_value("Sales", "A1", CellValue::String("Sales value".to_string()))
3289            .unwrap();
3290        wb.set_cell_value("Sales", "C3", CellValue::Number(200.0))
3291            .unwrap();
3292        wb.set_cell_value("Data", "A1", CellValue::String("Data value".to_string()))
3293            .unwrap();
3294        wb.set_cell_value("Data", "D4", CellValue::Bool(true))
3295            .unwrap();
3296        wb.save(&path1).unwrap();
3297
3298        // Reopen with only Sheet1 parsed.
3299        let opts = OpenOptions::new().sheets(vec!["Sheet1".to_string()]);
3300        let wb2 = Workbook::open_with_options(&path1, &opts).unwrap();
3301
3302        // Verify Sheet1 was parsed.
3303        assert_eq!(
3304            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3305            CellValue::String("Sheet1 value".to_string())
3306        );
3307
3308        // Save to a new file.
3309        wb2.save(&path2).unwrap();
3310
3311        // Reopen the resaved file with all sheets parsed.
3312        let wb3 = Workbook::open(&path2).unwrap();
3313        assert_eq!(wb3.sheet_names(), vec!["Sheet1", "Sales", "Data"]);
3314
3315        // Sheet1 data should be intact.
3316        assert_eq!(
3317            wb3.get_cell_value("Sheet1", "A1").unwrap(),
3318            CellValue::String("Sheet1 value".to_string())
3319        );
3320        assert_eq!(
3321            wb3.get_cell_value("Sheet1", "B2").unwrap(),
3322            CellValue::Number(100.0)
3323        );
3324
3325        // Sales data should be preserved from raw XML.
3326        assert_eq!(
3327            wb3.get_cell_value("Sales", "A1").unwrap(),
3328            CellValue::String("Sales value".to_string())
3329        );
3330        assert_eq!(
3331            wb3.get_cell_value("Sales", "C3").unwrap(),
3332            CellValue::Number(200.0)
3333        );
3334
3335        // Data sheet should be preserved from raw XML.
3336        assert_eq!(
3337            wb3.get_cell_value("Data", "A1").unwrap(),
3338            CellValue::String("Data value".to_string())
3339        );
3340        assert_eq!(
3341            wb3.get_cell_value("Data", "D4").unwrap(),
3342            CellValue::Bool(true)
3343        );
3344    }
3345
3346    #[test]
3347    fn test_open_from_buffer_with_options_backwards_compatible() {
3348        let mut wb = Workbook::new();
3349        wb.set_cell_value("Sheet1", "A1", CellValue::String("Hello".to_string()))
3350            .unwrap();
3351        let buf = wb.save_to_buffer().unwrap();
3352
3353        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
3354        assert_eq!(
3355            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3356            CellValue::String("Hello".to_string())
3357        );
3358    }
3359
3360    use crate::workbook::open_options::ReadMode;
3361
3362    #[test]
3363    fn test_readfast_open_reads_cell_data() {
3364        let mut wb = Workbook::new();
3365        wb.set_cell_value("Sheet1", "A1", CellValue::String("Hello".to_string()))
3366            .unwrap();
3367        wb.set_cell_value("Sheet1", "B2", CellValue::Number(42.0))
3368            .unwrap();
3369        wb.set_cell_value("Sheet1", "C3", CellValue::Bool(true))
3370            .unwrap();
3371        let buf = wb.save_to_buffer().unwrap();
3372
3373        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3374        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3375        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
3376        assert_eq!(
3377            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3378            CellValue::String("Hello".to_string())
3379        );
3380        assert_eq!(
3381            wb2.get_cell_value("Sheet1", "B2").unwrap(),
3382            CellValue::Number(42.0)
3383        );
3384        assert_eq!(
3385            wb2.get_cell_value("Sheet1", "C3").unwrap(),
3386            CellValue::Bool(true)
3387        );
3388    }
3389
3390    #[test]
3391    fn test_readfast_open_multi_sheet() {
3392        let mut wb = Workbook::new();
3393        wb.new_sheet("Sheet2").unwrap();
3394        wb.set_cell_value("Sheet1", "A1", CellValue::String("S1".to_string()))
3395            .unwrap();
3396        wb.set_cell_value("Sheet2", "A1", CellValue::String("S2".to_string()))
3397            .unwrap();
3398        let buf = wb.save_to_buffer().unwrap();
3399
3400        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3401        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3402        assert_eq!(wb2.sheet_names(), vec!["Sheet1", "Sheet2"]);
3403        assert_eq!(
3404            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3405            CellValue::String("S1".to_string())
3406        );
3407        assert_eq!(
3408            wb2.get_cell_value("Sheet2", "A1").unwrap(),
3409            CellValue::String("S2".to_string())
3410        );
3411    }
3412
3413    #[test]
3414    fn test_readfast_skips_comments() {
3415        let mut wb = Workbook::new();
3416        wb.set_cell_value("Sheet1", "A1", CellValue::String("data".to_string()))
3417            .unwrap();
3418        wb.add_comment(
3419            "Sheet1",
3420            &crate::comment::CommentConfig {
3421                cell: "A1".to_string(),
3422                author: "Tester".to_string(),
3423                text: "A test comment".to_string(),
3424            },
3425        )
3426        .unwrap();
3427        let buf = wb.save_to_buffer().unwrap();
3428
3429        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3430        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3431
3432        // Cell data is readable.
3433        assert_eq!(
3434            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3435            CellValue::String("data".to_string())
3436        );
3437        // Comments are hydrated on demand from deferred parts.
3438        let comments = wb2.get_comments("Sheet1").unwrap();
3439        assert_eq!(comments.len(), 1);
3440        assert_eq!(comments[0].text, "A test comment");
3441    }
3442
3443    #[test]
3444    fn test_readfast_get_doc_properties_without_mutation() {
3445        let mut wb = Workbook::new();
3446        wb.set_cell_value("Sheet1", "A1", CellValue::Number(1.0))
3447            .unwrap();
3448        wb.set_doc_props(crate::doc_props::DocProperties {
3449            title: Some("Test Title".to_string()),
3450            ..Default::default()
3451        });
3452        let buf = wb.save_to_buffer().unwrap();
3453
3454        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3455        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3456
3457        // Cell data is readable.
3458        assert_eq!(
3459            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3460            CellValue::Number(1.0)
3461        );
3462        // Doc properties should be readable directly from deferred parts.
3463        let props = wb2.get_doc_props();
3464        assert_eq!(props.title.as_deref(), Some("Test Title"));
3465    }
3466
3467    #[test]
3468    fn test_readfast_save_roundtrip_preserves_all_parts() {
3469        let mut wb = Workbook::new();
3470        wb.set_cell_value("Sheet1", "A1", CellValue::String("data".to_string()))
3471            .unwrap();
3472        wb.add_comment(
3473            "Sheet1",
3474            &crate::comment::CommentConfig {
3475                cell: "A1".to_string(),
3476                author: "Tester".to_string(),
3477                text: "A comment".to_string(),
3478            },
3479        )
3480        .unwrap();
3481        wb.set_doc_props(crate::doc_props::DocProperties {
3482            title: Some("Title".to_string()),
3483            ..Default::default()
3484        });
3485        let buf = wb.save_to_buffer().unwrap();
3486
3487        // Open in Lazy mode.
3488        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3489        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3490        let saved = wb2.save_to_buffer().unwrap();
3491
3492        // Re-open in Eager mode and verify all parts were preserved.
3493        let mut wb3 = Workbook::open_from_buffer(&saved).unwrap();
3494        assert_eq!(
3495            wb3.get_cell_value("Sheet1", "A1").unwrap(),
3496            CellValue::String("data".to_string())
3497        );
3498        let comments = wb3.get_comments("Sheet1").unwrap();
3499        assert_eq!(comments.len(), 1);
3500        assert_eq!(comments[0].text, "A comment");
3501        let props = wb3.get_doc_props();
3502        assert_eq!(props.title, Some("Title".to_string()));
3503    }
3504
3505    #[test]
3506    fn test_readfast_with_sheet_rows_limit() {
3507        let mut wb = Workbook::new();
3508        for i in 1..=100 {
3509            wb.set_cell_value("Sheet1", &format!("A{}", i), CellValue::Number(i as f64))
3510                .unwrap();
3511        }
3512        let buf = wb.save_to_buffer().unwrap();
3513
3514        let opts = OpenOptions::new().read_mode(ReadMode::Lazy).sheet_rows(10);
3515        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3516        let rows = wb2.get_rows("Sheet1").unwrap();
3517        assert_eq!(rows.len(), 10);
3518    }
3519
3520    #[test]
3521    fn test_readfast_with_sheets_filter() {
3522        let mut wb = Workbook::new();
3523        wb.new_sheet("Sheet2").unwrap();
3524        wb.set_cell_value("Sheet1", "A1", CellValue::String("S1".to_string()))
3525            .unwrap();
3526        wb.set_cell_value("Sheet2", "A1", CellValue::String("S2".to_string()))
3527            .unwrap();
3528        let buf = wb.save_to_buffer().unwrap();
3529
3530        let opts = OpenOptions::new()
3531            .read_mode(ReadMode::Lazy)
3532            .sheets(vec!["Sheet2".to_string()]);
3533        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3534        assert_eq!(wb2.sheet_names(), vec!["Sheet1", "Sheet2"]);
3535        assert_eq!(
3536            wb2.get_cell_value("Sheet2", "A1").unwrap(),
3537            CellValue::String("S2".to_string())
3538        );
3539        // Sheet1 was not parsed, should return empty.
3540        assert_eq!(
3541            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3542            CellValue::Empty
3543        );
3544    }
3545
3546    #[test]
3547    fn test_readfast_preserves_styles() {
3548        let mut wb = Workbook::new();
3549        let style_id = wb
3550            .add_style(&crate::style::Style {
3551                font: Some(crate::style::FontStyle {
3552                    bold: true,
3553                    ..Default::default()
3554                }),
3555                ..Default::default()
3556            })
3557            .unwrap();
3558        wb.set_cell_value("Sheet1", "A1", CellValue::String("bold".to_string()))
3559            .unwrap();
3560        wb.set_cell_style("Sheet1", "A1", style_id).unwrap();
3561        let buf = wb.save_to_buffer().unwrap();
3562
3563        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3564        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3565        let sid = wb2.get_cell_style("Sheet1", "A1").unwrap();
3566        assert!(sid.is_some());
3567        let style = crate::style::get_style(&wb2.stylesheet, sid.unwrap());
3568        assert!(style.is_some());
3569        assert!(style.unwrap().font.map_or(false, |f| f.bold));
3570    }
3571
3572    #[test]
3573    fn test_readfast_full_mode_unchanged() {
3574        let mut wb = Workbook::new();
3575        wb.set_cell_value("Sheet1", "A1", CellValue::String("test".to_string()))
3576            .unwrap();
3577        wb.add_comment(
3578            "Sheet1",
3579            &crate::comment::CommentConfig {
3580                cell: "A1".to_string(),
3581                author: "Author".to_string(),
3582                text: "comment text".to_string(),
3583            },
3584        )
3585        .unwrap();
3586        let buf = wb.save_to_buffer().unwrap();
3587
3588        // Eager mode: everything should be parsed.
3589        let opts = OpenOptions::new().read_mode(ReadMode::Eager);
3590        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3591        let comments = wb2.get_comments("Sheet1").unwrap();
3592        assert_eq!(comments.len(), 1);
3593    }
3594
3595    #[test]
3596    fn test_readfast_open_from_file() {
3597        let dir = TempDir::new().unwrap();
3598        let path = dir.path().join("readfast_test.xlsx");
3599
3600        let mut wb = Workbook::new();
3601        wb.set_cell_value("Sheet1", "A1", CellValue::String("file test".to_string()))
3602            .unwrap();
3603        wb.save(&path).unwrap();
3604
3605        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3606        let wb2 = Workbook::open_with_options(&path, &opts).unwrap();
3607        assert_eq!(
3608            wb2.get_cell_value("Sheet1", "A1").unwrap(),
3609            CellValue::String("file test".to_string())
3610        );
3611    }
3612
3613    #[test]
3614    fn test_readfast_roundtrip_with_custom_zip_entries() {
3615        let buf = create_xlsx_with_custom_entries();
3616
3617        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3618        let wb = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3619        assert_eq!(
3620            wb.get_cell_value("Sheet1", "A1").unwrap(),
3621            CellValue::String("hello".to_string())
3622        );
3623
3624        let saved = wb.save_to_buffer().unwrap();
3625        let cursor = std::io::Cursor::new(&saved);
3626        let mut archive = zip::ZipArchive::new(cursor).unwrap();
3627
3628        // Custom entries should be preserved through Lazy open/save.
3629        let mut custom_xml = String::new();
3630        std::io::Read::read_to_string(
3631            &mut archive.by_name("customXml/item1.xml").unwrap(),
3632            &mut custom_xml,
3633        )
3634        .unwrap();
3635        assert_eq!(custom_xml, "<custom>data1</custom>");
3636
3637        let mut printer = Vec::new();
3638        std::io::Read::read_to_end(
3639            &mut archive
3640                .by_name("xl/printerSettings/printerSettings1.bin")
3641                .unwrap(),
3642            &mut printer,
3643        )
3644        .unwrap();
3645        assert_eq!(printer, b"\x00\x01\x02\x03PRINTER");
3646    }
3647
3648    #[test]
3649    fn test_readfast_deferred_parts_not_empty_when_auxiliary_exist() {
3650        let mut wb = Workbook::new();
3651        wb.set_cell_value("Sheet1", "A1", CellValue::String("data".to_string()))
3652            .unwrap();
3653        wb.add_comment(
3654            "Sheet1",
3655            &crate::comment::CommentConfig {
3656                cell: "A1".to_string(),
3657                author: "Tester".to_string(),
3658                text: "comment".to_string(),
3659            },
3660        )
3661        .unwrap();
3662        let buf = wb.save_to_buffer().unwrap();
3663
3664        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3665        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3666        // When auxiliary parts exist, they should be captured in deferred_parts.
3667        assert!(
3668            wb2.deferred_parts.has_any(),
3669            "deferred_parts should contain skipped auxiliary parts"
3670        );
3671    }
3672
3673    #[test]
3674    fn test_readfast_eager_mode_has_no_deferred_parts() {
3675        use crate::workbook::open_options::{AuxParts, OpenOptions, ReadMode};
3676
3677        let mut wb = Workbook::new();
3678        wb.set_cell_value("Sheet1", "A1", CellValue::String("data".to_string()))
3679            .unwrap();
3680        wb.add_comment(
3681            "Sheet1",
3682            &crate::comment::CommentConfig {
3683                cell: "A1".to_string(),
3684                author: "Tester".to_string(),
3685                text: "comment".to_string(),
3686            },
3687        )
3688        .unwrap();
3689        let buf = wb.save_to_buffer().unwrap();
3690
3691        // Eager mode: deferred_parts should be empty.
3692        let opts = OpenOptions::new()
3693            .read_mode(ReadMode::Eager)
3694            .aux_parts(AuxParts::EagerLoad);
3695        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3696        assert!(
3697            !wb2.deferred_parts.has_any(),
3698            "Eager mode should not have deferred parts"
3699        );
3700    }
3701
3702    #[test]
3703    fn test_readfast_table_parts_preserved_on_roundtrip() {
3704        let mut wb = Workbook::new();
3705        wb.set_cell_value("Sheet1", "A1", CellValue::String("Name".to_string()))
3706            .unwrap();
3707        wb.set_cell_value("Sheet1", "B1", CellValue::String("Value".to_string()))
3708            .unwrap();
3709        wb.set_cell_value("Sheet1", "A2", CellValue::String("Alice".to_string()))
3710            .unwrap();
3711        wb.set_cell_value("Sheet1", "B2", CellValue::Number(10.0))
3712            .unwrap();
3713        wb.add_table(
3714            "Sheet1",
3715            &crate::table::TableConfig {
3716                name: "Table1".to_string(),
3717                display_name: "Table1".to_string(),
3718                range: "A1:B2".to_string(),
3719                columns: vec![
3720                    crate::table::TableColumn {
3721                        name: "Name".to_string(),
3722                        totals_row_function: None,
3723                        totals_row_label: None,
3724                    },
3725                    crate::table::TableColumn {
3726                        name: "Value".to_string(),
3727                        totals_row_function: None,
3728                        totals_row_label: None,
3729                    },
3730                ],
3731                ..Default::default()
3732            },
3733        )
3734        .unwrap();
3735        let buf = wb.save_to_buffer().unwrap();
3736
3737        // Open in Lazy mode and save.
3738        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3739        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3740        let saved = wb2.save_to_buffer().unwrap();
3741
3742        // Re-open in Eager mode and verify the table survived the round-trip.
3743        let wb3 = Workbook::open_from_buffer(&saved).unwrap();
3744        let tables = wb3.get_tables("Sheet1").unwrap();
3745        assert_eq!(tables.len(), 1);
3746        assert_eq!(tables[0].name, "Table1");
3747    }
3748
3749    #[test]
3750    fn test_readfast_delete_table_with_other_deferred_cleans_references() {
3751        use std::io::Read as _;
3752
3753        let mut wb = Workbook::new();
3754        wb.set_cell_value("Sheet1", "A1", CellValue::String("Name".to_string()))
3755            .unwrap();
3756        wb.set_cell_value("Sheet1", "B1", CellValue::String("Value".to_string()))
3757            .unwrap();
3758        wb.set_cell_value("Sheet1", "A2", CellValue::String("Alice".to_string()))
3759            .unwrap();
3760        wb.set_cell_value("Sheet1", "B2", CellValue::Number(10.0))
3761            .unwrap();
3762        wb.add_table(
3763            "Sheet1",
3764            &crate::table::TableConfig {
3765                name: "Table1".to_string(),
3766                display_name: "Table1".to_string(),
3767                range: "A1:B2".to_string(),
3768                columns: vec![
3769                    crate::table::TableColumn {
3770                        name: "Name".to_string(),
3771                        totals_row_function: None,
3772                        totals_row_label: None,
3773                    },
3774                    crate::table::TableColumn {
3775                        name: "Value".to_string(),
3776                        totals_row_function: None,
3777                        totals_row_label: None,
3778                    },
3779                ],
3780                ..Default::default()
3781            },
3782        )
3783        .unwrap();
3784        // Keep another deferred category so has_deferred remains true in Lazy mode.
3785        wb.set_doc_props(crate::doc_props::DocProperties {
3786            title: Some("Keep deferred".to_string()),
3787            ..Default::default()
3788        });
3789        let buf = wb.save_to_buffer().unwrap();
3790
3791        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3792        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3793        wb2.delete_table("Sheet1", "Table1").unwrap();
3794        let saved = wb2.save_to_buffer().unwrap();
3795
3796        let wb3 = Workbook::open_from_buffer(&saved).unwrap();
3797        assert!(wb3.get_tables("Sheet1").unwrap().is_empty());
3798
3799        let cursor = std::io::Cursor::new(saved);
3800        let mut archive = zip::ZipArchive::new(cursor).unwrap();
3801
3802        let mut ct_xml = String::new();
3803        archive
3804            .by_name("[Content_Types].xml")
3805            .unwrap()
3806            .read_to_string(&mut ct_xml)
3807            .unwrap();
3808        assert!(
3809            !ct_xml.contains("/xl/tables/table1.xml"),
3810            "content types must not reference the deleted table part"
3811        );
3812        assert!(
3813            !ct_xml.contains(mime_types::TABLE),
3814            "content types must not keep table override after deletion"
3815        );
3816
3817        let mut rels_xml = String::new();
3818        archive
3819            .by_name("xl/worksheets/_rels/sheet1.xml.rels")
3820            .unwrap()
3821            .read_to_string(&mut rels_xml)
3822            .unwrap();
3823        assert!(
3824            !rels_xml.contains(rel_types::TABLE),
3825            "worksheet rels must not contain table relationship after deletion"
3826        );
3827
3828        let mut sheet_xml = String::new();
3829        archive
3830            .by_name("xl/worksheets/sheet1.xml")
3831            .unwrap()
3832            .read_to_string(&mut sheet_xml)
3833            .unwrap();
3834        assert!(
3835            !sheet_xml.contains("tableParts"),
3836            "worksheet XML must not contain tableParts after deletion"
3837        );
3838    }
3839
3840    #[test]
3841    fn test_readfast_add_comment_then_save_no_duplicate() {
3842        let mut wb = Workbook::new();
3843        wb.set_cell_value("Sheet1", "A1", CellValue::String("data".to_string()))
3844            .unwrap();
3845        wb.add_comment(
3846            "Sheet1",
3847            &crate::comment::CommentConfig {
3848                cell: "A1".to_string(),
3849                author: "Tester".to_string(),
3850                text: "Original comment".to_string(),
3851            },
3852        )
3853        .unwrap();
3854        let buf = wb.save_to_buffer().unwrap();
3855
3856        // Open in Lazy mode, add a new comment, and save.
3857        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3858        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3859        wb2.add_comment(
3860            "Sheet1",
3861            &crate::comment::CommentConfig {
3862                cell: "B1".to_string(),
3863                author: "Tester".to_string(),
3864                text: "New comment".to_string(),
3865            },
3866        )
3867        .unwrap();
3868        // This must not fail with a duplicate ZIP entry error.
3869        let saved = wb2.save_to_buffer().unwrap();
3870
3871        // Re-open and verify both old and new comments are present.
3872        let mut wb3 = Workbook::open_from_buffer(&saved).unwrap();
3873        let comments = wb3.get_comments("Sheet1").unwrap();
3874        assert!(
3875            comments.iter().any(|c| c.text == "New comment"),
3876            "New comment should be present after Lazy + add_comment round-trip"
3877        );
3878        assert!(
3879            comments.iter().any(|c| c.text == "Original comment"),
3880            "Original comment must be preserved after Lazy + add_comment round-trip"
3881        );
3882        assert_eq!(
3883            comments.len(),
3884            2,
3885            "Both original and new comments must survive"
3886        );
3887    }
3888
3889    #[test]
3890    fn test_readfast_add_comment_preserves_existing_comments() {
3891        // Regression test: opening with Lazy mode, adding a comment, and saving
3892        // must not drop pre-existing comments.
3893        let mut wb = Workbook::new();
3894        wb.set_cell_value("Sheet1", "A1", CellValue::String("data".to_string()))
3895            .unwrap();
3896        wb.add_comment(
3897            "Sheet1",
3898            &crate::comment::CommentConfig {
3899                cell: "A1".to_string(),
3900                author: "Alice".to_string(),
3901                text: "First comment".to_string(),
3902            },
3903        )
3904        .unwrap();
3905        wb.add_comment(
3906            "Sheet1",
3907            &crate::comment::CommentConfig {
3908                cell: "B2".to_string(),
3909                author: "Bob".to_string(),
3910                text: "Second comment".to_string(),
3911            },
3912        )
3913        .unwrap();
3914        let buf = wb.save_to_buffer().unwrap();
3915
3916        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3917        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3918
3919        // Add a third comment.
3920        wb2.add_comment(
3921            "Sheet1",
3922            &crate::comment::CommentConfig {
3923                cell: "C3".to_string(),
3924                author: "Charlie".to_string(),
3925                text: "Third comment".to_string(),
3926            },
3927        )
3928        .unwrap();
3929        let saved = wb2.save_to_buffer().unwrap();
3930
3931        let mut wb3 = Workbook::open_from_buffer(&saved).unwrap();
3932        let comments = wb3.get_comments("Sheet1").unwrap();
3933        assert_eq!(comments.len(), 3, "All three comments must be present");
3934        assert!(comments
3935            .iter()
3936            .any(|c| c.cell == "A1" && c.text == "First comment"));
3937        assert!(comments
3938            .iter()
3939            .any(|c| c.cell == "B2" && c.text == "Second comment"));
3940        assert!(comments
3941            .iter()
3942            .any(|c| c.cell == "C3" && c.text == "Third comment"));
3943    }
3944
3945    #[test]
3946    fn test_readfast_get_comments_hydrates_deferred() {
3947        // get_comments should return deferred comments even if no mutation occurred.
3948        let mut wb = Workbook::new();
3949        wb.add_comment(
3950            "Sheet1",
3951            &crate::comment::CommentConfig {
3952                cell: "A1".to_string(),
3953                author: "Author".to_string(),
3954                text: "Deferred comment".to_string(),
3955            },
3956        )
3957        .unwrap();
3958        let buf = wb.save_to_buffer().unwrap();
3959
3960        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3961        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3962
3963        // get_comments should hydrate and return the deferred comment.
3964        let comments = wb2.get_comments("Sheet1").unwrap();
3965        assert_eq!(comments.len(), 1);
3966        assert_eq!(comments[0].cell, "A1");
3967        assert_eq!(comments[0].text, "Deferred comment");
3968    }
3969
3970    #[test]
3971    fn test_readfast_remove_comment_hydrates_first() {
3972        // remove_comment on a Lazy workbook must hydrate deferred comments,
3973        // then remove only the target comment, preserving others.
3974        let mut wb = Workbook::new();
3975        wb.add_comment(
3976            "Sheet1",
3977            &crate::comment::CommentConfig {
3978                cell: "A1".to_string(),
3979                author: "Alice".to_string(),
3980                text: "Keep me".to_string(),
3981            },
3982        )
3983        .unwrap();
3984        wb.add_comment(
3985            "Sheet1",
3986            &crate::comment::CommentConfig {
3987                cell: "B2".to_string(),
3988                author: "Bob".to_string(),
3989                text: "Remove me".to_string(),
3990            },
3991        )
3992        .unwrap();
3993        let buf = wb.save_to_buffer().unwrap();
3994
3995        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
3996        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
3997        wb2.remove_comment("Sheet1", "B2").unwrap();
3998
3999        let saved = wb2.save_to_buffer().unwrap();
4000        let mut wb3 = Workbook::open_from_buffer(&saved).unwrap();
4001        let comments = wb3.get_comments("Sheet1").unwrap();
4002        assert_eq!(comments.len(), 1);
4003        assert_eq!(comments[0].cell, "A1");
4004        assert_eq!(comments[0].text, "Keep me");
4005    }
4006
4007    #[test]
4008    fn test_readfast_add_comment_no_preexisting_comments() {
4009        // Adding a comment to a sheet that had no comments when opened in Lazy mode
4010        // must create proper relationships and content types on save, even when
4011        // deferred_parts is non-empty due to other auxiliary parts (e.g. doc props).
4012        let mut wb = Workbook::new();
4013        wb.set_cell_value("Sheet1", "A1", CellValue::String("data".to_string()))
4014            .unwrap();
4015        // Add doc props so that Lazy mode will have non-empty deferred_parts.
4016        wb.set_doc_props(crate::doc_props::DocProperties {
4017            title: Some("Trigger deferred".to_string()),
4018            ..Default::default()
4019        });
4020        let buf = wb.save_to_buffer().unwrap();
4021
4022        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
4023        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4024        wb2.add_comment(
4025            "Sheet1",
4026            &crate::comment::CommentConfig {
4027                cell: "A1".to_string(),
4028                author: "Newcomer".to_string(),
4029                text: "Brand new comment".to_string(),
4030            },
4031        )
4032        .unwrap();
4033
4034        let saved = wb2.save_to_buffer().unwrap();
4035
4036        // Verify the comment is readable after re-open.
4037        let mut wb3 = Workbook::open_from_buffer(&saved).unwrap();
4038        let comments = wb3.get_comments("Sheet1").unwrap();
4039        assert_eq!(comments.len(), 1);
4040        assert_eq!(comments[0].text, "Brand new comment");
4041
4042        // Verify the ZIP contains the comment XML and VML parts.
4043        let reader = std::io::Cursor::new(&saved);
4044        let mut archive = zip::ZipArchive::new(reader).unwrap();
4045        assert!(
4046            archive.by_name("xl/comments1.xml").is_ok(),
4047            "comments1.xml must be present"
4048        );
4049        assert!(
4050            archive.by_name("xl/drawings/vmlDrawing1.vml").is_ok(),
4051            "vmlDrawing1.vml must be present for the comment"
4052        );
4053    }
4054
4055    #[test]
4056    fn test_readfast_add_comment_vml_roundtrip() {
4057        // Verify that VML parts are correct after Lazy hydration + add comment.
4058        let mut wb = Workbook::new();
4059        wb.add_comment(
4060            "Sheet1",
4061            &crate::comment::CommentConfig {
4062                cell: "A1".to_string(),
4063                author: "Original".to_string(),
4064                text: "Has VML".to_string(),
4065            },
4066        )
4067        .unwrap();
4068        let buf = wb.save_to_buffer().unwrap();
4069
4070        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
4071        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4072        wb2.add_comment(
4073            "Sheet1",
4074            &crate::comment::CommentConfig {
4075                cell: "B2".to_string(),
4076                author: "New".to_string(),
4077                text: "Also has VML".to_string(),
4078            },
4079        )
4080        .unwrap();
4081        let saved = wb2.save_to_buffer().unwrap();
4082
4083        // Verify VML part is present and references both cells.
4084        let reader = std::io::Cursor::new(&saved);
4085        let mut archive = zip::ZipArchive::new(reader).unwrap();
4086        assert!(archive.by_name("xl/drawings/vmlDrawing1.vml").is_ok());
4087
4088        // Verify both comments survive a full open.
4089        let mut wb3 = Workbook::open_from_buffer(&saved).unwrap();
4090        let comments = wb3.get_comments("Sheet1").unwrap();
4091        assert_eq!(comments.len(), 2);
4092    }
4093
4094    #[test]
4095    fn test_readfast_set_doc_props_then_save_no_duplicate() {
4096        let mut wb = Workbook::new();
4097        wb.set_cell_value("Sheet1", "A1", CellValue::Number(1.0))
4098            .unwrap();
4099        wb.set_doc_props(crate::doc_props::DocProperties {
4100            title: Some("Original Title".to_string()),
4101            ..Default::default()
4102        });
4103        let buf = wb.save_to_buffer().unwrap();
4104
4105        // Open in Lazy mode, update doc props, and save.
4106        let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
4107        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4108        wb2.set_doc_props(crate::doc_props::DocProperties {
4109            title: Some("Updated Title".to_string()),
4110            ..Default::default()
4111        });
4112        // This must not fail with a duplicate ZIP entry error.
4113        let saved = wb2.save_to_buffer().unwrap();
4114
4115        // Re-open and verify the updated doc props.
4116        let wb3 = Workbook::open_from_buffer(&saved).unwrap();
4117        let props = wb3.get_doc_props();
4118        assert_eq!(props.title, Some("Updated Title".to_string()));
4119    }
4120
4121    #[test]
4122    fn test_read_xml_part_from_reader_worksheet() {
4123        use sheetkit_xml::worksheet::WorksheetXml;
4124        let ws_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4125<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
4126  <sheetData>
4127    <row r="1"><c r="A1" t="s"><v>0</v></c></row>
4128    <row r="2"><c r="A2"><v>42</v></c></row>
4129  </sheetData>
4130</worksheet>"#;
4131        let mut buf = Vec::new();
4132        {
4133            let cursor = std::io::Cursor::new(&mut buf);
4134            let mut zip = zip::ZipWriter::new(cursor);
4135            let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
4136            zip.start_file("test.xml", opts).unwrap();
4137            use std::io::Write;
4138            zip.write_all(ws_xml.as_bytes()).unwrap();
4139            zip.finish().unwrap();
4140        }
4141        let cursor = std::io::Cursor::new(&buf);
4142        let mut archive = zip::ZipArchive::new(cursor).unwrap();
4143        let ws: WorksheetXml = read_xml_part(&mut archive, "test.xml").unwrap();
4144        assert_eq!(ws.sheet_data.rows.len(), 2);
4145        assert_eq!(ws.sheet_data.rows[0].r, 1);
4146        assert_eq!(ws.sheet_data.rows[0].cells[0].r, "A1");
4147        assert_eq!(ws.sheet_data.rows[1].r, 2);
4148        assert_eq!(ws.sheet_data.rows[1].cells[0].v, Some("42".to_string()));
4149    }
4150
4151    #[test]
4152    fn test_read_xml_part_from_reader_sst() {
4153        use sheetkit_xml::shared_strings::Sst;
4154        let sst_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4155<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="2" uniqueCount="2">
4156  <si><t>Hello</t></si>
4157  <si><t>World</t></si>
4158</sst>"#;
4159        let mut buf = Vec::new();
4160        {
4161            let cursor = std::io::Cursor::new(&mut buf);
4162            let mut zip = zip::ZipWriter::new(cursor);
4163            let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
4164            zip.start_file("sst.xml", opts).unwrap();
4165            use std::io::Write;
4166            zip.write_all(sst_xml.as_bytes()).unwrap();
4167            zip.finish().unwrap();
4168        }
4169        let cursor = std::io::Cursor::new(&buf);
4170        let mut archive = zip::ZipArchive::new(cursor).unwrap();
4171        let sst: Sst = read_xml_part(&mut archive, "sst.xml").unwrap();
4172        assert_eq!(sst.count, Some(2));
4173        assert_eq!(sst.unique_count, Some(2));
4174        assert_eq!(sst.items.len(), 2);
4175        assert_eq!(sst.items[0].t.as_ref().unwrap().value, "Hello");
4176        assert_eq!(sst.items[1].t.as_ref().unwrap().value, "World");
4177    }
4178
4179    #[test]
4180    fn test_read_xml_part_from_reader_large_worksheet() {
4181        use sheetkit_xml::worksheet::WorksheetXml;
4182        let mut ws_xml = String::from(
4183            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4184<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
4185  <sheetData>"#,
4186        );
4187        for i in 1..=500 {
4188            ws_xml.push_str(&format!(
4189                "<row r=\"{i}\"><c r=\"A{i}\"><v>{}</v></c><c r=\"B{i}\"><v>{}</v></c></row>",
4190                i * 10,
4191                i * 20,
4192            ));
4193        }
4194        ws_xml.push_str("</sheetData></worksheet>");
4195
4196        let mut buf = Vec::new();
4197        {
4198            let cursor = std::io::Cursor::new(&mut buf);
4199            let mut zip = zip::ZipWriter::new(cursor);
4200            let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
4201            zip.start_file("sheet.xml", opts).unwrap();
4202            use std::io::Write;
4203            zip.write_all(ws_xml.as_bytes()).unwrap();
4204            zip.finish().unwrap();
4205        }
4206        let cursor = std::io::Cursor::new(&buf);
4207        let mut archive = zip::ZipArchive::new(cursor).unwrap();
4208        let ws: WorksheetXml = read_xml_part(&mut archive, "sheet.xml").unwrap();
4209        assert_eq!(ws.sheet_data.rows.len(), 500);
4210        assert_eq!(ws.sheet_data.rows[0].r, 1);
4211        assert_eq!(ws.sheet_data.rows[0].cells[0].v, Some("10".to_string()));
4212        assert_eq!(ws.sheet_data.rows[499].r, 500);
4213        assert_eq!(
4214            ws.sheet_data.rows[499].cells[1].v,
4215            Some("10000".to_string())
4216        );
4217    }
4218
4219    // -- Copy-on-write passthrough tests --
4220
4221    #[test]
4222    fn test_lazy_open_save_without_modification_roundtrips() {
4223        let mut wb = Workbook::new();
4224        wb.set_cell_value("Sheet1", "A1", "Hello").unwrap();
4225        wb.set_cell_value("Sheet1", "B1", 42.0f64).unwrap();
4226        let buf = wb.save_to_buffer().unwrap();
4227
4228        let opts = crate::workbook::open_options::OpenOptions::new()
4229            .read_mode(crate::workbook::open_options::ReadMode::Lazy);
4230        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4231
4232        // No modifications: save should use passthrough for the worksheet.
4233        let saved = wb2.save_to_buffer().unwrap();
4234
4235        // Re-open in Eager mode and verify data integrity.
4236        let wb3 = Workbook::open_from_buffer(&saved).unwrap();
4237        assert_eq!(
4238            wb3.get_cell_value("Sheet1", "A1").unwrap(),
4239            CellValue::String("Hello".to_string())
4240        );
4241        assert_eq!(
4242            wb3.get_cell_value("Sheet1", "B1").unwrap(),
4243            CellValue::Number(42.0)
4244        );
4245    }
4246
4247    #[test]
4248    fn test_lazy_open_modify_one_sheet_passthroughs_others() {
4249        let mut wb = Workbook::new();
4250        wb.set_cell_value("Sheet1", "A1", "First sheet").unwrap();
4251        wb.new_sheet("Sheet2").unwrap();
4252        wb.set_cell_value("Sheet2", "A1", "Second sheet").unwrap();
4253        wb.new_sheet("Sheet3").unwrap();
4254        wb.set_cell_value("Sheet3", "A1", "Third sheet").unwrap();
4255        let buf = wb.save_to_buffer().unwrap();
4256
4257        let opts = crate::workbook::open_options::OpenOptions::new()
4258            .read_mode(crate::workbook::open_options::ReadMode::Lazy);
4259        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4260
4261        // Only modify Sheet2; Sheet1 and Sheet3 should use passthrough.
4262        wb2.set_cell_value("Sheet2", "B1", "Modified").unwrap();
4263
4264        // Verify dirty tracking.
4265        assert!(!wb2.is_sheet_dirty(0), "Sheet1 should not be dirty");
4266        assert!(wb2.is_sheet_dirty(1), "Sheet2 should be dirty");
4267        assert!(!wb2.is_sheet_dirty(2), "Sheet3 should not be dirty");
4268
4269        let saved = wb2.save_to_buffer().unwrap();
4270
4271        let wb3 = Workbook::open_from_buffer(&saved).unwrap();
4272        assert_eq!(
4273            wb3.get_cell_value("Sheet1", "A1").unwrap(),
4274            CellValue::String("First sheet".to_string())
4275        );
4276        assert_eq!(
4277            wb3.get_cell_value("Sheet2", "A1").unwrap(),
4278            CellValue::String("Second sheet".to_string())
4279        );
4280        assert_eq!(
4281            wb3.get_cell_value("Sheet2", "B1").unwrap(),
4282            CellValue::String("Modified".to_string())
4283        );
4284        assert_eq!(
4285            wb3.get_cell_value("Sheet3", "A1").unwrap(),
4286            CellValue::String("Third sheet".to_string())
4287        );
4288    }
4289
4290    #[test]
4291    fn test_lazy_open_deferred_aux_parts_preserved() {
4292        let mut wb = Workbook::new();
4293        wb.set_cell_value("Sheet1", "A1", "data").unwrap();
4294        wb.set_doc_props(crate::doc_props::DocProperties {
4295            title: Some("Test Title".to_string()),
4296            creator: Some("Test Author".to_string()),
4297            ..Default::default()
4298        });
4299        wb.add_comment(
4300            "Sheet1",
4301            &crate::comment::CommentConfig {
4302                cell: "A1".to_string(),
4303                author: "Tester".to_string(),
4304                text: "A comment".to_string(),
4305            },
4306        )
4307        .unwrap();
4308        let buf = wb.save_to_buffer().unwrap();
4309
4310        let opts = crate::workbook::open_options::OpenOptions::new()
4311            .read_mode(crate::workbook::open_options::ReadMode::Lazy);
4312        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4313
4314        // Save without touching anything; deferred aux parts should be preserved.
4315        let saved = wb2.save_to_buffer().unwrap();
4316
4317        let mut wb3 = Workbook::open_from_buffer(&saved).unwrap();
4318        assert_eq!(
4319            wb3.get_cell_value("Sheet1", "A1").unwrap(),
4320            CellValue::String("data".to_string())
4321        );
4322        let props = wb3.get_doc_props();
4323        assert_eq!(props.title.as_deref(), Some("Test Title"));
4324        assert_eq!(props.creator.as_deref(), Some("Test Author"));
4325        let comments = wb3.get_comments("Sheet1").unwrap();
4326        assert_eq!(comments.len(), 1);
4327        assert_eq!(comments[0].text, "A comment");
4328    }
4329
4330    #[test]
4331    fn test_eager_open_save_preserves_all_data() {
4332        let mut wb = Workbook::new();
4333        wb.set_cell_value("Sheet1", "A1", "data").unwrap();
4334        wb.set_cell_value("Sheet1", "B1", 42.0f64).unwrap();
4335        wb.new_sheet("Sheet2").unwrap();
4336        wb.set_cell_value("Sheet2", "A1", "sheet2").unwrap();
4337        let buf = wb.save_to_buffer().unwrap();
4338
4339        // Eager mode (default): all sheets parsed at open time.
4340        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
4341        let saved = wb2.save_to_buffer().unwrap();
4342
4343        let wb3 = Workbook::open_from_buffer(&saved).unwrap();
4344        assert_eq!(
4345            wb3.get_cell_value("Sheet1", "A1").unwrap(),
4346            CellValue::String("data".to_string())
4347        );
4348        assert_eq!(
4349            wb3.get_cell_value("Sheet1", "B1").unwrap(),
4350            CellValue::Number(42.0)
4351        );
4352        assert_eq!(
4353            wb3.get_cell_value("Sheet2", "A1").unwrap(),
4354            CellValue::String("sheet2".to_string())
4355        );
4356    }
4357
4358    #[test]
4359    fn test_lazy_read_then_save_passthrough() {
4360        let mut wb = Workbook::new();
4361        wb.set_cell_value("Sheet1", "A1", "value").unwrap();
4362        let buf = wb.save_to_buffer().unwrap();
4363
4364        let opts = crate::workbook::open_options::OpenOptions::new()
4365            .read_mode(crate::workbook::open_options::ReadMode::Lazy);
4366        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4367
4368        // Read the value (triggers hydration via worksheet_ref, not mutation).
4369        let val = wb2.get_cell_value("Sheet1", "A1").unwrap();
4370        assert_eq!(val, CellValue::String("value".to_string()));
4371
4372        // Sheet was read but not modified, so it should NOT be dirty.
4373        assert!(!wb2.is_sheet_dirty(0));
4374
4375        // Save should still use passthrough for the untouched sheet.
4376        let saved = wb2.save_to_buffer().unwrap();
4377        let wb3 = Workbook::open_from_buffer(&saved).unwrap();
4378        assert_eq!(
4379            wb3.get_cell_value("Sheet1", "A1").unwrap(),
4380            CellValue::String("value".to_string())
4381        );
4382    }
4383
4384    #[test]
4385    fn test_cow_passthrough_with_styles_and_formulas() {
4386        let mut wb = Workbook::new();
4387        let style_id = wb
4388            .add_style(&crate::style::Style {
4389                font: Some(crate::style::FontStyle {
4390                    bold: true,
4391                    ..Default::default()
4392                }),
4393                ..Default::default()
4394            })
4395            .unwrap();
4396        wb.set_cell_value("Sheet1", "A1", "styled").unwrap();
4397        wb.set_cell_style("Sheet1", "A1", style_id).unwrap();
4398        wb.set_cell_formula("Sheet1", "B1", "LEN(A1)").unwrap();
4399        wb.new_sheet("Sheet2").unwrap();
4400        wb.set_cell_value("Sheet2", "A1", "other").unwrap();
4401        let buf = wb.save_to_buffer().unwrap();
4402
4403        let opts = crate::workbook::open_options::OpenOptions::new()
4404            .read_mode(crate::workbook::open_options::ReadMode::Lazy);
4405        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4406        let saved = wb2.save_to_buffer().unwrap();
4407
4408        let wb3 = Workbook::open_from_buffer(&saved).unwrap();
4409        assert_eq!(
4410            wb3.get_cell_value("Sheet1", "A1").unwrap(),
4411            CellValue::String("styled".to_string())
4412        );
4413        assert_eq!(wb3.get_cell_style("Sheet1", "A1").unwrap(), Some(style_id));
4414        match wb3.get_cell_value("Sheet1", "B1").unwrap() {
4415            CellValue::Formula { expr, .. } => assert_eq!(expr, "LEN(A1)"),
4416            other => panic!("expected formula, got {:?}", other),
4417        }
4418        assert_eq!(
4419            wb3.get_cell_value("Sheet2", "A1").unwrap(),
4420            CellValue::String("other".to_string())
4421        );
4422    }
4423
4424    #[test]
4425    fn test_new_workbook_sheets_are_dirty() {
4426        let wb = Workbook::new();
4427        assert!(wb.is_sheet_dirty(0), "new workbook sheet should be dirty");
4428    }
4429
4430    #[test]
4431    fn test_eager_open_sheets_are_dirty() {
4432        use crate::workbook::open_options::{AuxParts, OpenOptions, ReadMode};
4433
4434        let mut wb = Workbook::new();
4435        wb.set_cell_value("Sheet1", "A1", "test").unwrap();
4436        let buf = wb.save_to_buffer().unwrap();
4437
4438        let opts = OpenOptions::new()
4439            .read_mode(ReadMode::Eager)
4440            .aux_parts(AuxParts::EagerLoad);
4441        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4442        assert!(
4443            wb2.is_sheet_dirty(0),
4444            "eagerly parsed sheet should be dirty"
4445        );
4446    }
4447
4448    #[test]
4449    fn test_lazy_open_sheets_start_clean() {
4450        let mut wb = Workbook::new();
4451        wb.set_cell_value("Sheet1", "A1", "test").unwrap();
4452        let buf = wb.save_to_buffer().unwrap();
4453
4454        let opts = crate::workbook::open_options::OpenOptions::new()
4455            .read_mode(crate::workbook::open_options::ReadMode::Lazy);
4456        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4457        assert!(
4458            !wb2.is_sheet_dirty(0),
4459            "lazily deferred sheet should start clean"
4460        );
4461    }
4462
4463    #[test]
4464    fn test_lazy_mutation_marks_dirty() {
4465        let mut wb = Workbook::new();
4466        wb.set_cell_value("Sheet1", "A1", "test").unwrap();
4467        let buf = wb.save_to_buffer().unwrap();
4468
4469        let opts = crate::workbook::open_options::OpenOptions::new()
4470            .read_mode(crate::workbook::open_options::ReadMode::Lazy);
4471        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4472        assert!(!wb2.is_sheet_dirty(0));
4473
4474        wb2.set_cell_value("Sheet1", "B1", "new").unwrap();
4475        assert!(
4476            wb2.is_sheet_dirty(0),
4477            "sheet should be dirty after mutation"
4478        );
4479    }
4480
4481    #[test]
4482    fn test_lazy_open_multi_sheet_selective_dirty() {
4483        let mut wb = Workbook::new();
4484        wb.set_cell_value("Sheet1", "A1", "s1").unwrap();
4485        wb.new_sheet("Sheet2").unwrap();
4486        wb.set_cell_value("Sheet2", "A1", "s2").unwrap();
4487        wb.new_sheet("Sheet3").unwrap();
4488        wb.set_cell_value("Sheet3", "A1", "s3").unwrap();
4489        let buf = wb.save_to_buffer().unwrap();
4490
4491        let opts = crate::workbook::open_options::OpenOptions::new()
4492            .read_mode(crate::workbook::open_options::ReadMode::Lazy);
4493        let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4494
4495        // All sheets start clean.
4496        assert!(!wb2.is_sheet_dirty(0));
4497        assert!(!wb2.is_sheet_dirty(1));
4498        assert!(!wb2.is_sheet_dirty(2));
4499
4500        // Read Sheet1 (no mutation).
4501        let _ = wb2.get_cell_value("Sheet1", "A1").unwrap();
4502        assert!(!wb2.is_sheet_dirty(0), "reading should not dirty a sheet");
4503
4504        // Mutate Sheet3.
4505        wb2.set_cell_value("Sheet3", "B1", "modified").unwrap();
4506        assert!(!wb2.is_sheet_dirty(0));
4507        assert!(!wb2.is_sheet_dirty(1));
4508        assert!(wb2.is_sheet_dirty(2));
4509
4510        // Save and verify all data.
4511        let saved = wb2.save_to_buffer().unwrap();
4512        let wb3 = Workbook::open_from_buffer(&saved).unwrap();
4513        assert_eq!(
4514            wb3.get_cell_value("Sheet1", "A1").unwrap(),
4515            CellValue::String("s1".to_string())
4516        );
4517        assert_eq!(
4518            wb3.get_cell_value("Sheet2", "A1").unwrap(),
4519            CellValue::String("s2".to_string())
4520        );
4521        assert_eq!(
4522            wb3.get_cell_value("Sheet3", "A1").unwrap(),
4523            CellValue::String("s3".to_string())
4524        );
4525        assert_eq!(
4526            wb3.get_cell_value("Sheet3", "B1").unwrap(),
4527            CellValue::String("modified".to_string())
4528        );
4529    }
4530
4531    #[test]
4532    fn test_sheets_filter_preserves_filtered_sheet_with_comments_on_save() {
4533        let mut wb = Workbook::new();
4534        wb.new_sheet("Sheet2").unwrap();
4535        wb.set_cell_value("Sheet1", "A1", CellValue::String("keep_me".to_string()))
4536            .unwrap();
4537        wb.set_cell_value("Sheet2", "A1", CellValue::String("s2".to_string()))
4538            .unwrap();
4539        wb.add_comment(
4540            "Sheet1",
4541            &crate::comment::CommentConfig {
4542                cell: "A1".to_string(),
4543                author: "Test".to_string(),
4544                text: "a comment".to_string(),
4545            },
4546        )
4547        .unwrap();
4548        let buf = wb.save_to_buffer().unwrap();
4549
4550        let opts = OpenOptions::new().sheets(vec!["Sheet2".to_string()]);
4551        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
4552        assert_eq!(
4553            wb2.get_cell_value("Sheet1", "A1").unwrap(),
4554            CellValue::Empty
4555        );
4556
4557        let buf2 = wb2.save_to_buffer().unwrap();
4558        let opts_all = OpenOptions::new()
4559            .read_mode(ReadMode::Eager)
4560            .aux_parts(AuxParts::EagerLoad);
4561        let wb3 = Workbook::open_from_buffer_with_options(&buf2, &opts_all).unwrap();
4562        assert_eq!(
4563            wb3.get_cell_value("Sheet1", "A1").unwrap(),
4564            CellValue::String("keep_me".to_string()),
4565        );
4566        assert_eq!(
4567            wb3.get_cell_value("Sheet2", "A1").unwrap(),
4568            CellValue::String("s2".to_string()),
4569        );
4570    }
4571}