Skip to main content

sheetkit_core/workbook/
io.rs

1use super::*;
2use crate::workbook::open_options::OpenOptions;
3
4/// VBA project relationship type URI.
5const VBA_PROJECT_REL_TYPE: &str =
6    "http://schemas.microsoft.com/office/2006/relationships/vbaProject";
7
8/// VBA project content type.
9const VBA_PROJECT_CONTENT_TYPE: &str = "application/vnd.ms-office.vbaProject";
10
11impl Workbook {
12    /// Create a new empty workbook containing a single empty sheet named "Sheet1".
13    pub fn new() -> Self {
14        let sst_runtime = SharedStringTable::new();
15        let mut sheet_name_index = HashMap::new();
16        sheet_name_index.insert("Sheet1".to_string(), 0);
17        Self {
18            format: WorkbookFormat::default(),
19            content_types: ContentTypes::default(),
20            package_rels: relationships::package_rels(),
21            workbook_xml: WorkbookXml::default(),
22            workbook_rels: relationships::workbook_rels(),
23            worksheets: vec![("Sheet1".to_string(), WorksheetXml::default())],
24            stylesheet: StyleSheet::default(),
25            sst_runtime,
26            sheet_comments: vec![None],
27            charts: vec![],
28            raw_charts: vec![],
29            drawings: vec![],
30            images: vec![],
31            worksheet_drawings: HashMap::new(),
32            worksheet_rels: HashMap::new(),
33            drawing_rels: HashMap::new(),
34            core_properties: None,
35            app_properties: None,
36            custom_properties: None,
37            pivot_tables: vec![],
38            pivot_cache_defs: vec![],
39            pivot_cache_records: vec![],
40            theme_xml: None,
41            theme_colors: crate::theme::default_theme_colors(),
42            sheet_name_index,
43            sheet_sparklines: vec![vec![]],
44            sheet_vml: vec![None],
45            unknown_parts: vec![],
46            vba_blob: None,
47            tables: vec![],
48            raw_sheet_xml: vec![None],
49            slicer_defs: vec![],
50            slicer_caches: vec![],
51            sheet_threaded_comments: vec![None],
52            person_list: sheetkit_xml::threaded_comment::PersonList::default(),
53            sheet_form_controls: vec![vec![]],
54        }
55    }
56
57    /// Open an existing `.xlsx` file from disk.
58    ///
59    /// If the file is encrypted (CFB container), returns
60    /// [`Error::FileEncrypted`]. Use [`Workbook::open_with_password`] instead.
61    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
62        Self::open_with_options(path, &OpenOptions::default())
63    }
64
65    /// Open an existing `.xlsx` file with custom parsing options.
66    ///
67    /// See [`OpenOptions`] for available options including row limits,
68    /// sheet filtering, and ZIP safety limits.
69    pub fn open_with_options<P: AsRef<Path>>(path: P, options: &OpenOptions) -> Result<Self> {
70        let data = std::fs::read(path.as_ref())?;
71
72        // Detect encrypted files (CFB container)
73        #[cfg(feature = "encryption")]
74        if data.len() >= 8 {
75            if let Ok(crate::crypt::ContainerFormat::Cfb) =
76                crate::crypt::detect_container_format(&data)
77            {
78                return Err(Error::FileEncrypted);
79            }
80        }
81
82        let cursor = std::io::Cursor::new(data);
83        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Zip(e.to_string()))?;
84        Self::from_archive(&mut archive, options)
85    }
86
87    /// Build a Workbook from an already-opened ZIP archive.
88    fn from_archive<R: std::io::Read + std::io::Seek>(
89        archive: &mut zip::ZipArchive<R>,
90        options: &OpenOptions,
91    ) -> Result<Self> {
92        // ZIP safety checks: entry count and total decompressed size.
93        if let Some(max_entries) = options.max_zip_entries {
94            let count = archive.len();
95            if count > max_entries {
96                return Err(Error::ZipEntryCountExceeded {
97                    count,
98                    limit: max_entries,
99                });
100            }
101        }
102        if let Some(max_size) = options.max_unzip_size {
103            let mut total_size: u64 = 0;
104            for i in 0..archive.len() {
105                let entry = archive.by_index(i).map_err(|e| Error::Zip(e.to_string()))?;
106                total_size = total_size.saturating_add(entry.size());
107                if total_size > max_size {
108                    return Err(Error::ZipSizeExceeded {
109                        size: total_size,
110                        limit: max_size,
111                    });
112                }
113            }
114        }
115
116        // Track all ZIP entry paths that are explicitly handled so that the
117        // remaining entries can be preserved as unknown parts.
118        let mut known_paths: HashSet<String> = HashSet::new();
119
120        // Parse [Content_Types].xml
121        let content_types: ContentTypes = read_xml_part(archive, "[Content_Types].xml")?;
122        known_paths.insert("[Content_Types].xml".to_string());
123
124        // Infer the workbook format from the content type of xl/workbook.xml.
125        let format = content_types
126            .overrides
127            .iter()
128            .find(|o| o.part_name == "/xl/workbook.xml")
129            .and_then(|o| WorkbookFormat::from_content_type(&o.content_type))
130            .unwrap_or_default();
131
132        // Parse _rels/.rels
133        let package_rels: Relationships = read_xml_part(archive, "_rels/.rels")?;
134        known_paths.insert("_rels/.rels".to_string());
135
136        // Parse xl/workbook.xml
137        let workbook_xml: WorkbookXml = read_xml_part(archive, "xl/workbook.xml")?;
138        known_paths.insert("xl/workbook.xml".to_string());
139
140        // Parse xl/_rels/workbook.xml.rels
141        let workbook_rels: Relationships = read_xml_part(archive, "xl/_rels/workbook.xml.rels")?;
142        known_paths.insert("xl/_rels/workbook.xml.rels".to_string());
143
144        // Parse each worksheet referenced in the workbook.
145        let sheet_count = workbook_xml.sheets.sheets.len();
146        let mut worksheets = Vec::with_capacity(sheet_count);
147        let mut worksheet_paths = Vec::with_capacity(sheet_count);
148        let mut raw_sheet_xml: Vec<Option<Vec<u8>>> = Vec::with_capacity(sheet_count);
149        for sheet_entry in &workbook_xml.sheets.sheets {
150            // Find the relationship target for this sheet's rId.
151            let rel = workbook_rels
152                .relationships
153                .iter()
154                .find(|r| r.id == sheet_entry.r_id && r.rel_type == rel_types::WORKSHEET);
155
156            let rel = rel.ok_or_else(|| {
157                Error::Internal(format!(
158                    "missing worksheet relationship for sheet '{}'",
159                    sheet_entry.name
160                ))
161            })?;
162
163            let sheet_path = resolve_relationship_target("xl/workbook.xml", &rel.target);
164
165            if options.should_parse_sheet(&sheet_entry.name) {
166                let mut ws: WorksheetXml = read_xml_part(archive, &sheet_path)?;
167                for row in &mut ws.sheet_data.rows {
168                    row.cells.shrink_to_fit();
169                }
170                ws.sheet_data.rows.shrink_to_fit();
171                worksheets.push((sheet_entry.name.clone(), ws));
172                raw_sheet_xml.push(None);
173            } else {
174                // Store the raw XML bytes so the sheet round-trips unchanged on save.
175                let raw_bytes = read_bytes_part(archive, &sheet_path)?;
176                worksheets.push((sheet_entry.name.clone(), WorksheetXml::default()));
177                raw_sheet_xml.push(Some(raw_bytes));
178            };
179            known_paths.insert(sheet_path.clone());
180            worksheet_paths.push(sheet_path);
181        }
182
183        // Parse xl/styles.xml
184        let stylesheet: StyleSheet = read_xml_part(archive, "xl/styles.xml")?;
185        known_paths.insert("xl/styles.xml".to_string());
186
187        // Parse xl/sharedStrings.xml (optional -- may not exist for workbooks with no strings)
188        let shared_strings: Sst =
189            read_xml_part(archive, "xl/sharedStrings.xml").unwrap_or_default();
190        known_paths.insert("xl/sharedStrings.xml".to_string());
191
192        let sst_runtime = SharedStringTable::from_sst(shared_strings);
193
194        // Parse xl/theme/theme1.xml (optional -- preserved as raw bytes for round-trip).
195        let (theme_xml, theme_colors) = match read_bytes_part(archive, "xl/theme/theme1.xml") {
196            Ok(bytes) => {
197                let colors = sheetkit_xml::theme::parse_theme_colors(&bytes);
198                (Some(bytes), colors)
199            }
200            Err(_) => (None, crate::theme::default_theme_colors()),
201        };
202        known_paths.insert("xl/theme/theme1.xml".to_string());
203
204        // Parse per-sheet worksheet relationship files (optional).
205        let mut worksheet_rels: HashMap<usize, Relationships> = HashMap::with_capacity(sheet_count);
206        for (i, sheet_path) in worksheet_paths.iter().enumerate() {
207            let rels_path = relationship_part_path(sheet_path);
208            if let Ok(rels) = read_xml_part::<Relationships, _>(archive, &rels_path) {
209                worksheet_rels.insert(i, rels);
210                known_paths.insert(rels_path);
211            }
212        }
213
214        // Parse comments, VML drawings, drawings, drawing rels, charts, and images.
215        let mut sheet_comments: Vec<Option<Comments>> = vec![None; worksheets.len()];
216        let mut sheet_vml: Vec<Option<Vec<u8>>> = vec![None; worksheets.len()];
217        let mut drawings: Vec<(String, WsDr)> = Vec::new();
218        let mut worksheet_drawings: HashMap<usize, usize> = HashMap::new();
219        let mut drawing_path_to_idx: HashMap<String, usize> = HashMap::new();
220
221        for (sheet_idx, sheet_path) in worksheet_paths.iter().enumerate() {
222            let Some(rels) = worksheet_rels.get(&sheet_idx) else {
223                continue;
224            };
225
226            if let Some(comment_rel) = rels
227                .relationships
228                .iter()
229                .find(|r| r.rel_type == rel_types::COMMENTS)
230            {
231                let comment_path = resolve_relationship_target(sheet_path, &comment_rel.target);
232                if let Ok(comments) = read_xml_part::<Comments, _>(archive, &comment_path) {
233                    sheet_comments[sheet_idx] = Some(comments);
234                    known_paths.insert(comment_path);
235                }
236            }
237
238            if let Some(vml_rel) = rels
239                .relationships
240                .iter()
241                .find(|r| r.rel_type == rel_types::VML_DRAWING)
242            {
243                let vml_path = resolve_relationship_target(sheet_path, &vml_rel.target);
244                if let Ok(bytes) = read_bytes_part(archive, &vml_path) {
245                    sheet_vml[sheet_idx] = Some(bytes);
246                    known_paths.insert(vml_path);
247                }
248            }
249
250            if let Some(drawing_rel) = rels
251                .relationships
252                .iter()
253                .find(|r| r.rel_type == rel_types::DRAWING)
254            {
255                let drawing_path = resolve_relationship_target(sheet_path, &drawing_rel.target);
256                let drawing_idx = if let Some(idx) = drawing_path_to_idx.get(&drawing_path) {
257                    *idx
258                } else if let Ok(drawing) = read_xml_part::<WsDr, _>(archive, &drawing_path) {
259                    let idx = drawings.len();
260                    drawings.push((drawing_path.clone(), drawing));
261                    drawing_path_to_idx.insert(drawing_path.clone(), idx);
262                    known_paths.insert(drawing_path);
263                    idx
264                } else {
265                    continue;
266                };
267                worksheet_drawings.insert(sheet_idx, drawing_idx);
268            }
269        }
270
271        // Fallback: load drawing parts listed in content types even when they
272        // are not discoverable via worksheet rel parsing.
273        for ovr in &content_types.overrides {
274            if ovr.content_type != mime_types::DRAWING {
275                continue;
276            }
277            let drawing_path = ovr.part_name.trim_start_matches('/').to_string();
278            if drawing_path_to_idx.contains_key(&drawing_path) {
279                continue;
280            }
281            if let Ok(drawing) = read_xml_part::<WsDr, _>(archive, &drawing_path) {
282                let idx = drawings.len();
283                drawings.push((drawing_path.clone(), drawing));
284                known_paths.insert(drawing_path.clone());
285                drawing_path_to_idx.insert(drawing_path, idx);
286            }
287        }
288
289        let mut drawing_rels: HashMap<usize, Relationships> = HashMap::new();
290        let mut charts: Vec<(String, ChartSpace)> = Vec::new();
291        let mut raw_charts: Vec<(String, Vec<u8>)> = Vec::new();
292        let mut images: Vec<(String, Vec<u8>)> = Vec::new();
293        let mut seen_chart_paths: HashSet<String> = HashSet::new();
294        let mut seen_image_paths: HashSet<String> = HashSet::new();
295
296        for (drawing_idx, (drawing_path, _)) in drawings.iter().enumerate() {
297            let drawing_rels_path = relationship_part_path(drawing_path);
298            let Ok(rels) = read_xml_part::<Relationships, _>(archive, &drawing_rels_path) else {
299                continue;
300            };
301            known_paths.insert(drawing_rels_path);
302
303            for rel in &rels.relationships {
304                if rel.rel_type == rel_types::CHART {
305                    let chart_path = resolve_relationship_target(drawing_path, &rel.target);
306                    if seen_chart_paths.insert(chart_path.clone()) {
307                        match read_xml_part::<ChartSpace, _>(archive, &chart_path) {
308                            Ok(chart) => {
309                                known_paths.insert(chart_path.clone());
310                                charts.push((chart_path, chart));
311                            }
312                            Err(_) => {
313                                if let Ok(bytes) = read_bytes_part(archive, &chart_path) {
314                                    known_paths.insert(chart_path.clone());
315                                    raw_charts.push((chart_path, bytes));
316                                }
317                            }
318                        }
319                    }
320                } else if rel.rel_type == rel_types::IMAGE {
321                    let image_path = resolve_relationship_target(drawing_path, &rel.target);
322                    if seen_image_paths.insert(image_path.clone()) {
323                        if let Ok(bytes) = read_bytes_part(archive, &image_path) {
324                            known_paths.insert(image_path.clone());
325                            images.push((image_path, bytes));
326                        }
327                    }
328                }
329            }
330
331            drawing_rels.insert(drawing_idx, rels);
332        }
333
334        // Fallback: load chart parts listed in content types even when no
335        // drawing relationship was read.
336        for ovr in &content_types.overrides {
337            if ovr.content_type != mime_types::CHART {
338                continue;
339            }
340            let chart_path = ovr.part_name.trim_start_matches('/').to_string();
341            if seen_chart_paths.insert(chart_path.clone()) {
342                match read_xml_part::<ChartSpace, _>(archive, &chart_path) {
343                    Ok(chart) => {
344                        known_paths.insert(chart_path.clone());
345                        charts.push((chart_path, chart));
346                    }
347                    Err(_) => {
348                        if let Ok(bytes) = read_bytes_part(archive, &chart_path) {
349                            known_paths.insert(chart_path.clone());
350                            raw_charts.push((chart_path, bytes));
351                        }
352                    }
353                }
354            }
355        }
356
357        // Parse docProps/core.xml (optional - uses manual XML parsing)
358        let core_properties = read_string_part(archive, "docProps/core.xml")
359            .ok()
360            .and_then(|xml_str| {
361                sheetkit_xml::doc_props::deserialize_core_properties(&xml_str).ok()
362            });
363        known_paths.insert("docProps/core.xml".to_string());
364
365        // Parse docProps/app.xml (optional - uses serde)
366        let app_properties: Option<sheetkit_xml::doc_props::ExtendedProperties> =
367            read_xml_part(archive, "docProps/app.xml").ok();
368        known_paths.insert("docProps/app.xml".to_string());
369
370        // Parse docProps/custom.xml (optional - uses manual XML parsing)
371        let custom_properties = read_string_part(archive, "docProps/custom.xml")
372            .ok()
373            .and_then(|xml_str| {
374                sheetkit_xml::doc_props::deserialize_custom_properties(&xml_str).ok()
375            });
376        known_paths.insert("docProps/custom.xml".to_string());
377
378        // Parse pivot cache definitions, pivot tables, and pivot cache records.
379        let mut pivot_cache_defs = Vec::new();
380        let mut pivot_tables = Vec::new();
381        let mut pivot_cache_records = Vec::new();
382        for ovr in &content_types.overrides {
383            let path = ovr.part_name.trim_start_matches('/');
384            if ovr.content_type == mime_types::PIVOT_CACHE_DEFINITION {
385                if let Ok(pcd) = read_xml_part::<sheetkit_xml::pivot_cache::PivotCacheDefinition, _>(
386                    archive, path,
387                ) {
388                    known_paths.insert(path.to_string());
389                    pivot_cache_defs.push((path.to_string(), pcd));
390                }
391            } else if ovr.content_type == mime_types::PIVOT_TABLE {
392                if let Ok(pt) = read_xml_part::<sheetkit_xml::pivot_table::PivotTableDefinition, _>(
393                    archive, path,
394                ) {
395                    known_paths.insert(path.to_string());
396                    pivot_tables.push((path.to_string(), pt));
397                }
398            } else if ovr.content_type == mime_types::PIVOT_CACHE_RECORDS {
399                if let Ok(pcr) =
400                    read_xml_part::<sheetkit_xml::pivot_cache::PivotCacheRecords, _>(archive, path)
401                {
402                    known_paths.insert(path.to_string());
403                    pivot_cache_records.push((path.to_string(), pcr));
404                }
405            }
406        }
407
408        // Parse slicer definitions and slicer cache definitions.
409        let mut slicer_defs = Vec::new();
410        let mut slicer_caches = Vec::new();
411        for ovr in &content_types.overrides {
412            let path = ovr.part_name.trim_start_matches('/');
413            if ovr.content_type == mime_types::SLICER {
414                if let Ok(sd) =
415                    read_xml_part::<sheetkit_xml::slicer::SlicerDefinitions, _>(archive, path)
416                {
417                    slicer_defs.push((path.to_string(), sd));
418                }
419            } else if ovr.content_type == mime_types::SLICER_CACHE {
420                if let Ok(raw) = read_string_part(archive, path) {
421                    if let Some(scd) = sheetkit_xml::slicer::parse_slicer_cache(&raw) {
422                        slicer_caches.push((path.to_string(), scd));
423                    }
424                }
425            }
426        }
427
428        // Parse threaded comments per-sheet and the workbook-level person list.
429        let mut sheet_threaded_comments: Vec<
430            Option<sheetkit_xml::threaded_comment::ThreadedComments>,
431        > = vec![None; worksheets.len()];
432        for (sheet_idx, sheet_path) in worksheet_paths.iter().enumerate() {
433            let Some(rels) = worksheet_rels.get(&sheet_idx) else {
434                continue;
435            };
436            if let Some(tc_rel) = rels
437                .relationships
438                .iter()
439                .find(|r| r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_THREADED_COMMENT)
440            {
441                let tc_path = resolve_relationship_target(sheet_path, &tc_rel.target);
442                if let Ok(tc) = read_xml_part::<sheetkit_xml::threaded_comment::ThreadedComments, _>(
443                    archive, &tc_path,
444                ) {
445                    sheet_threaded_comments[sheet_idx] = Some(tc);
446                    known_paths.insert(tc_path);
447                }
448            }
449        }
450
451        // Parse person list (workbook-level).
452        let person_list: sheetkit_xml::threaded_comment::PersonList = {
453            let mut found = None;
454            // Check workbook rels for person relationship.
455            if let Some(person_rel) = workbook_rels
456                .relationships
457                .iter()
458                .find(|r| r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_PERSON)
459            {
460                let person_path =
461                    resolve_relationship_target("xl/workbook.xml", &person_rel.target);
462                if let Ok(pl) = read_xml_part::<sheetkit_xml::threaded_comment::PersonList, _>(
463                    archive,
464                    &person_path,
465                ) {
466                    known_paths.insert(person_path);
467                    found = Some(pl);
468                }
469            }
470            // Fallback: try the standard path.
471            if found.is_none() {
472                if let Ok(pl) = read_xml_part::<sheetkit_xml::threaded_comment::PersonList, _>(
473                    archive,
474                    "xl/persons/person.xml",
475                ) {
476                    known_paths.insert("xl/persons/person.xml".to_string());
477                    found = Some(pl);
478                }
479            }
480            found.unwrap_or_default()
481        };
482
483        // Parse sparklines from worksheet extension lists.
484        let mut sheet_sparklines: Vec<Vec<crate::sparkline::SparklineConfig>> =
485            vec![vec![]; worksheets.len()];
486        for (i, ws_path) in worksheet_paths.iter().enumerate() {
487            if let Ok(raw) = read_string_part(archive, ws_path) {
488                let parsed = parse_sparklines_from_xml(&raw);
489                if !parsed.is_empty() {
490                    sheet_sparklines[i] = parsed;
491                }
492            }
493        }
494
495        // Load VBA project binary blob if present (macro-enabled files).
496        let vba_blob = read_bytes_part(archive, "xl/vbaProject.bin").ok();
497        if vba_blob.is_some() {
498            known_paths.insert("xl/vbaProject.bin".to_string());
499        }
500
501        // Parse table parts referenced from worksheet relationships.
502        let mut tables: Vec<(String, sheetkit_xml::table::TableXml, usize)> = Vec::new();
503        for (sheet_idx, sheet_path) in worksheet_paths.iter().enumerate() {
504            let Some(rels) = worksheet_rels.get(&sheet_idx) else {
505                continue;
506            };
507            for rel in &rels.relationships {
508                if rel.rel_type != rel_types::TABLE {
509                    continue;
510                }
511                let table_path = resolve_relationship_target(sheet_path, &rel.target);
512                if let Ok(table_xml) =
513                    read_xml_part::<sheetkit_xml::table::TableXml, _>(archive, &table_path)
514                {
515                    known_paths.insert(table_path.clone());
516                    tables.push((table_path, table_xml, sheet_idx));
517                }
518            }
519        }
520        // Fallback: load table parts from content type overrides if not found via rels.
521        for ovr in &content_types.overrides {
522            if ovr.content_type != mime_types::TABLE {
523                continue;
524            }
525            let table_path = ovr.part_name.trim_start_matches('/').to_string();
526            if tables.iter().any(|(p, _, _)| p == &table_path) {
527                continue;
528            }
529            if let Ok(table_xml) =
530                read_xml_part::<sheetkit_xml::table::TableXml, _>(archive, &table_path)
531            {
532                known_paths.insert(table_path.clone());
533                tables.push((table_path, table_xml, 0));
534            }
535        }
536
537        // Parse form controls from VML drawing bytes.
538        let sheet_form_controls: Vec<Vec<crate::control::FormControlConfig>> =
539            vec![vec![]; worksheets.len()];
540
541        // Build sheet name -> index lookup.
542        let mut sheet_name_index = HashMap::with_capacity(worksheets.len());
543        for (i, (name, _)) in worksheets.iter().enumerate() {
544            sheet_name_index.insert(name.clone(), i);
545        }
546
547        // Collect all ZIP entries not explicitly handled as unknown parts.
548        let mut unknown_parts: Vec<(String, Vec<u8>)> = Vec::new();
549        for i in 0..archive.len() {
550            let Ok(entry) = archive.by_index(i) else {
551                continue;
552            };
553            let name = entry.name().to_string();
554            drop(entry);
555            if !known_paths.contains(&name) {
556                if let Ok(bytes) = read_bytes_part(archive, &name) {
557                    unknown_parts.push((name, bytes));
558                }
559            }
560        }
561
562        // Populate cached column numbers on all cells, apply row limit, and
563        // ensure sorted order for binary search correctness.
564        for (_name, ws) in &mut worksheets {
565            // Ensure rows are sorted by row number (some writers output unsorted data).
566            ws.sheet_data.rows.sort_unstable_by_key(|r| r.r);
567
568            // Apply sheet_rows limit: keep only the first N rows.
569            if let Some(max_rows) = options.sheet_rows {
570                ws.sheet_data.rows.truncate(max_rows as usize);
571            }
572
573            for row in &mut ws.sheet_data.rows {
574                for cell in &mut row.cells {
575                    cell.col = fast_col_number(cell.r.as_str());
576                }
577                // Ensure cells within a row are sorted by column number.
578                row.cells.sort_unstable_by_key(|c| c.col);
579            }
580        }
581
582        Ok(Self {
583            format,
584            content_types,
585            package_rels,
586            workbook_xml,
587            workbook_rels,
588            worksheets,
589            stylesheet,
590            sst_runtime,
591            sheet_comments,
592            charts,
593            raw_charts,
594            drawings,
595            images,
596            worksheet_drawings,
597            worksheet_rels,
598            drawing_rels,
599            core_properties,
600            app_properties,
601            custom_properties,
602            pivot_tables,
603            pivot_cache_defs,
604            pivot_cache_records,
605            theme_xml,
606            theme_colors,
607            sheet_name_index,
608            sheet_sparklines,
609            sheet_vml,
610            unknown_parts,
611            vba_blob,
612            tables,
613            raw_sheet_xml,
614            slicer_defs,
615            slicer_caches,
616            sheet_threaded_comments,
617            person_list,
618            sheet_form_controls,
619        })
620    }
621
622    /// Save the workbook to a file at the given path.
623    ///
624    /// The target format is inferred from the file extension. Supported
625    /// extensions are `.xlsx`, `.xlsm`, `.xltx`, `.xltm`, and `.xlam`.
626    /// An unsupported extension returns [`Error::UnsupportedFileExtension`].
627    ///
628    /// The inferred format overrides the workbook's stored format so that
629    /// the content type in the output always matches the extension.
630    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
631        let path = path.as_ref();
632        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
633        let target_format = WorkbookFormat::from_extension(ext)
634            .ok_or_else(|| Error::UnsupportedFileExtension(ext.to_string()))?;
635
636        let file = std::fs::File::create(path)?;
637        let mut zip = zip::ZipWriter::new(file);
638        let options = SimpleFileOptions::default()
639            .compression_method(CompressionMethod::Deflated)
640            .compression_level(Some(1));
641        self.write_zip_contents(&mut zip, options, Some(target_format))?;
642        zip.finish().map_err(|e| Error::Zip(e.to_string()))?;
643        Ok(())
644    }
645
646    /// Serialize the workbook to an in-memory buffer using the stored format.
647    pub fn save_to_buffer(&self) -> Result<Vec<u8>> {
648        // Estimate compressed output size to reduce reallocations.
649        let estimated = self.worksheets.len() * 4000
650            + self.sst_runtime.len() * 60
651            + self.images.iter().map(|(_, d)| d.len()).sum::<usize>()
652            + 32_000;
653        let mut buf = Vec::with_capacity(estimated);
654        {
655            let cursor = std::io::Cursor::new(&mut buf);
656            let mut zip = zip::ZipWriter::new(cursor);
657            let options =
658                SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
659            self.write_zip_contents(&mut zip, options, None)?;
660            zip.finish().map_err(|e| Error::Zip(e.to_string()))?;
661        }
662        Ok(buf)
663    }
664
665    /// Open a workbook from an in-memory `.xlsx` buffer.
666    pub fn open_from_buffer(data: &[u8]) -> Result<Self> {
667        Self::open_from_buffer_with_options(data, &OpenOptions::default())
668    }
669
670    /// Open a workbook from an in-memory buffer with custom parsing options.
671    pub fn open_from_buffer_with_options(data: &[u8], options: &OpenOptions) -> Result<Self> {
672        // Detect encrypted files (CFB container)
673        #[cfg(feature = "encryption")]
674        if data.len() >= 8 {
675            if let Ok(crate::crypt::ContainerFormat::Cfb) =
676                crate::crypt::detect_container_format(data)
677            {
678                return Err(Error::FileEncrypted);
679            }
680        }
681
682        let cursor = std::io::Cursor::new(data);
683        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Zip(e.to_string()))?;
684        Self::from_archive(&mut archive, options)
685    }
686
687    /// Open an encrypted `.xlsx` file using a password.
688    ///
689    /// The file must be in OLE/CFB container format. Supports both Standard
690    /// Encryption (Office 2007, AES-128-ECB) and Agile Encryption (Office
691    /// 2010+, AES-256-CBC).
692    #[cfg(feature = "encryption")]
693    pub fn open_with_password<P: AsRef<Path>>(path: P, password: &str) -> Result<Self> {
694        let data = std::fs::read(path.as_ref())?;
695        let decrypted_zip = crate::crypt::decrypt_xlsx(&data, password)?;
696        let cursor = std::io::Cursor::new(decrypted_zip);
697        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Zip(e.to_string()))?;
698        Self::from_archive(&mut archive, &OpenOptions::default())
699    }
700
701    /// Save the workbook as an encrypted `.xlsx` file using Agile Encryption
702    /// (AES-256-CBC + SHA-512, 100K iterations).
703    #[cfg(feature = "encryption")]
704    pub fn save_with_password<P: AsRef<Path>>(&self, path: P, password: &str) -> Result<()> {
705        // First, serialize to an in-memory ZIP buffer
706        let mut zip_buf = Vec::new();
707        {
708            let cursor = std::io::Cursor::new(&mut zip_buf);
709            let mut zip = zip::ZipWriter::new(cursor);
710            let options =
711                SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
712            self.write_zip_contents(&mut zip, options, None)?;
713            zip.finish().map_err(|e| Error::Zip(e.to_string()))?;
714        }
715
716        // Encrypt and write to CFB container
717        let cfb_data = crate::crypt::encrypt_xlsx(&zip_buf, password)?;
718        std::fs::write(path.as_ref(), &cfb_data)?;
719        Ok(())
720    }
721
722    /// Write all workbook parts into the given ZIP writer.
723    ///
724    /// When `format_override` is `Some`, that format is used for the workbook
725    /// content type instead of the stored `self.format`. This allows `save()`
726    /// to infer the format from the file extension without mutating `self`.
727    fn write_zip_contents<W: std::io::Write + std::io::Seek>(
728        &self,
729        zip: &mut zip::ZipWriter<W>,
730        options: SimpleFileOptions,
731        format_override: Option<WorkbookFormat>,
732    ) -> Result<()> {
733        let effective_format = format_override.unwrap_or(self.format);
734        let mut content_types = self.content_types.clone();
735
736        // Ensure the workbook override content type matches the effective format.
737        if let Some(wb_override) = content_types
738            .overrides
739            .iter_mut()
740            .find(|o| o.part_name == "/xl/workbook.xml")
741        {
742            wb_override.content_type = effective_format.content_type().to_string();
743        }
744
745        // Ensure VBA project content type override and workbook relationship are
746        // present when a VBA blob exists, and absent when it does not.
747        let mut workbook_rels = self.workbook_rels.clone();
748        if self.vba_blob.is_some() {
749            let vba_part_name = "/xl/vbaProject.bin";
750            if !content_types
751                .overrides
752                .iter()
753                .any(|o| o.part_name == vba_part_name)
754            {
755                content_types.overrides.push(ContentTypeOverride {
756                    part_name: vba_part_name.to_string(),
757                    content_type: VBA_PROJECT_CONTENT_TYPE.to_string(),
758                });
759            }
760            if !content_types.defaults.iter().any(|d| d.extension == "bin") {
761                content_types.defaults.push(ContentTypeDefault {
762                    extension: "bin".to_string(),
763                    content_type: VBA_PROJECT_CONTENT_TYPE.to_string(),
764                });
765            }
766            if !workbook_rels
767                .relationships
768                .iter()
769                .any(|r| r.rel_type == VBA_PROJECT_REL_TYPE)
770            {
771                let rid = crate::sheet::next_rid(&workbook_rels.relationships);
772                workbook_rels.relationships.push(Relationship {
773                    id: rid,
774                    rel_type: VBA_PROJECT_REL_TYPE.to_string(),
775                    target: "vbaProject.bin".to_string(),
776                    target_mode: None,
777                });
778            }
779        } else {
780            content_types
781                .overrides
782                .retain(|o| o.content_type != VBA_PROJECT_CONTENT_TYPE);
783            workbook_rels
784                .relationships
785                .retain(|r| r.rel_type != VBA_PROJECT_REL_TYPE);
786        }
787
788        let mut worksheet_rels = self.worksheet_rels.clone();
789
790        // Synchronize comment/form-control VML parts with worksheet relationships/content types.
791        // Per-sheet VML bytes to write: (sheet_idx, zip_path, bytes).
792        let mut vml_parts_to_write: Vec<(usize, String, Vec<u8>)> = Vec::new();
793        // Per-sheet legacy drawing relationship IDs for worksheet XML serialization.
794        let mut legacy_drawing_rids: HashMap<usize, String> = HashMap::new();
795
796        // Ensure the vml extension default content type is present if any VML exists.
797        let mut has_any_vml = false;
798
799        for sheet_idx in 0..self.worksheets.len() {
800            let has_comments = self
801                .sheet_comments
802                .get(sheet_idx)
803                .and_then(|c| c.as_ref())
804                .is_some();
805            let has_form_controls = self
806                .sheet_form_controls
807                .get(sheet_idx)
808                .map(|v| !v.is_empty())
809                .unwrap_or(false);
810            let has_preserved_vml = self
811                .sheet_vml
812                .get(sheet_idx)
813                .and_then(|v| v.as_ref())
814                .is_some();
815
816            if let Some(rels) = worksheet_rels.get_mut(&sheet_idx) {
817                rels.relationships
818                    .retain(|r| r.rel_type != rel_types::COMMENTS);
819                rels.relationships
820                    .retain(|r| r.rel_type != rel_types::VML_DRAWING);
821            }
822
823            let needs_vml = has_comments || has_form_controls || has_preserved_vml;
824            if !needs_vml && !has_comments {
825                continue;
826            }
827
828            if has_comments {
829                let comment_path = format!("xl/comments{}.xml", sheet_idx + 1);
830                let part_name = format!("/{}", comment_path);
831                if !content_types
832                    .overrides
833                    .iter()
834                    .any(|o| o.part_name == part_name && o.content_type == mime_types::COMMENTS)
835                {
836                    content_types.overrides.push(ContentTypeOverride {
837                        part_name,
838                        content_type: mime_types::COMMENTS.to_string(),
839                    });
840                }
841
842                let sheet_path = self.sheet_part_path(sheet_idx);
843                let target = relative_relationship_target(&sheet_path, &comment_path);
844                let rels = worksheet_rels
845                    .entry(sheet_idx)
846                    .or_insert_with(default_relationships);
847                let rid = crate::sheet::next_rid(&rels.relationships);
848                rels.relationships.push(Relationship {
849                    id: rid,
850                    rel_type: rel_types::COMMENTS.to_string(),
851                    target,
852                    target_mode: None,
853                });
854            }
855
856            if !needs_vml {
857                continue;
858            }
859
860            // Build VML bytes combining comments and form controls.
861            let vml_path = format!("xl/drawings/vmlDrawing{}.vml", sheet_idx + 1);
862            let vml_bytes = if has_comments && has_form_controls {
863                // Both comments and form controls: start with comment VML, then append controls.
864                let comment_vml =
865                    if let Some(bytes) = self.sheet_vml.get(sheet_idx).and_then(|v| v.as_ref()) {
866                        bytes.clone()
867                    } else if let Some(Some(comments)) = self.sheet_comments.get(sheet_idx) {
868                        let cells: Vec<&str> = comments
869                            .comment_list
870                            .comments
871                            .iter()
872                            .map(|c| c.r#ref.as_str())
873                            .collect();
874                        crate::vml::build_vml_drawing(&cells).into_bytes()
875                    } else {
876                        continue;
877                    };
878                let shape_count = crate::control::count_vml_shapes(&comment_vml);
879                let start_id = 1025 + shape_count;
880                let form_controls = &self.sheet_form_controls[sheet_idx];
881                crate::control::merge_vml_controls(&comment_vml, form_controls, start_id)
882            } else if has_comments {
883                if let Some(bytes) = self.sheet_vml.get(sheet_idx).and_then(|v| v.as_ref()) {
884                    bytes.clone()
885                } else if let Some(Some(comments)) = self.sheet_comments.get(sheet_idx) {
886                    let cells: Vec<&str> = comments
887                        .comment_list
888                        .comments
889                        .iter()
890                        .map(|c| c.r#ref.as_str())
891                        .collect();
892                    crate::vml::build_vml_drawing(&cells).into_bytes()
893                } else {
894                    continue;
895                }
896            } else if has_form_controls {
897                // Hydrated form controls only (no comments).
898                let form_controls = &self.sheet_form_controls[sheet_idx];
899                crate::control::build_form_control_vml(form_controls, 1025).into_bytes()
900            } else if let Some(Some(vml)) = self.sheet_vml.get(sheet_idx) {
901                // Preserved VML bytes only (controls not hydrated, no comments).
902                vml.clone()
903            } else {
904                continue;
905            };
906
907            let vml_part_name = format!("/{}", vml_path);
908            if !content_types
909                .overrides
910                .iter()
911                .any(|o| o.part_name == vml_part_name && o.content_type == mime_types::VML_DRAWING)
912            {
913                content_types.overrides.push(ContentTypeOverride {
914                    part_name: vml_part_name,
915                    content_type: mime_types::VML_DRAWING.to_string(),
916                });
917            }
918
919            let sheet_path = self.sheet_part_path(sheet_idx);
920            let rels = worksheet_rels
921                .entry(sheet_idx)
922                .or_insert_with(default_relationships);
923            let vml_target = relative_relationship_target(&sheet_path, &vml_path);
924            let vml_rid = crate::sheet::next_rid(&rels.relationships);
925            rels.relationships.push(Relationship {
926                id: vml_rid.clone(),
927                rel_type: rel_types::VML_DRAWING.to_string(),
928                target: vml_target,
929                target_mode: None,
930            });
931
932            legacy_drawing_rids.insert(sheet_idx, vml_rid);
933            vml_parts_to_write.push((sheet_idx, vml_path, vml_bytes));
934            has_any_vml = true;
935        }
936
937        // Add vml extension default content type if needed.
938        if has_any_vml && !content_types.defaults.iter().any(|d| d.extension == "vml") {
939            content_types.defaults.push(ContentTypeDefault {
940                extension: "vml".to_string(),
941                content_type: mime_types::VML_DRAWING.to_string(),
942            });
943        }
944
945        // Synchronize table parts with worksheet relationships and content types.
946        // Also build tableParts references for each worksheet.
947        let mut table_parts_by_sheet: HashMap<usize, Vec<String>> = HashMap::new();
948        for (sheet_idx, _) in self.worksheets.iter().enumerate() {
949            if let Some(rels) = worksheet_rels.get_mut(&sheet_idx) {
950                rels.relationships
951                    .retain(|r| r.rel_type != rel_types::TABLE);
952            }
953        }
954        content_types
955            .overrides
956            .retain(|o| o.content_type != mime_types::TABLE);
957        for (table_path, _table_xml, sheet_idx) in &self.tables {
958            let part_name = format!("/{table_path}");
959            content_types.overrides.push(ContentTypeOverride {
960                part_name,
961                content_type: mime_types::TABLE.to_string(),
962            });
963
964            let sheet_path = self.sheet_part_path(*sheet_idx);
965            let target = relative_relationship_target(&sheet_path, table_path);
966            let rels = worksheet_rels
967                .entry(*sheet_idx)
968                .or_insert_with(default_relationships);
969            let rid = crate::sheet::next_rid(&rels.relationships);
970            rels.relationships.push(Relationship {
971                id: rid.clone(),
972                rel_type: rel_types::TABLE.to_string(),
973                target,
974                target_mode: None,
975            });
976            table_parts_by_sheet
977                .entry(*sheet_idx)
978                .or_default()
979                .push(rid);
980        }
981
982        // Register threaded comment content types and relationships before writing.
983        let has_any_threaded = self.sheet_threaded_comments.iter().any(|tc| tc.is_some());
984        if has_any_threaded {
985            for (i, tc) in self.sheet_threaded_comments.iter().enumerate() {
986                if tc.is_some() {
987                    let tc_path = format!("xl/threadedComments/threadedComment{}.xml", i + 1);
988                    let tc_part_name = format!("/{tc_path}");
989                    if !content_types.overrides.iter().any(|o| {
990                        o.part_name == tc_part_name
991                            && o.content_type
992                                == sheetkit_xml::threaded_comment::THREADED_COMMENTS_CONTENT_TYPE
993                    }) {
994                        content_types.overrides.push(ContentTypeOverride {
995                            part_name: tc_part_name,
996                            content_type:
997                                sheetkit_xml::threaded_comment::THREADED_COMMENTS_CONTENT_TYPE
998                                    .to_string(),
999                        });
1000                    }
1001
1002                    let sheet_path = self.sheet_part_path(i);
1003                    let target = relative_relationship_target(&sheet_path, &tc_path);
1004                    let rels = worksheet_rels
1005                        .entry(i)
1006                        .or_insert_with(default_relationships);
1007                    if !rels.relationships.iter().any(|r| {
1008                        r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_THREADED_COMMENT
1009                    }) {
1010                        let rid = crate::sheet::next_rid(&rels.relationships);
1011                        rels.relationships.push(Relationship {
1012                            id: rid,
1013                            rel_type: sheetkit_xml::threaded_comment::REL_TYPE_THREADED_COMMENT
1014                                .to_string(),
1015                            target,
1016                            target_mode: None,
1017                        });
1018                    }
1019                }
1020            }
1021
1022            let person_part_name = "/xl/persons/person.xml";
1023            if !content_types.overrides.iter().any(|o| {
1024                o.part_name == person_part_name
1025                    && o.content_type == sheetkit_xml::threaded_comment::PERSON_LIST_CONTENT_TYPE
1026            }) {
1027                content_types.overrides.push(ContentTypeOverride {
1028                    part_name: person_part_name.to_string(),
1029                    content_type: sheetkit_xml::threaded_comment::PERSON_LIST_CONTENT_TYPE
1030                        .to_string(),
1031                });
1032            }
1033
1034            // Add person relationship to workbook_rels so Excel can discover the person list.
1035            if !workbook_rels
1036                .relationships
1037                .iter()
1038                .any(|r| r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_PERSON)
1039            {
1040                let rid = crate::sheet::next_rid(&workbook_rels.relationships);
1041                workbook_rels.relationships.push(Relationship {
1042                    id: rid,
1043                    rel_type: sheetkit_xml::threaded_comment::REL_TYPE_PERSON.to_string(),
1044                    target: "persons/person.xml".to_string(),
1045                    target_mode: None,
1046                });
1047            }
1048        }
1049
1050        // [Content_Types].xml
1051        write_xml_part(zip, "[Content_Types].xml", &content_types, options)?;
1052
1053        // _rels/.rels
1054        write_xml_part(zip, "_rels/.rels", &self.package_rels, options)?;
1055
1056        // xl/workbook.xml
1057        write_xml_part(zip, "xl/workbook.xml", &self.workbook_xml, options)?;
1058
1059        // xl/_rels/workbook.xml.rels
1060        write_xml_part(zip, "xl/_rels/workbook.xml.rels", &workbook_rels, options)?;
1061
1062        // xl/worksheets/sheet{N}.xml
1063        for (i, (_name, ws)) in self.worksheets.iter().enumerate() {
1064            let entry_name = self.sheet_part_path(i);
1065
1066            // If the sheet was not parsed (selective open), write raw bytes directly.
1067            if let Some(Some(raw_bytes)) = self.raw_sheet_xml.get(i) {
1068                zip.start_file(&entry_name, options)
1069                    .map_err(|e| Error::Zip(e.to_string()))?;
1070                zip.write_all(raw_bytes)?;
1071                continue;
1072            }
1073
1074            let empty_sparklines: Vec<crate::sparkline::SparklineConfig> = vec![];
1075            let sparklines = self.sheet_sparklines.get(i).unwrap_or(&empty_sparklines);
1076            let legacy_rid = legacy_drawing_rids.get(&i).map(|s| s.as_str());
1077            let sheet_table_rids = table_parts_by_sheet.get(&i);
1078            let stale_table_parts = sheet_table_rids.is_none() && ws.table_parts.is_some();
1079            let has_extras = legacy_rid.is_some()
1080                || !sparklines.is_empty()
1081                || sheet_table_rids.is_some()
1082                || stale_table_parts;
1083
1084            if !has_extras {
1085                write_xml_part(zip, &entry_name, ws, options)?;
1086            } else {
1087                let ws_to_serialize;
1088                let ws_ref = if let Some(rids) = sheet_table_rids {
1089                    ws_to_serialize = {
1090                        let mut cloned = ws.clone();
1091                        use sheetkit_xml::worksheet::{TablePart, TableParts};
1092                        cloned.table_parts = Some(TableParts {
1093                            count: Some(rids.len() as u32),
1094                            table_parts: rids
1095                                .iter()
1096                                .map(|rid| TablePart { r_id: rid.clone() })
1097                                .collect(),
1098                        });
1099                        cloned
1100                    };
1101                    &ws_to_serialize
1102                } else if stale_table_parts {
1103                    ws_to_serialize = {
1104                        let mut cloned = ws.clone();
1105                        cloned.table_parts = None;
1106                        cloned
1107                    };
1108                    &ws_to_serialize
1109                } else {
1110                    ws
1111                };
1112                let xml = serialize_worksheet_with_extras(ws_ref, sparklines, legacy_rid)?;
1113                zip.start_file(&entry_name, options)
1114                    .map_err(|e| Error::Zip(e.to_string()))?;
1115                zip.write_all(xml.as_bytes())?;
1116            }
1117        }
1118
1119        // xl/styles.xml
1120        write_xml_part(zip, "xl/styles.xml", &self.stylesheet, options)?;
1121
1122        // xl/sharedStrings.xml -- write from the runtime SST
1123        let sst_xml = self.sst_runtime.to_sst();
1124        write_xml_part(zip, "xl/sharedStrings.xml", &sst_xml, options)?;
1125
1126        // xl/comments{N}.xml -- write per-sheet comments
1127        for (i, comments) in self.sheet_comments.iter().enumerate() {
1128            if let Some(ref c) = comments {
1129                let entry_name = format!("xl/comments{}.xml", i + 1);
1130                write_xml_part(zip, &entry_name, c, options)?;
1131            }
1132        }
1133
1134        // xl/drawings/vmlDrawing{N}.vml -- write VML drawing parts
1135        for (_sheet_idx, vml_path, vml_bytes) in &vml_parts_to_write {
1136            zip.start_file(vml_path, options)
1137                .map_err(|e| Error::Zip(e.to_string()))?;
1138            zip.write_all(vml_bytes)?;
1139        }
1140
1141        // xl/drawings/drawing{N}.xml -- write drawing parts
1142        for (path, drawing) in &self.drawings {
1143            write_xml_part(zip, path, drawing, options)?;
1144        }
1145
1146        // xl/charts/chart{N}.xml -- write chart parts
1147        for (path, chart) in &self.charts {
1148            write_xml_part(zip, path, chart, options)?;
1149        }
1150        for (path, data) in &self.raw_charts {
1151            if self.charts.iter().any(|(p, _)| p == path) {
1152                continue;
1153            }
1154            zip.start_file(path, options)
1155                .map_err(|e| Error::Zip(e.to_string()))?;
1156            zip.write_all(data)?;
1157        }
1158
1159        // xl/media/image{N}.{ext} -- write image data
1160        for (path, data) in &self.images {
1161            zip.start_file(path, options)
1162                .map_err(|e| Error::Zip(e.to_string()))?;
1163            zip.write_all(data)?;
1164        }
1165
1166        // xl/worksheets/_rels/sheet{N}.xml.rels -- write worksheet relationships
1167        for (sheet_idx, rels) in &worksheet_rels {
1168            let sheet_path = self.sheet_part_path(*sheet_idx);
1169            let path = relationship_part_path(&sheet_path);
1170            write_xml_part(zip, &path, rels, options)?;
1171        }
1172
1173        // xl/drawings/_rels/drawing{N}.xml.rels -- write drawing relationships
1174        for (drawing_idx, rels) in &self.drawing_rels {
1175            if let Some((drawing_path, _)) = self.drawings.get(*drawing_idx) {
1176                let path = relationship_part_path(drawing_path);
1177                write_xml_part(zip, &path, rels, options)?;
1178            }
1179        }
1180
1181        // xl/pivotTables/pivotTable{N}.xml
1182        for (path, pt) in &self.pivot_tables {
1183            write_xml_part(zip, path, pt, options)?;
1184        }
1185
1186        // xl/pivotCache/pivotCacheDefinition{N}.xml
1187        for (path, pcd) in &self.pivot_cache_defs {
1188            write_xml_part(zip, path, pcd, options)?;
1189        }
1190
1191        // xl/pivotCache/pivotCacheRecords{N}.xml
1192        for (path, pcr) in &self.pivot_cache_records {
1193            write_xml_part(zip, path, pcr, options)?;
1194        }
1195
1196        // xl/tables/table{N}.xml
1197        for (path, table_xml, _sheet_idx) in &self.tables {
1198            write_xml_part(zip, path, table_xml, options)?;
1199        }
1200
1201        // xl/slicers/slicer{N}.xml
1202        for (path, sd) in &self.slicer_defs {
1203            write_xml_part(zip, path, sd, options)?;
1204        }
1205
1206        // xl/slicerCaches/slicerCache{N}.xml (manual serialization)
1207        for (path, scd) in &self.slicer_caches {
1208            let xml_str = format!(
1209                "{}\n{}",
1210                XML_DECLARATION,
1211                sheetkit_xml::slicer::serialize_slicer_cache(scd),
1212            );
1213            zip.start_file(path, options)
1214                .map_err(|e| Error::Zip(e.to_string()))?;
1215            zip.write_all(xml_str.as_bytes())?;
1216        }
1217
1218        // xl/theme/theme1.xml
1219        {
1220            let default_theme = crate::theme::default_theme_xml();
1221            let theme_bytes = self.theme_xml.as_deref().unwrap_or(&default_theme);
1222            zip.start_file("xl/theme/theme1.xml", options)
1223                .map_err(|e| Error::Zip(e.to_string()))?;
1224            zip.write_all(theme_bytes)?;
1225        }
1226
1227        // xl/vbaProject.bin -- write VBA blob if present
1228        if let Some(ref blob) = self.vba_blob {
1229            zip.start_file("xl/vbaProject.bin", options)
1230                .map_err(|e| Error::Zip(e.to_string()))?;
1231            zip.write_all(blob)?;
1232        }
1233
1234        // docProps/core.xml
1235        if let Some(ref props) = self.core_properties {
1236            let xml_str = sheetkit_xml::doc_props::serialize_core_properties(props);
1237            zip.start_file("docProps/core.xml", options)
1238                .map_err(|e| Error::Zip(e.to_string()))?;
1239            zip.write_all(xml_str.as_bytes())?;
1240        }
1241
1242        // docProps/app.xml
1243        if let Some(ref props) = self.app_properties {
1244            write_xml_part(zip, "docProps/app.xml", props, options)?;
1245        }
1246
1247        // docProps/custom.xml
1248        if let Some(ref props) = self.custom_properties {
1249            let xml_str = sheetkit_xml::doc_props::serialize_custom_properties(props);
1250            zip.start_file("docProps/custom.xml", options)
1251                .map_err(|e| Error::Zip(e.to_string()))?;
1252            zip.write_all(xml_str.as_bytes())?;
1253        }
1254
1255        // xl/threadedComments/threadedComment{N}.xml
1256        if has_any_threaded {
1257            for (i, tc) in self.sheet_threaded_comments.iter().enumerate() {
1258                if let Some(ref tc_data) = tc {
1259                    let tc_path = format!("xl/threadedComments/threadedComment{}.xml", i + 1);
1260                    write_xml_part(zip, &tc_path, tc_data, options)?;
1261                }
1262            }
1263            write_xml_part(zip, "xl/persons/person.xml", &self.person_list, options)?;
1264        }
1265
1266        // Write back unknown parts preserved from the original file.
1267        for (path, data) in &self.unknown_parts {
1268            zip.start_file(path, options)
1269                .map_err(|e| Error::Zip(e.to_string()))?;
1270            zip.write_all(data)?;
1271        }
1272
1273        Ok(())
1274    }
1275}
1276
1277impl Default for Workbook {
1278    fn default() -> Self {
1279        Self::new()
1280    }
1281}
1282
1283/// Serialize a value to XML with the standard XML declaration prepended.
1284pub(crate) fn serialize_xml<T: Serialize>(value: &T) -> Result<String> {
1285    let body = quick_xml::se::to_string(value).map_err(|e| Error::XmlParse(e.to_string()))?;
1286    let mut result = String::with_capacity(XML_DECLARATION.len() + 1 + body.len());
1287    result.push_str(XML_DECLARATION);
1288    result.push('\n');
1289    result.push_str(&body);
1290    Ok(result)
1291}
1292
1293/// Read a ZIP entry and deserialize it from XML.
1294pub(crate) fn read_xml_part<T: serde::de::DeserializeOwned, R: std::io::Read + std::io::Seek>(
1295    archive: &mut zip::ZipArchive<R>,
1296    name: &str,
1297) -> Result<T> {
1298    let mut entry = archive
1299        .by_name(name)
1300        .map_err(|e| Error::Zip(e.to_string()))?;
1301    let size_hint = entry.size() as usize;
1302    let mut content = String::with_capacity(size_hint);
1303    entry
1304        .read_to_string(&mut content)
1305        .map_err(|e| Error::Zip(e.to_string()))?;
1306    quick_xml::de::from_str(&content).map_err(|e| Error::XmlDeserialize(e.to_string()))
1307}
1308
1309/// Read a ZIP entry as a raw string (no serde deserialization).
1310pub(crate) fn read_string_part<R: std::io::Read + std::io::Seek>(
1311    archive: &mut zip::ZipArchive<R>,
1312    name: &str,
1313) -> Result<String> {
1314    let mut entry = archive
1315        .by_name(name)
1316        .map_err(|e| Error::Zip(e.to_string()))?;
1317    let size_hint = entry.size() as usize;
1318    let mut content = String::with_capacity(size_hint);
1319    entry
1320        .read_to_string(&mut content)
1321        .map_err(|e| Error::Zip(e.to_string()))?;
1322    Ok(content)
1323}
1324
1325/// Read a ZIP entry as raw bytes.
1326pub(crate) fn read_bytes_part<R: std::io::Read + std::io::Seek>(
1327    archive: &mut zip::ZipArchive<R>,
1328    name: &str,
1329) -> Result<Vec<u8>> {
1330    let mut entry = archive
1331        .by_name(name)
1332        .map_err(|e| Error::Zip(e.to_string()))?;
1333    let size_hint = entry.size() as usize;
1334    let mut content = Vec::with_capacity(size_hint);
1335    entry
1336        .read_to_end(&mut content)
1337        .map_err(|e| Error::Zip(e.to_string()))?;
1338    Ok(content)
1339}
1340
1341/// Serialize a worksheet with optional sparklines and legacy drawing injected
1342/// via string manipulation, avoiding a full WorksheetXml clone.
1343pub(crate) fn serialize_worksheet_with_extras(
1344    ws: &WorksheetXml,
1345    sparklines: &[crate::sparkline::SparklineConfig],
1346    legacy_drawing_rid: Option<&str>,
1347) -> Result<String> {
1348    let body = quick_xml::se::to_string(ws).map_err(|e| Error::XmlParse(e.to_string()))?;
1349
1350    let closing = "</worksheet>";
1351    let ext_xml = if sparklines.is_empty() {
1352        String::new()
1353    } else {
1354        build_sparkline_ext_xml(sparklines)
1355    };
1356    let legacy_xml = if let Some(rid) = legacy_drawing_rid {
1357        format!("<legacyDrawing r:id=\"{rid}\"/>")
1358    } else {
1359        String::new()
1360    };
1361
1362    if let Some(pos) = body.rfind(closing) {
1363        // If injecting a legacy drawing, strip any existing one from the serde output
1364        // to avoid duplicates (the original ws.legacy_drawing may already be set).
1365        let body_prefix = &body[..pos];
1366        let stripped;
1367        let prefix = if !legacy_xml.is_empty() {
1368            if let Some(ld_start) = body_prefix.find("<legacyDrawing ") {
1369                // Find the end of the self-closing element.
1370                let ld_end = body_prefix[ld_start..]
1371                    .find("/>")
1372                    .map(|e| ld_start + e + 2)
1373                    .unwrap_or(ld_start);
1374                stripped = format!("{}{}", &body_prefix[..ld_start], &body_prefix[ld_end..]);
1375                stripped.as_str()
1376            } else {
1377                body_prefix
1378            }
1379        } else {
1380            body_prefix
1381        };
1382
1383        let extra_len = ext_xml.len() + legacy_xml.len();
1384        let mut result = String::with_capacity(XML_DECLARATION.len() + 1 + body.len() + extra_len);
1385        result.push_str(XML_DECLARATION);
1386        result.push('\n');
1387        result.push_str(prefix);
1388        result.push_str(&legacy_xml);
1389        result.push_str(&ext_xml);
1390        result.push_str(closing);
1391        Ok(result)
1392    } else {
1393        Ok(format!("{XML_DECLARATION}\n{body}"))
1394    }
1395}
1396
1397/// Build the extLst XML block for sparklines using manual string construction.
1398pub(crate) fn build_sparkline_ext_xml(sparklines: &[crate::sparkline::SparklineConfig]) -> String {
1399    use std::fmt::Write;
1400    let mut xml = String::new();
1401    let _ = write!(
1402        xml,
1403        "<extLst>\
1404         <ext xmlns:x14=\"http://schemas.microsoft.com/office/spreadsheetml/2009/9/main\" \
1405         uri=\"{{05C60535-1F16-4fd2-B633-F4F36F0B64E0}}\">\
1406         <x14:sparklineGroups \
1407         xmlns:xm=\"http://schemas.microsoft.com/office/excel/2006/main\">"
1408    );
1409    for config in sparklines {
1410        let group = crate::sparkline::config_to_xml_group(config);
1411        let _ = write!(xml, "<x14:sparklineGroup");
1412        if let Some(ref t) = group.sparkline_type {
1413            let _ = write!(xml, " type=\"{t}\"");
1414        }
1415        if group.markers == Some(true) {
1416            let _ = write!(xml, " markers=\"1\"");
1417        }
1418        if group.high == Some(true) {
1419            let _ = write!(xml, " high=\"1\"");
1420        }
1421        if group.low == Some(true) {
1422            let _ = write!(xml, " low=\"1\"");
1423        }
1424        if group.first == Some(true) {
1425            let _ = write!(xml, " first=\"1\"");
1426        }
1427        if group.last == Some(true) {
1428            let _ = write!(xml, " last=\"1\"");
1429        }
1430        if group.negative == Some(true) {
1431            let _ = write!(xml, " negative=\"1\"");
1432        }
1433        if group.display_x_axis == Some(true) {
1434            let _ = write!(xml, " displayXAxis=\"1\"");
1435        }
1436        if let Some(w) = group.line_weight {
1437            let _ = write!(xml, " lineWeight=\"{w}\"");
1438        }
1439        let _ = write!(xml, "><x14:sparklines>");
1440        for sp in &group.sparklines.items {
1441            let _ = write!(
1442                xml,
1443                "<x14:sparkline><xm:f>{}</xm:f><xm:sqref>{}</xm:sqref></x14:sparkline>",
1444                sp.formula, sp.sqref
1445            );
1446        }
1447        let _ = write!(xml, "</x14:sparklines></x14:sparklineGroup>");
1448    }
1449    let _ = write!(xml, "</x14:sparklineGroups></ext></extLst>");
1450    xml
1451}
1452
1453/// Parse sparkline configurations from raw worksheet XML content.
1454pub(crate) fn parse_sparklines_from_xml(xml: &str) -> Vec<crate::sparkline::SparklineConfig> {
1455    use crate::sparkline::{SparklineConfig, SparklineType};
1456
1457    let mut sparklines = Vec::new();
1458
1459    // Find all sparklineGroup elements and parse their attributes and children.
1460    let mut search_from = 0;
1461    while let Some(group_start) = xml[search_from..].find("<x14:sparklineGroup") {
1462        let abs_start = search_from + group_start;
1463        let group_end_tag = "</x14:sparklineGroup>";
1464        let abs_end = match xml[abs_start..].find(group_end_tag) {
1465            Some(pos) => abs_start + pos + group_end_tag.len(),
1466            None => break,
1467        };
1468        let group_xml = &xml[abs_start..abs_end];
1469
1470        // Parse group-level attributes.
1471        let sparkline_type = extract_xml_attr(group_xml, "type")
1472            .and_then(|s| SparklineType::parse(&s))
1473            .unwrap_or_default();
1474        let markers = extract_xml_bool_attr(group_xml, "markers");
1475        let high_point = extract_xml_bool_attr(group_xml, "high");
1476        let low_point = extract_xml_bool_attr(group_xml, "low");
1477        let first_point = extract_xml_bool_attr(group_xml, "first");
1478        let last_point = extract_xml_bool_attr(group_xml, "last");
1479        let negative_points = extract_xml_bool_attr(group_xml, "negative");
1480        let show_axis = extract_xml_bool_attr(group_xml, "displayXAxis");
1481        let line_weight =
1482            extract_xml_attr(group_xml, "lineWeight").and_then(|s| s.parse::<f64>().ok());
1483
1484        // Parse individual sparkline entries within this group.
1485        let mut sp_from = 0;
1486        while let Some(sp_start) = group_xml[sp_from..].find("<x14:sparkline>") {
1487            let sp_abs = sp_from + sp_start;
1488            let sp_end_tag = "</x14:sparkline>";
1489            let sp_abs_end = match group_xml[sp_abs..].find(sp_end_tag) {
1490                Some(pos) => sp_abs + pos + sp_end_tag.len(),
1491                None => break,
1492            };
1493            let sp_xml = &group_xml[sp_abs..sp_abs_end];
1494
1495            let formula = extract_xml_element(sp_xml, "xm:f").unwrap_or_default();
1496            let sqref = extract_xml_element(sp_xml, "xm:sqref").unwrap_or_default();
1497
1498            if !formula.is_empty() && !sqref.is_empty() {
1499                sparklines.push(SparklineConfig {
1500                    data_range: formula,
1501                    location: sqref,
1502                    sparkline_type: sparkline_type.clone(),
1503                    markers,
1504                    high_point,
1505                    low_point,
1506                    first_point,
1507                    last_point,
1508                    negative_points,
1509                    show_axis,
1510                    line_weight,
1511                    style: None,
1512                });
1513            }
1514            sp_from = sp_abs_end;
1515        }
1516        search_from = abs_end;
1517    }
1518    sparklines
1519}
1520
1521/// Extract an XML attribute value from an element's opening tag.
1522///
1523/// Uses manual search to avoid allocating format strings for patterns.
1524pub(crate) fn extract_xml_attr(xml: &str, attr: &str) -> Option<String> {
1525    // Search for ` attr="` or ` attr='` without allocating pattern strings.
1526    for quote in ['"', '\''] {
1527        // Build the search target: " attr=" (space + attr name + = + quote)
1528        let haystack = xml.as_bytes();
1529        let attr_bytes = attr.as_bytes();
1530        let mut pos = 0;
1531        while pos + 1 + attr_bytes.len() + 2 <= haystack.len() {
1532            if haystack[pos] == b' '
1533                && haystack[pos + 1..pos + 1 + attr_bytes.len()] == *attr_bytes
1534                && haystack[pos + 1 + attr_bytes.len()] == b'='
1535                && haystack[pos + 1 + attr_bytes.len() + 1] == quote as u8
1536            {
1537                let val_start = pos + 1 + attr_bytes.len() + 2;
1538                if let Some(end) = xml[val_start..].find(quote) {
1539                    return Some(xml[val_start..val_start + end].to_string());
1540                }
1541            }
1542            pos += 1;
1543        }
1544    }
1545    None
1546}
1547
1548/// Extract a boolean attribute from an XML element (true for "1" or "true").
1549pub(crate) fn extract_xml_bool_attr(xml: &str, attr: &str) -> bool {
1550    extract_xml_attr(xml, attr)
1551        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1552        .unwrap_or(false)
1553}
1554
1555/// Extract the text content of an XML element like `<tag>content</tag>`.
1556pub(crate) fn extract_xml_element(xml: &str, tag: &str) -> Option<String> {
1557    let open = format!("<{tag}>");
1558    let close = format!("</{tag}>");
1559    let start = xml.find(&open)?;
1560    let content_start = start + open.len();
1561    let end = xml[content_start..].find(&close)?;
1562    Some(xml[content_start..content_start + end].to_string())
1563}
1564
1565/// Serialize a value to XML and write it as a ZIP entry.
1566pub(crate) fn write_xml_part<T: Serialize, W: std::io::Write + std::io::Seek>(
1567    zip: &mut zip::ZipWriter<W>,
1568    name: &str,
1569    value: &T,
1570    options: SimpleFileOptions,
1571) -> Result<()> {
1572    let xml = serialize_xml(value)?;
1573    zip.start_file(name, options)
1574        .map_err(|e| Error::Zip(e.to_string()))?;
1575    zip.write_all(xml.as_bytes())?;
1576    Ok(())
1577}
1578
1579/// Fast column number extraction from a cell reference string like "A1", "BC42".
1580///
1581/// Parses only the alphabetic prefix (column letters) and converts to a
1582/// 1-based column number. Much faster than [`cell_name_to_coordinates`] because
1583/// it skips row parsing and avoids error handling overhead.
1584fn fast_col_number(cell_ref: &str) -> u32 {
1585    let mut col: u32 = 0;
1586    for b in cell_ref.bytes() {
1587        if b.is_ascii_alphabetic() {
1588            col = col * 26 + (b.to_ascii_uppercase() - b'A') as u32 + 1;
1589        } else {
1590            break;
1591        }
1592    }
1593    col
1594}
1595
1596#[cfg(test)]
1597mod tests {
1598    use super::*;
1599    use tempfile::TempDir;
1600
1601    #[test]
1602    fn test_fast_col_number() {
1603        assert_eq!(fast_col_number("A1"), 1);
1604        assert_eq!(fast_col_number("B1"), 2);
1605        assert_eq!(fast_col_number("Z1"), 26);
1606        assert_eq!(fast_col_number("AA1"), 27);
1607        assert_eq!(fast_col_number("AZ1"), 52);
1608        assert_eq!(fast_col_number("BA1"), 53);
1609        assert_eq!(fast_col_number("XFD1"), 16384);
1610    }
1611
1612    #[test]
1613    fn test_extract_xml_attr() {
1614        let xml = r#"<tag type="column" markers="1" weight="2.5">"#;
1615        assert_eq!(extract_xml_attr(xml, "type"), Some("column".to_string()));
1616        assert_eq!(extract_xml_attr(xml, "markers"), Some("1".to_string()));
1617        assert_eq!(extract_xml_attr(xml, "weight"), Some("2.5".to_string()));
1618        assert_eq!(extract_xml_attr(xml, "missing"), None);
1619        // Single-quoted attributes
1620        let xml2 = "<tag name='hello'>";
1621        assert_eq!(extract_xml_attr(xml2, "name"), Some("hello".to_string()));
1622    }
1623
1624    #[test]
1625    fn test_extract_xml_bool_attr() {
1626        let xml = r#"<tag markers="1" hidden="0" visible="true">"#;
1627        assert!(extract_xml_bool_attr(xml, "markers"));
1628        assert!(!extract_xml_bool_attr(xml, "hidden"));
1629        assert!(extract_xml_bool_attr(xml, "visible"));
1630        assert!(!extract_xml_bool_attr(xml, "missing"));
1631    }
1632
1633    #[test]
1634    fn test_new_workbook_has_sheet1() {
1635        let wb = Workbook::new();
1636        assert_eq!(wb.sheet_names(), vec!["Sheet1"]);
1637    }
1638
1639    #[test]
1640    fn test_new_workbook_save_creates_file() {
1641        let dir = TempDir::new().unwrap();
1642        let path = dir.path().join("test.xlsx");
1643        let wb = Workbook::new();
1644        wb.save(&path).unwrap();
1645        assert!(path.exists());
1646    }
1647
1648    #[test]
1649    fn test_save_and_open_roundtrip() {
1650        let dir = TempDir::new().unwrap();
1651        let path = dir.path().join("roundtrip.xlsx");
1652
1653        let wb = Workbook::new();
1654        wb.save(&path).unwrap();
1655
1656        let wb2 = Workbook::open(&path).unwrap();
1657        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
1658    }
1659
1660    #[test]
1661    fn test_saved_file_is_valid_zip() {
1662        let dir = TempDir::new().unwrap();
1663        let path = dir.path().join("valid.xlsx");
1664        let wb = Workbook::new();
1665        wb.save(&path).unwrap();
1666
1667        // Verify it's a valid ZIP with expected entries
1668        let file = std::fs::File::open(&path).unwrap();
1669        let mut archive = zip::ZipArchive::new(file).unwrap();
1670
1671        let expected_files = [
1672            "[Content_Types].xml",
1673            "_rels/.rels",
1674            "xl/workbook.xml",
1675            "xl/_rels/workbook.xml.rels",
1676            "xl/worksheets/sheet1.xml",
1677            "xl/styles.xml",
1678            "xl/sharedStrings.xml",
1679        ];
1680
1681        for name in &expected_files {
1682            assert!(archive.by_name(name).is_ok(), "Missing ZIP entry: {}", name);
1683        }
1684    }
1685
1686    #[test]
1687    fn test_open_nonexistent_file_returns_error() {
1688        let result = Workbook::open("/nonexistent/path.xlsx");
1689        assert!(result.is_err());
1690    }
1691
1692    #[test]
1693    fn test_saved_xml_has_declarations() {
1694        let dir = TempDir::new().unwrap();
1695        let path = dir.path().join("decl.xlsx");
1696        let wb = Workbook::new();
1697        wb.save(&path).unwrap();
1698
1699        let file = std::fs::File::open(&path).unwrap();
1700        let mut archive = zip::ZipArchive::new(file).unwrap();
1701
1702        let mut content = String::new();
1703        std::io::Read::read_to_string(
1704            &mut archive.by_name("[Content_Types].xml").unwrap(),
1705            &mut content,
1706        )
1707        .unwrap();
1708        assert!(content.starts_with("<?xml"));
1709    }
1710
1711    #[test]
1712    fn test_default_trait() {
1713        let wb = Workbook::default();
1714        assert_eq!(wb.sheet_names(), vec!["Sheet1"]);
1715    }
1716
1717    #[test]
1718    fn test_serialize_xml_helper() {
1719        let ct = ContentTypes::default();
1720        let xml = serialize_xml(&ct).unwrap();
1721        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"));
1722        assert!(xml.contains("<Types"));
1723    }
1724
1725    #[test]
1726    fn test_save_to_buffer_and_open_from_buffer_roundtrip() {
1727        let mut wb = Workbook::new();
1728        wb.set_cell_value("Sheet1", "A1", CellValue::String("Hello".to_string()))
1729            .unwrap();
1730        wb.set_cell_value("Sheet1", "B2", CellValue::Number(42.0))
1731            .unwrap();
1732
1733        let buf = wb.save_to_buffer().unwrap();
1734        assert!(!buf.is_empty());
1735
1736        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
1737        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
1738        assert_eq!(
1739            wb2.get_cell_value("Sheet1", "A1").unwrap(),
1740            CellValue::String("Hello".to_string())
1741        );
1742        assert_eq!(
1743            wb2.get_cell_value("Sheet1", "B2").unwrap(),
1744            CellValue::Number(42.0)
1745        );
1746    }
1747
1748    #[test]
1749    fn test_save_to_buffer_produces_valid_zip() {
1750        let wb = Workbook::new();
1751        let buf = wb.save_to_buffer().unwrap();
1752
1753        let cursor = std::io::Cursor::new(buf);
1754        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1755
1756        let expected_files = [
1757            "[Content_Types].xml",
1758            "_rels/.rels",
1759            "xl/workbook.xml",
1760            "xl/_rels/workbook.xml.rels",
1761            "xl/worksheets/sheet1.xml",
1762            "xl/styles.xml",
1763            "xl/sharedStrings.xml",
1764        ];
1765
1766        for name in &expected_files {
1767            assert!(archive.by_name(name).is_ok(), "Missing ZIP entry: {}", name);
1768        }
1769    }
1770
1771    #[test]
1772    fn test_open_from_buffer_invalid_data() {
1773        let result = Workbook::open_from_buffer(b"not a zip file");
1774        assert!(result.is_err());
1775    }
1776
1777    #[cfg(feature = "encryption")]
1778    #[test]
1779    fn test_save_and_open_with_password_roundtrip() {
1780        let dir = TempDir::new().unwrap();
1781        let path = dir.path().join("encrypted.xlsx");
1782
1783        // Create a workbook with some data
1784        let mut wb = Workbook::new();
1785        wb.set_cell_value("Sheet1", "A1", CellValue::String("Hello".to_string()))
1786            .unwrap();
1787        wb.set_cell_value("Sheet1", "B2", CellValue::Number(42.0))
1788            .unwrap();
1789
1790        // Save with password
1791        wb.save_with_password(&path, "test123").unwrap();
1792
1793        // Verify it's a CFB file, not a ZIP
1794        let data = std::fs::read(&path).unwrap();
1795        assert_eq!(
1796            &data[..8],
1797            &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]
1798        );
1799
1800        // Open without password should fail
1801        let result = Workbook::open(&path);
1802        assert!(matches!(result, Err(Error::FileEncrypted)));
1803
1804        // Open with wrong password should fail
1805        let result = Workbook::open_with_password(&path, "wrong");
1806        assert!(matches!(result, Err(Error::IncorrectPassword)));
1807
1808        // Open with correct password should succeed
1809        let wb2 = Workbook::open_with_password(&path, "test123").unwrap();
1810        assert_eq!(
1811            wb2.get_cell_value("Sheet1", "A1").unwrap(),
1812            CellValue::String("Hello".to_string())
1813        );
1814        assert_eq!(
1815            wb2.get_cell_value("Sheet1", "B2").unwrap(),
1816            CellValue::Number(42.0)
1817        );
1818    }
1819
1820    /// Create a test xlsx buffer with extra custom ZIP entries that sheetkit
1821    /// does not natively handle.
1822    fn create_xlsx_with_custom_entries() -> Vec<u8> {
1823        let mut wb = Workbook::new();
1824        wb.set_cell_value("Sheet1", "A1", CellValue::String("hello".to_string()))
1825            .unwrap();
1826        let base_buf = wb.save_to_buffer().unwrap();
1827
1828        // Re-open the ZIP and inject custom entries.
1829        let cursor = std::io::Cursor::new(&base_buf);
1830        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1831        let mut out = Vec::new();
1832        {
1833            let out_cursor = std::io::Cursor::new(&mut out);
1834            let mut zip_writer = zip::ZipWriter::new(out_cursor);
1835            let options =
1836                SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
1837
1838            // Copy all existing entries.
1839            for i in 0..archive.len() {
1840                let mut entry = archive.by_index(i).unwrap();
1841                let name = entry.name().to_string();
1842                let mut data = Vec::new();
1843                std::io::Read::read_to_end(&mut entry, &mut data).unwrap();
1844                zip_writer.start_file(&name, options).unwrap();
1845                std::io::Write::write_all(&mut zip_writer, &data).unwrap();
1846            }
1847
1848            // Add custom entries that sheetkit does not handle.
1849            zip_writer
1850                .start_file("customXml/item1.xml", options)
1851                .unwrap();
1852            std::io::Write::write_all(&mut zip_writer, b"<custom>data1</custom>").unwrap();
1853
1854            zip_writer
1855                .start_file("customXml/itemProps1.xml", options)
1856                .unwrap();
1857            std::io::Write::write_all(
1858                &mut zip_writer,
1859                b"<ds:datastoreItem xmlns:ds=\"http://schemas.openxmlformats.org/officeDocument/2006/customXml\"/>",
1860            )
1861            .unwrap();
1862
1863            zip_writer
1864                .start_file("xl/printerSettings/printerSettings1.bin", options)
1865                .unwrap();
1866            std::io::Write::write_all(&mut zip_writer, b"\x00\x01\x02\x03PRINTER").unwrap();
1867
1868            zip_writer.finish().unwrap();
1869        }
1870        out
1871    }
1872
1873    #[test]
1874    fn test_unknown_zip_entries_preserved_on_roundtrip() {
1875        let buf = create_xlsx_with_custom_entries();
1876
1877        // Open, verify the data is still accessible.
1878        let wb = Workbook::open_from_buffer(&buf).unwrap();
1879        assert_eq!(
1880            wb.get_cell_value("Sheet1", "A1").unwrap(),
1881            CellValue::String("hello".to_string())
1882        );
1883
1884        // Save and re-open.
1885        let saved = wb.save_to_buffer().unwrap();
1886        let cursor = std::io::Cursor::new(&saved);
1887        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1888
1889        // Verify custom entries are present in the output.
1890        let mut custom_xml = String::new();
1891        std::io::Read::read_to_string(
1892            &mut archive.by_name("customXml/item1.xml").unwrap(),
1893            &mut custom_xml,
1894        )
1895        .unwrap();
1896        assert_eq!(custom_xml, "<custom>data1</custom>");
1897
1898        let mut props_xml = String::new();
1899        std::io::Read::read_to_string(
1900            &mut archive.by_name("customXml/itemProps1.xml").unwrap(),
1901            &mut props_xml,
1902        )
1903        .unwrap();
1904        assert!(props_xml.contains("datastoreItem"));
1905
1906        let mut printer = Vec::new();
1907        std::io::Read::read_to_end(
1908            &mut archive
1909                .by_name("xl/printerSettings/printerSettings1.bin")
1910                .unwrap(),
1911            &mut printer,
1912        )
1913        .unwrap();
1914        assert_eq!(printer, b"\x00\x01\x02\x03PRINTER");
1915    }
1916
1917    #[test]
1918    fn test_unknown_entries_survive_multiple_roundtrips() {
1919        let buf = create_xlsx_with_custom_entries();
1920        let wb1 = Workbook::open_from_buffer(&buf).unwrap();
1921        let buf2 = wb1.save_to_buffer().unwrap();
1922        let wb2 = Workbook::open_from_buffer(&buf2).unwrap();
1923        let buf3 = wb2.save_to_buffer().unwrap();
1924
1925        let cursor = std::io::Cursor::new(&buf3);
1926        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1927
1928        let mut custom_xml = String::new();
1929        std::io::Read::read_to_string(
1930            &mut archive.by_name("customXml/item1.xml").unwrap(),
1931            &mut custom_xml,
1932        )
1933        .unwrap();
1934        assert_eq!(custom_xml, "<custom>data1</custom>");
1935
1936        let mut printer = Vec::new();
1937        std::io::Read::read_to_end(
1938            &mut archive
1939                .by_name("xl/printerSettings/printerSettings1.bin")
1940                .unwrap(),
1941            &mut printer,
1942        )
1943        .unwrap();
1944        assert_eq!(printer, b"\x00\x01\x02\x03PRINTER");
1945    }
1946
1947    #[test]
1948    fn test_new_workbook_has_no_unknown_parts() {
1949        let wb = Workbook::new();
1950        let buf = wb.save_to_buffer().unwrap();
1951        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
1952        assert!(wb2.unknown_parts.is_empty());
1953    }
1954
1955    #[test]
1956    fn test_known_entries_not_duplicated_as_unknown() {
1957        let wb = Workbook::new();
1958        let buf = wb.save_to_buffer().unwrap();
1959        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
1960
1961        // None of the standard entries should appear in unknown_parts.
1962        let unknown_paths: Vec<&str> = wb2.unknown_parts.iter().map(|(p, _)| p.as_str()).collect();
1963        assert!(
1964            !unknown_paths.contains(&"[Content_Types].xml"),
1965            "Content_Types should not be in unknown_parts"
1966        );
1967        assert!(
1968            !unknown_paths.contains(&"xl/workbook.xml"),
1969            "workbook.xml should not be in unknown_parts"
1970        );
1971        assert!(
1972            !unknown_paths.contains(&"xl/styles.xml"),
1973            "styles.xml should not be in unknown_parts"
1974        );
1975    }
1976
1977    #[test]
1978    fn test_modifications_preserved_alongside_unknown_parts() {
1979        let buf = create_xlsx_with_custom_entries();
1980        let mut wb = Workbook::open_from_buffer(&buf).unwrap();
1981
1982        // Modify data in the workbook.
1983        wb.set_cell_value("Sheet1", "B1", CellValue::Number(42.0))
1984            .unwrap();
1985
1986        let saved = wb.save_to_buffer().unwrap();
1987        let wb2 = Workbook::open_from_buffer(&saved).unwrap();
1988
1989        // Original data preserved.
1990        assert_eq!(
1991            wb2.get_cell_value("Sheet1", "A1").unwrap(),
1992            CellValue::String("hello".to_string())
1993        );
1994        // New data present.
1995        assert_eq!(
1996            wb2.get_cell_value("Sheet1", "B1").unwrap(),
1997            CellValue::Number(42.0)
1998        );
1999        // Unknown parts still present.
2000        let cursor = std::io::Cursor::new(&saved);
2001        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2002        assert!(archive.by_name("customXml/item1.xml").is_ok());
2003    }
2004
2005    #[test]
2006    fn test_threaded_comment_person_rel_in_workbook_rels() {
2007        let mut wb = Workbook::new();
2008        wb.add_threaded_comment(
2009            "Sheet1",
2010            "A1",
2011            &crate::threaded_comment::ThreadedCommentInput {
2012                author: "Alice".to_string(),
2013                text: "Test comment".to_string(),
2014                parent_id: None,
2015            },
2016        )
2017        .unwrap();
2018
2019        let buf = wb.save_to_buffer().unwrap();
2020        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2021
2022        // Verify workbook_rels contains a REL_TYPE_PERSON relationship.
2023        let has_person_rel = wb2.workbook_rels.relationships.iter().any(|r| {
2024            r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_PERSON
2025                && r.target == "persons/person.xml"
2026        });
2027        assert!(
2028            has_person_rel,
2029            "workbook_rels must contain a person relationship for threaded comments"
2030        );
2031    }
2032
2033    #[test]
2034    fn test_no_person_rel_without_threaded_comments() {
2035        let wb = Workbook::new();
2036        let buf = wb.save_to_buffer().unwrap();
2037        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2038
2039        let has_person_rel = wb2
2040            .workbook_rels
2041            .relationships
2042            .iter()
2043            .any(|r| r.rel_type == sheetkit_xml::threaded_comment::REL_TYPE_PERSON);
2044        assert!(
2045            !has_person_rel,
2046            "workbook_rels must not contain a person relationship when there are no threaded comments"
2047        );
2048    }
2049
2050    #[cfg(feature = "encryption")]
2051    #[test]
2052    fn test_open_encrypted_file_without_password_returns_file_encrypted() {
2053        let dir = TempDir::new().unwrap();
2054        let path = dir.path().join("encrypted2.xlsx");
2055
2056        let wb = Workbook::new();
2057        wb.save_with_password(&path, "secret").unwrap();
2058
2059        let result = Workbook::open(&path);
2060        assert!(matches!(result, Err(Error::FileEncrypted)))
2061    }
2062
2063    #[test]
2064    fn test_workbook_format_from_content_type() {
2065        use sheetkit_xml::content_types::mime_types;
2066        assert_eq!(
2067            WorkbookFormat::from_content_type(mime_types::WORKBOOK),
2068            Some(WorkbookFormat::Xlsx)
2069        );
2070        assert_eq!(
2071            WorkbookFormat::from_content_type(mime_types::WORKBOOK_MACRO),
2072            Some(WorkbookFormat::Xlsm)
2073        );
2074        assert_eq!(
2075            WorkbookFormat::from_content_type(mime_types::WORKBOOK_TEMPLATE),
2076            Some(WorkbookFormat::Xltx)
2077        );
2078        assert_eq!(
2079            WorkbookFormat::from_content_type(mime_types::WORKBOOK_TEMPLATE_MACRO),
2080            Some(WorkbookFormat::Xltm)
2081        );
2082        assert_eq!(
2083            WorkbookFormat::from_content_type(mime_types::WORKBOOK_ADDIN_MACRO),
2084            Some(WorkbookFormat::Xlam)
2085        );
2086        assert_eq!(
2087            WorkbookFormat::from_content_type("application/unknown"),
2088            None
2089        );
2090    }
2091
2092    #[test]
2093    fn test_workbook_format_content_type_roundtrip() {
2094        for fmt in [
2095            WorkbookFormat::Xlsx,
2096            WorkbookFormat::Xlsm,
2097            WorkbookFormat::Xltx,
2098            WorkbookFormat::Xltm,
2099            WorkbookFormat::Xlam,
2100        ] {
2101            let ct = fmt.content_type();
2102            assert_eq!(WorkbookFormat::from_content_type(ct), Some(fmt));
2103        }
2104    }
2105
2106    #[test]
2107    fn test_new_workbook_defaults_to_xlsx_format() {
2108        let wb = Workbook::new();
2109        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
2110    }
2111
2112    #[test]
2113    fn test_xlsx_roundtrip_preserves_format() {
2114        let dir = TempDir::new().unwrap();
2115        let path = dir.path().join("roundtrip_format.xlsx");
2116
2117        let wb = Workbook::new();
2118        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
2119        wb.save(&path).unwrap();
2120
2121        let wb2 = Workbook::open(&path).unwrap();
2122        assert_eq!(wb2.format(), WorkbookFormat::Xlsx);
2123    }
2124
2125    #[test]
2126    fn test_save_writes_correct_content_type_for_each_extension() {
2127        let dir = TempDir::new().unwrap();
2128
2129        let cases = [
2130            (WorkbookFormat::Xlsx, "test.xlsx"),
2131            (WorkbookFormat::Xlsm, "test.xlsm"),
2132            (WorkbookFormat::Xltx, "test.xltx"),
2133            (WorkbookFormat::Xltm, "test.xltm"),
2134            (WorkbookFormat::Xlam, "test.xlam"),
2135        ];
2136
2137        for (expected_fmt, filename) in cases {
2138            let path = dir.path().join(filename);
2139            let wb = Workbook::new();
2140            wb.save(&path).unwrap();
2141
2142            let file = std::fs::File::open(&path).unwrap();
2143            let mut archive = zip::ZipArchive::new(file).unwrap();
2144
2145            let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2146            let wb_override = ct
2147                .overrides
2148                .iter()
2149                .find(|o| o.part_name == "/xl/workbook.xml")
2150                .expect("workbook override must exist");
2151            assert_eq!(
2152                wb_override.content_type,
2153                expected_fmt.content_type(),
2154                "content type mismatch for {}",
2155                filename
2156            );
2157        }
2158    }
2159
2160    #[test]
2161    fn test_set_format_changes_workbook_format() {
2162        let mut wb = Workbook::new();
2163        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
2164
2165        wb.set_format(WorkbookFormat::Xlsm);
2166        assert_eq!(wb.format(), WorkbookFormat::Xlsm);
2167    }
2168
2169    #[test]
2170    fn test_save_buffer_roundtrip_with_xlsm_format() {
2171        let mut wb = Workbook::new();
2172        wb.set_format(WorkbookFormat::Xlsm);
2173        wb.set_cell_value("Sheet1", "A1", CellValue::String("test".to_string()))
2174            .unwrap();
2175
2176        let buf = wb.save_to_buffer().unwrap();
2177        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2178        assert_eq!(wb2.format(), WorkbookFormat::Xlsm);
2179        assert_eq!(
2180            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2181            CellValue::String("test".to_string())
2182        );
2183    }
2184
2185    #[test]
2186    fn test_open_with_default_options_is_equivalent_to_open() {
2187        let dir = TempDir::new().unwrap();
2188        let path = dir.path().join("default_opts.xlsx");
2189        let mut wb = Workbook::new();
2190        wb.set_cell_value("Sheet1", "A1", CellValue::String("test".to_string()))
2191            .unwrap();
2192        wb.save(&path).unwrap();
2193
2194        let wb2 = Workbook::open_with_options(&path, &OpenOptions::default()).unwrap();
2195        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
2196        assert_eq!(
2197            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2198            CellValue::String("test".to_string())
2199        );
2200    }
2201
2202    #[test]
2203    fn test_format_inference_from_content_types_overrides() {
2204        use sheetkit_xml::content_types::mime_types;
2205
2206        // Simulate a content_types with xlsm workbook type.
2207        let ct = ContentTypes {
2208            xmlns: "http://schemas.openxmlformats.org/package/2006/content-types".to_string(),
2209            defaults: vec![],
2210            overrides: vec![ContentTypeOverride {
2211                part_name: "/xl/workbook.xml".to_string(),
2212                content_type: mime_types::WORKBOOK_MACRO.to_string(),
2213            }],
2214        };
2215
2216        let detected = ct
2217            .overrides
2218            .iter()
2219            .find(|o| o.part_name == "/xl/workbook.xml")
2220            .and_then(|o| WorkbookFormat::from_content_type(&o.content_type))
2221            .unwrap_or_default();
2222        assert_eq!(detected, WorkbookFormat::Xlsm);
2223    }
2224
2225    #[test]
2226    fn test_workbook_format_default_is_xlsx() {
2227        assert_eq!(WorkbookFormat::default(), WorkbookFormat::Xlsx);
2228    }
2229
2230    fn build_xlsm_with_vba(vba_bytes: &[u8]) -> Vec<u8> {
2231        use std::io::Write;
2232        let mut buf = Vec::new();
2233        {
2234            let cursor = std::io::Cursor::new(&mut buf);
2235            let mut zip = zip::ZipWriter::new(cursor);
2236            let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
2237
2238            let ct_xml = format!(
2239                r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2240<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
2241  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
2242  <Default Extension="xml" ContentType="application/xml"/>
2243  <Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>
2244  <Override PartName="/xl/workbook.xml" ContentType="{wb_ct}"/>
2245  <Override PartName="/xl/worksheets/sheet1.xml" ContentType="{ws_ct}"/>
2246  <Override PartName="/xl/styles.xml" ContentType="{st_ct}"/>
2247  <Override PartName="/xl/sharedStrings.xml" ContentType="{sst_ct}"/>
2248  <Override PartName="/xl/vbaProject.bin" ContentType="application/vnd.ms-office.vbaProject"/>
2249</Types>"#,
2250                wb_ct = mime_types::WORKBOOK_MACRO,
2251                ws_ct = mime_types::WORKSHEET,
2252                st_ct = mime_types::STYLES,
2253                sst_ct = mime_types::SHARED_STRINGS,
2254            );
2255            zip.start_file("[Content_Types].xml", opts).unwrap();
2256            zip.write_all(ct_xml.as_bytes()).unwrap();
2257
2258            let pkg_rels = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2259<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2260  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
2261</Relationships>"#;
2262            zip.start_file("_rels/.rels", opts).unwrap();
2263            zip.write_all(pkg_rels.as_bytes()).unwrap();
2264
2265            let wb_rels = format!(
2266                r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2267<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2268  <Relationship Id="rId1" Type="{ws_rel}" Target="worksheets/sheet1.xml"/>
2269  <Relationship Id="rId2" Type="{st_rel}" Target="styles.xml"/>
2270  <Relationship Id="rId3" Type="{sst_rel}" Target="sharedStrings.xml"/>
2271  <Relationship Id="rId4" Type="{vba_rel}" Target="vbaProject.bin"/>
2272</Relationships>"#,
2273                ws_rel = rel_types::WORKSHEET,
2274                st_rel = rel_types::STYLES,
2275                sst_rel = rel_types::SHARED_STRINGS,
2276                vba_rel = VBA_PROJECT_REL_TYPE,
2277            );
2278            zip.start_file("xl/_rels/workbook.xml.rels", opts).unwrap();
2279            zip.write_all(wb_rels.as_bytes()).unwrap();
2280
2281            let wb_xml = concat!(
2282                r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
2283                r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main""#,
2284                r#" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
2285                r#"<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>"#,
2286                r#"</workbook>"#,
2287            );
2288            zip.start_file("xl/workbook.xml", opts).unwrap();
2289            zip.write_all(wb_xml.as_bytes()).unwrap();
2290
2291            let ws_xml = concat!(
2292                r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
2293                r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main""#,
2294                r#" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
2295                r#"<sheetData/>"#,
2296                r#"</worksheet>"#,
2297            );
2298            zip.start_file("xl/worksheets/sheet1.xml", opts).unwrap();
2299            zip.write_all(ws_xml.as_bytes()).unwrap();
2300
2301            let styles_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2302<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
2303  <fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
2304  <fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
2305  <borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
2306  <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
2307  <cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
2308</styleSheet>"#;
2309            zip.start_file("xl/styles.xml", opts).unwrap();
2310            zip.write_all(styles_xml.as_bytes()).unwrap();
2311
2312            let sst_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2313<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="0" uniqueCount="0"/>"#;
2314            zip.start_file("xl/sharedStrings.xml", opts).unwrap();
2315            zip.write_all(sst_xml.as_bytes()).unwrap();
2316
2317            zip.start_file("xl/vbaProject.bin", opts).unwrap();
2318            zip.write_all(vba_bytes).unwrap();
2319
2320            zip.finish().unwrap();
2321        }
2322        buf
2323    }
2324
2325    #[test]
2326    fn test_vba_blob_loaded_when_present() {
2327        let vba_data = b"FAKE_VBA_PROJECT_BINARY_DATA_1234567890";
2328        let xlsm = build_xlsm_with_vba(vba_data);
2329        let wb = Workbook::open_from_buffer(&xlsm).unwrap();
2330        assert!(wb.vba_blob.is_some());
2331        assert_eq!(wb.vba_blob.as_deref().unwrap(), vba_data);
2332    }
2333
2334    #[test]
2335    fn test_vba_blob_none_for_plain_xlsx() {
2336        let wb = Workbook::new();
2337        assert!(wb.vba_blob.is_none());
2338
2339        let buf = wb.save_to_buffer().unwrap();
2340        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2341        assert!(wb2.vba_blob.is_none());
2342    }
2343
2344    #[test]
2345    fn test_vba_blob_survives_roundtrip_with_identical_bytes() {
2346        let vba_data: Vec<u8> = (0..=255).cycle().take(1024).collect();
2347        let xlsm = build_xlsm_with_vba(&vba_data);
2348
2349        let wb = Workbook::open_from_buffer(&xlsm).unwrap();
2350        assert_eq!(wb.vba_blob.as_deref().unwrap(), &vba_data[..]);
2351
2352        let saved = wb.save_to_buffer().unwrap();
2353        let cursor = std::io::Cursor::new(&saved);
2354        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2355
2356        let mut roundtripped = Vec::new();
2357        std::io::Read::read_to_end(
2358            &mut archive.by_name("xl/vbaProject.bin").unwrap(),
2359            &mut roundtripped,
2360        )
2361        .unwrap();
2362        assert_eq!(roundtripped, vba_data);
2363    }
2364
2365    #[test]
2366    fn test_vba_relationship_preserved_on_roundtrip() {
2367        let vba_data = b"VBA_BLOB";
2368        let xlsm = build_xlsm_with_vba(vba_data);
2369
2370        let wb = Workbook::open_from_buffer(&xlsm).unwrap();
2371        let saved = wb.save_to_buffer().unwrap();
2372
2373        let cursor = std::io::Cursor::new(&saved);
2374        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2375
2376        let rels: Relationships =
2377            read_xml_part(&mut archive, "xl/_rels/workbook.xml.rels").unwrap();
2378        let vba_rel = rels
2379            .relationships
2380            .iter()
2381            .find(|r| r.rel_type == VBA_PROJECT_REL_TYPE);
2382        assert!(vba_rel.is_some(), "VBA relationship must be preserved");
2383        assert_eq!(vba_rel.unwrap().target, "vbaProject.bin");
2384    }
2385
2386    #[test]
2387    fn test_vba_content_type_preserved_on_roundtrip() {
2388        let vba_data = b"VBA_BLOB";
2389        let xlsm = build_xlsm_with_vba(vba_data);
2390
2391        let wb = Workbook::open_from_buffer(&xlsm).unwrap();
2392        let saved = wb.save_to_buffer().unwrap();
2393
2394        let cursor = std::io::Cursor::new(&saved);
2395        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2396
2397        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2398        let vba_override = ct
2399            .overrides
2400            .iter()
2401            .find(|o| o.part_name == "/xl/vbaProject.bin");
2402        assert!(
2403            vba_override.is_some(),
2404            "VBA content type override must be preserved"
2405        );
2406        assert_eq!(vba_override.unwrap().content_type, VBA_PROJECT_CONTENT_TYPE);
2407    }
2408
2409    #[test]
2410    fn test_non_vba_save_has_no_vba_entries() {
2411        let wb = Workbook::new();
2412        let buf = wb.save_to_buffer().unwrap();
2413
2414        let cursor = std::io::Cursor::new(&buf);
2415        let mut archive = zip::ZipArchive::new(cursor).unwrap();
2416
2417        assert!(
2418            archive.by_name("xl/vbaProject.bin").is_err(),
2419            "plain xlsx must not contain vbaProject.bin"
2420        );
2421
2422        let rels: Relationships =
2423            read_xml_part(&mut archive, "xl/_rels/workbook.xml.rels").unwrap();
2424        assert!(
2425            !rels
2426                .relationships
2427                .iter()
2428                .any(|r| r.rel_type == VBA_PROJECT_REL_TYPE),
2429            "plain xlsx must not have VBA relationship"
2430        );
2431
2432        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2433        assert!(
2434            !ct.overrides
2435                .iter()
2436                .any(|o| o.content_type == VBA_PROJECT_CONTENT_TYPE),
2437            "plain xlsx must not have VBA content type override"
2438        );
2439    }
2440
2441    #[test]
2442    fn test_xlsm_format_detected_with_vba() {
2443        let vba_data = b"VBA_BLOB";
2444        let xlsm = build_xlsm_with_vba(vba_data);
2445        let wb = Workbook::open_from_buffer(&xlsm).unwrap();
2446        assert_eq!(wb.format(), WorkbookFormat::Xlsm);
2447    }
2448
2449    #[test]
2450    fn test_from_extension_recognized() {
2451        assert_eq!(
2452            WorkbookFormat::from_extension("xlsx"),
2453            Some(WorkbookFormat::Xlsx)
2454        );
2455        assert_eq!(
2456            WorkbookFormat::from_extension("xlsm"),
2457            Some(WorkbookFormat::Xlsm)
2458        );
2459        assert_eq!(
2460            WorkbookFormat::from_extension("xltx"),
2461            Some(WorkbookFormat::Xltx)
2462        );
2463        assert_eq!(
2464            WorkbookFormat::from_extension("xltm"),
2465            Some(WorkbookFormat::Xltm)
2466        );
2467        assert_eq!(
2468            WorkbookFormat::from_extension("xlam"),
2469            Some(WorkbookFormat::Xlam)
2470        );
2471    }
2472
2473    #[test]
2474    fn test_from_extension_case_insensitive() {
2475        assert_eq!(
2476            WorkbookFormat::from_extension("XLSX"),
2477            Some(WorkbookFormat::Xlsx)
2478        );
2479        assert_eq!(
2480            WorkbookFormat::from_extension("Xlsm"),
2481            Some(WorkbookFormat::Xlsm)
2482        );
2483        assert_eq!(
2484            WorkbookFormat::from_extension("XLTX"),
2485            Some(WorkbookFormat::Xltx)
2486        );
2487    }
2488
2489    #[test]
2490    fn test_from_extension_unrecognized() {
2491        assert_eq!(WorkbookFormat::from_extension("csv"), None);
2492        assert_eq!(WorkbookFormat::from_extension("xls"), None);
2493        assert_eq!(WorkbookFormat::from_extension("txt"), None);
2494        assert_eq!(WorkbookFormat::from_extension("pdf"), None);
2495        assert_eq!(WorkbookFormat::from_extension(""), None);
2496    }
2497
2498    #[test]
2499    fn test_save_unsupported_extension_csv() {
2500        let dir = TempDir::new().unwrap();
2501        let path = dir.path().join("output.csv");
2502        let wb = Workbook::new();
2503        let result = wb.save(&path);
2504        assert!(matches!(result, Err(Error::UnsupportedFileExtension(ext)) if ext == "csv"));
2505    }
2506
2507    #[test]
2508    fn test_save_unsupported_extension_xls() {
2509        let dir = TempDir::new().unwrap();
2510        let path = dir.path().join("output.xls");
2511        let wb = Workbook::new();
2512        let result = wb.save(&path);
2513        assert!(matches!(result, Err(Error::UnsupportedFileExtension(ext)) if ext == "xls"));
2514    }
2515
2516    #[test]
2517    fn test_save_unsupported_extension_unknown() {
2518        let dir = TempDir::new().unwrap();
2519        let path = dir.path().join("output.foo");
2520        let wb = Workbook::new();
2521        let result = wb.save(&path);
2522        assert!(matches!(result, Err(Error::UnsupportedFileExtension(ext)) if ext == "foo"));
2523    }
2524
2525    #[test]
2526    fn test_save_no_extension_fails() {
2527        let dir = TempDir::new().unwrap();
2528        let path = dir.path().join("noext");
2529        let wb = Workbook::new();
2530        let result = wb.save(&path);
2531        assert!(matches!(
2532            result,
2533            Err(Error::UnsupportedFileExtension(ext)) if ext.is_empty()
2534        ));
2535    }
2536
2537    #[test]
2538    fn test_save_as_xlsm_writes_xlsm_content_type() {
2539        let dir = TempDir::new().unwrap();
2540        let path = dir.path().join("output.xlsm");
2541        let wb = Workbook::new();
2542        wb.save(&path).unwrap();
2543
2544        let file = std::fs::File::open(&path).unwrap();
2545        let mut archive = zip::ZipArchive::new(file).unwrap();
2546        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2547        let wb_ct = ct
2548            .overrides
2549            .iter()
2550            .find(|o| o.part_name == "/xl/workbook.xml")
2551            .expect("workbook override must exist");
2552        assert_eq!(wb_ct.content_type, WorkbookFormat::Xlsm.content_type());
2553    }
2554
2555    #[test]
2556    fn test_save_as_xltx_writes_template_content_type() {
2557        let dir = TempDir::new().unwrap();
2558        let path = dir.path().join("output.xltx");
2559        let wb = Workbook::new();
2560        wb.save(&path).unwrap();
2561
2562        let file = std::fs::File::open(&path).unwrap();
2563        let mut archive = zip::ZipArchive::new(file).unwrap();
2564        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2565        let wb_ct = ct
2566            .overrides
2567            .iter()
2568            .find(|o| o.part_name == "/xl/workbook.xml")
2569            .expect("workbook override must exist");
2570        assert_eq!(wb_ct.content_type, WorkbookFormat::Xltx.content_type());
2571    }
2572
2573    #[test]
2574    fn test_save_as_xltm_writes_template_macro_content_type() {
2575        let dir = TempDir::new().unwrap();
2576        let path = dir.path().join("output.xltm");
2577        let wb = Workbook::new();
2578        wb.save(&path).unwrap();
2579
2580        let file = std::fs::File::open(&path).unwrap();
2581        let mut archive = zip::ZipArchive::new(file).unwrap();
2582        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2583        let wb_ct = ct
2584            .overrides
2585            .iter()
2586            .find(|o| o.part_name == "/xl/workbook.xml")
2587            .expect("workbook override must exist");
2588        assert_eq!(wb_ct.content_type, WorkbookFormat::Xltm.content_type());
2589    }
2590
2591    #[test]
2592    fn test_save_as_xlam_writes_addin_content_type() {
2593        let dir = TempDir::new().unwrap();
2594        let path = dir.path().join("output.xlam");
2595        let wb = Workbook::new();
2596        wb.save(&path).unwrap();
2597
2598        let file = std::fs::File::open(&path).unwrap();
2599        let mut archive = zip::ZipArchive::new(file).unwrap();
2600        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2601        let wb_ct = ct
2602            .overrides
2603            .iter()
2604            .find(|o| o.part_name == "/xl/workbook.xml")
2605            .expect("workbook override must exist");
2606        assert_eq!(wb_ct.content_type, WorkbookFormat::Xlam.content_type());
2607    }
2608
2609    #[test]
2610    fn test_save_extension_overrides_stored_format() {
2611        let dir = TempDir::new().unwrap();
2612        let path = dir.path().join("output.xlsm");
2613
2614        // Workbook has Xlsx format stored, but saved as .xlsm
2615        let wb = Workbook::new();
2616        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
2617        wb.save(&path).unwrap();
2618
2619        let file = std::fs::File::open(&path).unwrap();
2620        let mut archive = zip::ZipArchive::new(file).unwrap();
2621        let ct: ContentTypes = read_xml_part(&mut archive, "[Content_Types].xml").unwrap();
2622        let wb_ct = ct
2623            .overrides
2624            .iter()
2625            .find(|o| o.part_name == "/xl/workbook.xml")
2626            .expect("workbook override must exist");
2627        assert_eq!(
2628            wb_ct.content_type,
2629            WorkbookFormat::Xlsm.content_type(),
2630            "extension .xlsm must override stored Xlsx format"
2631        );
2632    }
2633
2634    #[test]
2635    fn test_save_to_buffer_preserves_stored_format() {
2636        let mut wb = Workbook::new();
2637        wb.set_format(WorkbookFormat::Xltx);
2638
2639        let buf = wb.save_to_buffer().unwrap();
2640        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2641        assert_eq!(
2642            wb2.format(),
2643            WorkbookFormat::Xltx,
2644            "save_to_buffer must use the stored format, not infer from extension"
2645        );
2646    }
2647
2648    #[test]
2649    fn test_sheet_rows_limits_rows_read() {
2650        let dir = TempDir::new().unwrap();
2651        let path = dir.path().join("sheet_rows.xlsx");
2652
2653        let mut wb = Workbook::new();
2654        for i in 1..=20 {
2655            let cell = format!("A{}", i);
2656            wb.set_cell_value("Sheet1", &cell, CellValue::Number(i as f64))
2657                .unwrap();
2658        }
2659        wb.save(&path).unwrap();
2660
2661        let opts = OpenOptions::new().sheet_rows(5);
2662        let wb2 = Workbook::open_with_options(&path, &opts).unwrap();
2663
2664        // First 5 rows should be present
2665        for i in 1..=5 {
2666            let cell = format!("A{}", i);
2667            assert_eq!(
2668                wb2.get_cell_value("Sheet1", &cell).unwrap(),
2669                CellValue::Number(i as f64)
2670            );
2671        }
2672
2673        // Rows 6+ should return Empty
2674        for i in 6..=20 {
2675            let cell = format!("A{}", i);
2676            assert_eq!(
2677                wb2.get_cell_value("Sheet1", &cell).unwrap(),
2678                CellValue::Empty
2679            );
2680        }
2681    }
2682
2683    #[test]
2684    fn test_sheet_rows_with_buffer() {
2685        let mut wb = Workbook::new();
2686        for i in 1..=10 {
2687            let cell = format!("A{}", i);
2688            wb.set_cell_value("Sheet1", &cell, CellValue::Number(i as f64))
2689                .unwrap();
2690        }
2691        let buf = wb.save_to_buffer().unwrap();
2692
2693        let opts = OpenOptions::new().sheet_rows(3);
2694        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
2695
2696        assert_eq!(
2697            wb2.get_cell_value("Sheet1", "A3").unwrap(),
2698            CellValue::Number(3.0)
2699        );
2700        assert_eq!(
2701            wb2.get_cell_value("Sheet1", "A4").unwrap(),
2702            CellValue::Empty
2703        );
2704    }
2705
2706    #[test]
2707    fn test_save_xlsx_preserves_existing_behavior() {
2708        let dir = TempDir::new().unwrap();
2709        let path = dir.path().join("preserved.xlsx");
2710
2711        let mut wb = Workbook::new();
2712        wb.set_cell_value("Sheet1", "A1", CellValue::String("hello".to_string()))
2713            .unwrap();
2714        wb.save(&path).unwrap();
2715
2716        let wb2 = Workbook::open(&path).unwrap();
2717        assert_eq!(wb2.format(), WorkbookFormat::Xlsx);
2718        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
2719        assert_eq!(
2720            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2721            CellValue::String("hello".to_string())
2722        );
2723    }
2724
2725    #[test]
2726    fn test_selective_sheet_parsing() {
2727        let dir = TempDir::new().unwrap();
2728        let path = dir.path().join("selective.xlsx");
2729
2730        let mut wb = Workbook::new();
2731        wb.new_sheet("Sales").unwrap();
2732        wb.new_sheet("Data").unwrap();
2733        wb.set_cell_value("Sheet1", "A1", CellValue::String("Sheet1 data".to_string()))
2734            .unwrap();
2735        wb.set_cell_value("Sales", "A1", CellValue::String("Sales data".to_string()))
2736            .unwrap();
2737        wb.set_cell_value("Data", "A1", CellValue::String("Data data".to_string()))
2738            .unwrap();
2739        wb.save(&path).unwrap();
2740
2741        let opts = OpenOptions::new().sheets(vec!["Sales".to_string()]);
2742        let wb2 = Workbook::open_with_options(&path, &opts).unwrap();
2743
2744        // All sheets exist in the workbook
2745        assert_eq!(wb2.sheet_names(), vec!["Sheet1", "Sales", "Data"]);
2746
2747        // Only Sales should have data
2748        assert_eq!(
2749            wb2.get_cell_value("Sales", "A1").unwrap(),
2750            CellValue::String("Sales data".to_string())
2751        );
2752
2753        // Sheet1 and Data were not parsed, so they should be empty
2754        assert_eq!(
2755            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2756            CellValue::Empty
2757        );
2758        assert_eq!(wb2.get_cell_value("Data", "A1").unwrap(), CellValue::Empty);
2759    }
2760
2761    #[test]
2762    fn test_selective_sheets_multiple() {
2763        let mut wb = Workbook::new();
2764        wb.new_sheet("Alpha").unwrap();
2765        wb.new_sheet("Beta").unwrap();
2766        wb.set_cell_value("Sheet1", "A1", CellValue::Number(1.0))
2767            .unwrap();
2768        wb.set_cell_value("Alpha", "A1", CellValue::Number(2.0))
2769            .unwrap();
2770        wb.set_cell_value("Beta", "A1", CellValue::Number(3.0))
2771            .unwrap();
2772        let buf = wb.save_to_buffer().unwrap();
2773
2774        let opts = OpenOptions::new().sheets(vec!["Sheet1".to_string(), "Beta".to_string()]);
2775        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
2776
2777        assert_eq!(
2778            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2779            CellValue::Number(1.0)
2780        );
2781        assert_eq!(wb2.get_cell_value("Alpha", "A1").unwrap(), CellValue::Empty);
2782        assert_eq!(
2783            wb2.get_cell_value("Beta", "A1").unwrap(),
2784            CellValue::Number(3.0)
2785        );
2786    }
2787
2788    #[test]
2789    fn test_save_does_not_mutate_stored_format() {
2790        let dir = TempDir::new().unwrap();
2791        let path = dir.path().join("test.xlsm");
2792        let wb = Workbook::new();
2793        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
2794        wb.save(&path).unwrap();
2795        // The save call takes &self, so the stored format is unchanged.
2796        assert_eq!(wb.format(), WorkbookFormat::Xlsx);
2797    }
2798
2799    #[test]
2800    fn test_max_zip_entries_exceeded() {
2801        let wb = Workbook::new();
2802        let buf = wb.save_to_buffer().unwrap();
2803
2804        // A basic workbook has at least 8 ZIP entries -- set limit to 2
2805        let opts = OpenOptions::new().max_zip_entries(2);
2806        let result = Workbook::open_from_buffer_with_options(&buf, &opts);
2807        assert!(matches!(result, Err(Error::ZipEntryCountExceeded { .. })));
2808    }
2809
2810    #[test]
2811    fn test_max_zip_entries_within_limit() {
2812        let wb = Workbook::new();
2813        let buf = wb.save_to_buffer().unwrap();
2814
2815        let opts = OpenOptions::new().max_zip_entries(1000);
2816        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
2817        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
2818    }
2819
2820    #[test]
2821    fn test_max_unzip_size_exceeded() {
2822        let mut wb = Workbook::new();
2823        // Write enough data so the decompressed size is non-trivial
2824        for i in 1..=100 {
2825            let cell = format!("A{}", i);
2826            wb.set_cell_value(
2827                "Sheet1",
2828                &cell,
2829                CellValue::String("long_value_for_size_check".repeat(10)),
2830            )
2831            .unwrap();
2832        }
2833        let buf = wb.save_to_buffer().unwrap();
2834
2835        // Set a very small decompressed size limit
2836        let opts = OpenOptions::new().max_unzip_size(100);
2837        let result = Workbook::open_from_buffer_with_options(&buf, &opts);
2838        assert!(matches!(result, Err(Error::ZipSizeExceeded { .. })));
2839    }
2840
2841    #[test]
2842    fn test_max_unzip_size_within_limit() {
2843        let wb = Workbook::new();
2844        let buf = wb.save_to_buffer().unwrap();
2845
2846        let opts = OpenOptions::new().max_unzip_size(1_000_000_000);
2847        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
2848        assert_eq!(wb2.sheet_names(), vec!["Sheet1"]);
2849    }
2850
2851    #[test]
2852    fn test_combined_options() {
2853        let mut wb = Workbook::new();
2854        wb.new_sheet("Parsed").unwrap();
2855        wb.new_sheet("Skipped").unwrap();
2856        for i in 1..=10 {
2857            let cell = format!("A{}", i);
2858            wb.set_cell_value("Parsed", &cell, CellValue::Number(i as f64))
2859                .unwrap();
2860            wb.set_cell_value("Skipped", &cell, CellValue::Number(i as f64))
2861                .unwrap();
2862        }
2863        let buf = wb.save_to_buffer().unwrap();
2864
2865        let opts = OpenOptions::new()
2866            .sheets(vec!["Parsed".to_string()])
2867            .sheet_rows(3);
2868        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
2869
2870        // Parsed sheet has only 3 rows
2871        assert_eq!(
2872            wb2.get_cell_value("Parsed", "A3").unwrap(),
2873            CellValue::Number(3.0)
2874        );
2875        assert_eq!(
2876            wb2.get_cell_value("Parsed", "A4").unwrap(),
2877            CellValue::Empty
2878        );
2879
2880        // Skipped sheet is empty
2881        assert_eq!(
2882            wb2.get_cell_value("Skipped", "A1").unwrap(),
2883            CellValue::Empty
2884        );
2885    }
2886
2887    #[test]
2888    fn test_sheet_rows_zero_means_no_rows() {
2889        let mut wb = Workbook::new();
2890        wb.set_cell_value("Sheet1", "A1", CellValue::Number(1.0))
2891            .unwrap();
2892        let buf = wb.save_to_buffer().unwrap();
2893
2894        let opts = OpenOptions::new().sheet_rows(0);
2895        let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
2896        assert_eq!(
2897            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2898            CellValue::Empty
2899        );
2900    }
2901
2902    #[test]
2903    fn test_selective_sheet_parsing_preserves_unparsed_sheets_on_save() {
2904        let dir = TempDir::new().unwrap();
2905        let path1 = dir.path().join("original.xlsx");
2906        let path2 = dir.path().join("resaved.xlsx");
2907
2908        // Create a workbook with 3 sheets, each with distinct data.
2909        let mut wb = Workbook::new();
2910        wb.new_sheet("Sales").unwrap();
2911        wb.new_sheet("Data").unwrap();
2912        wb.set_cell_value(
2913            "Sheet1",
2914            "A1",
2915            CellValue::String("Sheet1 value".to_string()),
2916        )
2917        .unwrap();
2918        wb.set_cell_value("Sheet1", "B2", CellValue::Number(100.0))
2919            .unwrap();
2920        wb.set_cell_value("Sales", "A1", CellValue::String("Sales value".to_string()))
2921            .unwrap();
2922        wb.set_cell_value("Sales", "C3", CellValue::Number(200.0))
2923            .unwrap();
2924        wb.set_cell_value("Data", "A1", CellValue::String("Data value".to_string()))
2925            .unwrap();
2926        wb.set_cell_value("Data", "D4", CellValue::Bool(true))
2927            .unwrap();
2928        wb.save(&path1).unwrap();
2929
2930        // Reopen with only Sheet1 parsed.
2931        let opts = OpenOptions::new().sheets(vec!["Sheet1".to_string()]);
2932        let wb2 = Workbook::open_with_options(&path1, &opts).unwrap();
2933
2934        // Verify Sheet1 was parsed.
2935        assert_eq!(
2936            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2937            CellValue::String("Sheet1 value".to_string())
2938        );
2939
2940        // Save to a new file.
2941        wb2.save(&path2).unwrap();
2942
2943        // Reopen the resaved file with all sheets parsed.
2944        let wb3 = Workbook::open(&path2).unwrap();
2945        assert_eq!(wb3.sheet_names(), vec!["Sheet1", "Sales", "Data"]);
2946
2947        // Sheet1 data should be intact.
2948        assert_eq!(
2949            wb3.get_cell_value("Sheet1", "A1").unwrap(),
2950            CellValue::String("Sheet1 value".to_string())
2951        );
2952        assert_eq!(
2953            wb3.get_cell_value("Sheet1", "B2").unwrap(),
2954            CellValue::Number(100.0)
2955        );
2956
2957        // Sales data should be preserved from raw XML.
2958        assert_eq!(
2959            wb3.get_cell_value("Sales", "A1").unwrap(),
2960            CellValue::String("Sales value".to_string())
2961        );
2962        assert_eq!(
2963            wb3.get_cell_value("Sales", "C3").unwrap(),
2964            CellValue::Number(200.0)
2965        );
2966
2967        // Data sheet should be preserved from raw XML.
2968        assert_eq!(
2969            wb3.get_cell_value("Data", "A1").unwrap(),
2970            CellValue::String("Data value".to_string())
2971        );
2972        assert_eq!(
2973            wb3.get_cell_value("Data", "D4").unwrap(),
2974            CellValue::Bool(true)
2975        );
2976    }
2977
2978    #[test]
2979    fn test_open_from_buffer_with_options_backwards_compatible() {
2980        let mut wb = Workbook::new();
2981        wb.set_cell_value("Sheet1", "A1", CellValue::String("Hello".to_string()))
2982            .unwrap();
2983        let buf = wb.save_to_buffer().unwrap();
2984
2985        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2986        assert_eq!(
2987            wb2.get_cell_value("Sheet1", "A1").unwrap(),
2988            CellValue::String("Hello".to_string())
2989        );
2990    }
2991}