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